feat: add permission modes and CLI orchestration layer
Permission Modes: - Add default/plan/auto_accept modes controlling tool access - Plan mode restricts Task agent to read-only tools only - Auto-accept mode bypasses approval prompts (with confirmation) Approval Scaffolding: - Add ApprovalRule/ApprovalRuleSet for granular tool control - Pattern-based matching on tool name and arguments - Default rules for common safe/dangerous patterns - Prep for future bidirectional approval flow CLI Refactor: - Default to Task agent (main orchestrator) - Add --mode flag and runtime mode switching - Integrate prompt_toolkit for better UX: - Persistent command history (~/.webber_history) - Tab completion for commands and file paths - Auto-suggest from history - Deprecate standalone 'explore' command Other: - Split CHANGELOG.md into per-package files - Update AGENTS.md release procedure for both packages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Tool approval evaluation logic.
|
||||
|
||||
Provides granular control over tool execution:
|
||||
- Rule-based matching on tool name and arguments
|
||||
- Priority-ordered rule evaluation
|
||||
- Default fallback behavior
|
||||
"""
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from src.domains.agents.schemas import (
|
||||
ApprovalAction,
|
||||
ApprovalRule,
|
||||
ApprovalRuleSet,
|
||||
PermissionMode,
|
||||
)
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _serialize_tool_args(tool_args: dict[str, Any]) -> str:
|
||||
"""
|
||||
Serialize tool arguments to a string for pattern matching.
|
||||
|
||||
Converts tool args dict to a consistent string format that can be
|
||||
matched against regex patterns.
|
||||
|
||||
Examples:
|
||||
{"command": "curl localhost:8095"} -> "command=curl localhost:8095"
|
||||
{"file_path": "/src/main.py"} -> "file_path=/src/main.py"
|
||||
"""
|
||||
parts = []
|
||||
for key, value in sorted(tool_args.items()):
|
||||
parts.append(f"{key}={value}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def evaluate_rule(rule: ApprovalRule, tool_name: str, tool_args: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if a rule matches the given tool call.
|
||||
|
||||
Args:
|
||||
rule: The approval rule to evaluate
|
||||
tool_name: Name of the tool being called
|
||||
tool_args: Arguments passed to the tool
|
||||
|
||||
Returns:
|
||||
True if the rule matches, False otherwise
|
||||
"""
|
||||
# Tool name must match exactly
|
||||
if rule.tool != tool_name and rule.tool != "*":
|
||||
return False
|
||||
|
||||
# Serialize args for pattern matching
|
||||
args_str = _serialize_tool_args(tool_args)
|
||||
|
||||
# Try to match pattern against serialized args
|
||||
try:
|
||||
if re.search(rule.pattern, args_str, re.IGNORECASE):
|
||||
return True
|
||||
except re.error as e:
|
||||
logger.warning(f"Invalid regex pattern in rule: {rule.pattern} - {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_approval(
|
||||
ruleset: ApprovalRuleSet,
|
||||
tool_name: str,
|
||||
tool_args: dict[str, Any],
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
) -> ApprovalAction:
|
||||
"""
|
||||
Evaluate whether a tool call should be allowed, denied, or prompt for approval.
|
||||
|
||||
Args:
|
||||
ruleset: Set of approval rules to evaluate
|
||||
tool_name: Name of the tool being called
|
||||
tool_args: Arguments passed to the tool
|
||||
mode: Current permission mode
|
||||
|
||||
Returns:
|
||||
ApprovalAction indicating what to do (allow, deny, ask)
|
||||
"""
|
||||
# Plan mode: only read-only tools are even registered, so if we get here
|
||||
# it's a read-only tool and should be allowed
|
||||
if mode == PermissionMode.plan:
|
||||
return ApprovalAction.allow
|
||||
|
||||
# Auto-accept mode: allow everything without prompting
|
||||
if mode == PermissionMode.auto_accept:
|
||||
return ApprovalAction.allow
|
||||
|
||||
# Default mode: evaluate rules
|
||||
# Sort rules by priority (highest first)
|
||||
sorted_rules = sorted(ruleset.rules, key=lambda r: r.priority, reverse=True)
|
||||
|
||||
for rule in sorted_rules:
|
||||
if evaluate_rule(rule, tool_name, tool_args):
|
||||
logger.debug(
|
||||
f"Rule matched: {rule.description or rule.pattern} -> {rule.action}"
|
||||
)
|
||||
return rule.action
|
||||
|
||||
# No rules matched, use default action
|
||||
return ruleset.default_action
|
||||
|
||||
|
||||
# === Default rule sets ===
|
||||
|
||||
# Read-only tools that never need approval
|
||||
READONLY_TOOLS = {"read_file", "glob_files", "grep_content", "bash_readonly"}
|
||||
|
||||
# Default rules for common patterns
|
||||
DEFAULT_RULES = ApprovalRuleSet(
|
||||
rules=[
|
||||
# Always allow read-only tools
|
||||
ApprovalRule(
|
||||
tool="read_file",
|
||||
pattern=".*",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow all file reads",
|
||||
priority=100,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="glob_files",
|
||||
pattern=".*",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow all glob searches",
|
||||
priority=100,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="grep_content",
|
||||
pattern=".*",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow all grep searches",
|
||||
priority=100,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="bash_readonly",
|
||||
pattern=".*",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow all read-only bash commands",
|
||||
priority=100,
|
||||
),
|
||||
# Dangerous patterns - always deny
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="rm\\s+-rf\\s+/",
|
||||
action=ApprovalAction.deny,
|
||||
description="Deny recursive delete from root",
|
||||
priority=90,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="sudo\\s+",
|
||||
action=ApprovalAction.deny,
|
||||
description="Deny sudo commands",
|
||||
priority=90,
|
||||
),
|
||||
# Common safe patterns - allow without prompting
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="command=git\\s+(status|log|diff|show|branch)",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow read-only git commands",
|
||||
priority=50,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="command=pytest\\s+",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow pytest execution",
|
||||
priority=50,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="command=python\\s+-m\\s+pytest",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow pytest via python -m",
|
||||
priority=50,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="command=curl.*localhost",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow curl to localhost",
|
||||
priority=50,
|
||||
),
|
||||
ApprovalRule(
|
||||
tool="bash",
|
||||
pattern="command=curl.*127\\.0\\.0\\.1",
|
||||
action=ApprovalAction.allow,
|
||||
description="Allow curl to 127.0.0.1",
|
||||
priority=50,
|
||||
),
|
||||
],
|
||||
default_action=ApprovalAction.ask,
|
||||
)
|
||||
|
||||
|
||||
def get_default_ruleset() -> ApprovalRuleSet:
|
||||
"""Get the default approval ruleset."""
|
||||
return DEFAULT_RULES
|
||||
|
||||
|
||||
def is_readonly_tool(tool_name: str) -> bool:
|
||||
"""Check if a tool is read-only (never needs approval)."""
|
||||
return tool_name in READONLY_TOOLS
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
REST API routes for agents.
|
||||
|
||||
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
|
||||
"""
|
||||
import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -16,6 +21,7 @@ from src.domains.agents.schemas import (
|
||||
AgentRunResponse,
|
||||
AgentInfo,
|
||||
AgentListResponse,
|
||||
PermissionMode,
|
||||
)
|
||||
from src.shared.logging import logged, get_logger
|
||||
|
||||
@@ -40,6 +46,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
Run an agent with the given prompt.
|
||||
|
||||
The agent will use tools to explore the codebase and answer questions.
|
||||
Permission mode controls which tools are available.
|
||||
"""
|
||||
# Get the requested agent
|
||||
agent = get_agent(request.agent_type)
|
||||
@@ -50,15 +57,17 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
# Run the agent
|
||||
# Run the agent with mode
|
||||
response = await agent.run(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=request.mode,
|
||||
)
|
||||
|
||||
return AgentRunResponse(
|
||||
response=response,
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode,
|
||||
success=True,
|
||||
)
|
||||
|
||||
@@ -67,6 +76,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
return AgentRunResponse(
|
||||
response="",
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
@@ -79,6 +89,8 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
Run an agent with streaming response.
|
||||
|
||||
Returns Server-Sent Events (SSE) with text chunks.
|
||||
Permission mode controls which tools are available.
|
||||
|
||||
Event types:
|
||||
- "chunk": Text chunk from the agent
|
||||
- "done": Stream complete
|
||||
@@ -96,13 +108,14 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
async for chunk in agent.run_stream(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=request.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'})}\n\n"
|
||||
yield f"data: {json.dumps({'event': 'done', 'mode': request.mode.value})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
|
||||
@@ -1,22 +1,121 @@
|
||||
"""
|
||||
Request and response schemas for agent API.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class PermissionMode(str, Enum):
|
||||
"""
|
||||
Permission modes that control agent tool access.
|
||||
|
||||
Aligns with Claude Code's permission model:
|
||||
- default: Full tools, approval required for writes (future)
|
||||
- plan: Read-only tools only, no approval needed
|
||||
- auto_accept: Full tools, no approval prompts
|
||||
"""
|
||||
default = "default"
|
||||
plan = "plan"
|
||||
auto_accept = "auto_accept"
|
||||
|
||||
|
||||
class ApprovalStatus(str, Enum):
|
||||
"""Status of a tool approval request."""
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
denied = "denied"
|
||||
|
||||
|
||||
class ApprovalAction(str, Enum):
|
||||
"""Action to take when a rule matches."""
|
||||
allow = "allow" # Auto-approve without prompting
|
||||
deny = "deny" # Auto-deny without prompting
|
||||
ask = "ask" # Prompt user for approval
|
||||
|
||||
|
||||
class ApprovalRule(BaseSchema):
|
||||
"""
|
||||
Granular approval rule for tool execution.
|
||||
|
||||
Allows fine-grained control over which tool calls are allowed:
|
||||
- Pattern matching on tool arguments
|
||||
- Different actions per rule (allow, deny, ask)
|
||||
|
||||
Examples:
|
||||
# Allow curl to localhost
|
||||
ApprovalRule(tool="bash", pattern="curl.*localhost.*", action="allow")
|
||||
|
||||
# Deny any rm command
|
||||
ApprovalRule(tool="bash", pattern="rm\\s+.*", action="deny")
|
||||
|
||||
# Ask for git push
|
||||
ApprovalRule(tool="bash", pattern="git\\s+push.*", action="ask")
|
||||
|
||||
# Allow all file reads in src/
|
||||
ApprovalRule(tool="read_file", pattern=".*/src/.*", action="allow")
|
||||
"""
|
||||
tool: str # Tool name to match (e.g., "bash", "edit_file")
|
||||
pattern: str # Regex pattern to match against tool args
|
||||
action: ApprovalAction # What to do when matched
|
||||
description: str | None = None # Human-readable description of rule
|
||||
priority: int = 0 # Higher priority rules evaluated first
|
||||
|
||||
|
||||
class ApprovalRuleSet(BaseSchema):
|
||||
"""
|
||||
Collection of approval rules with evaluation logic.
|
||||
|
||||
Rules are evaluated in priority order (highest first).
|
||||
First matching rule determines the action.
|
||||
If no rules match, falls back to default action.
|
||||
"""
|
||||
rules: list[ApprovalRule] = []
|
||||
default_action: ApprovalAction = ApprovalAction.ask # Default when no rules match
|
||||
|
||||
|
||||
class ToolApprovalRequest(BaseSchema):
|
||||
"""
|
||||
Request for tool execution approval.
|
||||
|
||||
Sent from API to CLI when a tool needs user approval.
|
||||
Prep for future bidirectional approval flow.
|
||||
"""
|
||||
request_id: str
|
||||
tool_name: str
|
||||
tool_args: dict
|
||||
description: str
|
||||
risk_level: str = "write" # "read", "write", "dangerous"
|
||||
|
||||
|
||||
class ToolApprovalResponse(BaseSchema):
|
||||
"""
|
||||
Response to a tool approval request.
|
||||
|
||||
Sent from CLI to API with user's decision.
|
||||
"""
|
||||
request_id: str
|
||||
status: ApprovalStatus
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class AgentRunRequest(BaseSchema):
|
||||
"""Request to run an agent."""
|
||||
prompt: str
|
||||
working_dir: str = "."
|
||||
agent_type: str = "explore"
|
||||
agent_type: str = "task" # Default to task agent (main agent)
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
|
||||
|
||||
class AgentRunResponse(BaseSchema):
|
||||
"""Response from agent execution."""
|
||||
response: str
|
||||
agent_type: str
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
# Prep for approval flow - if set, CLI should handle approval
|
||||
pending_approval: ToolApprovalRequest | None = None
|
||||
|
||||
|
||||
class AgentInfo(BaseSchema):
|
||||
|
||||
@@ -3,19 +3,20 @@ Task Agent implementation using PydanticAI.
|
||||
|
||||
Full orchestrator agent that can:
|
||||
- Execute multi-step tasks autonomously
|
||||
- Use all tools (read + write)
|
||||
- Use all tools (read + write) based on permission mode
|
||||
- Spawn sub-agents (Explore, Plan) for focused work
|
||||
"""
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
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.domains.agents.schemas import PermissionMode
|
||||
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
|
||||
from src.shared.logging import logged, get_logger, trace_span
|
||||
@@ -29,20 +30,21 @@ class TaskContext(AgentContext):
|
||||
Context for task agent tools.
|
||||
|
||||
Passed to all tool functions via RunContext.
|
||||
Uses the same fields as base AgentContext.
|
||||
Extends base AgentContext with permission mode.
|
||||
"""
|
||||
pass
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
# Prep for approval flow - tools can check this
|
||||
pending_approvals: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
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)
|
||||
Tool access depends on permission mode:
|
||||
- plan: Read-only tools only (safe exploration)
|
||||
- default: All tools (approval required for writes - future)
|
||||
- auto_accept: All tools (no approval prompts)
|
||||
|
||||
Can spawn Explore and Plan agents to offload focused tasks,
|
||||
keeping context efficient across complex multi-step work.
|
||||
@@ -53,10 +55,22 @@ class TaskAgentImpl(BaseAgent):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the task agent."""
|
||||
self._agent: Agent[TaskContext, str] | None = None
|
||||
# Cache agents by mode to avoid recreating
|
||||
self._agents: dict[PermissionMode, Agent[TaskContext, str]] = {}
|
||||
self._settings = get_settings()
|
||||
|
||||
def _create_agent(self) -> Agent[TaskContext, str]:
|
||||
@property
|
||||
def agent(self) -> Agent[TaskContext, str]:
|
||||
"""Default agent (full mode) for compatibility."""
|
||||
return self._get_agent_for_mode(PermissionMode.default)
|
||||
|
||||
def _get_agent_for_mode(self, mode: PermissionMode) -> Agent[TaskContext, str]:
|
||||
"""Get or create agent configured for the specified mode."""
|
||||
if mode not in self._agents:
|
||||
self._agents[mode] = self._create_agent(mode)
|
||||
return self._agents[mode]
|
||||
|
||||
def _create_agent(self, mode: PermissionMode = PermissionMode.default) -> Agent[TaskContext, str]:
|
||||
"""Create the PydanticAI agent with Ollama backend."""
|
||||
# Use sanitized Ollama provider to fix content: null issues
|
||||
model = OpenAIModel(
|
||||
@@ -64,9 +78,12 @@ class TaskAgentImpl(BaseAgent):
|
||||
provider=get_ollama_provider(),
|
||||
)
|
||||
|
||||
# Select system prompt based on mode
|
||||
system_prompt = TASK_PLAN_MODE_PROMPT if mode == PermissionMode.plan else TASK_SYSTEM_PROMPT
|
||||
|
||||
agent: Agent[TaskContext, str] = Agent(
|
||||
model=model,
|
||||
system_prompt=TASK_SYSTEM_PROMPT,
|
||||
system_prompt=system_prompt,
|
||||
deps_type=TaskContext,
|
||||
output_type=str,
|
||||
# Mistral Nemo settings:
|
||||
@@ -78,15 +95,21 @@ class TaskAgentImpl(BaseAgent):
|
||||
},
|
||||
)
|
||||
|
||||
# Register all tools including orchestration
|
||||
self._register_tools(agent)
|
||||
# Register tools based on mode
|
||||
self._register_tools(agent, mode)
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
if mode == PermissionMode.plan:
|
||||
# Plan mode: read-only tools only
|
||||
register_readonly_tools(agent)
|
||||
else:
|
||||
# Default and auto_accept: all tools
|
||||
register_task_tools(agent)
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
@@ -94,6 +117,7 @@ class TaskAgentImpl(BaseAgent):
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
allowed_paths: list[str] | None = None,
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
"""
|
||||
@@ -103,6 +127,7 @@ class TaskAgentImpl(BaseAgent):
|
||||
prompt: Description of the task to execute
|
||||
working_dir: Working directory for the agent
|
||||
allowed_paths: Restrict tool access to these paths
|
||||
mode: Permission mode controlling tool access
|
||||
|
||||
Returns:
|
||||
Consolidated task summary with results
|
||||
@@ -111,12 +136,15 @@ class TaskAgentImpl(BaseAgent):
|
||||
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,
|
||||
)
|
||||
|
||||
async with trace_span("task_agent_run"):
|
||||
# Get agent configured for this mode
|
||||
agent = self._get_agent_for_mode(mode)
|
||||
|
||||
async with trace_span("task_agent_run", mode=mode.value):
|
||||
try:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(prompt, deps=ctx)
|
||||
result = await agent.run(prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent error: {e}")
|
||||
@@ -127,22 +155,34 @@ class TaskAgentImpl(BaseAgent):
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
allowed_paths: list[str] | None = None,
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
**kwargs: Any
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Run the task agent with streaming output.
|
||||
|
||||
Yields text chunks as they become available.
|
||||
Args:
|
||||
prompt: Task description
|
||||
working_dir: Working directory
|
||||
allowed_paths: Restrict tool access
|
||||
mode: Permission mode controlling tool access
|
||||
|
||||
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,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
async with trace_span("task_agent_stream"):
|
||||
# Get agent configured for this mode
|
||||
agent = self._get_agent_for_mode(mode)
|
||||
|
||||
async with trace_span("task_agent_stream", mode=mode.value):
|
||||
try:
|
||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||
async with agent.run_stream(prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,10 +3,68 @@ 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)
|
||||
- Use all tools (read + write) based on permission mode
|
||||
- Spawn sub-agents (Explore, Plan) for focused work
|
||||
"""
|
||||
|
||||
TASK_PLAN_MODE_PROMPT = """You are a codebase analysis and planning agent in READ-ONLY mode.
|
||||
|
||||
You can explore and analyze code but CANNOT modify files or execute write operations.
|
||||
|
||||
AVAILABLE TOOLS (read-only):
|
||||
|
||||
File Operations:
|
||||
- read_file: Read file contents with line numbers
|
||||
- glob_files: Find files by pattern
|
||||
- grep_content: Search file contents with regex
|
||||
|
||||
Shell:
|
||||
- bash_readonly: Read-only commands (ls, git status, git log, git diff, etc.)
|
||||
|
||||
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
|
||||
|
||||
TOOL CALL EXAMPLES:
|
||||
|
||||
To find all Python files:
|
||||
Call glob_files with pattern="**/*.py"
|
||||
|
||||
To search for a function:
|
||||
Call grep_content with pattern="def my_function"
|
||||
|
||||
To check git status:
|
||||
Call bash_readonly with command="git status"
|
||||
|
||||
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
|
||||
- Be thorough in exploration
|
||||
- Provide specific file paths and line numbers in findings
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Structure your response with:
|
||||
|
||||
### Analysis
|
||||
- What was found
|
||||
- Key patterns identified
|
||||
- Relevant files
|
||||
|
||||
### Recommendations
|
||||
- Suggested approach
|
||||
- Potential concerns
|
||||
- Next steps (to be executed in full mode)
|
||||
"""
|
||||
|
||||
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
||||
|
||||
You have access to ALL tools including file editing, writing, and bash execution.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
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)
|
||||
The Task agent has access to tools based on permission mode:
|
||||
- Plan mode: Read-only tools only
|
||||
- Default/auto_accept: All tools including write operations
|
||||
"""
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
@@ -20,19 +18,8 @@ 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 ===
|
||||
|
||||
def _register_read_file(agent: Agent[AgentContext, str]) -> None:
|
||||
"""Register read_file tool."""
|
||||
@agent.tool
|
||||
async def read_file(
|
||||
ctx: RunContext[AgentContext],
|
||||
@@ -60,6 +47,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||
)
|
||||
return result.to_string()
|
||||
|
||||
|
||||
def _register_glob_files(agent: Agent[AgentContext, str]) -> None:
|
||||
"""Register glob_files tool."""
|
||||
@agent.tool
|
||||
async def glob_files(
|
||||
ctx: RunContext[AgentContext],
|
||||
@@ -91,6 +81,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||
)
|
||||
return result.to_string()
|
||||
|
||||
|
||||
def _register_grep_content(agent: Agent[AgentContext, str]) -> None:
|
||||
"""Register grep_content tool."""
|
||||
@agent.tool
|
||||
async def grep_content(
|
||||
ctx: RunContext[AgentContext],
|
||||
@@ -124,6 +117,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||
)
|
||||
return result.to_string()
|
||||
|
||||
|
||||
def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
|
||||
"""Register bash_readonly tool."""
|
||||
@agent.tool
|
||||
async def bash_readonly(
|
||||
ctx: RunContext[AgentContext],
|
||||
@@ -159,6 +155,94 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||
)
|
||||
return result.to_string()
|
||||
|
||||
|
||||
def _register_spawn_agent(agent: Agent[AgentContext, str], readonly_only: bool = False) -> None:
|
||||
"""Register spawn_agent tool."""
|
||||
@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}"
|
||||
|
||||
|
||||
def register_readonly_tools(agent: Agent[AgentContext, str]) -> None:
|
||||
"""
|
||||
Register read-only tools with the agent.
|
||||
|
||||
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(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
|
||||
"""
|
||||
# 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
|
||||
@@ -295,56 +379,4 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||
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}"
|
||||
_register_spawn_agent(agent, readonly_only=False)
|
||||
|
||||
Reference in New Issue
Block a user