3 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 b5b2346db5 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>
2026-01-11 19:57:30 +01:00
jpmschweitzerandClaude Opus 4.5 617ff61347 chore: release api v0.3.2
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:43:08 +01:00
jpmschweitzerandClaude Opus 4.5 8609181447 docs: add mandatory release procedure to AGENTS.md
Document the correct order for creating releases:
1. Update pyproject.toml version
2. Update CHANGELOG.md
3. Commit version bump
4. Create tag
5. Push with --tags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:37:20 +01:00
10 changed files with 643 additions and 10 deletions
+17 -7
View File
@@ -184,18 +184,28 @@ cat ../webber-sandbox/TASKS.md
## Versioning & Releases
Uses prefixed tags:
- `api/v0.3.0` → Triggers API Docker build
- `cli/v0.1.0` → Triggers CLI build (future)
- `api/vX.Y.Z` → Triggers API Docker build
- `cli/vX.Y.Z` → Triggers CLI build (future)
### MANDATORY Release Procedure
**NEVER push a tag before updating version files.** Follow this exact order:
```bash
# API release
cd webber-api
# Update version in pyproject.toml
git add -A && git commit -m "chore: release api v0.3.0"
git tag api/v0.3.0
# 1. Update version in pyproject.toml
# 2. Update CHANGELOG.md with release notes
# 3. Commit the version bump
git add -A && git commit -m "chore: release api vX.Y.Z"
# 4. Create the tag (AFTER the commit)
git tag api/vX.Y.Z
# 5. Push everything together
git push origin main --tags
```
**Why this matters:** Pushing a tag before the version commit requires deleting and recreating the tag, which can trigger CI/CD pipelines prematurely and cause deployment issues.
---
## Troubleshooting
+17
View File
@@ -7,6 +7,23 @@ 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
- Mandatory release procedure documentation in AGENTS.md
## [0.3.1] - 2026-01-11
### Added
+24 -2
View File
@@ -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" \
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "webber-api"
version = "0.3.1"
version = "0.3.3"
description = "Webber API - Multi-Agent AI Development Server"
authors = [
{name = "jpmschweitzer"}
@@ -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,
+152
View File
@@ -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