symfonic.core.tool_result_ledger¶
tool_result_ledger ¶
Tool-result compaction capability — v7.12.0.
Background¶
Anthropic's prompt cache hashes system + tools[] + messages-up-to-
breakpoint. System + tools are cached; the messages region is
NOT. Every iteration replays the full message history at $15/M
uncached input. When a tool emits a 360KB Keepa / Jungle Scout
payload, that payload rides along on every subsequent turn -- the
turn-3 cost lands again at turn-4, turn-5, ...
Jarvio's massage-gun trace surfaced this empirically: - call-02: msgs=3 / 10.6K chars / 23K input tokens - call-03: adds one 360KB ToolMessage -> msgs=5 / 374K chars / 114K input tokens - call-04..06: stays ~130K input tokens
The symfonic-core HMS substrate exists (EpisodicLayer storage, hydrate_context as the precedent framework-tool pattern, the v7.10 / v7.11 deps-registered-resolver pattern). v7.12.0 wires them into a "compact stale tool results into memory stubs; let the model fetch the full detail on demand via a framework-provided recall tool" pipeline.
Design (per architect ADR 2026-05-31):
- Trigger: len(content) > size_threshold AND the result is
older than the last keep_last_n tool results.
- Replacement: the ToolMessage.content is swapped for a
deterministic compact stub (~80 tokens). tool_call_id is
preserved verbatim so Anthropic's tool_use/tool_result pairing
invariant holds (any modification would return HTTP 400).
- Retrieval: the framework registers recall_tool_result(mem_id,
format) as a normal tool so the model authors the args (the
v7.8.3 architectural-line guard -- framework constrains choice
set; model authors arguments).
- Persistence: in-memory per-agent for v7.12.0 (intra-conversation
recall works; cross-process recall requires adopter-provided
Ledger). Adopters can register a custom ToolResultLedger on
deps to back the recall path with EpisodicLayer or any other
durable store.
Architectural-line constraints¶
- Stub determinism.
stub_for(handle)MUST be a pure function of the handle. Any non-determinism (timestamps, random ids) would bust the messages-region cache for everything older than the stub on every turn. v7.0.13 / v7.11.0 cache bug class: introspect at the wrong lifecycle phase. - No state mutation. The rewriter at
react.pyoperates on a SNAPSHOT ofstate["messages"]-- the LangGraph state and checkpoint history stay lossless. Only the wire view is compact. - Tool-call ID preservation.
ToolMessage.tool_call_idmust equal the originating AIMessage'stool_use_idexactly. Rewriting.contentis safe; rewriting.tool_call_idis an immediate API 400.
InMemoryToolResultLedger
dataclass
¶
InMemoryToolResultLedger(
_handles: dict[str, ToolResultHandle] = dict(),
_payloads: dict[str, str] = dict(),
)
Process-local ledger keyed by tool_call_id.
Suitable for intra-conversation recall (the same agent instance
serves all turns of a conversation). Cross-process recall
requires checkpoint restore + a durable backing -- adopters
register a custom :class:ToolResultLedger for that case.
Idempotent on tool_call_id per the Protocol contract: a
second record call with the same id returns the original
handle and leaves storage untouched.
ToolResultHandle
dataclass
¶
ToolResultHandle(
mem_id: str,
tool_name: str,
tool_call_id: str,
size_chars: int,
summary_oneliner: str,
schema_hint: str | None = None,
head_preview: str | None = None,
)
Compact descriptor that survives the messages cache and lets
the model recall the full payload via recall_tool_result.
The mem_id is the public address (passed back through the
tool call). All other fields feed the deterministic stub.
deterministic_stub ¶
Render the wire-replacement stub. PURE function of the handle fields -- byte-identical across calls so the messages cache prefix stays stable iteration-over-iteration.
Source code in src/symfonic/core/tool_result_ledger.py
ToolResultLedger ¶
Bases: Protocol
Per-iteration compactor for verbose ToolMessage payloads.
Lives on :class:BaseAgentDeps. Engine registers a default
in-memory adapter at construction time. Adopters can register a
custom implementation (e.g. backed by EpisodicLayer or an LLM-
summarizing pipeline) via
deps.register(ToolResultLedger, impl).
fetch
async
¶
fetch(
mem_id: str,
*,
format: Literal["summary", "full"] = "summary",
query: str | None = None,
offset: int | None = None,
max_chars: int | None = None,
json_path: str | None = None,
) -> str
Retrieve a stored tool result, whole or keyed.
v8.6.0 keyed retrieval (design §3.4), all deterministic:
* query — substring; returns ±window around matches.
* offset + max_chars — a char range.
* json_path — dotted path into a JSON sub-structure.
No keyed arg → whole payload (full) or stub (summary).
Returns "[tool_memory not found: <mem_id>]" when the id
is unknown (defensive -- the model may hallucinate a mem_id;
the framework MUST NOT raise into the tool path).
Source code in src/symfonic/core/tool_result_ledger.py
record
async
¶
record(
tool_call_id: str,
tool_name: str,
tool_args: dict[str, Any] | None,
content: str,
) -> ToolResultHandle
Persist a tool result and return the handle.
Idempotent on tool_call_id: calling twice with the same
tool_call_id MUST return the same handle (so the rewriter
can run on every iteration without churning the ledger).