Conversation
An assistant turn is now an ordered sequence of blocks (reasoning, text, tool calls, media) preserving the model's true emission order. blocks is the only stored state; content/reasoning/tool_calls survive as read-only projections, and channel-shaped construction/v0.1 payloads upgrade at validation (permanent path). - New kinds: text, reasoning, signed_reasoning, redacted_reasoning, reasoning_ref (+ ToolCall and media blocks join the union) - Clients: Responses client captures/replays items in true order incl. reasoning refs (id/summary/encrypted_content); LiteLLM captures multiple thinking blocks (previously raised) and replays one entry per signed block; chat-completions requests byte-identical for non-signed turns - Wire fixes: assistant metadata no longer sent on the wire; ToolCall.kind excluded from payloads - Integration contract (§8): stable message ids, metadata opacity, subclass preservation, Agent(history_listener=...) with summarization and context_overflow_unwind events, SummaryMessage.replaced_ids - BREAKING: channel assignment raises; dumps emit blocks only; changelog carries the full migration table
- OpaqueBlock: provider-native marker/control block the framework does not
interpret, echoed back verbatim in position on passback. data holds the
block's raw self-describing JSON. Outside the reasoning family;
projections ignore it and built-in clients skip it on replay. Added
pre-freeze because post-flip additions to the block union are breaking.
- Mixing guard and channel synthesis gate on truthiness: empty values
("", [], {}) carry no data and never conflict with blocks (spec §6.2);
a falsy reasoning payload no longer synthesizes an empty ReasoningBlock.
- SummaryMessage.replaced_ids chains across summarization rounds so dumped
histories reconstruct lineage transitively (spec §13).
- Both channel-write break errors now point at the migration guide (§11);
channel projections gain raising setters carrying that message.
…acy fixes - Drop HistoryListener/HistoryReplacedReason, the Agent(history_listener=...) parameter, and the notification wiring: the hook had no consumer, and SummaryMessage.replaced_ids already carries summary lineage in dumped histories. - OpaqueBlock docstring now matches shipped behavior: the framework carries the block uninterpreted and in position; built-in clients skip it on replay. - CHANGELOG: add OpaqueBlock to the block-type list, note the ToolCall 'kind' discriminator as a dump-shape change, drop the listener entry. - Genericize integrator references in the integration-contract tests. Claude-Session: https://claude.ai/code/session_011agaazKAnVPBymspi3XZZK
… tests - Nest a kind-discriminated user-role union inside ChatMessage so SummaryMessage/TurnWarningMessage rehydrate as their own types through dumped histories (SubAgentMetadata previously failed validation on reload, losing replaced_ids lineage). Round-trip test added. - Remove SummaryMessage from the top-level namespace for symmetry with TurnWarningMessage; both import from stirrup.core.models. - Lock the cache path: a blocks-native assistant message (every reasoning kind, opaque, media, signed tool call) round-trips serialize/deserialize bit-for-bit, and SummaryMessage.replaced_ids survives CacheState. - CHANGELOG entries for the union fix and the export removal. Claude-Session: https://claude.ai/code/session_011agaazKAnVPBymspi3XZZK
The nested user-role union requires the kind key on raw dicts validated through ChatMessage; every dump since the field was introduced carries it. Claude-Session: https://claude.ai/code/session_011agaazKAnVPBymspi3XZZK
…; drop unused ignores ty does not credit the Annotated default_factory on SubAgentMetadata.run_metadata, and the raising channel setters made the assignment type: ignore comments unused. Claude-Session: https://claude.ai/code/session_011agaazKAnVPBymspi3XZZK
Encrypted reasoning payloads get their own block kind instead of riding ReasoningRefBlock: a reasoning item returned under include=["reasoning.encrypted_content"] (store=false / zero-data-retention) is captured as EncryptedReasoningBlock — id, encrypted payload, and summary parts kept split — and re-emitted verbatim in position on replay. OpenResponsesClient(encrypted_reasoning=True) turns on the stateless mode. Claude-Session: https://claude.ai/code/session_011agaazKAnVPBymspi3XZZK
…er include reasoning_blocks() now includes EncryptedReasoningBlock, so the reasoning projection and Chat Completions reasoning_content replay surface encrypted summaries as readable content; the block is also exported from core.models.__all__. encrypted_reasoning=True extends a caller-supplied include list instead of clobbering it. Claude-Session: https://claude.ai/code/session_011agaazKAnVPBymspi3XZZK
# Conflicts: # pyproject.toml # uv.lock
…e hygiene, docs, tests - OpenResponsesClient: raise on encrypted_content without an item id, on reasoning summary entries without text, and at __init__ for store=False without encrypted_reasoning; surface refusal content as answer text; split self-produced ReasoningBlock out of the silent replay-skip arm; statically exhaustive replay match via assert_never - AssistantMessage: with_text/with_blocks clear provider_response_id (the handle is bound to the exact emitted content); malformed v0.1 content payloads fail validation instead of vanishing; TextBlock.signature documented as external-client-only; shared REASONING_BLOCK_TYPES tuple - Agent: raise on summarizer responses with no text instead of replacing history with an empty summary - Docs/CHANGELOG: document provider_response_id stateful continuation, raise-on-unknown Responses items, complete block-kind lists, fix the custom-client example to use block helpers - Tests: new litellm _parse_thinking_blocks and ChatCompletions parse suites, continuation guard-path tests, drop dead pytest.mark.asyncio markers
declanjackson
left a comment
There was a problem hiding this comment.
Looks good! Couple comments more looking for you to explain why certain things are the way they are.
A quick though is if we should fix or at least build in some smart error handling for cache reads now as I believe v0.1 caches will be invalid for v0.2?
Also another issue raised by CC, but I'm not sure is super high prio:
provider_response_id is saved to disk, so it survives cache saves and resumes. But the provider's stored copy doesn't live forever (responses expire, get deleted, or a resume runs against a different API key/org/server). When the stored copy is gone, continue-by-id fails at the provider with a confusing API error, and there's no fallback — even though the client has the full history locally and could simply re-send it. Ask: catch the provider's "not found" error in generate() and retry once with a full re-send (or at minimum document this failure on the field). Related: a history recorded against one server, replayed through a client pointed at another, will also present an id that server has never seen.
| signature = next( | ||
| (block.signature for block in rblocks if isinstance(block, SignedReasoningBlock)), | ||
| None, | ||
| ) | ||
| content = "".join(block.content for block in rblocks if not isinstance(block, RedactedReasoningBlock)) |
There was a problem hiding this comment.
Would this not combine multiple SignedReasoningBlocks (which could have separate signatures) into a single one with just the first block's sig?
| def tool_calls(self, _value: object) -> None: | ||
| raise AttributeError(_CHANNEL_ASSIGNMENT_ERROR.format(channel="tool_calls")) | ||
|
|
||
| def with_text(self, text: str) -> "AssistantMessage": |
There was a problem hiding this comment.
OOI when would you want to use this? Why would we be mutating past messages
| raise ValueError("Cannot replace signed text blocks; their signatures are bound to the original text") | ||
| replaced: list[AssistantBlock] = [block for block in self.blocks if not isinstance(block, TextBlock)] | ||
| insert_at = next((i for i, block in enumerate(replaced) if isinstance(block, ToolCall)), len(replaced)) | ||
| replaced.insert(insert_at, TextBlock(text=text)) |
There was a problem hiding this comment.
CC raised that this is inconsistent with how we build TextBlock in _upgrade_channel_fields(), since there we don't build if empty string whereas here we can - any reason for the distinction?
Add the v0.1.1–v0.1.12 release history and tag v0.2.0 as (unreleased). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.2.0 — Block-based assistant messages
Summary
Makes an assistant turn an ordered sequence of blocks—reasoning, text, tool calls, media, and opaque provider state—so capture, history, serialization, and replay preserve actual emission order.
AssistantMessage.blocksis the only stored content representation.This change is coordinated with ArtificialAnalysis #6276, which pins this reviewed head exactly at
9672fa160795fbd994f65babd882b73c1cbb935f.Model and API
AssistantBlockis akind-discriminated union ofTextBlock,ReasoningBlock,SignedReasoningBlock,RedactedReasoningBlock,ReasoningRefBlock,EncryptedReasoningBlock,OpaqueBlock,ToolCall, and media blocks.provider_response_idis top-level turn metadata.with_blocks(), Pydanticmodel_copy(), and deprecatedcopy()validate replacements and clear it whenever content changes; dumps remain list-shaped.TextBlock.signatureandToolCall.signatureare nullable block-attached passback fields, for example for Google thought signatures.ToolCall.tool_call_idremains the internal correlation key.has_provider_tool_call_idrecords provider provenance so synthesized IDs are never emitted as provider-authored wire state.AssistantMessage(blocks=[...]). There is no custom or channel-shaped constructor overload.joined_text,final_text,tool_call_blocks, andreasoning_blocks.Compatibility and migration
content,reasoning, andtool_callsremains temporarily available as a read-only projection and emitsDeprecationWarning; assignment raises.SubAgentMetadata, cache payloads, aggregate cache metadata, nullable legacy call/result correlation, and tool calls withoutkindupgrade during validation. Channel-shaped construction is reader compatibility only.blockswith any payload-bearing legacy channel raises instead of discarding one representation. Empty transitional legacy fields do not override canonical blocks.Capture and replay
provider_response_id/previous_response_idand requires a non-empty response ID. A definitive stale-ID error retries once with full local history only when replay is exact; provider-reference reasoning makes that recovery explicitly unrecoverable.encrypted_reasoning=Trueis explicit stateless/ZDR mode:store=false, encrypted-content inclusion, client-side encrypted reasoning replay, and no stored response ID.tools,tool_choice, andreasoningkwargs remain available when no dedicated configuration conflicts; conflicting combinations now raise instead of being silently overwritten.Agent history
SummaryMessage.replaced_idspreserves transitive summary lineage;SummaryMessageandTurnWarningMessagerehydrate as their concrete union members.ChatMessagerequire theirkinddiscriminator. The unusedHistoryListenerand top-levelSummaryMessageexport are removed.Validation
ruffandtychecks pass.