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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,49 @@ the GitHub Release body, so a release with no entry here fails.

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

## [0.8.2] - 2026-09-06

### Changed

- Tier 1 mini card narrowed on measurement, in two places.
`_MINI_CARD_MAX_URLS` drops 3 -> 1, and a result carrying no source anywhere
gets `[Called: <tool>]` alone instead of a 120-char argument preview.

**Consumer impact:** a host that needs several independent sources per claim
should now raise `_MINI_CARD_MAX_URLS` deliberately rather than inherit 3.
Nothing else changes: cards still carry the call, still carry a source when one
exists, and the placeholder/footer contract is untouched.

Rationale, measured over 12 real long-running research trials in ApodexHarness
(1295 carded results, ~13.5k source URLs in the bodies being discarded):

- Keeping 3 URLs retained 16.5% of all URLs, but the only quantity anything
downstream consumes is whether a retrieval left *one* traceable source
behind, and the first URL alone covers 769/775 (99.2%) of cards that had any
URL. Dropping to 1 took total retention to 8.9% and left that 99.2%
unchanged - the extra two URLs were spending ~120 chars each on a percentage
with no reader.
- 306 of 1295 cards (24%) had no source at all - shell commands, task-board
updates, file writes. The card exists so a later turn does not redo work
whose provenance it can still see; without a source that premise does not
hold, and repeating such a call is usually legitimate because the state it
reads has changed. Those arguments are not decision information.

A source can live in the arguments rather than the body (`web_fetch`'s argument
IS the url), so the sourceless test is "no URL in the body **and** none in the
arguments" - reading only the body would strip `web_fetch` of its one source.

Cost on that sample: cards add ~55.9k tokens across the 12 trials, 27.2% of the
post-compaction context under an aggressive `keep_last_k=5`. Under the
threshold-triggered `tiered` path a product actually ships, post-compaction
context is 150-200k, putting the same cards at 2-3%.

Those figures are measured *after* the argument-URL fix below. Detecting the
source from the bounded preview instead of the full arguments had mislabelled
roughly 130 cards (~10%) as sourceless, so the pre-fix numbers merely looked
cheaper (33% name-only, 25.0% cost) by discarding provenance those calls
really had.

## [0.8.1] - 2026-09-05

### Fixed
Expand Down
75 changes: 63 additions & 12 deletions agent_core/runtime/loop/compact.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,16 @@ def tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:

_MINI_CARD_ARGS_MAX_CHARS = 120
_MINI_CARD_BODY_MAX_CHARS = 400
_MINI_CARD_MAX_URLS = 3
# ONE url, not three. Measured over 12 real long-running trials of a research
# agent (1295 carded results, 13.5k source URLs in the bodies being discarded):
# keeping 3 URLs retained 16.5% of all URLs, but the quantity anything downstream
# consumes is whether a retrieval left behind *one* traceable source — and the
# first URL alone covers 769/775 (99.2%) of the carded results that had any URL.
# Dropping to 1 took total retention to 8.9% and left that 99.2% unchanged, i.e.
# the extra two URLs per card were spending ~120 chars each on a percentage with
# no reader. A host that needs several independent sources per claim should raise
# this deliberately rather than inherit it.
_MINI_CARD_MAX_URLS = 1
_WHITESPACE_RE = re.compile(r"\s+")


Expand All @@ -179,13 +188,20 @@ def _args_preview(raw: object) -> str:
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.
def _tool_args_by_call_id(
messages: list[Message],
) -> tuple[dict[str, str], dict[str, str]]:
"""Map ``tool_call_id`` to its bounded preview and first source URL.

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.

Source detection reads the complete rendered arguments before the preview is
truncated. Otherwise a URL after character 120 would make a sourced call look
sourceless and lose both its arguments and its only traceable source.
"""
out: dict[str, str] = {}
previews: dict[str, str] = {}
source_urls: dict[str, str] = {}
for msg in messages:
if not is_assistant_msg(msg):
continue
Expand All @@ -205,17 +221,41 @@ def _tool_args_by_call_id(messages: list[Message]) -> dict[str, str]:
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:
previews[tid] = preview
rendered = raw if isinstance(raw, str) else str(raw or "")
source_match = URL_RE.search(rendered)
if source_match is not None:
source_urls[tid] = source_match.group(0)
return previews, source_urls


def _elided_tool_card(
tool_name: str,
args_preview: str,
args_source_url: 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.

A result carrying no source at all gets the tool name only. The card exists so
a later turn does not redo work whose provenance it can still see, and that
premise needs a source: for a body with no URL — a shell command, a task-board
update, a file write — repeating the call is usually legitimate, because the
state it reads has changed. Such arguments are not decision information and do
not earn a 120-char preview. Measured over the same 12 trials, 306 of 1295
carded results (24%) had no source and were charging roughly a quarter of the
feature's cost for none of its benefit.
"""
# A source can live in the arguments rather than the body: web_fetch's argument
# IS the url. Inspect the URL extracted from the full arguments, not their
# bounded preview, because truncation can hide the only source.
if not URL_RE.search(content) and not args_source_url:
return f"[Called: {tool_name}]" if tool_name else ""

lines: list[str] = []
if tool_name or args_preview:
call_line = (
Expand All @@ -241,6 +281,13 @@ def _elided_tool_card(tool_name: str, args_preview: str, content: str) -> str:
urls = candidate_urls
if urls:
lines.append("[Source URLs] " + " | ".join(urls))
elif args_source_url and args_source_url not in args_preview:
# The argument preview can truncate before or inside its URL. If the body
# has no source to retain instead, carry the complete argument URL on its
# own line so the card still contains one traceable source.
candidate_lines = [*lines, "[Source URLs] " + args_source_url]
if len("\n".join(candidate_lines)) <= _MINI_CARD_BODY_MAX_CHARS:
lines = candidate_lines
return "\n".join(lines)


Expand Down Expand Up @@ -581,7 +628,8 @@ class KeepLastNToolResultsCompactor:
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
to :data:`_MINI_CARD_MAX_URLS` source URLs found in the discarded body (a
result with no source anywhere gets the tool name alone), 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
Expand Down Expand Up @@ -648,7 +696,7 @@ def compact(
# 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)
id_to_args, id_to_arg_url = _tool_args_by_call_id(messages)

out: list[Message] = []
for idx, msg in enumerate(messages):
Expand Down Expand Up @@ -680,7 +728,10 @@ def compact(
out.append(msg)
continue
card = _elided_tool_card(
id_to_name.get(call_id, ""), id_to_args.get(call_id, ""), content,
id_to_name.get(call_id, ""),
id_to_args.get(call_id, ""),
id_to_arg_url.get(call_id, ""),
content,
)
if card:
placeholder += "\n" + card
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "apodex-agent-core"
version = "0.8.1"
version = "0.8.2"
description = "Shared, product-neutral runtime primitives for Apodex agents"
readme = "README.md"
license = "Apache-2.0"
Expand Down
84 changes: 75 additions & 9 deletions tests/test_keep_last_n_compactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def _card_of(content: str) -> str:


def test_card_names_the_call_and_its_arguments():
body = "RESULT " + "x" * 2_000
body = "RESULT https://example.com/nvda " + "x" * 2_000
args = '{"query": "NVIDIA H100 market share 2025"}'
content = _blanked(_one_call("web_search", args, body))
assert "[Called: web_search(" in content
Expand All @@ -66,8 +66,10 @@ def test_card_carries_source_urls_from_the_discarded_body():
body = "see https://nvidianews.nvidia.com/q3 and https://tomshardware.com/h100 " + "x" * 2_000
content = _blanked(_one_call("web_search", '{"query": "h100"}', body))
assert "[Source URLs]" in content
# One traceable source per retrieval is the whole requirement, so the first
# URL is kept and extras are not bought (see _MINI_CARD_MAX_URLS).
assert "https://nvidianews.nvidia.com/q3" in content
assert "https://tomshardware.com/h100" in content
assert content.count("https://") == 1


def test_url_already_in_the_arguments_is_not_repeated():
Expand Down Expand Up @@ -96,21 +98,29 @@ def test_exact_rendered_card_stays_within_budget():


def test_overlong_arguments_are_truncated():
args = '{"command": "' + "a" * 500 + '"}'
content = _blanked(_one_call("bash", args, "OUT " + "x" * 2_000))
args = '{"query": "' + "a" * 500 + '"}'
content = _blanked(
_one_call("web_search", args, "OUT https://example.com/x " + "x" * 2_000)
)
call_line = _card_of(content).splitlines()[0]
assert "…" in call_line
assert len(call_line) < _MINI_CARD_ARGS_MAX_CHARS + 60
assert len(_args_preview(args)) == _MINI_CARD_ARGS_MAX_CHARS


def test_multiline_arguments_are_flattened_to_one_line():
args = '{"command": "cat <<EOF\\nline one\\nline two\\nEOF"}'
content = _blanked(_one_call("bash", args, "OUT " + "x" * 2_000))
args = '{"query": "cat <<EOF\\nline one\\nline two\\nEOF"}'
content = _blanked(
_one_call("web_search", args, "OUT https://example.com/x " + "x" * 2_000)
)
card = _card_of(content)
assert card.splitlines()[0].startswith("[Called: bash(")
# No URLs in this body, so the whole card must be the single call line.
assert len(card.splitlines()) == 1
lines = card.splitlines()
# Flattening means the whole argument preview fits on the call line: the
# newlines inside it must not become extra card rows.
assert lines[0].startswith("[Called: web_search(")
assert lines[0].endswith(")]")
assert sum(1 for line in lines if line.startswith("[Called:")) == 1
assert "\n" not in _args_preview(args)


# --- when the card is skipped ---------------------------------------------
Expand Down Expand Up @@ -301,3 +311,59 @@ def test_a_host_footer_survives_a_second_pass_unnested():
second = next(m["content"] for m in twice if m.get("role") == "tool")
assert first == second
assert second.count("Recovery id:") == 1


# ── a result with no source anywhere gets the tool name alone ────────────────


def test_sourceless_result_gets_tool_name_only():
"""The card exists so a later turn does not redo work whose provenance it can
still see, and that premise needs a source. A shell command's output carries
none, and repeating it is usually legitimate because the state it reads has
changed — so its arguments are not decision information worth 120 chars."""
args = '{"command": "ls -la /workspace/some/deep/path"}'
content = _blanked(_one_call("bash", args, "total 48 drwxr-xr-x " + "x" * 2_000))
assert _card_of(content) == "[Called: bash]"
assert "ls -la" not in content
assert "[Source URLs]" not in content


def test_source_in_the_arguments_still_earns_a_full_card():
"""A source can live in the arguments rather than the body: web_fetch's
argument IS the url. "No URL in the body" must not be read as "no source"."""
url = "https://example.com/the-page-that-matters"
body = "page text with no links whatsoever " + "x" * 2_000
content = _blanked(_one_call("web_fetch", f'{{"url": "{url}"}}', body))
assert url in content
assert content.count(url) == 1


def test_source_after_the_argument_preview_limit_is_still_retained():
"""Source detection must inspect raw arguments, not the truncated preview."""
url = "https://example.com/source-after-long-metadata"
args = '{"metadata": "' + "x" * 140 + f'", "url": "{url}"}}'
body = "page text with no links whatsoever " + "x" * 2_000
content = _blanked(_one_call("web_fetch", args, body))
assert "[Called: web_fetch(" in content
assert f"[Source URLs] {url}" in content
assert content.count(url) == 1


def test_sourceless_card_costs_far_less_than_a_sourced_one():
"""The cost reduction is the point of the narrowing, so assert it directly."""
long_args = '{"q": "' + "a" * 300 + '"}'
sourceless = _card_of(
_blanked(_one_call("bash", long_args, "OUT " + "x" * 2_000))
)
sourced = _card_of(
_blanked(
_one_call("web_search", long_args, "OUT https://example.com/a " + "x" * 2_000)
)
)
assert len(sourceless) < len(sourced) / 2


def test_at_most_one_url_even_in_a_url_heavy_body():
body = " ".join(f"https://example.com/r{i}" for i in range(50)) + "x" * 2_000
content = _blanked(_one_call("web_search", '{"query": "q"}', body))
assert content.count("https://example.com/r") == _MINI_CARD_MAX_URLS
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.