Skip to content

Commit 69b2035

Browse files
zhanghanduoclaude
andcommitted
feat(context): Tier1 迷你卡 + 收紧 handle 与 host metadata 契约
四件事,同一条线:Tier 1 丢掉的东西要么别丢,要么留得住,而"留得住" 依赖 handle 语义统一。 `KeepLastNToolResultsCompactor` 原先把老 tool body 整条换成占位符,丢掉调用 args 和 body 里所有 URL —— 恰好是后续轮次判断「这条 query 我是不是已经跑过」 最需要的两样。Tier 2 的摘要保留这两样,但 Tier 2 只在 Tier 1 没减够时才触发, 所以 Tier1-only 的轮次里模型看到的严格更少。 两个字段都是免费的:args 在请求方 assistant message 上,URL 在即将被丢弃的 body 里。没有 LLM 调用,没有额外存储,也没有第二套模型可见的恢复路径 —— 卡片只说"调了什么",不提供取回手段(那仍归下面的 recovery 脚注)。 实测 +218 字符/条,硬上限 400,URL 上限 3 条。 placeholder 仍是第一行,所以既有的 `startswith` 幂等判据一字不改继续成立。 卡片不比原文短就保留原文 —— 但**仅当没配 spill**。有 handle 一律替换,即使 卡片更长:handle 只能经 `spill_refs` → Tier 2 recovery index 到达模型,留着 body 反而让上游 spill 掉的全文永久不可恢复,而那种 body 本身已经是截断预览。 本仓库这条判据的作用域比同源实现窄一档:配了 spill 而被拒收时,既有分支已经 无条件保留 body,所以这里不需要额外的最小尺寸门槛。新增测试锁住它依赖的那条 既有分支。 `KeepLastNToolResultsCompactor` 此前没有任何直接测试,只被 tiered 用例借道 覆盖过两次;连同这次改动补 14 条。 `spill_refs` 描述的是消息上**当前还在**的内容,`result_store_ref` 描述的是 上游截断前的 body。先读后者会把已存过的 body 再存一遍,更糟的是把错的 handle 钉进 recovery index。 `manifest_max_paths` / `manifest_max_chars`,任一可为 `None` 解除。默认值不变 (20 / 3000),那是按「handle 渲染成文件路径」(60+ 字符/条) 定的尺寸。handle 是 短内容寻址 id 的产品每条只花约 17 字符,应当自己放开:上限一旦生效丢的是**最旧** 的 handle,而实测过决定性的早期证据正因为它的 handle 老化掉而在一段无关长弯路 之后不可恢复。 上限按**渲染后字符数**计,因为那是两种 handle 形态唯一共享的量 —— 也正因如此, 上限和 handle 形态不是可独立选择的两件事。 `result_metadata` 返回的东西原样带到 `render_tool_result`。这是产品自己给模型 措辞「这个调用重复了」所需的接缝:**算不算重复**是 per-tool 的产品策略 (`repeat_count`),**正文是否字节相同**是另一件始终实测的事实、没有对应字段, 而两者缺一就会对着一个实际不同的 body 断言 "identical output" —— 模型能对着 自己 history 验证的谎言。 透传是**原样**而非"AgentCore 不认识的剩余 key":剩余语义会在这里新增保留 key 的那天悄悄改变产品能看到的东西,正是下面这条检查存在的理由。 `scripts/check_unconsumed_fields.py` 进 CI —— 被监视模型上的字段,若在 `agent_core/` 内没有任何属性读取,必须在某个 `docs/*-boundary.md` 里被点名。 这条规则不是"给字段写文档",而是:**决定不消费某个字段本身是一个边界决策**, 没写下来的边界决策和疏漏无法区分。0.4.0 有四个字段正是这个状态 —— 类型对、测试过、pyright 干净,零消费者,而它们在产品侧的消费者全留在产品自己 那份 loop 拷贝里。产品换用 `run_agent_loop` 会丢掉据此措辞的提示,且**静默** 丢掉:不抛异常,不挂测试,模型原先读到的那句话直接消失。canary merge 看不见 这一类,因为它只能发现硬冲突。 脚本写完立刻抓到本次新增的 `host_metadata`,已按规则补进 boundary doc。 0.4.0 → 0.5.0。改了模型读到的文本,按 `docs/versioning.md` 属于 compaction-decision 变更,即 breaking,走 MINOR。 - 新增 `tests/test_keep_last_n_compactor.py` 14 条 - `tests/test_tiered_compact.py` +2(上限可配置 / 丢最旧) - `tests/test_tool_exec.py` +2(透传原样、默认空且不共享实例) - `uv run pytest -q` → 1164 passed - ruff / pyright / check_unconsumed_fields / uv build 全过 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1284ab3 commit 69b2035

13 files changed

Lines changed: 750 additions & 15 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ jobs:
1818
- run: uv sync --frozen --extra dev
1919
- run: uv run ruff check agent_core tests scripts
2020
- run: uv run pyright agent_core
21+
# A host-supplied field that agent_core/ never reads is invisible to every
22+
# other check here and to a canary merge: it types, it tests, and the note
23+
# a product worded from it vanishes on adoption. See the script's docstring.
24+
- run: python3 scripts/check_unconsumed_fields.py
2125
- run: uv run pytest -q
2226
- run: uv build
2327

CHANGELOG.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,86 @@ 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.6.0] - 2026-09-04
11+
12+
### Changed
13+
14+
- Tier 1 deterministic compaction (`KeepLastNToolResultsCompactor`) no longer
15+
leaves a bare placeholder where it drops a tool body. It now appends a bounded
16+
card naming the call (tool name + a single-line, 120-char arguments preview)
17+
and up to three source URLs found in the discarded body, above the existing
18+
recovery pointer. Both fields already existed in the history and in the body,
19+
so this adds no LLM call, no storage, and no second model-visible recovery
20+
route. Measured cost is ~+220 characters per elided result, hard-capped at 400.
21+
22+
Rationale: Tier 2's summary preserves arguments and URLs, but Tier 2 only fires
23+
when Tier 1 did not free enough, so on a Tier1-only turn the model lost exactly
24+
the two things it needs in order not to re-issue a query it already ran.
25+
26+
A card that would not be shorter than the body it replaces is skipped and the
27+
body kept verbatim — but only when no spill callback is configured. With a
28+
recovery pointer the body is always replaced even if the card is longer: the
29+
pointer reaches the model only through `spill_refs` → the Tier 2 recovery
30+
index, and such a body is itself already an upstream-truncated preview, so
31+
keeping it would strand the spilled full text as unrecoverable.
32+
33+
- `KeepLastNToolResultsCompactor` now resolves an existing recovery handle with
34+
`spill_refs` taking precedence over `result_store_ref`. A ref pinned by an
35+
earlier compaction pass describes the content still on the message, while the
36+
loop-cap handle describes the pre-truncation body upstream shed; reading the
37+
latter first re-spilled a body that was already stored and pinned the wrong
38+
handle into the recovery index.
39+
40+
### Added
41+
42+
- `TieredCompactor` takes `manifest_max_paths` and `manifest_max_chars`, either
43+
of which may be `None` to remove that bound. The defaults are unchanged (20 /
44+
3,000) and are sized for a handle rendered as a filesystem path. A product
45+
whose handles are short content-addressed ids pays a fraction of that per entry
46+
and should raise or remove the cap: when a cap binds, the OLDEST handles are
47+
dropped, and a product measured decisive early evidence becoming unrecoverable
48+
after a long unrelated detour for exactly that reason. The cap is charged
49+
against rendered characters, which is the only quantity the two handle shapes
50+
share — cap and handle shape are therefore not independent choices.
51+
- `ToolResult.host_metadata` carries whatever `ToolExecutionHooks.result_metadata`
52+
returned, verbatim, through to `AgentLoopHooks.render_tool_result`. This is the
53+
seam for a product that words its own note about a repeated call: whether a call
54+
*counts* as a repeat is per-tool product policy (`repeat_count`), while whether
55+
the body came back byte-identical is a separate observed fact with no typed
56+
field, and both are needed to avoid asserting "identical output" for a body that
57+
differs. The pass-through is verbatim rather than filtered to unrecognised keys,
58+
so adopting a new reserved key here cannot silently remove something a product
59+
already reads.
60+
61+
### Consumer action
62+
63+
- This changes text the model reads and is therefore a compaction-decision
64+
change under `docs/versioning.md`. A consumer asserting equality against
65+
`OMITTED_TOOL_RESULT_PLACEHOLDER` must switch to `startswith`; the placeholder
66+
remains the first line precisely so that check keeps working.
67+
- No API change for the card. No configuration flag for it either: it has no
68+
failure mode of its own, and a switch would be one more configuration dimension
69+
to maintain.
70+
- A product that words a note from `repeat_count`, `repeat_recovery_id`,
71+
`result_id` or `error_kind` in its own loop copy **must port that note into
72+
`render_tool_result` before adopting `run_agent_loop`**. AgentCore reads none of
73+
those fields, so nothing fails if the note is forgotten — it just stops reaching
74+
the model. `docs/agent-loop-boundary.md` records why, and
75+
`scripts/check_unconsumed_fields.py` now fails CI on a new field in that state.
76+
77+
### Documented
78+
79+
- `docs/agent-loop-boundary.md` now records that `ToolResult.repeat_count`,
80+
`repeat_recovery_id`, `result_id` and `error_kind` have no consumer inside
81+
AgentCore, that this is by construction, and that a product moving onto
82+
`run_agent_loop` therefore loses any note it words from them *silently*. The
83+
two ways to close it are stated, with `host_metadata` as the chosen route.
84+
- `scripts/check_unconsumed_fields.py` runs in CI: a field on a watched model with
85+
no attribute read anywhere in `agent_core/` must be named in a boundary
86+
document. Deciding not to consume a field is a boundary decision, and an
87+
undocumented one is indistinguishable from an oversight — which is how four
88+
fields reached 0.4.0 with no consumer and no note.
89+
1090
## [0.5.0] - 2026-09-04
1191

1292
### Added

agent_core/loop_types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,21 @@ class ToolResult:
315315
# Host-provided repeated-invocation metadata. Execution is never skipped.
316316
repeat_count: int = 1
317317
repeat_recovery_id: str = ""
318+
# Whatever ``ToolExecutionHooks.result_metadata`` returned, verbatim.
319+
#
320+
# Passed through rather than filtered down to "the keys AgentCore did not
321+
# recognise": a residue-based rule silently changes what the host can see
322+
# whenever AgentCore adopts a new reserved key, which is exactly the class of
323+
# quiet breakage this field exists to avoid. The reserved keys are also
324+
# promoted to the typed fields above; seeing them twice costs nothing.
325+
#
326+
# The immediate reason it exists: a product wording a note about a repeated
327+
# call needs both whether the call COUNTS as a repeat (its own per-tool
328+
# policy, reported through ``repeat_count``) and whether the body came back
329+
# byte-identical (a separate observed fact with no field of its own). Without
330+
# a pass-through it has to keep a side table keyed by ``tool_call_id``. See
331+
# ``docs/agent-loop-boundary.md``.
332+
host_metadata: dict[str, Any] = field(default_factory=dict[str, Any])
318333

319334

320335
@dataclass

agent_core/runtime/loop/compact.py

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,125 @@ def tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
118118
_tool_names_by_call_id = tool_names_by_call_id
119119

120120

121+
# ---------------------------------------------------------------------------
122+
# Elided-tool-result mini card
123+
#
124+
# Tier 1 used to leave ONLY ``OMITTED_TOOL_RESULT_PLACEHOLDER``, dropping the
125+
# call's arguments and every source URL — precisely the two things a later turn
126+
# needs in order not to re-issue a query it already ran. Tier 2's summary does
127+
# preserve both, but Tier 2 only fires when Tier 1 did not free enough, so on a
128+
# Tier1-only turn the model saw strictly less than it had to.
129+
#
130+
# Both fields are free: the arguments are on the requesting assistant message,
131+
# the URLs are in the body about to be discarded. No LLM call, no extra storage,
132+
# and no second model-visible index — the card names the call, it does not offer
133+
# a way to fetch anything (that stays with the recovery footnote below it).
134+
#
135+
# The budget matters: a single web_search body can carry dozens of URLs, and an
136+
# unbounded card would hand back the context Tier 1 just freed. Fill args first
137+
# (they identify the call), then URLs until the budget runs out.
138+
# ---------------------------------------------------------------------------
139+
140+
_MINI_CARD_ARGS_MAX_CHARS = 120
141+
_MINI_CARD_BODY_MAX_CHARS = 400
142+
_MINI_CARD_MAX_URLS = 3
143+
_WHITESPACE_RE = re.compile(r"\s+")
144+
145+
146+
def _args_preview(raw: object) -> str:
147+
"""Collapse a tool call's arguments to one short single-line preview.
148+
149+
``bash`` commands and ``web_fetch`` payloads carry newlines and heredocs; a
150+
multi-line card would cost more rows than the body it replaces.
151+
"""
152+
rendered = raw if isinstance(raw, str) else str(raw or "")
153+
collapsed = _WHITESPACE_RE.sub(" ", rendered).strip()
154+
if len(collapsed) <= _MINI_CARD_ARGS_MAX_CHARS:
155+
return collapsed
156+
return collapsed[:_MINI_CARD_ARGS_MAX_CHARS] + "\u2026"
157+
158+
159+
def _tool_args_by_call_id(messages: list[Message]) -> dict[str, str]:
160+
"""Map ``tool_call_id`` → bounded preview of the arguments it was sent.
161+
162+
Kept private, unlike :func:`tool_names_by_call_id`: no product facade
163+
resolves arguments by call id, so there is no older spelling to honour.
164+
"""
165+
out: dict[str, str] = {}
166+
for msg in messages:
167+
if not is_assistant_msg(msg):
168+
continue
169+
for tc_value in cast(list[Any], msg.get("tool_calls") or []):
170+
if not isinstance(tc_value, dict):
171+
continue
172+
tc = cast(dict[str, Any], tc_value)
173+
fn_value = tc.get("function")
174+
fn = cast(dict[str, Any], fn_value) if isinstance(fn_value, dict) else None
175+
raw = (
176+
fn.get("arguments", tc.get("args", ""))
177+
if fn is not None
178+
else tc.get("arguments", tc.get("args", ""))
179+
)
180+
tid = tc.get("id") or (fn.get("id") if fn is not None else None)
181+
if not isinstance(tid, str) or not tid:
182+
continue
183+
preview = _args_preview(raw)
184+
if preview:
185+
out[tid] = preview
186+
return out
187+
188+
189+
def _elided_tool_card(tool_name: str, args_preview: str, content: str) -> str:
190+
"""Render the card lines that stand in for a discarded tool body.
191+
192+
Returns ``""`` when there is nothing worth saying (no name, no arguments, no
193+
URLs), so the caller falls back to the bare placeholder rather than emitting
194+
an empty line.
195+
"""
196+
budget = _MINI_CARD_BODY_MAX_CHARS
197+
lines: list[str] = []
198+
if tool_name or args_preview:
199+
call_line = (
200+
f"[Called: {tool_name}({args_preview})]"
201+
if args_preview
202+
else f"[Called: {tool_name}]"
203+
)
204+
lines.append(call_line)
205+
budget -= len(call_line)
206+
207+
urls: list[str] = []
208+
for url in dict.fromkeys(URL_RE.findall(content)):
209+
if len(urls) >= _MINI_CARD_MAX_URLS:
210+
break
211+
# A web_fetch card would otherwise print its own url twice.
212+
if url in args_preview:
213+
continue
214+
cost = len(url) + 3 # " | " separator
215+
if cost > budget:
216+
break
217+
urls.append(url)
218+
budget -= cost
219+
if urls:
220+
lines.append("[Source URLs] " + " | ".join(urls))
221+
return "\n".join(lines)
222+
223+
224+
def _message_recovery_ref(message: Message) -> str:
225+
"""Return a handle that already backs this body, so we never store it twice.
226+
227+
``spill_refs`` wins over ``result_store_ref``: a ref pinned by an EARLIER
228+
compaction pass describes the content that is actually still on the message,
229+
whereas the loop-cap handle describes the pre-truncation body upstream shed.
230+
Reading the latter first would re-spill a body that is already stored, and —
231+
worse — would pin the wrong handle into the recovery index.
232+
"""
233+
refs = [r for r in (message.get("spill_refs") or []) if r]
234+
canonical = str(message.get("result_store_ref") or "")
235+
if canonical and canonical not in refs:
236+
refs.append(canonical)
237+
return refs[0] if refs else ""
238+
239+
121240
def _condense(content: str, max_chars: int) -> str:
122241
"""Head + tail + URLs of *content*, never longer than the original."""
123242
prefix = f"[Compressed tool result: {len(content):,} characters]\n"
@@ -434,10 +553,17 @@ def should_compact(
434553

435554

436555
class KeepLastNToolResultsCompactor:
437-
"""Replace older ``ToolMessage`` bodies with a short placeholder.
438-
439-
Keeps the last ``keep_tool_result`` tool results verbatim and replaces
440-
the content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`.
556+
"""Replace older ``ToolMessage`` bodies with a short mini card.
557+
558+
Keeps the last ``keep_tool_result`` tool results verbatim and replaces the
559+
content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`
560+
followed by a bounded card naming the call (tool + arguments preview) and up
561+
to :data:`_MINI_CARD_MAX_URLS` source URLs found in the discarded body, then
562+
the recovery pointer when the body was spilled. The card is free — both
563+
fields already exist in the history and in the body — and it is what keeps a
564+
later turn from re-issuing a query whose result it can no longer see. When no
565+
spill is configured and the card would not be shorter than the body it
566+
replaces, the body is kept verbatim instead.
441567
``SystemMessage``, ``HumanMessage``, and every ``AIMessage`` (including
442568
its thinking trace) are left intact, so the model retains its full
443569
chain of reasoning and tool-call metadata while dropping the bulk of
@@ -489,7 +615,10 @@ def compact(
489615
if len(keep_set) == len(tool_indices):
490616
return messages
491617

618+
# Names and arguments are needed unconditionally now: the mini card names
619+
# the call it replaced even when nothing is protected and nothing spills.
492620
id_to_name = tool_names_by_call_id(messages)
621+
id_to_args = _tool_args_by_call_id(messages)
493622

494623
out: list[Message] = []
495624
for idx, msg in enumerate(messages):
@@ -505,12 +634,12 @@ def compact(
505634
):
506635
out.append(msg)
507636
continue
637+
call_id = msg.get("tool_call_id", "")
508638
placeholder = OMITTED_TOOL_RESULT_PLACEHOLDER
509-
spill_path = str(msg.get("result_store_ref") or "")
639+
spill_path = _message_recovery_ref(msg)
510640
if not spill_path and self._spill is not None:
511-
tool_name = id_to_name.get(msg.get("tool_call_id", ""), "tool")
512641
try:
513-
spill_path = self._spill(tool_name, content)
642+
spill_path = self._spill(id_to_name.get(call_id, "tool"), content)
514643
except Exception:
515644
spill_path = None
516645
# A configured spill callback is a promise that discarded content
@@ -520,9 +649,28 @@ def compact(
520649
if self._spill is not None and not spill_path:
521650
out.append(msg)
522651
continue
652+
card = _elided_tool_card(
653+
id_to_name.get(call_id, ""), id_to_args.get(call_id, ""), content,
654+
)
655+
if card:
656+
placeholder += "\n" + card
523657
if spill_path:
524658
placeholder += f"\n[Full text] {spill_path}"
525-
replacement = tool_msg(placeholder, msg.get("tool_call_id", ""))
659+
# Without a pointer, replacing the body DESTROYS it, so a card that
660+
# is not even shorter is a pure loss and we keep the body. With one
661+
# we always replace, even when the card is longer: the pointer only
662+
# reaches the model through ``spill_refs`` → the Tier 2 recovery
663+
# index, so keeping the body here would strand the spilled full text
664+
# as unrecoverable — and such a body is itself already a truncated
665+
# preview, not the full content.
666+
#
667+
# Reachable only when NO spill callback is configured: a configured
668+
# one that declined already returned the body verbatim above, at any
669+
# size. That is why this needs no minimum-size threshold of its own.
670+
if not spill_path and len(placeholder) >= len(content):
671+
out.append(msg)
672+
continue
673+
replacement = tool_msg(placeholder, call_id)
526674
if spill_path:
527675
# The text is for the model; this is for us. ``TieredCompactor``
528676
# collects refs from the field, so nothing has to recognise a

0 commit comments

Comments
 (0)