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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user