replay: never turn thinking into speech
This commit is contained in:
parent
59b52a818b
commit
707a277273
2 changed files with 167 additions and 64 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue