Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Decision records

ADR 0142 — Agent lean tool-call log: agentLoop$({ toolCallLog: "lean" })

  • Status: Accepted (2026-11-01) — proposed 2026-10-31, implemented and merged; accepted by the owner
  • Date: 2026-10-31
  • Found by: GitHub issue #732 (step of the snapshot-size plan in #730). Owner ruling: default behaviour is unchanged; this is OPT-IN only.
  • Builds on: ADR 0121 (D3: the loop’s { content, toolCallLog, iterations } output), ADR 0140 (the claim-check, the other lever on tool-output bytes), ADR 0138 (the archive a removed frame stays resolvable from), ADR 0061 (the frame ids), ADR 0083.
  • Placement: 1P, @loom/agent. Kernel, KERNEL_ABI, EVENT_SCHEMA, every SPI unchanged.

Context

agentLoop$ returns { content, toolCallLog, iterations }, and the flow’s _output (the finished step’s value, kept in the folded state of a completed execution) carries every tool’s full result AGAIN in toolCallLog[].toolOutput, although each result is already journaled in its tool frame (<loop>#turn:<i>:tool:<j>). Measured on a completed 200-turn run: total state 1.13 MB, of which _output is 508 KB, 494 KB of it that duplicate. The log was a verbatim port of AgentRunner’s return value, not a decision.

Decisions

D1 — one opt-in option, default unchanged. AgentLoopConfig.toolCallLog?: "full" | "lean". Unset or "full" is today’s shape, byte for byte ({ iteration, toolName, toolInput, toolOutput }). "lean" replaces toolOutput with { status, bytes, digest, stepId }:

{ iteration, toolName, toolInput, status: "ok" | "error", bytes, digest, stepId }

status is the isError flag the loop fed back to the model: "error" for a tool that failed (consumed as feedback), and also for a refusal fed back the same way (an oversized input, a declined approval, an unknown tool). stepId is the tool frame id <loop>#turn:<i>:tool:<j>, so the full output stays resolvable from the frame, the log or, after compaction, the ADR 0138 archive (tested: the frame is MOVED to the archive and readArchived returns an output whose digest verifies). Two cases have no output at that frame. For status: "error" from a tool that FAILED, the frame completed with no output: the failure the model was fed is recorded in the <stepId>:outcome decision’s choice.failed field, and the entry’s digest is the digest of THAT value (tested against a flaky tool). A call settled without running (a refusal, a search_tools / skill-activation call) has no frame and no decision at that id; its status and digest describe the result the model saw, which the log holds only inside the next model call’s messages. An invalid value throws AgentLoopConfigError.

D2 — digest canonicalization. digest is the sha256 (lowercase hex, sha256Hex from @loom/core) of canonicalJson(JSON.parse(JSON.stringify(output))) (@loom/core: sorted object keys, no whitespace, arrays in order; the one canonicalization ADR 0063 uses for schema pins), and bytes is the UTF-8 length of that text. The JSON round trip is deliberate: the live output a tool returned is not what a JSON event store records (a Date is its ISO string, a class with toJSON is its JSON, a Map is {}) and a resumed run reads the parsed form back, so digesting the live value could never match the recorded frame. Digesting the round trip makes digest equal sha256(canonicalJson(recorded output)) live and after a resume (tested against a store that keeps a JSON copy, for a Date, a toJSON class and a Map, including after a resume). With the ADR 0140 claim-check on, the output is the envelope { __loomClaimCheck, ref, size, digest }, so the entry’s digest covers the ENVELOPE (the envelope’s own digest field is the blob hash; verify the blob through that). An output with no JSON form (undefined, a function or bigint, a cycle, or deeper than the canonicalizer’s cap) gets digest: null, bytes: 0; building the entry never throws.

D3 — the mode is fixed for the life of a run. An EXPLICIT toolCallLog is journaled once as a decision (<loop>#toolCallLog, source: "agent-tool-call-log") and served on replay; a resume configured with the other value throws AgentLoopConfigError (“recorded … but is configured …”) before any further append, instead of mixing shapes. Rejected: a durable step frame as the pin (the replay-divergence guard only judges frames, so it would refuse a flip in both directions without a decision, but a frame needs a registered effect and a K2 grant, which the loop cannot supply). Late pin: a run recorded with the option UNSET (no pin) and resumed with an explicit mode journals the pin at the resume, and the whole toolCallLog is rebuilt in that shape (earlier turns included; it is never a mix); tested. Limit: an UNSET option journals nothing (that is what keeps the default’s log byte-identical), so a run recorded "lean" and resumed with the option removed is not detected; it simply rebuilds toolCallLog in the full shape. Set the option explicitly ("full" journals the pin) where the mode must be guarded: users who choose "lean" must set it explicitly on every deployment of that agent.

D4 — the interrupted payload follows the mode. The agentInterrupted failure’s toolCallLog (InterruptedAgentRun) is the same array, so it is lean when the option is.

D5 — types. AgentStep is UNCHANGED (toolOutput: unknown, required). Lean entries have their own LeanAgentStep (status, bytes, digest, stepId, and toolOutput?: undefined), and AgentOutput.toolCallLog / InterruptedAgentRun.toolCallLog are AgentStep[] | LeanAgentStep[]. Code that READS entry.toolOutput (or .maps the log) still compiles, since the lean member declares it as undefined; code that passes the whole toolCallLog to a parameter typed AgentStep[] needs a narrowing (it cannot be proven full at compile time: the mode is runtime config) - a type-level change for that one shape, noted in the CHANGELOG. isLeanAgentStep(step) narrows an entry. AgentRunner and agentStep$ (deprecated, ADR 0121) are not given the option.

D6 — readers. @loom/conversation reads tool names only (works in both modes, tested). The evals agentLoop subject reads status on a lean entry and the output heuristic on a full one; a DECLINED approval is "error" in lean mode only (the heuristic does not count a refusal), documented in its test. A reader that needs the output itself (a diagnostics runner that parses a tool’s JSON) must resolve stepId, or stay on "full".

Consequences

Measured (leanToolCallLog.perf.test.ts, completed 200-turn run, 1.5-4 kB tool outputs): folded state 1201.5 KB full -> 683.4 KB lean (-43%), _output 554.7 KB -> 36.6 KB. With the ADR 0140 claim-check on, _output is already 101.8 KB and lean takes the state 295.6 KB -> 230.3 KB. The log (the journaled frames) is unchanged: this shrinks what a resume reads and the final step value, not the audit record.

Source: docs/adr/0142-agent-lean-tool-call-log.md in the loom repository.