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
There is a single record: the messages stored for a conversation. Everything
that talks to a model — her turn, her subconscious a moment later, a terminal,
a voice, a web surface — replays that same record. None of them holds a version
of its own.
There is a single record: typed message blocks stored for a conversation. Her
turn, her subconscious a moment later, the TUI, and the Panel all read that
record. None of them holds a transcript of its own.
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
@ -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
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 |
| tool use | `Tool use: name(input)` |
| tool result | `Result (name): output`, or `Error (name): output` |
| reasoning | `[Reasoning]: …` |
| image | natively, or a marker saying it was not seen |
| text | assistant/user text |
| complete tool round | one assistant `tool_calls` message, then bound tool results |
| call with no result | native call plus an explicit did-not-complete result |
| result whose call is gone | `Result (name): output`, or `Error (name): output` prose |
| 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
match is exhaustive, so a new kind of block fails to compile rather than
quietly going missing.
Text crosses as speech. Tool calls and their results cross as a native tool
round when the pair exists; orphaned evidence degrades explicitly rather than
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
blocks were dropped on replay, everything she learned by *doing* survived only
as whatever she happened to write down in prose, and she would re-derive
Reasoning is different. It remains a typed `reasoning` block in persistence,
token accounting, compaction input, transcript APIs, and the Panel's
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
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
single assistant turn that called several tools into adjacent assistant
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.
All three were learned from failures, and all look like details until they
break.
**No tool roles and no call-id obligations.** Inside a live turn, a tool call
and its result are both known to exist, and the exact linkage is used. Across
persisted turns that linkage is brittle: a turn killed mid-round leaves a call
whose result never landed, and replaying it as half a pair fails the whole
request. So the *content* of a tool round survives as prose while the wire
obligations do not. The record is not the protocol.
**One declaration for one stored assistant round.** Replaying block by block
splits a turn that called several tools into adjacent assistant messages.
OpenAI-shaped providers reject that shape. All calls from the stored assistant
message remain on one wire message.
## 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
to a marker that says plainly they were not visible. The caller supplies that
one fact; nothing else about the projection changes.
**Doing never becomes saying.** A tool call must not become assistant prose
such as `Tool use: bash(...)`. That falsifies the record and teaches the model
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
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
Live tool rounds inside a turn build their own messages, with real
`tool_call_id` linkage. That is correct and separate — replay is for what is
already on disk. The two should not be merged; they answer different questions.
Live tool rounds inside a turn and replayed completed rounds both use real
`tool_call_id` linkage. Their repair rules differ: live execution knows whether
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
/// authority for that shape, as `countable_text` is for weight.
/// The prose a block may take when replayed as cross-turn model history —
/// 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
/// persisted `ToolUse` whose result never landed — a turn killed
/// mid-round, which has happened twice — would replay as an orphaned half
/// of a pair and fail the whole request. So the *content* survives as
/// assistant prose while the wire obligations do not.
/// mid-round, which has happened twice — must be repaired rather than sent
/// as an orphaned half of a pair.
///
/// 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 —
/// whose entire job is blurring old `ToolResult` output — with nothing in
/// the payload to blur.
///
/// `None` means the block has no cross-turn prose: images are carried
/// natively by callers that can send them, and degraded by those that
/// cannot.
/// `None` means the block has no honest prose form. Images are carried
/// natively by callers that can send them and degraded by those that
/// 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> {
match self {
ContentBlock::Text { text } => Some(text.clone()),
@ -115,7 +121,7 @@ impl ContentBlock {
} else {
format!("Result ({tool_name}): {output}")
}),
ContentBlock::Reasoning { reasoning } => Some(format!("[Reasoning]: {reasoning}")),
ContentBlock::Reasoning { .. } => None,
ContentBlock::Image { .. } => None,
}
}
@ -372,11 +378,13 @@ const UNFINISHED_CALL: &str =
/// an explicit unfinished result; orphaned results, which can be bound to
/// nothing, degrade to prose rather than being dropped.
///
/// Reasoning stays prose. Anthropic requires a replayed thinking block to
/// carry its original signature and `ContentBlock::Reasoning` does not persist
/// one, so there is no honest native form for it here. That constraint binds
/// only the assistant turn immediately awaiting a result — inside the live
/// loop, never in replayed history.
/// Reasoning stays typed in the stored record and in transcript APIs, but does
/// not enter this generic model projection. Anthropic requires a replayed
/// thinking block to carry its original signature and
/// `ContentBlock::Reasoning` does not persist one, so there is no honest native
/// 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(
messages: &[ConversationMessage],
images: ImagePolicy,
@ -554,7 +562,10 @@ pub fn replay_messages(
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
@ -575,11 +586,12 @@ mod tests {
/// after it — and microcompact, whose whole job is blurring old
/// `ToolResult` output, had nothing in the payload left to blur.
///
/// Image is the only block with no cross-turn prose; it is carried
/// natively or degraded by the caller. Every other kind must render, so
/// a block kind added later cannot go silently missing.
/// Images and reasoning have no generic cross-turn prose. Images are
/// carried natively or degraded by the caller. Reasoning stays typed in
/// the stored record and transcript API; without a provider-valid signed
/// thinking block it must not masquerade as visible assistant speech.
#[test]
fn only_images_have_no_cross_turn_prose() {
fn only_speech_and_tool_evidence_have_cross_turn_prose() {
let blocks = vec![
ContentBlock::Text {
text: "plain".into(),
@ -595,9 +607,6 @@ mod tests {
output: "file body".into(),
is_error: false,
},
ContentBlock::Reasoning {
reasoning: "weighing it".into(),
},
];
for b in &blocks {
@ -608,6 +617,12 @@ mod tests {
);
}
assert!(ContentBlock::Reasoning {
reasoning: "weighing it".into(),
}
.replay_text()
.is_none());
assert!(ContentBlock::Image {
media_type: "image/png".into(),
data: "AAAA".into(),
@ -616,6 +631,47 @@ mod tests {
.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]
fn replay_prose_carries_the_tool_name_and_its_output() {
let call = ContentBlock::ToolUse {