Skip to content

Commit f394d92

Browse files
tbitcsoz-agent
andcommitted
feat: Phase 2 — AG2 agent shell (Planner/Builder/Verifier over Ollama)
New package: src/specsmith/agents/ - agents/config.py: AgentConfig loaded from scaffold.yml agents: key - agents/tools/filesystem.py: read, write, patch, list_tree, search (pathlib) - agents/tools/shell.py: run_project_command with structured output - agents/tools/git.py: status, diff, changed_files, branch_info - agents/tools/tests.py: run_unit_tests, summarize_failures - agents/roles.py: Planner/Builder/Verifier with AG2 ConversableAgent - agents/cli.py: specsmith agent run/plan/status/verify commands - cli.py: wire agent command group into main CLI Dependencies: ag2[ollama] added as optional extra in pyproject.toml AG2 uses native Ollama tool calling, hide_tools=if_any_run Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent ec32d9d commit f394d92

11 files changed

Lines changed: 873 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ openai = ["openai>=1.0"]
6565
gemini = ["google-genai>=1.0"]
6666
mistral = ["openai>=1.0"] # Mistral uses the openai SDK pointed at api.mistral.ai
6767
gui = ["PySide6>=6.6"]
68+
# AG2 agent shell (Planner/Builder/Verifier over Ollama)
69+
ag2 = ["ag2[ollama]"]
6870
# Install all optional LLM providers
6971
agent = ["anthropic>=0.56", "openai>=1.0"]
7072
# Convenience bundle: everything

src/specsmith/agents/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3+
"""specsmith AG2 agent shell — Planner/Builder/Verifier over Ollama."""
4+
5+
from specsmith.agents.config import AgentConfig, load_agent_config
6+
7+
__all__ = ["AgentConfig", "load_agent_config"]

src/specsmith/agents/cli.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3+
"""CLI commands for the AG2 agent shell.
4+
5+
Wired into the main specsmith CLI as the ``agent`` command group.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
import click
13+
from rich.console import Console
14+
15+
console = Console()
16+
17+
18+
@click.group()
19+
def agent() -> None:
20+
"""AG2 agent shell — Planner/Builder/Verifier over Ollama."""
21+
22+
23+
@agent.command()
24+
@click.argument("task")
25+
@click.option("--project-dir", default=".", help="Project root directory.")
26+
@click.option("--max-turns", default=6, help="Maximum conversation turns per agent.")
27+
def run(task: str, project_dir: str, max_turns: int) -> None:
28+
"""Execute a task through the full Planner → Builder → Verifier pipeline."""
29+
try:
30+
from autogen import ConversableAgent # noqa: F401 — verify AG2 is installed
31+
except ImportError:
32+
console.print("[red]AG2 is not installed.[/red] Run: pip install ag2[ollama]")
33+
raise SystemExit(1) # noqa: B904
34+
35+
from specsmith.agents.config import load_agent_config
36+
from specsmith.agents.roles import create_builder, create_planner, create_verifier
37+
38+
project_dir = str(Path(project_dir).resolve())
39+
config = load_agent_config(project_dir)
40+
41+
console.print(
42+
f"\n[bold cyan]specsmith agent shell[/bold cyan] — {config.primary_model} via Ollama"
43+
)
44+
console.print(f"Task: [bold]{task}[/bold]\n")
45+
46+
# Phase 1: Plan
47+
console.print("[dim]─── Phase 1: Planning ───[/dim]")
48+
planner = create_planner(config, project_dir)
49+
plan_result = planner.run(message=f"Plan this task:\n{task}", max_turns=max_turns)
50+
plan_result.process()
51+
52+
plan_text = ""
53+
for msg in plan_result.messages:
54+
if msg.get("role") == "assistant" and msg.get("content"):
55+
plan_text = msg["content"]
56+
57+
if not plan_text:
58+
console.print("[yellow]Planner produced no output.[/yellow]")
59+
return
60+
61+
# Phase 2: Build
62+
console.print("\n[dim]─── Phase 2: Building ───[/dim]")
63+
builder = create_builder(config, project_dir)
64+
build_result = builder.run(
65+
message=f"Execute this plan:\n\n{plan_text}",
66+
max_turns=max_turns,
67+
)
68+
build_result.process()
69+
70+
build_text = ""
71+
for msg in build_result.messages:
72+
if msg.get("role") == "assistant" and msg.get("content"):
73+
build_text = msg["content"]
74+
75+
# Phase 3: Verify
76+
console.print("\n[dim]─── Phase 3: Verifying ───[/dim]")
77+
verifier = create_verifier(config, project_dir)
78+
verify_result = verifier.run(
79+
message=(
80+
f"Verify the following changes:\n\n{build_text}"
81+
"\n\nRun the relevant tests and report ACCEPT or REJECT."
82+
),
83+
max_turns=max_turns,
84+
)
85+
verify_result.process()
86+
87+
# Summary
88+
console.print("\n[bold cyan]─── Done ───[/bold cyan]")
89+
for msg in verify_result.messages:
90+
if msg.get("role") == "assistant" and msg.get("content"):
91+
content = msg["content"]
92+
if "ACCEPT" in content.upper():
93+
console.print("[bold green]✓ ACCEPTED[/bold green]")
94+
elif "REJECT" in content.upper():
95+
console.print("[bold red]✗ REJECTED[/bold red]")
96+
break
97+
98+
99+
@agent.command()
100+
@click.argument("task")
101+
@click.option("--project-dir", default=".", help="Project root directory.")
102+
@click.option("--max-turns", default=6, help="Maximum conversation turns.")
103+
def plan(task: str, project_dir: str, max_turns: int) -> None:
104+
"""Generate a plan without executing it."""
105+
try:
106+
from autogen import ConversableAgent # noqa: F401
107+
except ImportError:
108+
console.print("[red]AG2 is not installed.[/red] Run: pip install ag2[ollama]")
109+
raise SystemExit(1) # noqa: B904
110+
111+
from specsmith.agents.config import load_agent_config
112+
from specsmith.agents.roles import create_planner
113+
114+
project_dir = str(Path(project_dir).resolve())
115+
config = load_agent_config(project_dir)
116+
117+
console.print(f"\n[bold cyan]specsmith agent plan[/bold cyan] — {config.primary_model}")
118+
planner = create_planner(config, project_dir)
119+
result = planner.run(message=f"Plan this task:\n{task}", max_turns=max_turns)
120+
result.process()
121+
122+
123+
@agent.command()
124+
@click.option("--project-dir", default=".", help="Project root directory.")
125+
def status(project_dir: str) -> None:
126+
"""Show agent configuration and Ollama status."""
127+
from specsmith.agents.config import load_agent_config
128+
129+
config = load_agent_config(project_dir)
130+
console.print("[bold]Agent Configuration[/bold]")
131+
console.print(f" Primary model: {config.primary_model}")
132+
console.print(f" Utility model: {config.utility_model}")
133+
console.print(f" Ollama URL: {config.ollama_base_url}")
134+
console.print(f" Max iterations: {config.max_iterations}")
135+
console.print(f" Context length: {config.num_ctx}")
136+
console.print(f" Tools enabled: {', '.join(config.tools_enabled)}")
137+
138+
# Check Ollama
139+
try:
140+
from specsmith.agent.providers.ollama import OllamaProvider
141+
142+
p = OllamaProvider(base_url=config.ollama_base_url)
143+
if p.is_available():
144+
console.print(" Ollama: [green]running[/green]")
145+
else:
146+
console.print(" Ollama: [red]not available[/red]")
147+
except Exception: # noqa: BLE001
148+
console.print(" Ollama: [yellow]unknown[/yellow]")
149+
150+
151+
@agent.command()
152+
@click.option("--project-dir", default=".", help="Project root directory.")
153+
@click.option("--max-turns", default=4, help="Maximum conversation turns.")
154+
def verify(project_dir: str, max_turns: int) -> None:
155+
"""Run the Verifier agent on the current project state."""
156+
try:
157+
from autogen import ConversableAgent # noqa: F401
158+
except ImportError:
159+
console.print("[red]AG2 is not installed.[/red] Run: pip install ag2[ollama]")
160+
raise SystemExit(1) # noqa: B904
161+
162+
from specsmith.agents.config import load_agent_config
163+
from specsmith.agents.roles import create_verifier
164+
165+
project_dir = str(Path(project_dir).resolve())
166+
config = load_agent_config(project_dir)
167+
168+
console.print(f"\n[bold cyan]specsmith agent verify[/bold cyan] — {config.utility_model}")
169+
verifier = create_verifier(config, project_dir)
170+
result = verifier.run(
171+
message=(
172+
"Run the full test suite and report the current project health."
173+
" Report ACCEPT or REJECT."
174+
),
175+
max_turns=max_turns,
176+
)
177+
result.process()

src/specsmith/agents/config.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3+
"""Agent configuration — loaded from scaffold.yml ``agents:`` key."""
4+
5+
from __future__ import annotations
6+
7+
from dataclasses import dataclass, field
8+
from pathlib import Path
9+
from typing import Any
10+
11+
12+
@dataclass
13+
class AgentConfig:
14+
"""Configuration for the AG2 agent shell."""
15+
16+
primary_model: str = "qwen2.5:14b"
17+
utility_model: str = "qwen2.5:7b"
18+
ollama_base_url: str = "http://localhost:11434"
19+
max_iterations: int = 10
20+
stream: bool = False
21+
num_ctx: int = 4096
22+
tools_enabled: list[str] = field(
23+
default_factory=lambda: ["filesystem", "shell", "git", "tests", "docs"]
24+
)
25+
26+
def llm_config_dict(self, model: str | None = None) -> dict[str, Any]:
27+
"""Return an AG2-compatible LLM config dict for Ollama."""
28+
return {
29+
"model": model or self.primary_model,
30+
"api_type": "ollama",
31+
"client_host": self.ollama_base_url,
32+
"stream": self.stream,
33+
"num_ctx": self.num_ctx,
34+
"native_tool_calls": True,
35+
"hide_tools": "if_any_run",
36+
}
37+
38+
39+
def load_agent_config(project_dir: str | Path) -> AgentConfig:
40+
"""Load agent config from scaffold.yml ``agents:`` section.
41+
42+
Falls back to defaults if scaffold.yml doesn't exist or has no agents key.
43+
"""
44+
scaffold_path = Path(project_dir) / "scaffold.yml"
45+
if not scaffold_path.exists():
46+
return AgentConfig()
47+
48+
try:
49+
import yaml
50+
51+
with open(scaffold_path, encoding="utf-8") as f:
52+
raw = yaml.safe_load(f) or {}
53+
agents_raw = raw.get("agents", {}) or {}
54+
return AgentConfig(
55+
primary_model=agents_raw.get("primary_model", AgentConfig.primary_model),
56+
utility_model=agents_raw.get("utility_model", AgentConfig.utility_model),
57+
ollama_base_url=agents_raw.get("ollama_base_url", AgentConfig.ollama_base_url),
58+
max_iterations=agents_raw.get("max_iterations", AgentConfig.max_iterations),
59+
stream=agents_raw.get("stream", AgentConfig.stream),
60+
num_ctx=agents_raw.get("num_ctx", AgentConfig.num_ctx),
61+
tools_enabled=agents_raw.get("tools_enabled", AgentConfig().tools_enabled),
62+
)
63+
except Exception: # noqa: BLE001
64+
return AgentConfig()

0 commit comments

Comments
 (0)