Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ jobs:
- run: uv sync --frozen --extra dev
- run: uv run ruff check agent_core tests scripts
- run: uv run pyright agent_core
# A host-supplied field that agent_core/ never reads is invisible to every
# other check here and to a canary merge: it types, it tests, and the note
# a product worded from it vanishes on adoption. See the script's docstring.
- run: python3 scripts/check_unconsumed_fields.py
- run: uv run pytest -q
- run: uv build

Expand Down
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,94 @@ the GitHub Release body, so a release with no entry here fails.

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

## [0.7.0] - 2026-09-04

### Changed

- Tier 1 deterministic compaction (`KeepLastNToolResultsCompactor`) no longer
leaves a bare placeholder where it drops a tool body. It now appends a bounded
card naming the call (tool name + a single-line, 120-char arguments preview)
and up to three source URLs found in the discarded body, above the existing
recovery pointer. Both fields already existed in the history and in the body,
so this adds no LLM call, no storage, and no second model-visible recovery
route. Measured cost is ~+220 characters per elided result, hard-capped at 400.

Rationale: Tier 2's summary preserves arguments and URLs, but Tier 2 only fires
when Tier 1 did not free enough, so on a Tier1-only turn the model lost exactly
the two things it needs in order not to re-issue a query it already ran.

A card that would not be shorter than the body it replaces is skipped and the
body kept verbatim — but only when no spill callback is configured. With a
recovery pointer the body is always replaced even if the card is longer: the
pointer reaches the model only through `spill_refs` → the Tier 2 recovery
index, and such a body is itself already an upstream-truncated preview, so
keeping it would strand the spilled full text as unrecoverable.

- `KeepLastNToolResultsCompactor` now resolves an existing recovery handle with
`spill_refs` taking precedence over `result_store_ref`. A ref pinned by an
earlier compaction pass describes the content still on the message, while the
loop-cap handle describes the pre-truncation body upstream shed; reading the
latter first re-spilled a body that was already stored and pinned the wrong
handle into the recovery index.

### Added

- `TieredCompactor` takes `manifest_max_paths` and `manifest_max_chars`, either
of which may be `None` to remove that bound. The defaults are unchanged (20 /
3,000) and are sized for a handle rendered as a filesystem path. A product
whose handles are short content-addressed ids pays a fraction of that per entry
and should raise or remove the cap: when a cap binds, the OLDEST handles are
dropped, and a product measured decisive early evidence becoming unrecoverable
after a long unrelated detour for exactly that reason. The cap is charged
against rendered characters, which is the only quantity the two handle shapes
share — cap and handle shape are therefore not independent choices. The
character cap covers the complete rendered index, including its header and
list syntax. Non-`None` bounds must be large enough to retain at least one
entry; invalid zero or header-only bounds fail at construction time.
- `ToolResult.host_metadata` carries whatever `ToolExecutionHooks.result_metadata`
returned, verbatim, through to `AgentLoopHooks.render_tool_result`. This is the
seam for a product that words its own note about a repeated call: whether a call
*counts* as a repeat is per-tool product policy (`repeat_count`), while whether
the body came back byte-identical is a separate observed fact with no typed
field, and both are needed to avoid asserting "identical output" for a body that
differs. The pass-through is verbatim rather than filtered to unrecognised keys,
so adopting a new reserved key here cannot silently remove something a product
already reads.

### Consumer action

- This changes text the model reads and is therefore a compaction-decision
change under `docs/versioning.md`. A consumer asserting equality against
`OMITTED_TOOL_RESULT_PLACEHOLDER` must switch to `startswith`; the placeholder
remains the first line precisely so that check keeps working.
- No API change for the card. No configuration flag for it either: it has no
failure mode of its own, and a switch would be one more configuration dimension
to maintain.
- A product that words a note from `repeat_count`, `repeat_recovery_id`,
`result_id` or `error_kind` in its own loop copy **must port that note into
`render_tool_result` before adopting `run_agent_loop`**. AgentCore reads none of
those fields, so nothing fails if the note is forgotten — it just stops reaching
the model. `docs/agent-loop-boundary.md` records why, and
`scripts/check_unconsumed_fields.py` now fails CI on a new field in that state.

### Documented

- `docs/agent-loop-boundary.md` now records that `ToolResult.repeat_count`,
`repeat_recovery_id`, `result_id` and `error_kind` have no consumer inside
AgentCore, that this is by construction, and that a product moving onto
`run_agent_loop` therefore loses any note it words from them *silently*. The
two ways to close it are stated, with `host_metadata` as the chosen route.
- `scripts/check_unconsumed_fields.py` runs in CI: a field on a watched model with
no attribute read anywhere in `agent_core/` must be named in a boundary
document. Deciding not to consume a field is a boundary decision, and an
undocumented one is indistinguishable from an oversight — which is how four
fields reached 0.4.0 with no consumer and no note.

## [0.6.0] - 2026-09-04

**Never published.** Merged to `main` but never tagged; its contents ship in
0.7.0. Nothing pins it.

### Added

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


@dataclass
Expand Down
164 changes: 156 additions & 8 deletions agent_core/runtime/loop/compact.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,125 @@ def tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
_tool_names_by_call_id = tool_names_by_call_id


# ---------------------------------------------------------------------------
# Elided-tool-result mini card
#
# Tier 1 used to leave ONLY ``OMITTED_TOOL_RESULT_PLACEHOLDER``, dropping the
# call's arguments and every source URL — precisely the two things a later turn
# needs in order not to re-issue a query it already ran. Tier 2's summary does
# preserve both, but Tier 2 only fires when Tier 1 did not free enough, so on a
# Tier1-only turn the model saw strictly less than it had to.
#
# Both fields are free: the arguments are on the requesting assistant message,
# the URLs are in the body about to be discarded. No LLM call, no extra storage,
# and no second model-visible index — the card names the call, it does not offer
# a way to fetch anything (that stays with the recovery footnote below it).
#
# The budget matters: a single web_search body can carry dozens of URLs, and an
# unbounded card would hand back the context Tier 1 just freed. Fill args first
# (they identify the call), then URLs until the budget runs out.
# ---------------------------------------------------------------------------

_MINI_CARD_ARGS_MAX_CHARS = 120
_MINI_CARD_BODY_MAX_CHARS = 400
_MINI_CARD_MAX_URLS = 3
_WHITESPACE_RE = re.compile(r"\s+")


def _args_preview(raw: object) -> str:
"""Collapse a tool call's arguments to one short single-line preview.

``bash`` commands and ``web_fetch`` payloads carry newlines and heredocs; a
multi-line card would cost more rows than the body it replaces.
"""
rendered = raw if isinstance(raw, str) else str(raw or "")
collapsed = _WHITESPACE_RE.sub(" ", rendered).strip()
if len(collapsed) <= _MINI_CARD_ARGS_MAX_CHARS:
return collapsed
return collapsed[: _MINI_CARD_ARGS_MAX_CHARS - 1] + "\u2026"


def _tool_args_by_call_id(messages: list[Message]) -> dict[str, str]:
"""Map ``tool_call_id`` → bounded preview of the arguments it was sent.

Kept private, unlike :func:`tool_names_by_call_id`: no product facade
resolves arguments by call id, so there is no older spelling to honour.
"""
out: dict[str, str] = {}
for msg in messages:
if not is_assistant_msg(msg):
continue
for tc_value in cast(list[Any], msg.get("tool_calls") or []):
if not isinstance(tc_value, dict):
continue
tc = cast(dict[str, Any], tc_value)
fn_value = tc.get("function")
fn = cast(dict[str, Any], fn_value) if isinstance(fn_value, dict) else None
raw = (
fn.get("arguments", tc.get("args", ""))
if fn is not None
else tc.get("arguments", tc.get("args", ""))
)
tid = tc.get("id") or (fn.get("id") if fn is not None else None)
if not isinstance(tid, str) or not tid:
continue
preview = _args_preview(raw)
if preview:
out[tid] = preview
return out


def _elided_tool_card(tool_name: str, args_preview: str, content: str) -> str:
"""Render the card lines that stand in for a discarded tool body.

Returns ``""`` when there is nothing worth saying (no name, no arguments, no
URLs), so the caller falls back to the bare placeholder rather than emitting
an empty line.
"""
lines: list[str] = []
if tool_name or args_preview:
call_line = (
f"[Called: {tool_name}({args_preview})]"
if args_preview
else f"[Called: {tool_name}]"
)
if len(call_line) > _MINI_CARD_BODY_MAX_CHARS:
call_line = call_line[: _MINI_CARD_BODY_MAX_CHARS - 1] + "\u2026"
lines.append(call_line)

urls: list[str] = []
for url in dict.fromkeys(URL_RE.findall(content)):
if len(urls) >= _MINI_CARD_MAX_URLS:
break
# A web_fetch card would otherwise print its own url twice.
if url in args_preview:
continue
candidate_urls = [*urls, url]
candidate_lines = [*lines, "[Source URLs] " + " | ".join(candidate_urls)]
if len("\n".join(candidate_lines)) > _MINI_CARD_BODY_MAX_CHARS:
break
urls = candidate_urls
if urls:
lines.append("[Source URLs] " + " | ".join(urls))
return "\n".join(lines)


def _message_recovery_ref(message: Message) -> str:
"""Return a handle that already backs this body, so we never store it twice.

``spill_refs`` wins over ``result_store_ref``: a ref pinned by an EARLIER
compaction pass describes the content that is actually still on the message,
whereas the loop-cap handle describes the pre-truncation body upstream shed.
Reading the latter first would re-spill a body that is already stored, and —
worse — would pin the wrong handle into the recovery index.
"""
refs = [r for r in (message.get("spill_refs") or []) if r]
canonical = str(message.get("result_store_ref") or "")
if canonical and canonical not in refs:
refs.append(canonical)
return refs[0] if refs else ""


def _condense(content: str, max_chars: int) -> str:
"""Head + tail + URLs of *content*, never longer than the original."""
prefix = f"[Compressed tool result: {len(content):,} characters]\n"
Expand Down Expand Up @@ -434,10 +553,17 @@ def should_compact(


class KeepLastNToolResultsCompactor:
"""Replace older ``ToolMessage`` bodies with a short placeholder.

Keeps the last ``keep_tool_result`` tool results verbatim and replaces
the content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`.
"""Replace older ``ToolMessage`` bodies with a short mini card.

Keeps the last ``keep_tool_result`` tool results verbatim and replaces the
content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`
followed by a bounded card naming the call (tool + arguments preview) and up
to :data:`_MINI_CARD_MAX_URLS` source URLs found in the discarded body, then
the recovery pointer when the body was spilled. The card is free — both
fields already exist in the history and in the body — and it is what keeps a
later turn from re-issuing a query whose result it can no longer see. When no
spill is configured and the card would not be shorter than the body it
replaces, the body is kept verbatim instead.
``SystemMessage``, ``HumanMessage``, and every ``AIMessage`` (including
its thinking trace) are left intact, so the model retains its full
chain of reasoning and tool-call metadata while dropping the bulk of
Expand Down Expand Up @@ -489,7 +615,10 @@ def compact(
if len(keep_set) == len(tool_indices):
return messages

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

out: list[Message] = []
for idx, msg in enumerate(messages):
Expand All @@ -505,12 +634,12 @@ def compact(
):
out.append(msg)
continue
call_id = msg.get("tool_call_id", "")
placeholder = OMITTED_TOOL_RESULT_PLACEHOLDER
spill_path = str(msg.get("result_store_ref") or "")
spill_path = _message_recovery_ref(msg)
if not spill_path and self._spill is not None:
tool_name = id_to_name.get(msg.get("tool_call_id", ""), "tool")
try:
spill_path = self._spill(tool_name, content)
spill_path = self._spill(id_to_name.get(call_id, "tool"), content)
except Exception:
spill_path = None
# A configured spill callback is a promise that discarded content
Expand All @@ -520,9 +649,28 @@ def compact(
if self._spill is not None and not spill_path:
out.append(msg)
continue
card = _elided_tool_card(
id_to_name.get(call_id, ""), id_to_args.get(call_id, ""), content,
)
if card:
placeholder += "\n" + card
if spill_path:
placeholder += f"\n[Full text] {spill_path}"
replacement = tool_msg(placeholder, msg.get("tool_call_id", ""))
# Without a pointer, replacing the body DESTROYS it, so a card that
# is not even shorter is a pure loss and we keep the body. With one
# we always replace, even when the card is longer: the pointer only
# reaches the model through ``spill_refs`` → the Tier 2 recovery
# index, so keeping the body here would strand the spilled full text
# as unrecoverable — and such a body is itself already a truncated
# preview, not the full content.
#
# Reachable only when NO spill callback is configured: a configured
# one that declined already returned the body verbatim above, at any
# size. That is why this needs no minimum-size threshold of its own.
if not spill_path and len(placeholder) >= len(content):
out.append(msg)
continue
replacement = tool_msg(placeholder, call_id)
if spill_path:
# The text is for the model; this is for us. ``TieredCompactor``
# collects refs from the field, so nothing has to recognise a
Expand Down
Loading