feat: add Plan Agent for implementation planning
Build and Push API / release (push) Successful in 5s
Build and Push API / build (push) Successful in 1m15s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 19:57:30 +01:00
co-authored by Claude Opus 4.5
parent 617ff61347
commit b5b2346db5
9 changed files with 621 additions and 3 deletions
@@ -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",
]
+168
View File
@@ -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
@@ -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
"""
+170
View File
@@ -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()
+1
View File
@@ -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,