5bcfafcbaf
- Mobile: add push notification and calendar sync services with full test suite (calendar, eventStore, notifications, screens) - Mobile: add EAS build config and Jest setup - Web: add API proxy, update useDestinationStation/useJourneys hooks, add middleware tests - Web: update next.config and rebuild - Agent: add Gemma4-based agent loop (agent_base, ttl_agent, ts_agent) - Docs: add privacy policy, post-MVP plan, and aider rules
1479 lines
59 KiB
Python
1479 lines
59 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import requests
|
|
|
|
# Strips <think>...</think> blocks that Gemma 4 may emit inline in the content
|
|
# field rather than in the dedicated thinking field, depending on Ollama version.
|
|
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
WRITER_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"summary": {"type": "string"},
|
|
"files": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
"required": ["path", "content"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
"tests": {"type": "array", "items": {"type": "string"}},
|
|
"notes": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": ["summary", "files", "tests", "notes"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
# Unified review schema — used by both the inline reviewer and as the canonical
|
|
# definition for any external tooling that consumes agent state files.
|
|
REVIEW_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"verdict": {"type": "string", "enum": ["approve", "needs_changes"]},
|
|
"summary": {"type": "string"},
|
|
"critical_issues": {"type": "array", "items": {"type": "string"}},
|
|
"important_improvements": {"type": "array", "items": {"type": "string"}},
|
|
"preserve": {"type": "array", "items": {"type": "string"}},
|
|
"rewrite_strategy": {"type": "array", "items": {"type": "string"}},
|
|
"missing_files": {"type": "array", "items": {"type": "string"}},
|
|
"test_gaps": {"type": "array", "items": {"type": "string"}},
|
|
"file_comments": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string"}, "comment": {"type": "string"}},
|
|
"required": ["path", "comment"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
"required": [
|
|
"verdict", "summary", "critical_issues", "important_improvements",
|
|
"preserve", "rewrite_strategy", "missing_files", "test_gaps", "file_comments",
|
|
],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
DESIGN_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"summary": {"type": "string"},
|
|
"files": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string"}, "purpose": {"type": "string"}},
|
|
"required": ["path", "purpose"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
"responsibilities": {"type": "array", "items": {"type": "string"}},
|
|
"interfaces": {"type": "array", "items": {"type": "string"}},
|
|
"integration_points": {"type": "array", "items": {"type": "string"}},
|
|
"dependencies": {"type": "array", "items": {"type": "string"}},
|
|
"testing_plan": {"type": "array", "items": {"type": "string"}},
|
|
"risks": {"type": "array", "items": {"type": "string"}},
|
|
"assumptions": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": [
|
|
"summary", "files", "responsibilities", "interfaces", "integration_points",
|
|
"dependencies", "testing_plan", "risks", "assumptions",
|
|
],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Config:
|
|
api_base: str
|
|
writer_model: str
|
|
reviewer_model: str
|
|
design_model: str
|
|
workspace: Path
|
|
outputs: Path
|
|
task: str
|
|
max_review_loops: int
|
|
max_context_chars: int
|
|
max_files: int
|
|
language: str
|
|
writer_system_prompt: str
|
|
reviewer_system_prompt: str
|
|
design_system_prompt: str
|
|
log_dir: Path
|
|
redesign_after: int # trigger design revision after this many consecutive failed loops (0 = disabled)
|
|
per_file_write: bool = True # write one file per pass instead of all files at once
|
|
# Write approved files directly into workspace instead of a separate output dir.
|
|
# Also marks the ✔️ column in CHECKLIST.md after a reviewer approval.
|
|
write_to_workspace: bool = False
|
|
# Keep running until all ✅ boxes in CHECKLIST.md are checked. Implies write_to_workspace.
|
|
run_until_done: bool = False
|
|
# Maximum seconds of silence before a stream is considered hung.
|
|
# Must be longer than the worst-case prefill time (prompt processing before
|
|
# the first token arrives). For a 32B model + large context this can be
|
|
# 5-10 minutes. Default: 600s. Between-token gaps in practice are <1s.
|
|
read_timeout: int = 600
|
|
# Context chars passed to each individual file write. Smaller than
|
|
# max_context_chars to keep per-file prompts short and reduce prefill time.
|
|
per_file_context_chars: int = 40_000
|
|
# Writer implementation. "ollama" keeps the historical JSON full-file
|
|
# writer; "aider" lets the Aider CLI edit the workspace and then captures
|
|
# changed files back into the existing review loop.
|
|
writer_backend: str = "ollama"
|
|
aider_command: str = "aider"
|
|
aider_model: str = ""
|
|
aider_extra_args: str = ""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ContextVar instead of a plain module global so that concurrent runs in
|
|
# different threads or async tasks each maintain their own log file pointer.
|
|
_LOG_FILE: ContextVar[Optional[Path]] = ContextVar("_LOG_FILE", default=None)
|
|
|
|
|
|
def _set_log_file(path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
_LOG_FILE.set(path)
|
|
|
|
|
|
def _emit(text: str) -> None:
|
|
print(text, flush=True)
|
|
lf = _LOG_FILE.get()
|
|
if lf is not None:
|
|
with lf.open("a", encoding="utf-8") as f:
|
|
f.write(text + "\n")
|
|
|
|
|
|
def _log_header(title: str) -> None:
|
|
_emit(f"\n=== {title} ===")
|
|
|
|
|
|
def _log_kv(key: str, value: str) -> None:
|
|
_emit(f"{key}: {value}")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _now_ts() -> float:
|
|
return time.time()
|
|
|
|
|
|
def _fmt_elapsed(seconds: float) -> str:
|
|
if seconds < 1:
|
|
return f"{seconds:.2f}s"
|
|
if seconds < 60:
|
|
return f"{seconds:.1f}s"
|
|
minutes = int(seconds // 60)
|
|
rem = seconds % 60
|
|
return f"{minutes}m {rem:.1f}s"
|
|
|
|
|
|
def _truncate(text: str, limit: int = 4000) -> str:
|
|
text = (text or "").strip()
|
|
if len(text) <= limit:
|
|
return text
|
|
return text[:limit] + "\n...[truncated]..."
|
|
|
|
|
|
def _extract_json(text: str) -> str:
|
|
t = text.strip()
|
|
start = t.find("{")
|
|
if start == -1:
|
|
return t
|
|
depth = 0
|
|
for i, ch in enumerate(t[start:], start):
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return t[start : i + 1]
|
|
return t[start:]
|
|
|
|
|
|
def _pretty_json_or_text(text: str) -> str:
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return text
|
|
try:
|
|
obj = json.loads(_extract_json(text))
|
|
return json.dumps(obj, ensure_ascii=False, indent=2)
|
|
except Exception:
|
|
return text
|
|
|
|
|
|
def _log_model_output(
|
|
label: str, model: str, content: str, elapsed: float, iteration: int | None = None
|
|
) -> None:
|
|
_log_header(label)
|
|
if iteration is not None:
|
|
_log_kv("Iteration", str(iteration))
|
|
_log_kv("Model", model)
|
|
_log_kv("Elapsed", _fmt_elapsed(elapsed))
|
|
_emit("--- model output ---")
|
|
_emit(_truncate(_pretty_json_or_text(content), limit=4000))
|
|
_emit("--------------------")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workspace reader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SKIP_SUFFIXES = frozenset({
|
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf",
|
|
".zip", ".tar", ".gz", ".db", ".sqlite", ".lock",
|
|
".map", ".pyc", ".pyo", ".tsbuildinfo",
|
|
})
|
|
|
|
# Directory names that are never useful context for a coding agent.
|
|
_SKIP_DIRS = frozenset({
|
|
"node_modules", ".next", ".git", "__pycache__",
|
|
".idea", ".vscode", "dist", "build", "out", "coverage",
|
|
"logs", "runs", "agent_loop",
|
|
})
|
|
|
|
# Specific filenames to exclude regardless of location.
|
|
_SKIP_FILENAMES = frozenset({
|
|
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
})
|
|
|
|
|
|
def _read_context(workspace: Path, max_context_chars: int, max_files: int) -> List[Dict[str, str]]:
|
|
files: List[Dict[str, str]] = []
|
|
total = 0
|
|
for path in sorted(workspace.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
if any(part in _SKIP_DIRS for part in path.parts):
|
|
continue
|
|
if path.suffix.lower() in _SKIP_SUFFIXES:
|
|
continue
|
|
if path.name in _SKIP_FILENAMES:
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
remaining = max_context_chars - total
|
|
if remaining <= 0:
|
|
_emit(f"[warn] context truncated at {max_context_chars} chars after {len(files)} files")
|
|
break
|
|
if len(text) > remaining:
|
|
text = text[:remaining]
|
|
_emit(f"[warn] truncated {path.name} to fit context limit")
|
|
total += len(text)
|
|
files.append({"name": str(path.relative_to(workspace)), "content": text})
|
|
if len(files) >= max_files:
|
|
_emit(f"[warn] file limit reached ({max_files} files); remaining files excluded")
|
|
break
|
|
if total >= max_context_chars:
|
|
_emit(f"[warn] context truncated at {max_context_chars} chars after {len(files)} files")
|
|
break
|
|
return files
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ollama chat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _chat(
|
|
api_base: str,
|
|
model: str,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
schema: Dict[str, Any],
|
|
temperature: float,
|
|
allow_empty: bool = False,
|
|
log_label: str = "Model call",
|
|
iteration: int | None = None,
|
|
read_timeout: int = 600,
|
|
) -> str:
|
|
"""Call the Ollama /api/chat endpoint using streaming.
|
|
|
|
Streaming is essential for long generations (large source files, reasoning
|
|
chains). With stream=False the HTTP connection sits idle waiting for the
|
|
entire response and hits the read-timeout long before the model finishes.
|
|
With streaming each token keeps the connection alive, so generation can
|
|
take arbitrarily long without timing out.
|
|
|
|
connect_timeout=30 — fail fast if Ollama is unreachable
|
|
read_timeout=600 (default) — maximum silence before the FIRST token
|
|
(covers model load + prefill time) and
|
|
between subsequent tokens. For a 32B model
|
|
with a large prompt, prefill alone can take
|
|
5-10 minutes. Between-token gaps in
|
|
practice are <1 second.
|
|
"""
|
|
start = _now_ts()
|
|
url = f"{api_base.rstrip('/')}/api/chat"
|
|
|
|
base_options: Dict[str, Any] = {
|
|
"temperature": temperature,
|
|
# Gemma 4 recommended sampling settings (harmless on other models).
|
|
"top_p": 0.95,
|
|
"top_k": 64,
|
|
# Unlimited output tokens. Without this Ollama defaults to ~2048
|
|
# tokens which is far too small for complete source files.
|
|
"num_predict": -1,
|
|
}
|
|
|
|
def _stream_chat(fmt: Any) -> str:
|
|
"""Stream a /api/chat request and return the concatenated content."""
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
"format": fmt,
|
|
"options": base_options,
|
|
"stream": True,
|
|
}
|
|
content_chunks: list[str] = []
|
|
thinking_chunks: list[str] = []
|
|
|
|
# connect_timeout=30; read_timeout covers both prefill silence and
|
|
# between-token gaps — must be large enough for worst-case prefill.
|
|
with requests.post(url, json=payload, stream=True, timeout=(30, read_timeout)) as resp:
|
|
if not resp.ok:
|
|
raise RuntimeError(
|
|
f"Ollama API error {resp.status_code}: {resp.text[:2000]}"
|
|
)
|
|
for raw_line in resp.iter_lines():
|
|
if not raw_line:
|
|
continue
|
|
try:
|
|
chunk = json.loads(raw_line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
msg = chunk.get("message", {}) or {}
|
|
delta_content = msg.get("content", "") or ""
|
|
delta_thinking = msg.get("thinking", "") or ""
|
|
if delta_content:
|
|
content_chunks.append(delta_content)
|
|
if delta_thinking:
|
|
thinking_chunks.append(delta_thinking)
|
|
if chunk.get("done"):
|
|
break
|
|
|
|
content = "".join(content_chunks).strip()
|
|
thinking = "".join(thinking_chunks).strip()
|
|
# Gemma 4 may emit <think> blocks inline in content rather than in the
|
|
# dedicated thinking field depending on Ollama version — strip them so
|
|
# raw reasoning never leaks into JSON output or multi-turn history.
|
|
content = _THINK_RE.sub("", content).strip()
|
|
return content if content else thinking
|
|
|
|
# ── Primary attempt: structured schema ───────────────────────────────
|
|
try:
|
|
text = _stream_chat(schema)
|
|
except RuntimeError as exc:
|
|
# Non-2xx from Ollama — fall through to unstructured retry
|
|
_emit(f"[warn] Primary stream failed: {exc} — retrying without schema")
|
|
text = ""
|
|
|
|
elapsed = _now_ts() - start
|
|
|
|
if text.strip():
|
|
_log_model_output(log_label, model, text, elapsed, iteration)
|
|
return text.strip()
|
|
|
|
# ── Fallback: unstructured JSON mode ──────────────────────────────────
|
|
# Some models produce empty content when schema enforcement is strict.
|
|
# Retry without the schema object, relying on the system prompt instead.
|
|
_emit(
|
|
f"[warn] {log_label}: primary response empty after {_fmt_elapsed(elapsed)} "
|
|
f"— retrying in unstructured JSON mode"
|
|
)
|
|
try:
|
|
fallback_system = system_prompt + "\n\nReturn valid JSON only. No extra text."
|
|
payload_fb = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": fallback_system},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
"format": "json",
|
|
"options": base_options,
|
|
"stream": True,
|
|
}
|
|
fb_chunks: list[str] = []
|
|
with requests.post(url, json=payload_fb, stream=True, timeout=(30, read_timeout)) as resp:
|
|
resp.raise_for_status()
|
|
for raw_line in resp.iter_lines():
|
|
if not raw_line:
|
|
continue
|
|
try:
|
|
chunk = json.loads(raw_line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
msg = chunk.get("message", {}) or {}
|
|
fb_chunks.append(msg.get("content", "") or "")
|
|
if chunk.get("done"):
|
|
break
|
|
text = "".join(fb_chunks).strip()
|
|
except Exception as exc:
|
|
raise RuntimeError(
|
|
f"Both primary and fallback streams failed for model {model}. "
|
|
f"Last error: {exc}"
|
|
) from exc
|
|
|
|
elapsed = _now_ts() - start
|
|
if not text and not allow_empty:
|
|
raise RuntimeError(
|
|
f"Model {model} returned empty content on both attempts "
|
|
f"after {_fmt_elapsed(elapsed)}. "
|
|
f"Check that Ollama is reachable at {url} and the model is loaded."
|
|
)
|
|
_log_model_output(f"{log_label} (fallback)", model, text, elapsed, iteration)
|
|
return text.strip()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Normalisation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _safe_json(raw: str, fallback: Dict[str, Any]) -> Dict[str, Any]:
|
|
try:
|
|
return json.loads(_extract_json(raw))
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def _fallback_review(reason: str, approve: bool = False) -> Dict[str, Any]:
|
|
# Safe default is needs_changes — never silently approve on error/timeout.
|
|
return {
|
|
"verdict": "approve" if approve else "needs_changes",
|
|
"summary": reason,
|
|
"critical_issues": [] if approve else [reason],
|
|
"important_improvements": [] if approve else ["Retry review manually if needed."],
|
|
"preserve": [],
|
|
"rewrite_strategy": [],
|
|
"missing_files": [],
|
|
"test_gaps": [],
|
|
"file_comments": [],
|
|
}
|
|
|
|
|
|
def _normalize_draft(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(data.get("files"), list):
|
|
data["files"] = []
|
|
if not isinstance(data.get("tests"), list):
|
|
data["tests"] = []
|
|
if not isinstance(data.get("notes"), list):
|
|
data["notes"] = []
|
|
if not isinstance(data.get("summary"), str):
|
|
data["summary"] = "No summary provided."
|
|
return data
|
|
|
|
|
|
def _normalize_design(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
for key in (
|
|
"files", "responsibilities", "interfaces", "integration_points",
|
|
"dependencies", "testing_plan", "risks", "assumptions",
|
|
):
|
|
if not isinstance(data.get(key), list):
|
|
data[key] = []
|
|
if not isinstance(data.get("summary"), str):
|
|
data["summary"] = "No design summary provided."
|
|
return data
|
|
|
|
|
|
def _normalize_review(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
if data.get("verdict") not in {"approve", "needs_changes"}:
|
|
data["verdict"] = "needs_changes"
|
|
for k in (
|
|
"critical_issues", "important_improvements", "preserve",
|
|
"rewrite_strategy", "missing_files", "test_gaps", "file_comments",
|
|
):
|
|
if not isinstance(data.get(k), list):
|
|
data[k] = []
|
|
if not isinstance(data.get("summary"), str):
|
|
data["summary"] = "Reviewer returned no summary."
|
|
return data
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# I/O helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _write_state(outputs: Path, stem: str, state: Dict[str, Any], suffix: str = "") -> Path:
|
|
state_dir = outputs / "coding-agent-state"
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
path = state_dir / f"{stem}{suffix}.json"
|
|
path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _write_files(root: Path, draft: Dict[str, Any]) -> Path:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
root_resolved = root.resolve()
|
|
for item in draft.get("files", []):
|
|
raw = item.get("path", "").strip()
|
|
parts = [p for p in Path(raw).parts if p not in ("..", ".", "") and p != "/"]
|
|
if not parts:
|
|
continue
|
|
dest = root / Path(*parts)
|
|
try:
|
|
if not dest.resolve().is_relative_to(root_resolved):
|
|
_emit(f"[warn] skipping unsafe path: {raw!r}")
|
|
continue
|
|
except Exception:
|
|
continue
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_text(item["content"], encoding="utf-8")
|
|
return root
|
|
|
|
|
|
def _snapshot_workspace_files(workspace: Path) -> Dict[str, str]:
|
|
"""Capture current text-file contents so Aider edits can be diffed safely."""
|
|
snapshot: Dict[str, str] = {}
|
|
for path in sorted(workspace.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
if any(part in _SKIP_DIRS for part in path.relative_to(workspace).parts):
|
|
continue
|
|
if path.suffix.lower() in _SKIP_SUFFIXES:
|
|
continue
|
|
if path.name in _SKIP_FILENAMES:
|
|
continue
|
|
try:
|
|
snapshot[str(path.relative_to(workspace))] = path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
return snapshot
|
|
|
|
|
|
def _changed_files_from_snapshots(before: Dict[str, str], after: Dict[str, str]) -> List[Dict[str, str]]:
|
|
changed: List[Dict[str, str]] = []
|
|
for path in sorted(after):
|
|
if before.get(path) != after[path]:
|
|
changed.append({"path": path, "content": after[path]})
|
|
return changed
|
|
|
|
|
|
def _merge_draft_files(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
|
|
merged: Dict[str, Dict[str, str]] = {
|
|
f["path"]: f
|
|
for f in base.get("files", [])
|
|
if isinstance(f, dict) and isinstance(f.get("path"), str)
|
|
}
|
|
for f in updates.get("files", []):
|
|
if isinstance(f, dict) and isinstance(f.get("path"), str):
|
|
merged[f["path"]] = f
|
|
return _normalize_draft({
|
|
"summary": updates.get("summary") or base.get("summary", ""),
|
|
"files": list(merged.values()),
|
|
"tests": [*base.get("tests", []), *updates.get("tests", [])],
|
|
"notes": [*base.get("notes", []), *updates.get("notes", [])],
|
|
})
|
|
|
|
|
|
def _assert_git_workspace(workspace: Path) -> None:
|
|
try:
|
|
subprocess.run(
|
|
["git", "rev-parse", "--is-inside-work-tree"],
|
|
cwd=workspace,
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
raise RuntimeError("Aider backend requires --workspace to be inside a git repository.") from exc
|
|
|
|
|
|
def _build_aider_command(config: Config, message: str, file_paths: List[str]) -> List[str]:
|
|
cmd = shlex.split(config.aider_command)
|
|
if not cmd:
|
|
raise RuntimeError("--aider-command cannot be empty.")
|
|
|
|
cmd.extend(["--yes-always", "--no-auto-commits"])
|
|
if config.aider_model:
|
|
cmd.extend(["--model", config.aider_model])
|
|
if config.aider_extra_args:
|
|
cmd.extend(shlex.split(config.aider_extra_args))
|
|
cmd.extend(["--message", message])
|
|
|
|
# Passing expected files keeps Aider focused while still allowing it to
|
|
# create extra files when the prompt calls for tests or supporting modules.
|
|
cmd.extend([p for p in file_paths if p and not Path(p).is_absolute()])
|
|
return cmd
|
|
|
|
|
|
def _run_aider_writer(
|
|
config: Config,
|
|
prompt: str,
|
|
file_paths: List[str],
|
|
label: str,
|
|
iteration: int,
|
|
) -> Dict[str, Any]:
|
|
if shutil.which(shlex.split(config.aider_command)[0]) is None:
|
|
raise RuntimeError(
|
|
f"Aider command not found: {config.aider_command!r}. "
|
|
"Install aider-chat or pass --aider-command."
|
|
)
|
|
_assert_git_workspace(config.workspace)
|
|
|
|
before = _snapshot_workspace_files(config.workspace)
|
|
cmd = _build_aider_command(config, prompt, file_paths)
|
|
|
|
_log_header(label)
|
|
_log_kv("Iteration", str(iteration))
|
|
_log_kv("Command", " ".join(shlex.quote(part) for part in cmd[:4]) + " ...")
|
|
|
|
start = _now_ts()
|
|
proc = subprocess.run(
|
|
cmd,
|
|
cwd=config.workspace,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
elapsed = _now_ts() - start
|
|
_log_kv("Elapsed", _fmt_elapsed(elapsed))
|
|
_emit("--- aider output ---")
|
|
_emit(_truncate(proc.stdout or "", limit=6000))
|
|
_emit("--------------------")
|
|
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"Aider exited with status {proc.returncode}. See log for output.")
|
|
|
|
after = _snapshot_workspace_files(config.workspace)
|
|
files = _changed_files_from_snapshots(before, after)
|
|
return _normalize_draft({
|
|
"summary": f"Aider changed {len(files)} file(s).",
|
|
"files": files,
|
|
"tests": [],
|
|
"notes": [f"Aider backend: {label}"],
|
|
})
|
|
|
|
|
|
def _mark_checklist_reviewed(workspace: Path) -> bool:
|
|
"""After reviewer approves, mark the first step where ✅=[x] and ✔️=[ ] as reviewed."""
|
|
checklist = workspace / "CHECKLIST.md"
|
|
if not checklist.exists():
|
|
return False
|
|
text = checklist.read_text(encoding="utf-8")
|
|
# Match the first table row where the written column is [x] and reviewed column is [ ]
|
|
# Row format: | N | description | [x] | [ ] |
|
|
pattern = re.compile(r'(\|[^|]+\|[^|]+\| *)\[x\]( *\| *)\[ \]( *\|)')
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return False
|
|
updated = (
|
|
text[: match.start()]
|
|
+ match.group(1) + "[x]"
|
|
+ match.group(2) + "[x]"
|
|
+ match.group(3)
|
|
+ text[match.end() :]
|
|
)
|
|
checklist.write_text(updated, encoding="utf-8")
|
|
_emit("[checklist] Marked ✔️ reviewed in CHECKLIST.md")
|
|
return True
|
|
|
|
|
|
def _checklist_has_pending(workspace: Path) -> bool:
|
|
"""Return True if CHECKLIST.md has any step where ✅ is still [ ]."""
|
|
checklist = workspace / "CHECKLIST.md"
|
|
if not checklist.exists():
|
|
return False
|
|
text = checklist.read_text(encoding="utf-8")
|
|
# Match any table row where the written (✅) column is [ ]
|
|
# Row format: | N | description | [ ] | ... |
|
|
return bool(re.search(r'\|[^|]+\|[^|]+\| *\[ \] *\|', text))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Design stage (extracted so it can be called again for revisions)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _run_design(config: Config, ctx: str, extra_context: str = "") -> Dict[str, Any]:
|
|
"""Run (or re-run) the design stage.
|
|
|
|
Pass *extra_context* to inject accumulated reviewer feedback when triggering
|
|
a mid-loop design revision.
|
|
"""
|
|
parts = [
|
|
f"Task:\n{config.task}",
|
|
f"\nContext files:\n{ctx}",
|
|
]
|
|
if extra_context:
|
|
parts.append(f"\n{extra_context}")
|
|
parts.append(f"\nProduce a concise implementation design for a {config.language} project.")
|
|
|
|
design_raw = _chat(
|
|
config.api_base, config.design_model, config.design_system_prompt,
|
|
"\n".join(parts), DESIGN_SCHEMA, 0.0,
|
|
log_label="Design stage",
|
|
iteration=0,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
return _normalize_design(_safe_json(design_raw, {
|
|
"summary": "Could not parse design output",
|
|
"files": [], "responsibilities": [], "interfaces": [],
|
|
"integration_points": [], "dependencies": [],
|
|
"testing_plan": [], "risks": [], "assumptions": [],
|
|
}))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-file writing helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _write_single_file(
|
|
config: "Config",
|
|
design: dict,
|
|
file_entry: dict,
|
|
already_written: list,
|
|
ctx: str,
|
|
iteration: int,
|
|
) -> dict:
|
|
"""Write a single file as specified by its design entry.
|
|
|
|
*already_written* contains files produced in previous passes so the writer
|
|
can maintain consistent imports, types, and naming conventions.
|
|
|
|
The workspace context (*ctx*) is capped at *per_file_context_chars* rather
|
|
than the full *max_context_chars*. Shorter prompts mean shorter prefill
|
|
times and fewer read-timeout risks when writing individual files.
|
|
"""
|
|
# Cap workspace context for per-file calls — a single file only needs
|
|
# enough context to understand the surrounding project, not the full repo.
|
|
trimmed_ctx = ctx[: config.per_file_context_chars]
|
|
if len(ctx) > config.per_file_context_chars:
|
|
trimmed_ctx += "\n... [workspace context trimmed — see full context in design] ..."
|
|
|
|
already_ctx = ""
|
|
if already_written:
|
|
already_ctx = (
|
|
"\n\nAlready-written files (read-only reference — use consistent "
|
|
"types, imports, and naming):\n\n"
|
|
+ "\n\n".join(
|
|
f"--- {f['path']} ---\n{f['content']}" for f in already_written
|
|
)
|
|
)
|
|
|
|
prompt = (
|
|
f"Task:\n{config.task}\n\n"
|
|
f"Full design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"YOUR CURRENT ASSIGNMENT — write this file only:\n"
|
|
f" path : {file_entry['path']}\n"
|
|
f" purpose: {file_entry.get('purpose', '(see design)')}\n\n"
|
|
f"Existing workspace files (read-only context):\n{trimmed_ctx}"
|
|
f"{already_ctx}\n\n"
|
|
f"Return only the file at '{file_entry['path']}' plus any directly "
|
|
f"associated test file. Do not write files outside your assignment."
|
|
)
|
|
|
|
raw = _chat(
|
|
config.api_base, config.writer_model, config.writer_system_prompt,
|
|
prompt, WRITER_SCHEMA, 0.1,
|
|
log_label=f"Writer [{file_entry['path']}]",
|
|
iteration=iteration,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
result = _normalize_draft(_safe_json(raw, {
|
|
"summary": f"Could not parse output for {file_entry['path']}",
|
|
"files": [], "tests": [], "notes": [raw[:1000]],
|
|
}))
|
|
if not result.get("files"):
|
|
_emit(f"[warn] Writer produced no files for {file_entry['path']} — skipping")
|
|
return result
|
|
|
|
|
|
def _files_needing_rewrite(review: dict, draft_files: list) -> set:
|
|
"""Return the set of file paths the reviewer flagged for changes."""
|
|
flagged = {
|
|
fc.get("path", "")
|
|
for fc in review.get("file_comments", [])
|
|
if fc.get("path")
|
|
}
|
|
draft_paths = {f["path"] for f in draft_files}
|
|
for issue in review.get("critical_issues", []):
|
|
for path in draft_paths:
|
|
if path in issue:
|
|
flagged.add(path)
|
|
if review.get("critical_issues") and not flagged:
|
|
return draft_paths
|
|
return flagged
|
|
|
|
|
|
def _rewrite_targeted_files(
|
|
config: "Config",
|
|
design: dict,
|
|
draft: dict,
|
|
review: dict,
|
|
ctx: str,
|
|
iteration: int,
|
|
temperature: float,
|
|
) -> dict:
|
|
"""Rewrite only the files flagged in the review, merging back into draft."""
|
|
all_files = draft.get("files", [])
|
|
flagged_paths = _files_needing_rewrite(review, all_files)
|
|
|
|
if not flagged_paths:
|
|
_emit("[per-file] No specific files flagged — falling back to full rewrite")
|
|
return draft
|
|
|
|
_emit(f"[per-file] Targeted rewrite for: {sorted(flagged_paths)}")
|
|
design_file_map = {f["path"]: f for f in design.get("files", [])}
|
|
current_file_map: Dict[str, Dict[str, str]] = {f["path"]: f for f in all_files}
|
|
|
|
for path in sorted(flagged_paths):
|
|
if path not in current_file_map:
|
|
_emit(f"[per-file] Skipping unknown path: {path}")
|
|
continue
|
|
|
|
file_entry = design_file_map.get(path, {"path": path, "purpose": "(not in design)"})
|
|
current_content = current_file_map[path].get("content", "")
|
|
other_files_ctx = "\n\n".join(
|
|
f"--- {f['path']} ---\n{f['content']}"
|
|
for f in all_files if f["path"] != path
|
|
)
|
|
file_comments = [
|
|
fc["comment"]
|
|
for fc in review.get("file_comments", [])
|
|
if fc.get("path") == path
|
|
]
|
|
|
|
prompt = (
|
|
f"Task:\n{config.task}\n\n"
|
|
f"Full design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"YOUR CURRENT ASSIGNMENT — fix this file only:\n"
|
|
f" path : {path}\n"
|
|
f" purpose: {file_entry.get('purpose', '(see design)')}\n\n"
|
|
f"Current content of '{path}':\n{current_content}\n\n"
|
|
f"Other project files (read-only reference):\n{other_files_ctx}\n\n"
|
|
f"Review issues specific to '{path}':\n"
|
|
+ ("\n".join(f"- {c}" for c in file_comments) if file_comments
|
|
else "(none — fix based on general review below)")
|
|
+ f"\n\nGeneral review summary: {review.get('summary', '')}\n"
|
|
f"Critical issues: {review.get('critical_issues', [])}\n"
|
|
f"Rewrite strategy: {review.get('rewrite_strategy', [])}\n\n"
|
|
f"Return only '{path}' in the files array. Do not rewrite other files."
|
|
)
|
|
|
|
raw = _chat(
|
|
config.api_base, config.writer_model, config.writer_system_prompt,
|
|
prompt, WRITER_SCHEMA, temperature,
|
|
log_label=f"Writer rewrite [{path}]",
|
|
iteration=iteration,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
result = _normalize_draft(_safe_json(raw, {
|
|
"summary": f"Could not parse rewrite for {path}",
|
|
"files": [], "tests": [], "notes": [raw[:1000]],
|
|
}))
|
|
for updated_file in result.get("files", []):
|
|
updated_path = updated_file.get("path", "")
|
|
current_file_map[updated_path] = updated_file
|
|
_emit(f"[per-file] Updated: {updated_path}")
|
|
|
|
return _normalize_draft({
|
|
"summary": draft.get("summary", ""),
|
|
"files": list(current_file_map.values()),
|
|
"tests": draft.get("tests", []),
|
|
"notes": draft.get("notes", []),
|
|
})
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main agent loop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run_agent(config: Config) -> Dict[str, Any]:
|
|
run_stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
_set_log_file(config.log_dir / f"{config.language.lower()}-agent-{run_stamp}.log")
|
|
_log_header("Agent run started")
|
|
_log_kv("Language", config.language)
|
|
_log_kv("Writer model", config.writer_model)
|
|
_log_kv("Reviewer model", config.reviewer_model)
|
|
_log_kv("Design model", config.design_model)
|
|
_log_kv("Writer backend", config.writer_backend)
|
|
|
|
if config.writer_backend == "aider" and not config.write_to_workspace:
|
|
raise RuntimeError(
|
|
"The Aider writer backend edits the workspace directly. "
|
|
"Run with --write-to-workspace or --run-until-done."
|
|
)
|
|
|
|
context_files = _read_context(config.workspace, config.max_context_chars, config.max_files)
|
|
ctx = (
|
|
"\n\n".join(f"--- FILE: {f['name']} ---\n{f['content']}" for f in context_files)
|
|
if context_files else "(no workspace files found)"
|
|
)
|
|
|
|
# --- Design ---
|
|
design = _run_design(config, ctx)
|
|
_log_header("Design parsed")
|
|
_emit(_truncate(json.dumps(design, ensure_ascii=False, indent=2)))
|
|
|
|
# --- Initial write ---
|
|
# Per-file mode: write one file at a time so each generation stays within
|
|
# a manageable output size and avoids streaming timeouts on large projects.
|
|
# Bulk mode: write everything in one pass (useful for very small tasks).
|
|
if config.writer_backend == "aider":
|
|
_log_header("Initial write — aider backend")
|
|
aider_prompt = (
|
|
f"Task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Implement only this task. Keep the change scoped to the design, "
|
|
f"update CHECKLIST.md as instructed by the project prompt, and add "
|
|
f"or update tests required for the current step."
|
|
)
|
|
draft = _run_aider_writer(
|
|
config,
|
|
aider_prompt,
|
|
[f.get("path", "") for f in design.get("files", [])],
|
|
label="Aider writer stage",
|
|
iteration=0,
|
|
)
|
|
elif config.per_file_write and design.get("files"):
|
|
_log_header(f"Initial write — per-file mode ({len(design['files'])} files)")
|
|
combined: Dict[str, Any] = {} # path → file dict (deduplicates by path)
|
|
combined_tests: List[str] = []
|
|
combined_notes: List[str] = []
|
|
already_written: List[Dict[str, str]] = []
|
|
|
|
for i, file_entry in enumerate(design["files"]):
|
|
_log_header(f"Writing file {i + 1}/{len(design['files'])}: {file_entry['path']}")
|
|
result = _write_single_file(config, design, file_entry, already_written, ctx, iteration=0)
|
|
for f in result.get("files", []):
|
|
combined[f["path"]] = f
|
|
already_written.append(f)
|
|
combined_tests.extend(result.get("tests", []))
|
|
combined_notes.extend(result.get("notes", []))
|
|
|
|
draft = _normalize_draft({
|
|
"summary": f"Per-file write: {len(combined)} files",
|
|
"files": list(combined.values()),
|
|
"tests": combined_tests,
|
|
"notes": combined_notes,
|
|
})
|
|
else:
|
|
_log_header("Initial write — bulk mode")
|
|
writer_prompt = (
|
|
f"Task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Context files:\n{ctx}\n\n"
|
|
f"Generate code that follows the design."
|
|
)
|
|
draft_raw = _chat(
|
|
config.api_base, config.writer_model, config.writer_system_prompt,
|
|
writer_prompt, WRITER_SCHEMA, 0.1,
|
|
log_label="Writer stage", iteration=0,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
draft = _normalize_draft(_safe_json(draft_raw, {
|
|
"summary": "Could not parse writer output",
|
|
"files": [], "tests": [], "notes": [draft_raw[:3000]],
|
|
}))
|
|
|
|
_log_header("Writer parsed")
|
|
_emit(_truncate(json.dumps(draft, ensure_ascii=False, indent=2)))
|
|
|
|
if not draft.get("files"):
|
|
# Diagnose and retry regardless of mode.
|
|
_log_header("Writer returned no files — diagnosing")
|
|
_emit(
|
|
"[diagnose] All files are empty. In per-file mode this means every "
|
|
"individual file call returned nothing. In bulk mode check the log for "
|
|
"truncation. Retrying with an explicit files-or-fail prompt."
|
|
)
|
|
if config.writer_backend == "aider":
|
|
retry_prompt = (
|
|
f"Task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"The previous Aider run produced no changed files. Make concrete "
|
|
f"workspace edits for the current task now. Do not stop at an "
|
|
f"explanation."
|
|
)
|
|
draft = _run_aider_writer(
|
|
config,
|
|
retry_prompt,
|
|
[f.get("path", "") for f in design.get("files", [])],
|
|
label="Aider writer retry stage",
|
|
iteration=0,
|
|
)
|
|
else:
|
|
retry_prompt = (
|
|
f"Task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Context files:\n{ctx}\n\n"
|
|
f'You MUST write all source files listed in the design. '
|
|
f'Return a JSON object: '
|
|
f'{{"summary":"...","files":[{{"path":"...","content":"..."}}],"tests":[],"notes":[]}}\n\n'
|
|
f"The files array MUST contain at least one entry."
|
|
)
|
|
retry_raw = _chat(
|
|
config.api_base, config.writer_model, config.writer_system_prompt,
|
|
retry_prompt, {"type": "object"}, temperature=0.4,
|
|
log_label="Writer retry stage", iteration=0,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
draft = _normalize_draft(_safe_json(retry_raw, {
|
|
"summary": "Could not parse writer retry output",
|
|
"files": [], "tests": [], "notes": [retry_raw[:3000]],
|
|
}))
|
|
_log_header("Writer retry parsed")
|
|
_emit(_truncate(json.dumps(draft, ensure_ascii=False, indent=2)))
|
|
|
|
if not draft.get("files"):
|
|
raise RuntimeError(
|
|
"Writer returned no files on initial attempt and retry. "
|
|
"Check the log for '[diagnose]' lines. Common causes: "
|
|
"streaming error, model not loaded, or design produced no file entries."
|
|
)
|
|
|
|
stem = run_stamp
|
|
|
|
# Partial state — written immediately so progress isn't lost on crash
|
|
partial = {
|
|
"language": config.language, "task": config.task, "design": design,
|
|
"writer_model": config.writer_model, "reviewer_model": config.reviewer_model,
|
|
"design_model": config.design_model, "draft": draft,
|
|
"review": _fallback_review("Review not completed yet."),
|
|
"loop_info": {
|
|
"loops_used": 0, "max_review_loops": config.max_review_loops,
|
|
"stop_reason": "review_not_started", "final_verdict": "needs_changes",
|
|
"designs_used": 1, "history": [],
|
|
},
|
|
"log_file": str(_LOG_FILE.get() or ""),
|
|
}
|
|
_write_state(config.outputs, f"{config.language.lower()}-{stem}", partial)
|
|
|
|
# --- Inline reviewer ---
|
|
def do_review(cur_draft: Dict[str, Any], iteration: int) -> Dict[str, Any]:
|
|
review_prompt = (
|
|
f"Original task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Draft JSON:\n{json.dumps(cur_draft, ensure_ascii=False, indent=2)}"
|
|
)
|
|
try:
|
|
raw = _chat(
|
|
config.api_base, config.reviewer_model, config.reviewer_system_prompt,
|
|
review_prompt, REVIEW_SCHEMA, 0.0,
|
|
allow_empty=True, log_label="Reviewer stage", iteration=iteration,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
if not raw.strip():
|
|
# Empty response — conservative: don't approve
|
|
return _fallback_review("Reviewer returned an empty response.")
|
|
data = _safe_json(raw, _fallback_review("Reviewer returned non-parseable output."))
|
|
rv = _normalize_review(data)
|
|
_log_header("Reviewer parsed")
|
|
_emit(_truncate(json.dumps(rv, ensure_ascii=False, indent=2)))
|
|
return rv
|
|
except Exception as e:
|
|
# Never auto-approve on error — safe default is needs_changes
|
|
return _fallback_review(f"Reviewer error: {type(e).__name__}: {e}", approve=False)
|
|
|
|
# --- Review / rewrite loop ---
|
|
review = do_review(draft, 0)
|
|
|
|
# full_history stores the complete draft summary + review per iteration so
|
|
# the -history file is genuinely useful for debugging convergence.
|
|
full_history: List[Dict[str, Any]] = [{
|
|
"iteration": 0,
|
|
"draft_summary": draft.get("summary", ""),
|
|
"file_paths": [f.get("path", "") for f in draft.get("files", [])],
|
|
"review": review,
|
|
}]
|
|
# Compact summary used inside loop_info for quick inspection
|
|
history = [{
|
|
"iteration": 0,
|
|
"review_verdict": review.get("verdict", "needs_changes"),
|
|
"review_summary": review.get("summary", ""),
|
|
"file_count": len(draft.get("files", [])),
|
|
}]
|
|
|
|
loops_used = 0
|
|
designs_used = 1
|
|
consecutive_needs_changes = 0 if review.get("verdict") == "approve" else 1
|
|
stop_reason = "approved" if review.get("verdict") == "approve" else "not_entered"
|
|
|
|
while review.get("verdict") == "needs_changes" and loops_used < config.max_review_loops:
|
|
loops_used += 1
|
|
_log_header("Review loop")
|
|
_log_kv("Iteration", str(loops_used))
|
|
_log_kv("Verdict", review.get("verdict", "needs_changes"))
|
|
_log_kv("Summary", review.get("summary", ""))
|
|
|
|
# --- Optional design revision when the writer is stuck ---
|
|
if (
|
|
config.redesign_after > 0
|
|
and consecutive_needs_changes > 0
|
|
and consecutive_needs_changes % config.redesign_after == 0
|
|
and designs_used < 3 # cap to prevent infinite redesign cycles
|
|
):
|
|
_log_header("Design revision triggered")
|
|
feedback_window = full_history[-config.redesign_after:]
|
|
accumulated = "\n".join(
|
|
f"Iteration {h['iteration']}: {h['review'].get('summary', '')}\n"
|
|
f" Critical: {h['review'].get('critical_issues', [])}\n"
|
|
f" Strategy: {h['review'].get('rewrite_strategy', [])}"
|
|
for h in feedback_window
|
|
)
|
|
extra = (
|
|
f"The previous design caused {consecutive_needs_changes} consecutive failed reviews.\n"
|
|
f"Accumulated reviewer feedback:\n{accumulated}\n\n"
|
|
f"Revise the design to address these issues directly. "
|
|
f"Do not repeat a structure that has already failed."
|
|
)
|
|
design = _run_design(config, ctx, extra_context=extra)
|
|
designs_used += 1
|
|
_log_header("Revised design parsed")
|
|
_emit(_truncate(json.dumps(design, ensure_ascii=False, indent=2)))
|
|
|
|
# Pass the last 3 reviews to the writer so it can see repeated failures
|
|
recent_history = full_history[-3:]
|
|
history_context = json.dumps(
|
|
[{"iteration": h["iteration"], "review": h["review"]} for h in recent_history],
|
|
ensure_ascii=False, indent=2,
|
|
)
|
|
|
|
# Gradually raise temperature so a stuck writer tries different approaches.
|
|
# Starts at 0.1 and climbs toward 0.6 over successive loops.
|
|
write_temperature = min(0.1 + (loops_used - 1) * 0.1, 0.6)
|
|
|
|
# Per-file rewrite: only touch files the reviewer flagged.
|
|
# Bulk rewrite: rewrite the whole draft (fallback / non-per-file mode).
|
|
if config.writer_backend == "aider":
|
|
flagged_paths = sorted(_files_needing_rewrite(review, draft.get("files", [])))
|
|
missing_paths = [
|
|
path for path in review.get("missing_files", [])
|
|
if isinstance(path, str) and path
|
|
]
|
|
aider_paths = sorted(set(flagged_paths + missing_paths))
|
|
if not aider_paths:
|
|
aider_paths = [f.get("path", "") for f in draft.get("files", [])]
|
|
aider_rewrite_prompt = (
|
|
f"Original task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Review history (last {len(recent_history)} iterations):\n{history_context}\n\n"
|
|
f"Current review feedback:\n{json.dumps(review, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Patch the workspace to resolve the review feedback. Keep the "
|
|
f"change scoped to the current CHECKLIST step."
|
|
)
|
|
aider_updates = _run_aider_writer(
|
|
config,
|
|
aider_rewrite_prompt,
|
|
aider_paths,
|
|
label="Aider rewrite stage",
|
|
iteration=loops_used,
|
|
)
|
|
new_draft = _merge_draft_files(draft, aider_updates)
|
|
elif config.per_file_write:
|
|
new_draft = _rewrite_targeted_files(
|
|
config, design, draft, review, ctx,
|
|
iteration=loops_used, temperature=write_temperature,
|
|
)
|
|
else:
|
|
rewrite_prompt = (
|
|
f"Follow-up: Rewrite considering the review.\n\n"
|
|
f"Original task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Previous proposal:\n{json.dumps(draft, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Review history (last {len(recent_history)} iterations):\n{history_context}\n\n"
|
|
f"Current review feedback:\n{json.dumps(review, ensure_ascii=False, indent=2)}"
|
|
)
|
|
rewrite_raw = _chat(
|
|
config.api_base, config.writer_model, config.writer_system_prompt,
|
|
rewrite_prompt, WRITER_SCHEMA, write_temperature,
|
|
log_label="Writer rewrite stage", iteration=loops_used,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
new_draft = _normalize_draft(_safe_json(rewrite_raw, draft))
|
|
|
|
_log_header("Writer rewrite parsed")
|
|
_emit(_truncate(json.dumps(new_draft, ensure_ascii=False, indent=2)))
|
|
|
|
if not new_draft.get("files"):
|
|
stop_reason = "rewrite_returned_no_files"
|
|
break
|
|
|
|
old_files = [(f.get("path", ""), f.get("content", "")) for f in draft.get("files", [])]
|
|
new_files_list = [(f.get("path", ""), f.get("content", "")) for f in new_draft.get("files", [])]
|
|
|
|
if old_files == new_files_list:
|
|
# Writer returned identical output — stall-break at high temperature.
|
|
# In per-file mode, rewrite every file unconditionally to break the deadlock.
|
|
_log_header("Stall detected — retrying at high temperature")
|
|
if config.writer_backend == "aider":
|
|
stall_prompt = (
|
|
f"Original task:\n{config.task}\n\n"
|
|
f"Design:\n{json.dumps(design, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Current review feedback:\n{json.dumps(review, ensure_ascii=False, indent=2)}\n\n"
|
|
f"Your previous Aider pass did not change any tracked text files. "
|
|
f"Make concrete edits that address the review feedback now."
|
|
)
|
|
stall_updates = _run_aider_writer(
|
|
config,
|
|
stall_prompt,
|
|
[f.get("path", "") for f in draft.get("files", [])],
|
|
label="Aider stall-break attempt",
|
|
iteration=loops_used,
|
|
)
|
|
stall_draft = _merge_draft_files(draft, stall_updates)
|
|
elif config.per_file_write:
|
|
stall_draft = _rewrite_targeted_files(
|
|
config, design, draft, review, ctx,
|
|
iteration=loops_used, temperature=0.7,
|
|
)
|
|
else:
|
|
stall_prompt = (
|
|
f"{rewrite_prompt}\n\n"
|
|
f"IMPORTANT: Your last rewrite returned files identical to the previous attempt. "
|
|
f"You must make concrete changes. "
|
|
f"Try a different implementation strategy for the issues flagged in the review."
|
|
)
|
|
stall_raw = _chat(
|
|
config.api_base, config.writer_model, config.writer_system_prompt,
|
|
stall_prompt, WRITER_SCHEMA, 0.7,
|
|
log_label="Writer stall-break attempt", iteration=loops_used,
|
|
read_timeout=config.read_timeout,
|
|
)
|
|
stall_draft = _normalize_draft(_safe_json(stall_raw, new_draft))
|
|
|
|
stall_files = [(f.get("path", ""), f.get("content", "")) for f in stall_draft.get("files", [])]
|
|
if stall_files == old_files:
|
|
stop_reason = "no_file_changes"
|
|
break
|
|
new_draft = stall_draft
|
|
|
|
draft = new_draft
|
|
review = do_review(draft, loops_used)
|
|
|
|
history.append({
|
|
"iteration": loops_used,
|
|
"review_verdict": review.get("verdict", "needs_changes"),
|
|
"review_summary": review.get("summary", ""),
|
|
"file_count": len(draft.get("files", [])),
|
|
})
|
|
full_history.append({
|
|
"iteration": loops_used,
|
|
"draft_summary": draft.get("summary", ""),
|
|
"file_paths": [f.get("path", "") for f in draft.get("files", [])],
|
|
"review": review,
|
|
})
|
|
|
|
if review.get("verdict") == "approve":
|
|
consecutive_needs_changes = 0
|
|
stop_reason = "approved"
|
|
break
|
|
|
|
consecutive_needs_changes += 1
|
|
|
|
else:
|
|
stop_reason = "approved" if review.get("verdict") == "approve" else "max_review_loops_reached"
|
|
|
|
state = {
|
|
"language": config.language, "task": config.task, "design": design,
|
|
"writer_model": config.writer_model, "reviewer_model": config.reviewer_model,
|
|
"design_model": config.design_model, "draft": draft, "review": review,
|
|
"loop_info": {
|
|
"loops_used": loops_used,
|
|
"max_review_loops": config.max_review_loops,
|
|
"stop_reason": stop_reason,
|
|
"final_verdict": review.get("verdict", "needs_changes"),
|
|
"designs_used": designs_used,
|
|
"history": history,
|
|
},
|
|
"log_file": str(_LOG_FILE.get() or ""),
|
|
}
|
|
# Final state — overwrites the partial written at the start
|
|
_write_state(config.outputs, f"{config.language.lower()}-{stem}", state)
|
|
# Genuine iteration history: one entry per pass with draft summary + full review
|
|
_write_state(
|
|
config.outputs,
|
|
f"{config.language.lower()}-{stem}",
|
|
{"task": config.task, "full_history": full_history},
|
|
suffix="-history",
|
|
)
|
|
if config.writer_backend == "aider":
|
|
project_dir = config.workspace
|
|
if stop_reason == "approved":
|
|
_mark_checklist_reviewed(config.workspace)
|
|
elif config.write_to_workspace:
|
|
output_root = config.workspace
|
|
project_dir = _write_files(output_root, draft)
|
|
if stop_reason == "approved":
|
|
_mark_checklist_reviewed(config.workspace)
|
|
else:
|
|
output_root = config.outputs / f"{config.language.lower()}-agent-output-{stem}"
|
|
project_dir = _write_files(output_root, draft)
|
|
state["project_dir"] = str(project_dir)
|
|
return state
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_args(language: str, writer_prompt: str, reviewer_prompt: str, design_prompt: str) -> Config:
|
|
p = argparse.ArgumentParser()
|
|
_default_task = os.getenv("AGENT_TASK")
|
|
p.add_argument("--task", default=_default_task, required=_default_task is None)
|
|
p.add_argument("--workspace", default=os.getenv("AGENT_WORKSPACE", "/workspace"))
|
|
p.add_argument("--outputs", default=os.getenv("AGENT_OUTPUTS", "./runs"))
|
|
p.add_argument("--writer-model", default=os.getenv("WRITER_MODEL", "gemma4:31b"))
|
|
p.add_argument("--reviewer-model", default=os.getenv("REVIEWER_MODEL", "gemma4:31b"))
|
|
p.add_argument(
|
|
"--design-model",
|
|
default=os.getenv("DESIGN_MODEL", os.getenv("REVIEWER_MODEL", "gemma4:31b")),
|
|
)
|
|
p.add_argument("--api-base", default=os.getenv("OLLAMA_API_BASE", "http://ollama:11434"))
|
|
p.add_argument("--max-review-loops", type=int, default=int(os.getenv("AGENT_MAX_REVIEW_LOOPS", "6")))
|
|
p.add_argument("--max-context-chars", type=int, default=120_000)
|
|
p.add_argument("--max-files", type=int, default=80)
|
|
p.add_argument(
|
|
"--redesign-after", type=int, default=3,
|
|
help="Trigger a design revision after this many consecutive failed review loops (0 = disabled).",
|
|
)
|
|
p.add_argument("--log-dir", default=os.getenv("AGENT_LOG_DIR", "./logs"))
|
|
p.add_argument(
|
|
"--no-per-file", action="store_true",
|
|
help=(
|
|
"Disable per-file writing and write all files in a single pass. "
|
|
"Per-file mode is the default: it keeps each generation short, "
|
|
"avoids streaming timeouts on large projects, and enables targeted "
|
|
"rewrites in the review loop."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--read-timeout", type=int, default=600,
|
|
help=(
|
|
"Seconds of silence before a stream is considered hung. "
|
|
"Must cover worst-case prefill time (model load + prompt processing "
|
|
"before the first token). For a 32B model with a large context this "
|
|
"can be 5-10 minutes. Default: 600."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--per-file-context-chars", type=int, default=40_000,
|
|
help=(
|
|
"Maximum workspace context chars passed to each individual file write. "
|
|
"Smaller than --max-context-chars to keep per-file prompts short and "
|
|
"reduce prefill time. Default: 40000."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--writer-backend",
|
|
choices=("ollama", "aider"),
|
|
default=os.getenv("AGENT_WRITER_BACKEND", "ollama"),
|
|
help=(
|
|
"Writer backend to use. 'ollama' asks the configured writer model for "
|
|
"JSON full-file contents. 'aider' runs the Aider CLI, lets it edit "
|
|
"the workspace directly, then captures changed files for review."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--aider-command",
|
|
default=os.getenv("AIDER_COMMAND", "aider"),
|
|
help="Aider executable or command prefix. Default: aider.",
|
|
)
|
|
p.add_argument(
|
|
"--aider-model",
|
|
default=os.getenv("AIDER_MODEL", ""),
|
|
help="Optional model passed to Aider as --model.",
|
|
)
|
|
p.add_argument(
|
|
"--aider-extra-args",
|
|
default=os.getenv("AIDER_EXTRA_ARGS", ""),
|
|
help="Optional extra CLI args appended to Aider before --message.",
|
|
)
|
|
p.add_argument(
|
|
"--write-to-workspace", action="store_true",
|
|
help=(
|
|
"Write approved files directly into --workspace instead of a timestamped "
|
|
"subdirectory under --outputs. Also marks the ✔️ column in CHECKLIST.md "
|
|
"automatically after the reviewer approves."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--run-until-done", action="store_true",
|
|
help=(
|
|
"Loop automatically through every pending CHECKLIST step without human input. "
|
|
"Stops when all ✅ boxes are checked or a step fails to get approved. "
|
|
"Implies --write-to-workspace."
|
|
),
|
|
)
|
|
a = p.parse_args()
|
|
return Config(
|
|
api_base=a.api_base,
|
|
writer_model=a.writer_model,
|
|
reviewer_model=a.reviewer_model,
|
|
design_model=a.design_model,
|
|
workspace=Path(a.workspace),
|
|
outputs=Path(a.outputs),
|
|
task=a.task,
|
|
max_review_loops=a.max_review_loops,
|
|
max_context_chars=a.max_context_chars,
|
|
max_files=a.max_files,
|
|
language=language,
|
|
writer_system_prompt=writer_prompt,
|
|
reviewer_system_prompt=reviewer_prompt,
|
|
design_system_prompt=design_prompt,
|
|
log_dir=Path(a.log_dir),
|
|
redesign_after=a.redesign_after,
|
|
per_file_write=not a.no_per_file,
|
|
write_to_workspace=a.write_to_workspace or a.run_until_done,
|
|
run_until_done=a.run_until_done,
|
|
read_timeout=a.read_timeout,
|
|
per_file_context_chars=a.per_file_context_chars,
|
|
writer_backend=a.writer_backend,
|
|
aider_command=a.aider_command,
|
|
aider_model=a.aider_model,
|
|
aider_extra_args=a.aider_extra_args,
|
|
)
|
|
|
|
|
|
def main(language: str, writer_prompt: str, reviewer_prompt: str, design_prompt: str) -> int:
|
|
cfg = parse_args(language, writer_prompt, reviewer_prompt, design_prompt)
|
|
|
|
if cfg.run_until_done:
|
|
step = 0
|
|
while _checklist_has_pending(cfg.workspace):
|
|
step += 1
|
|
_emit(f"\n{'=' * 60}\n[loop] Starting step {step}\n{'=' * 60}")
|
|
state = run_agent(cfg)
|
|
stop_reason = state["loop_info"]["stop_reason"]
|
|
verdict = state["review"]["verdict"]
|
|
_emit(f"[loop] Step {step} finished — stop_reason={stop_reason} verdict={verdict}")
|
|
if stop_reason != "approved":
|
|
_emit(f"[loop] Step {step} did not get approved. Halting.")
|
|
print(f"Stopped at step {step}: {stop_reason}")
|
|
return 1
|
|
_emit("[loop] All CHECKLIST steps complete.")
|
|
print(f"All {step} steps completed.")
|
|
return 0
|
|
|
|
state = run_agent(cfg)
|
|
print(f"{cfg.language} agent completed")
|
|
print(f"Iteration count: {state['loop_info']['loops_used']}/{state['loop_info']['max_review_loops']}")
|
|
print(f"Stop reason: {state['loop_info']['stop_reason']}")
|
|
print(f"Final verdict: {state['review']['verdict']}")
|
|
print(f"Designs used: {state['loop_info']['designs_used']}")
|
|
print(f"Project output: {state['project_dir']}")
|
|
print(f"Log file: {state.get('log_file', '')}")
|
|
return 0
|