Files
bench/manager/core/reports.py
T
istosandClaude Opus 5 d9907fadde core: a long report keeps its head, and says what it cut
The closing report was capped by keeping its last 3000 characters, so a
report longer than that lost exactly the sentence the prompt contract
puts first — the state of the work — and the task file began mid-word.
Card 32's record still shows it.

reports.py is now the one place a report is clipped: it keeps both ends
(the headline and the review-first pointer), cuts the middle on line
boundaries, and leaves one line of prose saying how many characters went
and naming the log under local/state/agent/logs/ that still holds all of
it. One documented cap, CAP = 12000, replaces 3000 in agents.py and 1500
in github.py, so the PR body and the task file carry the same text for
the same run. It sits left of both consumers in the module map and
depends on config alone.

The failure excerpt keeps its tail deliberately — for a crash the end is
the story — and now says so through reports.tail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:27:29 +02:00

161 lines
6.3 KiB
Python

"""Keeping an agent's closing report — one clip, every surface.
A report is the permanent record: it is appended to the task file, shown
as the session's last entry, and carried into the PR body. When one is
too long to keep whole, *which end survives* is the whole question. The
report contract asks the agent to lead with the state of the work, so the
first lines are the part a reader must not lose — a tail slice drops
exactly the sentence that says what happened.
So the clip here keeps both ends and cuts the middle, on line boundaries,
and says so in words on its own line, naming the log that still holds the
whole thing. The failure excerpt is the one deliberate exception and does
not come through here: for a run that died, the *end* is the story.
Sits left of both consumers in the module map (config → state →
taskfiles → events / github / … → agents), so agents.py and github.py
clip identically instead of reaching sideways for each other's helper.
Depends on config alone, for the repo root a log path is named against.
"""
from __future__ import annotations
from pathlib import Path
import config
# What the permanent record keeps of one report, in characters. Roughly
# 1,800 words — enough for the report the prompt contract asks for
# (state of the work, what to do, what to know, review-first pointer)
# with room to spare, so in practice reports arrive whole and this is
# the backstop rather than the norm. One number, both consumers: the
# task file and the PR body must never tell different stories about the
# same run.
CAP = 12000
# Of the budget, how much goes to the head. The headline is the contract;
# the tail is the "review first" pointer that closes it. Two to one.
HEAD_SHARE = 2 / 3
def clean(text: str) -> str:
"""An agent's -p output is its final report; strip the hook-failure
noise other tools may have interleaved. No capping — that is `report`
(head + tail) or `tail` (a dead run's ending)."""
lines = [l for l in (text or "").strip().splitlines()
if not ("hook" in l and "failed" in l)]
return "\n".join(lines).strip()
def tail(text: str, cap: int) -> str:
"""The last `cap` characters, cleaned. For a crash, where the end is
the story — see `_failure_excerpt` in agents.py."""
return clean(text)[-cap:]
def report(text: str, log_path: str | None = None, cap: int = CAP) -> str:
"""The report as the record should keep it.
Shorter than the cap: returned cleaned and otherwise byte for byte.
Longer: the leading lines and the trailing lines survive, separated by
one line of prose saying how much was cut and where the whole report
still lives. Cuts land on line boundaries, so no line of the record is
half a line — the single exception is a report that is one enormous
line, which is cut at a space rather than not shown at all.
"""
text = clean(text)
if len(text) <= cap:
return text
# Two passes over the elision line: the first counts what it costs at
# its longest (every character dropped), the second states the truth.
# Digits can only shrink, so the result never exceeds the cap. The 4
# is the blank line either side of it.
budget = cap - len(_elision(len(text), cap, log_path)) - 4
if budget <= 0:
# A cap too small to hold even the notice: keep the head, say
# nothing else. Nothing in bench configures one this small.
return _split_head(text, cap)[0]
head, rest = _split_head(text, int(budget * HEAD_SHARE))
end = _take_tail(rest, budget - len(head)).lstrip()
head = head.rstrip()
dropped = len(text) - len(head) - len(end)
kept = [head, _elision(dropped, cap, log_path)]
if end:
kept.append(end)
return "\n\n".join(kept)
def _elision(dropped: int, cap: int, log_path: str | None) -> str:
"""The line that stands where the middle was. A reader must never have
to infer that something was removed, nor go looking for the rest."""
return (f"… {dropped} characters of this report were cut here to keep the "
f"record within {cap} characters. The whole report is in "
f"{_log_reference(log_path)}.")
def _log_reference(log_path: str | None) -> str:
"""Where the unclipped report still is, repo-relative when it can be."""
logs = "manager/local/state/agent/logs/"
if not log_path:
return f"this run's log under `{logs}`"
path = Path(log_path)
try:
return f"`{path.resolve().relative_to(config.REPO)}`"
except (ValueError, OSError):
return f"`{logs}{path.name}`"
def _split_head(text: str, budget: int) -> tuple[str, str]:
"""Leading whole lines fitting the budget, and everything after them.
A first line longer than the entire budget is the one case a line gets
cut: at its last space, so the record ends on a word rather than on
`four" — they are`. A line with no space in it at all (one long token)
is cut where the budget runs out; there is no better place.
"""
kept, used = [], 0
for line in text.split("\n"):
cost = len(line) + (1 if kept else 0)
if used + cost > budget:
break
kept.append(line)
used += cost
if kept:
return "\n".join(kept), text[used:].lstrip("\n")
piece = _cut_at_space(text[:budget])
return piece, text[len(piece):].lstrip("\n")
def _take_tail(text: str, budget: int) -> str:
"""Trailing whole lines fitting the budget — the mirror of the head,
including how it treats one line too long to keep whole."""
kept, used = [], 0
for line in reversed(text.split("\n")):
cost = len(line) + (1 if kept else 0)
if used + cost > budget:
break
kept.insert(0, line)
used += cost
if kept:
return "\n".join(kept)
return _cut_at_space(text[-budget:], from_start=True)
def _cut_at_space(piece: str, from_start: bool = False) -> str:
"""Trim a fragment back to a word boundary, if one is near enough to
the edge to be worth losing. Half the fragment is the limit: past that
the cut is doing more harm than the broken word it avoids."""
if not piece:
return ""
if from_start:
space = piece.find(" ")
if 0 <= space < len(piece) // 2:
piece = piece[space + 1:]
else:
space = piece.rfind(" ")
if space > len(piece) // 2:
piece = piece[:space]
return piece.strip()