Files

182 lines
7.2 KiB
Python

"""
AutoDev - Planner
Reads description + manuals, queries LLM to produce a structured development plan.
Approaches the task like a principal engineer — thorough, skeptical, complete.
"""
import json
import os
from .llm import LLM
from .logger import Logger
from .context import ContextManager
from . import config
SYSTEM_PROMPT = config.EXPERT_IDENTITY + """
You are now in PLANNING mode. You have been given a project description and optional reference manuals.
Your job is to produce a complete, actionable development plan that another engineer could follow blindly.
Think through this carefully:
1. What EXACTLY is being asked for? Read the description multiple times. Identify every requirement.
2. What language, frameworks, and tools are needed?
3. What is the correct project structure? Think about separation of concerns.
4. What are the build steps? What compiler flags, linker flags, dependencies?
5. What could go wrong? Plan for error handling and edge cases.
6. What is the correct order of implementation? Dependencies between files matter.
Output ONLY valid JSON with this structure:
{
"project_name": "string",
"language": "primary language",
"summary": "2-3 sentence summary of what we're building and why",
"dependencies": ["every tool, compiler, library needed"],
"structure": ["every file and directory to create, in order"],
"steps": [
{
"id": 1,
"phase": "setup|implement|test|debug|finalize",
"description": "detailed description of what to do and WHY",
"files": ["files this step creates or modifies"],
"commands": ["exact shell commands to run, if any"],
"acceptance": "how to verify this step succeeded"
}
]
}
Rules:
- Every file that needs to exist MUST have a step that creates it.
- Implementation steps MUST be ordered so dependencies are created before dependents.
- Include a setup step for directory structure and dependency checks.
- Include test/compile steps after implementation.
- Include a finalize step that does a clean build and verification.
- The "acceptance" field is critical — it defines what success looks like for each step.
- Be specific in commands. Not "compile the code" but "gcc -Wall -Wextra -o main main.c util.c -lm".
"""
class Planner:
def __init__(self, llm: LLM, logger: Logger, ctx: ContextManager, workdir: str):
self.llm = llm
self.logger = logger
self.ctx = ctx
self.workdir = workdir
self.plan_path = os.path.join(workdir, config.PLAN_FILE)
def read_description(self) -> str | None:
path = os.path.join(self.workdir, config.DESCRIPTION_FILE)
if not os.path.exists(path):
return None
with open(path, "r") as f:
return f.read().strip()
def read_manuals(self) -> dict[str, str]:
manuals = {}
mdir = os.path.join(self.workdir, config.MANUALS_DIR)
if not os.path.isdir(mdir):
return manuals
for fname in sorted(os.listdir(mdir)):
fpath = os.path.join(mdir, fname)
if os.path.isfile(fpath):
try:
with open(fpath, "r") as f:
manuals[fname] = f.read()
except (IOError, UnicodeDecodeError):
self.logger.log("manual_skip", f"Could not read {fname}", "warn")
return manuals
def create_plan(self, description: str, manuals: dict[str, str],
existing_work: str = "") -> dict:
prompt_parts = [
"## Project Description\n"
"Read this VERY carefully. Every sentence is a requirement.\n\n"
f"{description}"
]
for name, content in manuals.items():
prompt_parts.append(f"## Reference Manual: {name}\n{content[:6000]}")
if existing_work:
prompt_parts.append(
"## Existing Work\n"
"The following files already exist from a previous iteration. "
"Your plan should BUILD ON what exists — only add steps for NEW or CHANGED requirements. "
"Do NOT recreate files that already satisfy the requirements.\n\n"
f"{existing_work}"
)
prompt = "\n\n".join(prompt_parts)
self.ctx.add("user", prompt, priority=9)
self.logger.log("planning", "Querying LLM for development plan")
response = self.llm.query(prompt, system=SYSTEM_PROMPT, temperature=0.3)
self.ctx.add("assistant", response, priority=8)
plan = self._parse_plan(response)
# Validate plan has required fields
if not plan.get("steps"):
# Retry once with more explicit instruction
self.logger.log("plan_retry", "First plan had no steps, retrying", "warn")
retry_prompt = (
prompt + "\n\nIMPORTANT: Your previous response could not be parsed. "
"You MUST output ONLY a JSON object. No text before or after. "
"Start your response with { and end with }."
)
response = self.llm.query(retry_prompt, system=SYSTEM_PROMPT, temperature=0.1)
plan = self._parse_plan(response)
self._save_plan(plan)
self.logger.log("plan_created", f"{len(plan.get('steps', []))} steps")
return plan
def load_existing_plan(self) -> dict | None:
if os.path.exists(self.plan_path):
try:
with open(self.plan_path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
return None
def _parse_plan(self, response: str) -> dict:
text = response.strip()
# Try progressively more aggressive extraction
for extracted in self._extract_json_candidates(text):
try:
plan = json.loads(extracted)
if isinstance(plan, dict):
return plan
except json.JSONDecodeError:
continue
self.logger.log("plan_parse_fail", "Could not parse plan JSON", "error")
return {"steps": [], "error": "Failed to parse plan", "raw": response[:1000]}
def _extract_json_candidates(self, text: str) -> list[str]:
"""Yield JSON candidates from most to least likely."""
candidates = []
# 1. Markdown fenced JSON
if "```json" in text:
block = text.split("```json", 1)[1].split("```", 1)[0].strip()
candidates.append(block)
elif "```" in text:
block = text.split("```", 1)[1].split("```", 1)[0].strip()
candidates.append(block)
# 2. Raw text (entire response)
candidates.append(text)
# 3. Find outermost { ... } with brace matching
start = text.find("{")
if start >= 0:
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
candidates.append(text[start:i + 1])
break
return candidates
def _save_plan(self, plan: dict):
with open(self.plan_path, "w") as f:
json.dump(plan, f, indent=2)