Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions forgegod/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <think> tags in message history for MiniMax M2.7.
# MiniMax uses <think> blocks for chain-of-thought. Stripping them before
# storing in self.messages destroys context and causes infinite loops.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions forgegod/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
149 changes: 145 additions & 4 deletions forgegod/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading