diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e59953..53c2e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ]` 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 diff --git a/agent_core/runtime/loop/compact.py b/agent_core/runtime/loop/compact.py index 4f1c6cb..fd570a9 100644 --- a/agent_core/runtime/loop/compact.py +++ b/agent_core/runtime/loop/compact.py @@ -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+") @@ -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 @@ -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 = ( @@ -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) @@ -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 @@ -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): @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0371fde..07a3639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_keep_last_n_compactor.py b/tests/test_keep_last_n_compactor.py index d31973c..e4c85a1 100644 --- a/tests/test_keep_last_n_compactor.py +++ b/tests/test_keep_last_n_compactor.py @@ -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 @@ -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(): @@ -96,8 +98,10 @@ 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 @@ -105,12 +109,18 @@ def test_overlong_arguments_are_truncated(): def test_multiline_arguments_are_flattened_to_one_line(): - args = '{"command": "cat <