182 lines
7.2 KiB
Python
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)
|