Skip to content

Commit 53bd2cf

Browse files
authored
Merge pull request #31 from ApodexAI/feat/tier1-recovery-footer-hook
feat(context): let the host word a Tier 1 card's recovery footer
2 parents 993002a + 90843b3 commit 53bd2cf

7 files changed

Lines changed: 218 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,46 @@ the GitHub Release body, so a release with no entry here fails.
77

88
Versioning follows [docs/versioning.md](docs/versioning.md).
99

10+
## [0.8.0] - 2026-09-05
11+
12+
### Added
13+
14+
- `KeepLastNToolResultsCompactor` takes a `recovery_footer` callable that renders
15+
the last line of a Tier 1 mini card — the handle that fetches the discarded body
16+
back. The default, `default_recovery_footer`, is the existing
17+
`[Full text] <handle>`, so no consumer changes behaviour by upgrading.
18+
19+
Rationale: a Tier 1 card *replaces* the whole tool message, so unlike the
20+
loop's own truncation footer (`_spill_footer`, which appends to a surviving
21+
body) there is nothing left beside the handle to tell the model what it is for.
22+
On a Tier1-only turn — Tier 2 fires only when Tier 1 did not free enough — the
23+
model therefore sees bare handles and is never told they are recoverable at all.
24+
25+
The default cannot fix that itself: this module knows neither what a host calls
26+
its recovery tool nor whether that tool is bound for the agent whose history it
27+
is compacting, and a footer naming a tool the agent cannot call is worse than no
28+
footer (the same reason `_spill_footer` gates its prose on the tool map). A host
29+
that does know both should pass `recovery_footer`.
30+
31+
Write host prose as prose. `recover_result(spill_id="...")` renders to the model
32+
as source code and it answers in kind: on a live run a model reproduced such a
33+
footer inside a ```bash block instead of emitting a tool call, and
34+
`LeakedToolCallRetryObserver` fired twice. Name the tool and its argument in
35+
words.
36+
37+
Idempotency is unaffected — the already-carded check keys on
38+
`OMITTED_TOOL_RESULT_PLACEHOLDER`, not on the footer's wording — and the footer
39+
is called only when a body actually spilled, so a host renderer is never asked
40+
to point at nothing.
41+
42+
- `TieredCompactor` takes and forwards the same `recovery_footer` to the Tier 1
43+
compactor it builds internally. Tier 1 is a winning candidate in its own right,
44+
so its card reaches the model from the tiered path exactly as from the
45+
standalone one. Without the forward, a workflow whose primary path is tiered and
46+
whose fallback is standalone would render the handle-only default on every
47+
production turn and the host's prose only in the fallback — the harder of the
48+
two failures to notice.
49+
1050
## [0.7.0] - 2026-09-04
1151

1252
### Changed

‎agent_core/runtime/loop/compact.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"StringSliceCompactor",
3131
"compact_messages",
3232
"compress_tool_results",
33+
"default_recovery_footer",
3334
"estimate_tokens",
3435
"partition_for_compaction",
3536
"tool_names_by_call_id",
@@ -77,6 +78,28 @@
7778
URL_RE = re.compile(r'https?://[^\s\)>"\'<]+')
7879
_TOOL_RESULT_COMPACT_MAX_CHARS = 1_200
7980

81+
82+
def default_recovery_footer(spill_path: str) -> str:
83+
"""Render the last line of a mini card: the handle that fetches the body back.
84+
85+
Deliberately says nothing about HOW to fetch it. A host's recovery tool is
86+
the host's own — it may be named anything, take different arguments, or not
87+
be bound for this agent at all — and a footer naming a tool the agent cannot
88+
call is worse than no footer (see ``_spill_footer``, which gates its own
89+
prose on the tool being in the tool map for exactly that reason). This
90+
default therefore carries only the handle, which is correct everywhere.
91+
92+
Hosts that DO bind a recovery tool, and know it is bound for this agent,
93+
should pass ``recovery_footer`` to say so in prose: a card is the only thing
94+
left on the message, so unlike ``_spill_footer``'s site there is no
95+
surviving instruction next to it telling the model what the handle is for.
96+
Write that prose as prose. ``recover_result(spill_id="...")`` renders to the
97+
model as source code, and it responds in kind — on a live run it reproduced
98+
such a footer inside a ```bash block instead of emitting a tool call, and
99+
``LeakedToolCallRetryObserver`` fired twice.
100+
"""
101+
return f"[Full text] {spill_path}"
102+
80103
# Header of the spill recovery index. This is presentation only — the text the
81104
# MODEL reads above the paths — since the index is identified by
82105
# ``Message.spill_refs``. The two remaining substring checks against it
@@ -574,6 +597,11 @@ class KeepLastNToolResultsCompactor:
574597
575598
``keep_tool_result == -1`` disables filtering entirely.
576599
600+
``recovery_footer`` renders that last line. The default carries the handle
601+
and nothing else, because this module cannot know what a host's recovery tool
602+
is called or whether it is bound; a host that does know should pass prose
603+
naming it. See :func:`default_recovery_footer`.
604+
577605
Caveat: only ``ToolMessage`` content is redacted. Workflows that
578606
inject large content as ``HumanMessage`` (e.g. an observer that
579607
splices fan-in reports between turns) bypass this compactor; pair
@@ -586,6 +614,7 @@ def __init__(
586614
keep_tool_result: int,
587615
protect_tool_names: frozenset[str] = frozenset(),
588616
spill: Callable[[str, str], str | None] | None = None,
617+
recovery_footer: Callable[[str], str] = default_recovery_footer,
589618
) -> None:
590619
if keep_tool_result < -1:
591620
raise ValueError(f"keep_tool_result must be >= -1 (got {keep_tool_result})")
@@ -597,6 +626,7 @@ def __init__(
597626
# age only, so existing callers are unaffected.
598627
self._protect = frozenset(protect_tool_names)
599628
self._spill = spill
629+
self._recovery_footer = recovery_footer
600630

601631
def compact(
602632
self,
@@ -655,7 +685,7 @@ def compact(
655685
if card:
656686
placeholder += "\n" + card
657687
if spill_path:
658-
placeholder += f"\n[Full text] {spill_path}"
688+
placeholder += "\n" + self._recovery_footer(spill_path)
659689
# Without a pointer, replacing the body DESTROYS it, so a card that
660690
# is not even shorter is a pure loss and we keep the body. With one
661691
# we always replace, even when the card is longer: the pointer only

‎agent_core/runtime/loop/tiered_compact.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
INPUT_ESTIMATE_KEY,
1919
KeepLastNToolResultsCompactor,
2020
compress_tool_results,
21+
default_recovery_footer,
2122
estimate_tokens,
2223
tool_names_by_call_id,
2324
)
@@ -215,6 +216,7 @@ def __init__(
215216
summary_retries: int = 2,
216217
summary_retry_timeout_s: float | None = None,
217218
prompt_builder: SummaryPromptBuilder = compaction_prompt,
219+
recovery_footer: Callable[[str], str] = default_recovery_footer,
218220
manifest_max_paths: int | None = _SPILL_MANIFEST_MAX_PATHS,
219221
manifest_max_chars: int | None = _SPILL_MANIFEST_MAX_CHARS,
220222
) -> None:
@@ -233,10 +235,16 @@ def __init__(
233235
spill_callback = spill or (
234236
spill_store.spill_compacted_body if spill_store is not None else None
235237
)
238+
# Forwarded, not defaulted here: Tier 1's card is the ONLY thing left on
239+
# a message it rewrites, and Tier 1 is a winning candidate in its own
240+
# right (``best_label == "tier1"``), so a host that worded the footer for
241+
# the standalone compactor must get the same wording through this one or
242+
# the same run renders two different footers.
236243
self._tier1 = KeepLastNToolResultsCompactor(
237244
keep_tool_result=keep_tool_result,
238245
protect_tool_names=protect_tool_names,
239246
spill=spill_callback,
247+
recovery_footer=recovery_footer,
240248
)
241249
# Tier 1 is not the only candidate that can win, and the others rewrite
242250
# the SAME protected results — which Tier 1 also keeps out of its spill

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "apodex-agent-core"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
description = "Shared, product-neutral runtime primitives for Apodex agents"
55
readme = "README.md"
66
license = "Apache-2.0"

‎tests/test_keep_last_n_compactor.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
OMITTED_TOOL_RESULT_PLACEHOLDER,
1919
KeepLastNToolResultsCompactor,
2020
_args_preview,
21+
default_recovery_footer,
2122
)
2223

2324

@@ -218,3 +219,85 @@ def test_loop_cap_handle_is_used_when_no_spill_ref_is_pinned():
218219
)
219220
tool_out = next(m for m in out if m.get("role") == "tool")
220221
assert tool_out["spill_refs"] == ["/spill/loop-cap"]
222+
223+
224+
# --- the recovery footer hook ----------------------------------------------
225+
226+
227+
def test_the_default_footer_names_no_tool():
228+
"""A card is the whole message, so a wrong tool name here has no antidote.
229+
230+
This module cannot know what a host calls its recovery tool, or whether that
231+
tool is bound for the agent whose history this is. Carrying only the handle
232+
is the one rendering that is correct in every host, which is why it is the
233+
default rather than a guess at the common case.
234+
"""
235+
body = "see https://example.com/a " + "x" * 2_000
236+
content = _blanked(
237+
_one_call("web_search", '{"query": "x"}', body),
238+
spill=lambda _n, _c: "/spill/xyz",
239+
)
240+
assert content.splitlines()[-1] == default_recovery_footer("/spill/xyz")
241+
assert "recover" not in content.splitlines()[-1].lower()
242+
243+
244+
def test_a_host_footer_replaces_the_last_line_and_nothing_else():
245+
body = "see https://example.com/a " + "x" * 2_000
246+
messages = _one_call("web_search", '{"query": "x"}', body)
247+
default = _blanked(messages, spill=lambda _n, _c: "/spill/xyz")
248+
hosted = _blanked(
249+
messages,
250+
spill=lambda _n, _c: "/spill/xyz",
251+
recovery_footer=lambda ref: f"[Saved. Fetch it with fetch_body id {ref}.]",
252+
)
253+
254+
assert hosted.splitlines()[-1] == "[Saved. Fetch it with fetch_body id /spill/xyz.]"
255+
# The card above the footer — call line and source URLs — is untouched, so a
256+
# host swapping the footer cannot silently change the card's token budget.
257+
assert hosted.splitlines()[:-1] == default.splitlines()[:-1]
258+
259+
260+
def test_a_host_footer_still_reaches_the_model_only_when_a_body_spilled():
261+
"""No handle, no footer — a host renderer must not invent one.
262+
263+
The card is emitted for unspilled bodies too (that is the whole point of the
264+
args + URLs lines). Calling the footer there would have it render a pointer
265+
to nothing.
266+
"""
267+
calls: list[str] = []
268+
269+
def footer(ref: str) -> str:
270+
calls.append(ref)
271+
return f"[Saved: {ref}]"
272+
273+
content = _blanked(
274+
_one_call("web_search", '{"query": "x"}', "see https://example.com/a " + "x" * 2_000),
275+
recovery_footer=footer,
276+
)
277+
assert calls == []
278+
assert "[Saved:" not in content
279+
280+
281+
def test_a_host_footer_survives_a_second_pass_unnested():
282+
"""Idempotency is anchored on the placeholder, not the footer's wording.
283+
284+
A host footer is free to be longer than the default, which is exactly the
285+
case where a second pass re-carding the message would compound. The
286+
already-placeheld check has to catch it regardless of what the last line says.
287+
"""
288+
body = "see https://example.com/a " + "x" * 2_000
289+
messages = _one_call("web_search", '{"query": "x"}', body)
290+
compactor = KeepLastNToolResultsCompactor(
291+
keep_tool_result=0,
292+
spill=lambda _n, _c: "/spill/xyz",
293+
recovery_footer=lambda ref: (
294+
"[Full text saved. Recovery id: " + ref + " — use the recover_result tool "
295+
"(a tool call, not a shell command) with that spill id.]"
296+
),
297+
)
298+
once = compactor.compact(messages, 0)
299+
twice = compactor.compact(once, 0)
300+
first = next(m["content"] for m in once if m.get("role") == "tool")
301+
second = next(m["content"] for m in twice if m.get("role") == "tool")
302+
assert first == second
303+
assert second.count("Recovery id:") == 1

‎tests/test_tiered_compact.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
OMITTED_TOOL_RESULT_PLACEHOLDER,
1616
SPILL_MANIFEST_HEADER,
1717
KeepLastNToolResultsCompactor,
18+
default_recovery_footer,
1819
estimate_tokens,
1920
)
2021
from agent_core.runtime.loop.tiered_compact import (
@@ -268,3 +269,56 @@ def test_overlong_handle_removes_an_old_index_without_inserting_an_empty_one():
268269
for m in twice
269270
)
270271
assert not any(SPILL_MANIFEST_HEADER in str(m.get("content")) for m in twice)
272+
273+
274+
def test_tiered_forwards_the_recovery_footer_to_its_tier1():
275+
"""A host that worded the footer must not get two wordings in one run.
276+
277+
Tier 1 is a winning candidate in its own right, so its card reaches the model
278+
from inside ``TieredCompactor`` exactly as it does from the standalone
279+
compactor. If this constructor swallowed ``recovery_footer``, a workflow whose
280+
primary path is tiered and whose fallback path is standalone would render the
281+
handle-only default on every production turn and the host's prose only in the
282+
fallback — the harder failure to notice of the two.
283+
"""
284+
async def run():
285+
footer = lambda ref: f"[Saved. Ask fetch_body for {ref}.]" # noqa: E731
286+
tiered = TieredCompactor(
287+
keep_tool_result=1,
288+
summary_llm=_FakeLLM(),
289+
relief_target=10**9,
290+
spill=lambda _n, _c: "/spill/xyz",
291+
recovery_footer=footer,
292+
)
293+
out = await tiered.compact(_msgs(), 1)
294+
carded = [
295+
m.get("content") or ""
296+
for m in out
297+
if (m.get("content") or "").startswith(OMITTED_TOOL_RESULT_PLACEHOLDER)
298+
]
299+
assert carded, "expected Tier 1 to card at least one tool body"
300+
for content in carded:
301+
assert content.splitlines()[-1] == "[Saved. Ask fetch_body for /spill/xyz.]"
302+
303+
asyncio.run(run())
304+
305+
306+
def test_tiered_default_footer_is_unchanged():
307+
async def run():
308+
tiered = TieredCompactor(
309+
keep_tool_result=1,
310+
summary_llm=_FakeLLM(),
311+
relief_target=10**9,
312+
spill=lambda _n, _c: "/spill/xyz",
313+
)
314+
out = await tiered.compact(_msgs(), 1)
315+
carded = [
316+
m.get("content") or ""
317+
for m in out
318+
if (m.get("content") or "").startswith(OMITTED_TOOL_RESULT_PLACEHOLDER)
319+
]
320+
assert carded
321+
for content in carded:
322+
assert content.splitlines()[-1] == default_recovery_footer("/spill/xyz")
323+
324+
asyncio.run(run())

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)