016b348117
Phase 0 (foundation):
- Cause enum (controller_events.cause) for action attribution
- Schema: grooming_decisions audit table; workflows gains
grooming_evaluated_at + deferred_reason + deferred_at +
deferred_target_workflow_id; pulls gains touched_files
- audit_comments: CLOSE / DEFER templates + render_comment_template
- forgejo_writes: close_issue + defer_issue 5-step crash-safe protocol
(fingerprint dedup, error matrix, dry-run)
- patch_pr_state callback in forgejo_http
- grooming_config: 22-env-var frozen-dataclass config + log_effective
- pulls.touched_files cache extension (_pipeline_cache.py schema v8)
- reaper.reap_grooming_decisions audit-retention sweep
- reconciliation RESUME guard (deferred_reason)
Phase 1 (worker-queue shape, 2026-05-25):
- New state: GROOMING. New events: grooming_started, groom_verdict_
{proceed,defer,close}. 5 new transitions; all invariants still clean
- GroomingInputV1 + GroomingOutputV1 Pydantic contracts
- outcomes._map_grooming_outcome routes verdicts to state-machine events
- prefetch.build_grooming_stage_b_input + list_open_prs callback
- scheduler GROOMING -> grooming_stage_b role
- promote: cfg-gated DISCOVERED -> GROOMING when CONTROLLER_GROOMING_
ENABLED=true; issues skip grooming
- forgejo_writes decomposed: close_act/defer_act (Forgejo writes only;
state-machine already transitioned) + close_decide_and_act/
defer_decide_and_act (Phase 0 callers); _apply_workflow_transition
is underscore-private
- grooming.py library: tokenization, suspicion scoring (Jaccard +
weighted overlap), deterministic checks, action -> verdict mapping
- mcp/grooming_builder.py: 14-tool FastMCP server emits GroomingOutputV1
- .opencode/agents/grooming-stage-b.md: duplicate-detection agent
prompt (claude-haiku-4-5)
- grooming_side_effects.run_grooming_side_effects_tick: per-state tick
performs Forgejo writes after groom_verdict_{defer,close} fires.
Filters on event_type='transition' + payload.event (centralizes the
convention pending Phase 2's latest_transition_event helper)
- GroomingCallbacks frozen dataclass; loop.py + __main__.py wired
Worker role registry (single source of truth):
- worker/roles.py: WORKER_ROLES + WorkerRoleSpec + default_roles_csv
+ output_filename_for. agent_runner.ROLE_TO_MCP_MODULE / ROLE_TO_
OUTPUT_MODEL derive from it; opencode_session.agent_name_for reads
it for flat cases; all 6 prompt builders use output_filename_for;
worker --roles default = default_roles_csv(); launcher script
derives --roles via shell substitution. Cross-site invariant test
enforces alignment across 5 sites + opencode.json MCP registry.
Phase 0 silent-bug fix:
- reconciliation.py RESUME guard SELECT now includes deferred_reason
(was missing since Phase 0; guard was a silent no-op). Tightened
from getattr to attribute access to fail fast on future omissions.
Tests (1456 total, +91 grooming-specific):
- test_grooming_phase0.py: 34 tests (orchestrator matrix, crash
recovery, idempotency, dry-run)
- test_grooming_phase1.py: 60 tests (library, contracts, state
machine, outcomes, scheduler, promote, prefetch, act-variants
with signature parity, side-effect tick incl. natural-idempotency
+ executed-flag-skip + verdict-mismatch + reconciliation RESUME)
- test_mcp_builders.py TestGroomingBuilder: 29 tests (happy paths
+ 22 validation rules + Pydantic round-trip + master-tick-read-
path companion)
- test_worker_agent_runner.py TestRoleMaps: cross-role wiring
alignment + agent-prompt-vs-worker-fallback filename contract +
inspect.signature equality (close_act/defer_act vs
close_issue/defer_issue)
- test_state_machine.py: transition count 51 -> 56 +
events_from_grooming
Live-validated end-to-end on 4 staged sentinel PRs (#55-#58) in
dry_run: agent emits verdicts via MCP, state-machine transitions
fire, side-effect tick writes audit row, deferred_reason gates
reconciliation RESUME correctly.
Deferred refinements + Phase 2 prerequisite (latest_transition_event
helper) tracked in .drew/regressions-plan.md "Phase 1 follow-up
backlog".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1231 lines
53 KiB
Python
1231 lines
53 KiB
Python
"""Per-role prompt builders for the controller's worker sessions.
|
||
|
||
The prompt is the entire human-language frame the LLM sees when it
|
||
enters the OpenCode session. The agent's own system prompt (in
|
||
``.opencode/agents/{name}.md``) provides the HOW; this module provides
|
||
the WHAT — role-specific context drawn from the input_payload V1
|
||
contract.
|
||
|
||
Trial-path output channel (Phase 1k+++):
|
||
The controller's response-builder MCP layer is not yet wired into
|
||
OpenCode (not in ``.opencode/opencode.json``), so the agent CAN'T
|
||
call ``implementer_finalize`` etc. via MCP. For the trial we use a
|
||
direct file-write fallback: the prompt tells the agent to write its
|
||
V1 output JSON to ``{workspace_dir}/{role}_output.json``. The
|
||
controller polls that path after the session completes. The MCP
|
||
calls remain documented in the prompt as the PREFERRED path so that
|
||
once they're wired (Phase 1m) the agent transparently picks them
|
||
up.
|
||
|
||
Design constraints:
|
||
- Short. The system prompt is the long-form instruction; this one is
|
||
the per-attempt context.
|
||
- Structured. Each section has an H2 header so the agent (and humans
|
||
reading session transcripts) can navigate.
|
||
- Truncated. Long fields (full_diff, prior attempts) are bounded so a
|
||
pathological PR can't blow the context window.
|
||
- Robust. Missing-but-required fields render as ``(unavailable)``
|
||
rather than blowing the prompt build.
|
||
|
||
Each builder takes the role's V1 input_payload dict and returns a
|
||
plain string. The OpenCode session adapter passes the string straight
|
||
to OpenCode as the initial prompt.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from .roles import output_filename_for as _output_filename_for
|
||
|
||
# Truncation budgets (chars). Tuned for ~32k context windows with
|
||
# headroom for the system prompt + tool calls.
|
||
_DIFF_BUDGET_CHARS = 12_000
|
||
_PRIOR_ATTEMPT_BUDGET_CHARS = 2_000
|
||
_BODY_BUDGET_CHARS = 4_000
|
||
# Per-gate raw-log budget. CISummary stores up to 16KB per gate; we
|
||
# trim to 4KB in the prompt so a multi-gate failure doesn't blow the
|
||
# context window. The CI parser already tail-selects the excerpt (last
|
||
# ~80 lines), so the failure tracebacks + the run summary live at the
|
||
# BOTTOM — we trim from the head here too (see _truncate_tail) so that
|
||
# bottom always survives. Head-clipping it (the pre-2026-05-20 bug) cut
|
||
# off the actual failures and left the implementer flying blind.
|
||
_CI_LOG_EXCERPT_BUDGET_CHARS = 4_000
|
||
|
||
|
||
# ─── small helpers ───────────────────────────────────────────────────
|
||
|
||
|
||
def _truncate(text: str, limit: int) -> str:
|
||
"""Truncate a string with a clear marker — preserves the leading
|
||
portion (most signal-dense for diffs and bodies)."""
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[:limit] + f"\n\n[...truncated; original was {len(text)} chars]"
|
||
|
||
|
||
def _truncate_tail(text: str, limit: int) -> str:
|
||
"""Truncate keeping the TAIL — for content whose signal lives at the
|
||
bottom. CI log excerpts are tail-selected by the parser, so the
|
||
failure tracebacks + run summary are at the end; head-clipping them
|
||
(what _truncate does) would drop exactly the part the implementer
|
||
needs."""
|
||
if len(text) <= limit:
|
||
return text
|
||
return f"[...truncated; original was {len(text)} chars]\n\n" + text[-limit:]
|
||
|
||
|
||
def _fmt_or_unavailable(value: Any, *, fallback: str = "(unavailable)") -> str:
|
||
"""Render a value safely; falls back when None/empty/non-string."""
|
||
if value is None:
|
||
return fallback
|
||
if isinstance(value, str):
|
||
return value if value else fallback
|
||
return str(value)
|
||
|
||
|
||
def _fmt_attempt(attempt: dict) -> str:
|
||
"""One line per fact about a prior implementer attempt."""
|
||
parts = [
|
||
f"outcome={attempt.get('outcome', '?')}",
|
||
f"tier={attempt.get('used_tier', '?')}",
|
||
]
|
||
files = attempt.get("files_touched") or []
|
||
if files:
|
||
parts.append(
|
||
f"files=[{', '.join(files[:5])}]"
|
||
+ (f" (+{len(files) - 5} more)" if len(files) > 5 else "")
|
||
)
|
||
blockers = attempt.get("blockers") or []
|
||
if blockers:
|
||
parts.append(f"blockers=[{'; '.join(blockers[:3])}]")
|
||
return " ".join(parts)
|
||
|
||
|
||
_REVIEW_BODY_BUDGET_CHARS = 4000
|
||
|
||
|
||
def _fmt_review(review: dict) -> str:
|
||
"""Render one active review with FULL body for the implementer prompt.
|
||
|
||
Why: the implementer must see WHAT the reviewer is asking for, not just
|
||
that a review exists. Trial-5 showed that collapsing the body to its first
|
||
line silently dropped the actual blocking-issues detail, so the implementer
|
||
re-ran with no actionable guidance and emitted ``outcome=noop``.
|
||
"""
|
||
state = review.get("state", "?")
|
||
login = review.get("reviewer_login", "?")
|
||
submitted = review.get("submitted_at") or ""
|
||
body = (review.get("body") or "").strip() or "(no body)"
|
||
if len(body) > _REVIEW_BODY_BUDGET_CHARS:
|
||
body = body[:_REVIEW_BODY_BUDGET_CHARS] + "\n\n…(truncated)"
|
||
header = f"### {login} ({state})"
|
||
if submitted:
|
||
header += f" — submitted {submitted}"
|
||
return f"{header}\n{body}"
|
||
|
||
|
||
def _fmt_prior_attempts_block(block: dict | None) -> str:
|
||
"""Render a PriorAttemptsBlock dict."""
|
||
if not block:
|
||
return "_No prior attempts on this workflow._"
|
||
lines: list[str] = []
|
||
total = block.get("total_attempts", 0)
|
||
lines.append(f"Total prior attempts: {total}")
|
||
older_summary = block.get("older_summary")
|
||
if older_summary:
|
||
covers = block.get("older_summary_covers_through_attempt")
|
||
suffix = f" (covers through #{covers})" if covers else ""
|
||
lines.append(
|
||
f"\n**Summary of older attempts{suffix}:**\n"
|
||
+ _truncate(older_summary, _PRIOR_ATTEMPT_BUDGET_CHARS)
|
||
)
|
||
verbatim = block.get("verbatim") or []
|
||
if verbatim:
|
||
lines.append("\n**Most-recent attempts (oldest → newest):**")
|
||
for i, atm in enumerate(verbatim, 1):
|
||
lines.append(f"{i}. {_fmt_attempt(atm)}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _fmt_ci_summary(ci: dict | None) -> str:
|
||
"""Render a CISummary dict."""
|
||
if not ci:
|
||
return "_No CI summary available._"
|
||
head = (
|
||
f"Overall: **{ci.get('overall_state', '?')}** "
|
||
f"(passed={ci.get('gates_passed', '?')}, "
|
||
f"failed={ci.get('gates_failed', '?')}, "
|
||
f"skipped={ci.get('gates_skipped', '?')}, "
|
||
f"pending={ci.get('gates_pending', '?')})"
|
||
)
|
||
gates = ci.get("gates") or []
|
||
failing = [g for g in gates if g.get("status") in {"failed", "error"}]
|
||
if not failing:
|
||
return head + "\n\n(no failing gates)"
|
||
lines = [head, "", "**Failing gates:**"]
|
||
for g in failing[:10]:
|
||
lines.append(
|
||
f"- {g.get('name', '?')} "
|
||
f"(status={g.get('status', '?')}, "
|
||
f"severity={g.get('severity', '?')})"
|
||
)
|
||
target_url = g.get("target_url")
|
||
if isinstance(target_url, str) and target_url:
|
||
lines.append(f" - log: {target_url}")
|
||
failure = g.get("failure")
|
||
if isinstance(failure, dict):
|
||
summary = failure.get("summary_line")
|
||
if summary:
|
||
lines.append(f" - {summary}")
|
||
lines.extend(_fmt_failure_excerpt(failure, indent=" "))
|
||
for sub in failure.get("composite_findings") or []:
|
||
if not isinstance(sub, dict):
|
||
continue
|
||
sub_name = sub.get("parser_used", "?")
|
||
sub_summary = sub.get("summary_line") or ""
|
||
lines.append(f" - sub-finding ({sub_name}): {sub_summary}")
|
||
lines.extend(_fmt_failure_excerpt(sub, indent=" "))
|
||
if len(failing) > 10:
|
||
lines.append(f" (+{len(failing) - 10} more failing gates)")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _fmt_failure_excerpt(failure: dict, *, indent: str) -> list[str]:
|
||
"""Render the raw_log_excerpt (truncated) as fenced lines.
|
||
|
||
Returns [] if no excerpt is present, so a clean parser (e.g.
|
||
behave with no scenario failures parsed but with findings) doesn't
|
||
emit an empty log block.
|
||
"""
|
||
excerpt = failure.get("raw_log_excerpt")
|
||
if not isinstance(excerpt, str) or not excerpt.strip():
|
||
return []
|
||
trimmed = _truncate_tail(excerpt, _CI_LOG_EXCERPT_BUDGET_CHARS)
|
||
indented = "\n".join(indent + line for line in trimmed.splitlines())
|
||
return [
|
||
f"{indent}- log excerpt:",
|
||
f"{indent}```",
|
||
indented,
|
||
f"{indent}```",
|
||
]
|
||
|
||
|
||
# ─── per-role builders ───────────────────────────────────────────────
|
||
|
||
|
||
def build_implementer_prompt(input_payload: dict, *, tier: int) -> str:
|
||
"""Prompt for the implementer role.
|
||
|
||
Implementer needs: PR + branch coordinates, CI failures to address,
|
||
active reviewer feedback, comments since last attempt, prior attempts.
|
||
"""
|
||
pr = input_payload
|
||
sections = [
|
||
f"# Implementer — Tier {tier}",
|
||
"",
|
||
f"You are running as the controller-managed implementer for "
|
||
f"PR #{_fmt_or_unavailable(pr.get('pr_number'))} at tier {tier}.",
|
||
"",
|
||
"## Branch coordinates",
|
||
f"- workspace_dir: `{_fmt_or_unavailable(pr.get('workspace_dir'))}`",
|
||
f"- head_ref: `{_fmt_or_unavailable(pr.get('head_ref'))}`",
|
||
f"- head_sha: `{_fmt_or_unavailable(pr.get('head_sha'))}`",
|
||
f"- base_branch: `{_fmt_or_unavailable(pr.get('base_branch'))}`",
|
||
"",
|
||
"## CI summary",
|
||
_fmt_ci_summary(pr.get("ci_summary")),
|
||
"",
|
||
"## Diff so far",
|
||
f"{_fmt_or_unavailable(pr.get('diff_summary'))}",
|
||
"",
|
||
"## Active reviewer state",
|
||
]
|
||
reviews = pr.get("active_reviews") or []
|
||
if reviews:
|
||
sections.extend(_fmt_review(r) for r in reviews[:10])
|
||
if len(reviews) > 10:
|
||
sections.append(f"(+{len(reviews) - 10} more reviews)")
|
||
else:
|
||
sections.append("_No active reviews._")
|
||
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## New PR comments since last attempt",
|
||
]
|
||
)
|
||
comments = pr.get("pr_comments_since_last_attempt") or []
|
||
if comments:
|
||
for c in comments[:10]:
|
||
c_one_line = c.split("\n", 1)[0][:200]
|
||
sections.append(f"- {c_one_line}")
|
||
if len(comments) > 10:
|
||
sections.append(f"(+{len(comments) - 10} more comments)")
|
||
else:
|
||
sections.append("_No new comments since last attempt._")
|
||
|
||
workspace_dir_str = _fmt_or_unavailable(pr.get("workspace_dir"))
|
||
output_path = f"{workspace_dir_str}/{_output_filename_for('implementer')}"
|
||
# Concrete values for the MCP tool-call signatures so the agent
|
||
# doesn't have to guess. Trial-2 observed agents passing
|
||
# ``attempt_id=1`` literally when the prompt had ``attempt_id=...``,
|
||
# which broke the MCP cross-session reset → stale state →
|
||
# "already finalized" on every attempt after the first.
|
||
attempt_id = pr.get("attempt_id")
|
||
workflow_id = pr.get("workflow_id")
|
||
pr_number = pr.get("pr_number")
|
||
head_sha = pr.get("head_sha")
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## Prior implementer attempts",
|
||
_fmt_prior_attempts_block(pr.get("prior_attempts")),
|
||
"",
|
||
"## Output contract (REQUIRED — read carefully)",
|
||
"When your work is complete, call the `implementer-response-builder` "
|
||
"MCP to construct + validate your output, then `implementer_finalize` "
|
||
"with the per-attempt output path. The MCP tools:",
|
||
"",
|
||
f"1. `implementer_start(workflow_id={workflow_id}, "
|
||
f"attempt_id={attempt_id}, pr_number={pr_number}, tier={tier})` — "
|
||
"open the builder. PASS THESE EXACT VALUES.",
|
||
"2. Multiple `implementer_set_*` calls to populate fields "
|
||
"(outcome, files_touched, commit_shas, confidence, blockers "
|
||
"if blocked). Each set tool returns ``{status:ok,...}`` or "
|
||
"``{error:...}``; fix and retry on error.",
|
||
f'3. `implementer_finalize(output_path="{output_path}")` — '
|
||
"validates against ImplementerOutputV1 + writes the canonical "
|
||
"JSON. The controller polls this exact path. **You MUST call "
|
||
"this tool — without it the controller times out.**",
|
||
"",
|
||
"DO NOT emit a JSON object in your final chat message — the new "
|
||
"controller reads from the MCP-written file, not the chat "
|
||
"transcript. The legacy `{outcome: ...}` chat-JSON contract is "
|
||
"RETIRED for controller-managed sessions.",
|
||
]
|
||
)
|
||
|
||
# T5-9: dispute path is now available at ANY tier (was tier=2-only
|
||
# under the original T5-4). Trial-5 data showed even haiku produced
|
||
# credible structured disputes; gating to tier-2 threw away cheap
|
||
# recovery from reviewer hallucinations. Per-tier cap (1 dispute
|
||
# per tier) is enforced by the controller — a second dispute at
|
||
# the same tier downgrades to competence-failure (escalation).
|
||
sections.extend(_fmt_dispute_path_block(workspace_dir_str, tier))
|
||
|
||
# T5-11: verified-clean fast-success path. Only surface this guidance
|
||
# when the prior attempt was a conflict_resolver — otherwise the
|
||
# implementer might use ``verified-clean`` as an escape from real
|
||
# work. The check looks at prior_attempts.verbatim for a role of
|
||
# ``conflict_resolver`` or an outcome of ``resolved`` from a
|
||
# resolver-shaped attempt (resolver outputs have ``new_head_sha``).
|
||
if _prior_attempt_was_conflict_resolver(pr.get("prior_attempts")):
|
||
sections.extend(_fmt_verified_clean_block())
|
||
|
||
# CI-state routing — ORDER MATTERS, "still running" wins:
|
||
# - any gate still pending → ci-not-ready: WAIT for the run to
|
||
# finish. A run with an early failure but other gates still
|
||
# executing has no final verdict; acting on it is the PR-39/40
|
||
# "doesn't wait for CI" bug. Parks the workflow in AWAITING_CI.
|
||
# - run complete (0 pending) with failures → ci-infra-failure: a
|
||
# failed gate whose log carries no verdict (OOM / pod-eviction
|
||
# hard-kill) routes to a bounded CI rerun instead of dead-ending
|
||
# at blocked → STUCK.
|
||
if _ci_summary_is_pending(pr.get("ci_summary")):
|
||
sections.extend(_fmt_ci_not_ready_block())
|
||
elif _ci_summary_has_failures(pr.get("ci_summary")):
|
||
sections.extend(
|
||
_fmt_ci_infra_failure_block(
|
||
owner=pr.get("owner"),
|
||
repo=pr.get("repo"),
|
||
pr_branch=pr.get("head_ref"),
|
||
)
|
||
)
|
||
|
||
allowed = pr.get("allowed_files")
|
||
if allowed:
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## File scope",
|
||
"_Reviewer pinned the following files; do not modify others:_",
|
||
*[f"- `{f}`" for f in allowed[:20]],
|
||
]
|
||
)
|
||
if len(allowed) > 20:
|
||
sections.append(f"(+{len(allowed) - 20} more)")
|
||
|
||
return "\n".join(sections)
|
||
|
||
|
||
def _prior_attempt_was_conflict_resolver(prior: dict | None) -> bool:
|
||
"""T5-11: detect whether a successful conflict_resolver attempt
|
||
preceded this implementer pass.
|
||
|
||
Primary signal: ``PriorAttemptsBlock.last_resolver_output`` —
|
||
populated by ``prefetch._read_most_recent_conflict_resolver_output``
|
||
with the resolver's full ``ConflictResolverOutputV1`` payload when
|
||
a resolver attempt ran. We require ``outcome=resolved`` so a
|
||
failed/irreconcilable resolver doesn't trigger verified-clean
|
||
guidance (the implementer should reason about the failure
|
||
directly, not skip to CI).
|
||
|
||
Fallback signals (kept for test-fixture compatibility and
|
||
defense-in-depth in case the prefetch path changes): a
|
||
``new_head_sha`` field on the most-recent verbatim entry, or an
|
||
explicit ``role == "conflict_resolver"`` marker.
|
||
|
||
Returns False conservatively when the prior block is missing,
|
||
empty, or none of the markers fire — that hides the verified-clean
|
||
guidance from non-post-resolution attempts where using it would
|
||
short-circuit real work.
|
||
"""
|
||
if not isinstance(prior, dict):
|
||
return False
|
||
|
||
# Primary signal: last_resolver_output (populated by prefetch).
|
||
resolver = prior.get("last_resolver_output")
|
||
if isinstance(resolver, dict) and resolver.get("outcome") == "resolved":
|
||
return True
|
||
|
||
# Fallback signals — exercised by tests + survive prefetch refactors.
|
||
verbatim = prior.get("verbatim") or []
|
||
if not isinstance(verbatim, list) or not verbatim:
|
||
return False
|
||
most_recent = verbatim[-1]
|
||
if not isinstance(most_recent, dict):
|
||
return False
|
||
# Both fallback signals gate on ``outcome == "resolved"`` for
|
||
# parity with the primary path — a resolver that emitted
|
||
# ``partial`` / ``blocked`` / ``irreconcilable`` must NOT trigger
|
||
# the verified-clean fast-success guidance (the implementer has to
|
||
# reason about the failed resolution, not skip to CI). The
|
||
# ``new_head_sha`` marker + the explicit ``role`` marker are both
|
||
# only reachable via synthetic test fixtures today (no production
|
||
# producer writes a resolver output into ``verbatim``); they
|
||
# survive as defense-in-depth against future prefetch refactors.
|
||
if most_recent.get("outcome") != "resolved":
|
||
return False
|
||
if "new_head_sha" in most_recent:
|
||
return True
|
||
return most_recent.get("role") == "conflict_resolver"
|
||
|
||
|
||
def _ci_summary_is_pending(ci: dict | None) -> bool:
|
||
"""True when the CI run for the PR head is still in progress — at
|
||
least one gate is still pending/running. The situation the
|
||
``ci-not-ready`` implementer outcome exists for: the implementer
|
||
must wait for the run to finish rather than act on a partial result.
|
||
"""
|
||
if not isinstance(ci, dict):
|
||
return False
|
||
pending = ci.get("gates_pending")
|
||
overall = str(ci.get("overall_state") or "").lower()
|
||
# Deliberately NOT gated on "no failures": a run with one early
|
||
# failure but other gates still executing is STILL not finished.
|
||
# The implementer must wait for the whole run, not act on a partial
|
||
# 7-passed / 2-failed / 3-pending snapshot (PR-39/PR-40 incident).
|
||
return (isinstance(pending, int) and pending > 0) or overall in {
|
||
"pending",
|
||
"running",
|
||
"queued",
|
||
"in_progress",
|
||
}
|
||
|
||
|
||
def _ci_summary_has_failures(ci: dict | None) -> bool:
|
||
"""True when the CI summary shows at least one failing gate — the
|
||
situation where the ``ci-infra-failure`` outcome may apply (a
|
||
failure whose log carries no verdict is a hard-kill, not code)."""
|
||
if not isinstance(ci, dict):
|
||
return False
|
||
failed = ci.get("gates_failed")
|
||
if isinstance(failed, int) and failed > 0:
|
||
return True
|
||
overall = str(ci.get("overall_state") or "").lower()
|
||
return overall in {"failure", "error"}
|
||
|
||
|
||
def _fmt_ci_infra_failure_block(
|
||
owner: str | None = None,
|
||
repo: str | None = None,
|
||
pr_branch: str | None = None,
|
||
) -> list[str]:
|
||
"""Guidance for the ``ci-infra-failure`` outcome — rendered only
|
||
when the CI summary shows failing gates (see
|
||
``_ci_summary_has_failures``).
|
||
|
||
``owner`` / ``repo`` / ``pr_branch`` are interpolated into the
|
||
``implementer_retrigger_ci`` call so the agent re-triggers CI on
|
||
the right PR branch.
|
||
"""
|
||
return [
|
||
"",
|
||
"## CI failure with no verdict (ci-infra-failure)",
|
||
"",
|
||
"Before fixing anything, check whether each failing gate's log "
|
||
"actually contains a verdict. Read the ``raw_log_excerpt`` for "
|
||
"every failing gate in the CI summary above.",
|
||
"",
|
||
"A REAL code failure ALWAYS leaves a marker — a test assertion, "
|
||
"a ``Traceback``, a ``##[error]`` line, a "
|
||
"``nox > Command ... failed`` line, a test-runner summary, or a "
|
||
"non-zero exit code. Fix those and emit ``resolved``.",
|
||
"",
|
||
"But if a failing gate's log just **stops mid-run** — the last "
|
||
"lines are a test still ``EXECUTING`` / ``still running``, or a "
|
||
"command that was only just launched — with NONE of those "
|
||
"markers anywhere in the log, the CI job was hard-killed "
|
||
"(OOM-killer / pod eviction). It never produced a verdict and "
|
||
"there is nothing in the diff for you to fix.",
|
||
"",
|
||
"In that case — AND ONLY that case — do BOTH of these, in order:",
|
||
"",
|
||
f'1. Call ``implementer_retrigger_ci(owner="{owner}", '
|
||
f'repo="{repo}", pr_branch="{pr_branch}")``. This pushes an '
|
||
"empty commit that re-triggers CI so a fresh run can produce a "
|
||
"real verdict — without it CI never re-runs and the workflow "
|
||
"just loops on the same dead run. A failed re-trigger is "
|
||
"non-fatal; continue to step 2 regardless.",
|
||
'2. Emit ``implementer_set_outcome(outcome="ci-infra-failure")`` '
|
||
"with zero commits/files/blockers, set confidence, and "
|
||
"finalize. The empty re-trigger commit is CI machinery — do "
|
||
"NOT record it with ``implementer_record_commit``.",
|
||
"",
|
||
"Do NOT use ``ci-infra-failure`` to dodge a real failure: if "
|
||
"any failing gate's log shows an actual error or assertion, "
|
||
"that gate is a real failure — fix it.",
|
||
]
|
||
|
||
|
||
def _fmt_ci_not_ready_block() -> list[str]:
|
||
"""Guidance for the ``ci-not-ready`` outcome — rendered whenever the
|
||
CI run for the PR head still has a pending/running gate (see
|
||
``_ci_summary_is_pending``), INCLUDING when some gates have already
|
||
finished or even failed. A run that is still executing has no final
|
||
verdict; the implementer must wait, not act on a partial result."""
|
||
return [
|
||
"",
|
||
"## CI still running — wait for the verdict (ci-not-ready)",
|
||
"",
|
||
"The CI summary above shows CI for this PR head is **still "
|
||
"running** — at least one gate is still pending. Some gates may "
|
||
"already show passed or even FAILED, but the run is NOT "
|
||
"finished, so there is no final verdict. A gate that looks "
|
||
"failed now may be a job still in progress, and gates that "
|
||
"depend on it have not run yet. Do NOT act on a half-finished "
|
||
"run.",
|
||
"",
|
||
"Unless there is active reviewer feedback or worktree work to "
|
||
"do that is independent of the CI verdict, emit "
|
||
'``implementer_set_outcome(outcome="ci-not-ready")`` with zero '
|
||
"commits/files/blockers. The workflow routes to AWAITING_CI; "
|
||
"the controller waits for the run to FINISH and re-dispatches "
|
||
"you only if CI ends red — at which point you will have the "
|
||
"complete, final set of failing gates to fix.",
|
||
"",
|
||
"Do NOT use ``ci-not-ready`` to dodge real work: if there is "
|
||
"reviewer feedback or a worktree change to make that does not "
|
||
"depend on the CI verdict, do it and emit ``resolved``. If you "
|
||
"genuinely cannot proceed for an environment reason, use "
|
||
"``blocked``.",
|
||
]
|
||
|
||
|
||
def _fmt_verified_clean_block() -> list[str]:
|
||
"""T5-11: post-conflict-resolution fast-success guidance. Only
|
||
rendered when the prior attempt was a conflict_resolver (see
|
||
``_prior_attempt_was_conflict_resolver``); otherwise the
|
||
implementer might use ``verified-clean`` as an escape from real
|
||
work.
|
||
"""
|
||
return [
|
||
"",
|
||
"## Post-conflict-resolution: verified-clean (fast-success path)",
|
||
"",
|
||
"The prior attempt was a ``conflict_resolver`` that pushed "
|
||
"resolved commits. Your job is to **verify** the resolution — "
|
||
"not to invent more work. Two paths:",
|
||
"",
|
||
"1. **The resolver's commits are clean** (you read the diff, "
|
||
"the resolution looks semantically correct, no further code "
|
||
"changes are needed): emit "
|
||
'``implementer_set_outcome(outcome="verified-clean")`` with '
|
||
"zero commits/files/blockers. The workflow routes directly to "
|
||
"AWAITING_CI to confirm CI passes on the resolver's commits. "
|
||
"**Do NOT push a cosmetic commit just to satisfy the "
|
||
"``resolved`` invariant — that's the wart this outcome fixes.**",
|
||
"",
|
||
"2. **The resolver missed something** (you can see code that "
|
||
"needs fixing — a stale import, a test that needs updating "
|
||
"after the merge, a semantic bug in the resolved hunk): make "
|
||
'the fix, commit, push, and emit ``outcome="resolved"`` as '
|
||
"usual.",
|
||
"",
|
||
"If you can't determine which case applies because the diff is "
|
||
"too large or context is missing: use ``outcome=blocked`` with "
|
||
"a blocker explaining what context you need. Don't guess.",
|
||
]
|
||
|
||
|
||
def _fmt_dispute_path_block(workspace_dir: str, tier: int) -> list[str]:
|
||
"""Render the dispute-path section for the implementer prompt.
|
||
|
||
T5-9: available at any tier (was tier-2-only under T5-4).
|
||
|
||
Tells the agent: if the prior reviewer's blocking_issue cites code
|
||
that doesn't actually exist at the cited file:line, OR cites code
|
||
that already satisfies the reviewer's concern, you can dispute it
|
||
instead of fabricating a no-op or competence-failure. The dispute
|
||
routes to a fresh reviewer attempt with your rebuttal as context.
|
||
"""
|
||
is_max_tier = tier == 2
|
||
stalemate_blurb = (
|
||
"OPERATOR_ATTENTION (this is the top tier; only a human can "
|
||
"break a top-tier dispute stalemate)"
|
||
if is_max_tier
|
||
else f"ESCALATING (next tier — tier {tier + 1} — gets a fresh "
|
||
"shot at either fixing the code or making a more credible "
|
||
"dispute)"
|
||
)
|
||
return [
|
||
"",
|
||
f"## Dispute path (tier={tier}, available at any tier)",
|
||
"",
|
||
"If you read the disputed code and the reviewer's blocking_issue "
|
||
"is FACTUALLY WRONG — e.g., the cited file:line doesn't contain "
|
||
"what the reviewer claims, or the code already implements what "
|
||
"they're asking for — you may dispute it instead of inventing a "
|
||
"fix. To dispute:",
|
||
"",
|
||
"1. Decide which active_review and which blocking_issue (0-based "
|
||
"index) you're disputing. The synthetic `controller-reviewer-a*` "
|
||
"entries in `## Active reviewer state` above carry the prior "
|
||
"controller-reviewer's verdict — that's typically what you'd "
|
||
"dispute. Use the entry's `review_id` literally.",
|
||
"2. Open the cited file with `read` at explicit line numbers. "
|
||
"Capture the actual bytes — paste them into your dispute_evidence.",
|
||
"3. Compose `dispute_evidence` (≥200 chars after stripping) "
|
||
"documenting: what the reviewer claimed, what the bytes actually "
|
||
"say, why the two don't match. Include file:line citations.",
|
||
"4. Set the dispute fields via MCP:",
|
||
" `implementer_set_dispute(",
|
||
" disputed_review_id=<the review_id you're rebutting>,",
|
||
" disputed_blocker_index=<0-based index in that review's blocking_issues>,",
|
||
' dispute_evidence="<your rebuttal>",',
|
||
' verified_at_sha="<the HEAD sha you read against>")`',
|
||
'5. Set `implementer_set_outcome(outcome="dispute-reviewer")`. '
|
||
"Disputes forbid commits/files/blockers — don't record any. "
|
||
"Then `implementer_set_confidence(...)` and finalize.",
|
||
"",
|
||
"**When NOT to dispute**: if the reviewer is correct, fix the "
|
||
"code (outcome=resolved). If you genuinely cannot fix it, return "
|
||
"outcome=blocked with a blocker explaining why. Dispute is ONLY "
|
||
"for cases where the reviewer's claim is factually wrong about "
|
||
"the code at the cited location.",
|
||
"",
|
||
"**What happens if the re-reviewer disagrees with your dispute** "
|
||
f"(stand-down at tier {tier}): {stalemate_blurb}. Disputes that "
|
||
"get overturned cost the next tier real time — reserve disputes "
|
||
"for actual hallucinations, not for dodging escalation when you "
|
||
"can't fix the code.",
|
||
"",
|
||
"**Per-tier cap**: you get exactly ONE dispute per tier. A "
|
||
"second dispute outcome at the same tier is treated as a "
|
||
"competence-failure (the workflow escalates anyway). Don't "
|
||
"burn your dispute on a guess.",
|
||
f" Workspace for re-reads: `{workspace_dir}`",
|
||
]
|
||
|
||
|
||
def _fmt_implementer_dispute_block(claim: dict, head_sha: str | None) -> list[str]:
|
||
"""Render the T5-4 dispute-handling instructions for the reviewer.
|
||
|
||
Triggered when the most-recent implementer attempt's
|
||
``outcome=dispute-reviewer``. The reviewer must re-read the disputed
|
||
file at the cited sha, quote the actual bytes (T5-5), then either
|
||
concede (verdict=approve) or stand by the blocker
|
||
(verdict=request-changes). In BOTH cases the reviewer must call
|
||
``reviewer_set_re_examined_disputed_claim(true)`` to flag this
|
||
attempt; the controller uses that flag to route a standing
|
||
request-changes to OPERATOR_ATTENTION (stalemate) instead of
|
||
looping back to IMPLEMENTING.
|
||
"""
|
||
head_sha_disp = head_sha or "(unknown)"
|
||
out: list[str] = [
|
||
"",
|
||
"## ⚠ Implementer dispute — RE-EXAMINATION REQUIRED",
|
||
"",
|
||
"The previous implementer attempt returned "
|
||
"`outcome=dispute-reviewer`, asserting that one of your prior "
|
||
"blocking_issues is factually wrong about the code.",
|
||
"",
|
||
f"- Disputed review_id: `{claim.get('disputed_review_id')}`",
|
||
f"- Disputed blocker_index (0-based): `{claim.get('disputed_blocker_index')}`",
|
||
f"- Implementer verified at sha: `{claim.get('verified_at_sha')}`",
|
||
f"- Reviewer is now invoked against head_sha: `{head_sha_disp}`",
|
||
"",
|
||
"**Implementer's rebuttal (read carefully):**",
|
||
"",
|
||
"```",
|
||
(claim.get("dispute_evidence") or "(no evidence provided)").strip(),
|
||
"```",
|
||
"",
|
||
"### What you MUST do",
|
||
"",
|
||
"1. Open the file the disputed blocker references (use the `read` "
|
||
"tool with explicit line numbers — do NOT rely on memory of any "
|
||
"prior diff or PR description).",
|
||
"2. Quote the actual bytes at the line range your prior blocker "
|
||
"cited. If your prior description doesn't match the bytes, your "
|
||
"prior claim was wrong — concede.",
|
||
"3. Decide ONE of:",
|
||
" - **CONCEDE**: emit `verdict=approve`, "
|
||
f'`approved_at_sha="{head_sha_disp}"`, '
|
||
'`suggested_next_action="merge"`. Workflow proceeds to MERGING.',
|
||
" - **STAND BY**: emit `verdict=request-changes` with a "
|
||
"REVISED `blocking_issues` entry that includes the actual quoted "
|
||
"bytes proving the issue (not the description that was disputed). "
|
||
"Workflow routes to OPERATOR_ATTENTION — a human will decide.",
|
||
"4. **In BOTH cases** call "
|
||
"`reviewer_set_re_examined_disputed_claim(value=true)`. "
|
||
"Omitting this call is a contract violation — the controller "
|
||
"needs the flag to distinguish a genuine re-examination from a "
|
||
"vanilla retry.",
|
||
"",
|
||
"### Reviewer hygiene (T5-5)",
|
||
"",
|
||
"Before issuing ANY blocking_issue (now or in future attempts), "
|
||
"always quote the actual N-byte range from the cited file at "
|
||
"the claimed line numbers in the blocker's `description`. "
|
||
"Fabricated line numbers or invented code descriptions are how "
|
||
"this dispute happened in the first place — the controller "
|
||
"logs them as low-trust verdicts in subsequent runs.",
|
||
]
|
||
return out
|
||
|
||
|
||
def build_reviewer_prompt(input_payload: dict) -> str:
|
||
"""Prompt for the reviewer role.
|
||
|
||
Reviewer needs: PR coordinates, full diff (truncated), CI summary
|
||
(guaranteed green by state machine), implementer's most recent
|
||
claim, prior reviewer outputs.
|
||
"""
|
||
pr = input_payload
|
||
sections = [
|
||
"# Reviewer",
|
||
"",
|
||
f"You are running as the controller-managed reviewer for "
|
||
f"PR #{_fmt_or_unavailable(pr.get('pr_number'))}.",
|
||
"",
|
||
"## PR coordinates",
|
||
f"- workspace_dir: `{_fmt_or_unavailable(pr.get('workspace_dir'))}`",
|
||
f"- head_sha: `{_fmt_or_unavailable(pr.get('head_sha'))}`",
|
||
"",
|
||
"## CI summary",
|
||
_fmt_ci_summary(pr.get("ci_summary")),
|
||
"",
|
||
"## Diff",
|
||
f"_{_fmt_or_unavailable(pr.get('diff_summary'))}_",
|
||
]
|
||
full_diff = pr.get("full_diff")
|
||
if full_diff:
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"```diff",
|
||
_truncate(full_diff, _DIFF_BUDGET_CHARS),
|
||
"```",
|
||
]
|
||
)
|
||
else:
|
||
sections.append(
|
||
"_(full diff unavailable — fall back to filesystem reads in the workspace)_"
|
||
)
|
||
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## Implementer's latest claim",
|
||
]
|
||
)
|
||
claim = pr.get("implementer_claim")
|
||
if isinstance(claim, dict):
|
||
sections.append(
|
||
f"- outcome=`{claim.get('outcome', '?')}` "
|
||
f"confidence=`{claim.get('confidence', '?')}` "
|
||
f"tier=`{claim.get('used_tier', '?')}`"
|
||
)
|
||
files = claim.get("files_touched") or []
|
||
if files:
|
||
sections.append(
|
||
"- files_touched: " + ", ".join(f"`{f}`" for f in files[:10])
|
||
)
|
||
if len(files) > 10:
|
||
sections.append(f" (+{len(files) - 10} more)")
|
||
blockers = claim.get("blockers") or []
|
||
if blockers:
|
||
sections.append(f"- blockers: {'; '.join(blockers[:3])}")
|
||
else:
|
||
sections.append("_No implementer claim available._")
|
||
|
||
# T5-4: dispute mode. When the implementer's most-recent attempt
|
||
# came back as ``outcome=dispute-reviewer``, the reviewer is being
|
||
# re-invoked to look at a contested blocker. Render the dispute
|
||
# block with the disputed review_id + blocker_index + the
|
||
# implementer's evidence, and instruct the reviewer to re-read
|
||
# the file at the cited sha before deciding.
|
||
if isinstance(claim, dict) and claim.get("outcome") == "dispute-reviewer":
|
||
sections.extend(_fmt_implementer_dispute_block(claim, pr.get("head_sha")))
|
||
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## Prior reviewer outputs",
|
||
]
|
||
)
|
||
prior_reviews = pr.get("prior_reviews") or []
|
||
if prior_reviews:
|
||
for i, r in enumerate(prior_reviews, 1):
|
||
sections.append(
|
||
f"{i}. verdict=`{r.get('verdict', '?')}` "
|
||
f"next-action=`{r.get('suggested_next_action', '?')}` "
|
||
f"confidence=`{r.get('confidence', '?')}`"
|
||
)
|
||
else:
|
||
sections.append("_No prior reviewer outputs._")
|
||
|
||
# Concrete values to interpolate into MCP tool-call signatures —
|
||
# see implementer's prompt builder for the rationale.
|
||
attempt_id = pr.get("attempt_id")
|
||
workflow_id = pr.get("workflow_id")
|
||
pr_number = pr.get("pr_number")
|
||
head_sha = pr.get("head_sha")
|
||
workspace_dir_str = _fmt_or_unavailable(pr.get("workspace_dir"))
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## Prior implementer attempts",
|
||
_fmt_prior_attempts_block(pr.get("prior_implementer_attempts")),
|
||
"",
|
||
"## Output contract (REQUIRED — read carefully)",
|
||
"When your review is complete, call the `reviewer-response-builder` "
|
||
"MCP and `reviewer_finalize` with the per-attempt path:",
|
||
"",
|
||
f"1. `reviewer_start(workflow_id={workflow_id}, "
|
||
f"attempt_id={attempt_id}, pr_number={pr_number}, "
|
||
f'head_sha="{head_sha}", gates=[...from CI summary above])` — '
|
||
"open the builder. PASS THESE EXACT VALUES.",
|
||
"2. Per-gate: `reviewer_record_gate(name=..., status=..., ...)` for "
|
||
"each CI gate you want to comment on. Optional: "
|
||
"`reviewer_override_gate` with a ≥20-char justification.",
|
||
"3. Issue records: `reviewer_add_blocking_issue(...)` for each "
|
||
"blocking concern (only when verdict=request-changes). "
|
||
"**T5-5: BEFORE you add a blocking_issue, open the file you're "
|
||
"citing with `read` at explicit line numbers and quote the "
|
||
"actual bytes in the `description` field. Made-up line numbers "
|
||
"or invented code descriptions are a contract violation.**",
|
||
"3b. (Dispute re-examination only) If the prompt above contains "
|
||
"a dedicated dispute / re-examination section flagged with a "
|
||
"warning glyph, follow that section's instructions and call "
|
||
"`reviewer_set_re_examined_disputed_claim(value=true)`. Omitting "
|
||
"the flag on a re-examination is a contract violation.",
|
||
"4. `reviewer_set_verdict(verdict=..., approved_at_sha=...)`, "
|
||
"`reviewer_set_confidence(...)`, "
|
||
"`reviewer_set_suggested_next_action(...)`.",
|
||
f'5. `reviewer_finalize(output_path="{workspace_dir_str}/'
|
||
f'{_output_filename_for("reviewer")}")` '
|
||
"— validates against ReviewerOutputV1 + writes the canonical JSON. "
|
||
"**You MUST call this tool — without it the controller times out.**",
|
||
"",
|
||
"DO NOT emit a JSON object in your final chat message — the "
|
||
"new controller reads from the MCP-written file.",
|
||
]
|
||
)
|
||
|
||
return "\n".join(sections)
|
||
|
||
|
||
def build_estimator_prompt(input_payload: dict) -> str:
|
||
"""Prompt for the estimator role.
|
||
|
||
Estimator picks the starting tier for a freshly-discovered PR or
|
||
issue. Inputs: title, body, diff summary (if PR).
|
||
"""
|
||
e = input_payload
|
||
pr_number = e.get("pr_number")
|
||
kind = "PR" if pr_number is not None else "issue/workflow"
|
||
# Concrete values for the MCP tool-call signatures.
|
||
attempt_id = e.get("attempt_id")
|
||
workflow_id = e.get("workflow_id")
|
||
head_sha = e.get("head_sha")
|
||
workspace_dir_str = _fmt_or_unavailable(e.get("workspace_dir"))
|
||
sections = [
|
||
"# Estimator",
|
||
"",
|
||
f"You are running as the controller-managed estimator for "
|
||
f"a freshly-discovered {kind}.",
|
||
"",
|
||
"## Context",
|
||
f"- workflow_id: {_fmt_or_unavailable(workflow_id)}",
|
||
f"- pr_number: {_fmt_or_unavailable(pr_number)}",
|
||
f"- head_sha: {_fmt_or_unavailable(head_sha)}",
|
||
f"- attempt_id: {_fmt_or_unavailable(attempt_id)}",
|
||
"",
|
||
"## Title",
|
||
_fmt_or_unavailable(e.get("pr_title")),
|
||
"",
|
||
"## Body",
|
||
_truncate(_fmt_or_unavailable(e.get("pr_body")), _BODY_BUDGET_CHARS),
|
||
"",
|
||
"## Diff summary",
|
||
_fmt_or_unavailable(e.get("diff_summary")),
|
||
"",
|
||
"## CI summary",
|
||
_fmt_ci_summary(e.get("ci_summary")),
|
||
"",
|
||
"## Output contract (REQUIRED — read carefully)",
|
||
"When your estimate is complete, call the `estimator-response-builder` "
|
||
"MCP and `estimator_finalize` with the per-attempt path:",
|
||
"",
|
||
f"1. `estimator_start(workflow_id={workflow_id}, "
|
||
f"attempt_id={attempt_id}, pr_number={pr_number}, "
|
||
f'head_sha="{head_sha}")` — open the builder. '
|
||
"PASS THESE EXACT VALUES (they're already substituted above).",
|
||
"2. `estimator_set_recommended_tier(tier=N)`, "
|
||
"`estimator_set_is_metadata_only(value=bool)`, "
|
||
'`estimator_set_confidence(confidence="high"|"medium"|"low")`, '
|
||
"`estimator_set_reasoning(text=...)`.",
|
||
f'3. `estimator_finalize(output_path="{workspace_dir_str}/'
|
||
f'{_output_filename_for("estimator")}")` '
|
||
"— validates against EstimatorOutputV1 + writes the canonical JSON. "
|
||
"**You MUST call this tool — without it the controller times out.**",
|
||
"",
|
||
"DO NOT emit a JSON object in your final chat message — the "
|
||
"new controller reads from the MCP-written file.",
|
||
"",
|
||
"**Tier guidance:** 0 = small/local, 1 = cross-file or "
|
||
"context-heavy, 2 = repo-wide reasoning or research required.",
|
||
]
|
||
return "\n".join(sections)
|
||
|
||
|
||
def build_conflict_resolver_prompt(input_payload: dict, *, tier: int) -> str:
|
||
"""Prompt for the conflict_resolver role.
|
||
|
||
Renders the conflict-prep ``mode`` (``rebase`` or ``merge``) and a
|
||
mode-specific "how to finish" line so the agent knows whether it is
|
||
driving a mid-rebase worktree (resolve → ``git_rebase_continue``,
|
||
loop) or a mid-merge worktree (resolve all → ``git_commit``, one
|
||
merge commit). ``mode`` defaults to ``rebase`` for back-compat.
|
||
"""
|
||
c = input_payload
|
||
mode = c.get("mode") or "rebase"
|
||
if mode == "merge":
|
||
finish_line = (
|
||
"**How to finish (merge mode):** the worktree is mid-merge — "
|
||
"ONE 3-way merge of the base into the PR branch. Resolve "
|
||
"every conflicted file, then seal it with a SINGLE "
|
||
"`git_commit` (one merge commit). Do NOT loop — a merge has "
|
||
"exactly one conflict set."
|
||
)
|
||
else:
|
||
finish_line = (
|
||
"**How to finish (rebase mode):** the worktree is mid-rebase. "
|
||
"Resolve the conflicts for the current step, then call the "
|
||
"`git_rebase_continue` MCP tool. Repeat resolve → "
|
||
"`git_rebase_continue` until the rebase completes."
|
||
)
|
||
sections = [
|
||
f"# Conflict Resolver — Tier {tier}",
|
||
"",
|
||
f"You are running as the controller-managed conflict resolver for "
|
||
f"PR #{_fmt_or_unavailable(c.get('pr_number'))} at tier {tier}.",
|
||
"",
|
||
f"## Mode: {mode}",
|
||
finish_line,
|
||
"",
|
||
"## Coordinates",
|
||
f"- workspace_dir: `{_fmt_or_unavailable(c.get('workspace_dir'))}`",
|
||
f"- head_sha: `{_fmt_or_unavailable(c.get('head_sha'))}`",
|
||
f"- base_branch: `{_fmt_or_unavailable(c.get('base_branch'))}`",
|
||
f"- base_sha: `{_fmt_or_unavailable(c.get('base_sha'))}`",
|
||
"",
|
||
"## Conflicted files",
|
||
]
|
||
conflicts = c.get("conflicted_files") or []
|
||
if conflicts:
|
||
for cf in conflicts[:20]:
|
||
sections.append(f"- `{cf.get('file_path', '?')}`")
|
||
if len(conflicts) > 20:
|
||
sections.append(f"(+{len(conflicts) - 20} more)")
|
||
else:
|
||
sections.append(
|
||
"_Worker-side rebase did not enumerate conflicts; "
|
||
"fetch them yourself via `git status --porcelain`._"
|
||
)
|
||
|
||
# T5-13: render the prehydrated PR intent so the resolver can
|
||
# disambiguate merge choices WITHOUT its own Forgejo I/O
|
||
# (``forgejo*`` is denied for this agent). The contract carries
|
||
# pr_title / pr_body / pr_comments; the agent's EVIDENCE RULE
|
||
# depends on seeing them here.
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## PR intent",
|
||
f"- title: {_fmt_or_unavailable(c.get('pr_title'))}",
|
||
]
|
||
)
|
||
pr_body = (c.get("pr_body") or "").strip()
|
||
if pr_body:
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"PR description:",
|
||
"",
|
||
_truncate(pr_body, _BODY_BUDGET_CHARS),
|
||
]
|
||
)
|
||
else:
|
||
sections.append("- description: _(empty)_")
|
||
pr_comments = c.get("pr_comments") or []
|
||
if pr_comments:
|
||
sections.append("")
|
||
sections.append("PR comments:")
|
||
for cmt in pr_comments[:15]:
|
||
one_line = str(cmt).split("\n", 1)[0][:200]
|
||
sections.append(f"- {one_line}")
|
||
if len(pr_comments) > 15:
|
||
sections.append(f"(+{len(pr_comments) - 15} more comments)")
|
||
else:
|
||
sections.append("- comments: _(none)_")
|
||
|
||
# Concrete values for the MCP tool-call signatures.
|
||
attempt_id = c.get("attempt_id")
|
||
workflow_id = c.get("workflow_id")
|
||
pr_number = c.get("pr_number")
|
||
workspace_dir_str = _fmt_or_unavailable(c.get("workspace_dir"))
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## Prior implementer attempts",
|
||
_fmt_prior_attempts_block(c.get("prior_implementer_outputs")),
|
||
"",
|
||
"## Output contract (REQUIRED — read carefully)",
|
||
"When conflict resolution is complete, call the "
|
||
"`conflict-resolver-response-builder` MCP + `conflict_finalize`:",
|
||
"",
|
||
f"1. `conflict_start(workflow_id={workflow_id}, "
|
||
f"attempt_id={attempt_id}, pr_number={pr_number})` "
|
||
"— PASS THESE EXACT VALUES.",
|
||
"2. `conflict_set_outcome(...)`, `conflict_set_reasoning(...)`, "
|
||
"`conflict_set_confidence(...)`, optional "
|
||
"`conflict_record_resolved_file`, `conflict_record_commit`, "
|
||
"`conflict_set_new_head_sha` (if resolved).",
|
||
f'3. `conflict_finalize(output_path="{workspace_dir_str}/'
|
||
f'{_output_filename_for("conflict_resolver")}")` '
|
||
"— **You MUST call this tool — without it the controller times out.**",
|
||
"",
|
||
"DO NOT emit a JSON object in your final chat message.",
|
||
]
|
||
)
|
||
return "\n".join(sections)
|
||
|
||
|
||
def build_summarizer_prompt(input_payload: dict) -> str:
|
||
"""Prompt for the summarizer role.
|
||
|
||
Summarizer condenses an aged-out implementer attempt into 50–2000
|
||
chars of running summary the next attempt's prior_attempts.older_summary
|
||
field will carry.
|
||
"""
|
||
s = input_payload
|
||
sections = [
|
||
"# Summarizer",
|
||
"",
|
||
"You are running as the controller-managed summarizer. Your job "
|
||
"is to condense an aged-out implementer attempt into a running "
|
||
"summary the next implementer attempt will read.",
|
||
"",
|
||
"## Aged-out attempt",
|
||
"```json",
|
||
json.dumps(
|
||
s.get("newly_aged_out_attempt") or {}, indent=2, sort_keys=True, default=str
|
||
)[:_PRIOR_ATTEMPT_BUDGET_CHARS],
|
||
"```",
|
||
"",
|
||
"## Prior summary (covers attempts 1..K)",
|
||
]
|
||
prior = s.get("prior_summary")
|
||
if prior:
|
||
sections.append(_truncate(prior, _PRIOR_ATTEMPT_BUDGET_CHARS))
|
||
else:
|
||
sections.append("_None — this is the first summarization pass._")
|
||
|
||
# Concrete values for the MCP tool-call signatures.
|
||
attempt_id = s.get("attempt_id")
|
||
workflow_id = s.get("workflow_id")
|
||
workspace_dir_str = _fmt_or_unavailable(s.get("workspace_dir"))
|
||
sections.extend(
|
||
[
|
||
"",
|
||
"## Output contract (REQUIRED — read carefully)",
|
||
"Call `summarizer-response-builder` MCP + `summarizer_finalize`:",
|
||
"",
|
||
f"1. `summarizer_start(workflow_id={workflow_id}, "
|
||
f"attempt_id={attempt_id})` — PASS THESE EXACT VALUES.",
|
||
"2. `summarizer_set_summary(text=...)`, "
|
||
"`summarizer_set_covers_through_attempt(attempt_number=N)`.",
|
||
f'3. `summarizer_finalize(output_path="{workspace_dir_str}/'
|
||
f'{_output_filename_for("summarizer")}")` '
|
||
"— **You MUST call this tool — without it the controller times out.**",
|
||
"",
|
||
"DO NOT emit a JSON object in your final chat message — the "
|
||
"new controller reads from the MCP-written file.",
|
||
"",
|
||
"Be terse: capture what was tried, what failed, what's worth "
|
||
"carrying forward. Skip redundant headers, file lists already "
|
||
"in the next attempt's input.",
|
||
]
|
||
)
|
||
return "\n".join(sections)
|
||
|
||
|
||
# ─── dispatcher ──────────────────────────────────────────────────────
|
||
|
||
|
||
def build_grooming_stage_b_prompt(input_payload: dict) -> str:
|
||
"""Prompt for the grooming_stage_b role.
|
||
|
||
The grooming worker decides if the anchor PR is a duplicate of
|
||
another currently-open PR. Input carries the anchor PR's full
|
||
detail dict + the list of all open PRs in the same (owner, repo).
|
||
The agent reasons internally and reports verdict via the
|
||
grooming MCP.
|
||
"""
|
||
g = input_payload
|
||
workflow_id = g.get("workflow_id")
|
||
attempt_id = g.get("attempt_id")
|
||
pr_number = g.get("pr_number")
|
||
workspace_dir_str = _fmt_or_unavailable(g.get("workspace_dir"))
|
||
anchor = g.get("anchor_pr") or {}
|
||
open_prs = g.get("open_prs") or []
|
||
# Render the anchor + open-PR list as compact textual blocks the
|
||
# agent can scan without expending tool-call budget. Title + body
|
||
# head + head ref + touched_files matter most for duplicate
|
||
# detection; we deliberately do NOT dump the full PR diff (too
|
||
# large; the agent can request specific files via read tools if it
|
||
# needs them).
|
||
anchor_block = [
|
||
f"- number: {anchor.get('number')}",
|
||
f"- title: {anchor.get('title','')}",
|
||
f"- head: {(anchor.get('head') or {}).get('ref','')}@{(anchor.get('head') or {}).get('sha','')[:12]}",
|
||
f"- base: {(anchor.get('base') or {}).get('ref','')}",
|
||
f"- additions/deletions/changed_files: "
|
||
f"{anchor.get('additions','?')}/{anchor.get('deletions','?')}/"
|
||
f"{anchor.get('changed_files','?')}",
|
||
"- body (first 2KB):",
|
||
_truncate(anchor.get("body") or "", 2048),
|
||
]
|
||
other_blocks: list[str] = []
|
||
for p in open_prs:
|
||
if p.get("number") == anchor.get("number"):
|
||
continue # skip self
|
||
other_blocks.append(
|
||
"- "
|
||
f"#{p.get('number')} "
|
||
f"title={p.get('title','')[:120]!r} "
|
||
f"head={(p.get('head') or {}).get('ref','')} "
|
||
f"add/del/files={p.get('additions','?')}/{p.get('deletions','?')}/"
|
||
f"{p.get('changed_files','?')}"
|
||
)
|
||
if not other_blocks:
|
||
other_blocks = ["(none — no other open PRs in this repo)"]
|
||
sections = [
|
||
"# Grooming Stage B — duplicate-detection judge",
|
||
"",
|
||
"## Context",
|
||
f"- workflow_id: {_fmt_or_unavailable(workflow_id)}",
|
||
f"- attempt_id: {_fmt_or_unavailable(attempt_id)}",
|
||
f"- pr_number: {_fmt_or_unavailable(pr_number)}",
|
||
f"- workspace_dir: {workspace_dir_str}",
|
||
"",
|
||
"## Anchor PR (the workflow under evaluation)",
|
||
*anchor_block,
|
||
"",
|
||
f"## All other open PRs in this repo ({len(other_blocks)})",
|
||
*other_blocks,
|
||
"",
|
||
"## Output contract (REQUIRED — read carefully)",
|
||
"Use the `grooming-builder` MCP. Required call sequence:",
|
||
"",
|
||
f"1. `grooming_start(workflow_id={workflow_id}, "
|
||
f"attempt_id={attempt_id}, pr_number={pr_number})` — open the builder. "
|
||
"PASS THESE EXACT VALUES.",
|
||
"2. Setters for the fields your verdict requires (see the "
|
||
"agent system-prompt's required-sequence tables).",
|
||
# Output filename comes from the role registry — same source
|
||
# the worker's fallback-path-watcher uses, so the agent and
|
||
# worker can never disagree about which file to read.
|
||
f'3. `grooming_finalize(output_path="{workspace_dir_str}/'
|
||
f'{_output_filename_for("grooming_stage_b")}")` '
|
||
"— validates against GroomingOutputV1 + writes the canonical JSON. "
|
||
"**You MUST call this tool — without it the controller times out. "
|
||
"Use the EXACT path above — the worker watches that specific filename.**",
|
||
"",
|
||
"DO NOT emit a JSON object in your final chat message — the "
|
||
"controller reads only the MCP-written file.",
|
||
]
|
||
return "\n".join(sections)
|
||
|
||
|
||
def build_prompt(role: str, tier: int | None, input_payload: dict) -> str:
|
||
"""Dispatch by role. Matches the prompt_builder signature
|
||
``wire_opencode_session`` expects.
|
||
|
||
Raises ValueError for unknown role. Tier is required for
|
||
implementer/conflict_resolver; ignored for the rest.
|
||
"""
|
||
if role == "implementer":
|
||
if tier is None:
|
||
raise ValueError("implementer prompt requires tier")
|
||
return build_implementer_prompt(input_payload, tier=tier)
|
||
if role == "reviewer":
|
||
return build_reviewer_prompt(input_payload)
|
||
if role == "estimator":
|
||
return build_estimator_prompt(input_payload)
|
||
if role == "conflict_resolver":
|
||
# PD13: scheduler always sets tier for conflict_resolver
|
||
# (CONFLICT_RESOLVING uses workflow.current_tier), but defend
|
||
# against contract drift by defaulting to tier=1 with a warning
|
||
# instead of raising — a single attempt failure beats burning
|
||
# a pickup_count + stack trace inside production_agent_runner.
|
||
if tier is None:
|
||
import logging
|
||
|
||
logging.getLogger(__name__).warning(
|
||
"conflict_resolver prompt called without tier; defaulting to 1"
|
||
)
|
||
tier = 1
|
||
return build_conflict_resolver_prompt(input_payload, tier=tier)
|
||
if role == "summarizer":
|
||
return build_summarizer_prompt(input_payload)
|
||
if role == "grooming_stage_b":
|
||
return build_grooming_stage_b_prompt(input_payload)
|
||
raise ValueError(f"unknown role for prompt: {role!r}")
|
||
|
||
|
||
__all__ = [
|
||
"build_conflict_resolver_prompt",
|
||
"build_estimator_prompt",
|
||
"build_grooming_stage_b_prompt",
|
||
"build_implementer_prompt",
|
||
"build_prompt",
|
||
"build_reviewer_prompt",
|
||
"build_summarizer_prompt",
|
||
]
|