Files
cleveragents-core/tools/_implementer_prompt.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

1011 lines
40 KiB
Python

"""Prompt assembly for the deterministic implementer dispatcher.
Mirrors :mod:`_review_prompt` on the implementer side. Renders the
pre-fetched context from :mod:`_implementer_prefetch` into a single
worker prompt with every third-party body wrapped in
``UNTRUSTED CONTENT`` fences (see :mod:`_pr_prompt` for the marker
contract — closing-marker redaction prevents a forged fence in a
commit message from escaping).
Three per-work-group prompt builders:
- :func:`build_pr_fix_prompt` — failing-CI PR.
- :func:`build_request_changes_prompt`— PR with active REQUEST_CHANGES.
- :func:`build_new_issue_prompt` — new issue implementation.
Each builds on the same minimal-prompt scaffold the legacy
``_implementation_prompt`` produced (forgejo_url / owner / repo /
work_type / work_number / work_title / claim note / PR Compliance
Checklist / output JSON contract) so an operator switching
``IMPLEMENTER_DISPATCHER_PREFETCH=0`` ↔ ``=1`` does NOT see the worker
suddenly miss any of the rules already documented for the legacy
mode.
What the pre-fetch adds on top of the legacy prompt:
- ``## Pre-fetched PR description`` / ``## Pre-fetched issue body``
— the body of the PR / issue, fenced.
- ``## Pre-fetched diff`` (pr_fix / request_changes_pr only) —
reuses :func:`_pr_diff.build_diff_section_full`, same renderer
the reviewer uses; the worker is told to read it instead of
cloning.
- ``## Pre-fetched CI status`` (pr_fix / request_changes_pr) —
combined status + per-check detail when overall != success.
- ``## Pre-fetched PR comments`` (pr_fix / request_changes_pr) /
``## Pre-fetched issue comments`` (new_issue) — paginated.
- ``## Pre-fetched active REQUEST_CHANGES reviews``
(request_changes_pr only) — non-dismissed, non-stale, with their
inline review comments.
- ``## Pre-fetched linked issues`` — Closes / Fixes / Refs targets.
- ``## Pre-fetched Epic`` — when the body has ``Epic: #N`` /
``Parent: #N``.
- ``## Pre-cloned working copy`` (Phase 3) — gated on a non-None
clone handle. Falls through to the no-handle stanza otherwise.
The output-JSON contract (``{"outcome": ..., "files_touched": [...]}``)
is preserved verbatim from the legacy prompt — the post-session
contract has not changed.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import ( # noqa: E402 type: ignore[import-not-found]
load_sibling as _load_sibling,
)
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.py")
_pr_prompt = _load_sibling("_pr_prompt", "_pr_prompt.py")
_implementer_prefetch = _load_sibling(
"_implementer_prefetch", "_implementer_prefetch.py"
)
_block_prompt = _load_sibling("_block_prompt", "_block_prompt.py")
# ─── Output-JSON contract (preserved from legacy _implementation_prompt) ─────
OUTPUT_CONTRACT = (
"When the implementation work is complete, include exactly one JSON object in\n"
"your final response:\n"
'{"outcome": "resolved", "files_touched": ["path/changed"]}\n'
"\n"
"If you cannot complete the implementation because of an unrecoverable setup,\n"
"API, or repository problem, include:\n"
'{"outcome": "rebase-failed", "files_touched": []}'
)
# ─── PR Compliance Checklist (preserved from legacy prompt) ─────────────────
# Public name (intentionally not name-mangled): the implementer
# dispatcher embeds this string in its task-prompt scaffolding so
# the worker session sees the same compliance checklist in either
# the prefetch-on or prefetch-off branch. Re-exported via
# ``__all__`` so re-typing the same Markdown in two places (and
# drifting them apart) is impossible.
PR_COMPLIANCE_CHECKLIST = (
"PR Compliance Checklist "
"(MANDATORY - complete ALL items before creating a PR):\n"
"[ ] 1. CHANGELOG.md — add entry under [Unreleased] section\n"
"[ ] 2. CONTRIBUTORS.md — add or update contribution entry\n"
"[ ] 3. Commit footer — include `ISSUES CLOSED: #<issue-number>` in the "
"commit message\n"
"[ ] 4. CI passes — all quality gates and tests green before requesting "
"review\n"
"[ ] 5. BDD/Behave tests — added or updated for the changed behaviour\n"
"[ ] 6. Epic reference — PR description references the parent Epic issue "
"number\n"
"[ ] 7. Labels — applied via forgejo-label-manager: State/In Review, "
"Priority/<level>, MoSCoW/<level>, Type/<type>\n"
"[ ] 8. Milestone — PR assigned to the earliest open milestone matching "
"the issue"
)
# ─── Body truncation helper ─────────────────────────────────────────────────
def _truncate(body: str | None, max_chars: int) -> tuple[str, bool]:
"""Truncate ``body`` at the last newline boundary inside
``max_chars`` and append a ``[... N more chars truncated ...]``
marker. Returns ``(text, truncated_flag)``.
The newline alignment matches :func:`_pr_diff.fetch_pr_diff_detailed`
so a paragraph never gets cut mid-line. Empty input returns the
empty string with ``truncated=False``.
"""
if not body:
return "", False
if len(body) <= max_chars:
return body, False
cut = body.rfind("\n", 0, max_chars)
if cut < 0:
cut = max_chars
chars_dropped = len(body) - cut
head = body[:cut].rstrip()
return (
f"{head}\n\n[... {chars_dropped} more chars truncated; fetch the "
f"full body via the Forgejo API if critical ...]"
), True
# ─── Section builders ───────────────────────────────────────────────────────
def _section_unavailable(title: str, reason: str) -> str:
"""Render a placeholder when a section's pre-fetch failed.
The worker reads the explicit "X unavailable" stanza and decides
whether the missing data is critical to the fix. We do NOT
silently omit the section; an empty stanza would be ambiguous
(was it not fetched, or did the fetch return nothing?).
Recovery guidance is intentionally cautious: in-session HTTP
(``curl`` / ``webfetch``) is a **last resort** because Phase 4
telemetry treats ``webfetch_call_count > 0`` as a regression
against the prefetch substrate. Prefer reading from the
pre-cloned worktree (when one is provided in this prompt) or
deferring the fix until a later cycle re-prefetches the data.
"""
return f"""## {title} unavailable
The dispatcher attempted to pre-fetch this section but it was not
available ({reason}). Prefer reading the pre-cloned worktree (when
this prompt provides one) or, if the missing data is not critical,
proceed with the fix and call out the gap in the PR description.
Only as a last resort, fall back to in-session HTTP (``curl`` /
``webfetch``) — Phase 4 telemetry treats those calls as a
regression against the prefetch substrate."""
def _build_pr_description_section(pr_details: dict[str, Any] | None) -> str:
if not isinstance(pr_details, dict):
return _section_unavailable("Pre-fetched PR description", "no PR details")
body = str(pr_details.get("body") or "")
text, truncated = _truncate(body, _implementer_prefetch.DEFAULT_BODY_MAX_CHARS)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched PR description",
"pr_description",
text,
attrs={
"title": json.dumps(str(pr_details.get("title") or "")),
"state": str(pr_details.get("state") or ""),
"truncated": str(truncated).lower(),
},
)
def _build_issue_body_section(issue_body: str, item_title: str) -> str:
if not issue_body:
return _section_unavailable(
"Pre-fetched issue body", "issue body empty or fetch failed"
)
text, truncated = _truncate(
issue_body, _implementer_prefetch.DEFAULT_BODY_MAX_CHARS
)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched issue body",
"issue_body",
text,
attrs={
"title": json.dumps(item_title),
"truncated": str(truncated).lower(),
},
)
def _build_ci_status_section(ci_status: dict[str, Any] | None, completed: bool) -> str:
if ci_status is None or not isinstance(ci_status, dict):
return _section_unavailable(
"Pre-fetched CI status",
"fetch failed" if not completed else "no CI status reported",
)
state = str(ci_status.get("state") or "unknown")
statuses = ci_status.get("statuses") or []
summary_lines = [f"overall_state: {state}"]
if isinstance(statuses, list):
for s in statuses[:20]:
if not isinstance(s, dict):
continue
ctx = str(s.get("context") or "")
st = str(s.get("state") or "")
url = str(s.get("target_url") or "")
summary_lines.append(
f"- [{st}] {ctx} ({url})" if url else f"- [{st}] {ctx}"
)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched CI status",
"ci_status",
"\n".join(summary_lines),
attrs={"state": state, "completed": str(completed).lower()},
)
def _build_ci_detail_section(ci_detail: list[dict[str, Any]], completed: bool) -> str:
if not ci_detail:
return ""
lines: list[str] = []
for entry in ci_detail[:20]:
if not isinstance(entry, dict):
continue
ctx = str(entry.get("context") or "")
st = str(entry.get("state") or "")
url = str(entry.get("target_url") or "")
desc = str(entry.get("description") or "")
head = f"- [{st}] {ctx}"
if url:
head += f" ({url})"
if desc:
head += f"{desc}"
lines.append(head)
if not lines:
return ""
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched CI per-check detail",
"ci_detail",
"\n".join(lines),
attrs={"completed": str(completed).lower()},
postscript=(
"Each per-check entry's ``target_url`` points at the CI "
"log. For failing checks the dispatcher pre-fetches the "
"log tail and inlines it in the ``## Pre-fetched CI "
"failure logs`` section below — read that FIRST. Only "
"follow ``target_url`` manually when the failure logs "
"section has ``fetch_error`` for the entry you care "
"about."
),
)
def _build_ci_failure_logs_section(
payload: dict[str, Any] | None, completed: bool
) -> str:
"""Render the pre-fetched failing-CI log tails into the
implementer prompt. Mirrors the reviewer-side section, but
formatted as text blocks rather than embedded JSON so the
implementer reads it as a triage feed.
Empty render when ``payload`` is None (CI status was 'success'
or pre-fetch was skipped) — the dropping ``\\n\\n``-join in the
caller deals with that cleanly.
"""
if not payload:
return ""
failing = payload.get("failing_jobs") or []
if not failing:
return ""
blocks: list[str] = []
for j in failing:
if not isinstance(j, dict):
continue
ctx = str(j.get("context") or "?")
state = str(j.get("state") or "?")
url = str(j.get("log_url") or "")
err = j.get("fetch_error")
log_tail = str(j.get("log_tail") or "").rstrip()
truncated = bool(j.get("log_truncated"))
bytes_seen = j.get("log_bytes_seen")
header = f"### [{state}] {ctx}"
if url:
header += f"\nfull log: {url}"
if err:
body = f"_log unavailable_: ``{err}``"
elif not log_tail:
body = "_log was empty_"
else:
meta = []
if bytes_seen is not None:
meta.append(f"bytes_seen={bytes_seen}")
if truncated:
meta.append("truncated=true")
meta_line = ("(" + ", ".join(meta) + ")\n") if meta else ""
body = f"{meta_line}```\n{log_tail}\n```"
blocks.append(f"{header}\n\n{body}")
if not blocks:
return ""
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched CI failure logs",
"ci_failure_logs",
"\n\n".join(blocks),
attrs={"completed": str(completed).lower(), "count": str(len(blocks))},
postscript=(
"Each block is the LAST N chars of the corresponding "
"failing job's CI log (failing assertions live at the "
"tail). When ``fetch_error`` is set, follow ``full log`` "
"in a browser if needed. Read these BEFORE invoking "
"``ci_run_local_gate`` — most fixes are obvious from the "
"log tail and don't require a full local nox run "
"(which can take 10+ minutes for ``coverage_report``)."
),
)
def _comments_digest_preamble(digest: dict[str, Any] | None) -> str:
"""One-paragraph attempt-history rollup rendered above the verbatim
comments. Older bot ``**Implementation Attempt**`` comments are
summarised here rather than embedded in full — only the most-recent
window plus any human/reviewer comments reach the section body (see
``_implementer_prefetch._bounded_comment_view``). Returns "" when
there is no digest, so the ``\\n\\n``-join in the prompt builder
drops it cleanly."""
rendered = str((digest or {}).get("rendered") or "").strip()
if not rendered:
return ""
return (
"Attempt-history digest (older bot attempt comments are "
"summarised here, not listed verbatim below):\n\n" + rendered
)
def _build_comments_section(
title: str,
section_name: str,
comments: list[dict[str, Any]],
completed: bool,
digest: dict[str, Any] | None = None,
filter_summary: dict[str, Any] | None = None,
) -> str:
"""Render a fenced comments section.
``comments`` is the list embedded verbatim. When ``digest`` is
given the caller has already bounded that list (most-recent N +
humans) and the digest's one-paragraph summary of the older bot
attempt comments is rendered as a preamble.
``filter_summary`` (when set) is the cache's bot-filter rollup
(count + by-author). When non-zero, the section header attrs and
preamble surface "N bot status comments filtered" so the worker
knows there's a lower bound on raw activity that the cache
deliberately dropped.
"""
preamble = _comments_digest_preamble(digest)
filter_note = _comments_filter_note(filter_summary)
if filter_note:
preamble = f"{preamble}\n\n{filter_note}" if preamble else filter_note
total = int((digest or {}).get("total_comments") or len(comments))
filter_attrs = {}
if isinstance(filter_summary, dict) and int(filter_summary.get("count") or 0) > 0:
filter_attrs["filtered_bots"] = str(int(filter_summary["count"]))
if not comments:
if completed:
return _pr_prompt.wrap_untrusted_section(
title,
section_name,
"(no comments)",
attrs={"count": str(total), "completed": "true", **filter_attrs},
preamble=preamble,
)
return _section_unavailable(title, "comment pagination partial")
cap = _implementer_prefetch.DEFAULT_MAX_PROMPT_COMMENTS
# A digest (even an empty one) means the caller passed a
# pre-bounded view (humans kept beyond the recent-N window) —
# render it whole so those early human comments are not re-sliced
# away. ``digest is None`` is the issue-comments path: the list is
# unbounded, so take the most recent N here.
to_render = comments if digest is not None else comments[-cap:]
rendered: list[str] = []
for entry in to_render:
if not isinstance(entry, dict):
continue
author = ""
user = entry.get("user") if isinstance(entry.get("user"), dict) else {}
if isinstance(user, dict):
author = str(user.get("login") or "")
body, _truncated = _truncate(
str(entry.get("body") or ""),
_implementer_prefetch.DEFAULT_COMMENT_MAX_CHARS,
)
created = str(entry.get("created_at") or "")
rendered.append(f"### @{author or 'anonymous'} at {created}\n\n{body}")
return _pr_prompt.wrap_untrusted_section(
title,
section_name,
"\n\n---\n\n".join(rendered),
attrs={
"count": str(total),
"shown": str(len(rendered)),
"completed": str(completed).lower(),
**filter_attrs,
},
preamble=preamble,
)
def _comments_filter_note(summary: dict[str, Any] | None) -> str:
"""One-line operator-visible breakdown of the bot status comments
the comments cache dropped at write time. Returns ``""`` when
there's nothing to report (no filter, or zero filtered)."""
if not isinstance(summary, dict):
return ""
count = int(summary.get("count") or 0)
if count <= 0:
return ""
by_author = summary.get("by_author") or {}
# Legacy cache rows (pre-2026-05-17 fix) wrote ``null`` as the
# author key when the comment's user field was missing — render
# those as "unknown" instead of the literal string "None". Use an
# accumulator (not a dict comprehension) so a row with BOTH a
# legacy ``null`` key AND a fresh ``"unknown"`` key SUMS the
# counts rather than the later iteration silently dropping one.
normalized: dict[str, int] = {}
for login, n in by_author.items():
key = login if isinstance(login, str) and login else "unknown"
try:
value = int(n or 0)
except (TypeError, ValueError):
value = 0
normalized[key] = normalized.get(key, 0) + value
by_author_str = (
", ".join(f"{login}={n}" for login, n in sorted(normalized.items()))
or "anonymous bot"
)
return (
f"**Note:** {count} bot status/claim/release/sentinel comments "
f"were filtered from the cache at write time ({by_author_str}). "
"The bot's structured `**Implementation Attempt**` markers are "
"still in the cache and summarised below; the dropped comments "
"carried no signal the worker needs to act on."
)
def _build_active_reviews_section(
reviews: list[dict[str, Any]], completed: bool
) -> str:
if not reviews:
if completed:
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched active REQUEST_CHANGES reviews",
"active_request_changes",
"(no active REQUEST_CHANGES reviews — none of the listed "
"PRs were claimed at fetch time)",
attrs={"count": "0", "completed": "true"},
)
return _section_unavailable(
"Pre-fetched active REQUEST_CHANGES reviews",
"review pagination partial",
)
rendered: list[str] = []
for review in reviews:
if not isinstance(review, dict):
continue
author = ""
user = review.get("user") if isinstance(review.get("user"), dict) else {}
if isinstance(user, dict):
author = str(user.get("login") or "")
commit_id = str(review.get("commit_id") or "")[:12]
submitted = str(review.get("submitted_at") or "")
body, _ = _truncate(
str(review.get("body") or ""),
_implementer_prefetch.DEFAULT_COMMENT_MAX_CHARS,
)
review_block = (
f"### REQUEST_CHANGES from @{author or 'anonymous'} "
f"at {submitted} (commit {commit_id})\n\n{body or '(no body)'}"
)
inline = review.get("comments") or []
if isinstance(inline, list) and inline:
review_block += "\n\nInline comments:"
for c in inline:
if not isinstance(c, dict):
continue
path = str(c.get("path") or "")
pos = c.get("new_position")
ic_body = str(c.get("body") or "")
review_block += f"\n- **{path}:{pos}** — {ic_body}"
rendered.append(review_block)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched active REQUEST_CHANGES reviews",
"active_request_changes",
"\n\n---\n\n".join(rendered),
attrs={"count": str(len(reviews)), "completed": str(completed).lower()},
postscript=(
"Every blocking concern above must be addressed before "
"you push. Partially addressing a REQUEST_CHANGES review "
"is grounds for the next reviewer cycle to re-block."
),
)
def _build_comment_reviews_section(
reviews: list[dict[str, Any]], completed: bool
) -> str:
"""Render non-RC review feedback (COMMENT + APPROVE) as advisory
context for the implementer.
Added R3.4 (2026-05-17). Before this, the implementer never saw
these — they're the reviewer's substantive prose feedback in
cycles where ``data_complete=False`` forced a COMMENT-only
downgrade (so they're NOT a REQUEST_CHANGES the implementer is
formally blocked on, but they ARE the reviewer's actual
observations about the PR). Treat as context, not as blocking
requirements — the REQUEST_CHANGES section above is what blocks.
"""
if not reviews:
if completed:
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched reviewer comments and approvals",
"comment_reviews",
"(no COMMENT or APPROVE reviews on this PR yet)",
attrs={"count": "0", "completed": "true"},
)
return _section_unavailable(
"Pre-fetched reviewer comments and approvals",
"review pagination partial",
)
rendered: list[str] = []
for review in reviews:
if not isinstance(review, dict):
continue
author = ""
user = review.get("user") if isinstance(review.get("user"), dict) else {}
if isinstance(user, dict):
author = str(user.get("login") or "")
event = str(review.get("state") or review.get("event") or "COMMENT").upper()
commit_id = str(review.get("commit_id") or "")[:12]
submitted = str(review.get("submitted_at") or "")
body, _ = _truncate(
str(review.get("body") or ""),
_implementer_prefetch.DEFAULT_COMMENT_MAX_CHARS,
)
review_block = (
f"### {event} from @{author or 'anonymous'} "
f"at {submitted} (commit {commit_id})\n\n{body or '(no body)'}"
)
inline = review.get("comments") or []
if isinstance(inline, list) and inline:
review_block += "\n\nInline comments:"
for c in inline:
if not isinstance(c, dict):
continue
path = str(c.get("path") or "")
pos = c.get("new_position")
ic_body = str(c.get("body") or "")
review_block += f"\n- **{path}:{pos}** — {ic_body}"
rendered.append(review_block)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched reviewer comments and approvals",
"comment_reviews",
"\n\n---\n\n".join(rendered),
attrs={"count": str(len(reviews)), "completed": str(completed).lower()},
postscript=(
"These reviews are ADVISORY — they are not "
"REQUEST_CHANGES, so you are not formally blocked on "
"them. They are the reviewer's substantive observations "
"about your work. Consider them when implementing, "
"especially if multiple reviewers raise the same concern."
),
)
def _build_linked_issues_section(linked: list[dict[str, Any]], completed: bool) -> str:
if not linked:
if completed:
return ""
return _section_unavailable(
"Pre-fetched linked issues", "link pagination partial"
)
rendered: list[str] = []
for entry in linked:
if not isinstance(entry, dict):
continue
number = entry.get("number")
title = str(entry.get("title") or "")
state = str(entry.get("state") or "")
body, _ = _truncate(
str(entry.get("body") or ""),
_implementer_prefetch.DEFAULT_BODY_MAX_CHARS,
)
meta = entry.get("_review_pipeline_link")
unresolved_note = ""
if isinstance(meta, dict) and meta.get("unresolved"):
reason = str(meta.get("unresolved_reason") or "unknown")
unresolved_note = (
f" (UNRESOLVED — {reason}; treat the body below as absent)"
)
body_or_placeholder = body or "(no body)"
rendered.append(
f"### Issue #{number}: {title} "
f"[{state}]{unresolved_note}\n\n{body_or_placeholder}"
)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched linked issues",
"linked_issues",
"\n\n---\n\n".join(rendered),
attrs={"count": str(len(linked)), "completed": str(completed).lower()},
)
def _build_epic_section(epic: dict[str, Any] | None, completed: bool) -> str:
if epic is None:
if not completed:
return _section_unavailable(
"Pre-fetched Epic", "Epic reference present but fetch failed"
)
return ""
if not isinstance(epic, dict):
return ""
number = epic.get("number")
title = str(epic.get("title") or "")
state = str(epic.get("state") or "")
body, _ = _truncate(
str(epic.get("body") or ""),
_implementer_prefetch.DEFAULT_BODY_MAX_CHARS,
)
return _pr_prompt.wrap_untrusted_section(
"Pre-fetched Epic",
"epic",
f"### Epic #{number}: {title} [{state}]\n\n{body or '(no body)'}",
attrs={"number": str(number), "state": state},
postscript=(
"The Epic body above defines the parent acceptance criteria "
"for this work. Your PR description MUST include an `Epic: "
f"#{number}` reference so PR Compliance Checklist item 6 "
"passes."
),
)
def _build_diff_section_text(
*,
head_sha: str,
prefetch_diff_text: str,
prefetch_truncated: bool,
prefetch_unavailable: bool,
prefetch_info: dict[str, int],
) -> str:
"""Render the implementer's diff section by delegating to the
shared renderer in :mod:`_pr_diff`.
The reviewer side calls :func:`_pr_diff.build_diff_section_full`
which does its OWN HTTP fetch; the implementer already paid that
cost during prefetch, so we use
:func:`_pr_diff.render_diff_section_from_prefetch` with
``audience="fix"`` to drive the same fence shape from data we
already have. Single source of truth for the fence — drift
across dispatchers is structurally impossible.
"""
return _pr_diff.render_diff_section_from_prefetch(
head_sha=head_sha,
prefetch_diff_text=prefetch_diff_text,
prefetch_truncated=prefetch_truncated,
prefetch_unavailable=prefetch_unavailable,
prefetch_info=prefetch_info,
audience="fix",
)
# ─── Per-work-group prompt assembly ─────────────────────────────────────────
PR_CLAIM_NOTE = (
"The deterministic dispatcher already claimed "
"`auto/claimed-implementer` before starting this worker. If your "
"startup claim step sees the label already present, treat that as "
"success and continue normally. Still run your release step before "
"exiting; the dispatcher will also release in its finally block."
)
ISSUE_CLAIM_NOTE = (
"No PR exists yet for this issue work, so there is no "
"`auto/claimed-implementer` claim to acquire before dispatch."
)
def _resolve_worker_credentials(cfg: Any) -> dict[str, str]:
"""Return the credential triple the implementer worker needs in
its prompt: ``forgejo_pat``, ``git_user_name``, ``git_user_email``.
``forgejo_pat`` comes from ``cfg.token`` (already loaded via
:func:`_dispatch.load_secret` against ``FORGEJO_PAT`` /
``GITEA_TOKEN``). The two git identity values come from the
dispatcher's process environment, which is populated by
``tools/launch_fork.sh`` before the dispatcher launches.
Returns only the keys that have non-empty values. An empty dict is
the rendered "credentials section is empty — fall through to env
fallback" signal. We do NOT raise when values are missing because
the launch-time check belongs in the launcher, not the prompt
builder; the worker's ``Required?`` table catches the gap at
runtime with a clear error.
"""
import os as _os
out: dict[str, str] = {}
token = getattr(cfg, "token", "") or ""
if isinstance(token, str) and token:
out["forgejo_pat"] = token
git_user_name = _os.environ.get("GIT_USER_NAME", "")
if git_user_name:
out["git_user_name"] = git_user_name
git_user_email = _os.environ.get("GIT_USER_EMAIL", "")
if git_user_email:
out["git_user_email"] = git_user_email
return out
def render_worker_credentials_section(credentials: dict[str, str]) -> str:
"""Render the worker-credentials block embedded near the top of
every implementer worker prompt.
Returns an empty string when ``credentials`` is empty so the
section can be ``"\\n\\n".join(...)``-filtered out cleanly by the
per-work-group builders.
The section is intentionally formatted to match the
``key: <backtick>value<backtick>`` shape the worker is already
trained to expect elsewhere in the prompt (forgejo_url, etc.). This
means the worker does NOT have to burn 1-4 turns probing for these
via ``printenv FORGEJO_PAT`` / ``GIT_USER_NAME`` /
``GIT_USER_EMAIL`` — the values are already in the conversation
when the worker starts.
The ``forgejo_pat`` value is redacted from on-disk session archives
by ``_opencode_worker._archive_session``; see ``redact_values`` in
:func:`_opencode_worker.run_session_blocking`. The live OpenCode
session keeps the value verbatim until DELETE — that's a deliberate
trust boundary (anyone with API access to OpenCode can also read
env vars on the host).
"""
if not credentials:
return ""
lines = ["## Worker credentials (use these instead of env vars)"]
lines.append(
"Treat the values below as authoritative. They were resolved "
"by the dispatcher from the same environment your `printenv` "
"fallback would read, so reading them again is wasted turns. "
"The PAT is masked in the on-disk session archive."
)
lines.append("")
for key in ("forgejo_pat", "git_user_name", "git_user_email"):
if key in credentials:
lines.append(f"{key}: `{credentials[key]}`")
return "\n".join(lines)
def assemble_header(cfg: Any, item: dict[str, Any], group: Any, claim_note: str) -> str:
"""Render the leading scaffold of every implementer worker prompt.
Public so :mod:`dispatch_implementer`'s legacy-prompt branch can
call it directly without having to reach into a private symbol —
the legacy and prefetch branches share this header verbatim, and a
cross-module caller is a legitimate consumer (not an
encapsulation leak).
The worker-credentials block is appended automatically when the
dispatcher's ``cfg.token`` / ``GIT_USER_NAME`` / ``GIT_USER_EMAIL``
are populated. When they are not (dry-run, unit test), the section
is silently omitted and the worker falls back to its existing
env-var lookup — same behaviour as before this change.
"""
work_type = "issue_impl" if group.item_kind == "issue" else "pr_fix"
title = str(item.get("title") or "")
number = int(item["number"])
credentials_section = render_worker_credentials_section(
_resolve_worker_credentials(cfg)
)
credentials_block = f"\n\n{credentials_section}" if credentials_section else ""
return f"""Implement or fix the indicated issue or pull request.
forgejo_url: `{cfg.forgejo_url}`
forgejo_owner: `{cfg.owner}`
forgejo_repo: `{cfg.repo}`
work_type: {json.dumps(work_type)}
work_number: {number}
work_title: {json.dumps(title)}{credentials_block}
{PR_COMPLIANCE_CHECKLIST}
{claim_note}"""
# Backwards-compatible underscore aliases — kept for one release so
# any caller that captured the previous private names continues to
# work. New code MUST use the public names above. Removing these
# aliases is tracked in the parity-plan follow-up sprint.
_assemble_header = assemble_header
_PR_CLAIM_NOTE = PR_CLAIM_NOTE
_ISSUE_CLAIM_NOTE = ISSUE_CLAIM_NOTE
def _build_available_blocks_section(result: Any) -> str:
"""Render the ``## Available blocks`` section from
``result.block_refs``. Empty string when no refs (block store
disabled, every registration failed, or this is a dry-run path)."""
refs = list(getattr(result, "block_refs", None) or [])
return _block_prompt.render_available_blocks_section(refs)
def _data_completeness_section(result: Any) -> str:
return f"""## Data completeness
The dispatcher's prompt-time aggregate ``data_complete`` is
``{result.data_complete}``. When this is ``False``, at least one
pre-fetched section was truncated, paginated partially, or had its
upstream fetch fail. The fix you push will be evaluated against the
same body of work the next CI run sees, so consult the affected
section's ``unavailable`` stanza (or its ``completed=false``
attribute) and decide whether the missing data is critical. Prefer
reading the pre-cloned worktree (when present) over in-session
HTTP — Phase 4 telemetry treats ``webfetch`` / ``curl`` calls as a
regression against the prefetch substrate."""
def build_pr_fix_prompt(
cfg: Any,
item: dict[str, Any],
group: Any,
result: Any,
clone_section: str,
) -> str:
"""Assemble the full prompt for a ``pr_fix`` (failing-CI) work
group. ``result`` is an :class:`_implementer_prefetch.ImplementerPrefetchResult`;
``clone_section`` is the pre-rendered ``## Pre-cloned working
copy`` block (or the no-handle stanza when Phase 3 pre-clone is
disabled / failed)."""
header = assemble_header(cfg, item, group, PR_CLAIM_NOTE)
diff_section = _build_diff_section_text(
head_sha=result.head_sha,
prefetch_diff_text=result.diff_text,
prefetch_truncated=result.diff_truncated,
prefetch_unavailable=result.diff_unavailable,
prefetch_info=result.diff_info,
)
sections = [
header,
diff_section,
clone_section,
_build_pr_description_section(result.pr_details),
_build_ci_status_section(result.ci_status, result.ci_status_completed),
_build_ci_detail_section(result.ci_detail, result.ci_detail_completed),
_build_ci_failure_logs_section(
result.ci_failure_logs,
result.ci_failure_logs_completed,
),
# R3.4 follow-up (2026-05-17): pr_fix used to fetch zero
# reviews and render zero review sections. Now it fetches
# both via ``fetch_pr_fix_context(include_active_reviews=True)``
# and renders BOTH sections — the active RC reviews
# (blocking) and the COMMENT/APPROVE reviews (advisory).
# Without these renders the worker had no visibility into
# the reviewer's substantive feedback on a failing-CI PR,
# even though the reviewer was actively posting reviews.
_build_active_reviews_section(
result.request_changes_reviews,
result.request_changes_reviews_completed,
),
_build_comment_reviews_section(
result.comment_reviews,
result.comment_reviews_completed,
),
_build_comments_section(
"Pre-fetched PR comments",
"pr_comments",
result.pr_comments_view,
result.pr_comments_completed,
digest=result.pr_comments_digest,
filter_summary=getattr(result, "pr_comments_filter_summary", None),
),
_build_linked_issues_section(
result.linked_issues, result.linked_issues_completed
),
_build_epic_section(result.epic_issue, result.epic_completed),
_build_available_blocks_section(result),
_data_completeness_section(result),
OUTPUT_CONTRACT,
]
return "\n\n".join(s for s in sections if s)
def build_request_changes_prompt(
cfg: Any,
item: dict[str, Any],
group: Any,
result: Any,
clone_section: str,
) -> str:
"""Assemble the prompt for a ``request_changes_pr`` work group.
Same shape as :func:`build_pr_fix_prompt` plus the active
REQUEST_CHANGES review section. Order: header → diff → clone →
PR description → CI status → reviews → comments → linked → epic
→ data-completeness → output contract.
"""
header = assemble_header(cfg, item, group, PR_CLAIM_NOTE)
diff_section = _build_diff_section_text(
head_sha=result.head_sha,
prefetch_diff_text=result.diff_text,
prefetch_truncated=result.diff_truncated,
prefetch_unavailable=result.diff_unavailable,
prefetch_info=result.diff_info,
)
sections = [
header,
diff_section,
clone_section,
_build_pr_description_section(result.pr_details),
_build_ci_status_section(result.ci_status, result.ci_status_completed),
_build_ci_detail_section(result.ci_detail, result.ci_detail_completed),
_build_ci_failure_logs_section(
result.ci_failure_logs,
result.ci_failure_logs_completed,
),
_build_active_reviews_section(
result.request_changes_reviews,
result.request_changes_reviews_completed,
),
_build_comment_reviews_section(
result.comment_reviews,
result.comment_reviews_completed,
),
_build_comments_section(
"Pre-fetched PR comments",
"pr_comments",
result.pr_comments_view,
result.pr_comments_completed,
digest=result.pr_comments_digest,
filter_summary=getattr(result, "pr_comments_filter_summary", None),
),
_build_linked_issues_section(
result.linked_issues, result.linked_issues_completed
),
_build_epic_section(result.epic_issue, result.epic_completed),
_build_available_blocks_section(result),
_data_completeness_section(result),
OUTPUT_CONTRACT,
]
return "\n\n".join(s for s in sections if s)
def build_new_issue_prompt(
cfg: Any,
item: dict[str, Any],
group: Any,
result: Any,
) -> str:
"""Assemble the prompt for a ``new_issue`` work group. No diff,
no clone (no PR exists yet — the worker creates the branch); just
the issue body, comments, linked bodies, and Epic."""
header = assemble_header(cfg, item, group, ISSUE_CLAIM_NOTE)
title = str(item.get("title") or "")
sections = [
header,
_build_issue_body_section(result.issue_body, title),
_build_comments_section(
"Pre-fetched issue comments",
"issue_comments",
result.issue_comments,
result.issue_comments_completed,
),
_build_linked_issues_section(
result.linked_issues, result.linked_issues_completed
),
_build_epic_section(result.epic_issue, result.epic_completed),
_build_available_blocks_section(result),
_data_completeness_section(result),
OUTPUT_CONTRACT,
]
return "\n\n".join(s for s in sections if s)
__all__ = (
"ISSUE_CLAIM_NOTE",
"OUTPUT_CONTRACT",
"PR_CLAIM_NOTE",
"PR_COMPLIANCE_CHECKLIST",
"assemble_header",
"build_new_issue_prompt",
"build_pr_fix_prompt",
"build_request_changes_prompt",
"render_worker_credentials_section",
)