fix(delegation): budget subagent summaries against parent context headroom

Batch delegation returned each subagent's full final_response verbatim
into the parent's context. A fan-out of N children could dump 60k+ tokens
at once, blowing the parent's context window and — on rate-limited
providers — triggering a compression/429 death spiral (429 misread as
context-too-large -> window step-down -> retry loop -> conversation dies).

Cap each summary against the parent's *remaining* context headroom split
across the batch (not a magic char count). When trimming, mirror the
web_extract convention: spill the full text to cache/delegation (mounted
into remote backends via credential_files._CACHE_DIRS) and return a
head+tail window (75/25, line-snapped) plus a footer with the exact
read_file offset to page the omitted middle. Both the subagent's opening
AND its closing (outcomes / files-changed / issues, which live at the end)
survive in-context, and nothing is lost — the parent can read_file the
full version on any backend.

delegation.max_summary_chars (default 24000) is a static ceiling layered
on top as belt-and-suspenders for models that ignore 'be concise'; 0
disables it. Child prompt tightened to lead with outcomes / bullets.

Co-authored-by: rc-int <rcint@klaith.com>
This commit is contained in:
teknium1 2026-06-30 01:30:47 -07:00 committed by Teknium
parent 3b2bb30c5d
commit 35a0803a3b
5 changed files with 341 additions and 2 deletions

View file

@ -601,6 +601,18 @@ def _preserve_parent_mcp_toolsets(
DEFAULT_MAX_ITERATIONS = 50
# Hard per-summary character ceiling layered on top of the dynamic
# headroom budget (see _apply_summary_budget). Belt-and-suspenders for
# models that ignore the "be concise" instruction. 0 disables the ceiling.
DEFAULT_MAX_SUMMARY_CHARS = 24000
# Fraction of the parent's *remaining* context headroom that the whole batch
# of subagent summaries is allowed to consume. The per-summary budget is this
# slice divided across the batch, so N children can't collectively blow the
# parent's window (the compression/429 death-spiral in issue/PR #9126).
_SUMMARY_HEADROOM_FRACTION = 0.5
# Floor so a single summary always gets a usable slice even when the parent is
# already nearly full — below this we'd be truncating to noise.
_MIN_SUMMARY_CHARS = 2000
# No default wall-clock cap on child agents: legitimate heavy subagent work
# (deep reviews, research fan-outs, slow reasoning models) was being killed
# mid-task. Errors should come from what the child actually does; stuck-child
@ -702,8 +714,10 @@ def _build_child_system_prompt(
"- Any issues encountered\n\n"
"Important workspace rule: Never assume a repository lives at /workspace/... or any other container-style path unless the task/context explicitly gives that path. "
"If no exact local path is provided, discover it first before issuing git/workdir-specific commands.\n\n"
"Be thorough but concise -- your response is returned to the "
"parent agent as a summary."
"Keep your final summary tight: lead with outcomes, prefer bullet "
"points over paragraphs, and don't replay your whole process. Your "
"response is returned to the parent agent as a summary, and overlong "
"summaries crowd out the parent's context window."
)
if role == "orchestrator":
child_note = (
@ -1509,6 +1523,181 @@ def _dump_subagent_timeout_diagnostic(
return None
def _spill_summary_to_file(task_index: int, summary: str) -> Optional[str]:
"""Write a subagent's full summary to the delegation cache and return path.
Mirrors web_extract's ``_store_full_text``: the file lands in
``cache/delegation`` which is mounted read-only into remote backends
(Docker/Modal/SSH) via ``credential_files._CACHE_DIRS``, so the parent's
terminal/``read_file`` tools can page through the complete text on any
backend. Returns the absolute path, or None on failure (best-effort:
the trimmed head+tail is still returned to the parent regardless).
"""
try:
from hermes_constants import get_hermes_dir
import datetime as _dt
cache_dir = get_hermes_dir("cache/delegation", "delegation_cache")
cache_dir.mkdir(parents=True, exist_ok=True)
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
path = cache_dir / f"subagent-summary-{task_index}-{ts}.txt"
path.write_text(summary, encoding="utf-8")
return str(path)
except Exception as exc:
logger.debug("Failed to spill subagent summary to file: %s", exc)
return None
def _trim_summary_with_footer(
summary: str, cap: int, task_index: int
) -> tuple[str, Optional[str]]:
"""Return (model_text, spill_path) for one over-budget summary.
Mirrors web_extract's ``_truncate_with_footer``: keep a head+tail window
(~75% head / ~25% tail, snapped to line boundaries) so the subagent's
opening AND its closing (outcomes / files-changed / issues, which live at
the end) both survive, spill the full text to disk, and append a footer
telling the parent exactly how much it's seeing and the precise
``read_file offset=`` to page into the omitted middle. Deterministic.
"""
original_len = len(summary)
head_budget = int(cap * 0.75)
tail_budget = cap - head_budget
head = summary[:head_budget]
tail = summary[-tail_budget:]
# Snap the head cut back to the last newline so we don't slice mid-line.
nl = head.rfind("\n")
if nl > head_budget * 0.5:
head = head[:nl]
# Snap the tail cut forward to the next newline for the same reason.
nl = tail.find("\n")
if 0 <= nl < tail_budget * 0.5:
tail = tail[nl + 1:]
spill_path = _spill_summary_to_file(task_index, summary)
footer_lines = [
"",
"" * 8 + " [SUMMARY TRUNCATED] " + "" * 8,
f"Showing {len(head):,} chars (head) + {len(tail):,} chars (tail) "
f"of {original_len:,} total — trimmed to protect the parent's context window.",
]
if spill_path:
# read_file is 1-indexed; +2 moves past the last head line shown.
middle_start_line = head.count("\n") + 2
footer_lines.append(f"Full subagent output saved to: {spill_path}")
footer_lines.append(
f'To read the omitted middle: read_file path="{spill_path}" '
f"offset={middle_start_line} limit=200 (the file is the complete "
f"summary; raise/lower offset to page through it)."
)
else:
footer_lines.append(
"Full output could not be stored to disk; the head+tail above is "
"all that was preserved."
)
footer_lines.append("" * 37)
model_text = head + "\n\n[... middle omitted — see footer ...]\n\n" + tail + "\n".join(footer_lines)
return model_text, spill_path
def _parent_summary_char_budget(parent_agent, n_summaries: int) -> Optional[int]:
"""Per-summary character budget sized against the parent's *remaining*
context headroom, split across the batch.
The overflow this guards against is N summaries entering the parent
context at once (batch fan-out), not any single summary being large. We
take a fraction of the headroom the parent has left (resolved context
length minus what's already in its prompt) and divide it across the batch,
converting tokenschars at the standard ~4 chars/token estimate.
Returns the per-summary char budget, or None when the parent's context
state is unknown (no compressor / no token count) in which case the
caller falls back to the static char ceiling only.
"""
try:
compressor = getattr(parent_agent, "context_compressor", None)
context_length = getattr(compressor, "context_length", None)
if not isinstance(context_length, int) or context_length <= 0:
return None
used_tokens = getattr(parent_agent, "session_prompt_tokens", 0)
if not isinstance(used_tokens, (int, float)) or used_tokens < 0:
used_tokens = 0
# Reserve the compressor's output budget so we measure INPUT headroom.
reserved = getattr(compressor, "max_tokens", 0) or 0
headroom_tokens = context_length - int(used_tokens) - int(reserved)
if headroom_tokens <= 0:
# Parent is already over budget — give each summary only the floor.
return _MIN_SUMMARY_CHARS
batch_token_budget = int(headroom_tokens * _SUMMARY_HEADROOM_FRACTION)
per_summary_tokens = batch_token_budget // max(1, n_summaries)
per_summary_chars = per_summary_tokens * 4 # ~4 chars/token
return max(_MIN_SUMMARY_CHARS, per_summary_chars)
except Exception:
logger.debug("Summary budget computation failed", exc_info=True)
return None
def _apply_summary_budget(results: List[Dict[str, Any]], parent_agent) -> None:
"""Trim subagent summaries in-place so the batch can't overflow the
parent's context window, spilling full text to disk so nothing is lost.
The effective per-summary cap is the MIN of:
- the dynamic headroom budget (remaining parent context ÷ batch size), and
- the static ``delegation.max_summary_chars`` ceiling (0 = disabled).
When a summary exceeds the cap, its full text is written to a file and the
in-context summary becomes a head slice plus a pointer to that file. This
addresses issue/PR #9126: batch fan-out returned N full summaries verbatim,
blowing the parent context and (on rate-limited providers) triggering a
compression/429 death spiral.
"""
summaries = [
r for r in results if isinstance(r, dict) and isinstance(r.get("summary"), str) and r["summary"]
]
if not summaries:
return
cfg = _load_config()
try:
static_ceiling = int(cfg.get("max_summary_chars", DEFAULT_MAX_SUMMARY_CHARS))
except (TypeError, ValueError):
static_ceiling = DEFAULT_MAX_SUMMARY_CHARS
dynamic_budget = _parent_summary_char_budget(parent_agent, len(summaries))
# Combine the two caps. Either can be absent/disabled.
candidates = [c for c in (static_ceiling, dynamic_budget) if c and c > 0]
if not candidates:
return # both disabled / unknown → leave summaries untouched
cap = min(candidates)
for entry in summaries:
summary = entry["summary"]
if len(summary) <= cap:
continue
original_len = len(summary)
model_text, spill_path = _trim_summary_with_footer(
summary, cap, entry.get("task_index", -1)
)
entry["summary"] = model_text
entry["summary_truncated"] = True
if spill_path:
entry["summary_full_path"] = spill_path
logger.debug(
"[subagent-%s] summary trimmed %d → ~%d chars (spill=%s)",
entry.get("task_index", "?"),
original_len,
cap,
spill_path or "none",
)
def _run_single_child(
task_index: int,
goal: str,
@ -2437,6 +2626,12 @@ def delegate_task(
# Sort by task_index so results match input order
results.sort(key=lambda r: r["task_index"])
# Cap subagent summaries against the parent's remaining context
# headroom (split across the batch) before they enter the parent's
# conversation. Full text is spilled to disk so nothing is lost.
# Covers both the single-task and batch paths. See PR #9126.
_apply_summary_budget(results, parent_agent)
# Notify parent's memory provider of delegation outcomes
if (
parent_agent