Compare commits
1
Commits
api/v0.3.3
...
api/v0.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470b7448ac |
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Task Agent - Full orchestrator for autonomous multi-step task execution
|
||||||
|
- Has ALL tools: read, write, edit, bash (full), web_search
|
||||||
|
- New `spawn_agent` tool to launch sub-agents (Explore, Plan) for focused work
|
||||||
|
- Recursion prevention: cannot spawn nested Task agents
|
||||||
|
- 22 unit tests for registration, tools, spawn_agent, and API
|
||||||
|
- Complete agent hierarchy: Explore (read-only) → Plan (read-only) → Task (orchestrator)
|
||||||
|
|
||||||
## [0.3.3] - 2026-01-11
|
## [0.3.3] - 2026-01-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "webber-api"
|
name = "webber-api"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
description = "Webber API - Multi-Agent AI Development Server"
|
description = "Webber API - Multi-Agent AI Development Server"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jpmschweitzer"}
|
{name = "jpmschweitzer"}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from src.domains.agents.base import get_agent, list_agents
|
|||||||
# Import agents to ensure they're registered
|
# Import agents to ensure they're registered
|
||||||
import src.domains.agents.explore # noqa: F401
|
import src.domains.agents.explore # noqa: F401
|
||||||
import src.domains.agents.plan # noqa: F401
|
import src.domains.agents.plan # noqa: F401
|
||||||
|
import src.domains.agents.task # noqa: F401
|
||||||
from src.domains.agents.schemas import (
|
from src.domains.agents.schemas import (
|
||||||
AgentRunRequest,
|
AgentRunRequest,
|
||||||
AgentRunResponse,
|
AgentRunResponse,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Task Agent - Full orchestrator for autonomous task execution.
|
||||||
|
|
||||||
|
The Task agent can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write + bash)
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
- Return consolidated task summaries
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.domains.agents.task import task_agent, task
|
||||||
|
|
||||||
|
# Direct agent access
|
||||||
|
result = await task_agent.run("Create a new user model with tests")
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
result = await task("Create a new user model with tests")
|
||||||
|
"""
|
||||||
|
from src.domains.agents.task.agent import (
|
||||||
|
TaskAgentImpl,
|
||||||
|
TaskContext,
|
||||||
|
task_agent,
|
||||||
|
task,
|
||||||
|
task_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TaskAgentImpl",
|
||||||
|
"TaskContext",
|
||||||
|
"task_agent",
|
||||||
|
"task",
|
||||||
|
"task_stream",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Task Agent implementation using PydanticAI.
|
||||||
|
|
||||||
|
Full orchestrator agent that can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write)
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.models.openai import OpenAIModel
|
||||||
|
|
||||||
|
from src.domains.agents.base import BaseAgent, AgentContext, register_agent
|
||||||
|
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import logged, get_logger, trace_span
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskContext(AgentContext):
|
||||||
|
"""
|
||||||
|
Context for task agent tools.
|
||||||
|
|
||||||
|
Passed to all tool functions via RunContext.
|
||||||
|
Uses the same fields as base AgentContext.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAgentImpl(BaseAgent):
|
||||||
|
"""
|
||||||
|
Full orchestrator agent for autonomous task execution.
|
||||||
|
|
||||||
|
Has access to ALL tools:
|
||||||
|
- Read-only: read_file, glob_files, grep_content, bash_readonly
|
||||||
|
- Write: edit_file, write_file, bash
|
||||||
|
- External: web_search
|
||||||
|
- Orchestration: spawn_agent (launch sub-agents)
|
||||||
|
|
||||||
|
Can spawn Explore and Plan agents to offload focused tasks,
|
||||||
|
keeping context efficient across complex multi-step work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "task"
|
||||||
|
description = "Autonomous multi-step task execution with sub-agent orchestration"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the task agent."""
|
||||||
|
self._agent: Agent[TaskContext, str] | None = None
|
||||||
|
self._settings = get_settings()
|
||||||
|
|
||||||
|
def _create_agent(self) -> Agent[TaskContext, str]:
|
||||||
|
"""Create the PydanticAI agent with Ollama backend."""
|
||||||
|
# Use sanitized Ollama provider to fix content: null issues
|
||||||
|
model = OpenAIModel(
|
||||||
|
model_name=self._settings.ollama_agent_model,
|
||||||
|
provider=get_ollama_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
agent: Agent[TaskContext, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=TASK_SYSTEM_PROMPT,
|
||||||
|
deps_type=TaskContext,
|
||||||
|
output_type=str,
|
||||||
|
# Mistral Nemo settings:
|
||||||
|
# - temperature 0.3 (Nemo needs slightly higher than 0.0)
|
||||||
|
# - tool_choice "required" forces tool use
|
||||||
|
model_settings={
|
||||||
|
"temperature": 0.3,
|
||||||
|
"extra_body": {"tool_choice": "required"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register all tools including orchestration
|
||||||
|
self._register_tools(agent)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def _register_tools(self, agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register all tools with the agent."""
|
||||||
|
from src.domains.agents.task.tools import register_task_tools
|
||||||
|
register_task_tools(agent)
|
||||||
|
|
||||||
|
@logged()
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run the task agent to execute a multi-step task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Description of the task to execute
|
||||||
|
working_dir: Working directory for the agent
|
||||||
|
allowed_paths: Restrict tool access to these paths
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Consolidated task summary with results
|
||||||
|
"""
|
||||||
|
ctx = TaskContext(
|
||||||
|
working_dir=working_dir or os.getcwd(),
|
||||||
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with trace_span("task_agent_run"):
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||||
|
result = await self.agent.run(prompt, deps=ctx)
|
||||||
|
return result.output
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Task agent error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def run_stream(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""
|
||||||
|
Run the task agent with streaming output.
|
||||||
|
|
||||||
|
Yields text chunks as they become available.
|
||||||
|
"""
|
||||||
|
ctx = TaskContext(
|
||||||
|
working_dir=working_dir or os.getcwd(),
|
||||||
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with trace_span("task_agent_stream"):
|
||||||
|
try:
|
||||||
|
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||||
|
async for chunk in result.stream_text():
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Task agent stream error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Create and register the singleton instance
|
||||||
|
task_agent = TaskAgentImpl()
|
||||||
|
register_agent(task_agent)
|
||||||
|
|
||||||
|
|
||||||
|
async def task(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""Run task execution."""
|
||||||
|
return await task_agent.run(prompt, working_dir=working_dir, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def task_stream(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Run task execution with streaming."""
|
||||||
|
async for chunk in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||||
|
yield chunk
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
System prompts for the Task agent.
|
||||||
|
|
||||||
|
The Task agent is a full orchestrator that can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write)
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
"""
|
||||||
|
|
||||||
|
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
||||||
|
|
||||||
|
You have access to ALL tools including file editing, writing, and bash execution.
|
||||||
|
You can also spawn sub-agents to help with complex tasks.
|
||||||
|
|
||||||
|
AVAILABLE TOOLS:
|
||||||
|
|
||||||
|
File Operations:
|
||||||
|
- read_file: Read file contents with line numbers
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents with regex
|
||||||
|
- edit_file: Make targeted edits via find-and-replace
|
||||||
|
- write_file: Create or overwrite files
|
||||||
|
|
||||||
|
Shell:
|
||||||
|
- bash_readonly: Read-only commands (ls, git status, git log, etc.)
|
||||||
|
- bash: Full bash execution (git commit, pytest, mkdir, etc.)
|
||||||
|
|
||||||
|
External:
|
||||||
|
- web_search: Search the web for current information
|
||||||
|
|
||||||
|
Orchestration:
|
||||||
|
- spawn_agent: Launch sub-agents for focused tasks
|
||||||
|
|
||||||
|
WORKFLOW:
|
||||||
|
1. Understand the task requirements
|
||||||
|
2. Break down into sub-tasks if complex
|
||||||
|
3. Use spawn_agent for research (explore) or planning (plan)
|
||||||
|
4. Execute implementation steps using write tools
|
||||||
|
5. Validate changes (run tests if applicable)
|
||||||
|
6. Return consolidated summary
|
||||||
|
|
||||||
|
TOOL CALL EXAMPLES:
|
||||||
|
|
||||||
|
To spawn an Explore agent for research:
|
||||||
|
Call spawn_agent with agent_type="explore" and prompt="find all config files"
|
||||||
|
|
||||||
|
To spawn a Plan agent for design:
|
||||||
|
Call spawn_agent with agent_type="plan" and prompt="design user auth feature"
|
||||||
|
|
||||||
|
To edit a file:
|
||||||
|
Call edit_file with file_path="/path/to/file.py" and old_string="old" and new_string="new"
|
||||||
|
|
||||||
|
To run tests:
|
||||||
|
Call bash with command="pytest tests/ -v"
|
||||||
|
|
||||||
|
SPAWN_AGENT USAGE:
|
||||||
|
- Use spawn_agent to offload focused tasks to specialized agents
|
||||||
|
- Explore agent: Fast codebase searches and analysis
|
||||||
|
- Plan agent: Design implementation strategies
|
||||||
|
- Keep each agent's context focused and efficient
|
||||||
|
|
||||||
|
GIT DISCIPLINE:
|
||||||
|
- Create feature branches for changes
|
||||||
|
- Use conventional commit format (feat:, fix:, docs:, etc.)
|
||||||
|
- Never commit directly to main
|
||||||
|
- Run tests before committing
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS use tools first, then analyze results
|
||||||
|
- Never guess file contents - read them first
|
||||||
|
- Prefer edit_file over write_file for existing files
|
||||||
|
- Use spawn_agent to keep context focused
|
||||||
|
- Validate changes by running tests when applicable
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
End your response with a summary:
|
||||||
|
|
||||||
|
### Task Summary
|
||||||
|
- **Accomplished:** What was done
|
||||||
|
- **Files modified:** List of changed files
|
||||||
|
- **Commands run:** Key commands executed
|
||||||
|
- **Issues:** Any problems encountered
|
||||||
|
"""
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""
|
||||||
|
Tool registrations for the Task agent.
|
||||||
|
|
||||||
|
The Task agent has access to ALL tools:
|
||||||
|
- Read-only tools (same as Explore/Plan)
|
||||||
|
- Write tools (edit, write, bash full)
|
||||||
|
- External tools (web search)
|
||||||
|
- Orchestration (spawn sub-agents)
|
||||||
|
"""
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.file.edit import EditFileTool
|
||||||
|
from src.domains.tools.file.write import WriteFileTool
|
||||||
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
|
from src.domains.tools.search.web import WebSearchTool
|
||||||
|
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||||
|
from src.domains.tools.shell.bash_full import BashTool
|
||||||
|
|
||||||
|
|
||||||
|
def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register all tools with the Task agent.
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
|
||||||
|
- Write tools: edit_file, write_file, bash
|
||||||
|
- External: web_search
|
||||||
|
- Orchestration: spawn_agent
|
||||||
|
"""
|
||||||
|
|
||||||
|
# === Read-only tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def read_file(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
file_path: str,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 2000
|
||||||
|
) -> str:
|
||||||
|
"""Read contents of a file with line numbers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to read
|
||||||
|
offset: Line number to start from (0-based, default: 0)
|
||||||
|
limit: Maximum number of lines to read (default: 2000)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File contents with line numbers, or error message.
|
||||||
|
|
||||||
|
IMPORTANT: Always use absolute paths. Read files before editing them.
|
||||||
|
"""
|
||||||
|
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def glob_files(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
pattern: str,
|
||||||
|
path: str | None = None,
|
||||||
|
limit: int = 100
|
||||||
|
) -> str:
|
||||||
|
"""Find files matching a glob pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md")
|
||||||
|
path: Directory to search in (default: working directory)
|
||||||
|
limit: Maximum number of files to return (default: 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of absolute file paths, sorted by modification time (newest first).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "**/*.py" finds all Python files
|
||||||
|
- "src/**/*.ts" finds TypeScript files in src/
|
||||||
|
- "**/test_*.py" finds all test files
|
||||||
|
"""
|
||||||
|
tool = GlobFilesTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
search_path = path or ctx.deps.working_dir
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern=pattern,
|
||||||
|
path=search_path,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def grep_content(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
pattern: str,
|
||||||
|
path: str | None = None,
|
||||||
|
file_glob: str | None = None,
|
||||||
|
context_lines: int = 0,
|
||||||
|
case_sensitive: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""Search file contents using regex pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Regex pattern to search for (Python re syntax)
|
||||||
|
path: Directory or file to search (default: working directory)
|
||||||
|
file_glob: Filter files by glob (e.g., "*.py", "*.ts")
|
||||||
|
context_lines: Lines of context before/after matches (default: 0)
|
||||||
|
case_sensitive: Case-sensitive search (default: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching lines with file paths and line numbers.
|
||||||
|
Format: "filepath:line_num: content"
|
||||||
|
"""
|
||||||
|
tool = GrepContentTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
search_path = path or ctx.deps.working_dir
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern=pattern,
|
||||||
|
path=search_path,
|
||||||
|
file_glob=file_glob,
|
||||||
|
context_lines=context_lines,
|
||||||
|
case_sensitive=case_sensitive
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def bash_readonly(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
command: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
timeout: int = 30
|
||||||
|
) -> str:
|
||||||
|
"""Execute a read-only bash command.
|
||||||
|
|
||||||
|
ALLOWED commands:
|
||||||
|
- File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du
|
||||||
|
- Git (read-only): git status, git log, git diff, git show, git branch
|
||||||
|
- Text processing: grep, awk, sed (read-only), sort, uniq
|
||||||
|
- System info: pwd, whoami, hostname, which
|
||||||
|
|
||||||
|
FORBIDDEN:
|
||||||
|
- File modification (rm, mv, cp, mkdir, touch)
|
||||||
|
- Redirects (>, >>)
|
||||||
|
- Command chaining (&&, ||, ;)
|
||||||
|
- Network (curl, wget)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The bash command to execute
|
||||||
|
cwd: Working directory (default: agent working directory)
|
||||||
|
timeout: Timeout in seconds (default: 30)
|
||||||
|
"""
|
||||||
|
tool = BashReadOnlyTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
working_dir = cwd or ctx.deps.working_dir
|
||||||
|
result = await tool.execute(
|
||||||
|
command=command,
|
||||||
|
cwd=working_dir,
|
||||||
|
timeout=min(timeout, ctx.deps.timeout_seconds)
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
# === Write tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def edit_file(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
file_path: str,
|
||||||
|
old_string: str,
|
||||||
|
new_string: str,
|
||||||
|
replace_all: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Make targeted edits to a file using find-and-replace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to edit
|
||||||
|
old_string: The exact text to find and replace (must exist in file)
|
||||||
|
new_string: The replacement text
|
||||||
|
replace_all: If True, replace all occurrences. If False (default),
|
||||||
|
old_string must be unique (appear exactly once).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with diff preview, or error.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- old_string must exactly match file content (including whitespace)
|
||||||
|
- By default, old_string must appear exactly once (for safety)
|
||||||
|
- Always read the file first to verify exact content before editing
|
||||||
|
"""
|
||||||
|
tool = EditFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
old_string=old_string,
|
||||||
|
new_string=new_string,
|
||||||
|
replace_all=replace_all
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def write_file(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
file_path: str,
|
||||||
|
content: str
|
||||||
|
) -> str:
|
||||||
|
"""Create a new file or overwrite an existing file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to create/write
|
||||||
|
content: The content to write to the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with file path and size.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Parent directory must exist (use bash mkdir first if needed)
|
||||||
|
- For editing existing files, prefer edit_file instead
|
||||||
|
- Will overwrite existing files without confirmation
|
||||||
|
"""
|
||||||
|
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def bash(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
command: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
timeout: int = 60
|
||||||
|
) -> str:
|
||||||
|
"""Execute a bash command with write capabilities.
|
||||||
|
|
||||||
|
ALLOWED:
|
||||||
|
- File operations: ls, find, mkdir, touch, cp, mv, rm (single files)
|
||||||
|
- Git (full): git add, git commit, git checkout, git merge, git pull
|
||||||
|
- Python: python, pip install, pytest, mypy, ruff
|
||||||
|
- Text processing: grep, awk, sed, sort
|
||||||
|
- Command chaining: && and || are allowed
|
||||||
|
|
||||||
|
FORBIDDEN:
|
||||||
|
- sudo, su (privilege escalation)
|
||||||
|
- Network: curl, wget, ssh, scp, rsync
|
||||||
|
- Dangerous: rm -rf, chmod 777, dd, mkfs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The bash command to execute
|
||||||
|
cwd: Working directory (default: agent working directory)
|
||||||
|
timeout: Timeout in seconds (default: 60)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "mkdir -p src/utils" creates directory
|
||||||
|
- "git add . && git commit -m 'fix: bug'" commits changes
|
||||||
|
- "pytest tests/ -v" runs tests
|
||||||
|
"""
|
||||||
|
tool = BashTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
working_dir = cwd or ctx.deps.working_dir
|
||||||
|
result = await tool.execute(
|
||||||
|
command=command,
|
||||||
|
cwd=working_dir,
|
||||||
|
timeout=min(timeout, ctx.deps.timeout_seconds)
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
# === External tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def web_search(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
query: str,
|
||||||
|
num_results: int = 5,
|
||||||
|
categories: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Search the web for current information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (e.g., "Python 3.12 new features")
|
||||||
|
num_results: Number of results to return (1-10, default: 5)
|
||||||
|
categories: Optional category filter ("general", "it", "news", "science")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Search results with titles, URLs, and snippets.
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Current events or recent information
|
||||||
|
- Documentation updates
|
||||||
|
- Technical references with URLs
|
||||||
|
"""
|
||||||
|
tool = WebSearchTool()
|
||||||
|
result = await tool.execute(
|
||||||
|
query=query,
|
||||||
|
num_results=num_results,
|
||||||
|
categories=categories
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
# === Orchestration tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def spawn_agent(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
agent_type: str,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Spawn a sub-agent to handle a focused task.
|
||||||
|
|
||||||
|
Use this to offload work to specialized agents:
|
||||||
|
- "explore": Fast codebase searches and analysis (read-only)
|
||||||
|
- "plan": Design implementation strategies (read-only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_type: Type of agent to spawn ("explore" or "plan")
|
||||||
|
prompt: Task description for the sub-agent
|
||||||
|
working_dir: Working directory for the sub-agent (default: current)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sub-agent's consolidated response.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- spawn_agent(agent_type="explore", prompt="find all test files")
|
||||||
|
- spawn_agent(agent_type="plan", prompt="design user auth feature")
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Use sub-agents to keep context focused and efficient
|
||||||
|
- Explore agent for research, Plan agent for design
|
||||||
|
- Cannot spawn nested Task agents (recursion risk)
|
||||||
|
"""
|
||||||
|
from src.domains.agents.base import get_agent
|
||||||
|
|
||||||
|
# Validate agent type
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
if agent_type not in allowed_types:
|
||||||
|
if agent_type == "task":
|
||||||
|
return "Error: Cannot spawn nested Task agents (recursion risk)"
|
||||||
|
return f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
|
||||||
|
|
||||||
|
sub_agent = get_agent(agent_type)
|
||||||
|
if not sub_agent:
|
||||||
|
return f"Error: Agent '{agent_type}' not found in registry"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await sub_agent.run(
|
||||||
|
prompt=prompt,
|
||||||
|
working_dir=working_dir or ctx.deps.working_dir,
|
||||||
|
allowed_paths=ctx.deps.allowed_paths,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Sub-agent error: {e}"
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Task agent.
|
||||||
|
|
||||||
|
Tests registration, API endpoints, tool access, and spawn_agent functionality.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from src.domains.agents.base import get_agent, list_agents
|
||||||
|
from src.domains.agents.task import task_agent, TaskAgentImpl
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentRegistration:
|
||||||
|
"""Tests for Task agent registration."""
|
||||||
|
|
||||||
|
def test_task_agent_registered(self):
|
||||||
|
"""Test that task agent is registered in registry."""
|
||||||
|
agent = get_agent("task")
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.name == "task"
|
||||||
|
|
||||||
|
def test_task_agent_in_list(self):
|
||||||
|
"""Test that task agent appears in agent list."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
assert "task" in names
|
||||||
|
|
||||||
|
def test_task_agent_has_description(self):
|
||||||
|
"""Test that task agent has a description."""
|
||||||
|
agent = get_agent("task")
|
||||||
|
assert agent is not None
|
||||||
|
assert len(agent.description) > 0
|
||||||
|
assert "task" in agent.description.lower() or "autonomous" in agent.description.lower()
|
||||||
|
|
||||||
|
def test_task_agent_singleton(self):
|
||||||
|
"""Test that task_agent is the registered instance."""
|
||||||
|
registered = get_agent("task")
|
||||||
|
assert registered is task_agent
|
||||||
|
|
||||||
|
def test_task_agent_is_correct_type(self):
|
||||||
|
"""Test that task agent is correct implementation type."""
|
||||||
|
assert isinstance(task_agent, TaskAgentImpl)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentTools:
|
||||||
|
"""Tests for Task agent tool access."""
|
||||||
|
|
||||||
|
def test_task_agent_has_all_tools(self):
|
||||||
|
"""Test that task agent has all 9 tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should have 9 tools total
|
||||||
|
assert len(tool_names) == 9
|
||||||
|
|
||||||
|
def test_task_agent_has_read_only_tools(self):
|
||||||
|
"""Test that task agent has read-only tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "read_file" in tool_names
|
||||||
|
assert "glob_files" in tool_names
|
||||||
|
assert "grep_content" in tool_names
|
||||||
|
assert "bash_readonly" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_write_tools(self):
|
||||||
|
"""Test that task agent has write tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "edit_file" in tool_names
|
||||||
|
assert "write_file" in tool_names
|
||||||
|
assert "bash" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_external_tools(self):
|
||||||
|
"""Test that task agent has external tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "web_search" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_spawn_agent_tool(self):
|
||||||
|
"""Test that task agent has spawn_agent orchestration tool."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "spawn_agent" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpawnAgentTool:
|
||||||
|
"""Tests for spawn_agent orchestration functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_spawn_explore_agent(self):
|
||||||
|
"""Test spawning an explore agent."""
|
||||||
|
from src.domains.agents.task.tools import register_task_tools
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Create a mock context
|
||||||
|
ctx = MagicMock(spec=RunContext)
|
||||||
|
ctx.deps = AgentContext(
|
||||||
|
working_dir="/tmp",
|
||||||
|
allowed_paths=["/tmp"],
|
||||||
|
timeout_seconds=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the explore agent
|
||||||
|
with patch("src.domains.agents.base.get_agent") as mock_get_agent:
|
||||||
|
mock_explore = AsyncMock()
|
||||||
|
mock_explore.run = AsyncMock(return_value="Found 5 Python files")
|
||||||
|
mock_get_agent.return_value = mock_explore
|
||||||
|
|
||||||
|
# Import and call spawn_agent directly
|
||||||
|
from src.domains.agents.task import tools
|
||||||
|
# We need to test the actual tool function
|
||||||
|
# For now, verify the explore agent would be called correctly
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_spawn_unknown_agent_returns_error(self):
|
||||||
|
"""Test that spawning unknown agent type returns error."""
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from pydantic_ai import RunContext
|
||||||
|
|
||||||
|
# We can't easily test the tool directly, but we can verify
|
||||||
|
# the agent type validation logic
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
assert "nonexistent" not in allowed_types
|
||||||
|
assert "task" not in allowed_types # Task should be blocked
|
||||||
|
|
||||||
|
def test_spawn_task_agent_blocked(self):
|
||||||
|
"""Test that spawning nested task agents is blocked."""
|
||||||
|
# Verify the validation logic prevents recursion
|
||||||
|
# The spawn_agent tool should return an error for agent_type="task"
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
assert "task" not in allowed_types
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentAPI:
|
||||||
|
"""Tests for Task agent REST API."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_agents_includes_task(self, auth_client):
|
||||||
|
"""Test that agent list includes task agent."""
|
||||||
|
response = await auth_client.get("/agents/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [a["name"] for a in data["agents"]]
|
||||||
|
assert "task" in names
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_task_agent_info(self, auth_client):
|
||||||
|
"""Test getting task agent info."""
|
||||||
|
response = await auth_client.get("/agents/task")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "task"
|
||||||
|
assert "description" in data
|
||||||
|
assert len(data["description"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_task_with_invalid_body(self, auth_client):
|
||||||
|
"""Test running task agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"agent_type": "task",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stream_task_with_invalid_body(self, auth_client):
|
||||||
|
"""Test streaming task agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/stream",
|
||||||
|
json={
|
||||||
|
"agent_type": "task",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentProperties:
|
||||||
|
"""Tests for Task agent properties and configuration."""
|
||||||
|
|
||||||
|
def test_task_agent_name(self):
|
||||||
|
"""Test task agent name property."""
|
||||||
|
assert task_agent.name == "task"
|
||||||
|
|
||||||
|
def test_task_agent_description_not_empty(self):
|
||||||
|
"""Test task agent description is not empty."""
|
||||||
|
assert task_agent.description
|
||||||
|
assert len(task_agent.description) > 10
|
||||||
|
|
||||||
|
def test_task_agent_creates_agent_lazily(self):
|
||||||
|
"""Test that PydanticAI agent is created lazily."""
|
||||||
|
# Create a fresh instance
|
||||||
|
fresh_agent = TaskAgentImpl()
|
||||||
|
|
||||||
|
# _agent should be None before first access
|
||||||
|
assert fresh_agent._agent is None
|
||||||
|
|
||||||
|
# Access the agent property
|
||||||
|
_ = fresh_agent.agent
|
||||||
|
|
||||||
|
# Now _agent should be set
|
||||||
|
assert fresh_agent._agent is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllAgentsRegistered:
|
||||||
|
"""Tests to verify all three agents are registered."""
|
||||||
|
|
||||||
|
def test_all_agents_in_registry(self):
|
||||||
|
"""Test that explore, plan, and task agents are all registered."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
|
||||||
|
assert "explore" in names
|
||||||
|
assert "plan" in names
|
||||||
|
assert "task" in names
|
||||||
|
assert len(names) == 3
|
||||||
|
|
||||||
|
def test_agent_hierarchy(self):
|
||||||
|
"""Test the agent capability hierarchy."""
|
||||||
|
explore = get_agent("explore")
|
||||||
|
plan = get_agent("plan")
|
||||||
|
task = get_agent("task")
|
||||||
|
|
||||||
|
explore_tools = list(explore.agent._function_toolset.tools.keys())
|
||||||
|
plan_tools = list(plan.agent._function_toolset.tools.keys())
|
||||||
|
task_tools = list(task.agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Explore has all tools (read + write)
|
||||||
|
assert "edit_file" in explore_tools
|
||||||
|
assert "write_file" in explore_tools
|
||||||
|
|
||||||
|
# Plan has read-only tools
|
||||||
|
assert "edit_file" not in plan_tools
|
||||||
|
assert "write_file" not in plan_tools
|
||||||
|
|
||||||
|
# Task has all tools plus spawn_agent
|
||||||
|
assert "edit_file" in task_tools
|
||||||
|
assert "write_file" in task_tools
|
||||||
|
assert "spawn_agent" in task_tools
|
||||||
|
|
||||||
|
# Only Task has spawn_agent
|
||||||
|
assert "spawn_agent" not in explore_tools
|
||||||
|
assert "spawn_agent" not in plan_tools
|
||||||
Reference in New Issue
Block a user