Skip to content

v0.2.0: Block-based assistant messages#60

Draft
keatingw wants to merge 34 commits into
mainfrom
wk/v0-2
Draft

v0.2.0: Block-based assistant messages#60
keatingw wants to merge 34 commits into
mainfrom
wk/v0-2

Conversation

@keatingw

@keatingw keatingw commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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.blocks is the only stored content representation.

This change is coordinated with ArtificialAnalysis #6276, which pins this reviewed head exactly at 9672fa160795fbd994f65babd882b73c1cbb935f.

Model and API

  • AssistantBlock is a kind-discriminated union of TextBlock, ReasoningBlock, SignedReasoningBlock, RedactedReasoningBlock, ReasoningRefBlock, EncryptedReasoningBlock, OpaqueBlock, ToolCall, and media blocks.
  • Accepted block sequences validate to immutable tuples and the field is frozen. Blocks and nested function requests are also frozen, binding signatures, references, encrypted payloads, and tool arguments to the exact payload they authenticate.
  • provider_response_id is top-level turn metadata. with_blocks(), Pydantic model_copy(), and deprecated copy() validate replacements and clear it whenever content changes; dumps remain list-shaped.
  • TextBlock.signature and ToolCall.signature are nullable block-attached passback fields, for example for Google thought signatures.
  • ToolCall.tool_call_id remains the internal correlation key. has_provider_tool_call_id records provider provenance so synthesized IDs are never emitted as provider-authored wire state.
  • New code uses only the generated block constructor: AssistantMessage(blocks=[...]). There is no custom or channel-shaped constructor overload.
  • Block helpers include joined_text, final_text, tool_call_blocks, and reasoning_blocks.

Compatibility and migration

  • Reading content, reasoning, and tool_calls remains temporarily available as a read-only projection and emits DeprecationWarning; assignment raises.
  • v0.1 channel histories, SubAgentMetadata, cache payloads, aggregate cache metadata, nullable legacy call/result correlation, and tool calls without kind upgrade during validation. Channel-shaped construction is reader compatibility only.
  • Passing blocks with any payload-bearing legacy channel raises instead of discarding one representation. Empty transitional legacy fields do not override canonical blocks.
  • Deterministic synthetic call IDs preserve null legacy correlation across validation, cache restore, and subagent histories.
  • Legacy cache hashes are tried across historical schemas and migrate on the next save. Uniform scalar/list encodings are exhaustive; mixed ambiguous encodings are bounded to 256 combinations per schema to avoid exponential work.
  • New dumps contain blocks only and require a v0.2-aware reader; the complete matrix is in CHANGELOG.md.

Capture and replay

  • OpenAI Responses preserves message, reasoning, and function-call items in emission order and rejects unsupported inputs/outputs instead of skipping them.
  • Default stateful mode continues through provider_response_id / previous_response_id and 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=True is explicit stateless/ZDR mode: store=false, encrypted-content inclusion, client-side encrypted reasoning replay, and no stored response ID.
  • Structural kwargs owned by the client are rejected. Provider-native tools, tool_choice, and reasoning kwargs remain available when no dedicated configuration conflicts; conflicting combinations now raise instead of being silently overwritten.
  • Non-completed Responses statuses, unknown output kinds, unsupported replay blocks, and non-string tool results fail loudly.
  • Direct Chat Completions rejects signed text, unsupported provider-state blocks, and calls without provider IDs. LiteLLM explicitly handles its signed-thinking extension and preserves multiple signed/redacted blocks.
  • Metadata and internal tool-ID provenance are not leaked onto provider wire payloads.

Agent history

  • SummaryMessage.replaced_ids preserves transitive summary lineage; SummaryMessage and TurnWarningMessage rehydrate as their concrete union members.
  • Agent cache loading prefers the current hash, restores the hash actually loaded, migrates historical candidates, and clears all candidates after success.
  • Raw user-message dictionaries validated through ChatMessage require their kind discriminator. The unused HistoryListener and top-level SummaryMessage export are removed.

Validation

  • Full non-external suite: 225 passed, 24 deselected.
  • Focused OpenAI Responses suite: 67 passed.
  • Full ruff and ty checks pass.
  • Coordinated AA unit target: 3,979 passed, 1 skipped, 354 deselected; focused eval/compatibility suite: 243 passed.

keatingw added 7 commits July 2, 2026 12:57
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
@keatingw keatingw changed the title v0.2.0: Block-based assistant messages (SP-001) v0.2.0: Block-based assistant messages Jul 6, 2026
…; 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
@keatingw keatingw self-assigned this Jul 6, 2026
keatingw added 14 commits July 7, 2026 21:08
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 declanjackson left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/stirrup/core/models.py Outdated
Comment on lines +984 to +988
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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this not combine multiple SignedReasoningBlocks (which could have separate signatures) into a single one with just the first block's sig?

Comment thread src/stirrup/core/models.py Outdated
def tool_calls(self, _value: object) -> None:
raise AttributeError(_CHANNEL_ASSIGNMENT_ERROR.format(channel="tool_calls"))

def with_text(self, text: str) -> "AssistantMessage":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOI when would you want to use this? Why would we be mutating past messages

Comment thread src/stirrup/core/models.py Outdated
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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

declanjackson and others added 3 commits July 15, 2026 01:00
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>
@keatingw keatingw marked this pull request as draft July 15, 2026 17:24
@keatingw keatingw requested a review from declanjackson July 15, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants