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