chore: release api v1.0.0
- Event-based streaming for task agent - Retry logic when LLM responds without calling tools - Hardened prompts to enforce tool use - Working directory context in all agent prompts - Project paused: local LLMs not capable enough for agentic use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,14 @@ class ExploreAgentImpl(BaseAgent):
|
||||
from src.domains.agents.explore.tools import register_explore_tools
|
||||
register_explore_tools(agent)
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
Use paths within this working directory for file operations.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
self,
|
||||
@@ -98,16 +106,20 @@ class ExploreAgentImpl(BaseAgent):
|
||||
Returns:
|
||||
Agent response with findings
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = ExploreContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("explore_agent_run"):
|
||||
try:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(prompt, deps=ctx)
|
||||
result = await self.agent.run(full_prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Explore agent error: {e}")
|
||||
@@ -126,15 +138,19 @@ class ExploreAgentImpl(BaseAgent):
|
||||
|
||||
Yields text chunks as they become available.
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = ExploreContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("explore_agent_stream"):
|
||||
try:
|
||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -82,6 +82,14 @@ class PlanAgentImpl(BaseAgent):
|
||||
from src.domains.agents.plan.tools import register_plan_tools
|
||||
register_plan_tools(agent)
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
Use paths within this working directory for file operations.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
self,
|
||||
@@ -101,16 +109,20 @@ class PlanAgentImpl(BaseAgent):
|
||||
Returns:
|
||||
Implementation plan with steps and critical files
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = PlanContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("plan_agent_run"):
|
||||
try:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(prompt, deps=ctx)
|
||||
result = await self.agent.run(full_prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Plan agent error: {e}")
|
||||
@@ -128,15 +140,19 @@ class PlanAgentImpl(BaseAgent):
|
||||
|
||||
Yields text chunks as they become available.
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = PlanContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("plan_agent_stream"):
|
||||
try:
|
||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,6 +5,9 @@ Supports permission modes for controlling agent tool access:
|
||||
- default: All tools available (approval may be required)
|
||||
- plan: Read-only tools only
|
||||
- auto_accept: All tools, no approval prompts
|
||||
|
||||
Streaming uses structured events instead of raw text to avoid
|
||||
garbled output during tool execution.
|
||||
"""
|
||||
import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -22,6 +25,7 @@ from src.domains.agents.schemas import (
|
||||
AgentInfo,
|
||||
AgentListResponse,
|
||||
PermissionMode,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
|
||||
@@ -98,13 +102,16 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
"""
|
||||
Run an agent with streaming response.
|
||||
|
||||
Returns Server-Sent Events (SSE) with text chunks.
|
||||
Returns Server-Sent Events (SSE) with structured events.
|
||||
Permission mode controls which tools are available.
|
||||
|
||||
Event types:
|
||||
- "chunk": Text chunk from the agent
|
||||
- "done": Stream complete
|
||||
- "error": Error occurred
|
||||
Event types (from StreamEvent):
|
||||
- tool_start: Tool execution beginning
|
||||
- tool_done: Tool execution complete
|
||||
- thinking: Agent status update
|
||||
- response: Final response text chunk
|
||||
- error: Error occurred
|
||||
- done: Stream complete
|
||||
"""
|
||||
agent = get_agent(request.agent_type)
|
||||
if not agent:
|
||||
@@ -118,21 +125,23 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
async for chunk in agent.run_stream(
|
||||
async for event in agent.run_stream(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=mode,
|
||||
):
|
||||
# SSE format: data: {json}\n\n
|
||||
event = {"event": "chunk", "data": chunk}
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
# Signal completion
|
||||
yield f"data: {json.dumps({'event': 'done', 'mode': mode.value})}\n\n"
|
||||
# Handle both StreamEvent objects and legacy string chunks
|
||||
if isinstance(event, StreamEvent):
|
||||
# New structured event format
|
||||
event_data = event.model_dump(exclude_none=True)
|
||||
yield f"data: {json.dumps(event_data)}\n\n"
|
||||
else:
|
||||
# Legacy string chunk (for explore/plan agents)
|
||||
yield f"data: {json.dumps({'event': 'chunk', 'data': event})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
error_event = {"event": "error", "data": str(e)}
|
||||
error_event = {"event": "error", "error_message": str(e)}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -127,3 +127,36 @@ class AgentInfo(BaseSchema):
|
||||
class AgentListResponse(BaseSchema):
|
||||
"""List of available agents."""
|
||||
agents: list[AgentInfo]
|
||||
|
||||
|
||||
# Streaming event types for event-based streaming
|
||||
class StreamEventType(str, Enum):
|
||||
"""
|
||||
Event types for structured agent streaming.
|
||||
|
||||
Instead of streaming raw text (which gets garbled during tool calls),
|
||||
we emit structured events that the CLI can render appropriately.
|
||||
"""
|
||||
tool_start = "tool_start" # Tool execution starting
|
||||
tool_done = "tool_done" # Tool execution complete
|
||||
thinking = "thinking" # Agent reasoning status
|
||||
response = "response" # Final response text chunk
|
||||
error = "error" # Error occurred
|
||||
done = "done" # Stream complete
|
||||
|
||||
|
||||
class StreamEvent(BaseSchema):
|
||||
"""
|
||||
Structured streaming event from agent execution.
|
||||
|
||||
Events are emitted instead of raw text to provide clean
|
||||
progress feedback during multi-tool agent loops.
|
||||
"""
|
||||
event: StreamEventType
|
||||
tool: str | None = None # Tool name (for tool_start/tool_done)
|
||||
args: dict | None = None # Tool arguments (for tool_start)
|
||||
result_summary: str | None = None # Brief result (for tool_done)
|
||||
message: str | None = None # Status message (for thinking)
|
||||
text: str | None = None # Response text (for response)
|
||||
error_message: str | None = None # Error details (for error)
|
||||
mode: str | None = None # Permission mode (for done)
|
||||
|
||||
@@ -5,7 +5,9 @@ Full orchestrator agent that can:
|
||||
- Execute multi-step tasks autonomously
|
||||
- Use all tools (read + write) based on permission mode
|
||||
- Spawn sub-agents (Explore, Plan) for focused work
|
||||
- Stream structured events instead of raw text
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
@@ -15,7 +17,7 @@ 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.schemas import PermissionMode
|
||||
from src.domains.agents.schemas import PermissionMode, StreamEvent, StreamEventType
|
||||
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT, TASK_PLAN_MODE_PROMPT
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
from src.shared.config import get_settings
|
||||
@@ -30,11 +32,33 @@ class TaskContext(AgentContext):
|
||||
Context for task agent tools.
|
||||
|
||||
Passed to all tool functions via RunContext.
|
||||
Extends base AgentContext with permission mode.
|
||||
Extends base AgentContext with permission mode and event queue.
|
||||
"""
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
# Prep for approval flow - tools can check this
|
||||
pending_approvals: list[str] = field(default_factory=list)
|
||||
# Event queue for streaming events from tools
|
||||
event_queue: asyncio.Queue | None = field(default=None, repr=False)
|
||||
# Track tool calls for retry logic
|
||||
tools_called: int = 0
|
||||
|
||||
|
||||
def _emit_event(ctx: AgentContext, event: StreamEvent) -> None:
|
||||
"""Emit an event to the queue if available."""
|
||||
if hasattr(ctx, 'event_queue') and ctx.event_queue is not None:
|
||||
ctx.event_queue.put_nowait(event)
|
||||
|
||||
|
||||
def _summarize_result(result: str, max_len: int = 80) -> str:
|
||||
"""Create a brief summary of a tool result."""
|
||||
# Count lines if multiline
|
||||
lines = result.strip().split('\n')
|
||||
if len(lines) > 1:
|
||||
return f"{len(lines)} lines"
|
||||
# Single line - truncate if needed
|
||||
if len(result) > max_len:
|
||||
return result[:max_len] + "..."
|
||||
return result
|
||||
|
||||
|
||||
class TaskAgentImpl(BaseAgent):
|
||||
@@ -102,14 +126,41 @@ class TaskAgentImpl(BaseAgent):
|
||||
|
||||
def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None:
|
||||
"""Register tools with the agent based on permission mode."""
|
||||
from src.domains.agents.task.tools import register_task_tools, register_readonly_tools
|
||||
from src.domains.agents.task.tools_streaming import (
|
||||
register_task_tools_streaming,
|
||||
register_readonly_tools_streaming,
|
||||
)
|
||||
|
||||
if mode == PermissionMode.plan:
|
||||
# Plan mode: read-only tools only
|
||||
register_readonly_tools(agent)
|
||||
register_readonly_tools_streaming(agent)
|
||||
else:
|
||||
# Default and auto_accept: all tools
|
||||
register_task_tools(agent)
|
||||
register_task_tools_streaming(agent)
|
||||
|
||||
# Maximum retries when no tools are called
|
||||
MAX_NO_TOOL_RETRIES = 2
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
When using file tools, use paths relative to or within this working directory.
|
||||
For example, to read a file at {working_dir}/README.md, use file_path="{working_dir}/README.md".
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
def _build_retry_prompt(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build a stronger prompt for retry after no tool calls."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
IMPORTANT: Your previous response was REJECTED because you did not call any tools.
|
||||
You MUST call a tool (like glob_files, bash_readonly, or read_file) BEFORE responding.
|
||||
DO NOT answer from memory. DO NOT fabricate information.
|
||||
|
||||
Call a tool NOW to gather real information, then respond based on the results.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
@@ -132,23 +183,47 @@ class TaskAgentImpl(BaseAgent):
|
||||
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,
|
||||
mode=mode,
|
||||
)
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
# Get agent configured for this mode
|
||||
agent = self._get_agent_for_mode(mode)
|
||||
|
||||
async with trace_span("task_agent_run"):
|
||||
try:
|
||||
result = await agent.run(prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent error: {e}")
|
||||
raise
|
||||
retries = 0
|
||||
while retries <= self.MAX_NO_TOOL_RETRIES:
|
||||
# Create fresh context for each attempt
|
||||
ctx = TaskContext(
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
# Build prompt - use retry prompt if this is a retry
|
||||
if retries == 0:
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
else:
|
||||
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
|
||||
logger.warning(f"Retry {retries}/{self.MAX_NO_TOOL_RETRIES}: No tools called, retrying with stronger prompt")
|
||||
|
||||
try:
|
||||
result = await agent.run(full_prompt, deps=ctx)
|
||||
|
||||
# Check if tools were called
|
||||
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
if ctx.tools_called == 0:
|
||||
logger.warning("Agent responded without calling tools after all retries")
|
||||
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent error: {e}")
|
||||
raise
|
||||
|
||||
# Should not reach here, but just in case
|
||||
return result.output
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
@@ -157,9 +232,12 @@ class TaskAgentImpl(BaseAgent):
|
||||
allowed_paths: list[str] | None = None,
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
**kwargs: Any
|
||||
) -> AsyncIterator[str]:
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Run the task agent with streaming output.
|
||||
Run the task agent with structured event streaming.
|
||||
|
||||
Instead of streaming raw text (which gets garbled during tool calls),
|
||||
yields structured events that clients can render appropriately.
|
||||
|
||||
Args:
|
||||
prompt: Task description
|
||||
@@ -168,26 +246,128 @@ class TaskAgentImpl(BaseAgent):
|
||||
mode: Permission mode controlling tool access
|
||||
|
||||
Yields:
|
||||
Text chunks as they become available.
|
||||
StreamEvent objects for tool progress and final response.
|
||||
|
||||
Event types:
|
||||
- tool_start: Tool execution beginning
|
||||
- tool_done: Tool execution complete with summary
|
||||
- thinking: Agent status update
|
||||
- response: Final response text
|
||||
- error: Error occurred
|
||||
- done: Stream complete
|
||||
"""
|
||||
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,
|
||||
mode=mode,
|
||||
)
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
# Get agent configured for this mode
|
||||
agent = self._get_agent_for_mode(mode)
|
||||
|
||||
async with trace_span("task_agent_stream"):
|
||||
try:
|
||||
async with 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
|
||||
# Emit initial thinking event
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.thinking,
|
||||
message="Starting task execution..."
|
||||
)
|
||||
|
||||
retries = 0
|
||||
response = ""
|
||||
|
||||
while retries <= self.MAX_NO_TOOL_RETRIES:
|
||||
# Create fresh event queue and context for each attempt
|
||||
event_queue: asyncio.Queue[StreamEvent] = asyncio.Queue()
|
||||
|
||||
ctx = TaskContext(
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
mode=mode,
|
||||
event_queue=event_queue,
|
||||
)
|
||||
|
||||
# Build prompt - use retry prompt if this is a retry
|
||||
if retries == 0:
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
else:
|
||||
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.thinking,
|
||||
message=f"Retrying (attempt {retries + 1})..."
|
||||
)
|
||||
|
||||
# Run agent in background task so we can yield events
|
||||
async def run_agent() -> str:
|
||||
try:
|
||||
result = await agent.run(full_prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent stream error: {e}")
|
||||
raise
|
||||
|
||||
agent_task = asyncio.create_task(run_agent())
|
||||
|
||||
# Yield events from queue while agent runs
|
||||
try:
|
||||
while not agent_task.done():
|
||||
try:
|
||||
# Check for events with timeout
|
||||
event = await asyncio.wait_for(
|
||||
event_queue.get(),
|
||||
timeout=0.1
|
||||
)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# No events, check if agent is done
|
||||
continue
|
||||
|
||||
# Drain remaining events
|
||||
while not event_queue.empty():
|
||||
yield event_queue.get_nowait()
|
||||
|
||||
# Get final result
|
||||
response = await agent_task
|
||||
|
||||
# Check if tools were called - if not, retry
|
||||
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
|
||||
logger.warning(f"No tools called, retrying ({retries + 1}/{self.MAX_NO_TOOL_RETRIES})")
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
if ctx.tools_called == 0:
|
||||
logger.warning("Agent responded without calling tools after all retries")
|
||||
|
||||
# Success - break out of retry loop
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.error,
|
||||
error_message=str(e)
|
||||
)
|
||||
# Cancel agent if still running
|
||||
if not agent_task.done():
|
||||
agent_task.cancel()
|
||||
try:
|
||||
await agent_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return
|
||||
|
||||
# Yield response in chunks for streaming feel
|
||||
chunk_size = 100
|
||||
for i in range(0, len(response), chunk_size):
|
||||
chunk = response[i:i + chunk_size]
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.response,
|
||||
text=chunk
|
||||
)
|
||||
# Small delay for streaming effect
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Signal completion
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.done,
|
||||
mode=mode.value
|
||||
)
|
||||
|
||||
|
||||
# Create and register the singleton instance
|
||||
@@ -208,7 +388,7 @@ 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
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Run task execution with event streaming."""
|
||||
async for event in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||
yield event
|
||||
|
||||
@@ -9,6 +9,11 @@ The Task agent is a full orchestrator that can:
|
||||
|
||||
TASK_PLAN_MODE_PROMPT = """You are a codebase analysis and planning agent in READ-ONLY mode.
|
||||
|
||||
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
|
||||
- NEVER answer from memory or assumptions
|
||||
- NEVER fabricate file structures, code, or content
|
||||
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
|
||||
|
||||
You can explore and analyze code but CANNOT modify files or execute write operations.
|
||||
|
||||
AVAILABLE TOOLS (read-only):
|
||||
@@ -24,12 +29,10 @@ Shell:
|
||||
Orchestration:
|
||||
- spawn_agent: Launch sub-agents for focused tasks (explore, plan only)
|
||||
|
||||
WORKFLOW:
|
||||
1. Understand the request
|
||||
2. Explore the codebase to gather context
|
||||
3. Analyze code structure and patterns
|
||||
4. Create detailed implementation plans
|
||||
5. Return findings with actionable recommendations
|
||||
MANDATORY WORKFLOW:
|
||||
1. FIRST: Call a tool to gather real information
|
||||
2. THEN: Analyze the actual tool results
|
||||
3. FINALLY: Respond based only on what tools returned
|
||||
|
||||
TOOL CALL EXAMPLES:
|
||||
|
||||
@@ -42,22 +45,25 @@ To search for a function:
|
||||
To check git status:
|
||||
Call bash_readonly with command="git status"
|
||||
|
||||
To list directory contents:
|
||||
Call bash_readonly with command="ls -la"
|
||||
|
||||
To get deeper analysis:
|
||||
Call spawn_agent with agent_type="explore" and prompt="find authentication code"
|
||||
|
||||
RULES:
|
||||
- ALWAYS use tools first, then analyze results
|
||||
- Never guess file contents - read them first
|
||||
- ALWAYS call a tool FIRST - no exceptions
|
||||
- Never guess or fabricate - only report what tools return
|
||||
- Be thorough in exploration
|
||||
- Provide specific file paths and line numbers in findings
|
||||
- Provide specific file paths and line numbers from tool results
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Structure your response with:
|
||||
|
||||
### Analysis
|
||||
- What was found
|
||||
- What was found (from tool results)
|
||||
- Key patterns identified
|
||||
- Relevant files
|
||||
- Relevant files (actual paths from tools)
|
||||
|
||||
### Recommendations
|
||||
- Suggested approach
|
||||
@@ -67,6 +73,11 @@ Structure your response with:
|
||||
|
||||
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
||||
|
||||
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
|
||||
- NEVER answer from memory or assumptions
|
||||
- NEVER fabricate file structures, code, or content
|
||||
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
|
||||
|
||||
You have access to ALL tools including file editing, writing, and bash execution.
|
||||
You can also spawn sub-agents to help with complex tasks.
|
||||
|
||||
@@ -89,16 +100,21 @@ External:
|
||||
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
|
||||
MANDATORY WORKFLOW:
|
||||
1. FIRST: Call a tool to gather real information
|
||||
2. THEN: Analyze the actual tool results
|
||||
3. Execute implementation using write tools if needed
|
||||
4. Validate changes (run tests if applicable)
|
||||
5. FINALLY: Return summary based only on what tools returned
|
||||
|
||||
TOOL CALL EXAMPLES:
|
||||
|
||||
To list directory contents:
|
||||
Call bash_readonly with command="ls -la"
|
||||
|
||||
To find all Python files:
|
||||
Call glob_files with pattern="**/*.py"
|
||||
|
||||
To spawn an Explore agent for research:
|
||||
Call spawn_agent with agent_type="explore" and prompt="find all config files"
|
||||
|
||||
@@ -124,8 +140,8 @@ GIT DISCIPLINE:
|
||||
- Run tests before committing
|
||||
|
||||
RULES:
|
||||
- ALWAYS use tools first, then analyze results
|
||||
- Never guess file contents - read them first
|
||||
- ALWAYS call a tool FIRST - no exceptions
|
||||
- Never guess or fabricate - only report what tools return
|
||||
- Prefer edit_file over write_file for existing files
|
||||
- Use spawn_agent to keep context focused
|
||||
- Validate changes by running tests when applicable
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Tool registrations for the Task agent with event streaming.
|
||||
|
||||
Same tools as tools.py but emit StreamEvent events for progress tracking.
|
||||
Tools push events to the context's event_queue when available.
|
||||
"""
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
from src.domains.agents.base import AgentContext
|
||||
from src.domains.agents.schemas import StreamEvent, StreamEventType
|
||||
from src.domains.agents.task.agent import TaskContext
|
||||
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 _emit_event(ctx: AgentContext, event: StreamEvent) -> None:
|
||||
"""Emit an event to the queue if available."""
|
||||
if hasattr(ctx, 'event_queue') and ctx.event_queue is not None:
|
||||
ctx.event_queue.put_nowait(event)
|
||||
|
||||
|
||||
def _track_tool_call(ctx: AgentContext) -> None:
|
||||
"""Increment tool call counter for retry logic."""
|
||||
if hasattr(ctx, 'tools_called'):
|
||||
ctx.tools_called += 1
|
||||
|
||||
|
||||
def _summarize_result(result: str, max_len: int = 80) -> str:
|
||||
"""Create a brief summary of a tool result."""
|
||||
lines = result.strip().split('\n')
|
||||
if len(lines) > 3:
|
||||
return f"{len(lines)} lines"
|
||||
if len(result) > max_len:
|
||||
return result[:max_len] + "..."
|
||||
return result.replace('\n', ' ')
|
||||
|
||||
|
||||
def _register_read_file(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register read_file tool with event streaming."""
|
||||
@agent.tool
|
||||
async def read_file(
|
||||
ctx: RunContext[TaskContext],
|
||||
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.
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="read_file",
|
||||
args={"file_path": file_path, "offset": offset, "limit": limit}
|
||||
))
|
||||
|
||||
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
result = await tool.execute(
|
||||
file_path=file_path,
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="read_file",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_glob_files(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register glob_files tool with event streaming."""
|
||||
@agent.tool
|
||||
async def glob_files(
|
||||
ctx: RunContext[TaskContext],
|
||||
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
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="glob_files",
|
||||
args={"pattern": pattern, "path": path}
|
||||
))
|
||||
|
||||
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
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="glob_files",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_grep_content(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register grep_content tool with event streaming."""
|
||||
@agent.tool
|
||||
async def grep_content(
|
||||
ctx: RunContext[TaskContext],
|
||||
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"
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="grep_content",
|
||||
args={"pattern": pattern, "path": path, "file_glob": file_glob}
|
||||
))
|
||||
|
||||
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
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="grep_content",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_bash_readonly(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register bash_readonly tool with event streaming."""
|
||||
@agent.tool
|
||||
async def bash_readonly(
|
||||
ctx: RunContext[TaskContext],
|
||||
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)
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="bash_readonly",
|
||||
args={"command": command}
|
||||
))
|
||||
|
||||
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)
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="bash_readonly",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_spawn_agent(agent: Agent[TaskContext, str], readonly_only: bool = False) -> None:
|
||||
"""Register spawn_agent tool with event streaming."""
|
||||
@agent.tool
|
||||
async def spawn_agent(
|
||||
ctx: RunContext[TaskContext],
|
||||
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
|
||||
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="spawn_agent",
|
||||
args={"agent_type": agent_type, "prompt": prompt[:50] + "..." if len(prompt) > 50 else prompt}
|
||||
))
|
||||
|
||||
# Validate agent type
|
||||
allowed_types = ["explore", "plan"]
|
||||
if agent_type not in allowed_types:
|
||||
if agent_type == "task":
|
||||
result = "Error: Cannot spawn nested Task agents (recursion risk)"
|
||||
else:
|
||||
result = f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=result
|
||||
))
|
||||
return result
|
||||
|
||||
sub_agent = get_agent(agent_type)
|
||||
if not sub_agent:
|
||||
result = f"Error: Agent '{agent_type}' not found in registry"
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=result
|
||||
))
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await sub_agent.run(
|
||||
prompt=prompt,
|
||||
working_dir=working_dir or ctx.deps.working_dir,
|
||||
allowed_paths=ctx.deps.allowed_paths,
|
||||
)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=_summarize_result(result)
|
||||
))
|
||||
return result
|
||||
except Exception as e:
|
||||
result = f"Sub-agent error: {e}"
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=result
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def register_readonly_tools_streaming(agent: Agent[TaskContext, str]) -> None:
|
||||
"""
|
||||
Register read-only tools with event streaming.
|
||||
|
||||
Used in plan mode. Includes:
|
||||
- read_file, glob_files, grep_content, bash_readonly
|
||||
- spawn_agent (restricted to explore/plan)
|
||||
"""
|
||||
_register_read_file(agent)
|
||||
_register_glob_files(agent)
|
||||
_register_grep_content(agent)
|
||||
_register_bash_readonly(agent)
|
||||
_register_spawn_agent(agent, readonly_only=True)
|
||||
|
||||
|
||||
def register_task_tools_streaming(agent: Agent[TaskContext, str]) -> None:
|
||||
"""
|
||||
Register all tools with event streaming.
|
||||
|
||||
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
|
||||
"""
|
||||
# Register read-only tools via helpers
|
||||
_register_read_file(agent)
|
||||
_register_glob_files(agent)
|
||||
_register_grep_content(agent)
|
||||
_register_bash_readonly(agent)
|
||||
|
||||
# === Write tools ===
|
||||
|
||||
@agent.tool
|
||||
async def edit_file(
|
||||
ctx: RunContext[TaskContext],
|
||||
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
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="edit_file",
|
||||
args={"file_path": file_path, "replace_all": replace_all}
|
||||
))
|
||||
|
||||
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
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="edit_file",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
@agent.tool
|
||||
async def write_file(
|
||||
ctx: RunContext[TaskContext],
|
||||
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
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="write_file",
|
||||
args={"file_path": file_path, "content_length": len(content)}
|
||||
))
|
||||
|
||||
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
result = await tool.execute(
|
||||
file_path=file_path,
|
||||
content=content
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="write_file",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
@agent.tool
|
||||
async def bash(
|
||||
ctx: RunContext[TaskContext],
|
||||
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
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="bash",
|
||||
args={"command": command}
|
||||
))
|
||||
|
||||
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)
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="bash",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
# === External tools ===
|
||||
|
||||
@agent.tool
|
||||
async def web_search(
|
||||
ctx: RunContext[TaskContext],
|
||||
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
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="web_search",
|
||||
args={"query": query}
|
||||
))
|
||||
|
||||
tool = WebSearchTool()
|
||||
result = await tool.execute(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
categories=categories
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="web_search",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
# === Orchestration tools ===
|
||||
_register_spawn_agent(agent, readonly_only=False)
|
||||
Reference in New Issue
Block a user