Skip to content

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.py operates on a SNAPSHOT of state["messages"] -- the LangGraph state and checkpoint history stay lossless. Only the wire view is compact.
  • Tool-call ID preservation. ToolMessage.tool_call_id must equal the originating AIMessage's tool_use_id exactly. Rewriting .content is safe; rewriting .tool_call_id is 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

deterministic_stub() -> str

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
def deterministic_stub(self) -> str:
    """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.
    """
    lines = [
        f"[symfonic.tool_memory {STUB_FORMAT_VERSION}]",
        f"tool={self.tool_name} call_id={self.tool_call_id}",
        f"size_original={self.size_chars} chars, "
        f"archived as mem={self.mem_id}",
    ]
    if self.schema_hint:
        lines.append(f"schema: {self.schema_hint}")
    lines.append(f"summary: {self.summary_oneliner}")
    if self.head_preview:
        lines.append(f"preview: {self.head_preview}")
    lines.append(
        f"To re-expand: call recall_tool_result(mem_id="
        f"{self.mem_id!r}, format=\"full\").  For a slice, pass "
        f"query=<substring>, offset/max_chars=<range>, or "
        f"json_path=<path> instead of format=\"full\"."
    )
    return "\n".join(lines)

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
async def fetch(
    self,
    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).
    """
    ...

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).

Source code in src/symfonic/core/tool_result_ledger.py
async def record(
    self,
    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).
    """
    ...