Watch
1
0
Fork
You've already forked souveraine
0

replay: never turn thinking into speech

This commit is contained in:
Fimeg 2026-08-17 11:19:39 -04:00
commit 707a277273
2 changed files with 167 additions and 64 deletions

View file

@ -8,10 +8,9 @@ The code is `core::session::replay_messages`.
## One history ## One history
There is a single record: the messages stored for a conversation. Everything There is a single record: typed message blocks stored for a conversation. Her
that talks to a model — her turn, her subconscious a moment later, a terminal, turn, her subconscious a moment later, the TUI, and the Panel all read that
a voice, a web surface — replays that same record. None of them holds a version record. None of them holds a transcript of its own.
of its own.
That is not tidiness. It follows from what the substrate already says: one That is not tidiness. It follows from what the substrate already says: one
consciousness in two modes, and she is not the glass. A second projection is a consciousness in two modes, and she is not the glass. A second projection is a
@ -19,50 +18,92 @@ second opinion about what happened, and the two drift the moment one is
touched. There were three of them once. They disagreed about images before touched. There were three of them once. They disagreed about images before
anyone noticed. anyone noticed.
## What crosses One record does not mean one lossy encoding. A surface receives the blocks
verbatim and draws their types. A model provider receives the narrowest valid
wire projection of those blocks. Both are views of the same record; neither is
allowed to rewrite the record into its own authority.
A stored message is made of blocks. Each one has a prose form for replay: ## What crosses to a model
| block | crosses as | A stored message is made of blocks. The model projection preserves the
strongest valid form each block still has:
| stored shape | crosses as |
|---|---| |---|---|
| text | itself | | text | assistant/user text |
| tool use | `Tool use: name(input)` | | complete tool round | one assistant `tool_calls` message, then bound tool results |
| tool result | `Result (name): output`, or `Error (name): output` | | call with no result | native call plus an explicit did-not-complete result |
| reasoning | `[Reasoning]: …` | | result whose call is gone | `Result (name): output`, or `Error (name): output` prose |
| image | natively, or a marker saying it was not seen | | reasoning | omitted unless a provider-valid signed thinking block exists |
| image | native image, or a marker saying it was not seen |
Nothing is silently dropped. A block with no prose form does not exist — the Text crosses as speech. Tool calls and their results cross as a native tool
match is exhaustive, so a new kind of block fails to compile rather than round when the pair exists; orphaned evidence degrades explicitly rather than
quietly going missing. breaking the wire. Images cross natively when the model can see and otherwise
become an honest unseen marker.
This is what makes a turn's own work available to the turn after it. When tool Reasoning is different. It remains a typed `reasoning` block in persistence,
blocks were dropped on replay, everything she learned by *doing* survived only token accounting, compaction input, transcript APIs, and the Panel's
as whatever she happened to write down in prose, and she would re-derive collapsible Thinking card. It is not assistant speech. Anthropic will only
accept replayed thinking with the original signature, which the generic
persisted block does not carry. The honest generic projection therefore omits
it. Flattening it into `[Reasoning]: ...` puts an internal trace into the
assistant's visible prose and teaches the next model to imitate the leak.
The projection matches exhaustively. Adding a block kind still fails review at
the owning matches rather than disappearing behind a wildcard.
Native tool rounds are what make a turn's own work available to the turn after
it. When tool blocks were dropped, everything she learned by *doing* survived
only as whatever she happened to write down in prose, and she would re-derive
findings she had already reached. It also left microcompact — whose entire job findings she had already reached. It also left microcompact — whose entire job
is blurring old tool output — with nothing in the payload to blur. is blurring old tool output — with nothing in the stored payload to blur.
## Two properties that are load-bearing Microcompact does not drop tools. It retains every call and replaces only the
bulk of older tool-result output with an explicit cleared marker, keeping the
recent results intact. The typed record is compacted first; replay projects the
result afterward.
Both were learned from a failure, and both look like details until they break. ## Three properties that are load-bearing
**One wire message per stored message.** Replaying block by block splits a All three were learned from failures, and all look like details until they
single assistant turn that called several tools into adjacent assistant break.
messages, and OpenAI-shaped providers reject that outright: *assistant
tool_calls must be followed by tool messages*. The message boundary is part of
the record.
**No tool roles and no call-id obligations.** Inside a live turn, a tool call **One declaration for one stored assistant round.** Replaying block by block
and its result are both known to exist, and the exact linkage is used. Across splits a turn that called several tools into adjacent assistant messages.
persisted turns that linkage is brittle: a turn killed mid-round leaves a call OpenAI-shaped providers reject that shape. All calls from the stored assistant
whose result never landed, and replaying it as half a pair fails the whole message remain on one wire message.
request. So the *content* of a tool round survives as prose while the wire
obligations do not. The record is not the protocol.
## The only thing that varies **A native call is always answered.** Each result follows the assistant tool
declaration with its original call id. If a turn died before a result landed,
replay synthesizes an explicit did-not-complete result. If compaction removed
the call but left its result, that result cannot carry a tool role honestly and
degrades to named success/error prose. No half-pair reaches a provider.
Whether the model can see. Images ride as native multipart content, or degrade **Doing never becomes saying.** A tool call must not become assistant prose
to a marker that says plainly they were not visible. The caller supplies that such as `Tool use: bash(...)`. That falsifies the record and teaches the model
one fact; nothing else about the projection changes. that writing the sentence is how the action is performed. Calls stay calls;
results stay bound evidence whenever the pair can still be made valid.
## Surface hydration
`GET /v1/conversations/:id/messages` returns the stored
`ConversationMessage`s verbatim. The Panel maps `text` to speech, `reasoning`
to `ThinkingCard`, and tool use/result blocks to `ToolCard`. Live SSE follows
the same vocabulary: `assistant_message`, `reasoning_message`, tool call, and
tool return remain distinct events. A resume must use this typed path, never
`replay_messages`, which is the model projection.
That separation makes the visible invariant precise: Reasoning and Thinking
are the same typed thing. They may be folded or hidden by a surface, and kept
for later audit or reflection, but they may not be relabelled as ordinary text.
## The model capability that varies
Whether the model can see changes the image projection. Images ride as native
multipart content, or degrade to a marker that says plainly they were not
visible. The caller supplies that one fact; nothing else about the generic
projection changes.
A degraded image is named as unseen rather than dropped. A model told it did A degraded image is named as unseen rather than dropped. A model told it did
not look can say so. A model handed a stub shaped like success proceeds as not look can say so. A model handed a stub shaped like success proceeds as
@ -70,6 +111,12 @@ though it had looked.
## Open edges ## Open edges
Live tool rounds inside a turn build their own messages, with real Live tool rounds inside a turn and replayed completed rounds both use real
`tool_call_id` linkage. That is correct and separate — replay is for what is `tool_call_id` linkage. Their repair rules differ: live execution knows whether
already on disk. The two should not be merged; they answer different questions. a result is still coming, while replay must make an interrupted persisted round
valid and truthful before it crosses the wire.
Persisting provider-valid signed thinking would permit a native reasoning
projection for providers that support it. Until the record carries that proof,
reasoning stays available to surfaces and later internal readers but does not
cross as invented assistant speech.

View file

@ -83,24 +83,30 @@ impl ContentBlock {
} }
} }
/// The prose a block takes when replayed as cross-turn history — the one /// The prose a block may take when replayed as cross-turn model history —
/// authority for that shape, as `countable_text` is for weight. /// the one authority for that lossy shape, as `countable_text` is for
/// weight. This is not the transcript/API projection: surfaces receive the
/// stored typed blocks verbatim.
/// ///
/// Cross-turn replay is deliberately text-only. A `role=tool` message /// Complete tool rounds are projected natively by `replay_messages`, not
/// through this method. This prose fallback exists for text and evidence
/// that cannot be bound into a valid tool round. A `role=tool` message
/// without a `tool_call_id` is rejected by OpenAI-shaped providers, and a /// without a `tool_call_id` is rejected by OpenAI-shaped providers, and a
/// persisted `ToolUse` whose result never landed — a turn killed /// persisted `ToolUse` whose result never landed — a turn killed
/// mid-round, which has happened twice — would replay as an orphaned half /// mid-round, which has happened twice — must be repaired rather than sent
/// of a pair and fail the whole request. So the *content* survives as /// as an orphaned half of a pair.
/// assistant prose while the wire obligations do not.
/// ///
/// Dropping these blocks instead of flattening them is what made a turn's /// Dropping these blocks instead of flattening them is what made a turn's
/// own tool work invisible to the turn after it, and left microcompact — /// own tool work invisible to the turn after it, and left microcompact —
/// whose entire job is blurring old `ToolResult` output — with nothing in /// whose entire job is blurring old `ToolResult` output — with nothing in
/// the payload to blur. /// the payload to blur.
/// ///
/// `None` means the block has no cross-turn prose: images are carried /// `None` means the block has no honest prose form. Images are carried
/// natively by callers that can send them, and degraded by those that /// natively by callers that can send them and degraded by those that
/// cannot. /// cannot. Reasoning remains a typed persisted block for audit and surface
/// hydration, but is omitted from generic model replay: Anthropic thinking
/// needs its original signature, and making `[Reasoning]: ...` assistant
/// prose teaches the next model to emit internal thought as visible text.
pub fn replay_text(&self) -> Option<String> { pub fn replay_text(&self) -> Option<String> {
match self { match self {
ContentBlock::Text { text } => Some(text.clone()), ContentBlock::Text { text } => Some(text.clone()),
@ -115,7 +121,7 @@ impl ContentBlock {
} else { } else {
format!("Result ({tool_name}): {output}") format!("Result ({tool_name}): {output}")
}), }),
ContentBlock::Reasoning { reasoning } => Some(format!("[Reasoning]: {reasoning}")), ContentBlock::Reasoning { .. } => None,
ContentBlock::Image { .. } => None, ContentBlock::Image { .. } => None,
} }
} }
@ -372,11 +378,13 @@ const UNFINISHED_CALL: &str =
/// an explicit unfinished result; orphaned results, which can be bound to /// an explicit unfinished result; orphaned results, which can be bound to
/// nothing, degrade to prose rather than being dropped. /// nothing, degrade to prose rather than being dropped.
/// ///
/// Reasoning stays prose. Anthropic requires a replayed thinking block to /// Reasoning stays typed in the stored record and in transcript APIs, but does
/// carry its original signature and `ContentBlock::Reasoning` does not persist /// not enter this generic model projection. Anthropic requires a replayed
/// one, so there is no honest native form for it here. That constraint binds /// thinking block to carry its original signature and
/// only the assistant turn immediately awaiting a result — inside the live /// `ContentBlock::Reasoning` does not persist one, so there is no honest native
/// loop, never in replayed history. /// form here. Flattening it as `[Reasoning]: ...` assistant prose is worse than
/// omission: the next model can imitate that label in visible output. Signed
/// thinking needed by the live Anthropic loop remains a separate typed field.
pub fn replay_messages( pub fn replay_messages(
messages: &[ConversationMessage], messages: &[ConversationMessage],
images: ImagePolicy, images: ImagePolicy,
@ -554,7 +562,10 @@ pub fn replay_messages(
continue; continue;
} }
out.push(Message::text(role, prose_of(m.blocks.iter()))); let prose = prose_of(m.blocks.iter());
if !prose.is_empty() {
out.push(Message::text(role, prose));
}
} }
out out
@ -575,11 +586,12 @@ mod tests {
/// after it — and microcompact, whose whole job is blurring old /// after it — and microcompact, whose whole job is blurring old
/// `ToolResult` output, had nothing in the payload left to blur. /// `ToolResult` output, had nothing in the payload left to blur.
/// ///
/// Image is the only block with no cross-turn prose; it is carried /// Images and reasoning have no generic cross-turn prose. Images are
/// natively or degraded by the caller. Every other kind must render, so /// carried natively or degraded by the caller. Reasoning stays typed in
/// a block kind added later cannot go silently missing. /// the stored record and transcript API; without a provider-valid signed
/// thinking block it must not masquerade as visible assistant speech.
#[test] #[test]
fn only_images_have_no_cross_turn_prose() { fn only_speech_and_tool_evidence_have_cross_turn_prose() {
let blocks = vec![ let blocks = vec![
ContentBlock::Text { ContentBlock::Text {
text: "plain".into(), text: "plain".into(),
@ -595,9 +607,6 @@ mod tests {
output: "file body".into(), output: "file body".into(),
is_error: false, is_error: false,
}, },
ContentBlock::Reasoning {
reasoning: "weighing it".into(),
},
]; ];
for b in &blocks { for b in &blocks {
@ -608,6 +617,12 @@ mod tests {
); );
} }
assert!(ContentBlock::Reasoning {
reasoning: "weighing it".into(),
}
.replay_text()
.is_none());
assert!(ContentBlock::Image { assert!(ContentBlock::Image {
media_type: "image/png".into(), media_type: "image/png".into(),
data: "AAAA".into(), data: "AAAA".into(),
@ -616,6 +631,47 @@ mod tests {
.is_none()); .is_none());
} }
/// The regression visible in the Panel in August 2026. Replay used to
/// convert a typed reasoning block into `[Reasoning]: ...` assistant prose.
/// The next model imitated that text, so the renderer quite correctly drew
/// it as speech rather than a ThinkingCard. Keep the block on disk, but do
/// not feed an unsigned thought back as something she said.
#[test]
fn reasoning_never_replays_as_visible_assistant_prose() {
let stored = vec![ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![
ContentBlock::Reasoning {
reasoning: "private chain".into(),
},
ContentBlock::Text {
text: "the answer".into(),
},
],
usage: None,
timestamp: None,
}];
for policy in [ImagePolicy::Native, ImagePolicy::Degrade] {
let messages = replay_messages(&stored, policy);
assert_eq!(messages.len(), 1, "{policy:?}: {messages:?}");
let prose = messages[0].content.as_text();
assert_eq!(prose, "the answer");
assert!(!prose.contains("Reasoning"), "{policy:?}: {prose}");
assert!(!prose.contains("private chain"), "{policy:?}: {prose}");
}
let reasoning_only = vec![ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Reasoning {
reasoning: "still private".into(),
}],
usage: None,
timestamp: None,
}];
assert!(replay_messages(&reasoning_only, ImagePolicy::Degrade).is_empty());
}
#[test] #[test]
fn replay_prose_carries_the_tool_name_and_its_output() { fn replay_prose_carries_the_tool_name_and_its_output() {
let call = ContentBlock::ToolUse { let call = ContentBlock::ToolUse {