feat: add create mode for new files
Auto-detects create vs edit based on whether target_path exists. Edit jobs use unified diff/patch (existing behaviour). Create jobs ask the model to output the complete new file, validated by file type (_validate_output handles .py compile, .html tag check, generic non-empty check). - _resolve_target: handles /opt/sites/ and /opt/stacks/ paths - _CREATOR_SYSTEM + _build_create_prompt + _ask_creator: full-file output - _validate_output: replaces direct _syntax_check calls in the loop - run_coding_loop: branches on mode, stores mode on job dict - approve: mkdir parents for new paths, skip backup if no original - submit_task: initialise mode field as None Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,12 @@ JOBS_FILE = STACKS_DIR / "qyburn-coder" / "jobs.json"
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _resolve_target(target_path: str) -> Path:
|
||||
if target_path.startswith("/opt/"):
|
||||
return Path(target_path)
|
||||
return STACKS_DIR / target_path
|
||||
|
||||
|
||||
def _save_jobs():
|
||||
try:
|
||||
JOBS_FILE.write_text(json.dumps(jobs, indent=2))
|
||||
@@ -110,12 +116,43 @@ def _syntax_check(code: str, filename: str) -> str | None:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _validate_output(content: str, filename: str) -> str | None:
|
||||
"""Returns error string or None if valid. Handles .py, .html, and generic files."""
|
||||
if not content.strip():
|
||||
return "model returned empty output"
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext == ".py":
|
||||
return _syntax_check(content, filename)
|
||||
if ext in (".html", ".htm"):
|
||||
low = content.lower()
|
||||
if "<html" not in low and "<!doctype" not in low:
|
||||
return "output does not appear to be valid HTML (missing <html> or <!DOCTYPE>)"
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _make_diff(original: str, modified: str, filename: str) -> str:
|
||||
a = original.splitlines(keepends=True)
|
||||
b = modified.splitlines(keepends=True)
|
||||
return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)"
|
||||
|
||||
|
||||
def _build_create_prompt(job: dict, attempt: int, last_error: str | None) -> str:
|
||||
lines = [f"Task: {job['description']}"]
|
||||
if job.get("constraints"):
|
||||
lines.append(f"Constraints: {job['constraints']}")
|
||||
if attempt > 1 and last_error:
|
||||
lines.append(f"\nPrevious attempt failed:\n{last_error}")
|
||||
lines.append("Fix the issue in the output.")
|
||||
lines += [
|
||||
"",
|
||||
f"Create the file: {job['target_path']}",
|
||||
"",
|
||||
"Output the complete file content:",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str:
|
||||
lines = [f"Task: {job['description']}"]
|
||||
if job.get("constraints"):
|
||||
@@ -167,6 +204,28 @@ _CODER_SYSTEM = (
|
||||
"+ added lines, - removed lines."
|
||||
)
|
||||
|
||||
_CREATOR_SYSTEM = (
|
||||
"You are a code and file generator. "
|
||||
"When given a task, output ONLY the complete new file as raw text. "
|
||||
"No markdown fences, no explanations, no preamble. "
|
||||
"Output only the file content itself, starting with the very first character of the file."
|
||||
)
|
||||
|
||||
|
||||
async def _ask_creator(task_prompt: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
|
||||
"model": CODER_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": _CREATOR_SYSTEM},
|
||||
{"role": "user", "content": task_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1, "num_predict": 8192},
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()["message"]["content"]
|
||||
|
||||
|
||||
async def _ask_coder(task_prompt: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
@@ -227,17 +286,18 @@ async def run_coding_loop(job_id: str):
|
||||
job["status"] = "running"
|
||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
target = STACKS_DIR / job["target_path"]
|
||||
if not target.exists():
|
||||
job["status"] = "failed"
|
||||
job["error"] = f"target file not found: {job['target_path']}"
|
||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
logger.error(f"[{job_id[:8]}] {job['error']}")
|
||||
return
|
||||
target = _resolve_target(job["target_path"])
|
||||
mode = "edit" if target.exists() else "create"
|
||||
job["mode"] = mode
|
||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if mode == "edit":
|
||||
original = target.read_text()
|
||||
else:
|
||||
original = ""
|
||||
logger.info(f"[{job_id[:8]}] target does not exist — create mode")
|
||||
|
||||
await _warm_coder_model()
|
||||
|
||||
original = target.read_text()
|
||||
sandbox_dir = SANDBOX_DIR / job_id
|
||||
sandbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
sandbox_file = sandbox_dir / Path(job["target_path"]).name
|
||||
@@ -258,37 +318,43 @@ async def run_coding_loop(job_id: str):
|
||||
logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}")
|
||||
|
||||
try:
|
||||
response = await _ask_coder(_build_prompt(job, original, attempt, last_error))
|
||||
if mode == "edit":
|
||||
response = await _ask_coder(_build_prompt(job, original, attempt, last_error))
|
||||
else:
|
||||
response = await _ask_creator(_build_create_prompt(job, attempt, last_error))
|
||||
except Exception as e:
|
||||
last_error = f"Ollama error: {e}"
|
||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}")
|
||||
continue
|
||||
|
||||
job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4
|
||||
diff_text = _extract_diff(response)
|
||||
last_diff = diff_text
|
||||
raw = _extract_diff(response)
|
||||
last_diff = raw
|
||||
|
||||
patched, patch_error = _apply_diff(original, diff_text)
|
||||
if patch_error:
|
||||
last_error = patch_error
|
||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
|
||||
continue
|
||||
if mode == "edit":
|
||||
content, patch_error = _apply_diff(original, raw)
|
||||
if patch_error:
|
||||
last_error = patch_error
|
||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
|
||||
continue
|
||||
else:
|
||||
content = raw
|
||||
|
||||
syntax_error = _syntax_check(patched, Path(job["target_path"]).name)
|
||||
if syntax_error is None:
|
||||
sandbox_file.write_text(patched)
|
||||
error = _validate_output(content, Path(job["target_path"]).name)
|
||||
if error is None:
|
||||
sandbox_file.write_text(content)
|
||||
job["status"] = "pending_review"
|
||||
job["diff"] = diff_text
|
||||
job["diff"] = raw if mode == "edit" else f"[NEW FILE — {Path(job['target_path']).name}]\n\n{content}"
|
||||
job["sandbox_file"] = str(sandbox_file)
|
||||
job["original"] = original
|
||||
job["error"] = None
|
||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
logger.info(f"[{job_id[:8]}] patch OK on attempt {attempt} — pending review")
|
||||
logger.info(f"[{job_id[:8]}] {mode} OK on attempt {attempt} — pending review")
|
||||
_write_last_run()
|
||||
return
|
||||
|
||||
last_error = syntax_error
|
||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} syntax error: {syntax_error[:120]}")
|
||||
last_error = error
|
||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} error: {error[:120]}")
|
||||
|
||||
_write_handoff(job, original, last_diff)
|
||||
job["status"] = "escalated"
|
||||
@@ -463,7 +529,7 @@ async def submit_task(req: TaskRequest, background_tasks: BackgroundTasks):
|
||||
"constraints": req.constraints, "attempts": 0,
|
||||
"created_at": now, "updated_at": now,
|
||||
"cancelled": False, "error": None, "diff": None,
|
||||
"sandbox_file": None, "original": None,
|
||||
"mode": None, "sandbox_file": None, "original": None,
|
||||
"tokens_estimated": 0,
|
||||
}
|
||||
background_tasks.add_task(run_coding_loop, job_id)
|
||||
@@ -521,9 +587,11 @@ async def approve_job(job_id: str, request: Request):
|
||||
sandbox_file = Path(job["sandbox_file"])
|
||||
if not sandbox_file.exists():
|
||||
raise HTTPException(status_code=500, detail="sandbox file missing")
|
||||
target = STACKS_DIR / job["target_path"]
|
||||
backup = target.with_suffix(target.suffix + ".qyburn-bak")
|
||||
backup.write_text(job["original"])
|
||||
target = _resolve_target(job["target_path"])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if job.get("original"):
|
||||
backup = target.with_suffix(target.suffix + ".qyburn-bak")
|
||||
backup.write_text(job["original"])
|
||||
target.write_text(sandbox_file.read_text())
|
||||
job["status"] = "applied"
|
||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
Reference in New Issue
Block a user