diff --git a/forgegod/agent.py b/forgegod/agent.py index 49ffad6..030f22c 100644 --- a/forgegod/agent.py +++ b/forgegod/agent.py @@ -67,6 +67,9 @@ "yarn lint", "bun run lint", "cargo test", "go test", "deno test", "python ", "python3 ", "node ", "php ", "ruby ", "bash ", "sh ", "curl ", "wget ", "ping ", "openssl ", + # C/C++ build & test tools + "cmake", "make ", "ninja", "ctest", "g++", "clang++", + "gcc ", "clang ", "meson ", "nasm ", ) PERMISSION_ERROR_MARKERS = ( "blocked in read-only permission mode", @@ -247,12 +250,14 @@ def __init__( self._gutter_tracker: dict[str, int] = {} # action_hash -> repeat count self._error_solutions_used: list[str] = [] # avoid re-injecting same solution self._post_edit_verification_commands: list[str] = [] + self._last_verification_exit_code: int | None = None # exit code of last verification bash command self._reviewed_final_diff = False self._last_write_turn = -1 # turn number of the last write_file/edit_file call self._bash_ran_after_last_write = False # True if bash ran after the last write self._closure_ready_turns = 0 self._completion_closeout_prompted = False self._auto_research_count = 0 # auto-research trigger count + self._consecutive_empty_responses = 0 # abort story after 4 in a row self._last_denied_tool: str | None = None # tool name from last permission error self._latest_research_brief: ResearchBrief | None = None @@ -340,6 +345,7 @@ async def run(self, task: str) -> AgentResult: requires_code_changes and self.config.agent.research_before_code and self.config.security.permission_mode != "read-only" + and "Build/Test failure" not in task ): await self._maybe_auto_research( AutoResearchReason.MANUAL, @@ -398,6 +404,53 @@ async def run(self, task: str) -> AgentResult: self._accumulate_usage(usage) self.budget.record(usage, role=self.role) + # Detect consecutive empty responses — the model may be "giving up" + # on a context it can't handle. After 4 in a row, abort the story + # so it gets a fresh agent instead of looping with poisoned context. + if not response_text or not response_text.strip(): + self._consecutive_empty_responses += 1 + logger.warning( + "Empty model response (%d consecutive), turn=%d", + self._consecutive_empty_responses, self._turn, + ) + if self._consecutive_empty_responses >= 4: + logger.warning( + "Aborting story after %d consecutive empty responses", + self._consecutive_empty_responses, + ) + failed = self._build_result( + success=False, + output=( + "[Agent aborted: model returned empty responses " + f"{self._consecutive_empty_responses} times in a row. " + "Story will retry with a fresh agent.]" + ), + elapsed=time.time() - start, + error="consecutive empty responses", + ) + await self._emit_event( + "task_failed", + error=failed.error, + output=failed.output, + ) + await self._record_episode(task_id, task, failed) + return failed + # Add a minimal message so the conversation can continue + self.messages.append({ + "role": "assistant", + "content": "", + }) + self.messages.append({ + "role": "user", + "content": ( + "[The model returned an empty response. " + "Please continue working on the task.]" + ), + }) + continue + else: + self._consecutive_empty_responses = 0 + # BUG #1 FIX: Preserve tags in message history for MiniMax M2.7. # MiniMax uses blocks for chain-of-thought. Stripping them before # storing in self.messages destroys context and causes infinite loops. @@ -1314,6 +1367,7 @@ def _record_completion_signal(self, tc: ToolCall, result: ToolResult): if tc.name in {"write_file", "edit_file"}: self._post_edit_verification_commands = [] + self._last_verification_exit_code = None self._reviewed_final_diff = False self._closure_ready_turns = 0 self._completion_closeout_prompted = False @@ -1335,6 +1389,10 @@ def _record_completion_signal(self, tc: ToolCall, result: ToolResult): lowered = command.lower() if any(marker in lowered for marker in VERIFICATION_COMMAND_MARKERS): self._post_edit_verification_commands.append(command) + # Parse exit code from bash output (format: "...\n[exit code: N]") + m = re.search(r'\[exit code: (\d+)\]', result.content) + if m: + self._last_verification_exit_code = int(m.group(1)) # Track that bash ran after a write — waives git_diff requirement if self._last_write_turn >= 0: self._bash_ran_after_last_write = True @@ -1429,6 +1487,19 @@ def _completion_blockers(self, requires_code_changes: bool) -> list[str]: "code change (tests, lint, build, or typecheck)." ) + # Block completion if the last verification command failed (non-zero exit). + # Running a build/test that fails is NOT verification — it's evidence the code is broken. + if ( + self._post_edit_verification_commands + and self._last_verification_exit_code is not None + and self._last_verification_exit_code != 0 + ): + blockers.append( + f"Your last verification command exited with code " + f"{self._last_verification_exit_code} — the code is not passing. " + f"Fix the build/test failures before completing." + ) + return blockers def _maybe_force_closeout( diff --git a/forgegod/config.py b/forgegod/config.py index a230a1c..3cb44a1 100644 --- a/forgegod/config.py +++ b/forgegod/config.py @@ -320,6 +320,21 @@ class SOTAMonitorConfig(BaseModel): cache_ttl_hours: int = 24 + +class VerifyConfig(BaseModel): + """Build/test verification gate — enforces compilation before story completion. + + When enabled, the loop runs build_command (and optionally test_command) as a + subprocess before marking a story DONE. Non-zero exit routes the story back + to TODO with the compiler output appended to error_log. + """ + + enabled: bool = True + build_command: str = "" # e.g. "cmake --build build" + test_command: str = "" # e.g. "ctest --test-dir build --output-on-failure" + timeout_s: float = 300.0 + max_fail_lines: int = 50 # truncate compiler output stored in error_log + class SubagentsConfig(BaseModel): """Parallel subagent orchestration settings.""" @@ -396,6 +411,7 @@ class ForgeGodConfig(BaseModel): effort: EffortConfig = Field(default_factory=EffortConfig) deep_research: DeepResearchConfig = Field(default_factory=DeepResearchConfig) sota_monitor: SOTAMonitorConfig = Field(default_factory=SOTAMonitorConfig) + verify: VerifyConfig = Field(default_factory=VerifyConfig) # Runtime paths (not from config file) global_dir: Path = DEFAULT_GLOBAL_DIR diff --git a/forgegod/loop.py b/forgegod/loop.py index 8334a71..1e69a2e 100644 --- a/forgegod/loop.py +++ b/forgegod/loop.py @@ -613,12 +613,12 @@ def _handle_story_failure( async def _collect_review_code(self, result, review_code: str | None = None) -> str: """Collect the best available code artifact for reviewer analysis.""" if review_code: - return review_code[:6000] + return review_code[:12000] return await collect_review_artifact( self._workspace_root, files_changed=result.files_modified, fallback_text=result.output, - max_chars=6000, + max_chars=12000, ) async def _current_dirty_files(self) -> set[str]: @@ -723,6 +723,91 @@ async def _lint_check(self, files_modified: list[str] | None) -> bool: return True + async def _run_verify_command( + self, command: str, story: Story, gate_name: str + ) -> bool: + """Run a build/test command. Returns True on exit 0, else routes story back to TODO. + + On failure, the story is sent back to TODO with the compiler output + (tail-truncated) appended to error_log, so the next coder attempt + sees the exact errors. + """ + try: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(self._workspace_root), + ) + stdout, _ = await asyncio.wait_for( + proc.communicate(), timeout=self.config.verify.timeout_s + ) + output = stdout.decode(errors="replace") + if proc.returncode != 0: + lines = output.splitlines() + max_lines = self.config.verify.max_fail_lines + # Extract error lines first — compiler errors contain + # "error:", "fatal", "undefined reference", etc. These + # are what the agent needs to fix; the surrounding build + # progress ("[ 3%] Built target...") is noise. + error_patterns = ( + "error:", "Error ", "fatal", "undefined reference", + "cannot find", "No rule to make", "CMake Error", + "FAILED:", "collect2:", + ) + error_lines = [ + line for line in lines + if any(p in line for p in error_patterns) + ] + if error_lines: + truncated = "\n".join(error_lines[-max_lines:]) + else: + # No recognized error pattern — fall back to tail + tail = lines[-max_lines:] if len(lines) > max_lines else lines + truncated = "\n".join(tail) + logger.warning( + f"Story [{story.id}] {gate_name} gate FAILED " + f"(exit {proc.returncode})" + ) + story.status = StoryStatus.TODO + story.error_log.append( + f"{gate_name.capitalize()} failed (exit {proc.returncode}):\n" + f"{truncated}" + ) + self.prd.learnings.append( + f"[{story.id}] {gate_name.capitalize()} gate failed — " + f"see error_log for compiler output" + ) + self._save_prd() + return False + logger.info(f"Story [{story.id}] {gate_name} gate passed") + return True + except asyncio.TimeoutError: + logger.warning( + f"Story [{story.id}] {gate_name} gate timed out " + f"after {self.config.verify.timeout_s}s" + ) + story.status = StoryStatus.TODO + story.error_log.append( + f"{gate_name.capitalize()} command timed out " + f"after {self.config.verify.timeout_s}s" + ) + self._save_prd() + return False + except FileNotFoundError as e: + logger.warning( + f"Story [{story.id}] {gate_name} gate: " + f"command not found — skipping ({e})" + ) + # Don't block on missing toolchain (e.g. cmake not installed) + return True + except Exception as e: + logger.warning( + f"Story [{story.id}] {gate_name} gate error, skipping: {e}" + ) + # Don't block on infrastructure errors + return True + async def _finalize_story_result( self, story: Story, @@ -798,6 +883,20 @@ async def _finalize_story_result( self._save_prd() return + # ── Build/Test Gate — require compilation success ──────────────── + if self.config.verify.enabled and self.config.verify.build_command: + build_ok = await self._run_verify_command( + self.config.verify.build_command, story, "build" + ) + if not build_ok: + return + if self.config.verify.enabled and self.config.verify.test_command: + test_ok = await self._run_verify_command( + self.config.verify.test_command, story, "test" + ) + if not test_ok: + return + # ── End Build/Test Gate ─────────────────────────────────────────── # ── Effort Gate ────────────────────────────────────────────────────── if self.effort_gate: try: @@ -949,6 +1048,35 @@ async def _finalize_story_result( logger.info(f"Story [{story.id}] pushed to origin/main") except Exception: logger.debug("Auto-push skipped") + # ── Phase completion check — generate checklist + stop loop ────── + phase = story.id.split('-')[0] if '-' in story.id else None + if phase: + all_done = all( + s.status in (StoryStatus.DONE, StoryStatus.BLOCKED, StoryStatus.SKIPPED) + for s in self.prd.stories + if s.id.startswith(phase + '-') + ) + has_checklist = (self.config.project_dir / f"checklist_{phase}.xlsx").exists() + if all_done and not has_checklist: + import subprocess + checklist_path = self.config.project_dir / f"checklist_{phase}.xlsx" + gen_script = self.config.project_dir.parent / "tests" / "generate_checklist.py" + py_bin = str(self.config.project_dir.parent / ".venv" / "bin" / "python") + try: + subprocess.run( + [py_bin, str(gen_script), phase, "-f", "xlsx", + "-o", str(checklist_path)], + cwd=str(self._workspace_root), + capture_output=True, timeout=10, + ) + logger.info(f"Phase {phase} complete — checklist generated: {checklist_path}") + except Exception as e: + logger.warning(f"Could not generate checklist for phase {phase}: {e}") + # Drop killswitch so the loop stops for manual testing + killswitch = self.config.project_dir / "KILLSWITCH" + killswitch.touch() + logger.info(f"Phase {phase} complete — killswitch placed, loop will stop") + # ── End phase completion check ─────────────────────────────────────── else: self._handle_story_failure(story, result.error or result.output) self._export_story_summary(story, result=result) @@ -1102,9 +1230,22 @@ async def _build_story_prompt(self, story: Story) -> str: prompt += f"- {learning}\n" if story.error_log: - prompt += "\n## Previous attempt errors (FIX THESE)\n" + last_err = story.error_log[-1] + # Build/test gate failures just need compiler errors fixed — + # no research needed. Other failures (reviewer, timeout, crash) + # may indicate a wrong approach, so research is still warranted. + is_build_failure = ( + last_err.startswith("Build failed") + or last_err.startswith("Test failed") + ) + header = ( + "## Build/Test failure — fix compiler errors (no research needed)" + if is_build_failure + else "## Previous attempt errors (FIX THESE)" + ) + prompt += f"\n{header}\n" for err in story.error_log[-2:]: - prompt += f"- {err[:500]}\n" + prompt += f"- {err[:2000]}\n" # Inject relevant memory for this story if self.memory: diff --git a/forgegod/router.py b/forgegod/router.py index 0909403..32d7853 100644 --- a/forgegod/router.py +++ b/forgegod/router.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import asyncio import json import logging @@ -834,48 +835,101 @@ async def _call_openrouter( body["tools"] = tools client = self._get_client("openrouter", timeout=120.0) - # Boundary 1 — what goes IN to the LLM - self.wire_logger.debug( - ">> SENT [%s] | %d msgs | msgs=%r", - model, - len(messages), - body["messages"], - ) - resp = await client.post( - "https://openrouter.ai/api/v1/chat/completions", - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - json=body, - ) - resp.raise_for_status() - data = resp.json() - # Boundary 2 — what comes OUT of the LLM - self.wire_logger.debug( - "<< RECV [%s] | raw=%r", - model, - data, - ) - choice = data["choices"][0] - msg = choice.get("message", {}) - content = msg.get("content", "") + # Retry on empty/null content — with a strict provider filter, + # OpenRouter may return "content": null when no provider is + # available for the requested model. Retry gives the router a + # chance to find a provider on subsequent attempts. + max_retries = 3 + retry_delay = 4.0 # seconds between attempts + content = "" + usage: dict[str, Any] = {} - # Handle OpenRouter tool calls (OpenAI-compatible format) - tool_calls_raw = msg.get("tool_calls", []) - if tool_calls_raw: - tool_calls_json = [] - for tc in tool_calls_raw: - fn = tc.get("function", {}) - tool_calls_json.append({ - "id": tc.get("id", ""), - "name": fn.get("name", ""), - "arguments": fn.get("arguments", "{}"), - }) - content = json.dumps({"tool_calls": tool_calls_json}) + for attempt in range(max_retries + 1): + # Boundary 1 — what goes IN to the LLM + self.wire_logger.debug( + ">> SENT [%s] | %d msgs | attempt=%d/%d | msgs=%r", + model, len(messages), attempt + 1, max_retries + 1, + body["messages"], + ) + resp = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=body, + ) + resp.raise_for_status() + data = resp.json() + # Boundary 2 — what comes OUT of the LLM + self.wire_logger.debug( + "<< RECV [%s] | attempt=%d/%d | raw=%r", + model, attempt + 1, max_retries + 1, data, + ) + + choices = data.get("choices", []) + if not choices: + # No choices — no provider was available for this model + if attempt < max_retries: + logger.warning( + "OpenRouter returned no choices (attempt %d/%d), " + "retrying in %.0fs", + attempt + 1, max_retries + 1, retry_delay, + ) + await asyncio.sleep(retry_delay) + continue + logger.warning( + "OpenRouter returned no choices after %d attempts", + max_retries + 1, + ) + usage = data.get("usage", {}) + break + + choice = choices[0] + msg = choice.get("message", {}) + # Normalize None -> "" — OpenRouter returns "content": null + # when the model emits only tool calls or hits a content filter. + # The bare .get("content", "") default does NOT cover this case + # because the key *exists* with value null. + content = msg.get("content") or "" + + # Handle OpenRouter tool calls (OpenAI-compatible format) + tool_calls_raw = msg.get("tool_calls", []) + if tool_calls_raw: + tool_calls_json = [] + for tc in tool_calls_raw: + fn = tc.get("function", {}) + tool_calls_json.append({ + "id": tc.get("id", ""), + "name": fn.get("name", ""), + "arguments": fn.get("arguments", "{}"), + }) + content = json.dumps({"tool_calls": tool_calls_json}) + # Tool calls are a valid response — no retry needed + usage = data.get("usage", {}) + break + + if content: + # Got text content — done + usage = data.get("usage", {}) + break + + # Empty content, no tool calls — retry if attempts remain + usage = data.get("usage", {}) + if attempt < max_retries: + logger.warning( + "OpenRouter returned empty response (attempt %d/%d), " + "retrying in %.0fs", + attempt + 1, max_retries + 1, retry_delay, + ) + await asyncio.sleep(retry_delay) + else: + logger.warning( + "OpenRouter returned empty response after %d attempts", + max_retries + 1, + ) - usage = data.get("usage", {}) return content, { "input_tokens": usage.get("prompt_tokens", 0), "output_tokens": usage.get("completion_tokens", 0),