From b5b2346db556de9081ae47ef6a94756635cfb911 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 11 Jan 2026 19:57:30 +0100 Subject: [PATCH] feat: add Plan Agent for implementation planning - Add PlanAgentImpl with read-only tools only - System prompts optimized for architecture planning - Outputs step-by-step implementation plans with critical files - 15 unit tests for registration, tools, and API - Update COVERAGE.md to ~70% complete Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 12 ++ webber-api/docs/COVERAGE.md | 26 ++- webber-api/pyproject.toml | 2 +- .../src/domains/agents/plan/__init__.py | 30 ++++ webber-api/src/domains/agents/plan/agent.py | 168 +++++++++++++++++ webber-api/src/domains/agents/plan/prompts.py | 63 +++++++ webber-api/src/domains/agents/plan/tools.py | 170 ++++++++++++++++++ webber-api/src/domains/agents/router.py | 1 + webber-api/tests/test_plan_agent.py | 152 ++++++++++++++++ 9 files changed, 621 insertions(+), 3 deletions(-) create mode 100644 webber-api/src/domains/agents/plan/agent.py create mode 100644 webber-api/src/domains/agents/plan/prompts.py create mode 100644 webber-api/src/domains/agents/plan/tools.py create mode 100644 webber-api/tests/test_plan_agent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d42e12a..71ca24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.3] - 2026-01-11 + +### Added +- Plan Agent - READ-ONLY software architect that designs implementation strategies + - Uses only read-only tools: `read_file`, `glob_files`, `grep_content`, `bash_readonly` + - Creates step-by-step implementation plans with critical files list + - 15 unit tests for registration, tools, and API +- Web search summarizer added to roadmap (future feature) + +### Changed +- Updated COVERAGE.md to ~70% complete + ## [0.3.2] - 2026-01-11 ### Added diff --git a/webber-api/docs/COVERAGE.md b/webber-api/docs/COVERAGE.md index bc16676..c7342b8 100644 --- a/webber-api/docs/COVERAGE.md +++ b/webber-api/docs/COVERAGE.md @@ -2,7 +2,7 @@ > Tracking progress towards Claude Code-like functionality -## Current Status: ~65% Complete +## Current Status: ~70% Complete Last updated: 2026-01-11 @@ -44,6 +44,20 @@ Last updated: 2026-01-11 **Gap:** Mistral Nemo sometimes hallucinates instead of using tool results. +### Phase 2b: Plan Agent ✅ Complete + +| Component | Status | Notes | +|-----------|--------|-------| +| `PlanAgentImpl` | ✅ | READ-ONLY software architect agent | +| System prompts | ✅ | Architecture-focused with tool examples | +| Tool registration | ✅ | Only read-only tools (4 tools) | +| Streaming support | ✅ | `run_stream()` method with SSE | +| Unit tests | ✅ | 15 tests for registration, tools, API | + +**Available tools:** `read_file`, `glob_files`, `grep_content`, `bash_readonly` (read-only only) + +**Purpose:** Design implementation strategies before coding - explores codebase and creates step-by-step plans. + ### Phase 3: CLI Foundation ✅ Complete | Component | Status | Notes | @@ -94,7 +108,7 @@ Last updated: 2026-01-11 | Feature | Category | Description | Complexity | |---------|----------|-------------|------------| -| **Plan Agent** | Agents | Design implementation approaches | High | +| ~~**Plan Agent**~~ | Agents | ✅ Design implementation approaches | High | | **Task Agent** | Agents | Autonomous multi-step execution | High | | **Context summarization** | Infrastructure | Compress history at token limit | High | | **Conversation persistence** | CLI | Multi-turn memory in chat mode | Medium | @@ -103,6 +117,7 @@ Last updated: 2026-01-11 | Feature | Category | Description | Complexity | |---------|----------|-------------|------------| +| **Web search summarizer** | Tools | Agent to extract core content from web pages (remove nav, footers, etc.) and preserve relevant links for nested fetching | Medium | | **Tool result caching** | Infrastructure | Cache file reads for performance | Low | | **Session persistence** | CLI | Save/resume conversations | Medium | | **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium | @@ -129,6 +144,7 @@ Last updated: 2026-01-11 |------|---------|--------|--------| | Tool unit tests | 109 | 109 | ✅ | | API tests | 11 | 11 | ✅ | +| Plan agent tests | 15 | 15 | ✅ | | Security tests | 14 | 14 | ✅ | | Integration tests | 10 | 10 | ✅ Agent + real LLM | | E2E tests | 12 | 12 | ✅ Full API workflow | @@ -140,6 +156,7 @@ Last updated: 2026-01-11 - Web search: 10 tests - Gitignore filtering: 10 tests - API endpoints: 11 tests +- Plan agent: 15 tests - Security: 14 tests - Health checks: 2 tests - Integration (LLM): 10 tests @@ -205,6 +222,11 @@ curl -X POST http://localhost:8095/agents/run \ -H "Content-Type: application/json" \ -d '{"agent_type":"explore","prompt":"list python files","working_dir":"."}' +# Plan agent (read-only, creates implementation plans) +curl -X POST http://localhost:8095/agents/run \ + -H "Content-Type: application/json" \ + -d '{"agent_type":"plan","prompt":"plan how to add user auth","working_dir":"."}' + # Streaming endpoint curl -N http://localhost:8095/agents/stream \ -H "Content-Type: application/json" \ diff --git a/webber-api/pyproject.toml b/webber-api/pyproject.toml index 7dab730..a9f39f0 100644 --- a/webber-api/pyproject.toml +++ b/webber-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "webber-api" -version = "0.3.2" +version = "0.3.3" description = "Webber API - Multi-Agent AI Development Server" authors = [ {name = "jpmschweitzer"} diff --git a/webber-api/src/domains/agents/plan/__init__.py b/webber-api/src/domains/agents/plan/__init__.py index e69de29..ab4bf36 100644 --- a/webber-api/src/domains/agents/plan/__init__.py +++ b/webber-api/src/domains/agents/plan/__init__.py @@ -0,0 +1,30 @@ +""" +Plan Agent - Software architect for implementation planning. + +The Plan agent explores codebases and designs step-by-step implementation +strategies. It uses only read-only tools and cannot modify any files. + +Usage: + from src.domains.agents.plan import plan_agent, plan + + # Direct agent access + result = await plan_agent.run("Plan how to add user authentication") + + # Convenience function + result = await plan("Plan how to add user authentication") +""" +from src.domains.agents.plan.agent import ( + PlanAgentImpl, + PlanContext, + plan_agent, + plan, + plan_stream, +) + +__all__ = [ + "PlanAgentImpl", + "PlanContext", + "plan_agent", + "plan", + "plan_stream", +] diff --git a/webber-api/src/domains/agents/plan/agent.py b/webber-api/src/domains/agents/plan/agent.py new file mode 100644 index 0000000..d8b7a1c --- /dev/null +++ b/webber-api/src/domains/agents/plan/agent.py @@ -0,0 +1,168 @@ +""" +Plan Agent implementation using PydanticAI. + +Software architect agent that explores codebases and designs implementation plans. +Uses only read-only tools - cannot modify any files. +""" +import os +from collections.abc import AsyncIterator +from dataclasses import dataclass +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.plan.prompts import PLAN_SYSTEM_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 + +logger = get_logger(__name__) + + +@dataclass +class PlanContext(AgentContext): + """ + Context for plan agent tools. + + Passed to all tool functions via RunContext. + Uses the same fields as base AgentContext. + """ + pass + + +class PlanAgentImpl(BaseAgent): + """ + Software architect agent for implementation planning. + + Explores codebases to understand patterns and conventions, + then designs step-by-step implementation plans. + + READ-ONLY: Cannot modify files - uses only exploration tools. + """ + + name = "plan" + description = "Software architect for designing implementation plans - explores codebase and creates step-by-step strategies" + + def __init__(self): + """Initialize the plan agent.""" + self._agent: Agent[PlanContext, str] | None = None + self._settings = get_settings() + + def _create_agent(self) -> Agent[PlanContext, str]: + """Create the PydanticAI agent with Ollama backend.""" + # Use sanitized Ollama provider to fix content: null issues + model = OpenAIModel( + model_name=self._settings.ollama_agent_model, + provider=get_ollama_provider(), + ) + + agent: Agent[PlanContext, str] = Agent( + model=model, + system_prompt=PLAN_SYSTEM_PROMPT, + deps_type=PlanContext, + output_type=str, + # Mistral Nemo settings: + # - temperature 0.3 (Nemo needs slightly higher than 0.0) + # - tool_choice "required" forces tool use + model_settings={ + "temperature": 0.3, + "extra_body": {"tool_choice": "required"}, + }, + ) + + # Register read-only tools + self._register_tools(agent) + + return agent + + def _register_tools(self, agent: Agent[PlanContext, str]) -> None: + """Register read-only exploration tools with the agent.""" + from src.domains.agents.plan.tools import register_plan_tools + register_plan_tools(agent) + + @logged() + async def run( + self, + prompt: str, + working_dir: str | None = None, + allowed_paths: list[str] | None = None, + **kwargs: Any + ) -> str: + """ + Run the plan agent to design an implementation strategy. + + Args: + prompt: Description of what to implement + working_dir: Working directory for exploration + allowed_paths: Restrict tool access to these paths + + Returns: + Implementation plan with steps and critical files + """ + ctx = PlanContext( + working_dir=working_dir or os.getcwd(), + allowed_paths=allowed_paths or self._settings.effective_allowed_paths, + timeout_seconds=self._settings.tool_timeout_seconds, + ) + + async with trace_span("plan_agent_run"): + try: + # Use run() not run_stream() - Ollama has bugs with streaming + tools + result = await self.agent.run(prompt, deps=ctx) + return result.output + except Exception as e: + logger.exception(f"Plan agent error: {e}") + raise + + async def run_stream( + self, + prompt: str, + working_dir: str | None = None, + allowed_paths: list[str] | None = None, + **kwargs: Any + ) -> AsyncIterator[str]: + """ + Run the plan agent with streaming output. + + Yields text chunks as they become available. + """ + ctx = PlanContext( + working_dir=working_dir or os.getcwd(), + allowed_paths=allowed_paths or self._settings.effective_allowed_paths, + timeout_seconds=self._settings.tool_timeout_seconds, + ) + + async with trace_span("plan_agent_stream"): + try: + async with self.agent.run_stream(prompt, deps=ctx) as result: + async for chunk in result.stream_text(): + yield chunk + except Exception as e: + logger.exception(f"Plan agent stream error: {e}") + raise + + +# Create and register the singleton instance +plan_agent = PlanAgentImpl() +register_agent(plan_agent) + + +async def plan( + prompt: str, + working_dir: str | None = None, + **kwargs: Any +) -> str: + """Run planning query.""" + return await plan_agent.run(prompt, working_dir=working_dir, **kwargs) + + +async def plan_stream( + prompt: str, + working_dir: str | None = None, + **kwargs: Any +) -> AsyncIterator[str]: + """Run planning query with streaming.""" + async for chunk in plan_agent.run_stream(prompt, working_dir=working_dir, **kwargs): + yield chunk diff --git a/webber-api/src/domains/agents/plan/prompts.py b/webber-api/src/domains/agents/plan/prompts.py new file mode 100644 index 0000000..d48ed9a --- /dev/null +++ b/webber-api/src/domains/agents/plan/prompts.py @@ -0,0 +1,63 @@ +""" +System prompts for the Plan agent. + +The Plan agent is a READ-ONLY software architect that explores codebases +and designs implementation plans without modifying any files. +""" + +PLAN_SYSTEM_PROMPT = """You are a software architect and planning specialist. + +Your role is to explore codebases and design implementation plans. + +CRITICAL: You are READ-ONLY. You CANNOT modify any files. + +AVAILABLE TOOLS: +- glob_files: Find files by pattern +- read_file: Read file contents +- grep_content: Search code with regex +- bash_readonly: Run read-only commands (ls, git status, git log, etc.) + +WORKFLOW: +1. Understand the requirements +2. Explore the codebase to find relevant patterns and conventions +3. Design an implementation approach +4. Create a step-by-step plan with specific files and changes + +TOOL CALL EXAMPLES (follow exactly): + +To find Python files: + Call glob_files with pattern="**/*.py" + +To find a specific file: + Call glob_files with pattern="**/config.py" + +To read a file: + Call read_file with file_path="/absolute/path/to/file.py" + +To search for code patterns: + Call grep_content with pattern="class.*Controller" + +To check git history: + Call bash_readonly with command="git log --oneline -10" + +OUTPUT FORMAT: +End your response with: + +### Implementation Steps +1. [First step with specific file and changes] +2. [Second step...] +3. [Continue...] + +### Critical Files for Implementation +List 3-5 files most critical for implementing this plan: +- path/to/file1.py - [Brief reason: e.g., "Core logic to modify"] +- path/to/file2.py - [Brief reason: e.g., "Pattern to follow"] + +RULES: +- ALWAYS use tools first, then analyze results +- Follow existing patterns in the codebase +- Consider trade-offs and alternatives +- Identify dependencies and sequencing +- Never guess - verify with tools +- Provide specific file paths and code locations +""" diff --git a/webber-api/src/domains/agents/plan/tools.py b/webber-api/src/domains/agents/plan/tools.py new file mode 100644 index 0000000..b1ede1c --- /dev/null +++ b/webber-api/src/domains/agents/plan/tools.py @@ -0,0 +1,170 @@ +""" +Tool registrations for the Plan agent. + +The Plan agent only has access to READ-ONLY tools. +It cannot modify files - only explore and analyze. +""" +from pydantic_ai import Agent, RunContext + +from src.domains.agents.base import AgentContext +from src.domains.tools.file.read import ReadFileTool +from src.domains.tools.file.glob import GlobFilesTool +from src.domains.tools.search.grep import GrepContentTool +from src.domains.tools.shell.bash import BashReadOnlyTool + + +def register_plan_tools(agent: Agent[AgentContext, str]) -> None: + """ + Register read-only exploration tools with the Plan agent. + + The Plan agent is restricted to read-only tools: + - read_file: Read file contents + - glob_files: Find files by pattern + - grep_content: Search file contents + - bash_readonly: Read-only shell commands + + Write tools (edit_file, write_file, bash) are NOT available. + """ + + @agent.tool + async def read_file( + ctx: RunContext[AgentContext], + file_path: str, + offset: int = 0, + limit: int = 2000 + ) -> str: + """Read contents of a file with line numbers. + + Args: + file_path: Absolute path to the file to read + offset: Line number to start from (0-based, default: 0) + limit: Maximum number of lines to read (default: 2000) + + Returns: + File contents with line numbers, or error message. + + IMPORTANT: Always use absolute paths. Use this to understand existing code. + """ + tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths) + result = await tool.execute( + file_path=file_path, + offset=offset, + limit=limit + ) + return result.to_string() + + @agent.tool + async def glob_files( + ctx: RunContext[AgentContext], + pattern: str, + path: str | None = None, + limit: int = 100 + ) -> str: + """Find files matching a glob pattern. + + Args: + pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md") + path: Directory to search in (default: working directory) + limit: Maximum number of files to return (default: 100) + + Returns: + List of absolute file paths, sorted by modification time (newest first). + + Examples: + - "**/*.py" finds all Python files + - "src/**/*.ts" finds TypeScript files in src/ + - "**/test_*.py" finds all test files + + IMPORTANT: Use this to discover files before reading them. + """ + tool = GlobFilesTool(allowed_paths=ctx.deps.allowed_paths) + search_path = path or ctx.deps.working_dir + result = await tool.execute( + pattern=pattern, + path=search_path, + limit=limit + ) + return result.to_string() + + @agent.tool + async def grep_content( + ctx: RunContext[AgentContext], + pattern: str, + path: str | None = None, + file_glob: str | None = None, + context_lines: int = 0, + case_sensitive: bool = True + ) -> str: + """Search file contents using regex pattern. + + Args: + pattern: Regex pattern to search for (Python re syntax) + path: Directory or file to search (default: working directory) + file_glob: Filter files by glob (e.g., "*.py", "*.ts") + context_lines: Lines of context before/after matches (default: 0) + case_sensitive: Case-sensitive search (default: True) + + Returns: + Matching lines with file paths and line numbers. + Format: "filepath:line_num: content" + + Examples: + - pattern="def.*__init__" finds init methods + - pattern="class\\s+\\w+" finds class definitions + - pattern="TODO|FIXME" finds todo comments + + IMPORTANT: Use this to find code patterns and implementations. + """ + tool = GrepContentTool(allowed_paths=ctx.deps.allowed_paths) + search_path = path or ctx.deps.working_dir + result = await tool.execute( + pattern=pattern, + path=search_path, + file_glob=file_glob, + context_lines=context_lines, + case_sensitive=case_sensitive + ) + return result.to_string() + + @agent.tool + async def bash_readonly( + ctx: RunContext[AgentContext], + command: str, + cwd: str | None = None, + timeout: int = 30 + ) -> str: + """Execute a read-only bash command. + + ALLOWED commands: + - File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du + - Git (read-only): git status, git log, git diff, git show, git branch + - Text processing: grep, awk, sed (read-only), sort, uniq + - System info: pwd, whoami, hostname, which + + FORBIDDEN: + - File modification (rm, mv, cp, mkdir, touch) + - Redirects (>, >>) + - Command chaining (&&, ||, ;) + - Network (curl, wget) + + Args: + command: The bash command to execute + cwd: Working directory (default: agent working directory) + timeout: Timeout in seconds (default: 30) + + Returns: + Command output or error message. + + Examples: + - "ls -la" lists files with details + - "git status" shows git status + - "git log --oneline -10" shows recent commits + """ + tool = BashReadOnlyTool(allowed_paths=ctx.deps.allowed_paths) + working_dir = cwd or ctx.deps.working_dir + result = await tool.execute( + command=command, + cwd=working_dir, + timeout=min(timeout, ctx.deps.timeout_seconds) + ) + return result.to_string() diff --git a/webber-api/src/domains/agents/router.py b/webber-api/src/domains/agents/router.py index f32fd3b..1785f5d 100644 --- a/webber-api/src/domains/agents/router.py +++ b/webber-api/src/domains/agents/router.py @@ -9,6 +9,7 @@ from src.domains.agents.base import get_agent, list_agents # Import agents to ensure they're registered import src.domains.agents.explore # noqa: F401 +import src.domains.agents.plan # noqa: F401 from src.domains.agents.schemas import ( AgentRunRequest, AgentRunResponse, diff --git a/webber-api/tests/test_plan_agent.py b/webber-api/tests/test_plan_agent.py new file mode 100644 index 0000000..a39cbd0 --- /dev/null +++ b/webber-api/tests/test_plan_agent.py @@ -0,0 +1,152 @@ +""" +Tests for the Plan agent. + +Tests registration, API endpoints, and tool restrictions. +""" +import pytest + +from src.domains.agents.base import get_agent, list_agents +from src.domains.agents.plan import plan_agent, PlanAgentImpl + + +class TestPlanAgentRegistration: + """Tests for Plan agent registration.""" + + def test_plan_agent_registered(self): + """Test that plan agent is registered in registry.""" + agent = get_agent("plan") + assert agent is not None + assert agent.name == "plan" + + def test_plan_agent_in_list(self): + """Test that plan agent appears in agent list.""" + agents = list_agents() + names = [a["name"] for a in agents] + assert "plan" in names + + def test_plan_agent_has_description(self): + """Test that plan agent has a description.""" + agent = get_agent("plan") + assert agent is not None + assert len(agent.description) > 0 + assert "plan" in agent.description.lower() or "architect" in agent.description.lower() + + def test_plan_agent_singleton(self): + """Test that plan_agent is the registered instance.""" + registered = get_agent("plan") + assert registered is plan_agent + + def test_plan_agent_is_correct_type(self): + """Test that plan agent is correct implementation type.""" + assert isinstance(plan_agent, PlanAgentImpl) + + +class TestPlanAgentTools: + """Tests for Plan agent tool restrictions.""" + + def test_plan_agent_has_read_only_tools(self): + """Test that plan agent has read-only tools.""" + # Access the underlying PydanticAI agent to check tools + agent = plan_agent.agent + tool_names = list(agent._function_toolset.tools.keys()) + + # Should have read-only tools + assert "read_file" in tool_names + assert "glob_files" in tool_names + assert "grep_content" in tool_names + assert "bash_readonly" in tool_names + + def test_plan_agent_no_write_tools(self): + """Test that plan agent does NOT have write tools.""" + agent = plan_agent.agent + tool_names = list(agent._function_toolset.tools.keys()) + + # Should NOT have write tools + assert "edit_file" not in tool_names + assert "write_file" not in tool_names + assert "bash" not in tool_names + assert "web_search" not in tool_names + + def test_plan_agent_tool_count(self): + """Test that plan agent has exactly 4 tools.""" + agent = plan_agent.agent + tool_count = len(agent._function_toolset.tools) + assert tool_count == 4 + + +class TestPlanAgentAPI: + """Tests for Plan agent REST API.""" + + @pytest.mark.anyio + async def test_list_agents_includes_plan(self, auth_client): + """Test that agent list includes plan agent.""" + response = await auth_client.get("/agents/") + + assert response.status_code == 200 + data = response.json() + names = [a["name"] for a in data["agents"]] + assert "plan" in names + + @pytest.mark.anyio + async def test_get_plan_agent_info(self, auth_client): + """Test getting plan agent info.""" + response = await auth_client.get("/agents/plan") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "plan" + assert "description" in data + assert len(data["description"]) > 0 + + @pytest.mark.anyio + async def test_run_plan_with_invalid_body(self, auth_client): + """Test running plan agent with invalid request.""" + response = await auth_client.post( + "/agents/run", + json={ + "agent_type": "plan", + # Missing prompt + } + ) + + assert response.status_code == 422 + + @pytest.mark.anyio + async def test_stream_plan_with_invalid_body(self, auth_client): + """Test streaming plan agent with invalid request.""" + response = await auth_client.post( + "/agents/stream", + json={ + "agent_type": "plan", + # Missing prompt + } + ) + + assert response.status_code == 422 + + +class TestPlanAgentProperties: + """Tests for Plan agent properties and configuration.""" + + def test_plan_agent_name(self): + """Test plan agent name property.""" + assert plan_agent.name == "plan" + + def test_plan_agent_description_not_empty(self): + """Test plan agent description is not empty.""" + assert plan_agent.description + assert len(plan_agent.description) > 10 + + def test_plan_agent_creates_agent_lazily(self): + """Test that PydanticAI agent is created lazily.""" + # Create a fresh instance + fresh_agent = PlanAgentImpl() + + # _agent should be None before first access + assert fresh_agent._agent is None + + # Access the agent property + _ = fresh_agent.agent + + # Now _agent should be set + assert fresh_agent._agent is not None