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:
2026-01-14 08:48:07 +01:00
co-authored by Claude Opus 4.5
parent acf231eb66
commit b7956f88ed
14 changed files with 1047 additions and 367 deletions
+65 -25
View File
@@ -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: