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
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue