chore: release api v1.0.0
Build and Push API / release (push) Successful in 4s
Build and Push API / build (push) Successful in 2m26s

- 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:
2026-01-15 07:54:33 +01:00
co-authored by Claude Opus 4.5
parent 6ea520519c
commit d385f47395
13 changed files with 1061 additions and 110 deletions
+217 -37
View File
@@ -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