0.8.0

AgJSON draft.5: closed turns stay closed, and input tokens include the cache

A minor release that ships spec revision 1.0.0-draft.5 (AGJSON_VERSION). It’s a minor because the fold and the meaning of a field change. A turn’s closure now follows its turn record, so a messages.snapshot no longer reopens a closed turn, and a second terminal for a closed turn stops the fold and asks for a resync. A record event for a turn whose thread the reducer doesn’t know yet is held, instead of landing on a placeholder record. A Claude inputTokens now counts cache reads and writes. The release also adds toPersistable() for hosts that store folds, a threadId option on the OpenAI Agents SDK, Google ADK and Vercel AI SDK packages, MCP resource links as first-class blocks, and turns per generation for Google ADK Live sessions. Read the upgrading notes first.

Upgrading from 0.7.x

  • Claude inputTokens is cache-inclusive. In draft.5, inputTokens counts every input token the provider processed, cache reads and writes included, with cacheReadTokens and cacheWriteTokens as its breakdown, never an addition. Anthropic reports input without the cache, so a Claude inputTokens (on the turn, on each message and in each byModel entry) now includes its cache reads and writes, and rises on cached calls. One with no cache counters is unchanged. In the echo capture, the turn’s inputTokens goes from 4 to 4,056: 4 uncached tokens, plus 1,966 cache reads and 2,086 cache writes. A Claude turn terminal’s counters cover that turn, so its usage now reads cumulative: false; 0.7.x marked it true. Its costUsd is the SDK’s running estimate over the whole query() call, so it carries the new costScope: "query". Without partial messages, an assistant message’s outputTokens is a placeholder, so message.end.usage now omits it. Usage from the OpenAI Agents SDK, the Vercel AI SDK and Google ADK’s generateContent route is unchanged; Gemini Live changes (see Google ADK).
    • Repairing stored Claude rows. A stored Claude turn record is from before 0.8.0 exactly when its top-level usage carries a costUsd key (a 0 included) and no costScope; a record without usage has nothing to repair. To repair one, add cacheReadTokens + cacheWriteTokens to its inputTokens and, for each byModel entry, that entry’s own cacheReadTokens + cacheWriteTokens to the entry’s inputTokens. Read its top-level cumulative as false and its costUsd as carrying costScope: "query", and leave each entry’s cumulative as stored. The record’s token counters and cost then read as a 0.8.0 record’s. A host that rewrites the row stores the repaired counters and costScope: "query" together, which ends the marker, so the row is never repaired twice. A stored Claude message record has no exact marker: inputTokens < cacheReadTokens + cacheWriteTokens identifies some older rows, not all.
  • A snapshot no longer reopens a closed turn. A turn is closed while the fold holds a turn record for it that carries an outcome. A messages.snapshot changes closure only through the turn records it leaves: one that carries a turn with an outcome keeps that turn closed, and one that omits turns keeps every closure. After such a snapshot, a message.start, a tool.done adopting a message, or a terminal for that turn parks the reducer, where 0.7.x folded it. To reopen a turn, carry its record without an outcome (a snapshot with turns: [] drops every record, and so every closure). No framework package’s output changes.
    • This reverses part of what draft.4 and 0.7.0 said. The draft.4 spec note and AgJSON#1 (section 4) said a turn stays closed “until a messages.snapshot”, and the 0.7.0 note advised: when needsResync is set, resume from a snapshot. A snapshot still clears a message’s seal, since it replaces the messages. It no longer clears a turn’s closure. Resuming from a snapshot of the fold still recovers from a gap or a re-delivery. It doesn’t license new content for a turn the snapshot records as closed: a finished invoke re-sent from seq 0 after a snapshot of its own fold parks, and the fold stays the snapshot’s.
  • A second terminal for a closed turn parks. A turn.done, turn.error or turn.abort for a closed turn now sets needsResync and leaves the fold unchanged, whatever its outcome and whether or not it repeats the recorded one. In 0.7.x each terminal overwrote the record, so an error followed by a success read as a success. The one exception is a host’s paused refresh: a re-sent turn.start, then a turn.done with a paused outcome and refreshed asks, for a turn closed as paused. It replaces the outcome and asks, keeps the recorded usage, and the turn stays closed. The refresh still works when a messages.snapshot falls between the turn.start and the turn.done. The one-terminal rule binds a host’s own appended events as well as a normalizer’s. Every committed golden folds as before.
  • Record events for a turn whose thread isn’t known are held. A source, handoff, prompt.blocked, guardrail.result, agent.capabilities or display.required event lands on a record carrying the turn’s thread. The thread comes from a turn.start, subagent.start, message.start or messages.snapshot of that turn. Until the reducer knows it, the landing is held and result() omits the record.
    • Before (0.7.x): a record event for a turn not yet opened created a stub record whose threadId was the turnId, or an unknown-turn record.
    • After (0.8.0): no turn record carries a threadId that no event carried. A later opener sets a held record’s thread, and a subagent.start also sets its parentTurnId.
    • In the fold: a terminal or record event with no turnId resolves to the sole open turn, never a closed one. With no open turn, or several, its owner is unresolvable and the reducer parks. The reference reducer holds up to 64 turns and 1,024 landings; past either, it parks.
    • Reducer.ensureTurn(), which minted those stubs, is removed.
  • Ids change: Google ADK. Turn and message ids now carry a per-invoke stem, random by default, or the invokeId option: turn_<invocationId> becomes turn_adk_<16 hex>_<invocationId>, and message ids follow. invokeId: "adk" doesn’t reproduce 0.7.x’s ids. The host-error sentinel’s invocationId no longer names the turn. This keeps ids unique when an invoke reuses an invocationId, as a resumed ADK-Python run does; in 0.7.x two such invokes folded into one Reducer parked it.
  • Claude host-only fields moved. These are breaking if you read them:
    • The CLI wrapper keys context_usage, usage_report, user_message_uuid, user_message_uuids, resume_reason, aborted and supersedes move from providerMetadata to host-only _meta, on the first block’s start event, or on a message.metadata event when no block anchors them. On a frame whose first block is a tool call, they now always arrive on message.metadata. This retracts the providerMetadata channel described for aborted in 0.3.7, for context_usage in 0.4.4, and for the user_message_uuid family, resume_reason and usage_report in 0.6.1–0.6.3.
    • A tool result’s resourceLinks moves from tool.done.providerMetadata (0.5.4) to tool.done._meta["anthropic/resourceLinks"], the SDK’s list, verbatim. The result’s content stays the rendered text.
    • A permission denial’s context (decisionReasonType, decisionReasonCode, decisionReason, agentId) now rides one record, tool.done._meta["anthropic/permissionDenied"], on the denial and on the closing tool result alike. That includes decisionReasonCode, added in 0.6.3.
    • A notice’s level, preventContinuation and toolUseId ride the notice text block’s _meta.
    • Host records carry no durability promise (SPEC §2.1). A host may drop _meta when it persists a conversation, so a record read from the live stream can be absent after a reload.
  • Pass threadId if you persist. A host that persists, routes or folds across invokes by threadId must give the normalizer the thread it assigned the invoke to (SPEC §8.0 host obligation 6). Without the option, each package stamps a facet-local placeholder that is not a thread identity: "openai", "google" or "vercel", or the Claude session id. Output without the option is unchanged.

Spec: AgJSON 1.0.0-draft.5

  • Turn closure follows the fold’s turn records across a messages.snapshot (§5.0 INV-MSG, §5, §10 item 27).
  • A second terminal for a closed turn is a reduce() error and a snapshot resync, except the paused refresh (§5.0 INV-MSG and INV-TURN, §8.0 host obligation 5, §10 item 27).
  • Record events on unopened turns land on the turn’s known thread, never a placeholder, and a normalizer opens a turn before any event of it, record events included (§5.0 INV-OWNER and INV-TURN, §10 items 48 and 49). Erratum to draft.4: its note said a terminal for a turn seen only in a snapshot’s messages folds onto a record carrying that message’s threadId; the 0.7.x reducer did so only when no record event had reached that turn first.
  • The input side of AgUsage (§4; §8.0 items 4, 19, 24 and 29; §10 items 8, 21 and 50): inputTokens is cache-inclusive, the revision draft.3 deferred; cumulative binds the object it sits on; the optional costScope names a cost whose scope differs from its object’s counters. Over the Gemini Live API, thoughtsTokenCount always folds into outputTokens, totalTokens is inputTokens + outputTokens + (toolUseInputTokens ?? 0), and a differing provider total rides the new optional totalTokensRaw.
  • display.required is recorded for the live render, not for replay (§5, §5.0 INV-FOLD, §13.3, §10 item 51). A host’s persistence and re-display of a turn’s grounding records follow the grounding provider’s terms, and the render duty is conditioned on them. A host’s storage projection may omit turns[].displayRequired, which toPersistable() does in one call. Nothing changes on the wire or in the fold.
  • MCP resource links in tool results (§8.0 item 31, §10 item 44): a normalizer emits an MCP resource_link part as one resource-link block, never a provider-raw block. The block gains MCP’s optional name, title, description and size. A member that doesn’t fit the block rides one provider-raw right after it, and a part without a string uri stays one provider-raw. §13.4’s scheme validation now covers resource-link uris and links a consumer takes from a tool result. A resource-link uri is carried byte for byte, so a signed or credential-bearing link is the host’s to handle: under §13.4 for its scheme, and with its own care when it fetches or persists it.
  • Host records are side metadata (§2.1, §8.0 items 19, 21 and 29, §10 item 45): values that never round-trip to the provider ride _meta, not providerMetadata.
  • The OpenAI handoff round release (§8.0 item 14, §10 item 46): when a handoff resolves a round, a call of that round with no result and no run-item stops being pending at handoff_occurred, and the round’s deferred close is released.
  • A cross-invoke tool result lands in the later invoke’s own turn, as its own role: "tool" message (§5.0 INV-XINV), and a Claude deferred tool closes the turn paused with one approval ask (§8.0 item 32; §10 item 47).
  • Remedy-shaped vendor data is advisory and never executed automatically, and a provider credit token is never emitted (§13.10, §10 item 52). A fix the framework waits on is a hitl.ask; a fix it only reports rides, less what §13.10 forbids, in a carrier that folds (§8.0 item 33, §10 item 53). §13.6 names messageMetadata. No carry moved: the homes are the ones the 0.7.0 and 0.7.1 notes describe.
  • retriable on the error outcome (§10 item 54): AgOutcome’s error variant gains an optional retriable, copied from turn.error as the producer set it.
  • Whose value threadId is (§1.2; §8.0 Partition root and host obligation 6; §10 item 55): the host’s. No schema change.
  • Editorial: a key-replace state.delta patch never removes a member; handoff‘s transfer and escalate are defined from the producers’ semantics; turn.done.messageMetadata replaces the named message’s bag whole; §13.4 and §13.6 gain host-care sentences.

draft.4 envelopes remain accepted (same major, §12).

@silverprotocol/core

  • toPersistable(result) returns a deep copy of a fold with displayRequired removed from every turn record, and nothing else changed. Persist its result unless the grounding provider’s terms permit storing displayRequired.
  • outcome.retriable. The error outcome records turn.error’s retriable when the producer set it; an absent one stays absent. The non-terminal error event’s retriable never folds. Two committed Claude golden folds, an API authentication failure and a deferred tool no longer available, now carry retriable: false. A reader that validates a stored AgTurnRecord with an older core’s schema drops the field.
  • AgUsage gains the optional costScope and totalTokensRaw. A core from 0.6.5 on passes them through as unknown fields; a core up to 0.6.4 drops them at ingest.
  • The resource-link block gains the optional name, title, description and size (an integer), and its schema is exported as AgResourceLinkBlock, so a normalizer can check each native member against it.
  • StreamAssembler never reopens a closed turn, so a late merging turn.start or a second subagentStart for a closed id no longer makes flush() emit a second terminal. openTurn() on a turn already seen now keeps a trigger passed to it. No shipped framework package reaches either path.
  • Reducer.ensureTurn() is removed (see Upgrading).

Claude Agent SDK

  • Usage accounting follows draft.5 (see Upgrading).
  • A deferred tool pauses the turn. When a PreToolUse hook answers defer, the result closes the turn from push() with one hitl.ask (kind: "approval", askId approval_<call id>) and turn.done with a paused outcome naming it. The resumed invoke opens its own turn, and the call’s result lands there as a role: "tool" message. In 0.7.x the turn closed as a success with finishReason: "unknown", finishReasonRaw: "tool_deferred" and no ask. The ext.anthropic.result-meta carry is unchanged.
  • Host-only fields moved to _meta (see Upgrading).
  • turn.start.trigger. A top-level turn’s turn.start carries trigger: {kind: "user", ref} from the CLI’s user_message_uuid, taken from the frame that opens the turn. kind: "user" means a user-role send the host submitted with that uuid, not human authorship. It’s never set from an error result, a nested frame, a notice or a later frame of the turn.
  • Hardens the tool_result_meta carry: a provider credit token at any depth is removed before the entry is emitted, as for the other CLI wrapper carries.
  • The README documents the existing threadId option as the thread root.

OpenAI Agents SDK

  • A parallel handoff no longer leaves its source turn open. When the model requests several transfers in one response, @openai/agents from 0.8.1 runs the first and streams nothing for the others. At handoff_occurred, each call of the source round that has no result and that no run-item named now stops being pending. It’s carried as ext.openai.dropped-call inside the source turn and gets no tool.done. Once nothing of the round is pending, the round’s deferred close is released in the same push(): message.end, then the deferred turn.done, with its outcome, finish reason and usage. In 0.7.x the source turn ended with turn.abort (stream-truncated) at flush, its usage on the round’s message.end. A result that arrives later for a released call rides the same ext.openai.dropped-call key after the turn’s terminal, never as a tool.done. This replaces the parallel-handoff limit described in the 0.7.1 and 0.7.2 notes for @openai/agents 0.8.1 and later.
    • Still deferred: when a filter removes handoff_occurred or tool results; an approval pending beside the transfer on @openai/agents 0.8.x, which closes paused at flush; and pending program, hosted-shell or tool-search calls.
  • createOpenaiNormalizer() takes an optional { threadId }, stamped on every turn and message, nested handoff turns included. A handoff that streams before any turn of the invoke opened gets the parent id turn_<stem>_handoff_parent.

Vercel AI SDK

  • An MCP tool error folds as an error. @ai-sdk/mcp returns an MCP result with isError: true as ordinary tool output; it now folds as outcome: "error" with isError: true, where 0.7.x folded it as a success. The facet reads MCP members only from a result @ai-sdk/mcp stamped, so an ordinary tool that returns an MCP-shaped object is unaffected.
  • An MCP Apps view locator rides tool.done._meta. An MCP result’s _meta.ui is now carried unchanged on tool.done._meta, so it survives a later snapshot.
  • A result for a call made in a previous call (a tool result, tool error or denied approval that streamText delivers before its first step) now names its own message, <toolCallId>:result, in this invoke’s turn, and folds as a role: "tool" message. In 0.7.x it had no messageId and parked the fold.
  • Anthropic stop details. A refusal’s providerMetadata.anthropic.stopDetails on the finish step now rides the step’s message.metadata, verbatim, with any provider credit token removed at any depth. In 0.7.x it was dropped.
  • createVercelNormalizer() takes an optional { threadId }, stamped on every entity. ext.vercel.* never follows it.

Google ADK

  • Ids change to a per-invoke stem (see Upgrading).
  • Live sessions get a turn per generation. On a barge-in, ADK sends interrupted: true first, then the interrupted generation’s usage and turnComplete, then the reply. The interrupted generation now closes as turn.abort (interrupted) on its own turnComplete, with its usage on its message.end, and the reply opens the next turn, turn_<invokeId>_<invocationId>_g1. A live barge-in closes the interrupted generation and folds clean, as two live captures show, one of them with two barge-ins. This closes the known limit in the 0.7.1 note, where a real barge-in parked the reducer.
    • Still limited: a generation that follows a completed reply with no barge-in lands in the closed turn, and parks, unless you opt into hostCompletion, which defers that close. When text is buffered at the interrupt, ADK yields only the text aggregate, without the flag, so the turn closes at flush() as stream-truncated.
    • A transcription reads once. ADK’s finished transcription is no longer re-emitted when it repeats the chunks already streamed.
  • Gemini Live usage. Over the Live API, outputTokens now counts the response and the thoughts; 0.7.x counted the thoughts alone. totalTokens is derived as inputTokens + outputTokens + (toolUseInputTokens ?? 0), the total Gemini’s generateContent surface reports for the same counts, not a billing figure. Google’s own total rides totalTokensRaw where it differs, and neither is emitted when Google reports no total. Each report’s per-modality breakdown rides message.metadata as usageDetails, one verbatim entry per report. Live usage stored before 0.8.0 is unreliable. Usage on the generateContent route is unchanged.
  • MCP resource links. An MCP resource_link part of a tool result becomes one resource-link block, with a provider-raw right after it only for members that don’t fit. Image, audio and embedded-resource parts stay provider-raw.
  • createAdkNormalizer() takes an optional { threadId }, stamped on every turn and message it opens, the host-error turn included.

Wire version

1.0.0-draft.5. Replaying the 73 recorded streams through the 0.7.2 and 0.8.0 packages, output is byte-identical for 14 of 15 OpenAI Agents SDK streams and 6 of 8 Vercel AI SDK streams. Every Claude stream changes (usage accounting), and so does every Google ADK stream (ids). @silverprotocol/richtext is unchanged apart from its version.