Files
cleveragents-core/tools/controller/worker/prompts.py
T
drew 016b348117 feat(controller): grooming gate (Phase 0 + Phase 1 worker-shape dispatch)
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>
2026-05-25 15:05:29 -04:00

1231 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 502000
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",
]