Skip to content

feat(context): let the host word a Tier 1 card's recovery footer - #31

Merged
zhanghanduo merged 1 commit into
mainfrom
feat/tier1-recovery-footer-hook
Sep 5, 2026
Merged

zhanghanduo merged 1 commit into
mainfrom
feat/tier1-recovery-footer-hook

Conversation

@zhanghanduo

Copy link
Copy Markdown
Collaborator

What

Adds a recovery_footer: Callable[[str], str] keyword to KeepLastNToolResultsCompactor, and forwards the same callable through TieredCompactor to the Tier 1 compactor it builds internally. The default, default_recovery_footer, renders [Full text] {spill_path} — byte-for-byte what the compactor renders today, so no caller sees a change.

0.7.0 → 0.8.0 per docs/versioning.md: new capability, additive keyword argument with a default (non-breaking).

Why

A Tier 1 card's last line is a handle and nothing else, deliberately: this module knows neither what a host calls its recovery tool nor whether that tool is bound for the agent whose history it is compacting. A footer naming a tool the agent cannot call is worse than no footer — which is exactly why _spill_footer gates its own prose on recover_result being in the tool map.

But a card has no such surviving neighbour. _spill_footer appends to a message that still carries other text; a card replaces the whole tool message. So on a turn where only Tier 1 ran — and Tier 2 fires only when Tier 1 did not free enough — the model is shown a bare id with nothing beside it saying the body is still fetchable. Functionally nothing is lost (spill_refs is set on the same message and the recovery index reads it first); what is lost is the model knowing.

That makes it a host injection point rather than an upstream wording bug: a host that does bind a reader, and knows when, can say so. This PR opens that point and changes nothing else.

The prose constraint, documented in the code

default_recovery_footer's docstring carries the measured reason a host must write prose, not a call: recover_result(spill_id="...") renders to the model as source code and it answers in kind — on a live run a model reproduced exactly such a footer inside a ```bash block instead of emitting a tool call, and LeakedToolCallRetryObserver fired twice. Without that written down, the first host to use this hook re-derives the anti-pattern.

Why TieredCompactor forwards rather than defaults

Tier 1 is a winning candidate in its own right (best_label == "tier1"), so its card reaches the model from the tiered path exactly as from the standalone one. A workflow whose primary path is tiered and whose fallback is standalone would otherwise render the default on every production turn and the host's wording only in the fallback — the harder of the two failures to notice.

Tests (6 new)

Standalone (tests/test_keep_last_n_compactor.py):

  • the default footer names no tool
  • a host footer replaces the last line and nothing else — pins hosted[:-1] == default[:-1], so a host cannot silently change the card's token budget
  • the footer callable is invoked only when a body actually spilled (zero calls otherwise)
  • idempotency with a host footer longer than the default: a second pass does not nest it

Tiered (tests/test_tiered_compact.py):

  • the footer is forwarded to the internal Tier 1
  • the default is unchanged when nothing is passed

Idempotency is unaffected by wording throughout: the already-carded check keys on OMITTED_TOOL_RESULT_PLACEHOLDER, not on the footer.

Verification

  • uv run pytest -q1474 passed, 1 skipped
  • uv run ruff check . → All checks passed
  • scripts/check_version_bump.py --base main → "Published code changed and version increased 0.7.0 -> 0.8.0"
  • scripts/check_unconsumed_fields.py → clean
  • CHANGELOG has a [0.8.0] section

Consumer

ApodexHarness has the corresponding host-side adapter ready and held locally pending this release. On a >= 0.8.0 pin its core/runtime/loop/compact.py collapses from a compact()-overriding subclass to a single recovery_footer= argument, and — the part it cannot do today — the tiered path becomes reachable at all, since it aliases tiered_compact wholesale and so cannot subclass into it.

🤖 Generated with Claude Code

A Tier 1 mini card REPLACES the whole tool message. The loop's own truncation
footer (``_spill_footer``) appends to a surviving body and can therefore afford
to be the only place that explains recovery — a card cannot, because after it
runs there is nothing beside the handle at all. On a Tier1-only turn (Tier 2
fires only when Tier 1 did not free enough) the model sees a bare
``[Full text] <handle>`` and is never told the handle is recoverable, let alone
how.

The default cannot fix that itself. This module knows neither what a host calls
its recovery tool nor whether that tool is bound for the agent whose history it
is compacting, and a footer naming a tool the agent cannot call is worse than no
footer — precisely why ``_spill_footer`` gates its prose on ``recover_result``
being in the tool map. So the handle-only rendering stays the default, and the
decision moves to the party that has the tool map: ``recovery_footer``.

``default_recovery_footer`` is the existing wording, so no consumer changes
behaviour by upgrading; both its docstring and the CHANGELOG carry the one
constraint a host has to respect. Write the prose as prose. The callable form
``recover_result(spill_id="...")`` reads to the model as source code and it
answers in kind: on a live run a model reproduced such a footer inside a ```bash
block instead of emitting a tool call, and ``LeakedToolCallRetryObserver`` fired
twice. That failure is why the wording was flattened to ``[Full text]`` in the
first place — the fix is prose naming the tool, not a return to the callable
form.

Tests pin the three things a host renderer must not be able to break: the
default names no tool, a host footer replaces the last line and leaves the
card's arg/URL lines (and so its token budget) untouched, and the footer is
never invoked when nothing spilled. A fourth pins idempotency against a footer
LONGER than the default, since that is the case where a second pass re-carding
the message would compound.

MINOR per docs/versioning.md: new capability, additive keyword argument.

Generated with [Claude Code](https://claude.ai/code)
via [Very Happy](https://github.com/Mereithhh/very-happy)

Co-Authored-By: Claude <noreply@anthropic.com>
@zhanghanduo
zhanghanduo merged commit 53bd2cf into main Sep 5, 2026
5 checks passed
@zhanghanduo
zhanghanduo deleted the feat/tier1-recovery-footer-hook branch September 5, 2026 03:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant