Skip to content

Commit b5999bc

Browse files
authored
fix(agents): drop incomplete reasoning replay turns (#88656)
Drop assistant replay turns that ended at the token limit with only incomplete hidden reasoning while preserving visible text, tool calls, empty turns, and unknown content shapes. Apply the same classification to embedded replay and public transport transforms, with focused regression, live OpenAI/Anthropic provider proof, docs, autoreview, Testbox, and green CI. Co-authored-by: clawstation <[email protected]>
1 parent fc6d448 commit b5999bc

7 files changed

Lines changed: 252 additions & 8 deletions

File tree

docs/reference/transcript-hygiene.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Scope includes:
2020
- Thinking signature cleanup
2121
- Image payload sanitization
2222
- Blank text-block cleanup before provider replay
23+
- Incomplete reasoning-only length-turn cleanup before provider replay
2324
- User-input provenance tagging (for inter-session routed prompts)
2425
- Empty assistant error-turn repair for Bedrock Converse replay
2526

@@ -91,6 +92,21 @@ Implementation:
9192

9293
---
9394

95+
## Global rule: incomplete reasoning-only turns
96+
97+
Assistant turns that hit the provider output limit with only thinking or
98+
redacted-thinking content are omitted from the in-memory replay copy. Such turns
99+
contain incomplete provider state and may carry a partial thinking signature.
100+
101+
Empty length turns remain unchanged, as do length turns with visible text, tool
102+
calls, or unknown content blocks. Stored transcripts are not rewritten.
103+
104+
Implementation:
105+
106+
- `normalizeAssistantReplayContent` in `src/agents/embedded-agent-runner/replay-history.ts`
107+
108+
---
109+
94110
## Global rule: inter-session input provenance
95111

96112
When an agent sends a prompt into another session via `sessions_send` (including

src/agents/embedded-agent-runner/replay-history.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,79 @@ describe("normalizeAssistantReplayContent", () => {
154154
expect(out[2]).toBe(length);
155155
});
156156

157+
it("drops reasoning-only length turns before provider replay", () => {
158+
const reasoningOnly = bedrockAssistant(
159+
[
160+
{
161+
type: "thinking",
162+
thinking: "partial hidden reasoning",
163+
thinkingSignature: "partial-signature",
164+
},
165+
{ type: "text", text: " " },
166+
],
167+
"length",
168+
{ output: 42, totalTokens: 42 },
169+
);
170+
const messages = [userMessage("before"), reasoningOnly, userMessage("continue")];
171+
172+
const out = normalizeAssistantReplayContent(messages);
173+
174+
expect(out).toEqual([messages[0], messages[2]]);
175+
expect(JSON.stringify(out)).not.toContain("partial-signature");
176+
});
177+
178+
it("drops length turns that become reasoning-only after content normalization", () => {
179+
const messages = [
180+
userMessage("before"),
181+
bedrockAssistant(
182+
[
183+
{
184+
type: "thinking",
185+
thinking: "partial hidden reasoning",
186+
thinkingSignature: "partial-signature",
187+
},
188+
{ type: "text", text: "NO_REPLY" },
189+
],
190+
"length",
191+
),
192+
{
193+
...bedrockAssistant([], "length"),
194+
content: {
195+
type: "thinking",
196+
thinking: "partial object reasoning",
197+
thinkingSignature: "partial-object-signature",
198+
},
199+
},
200+
userMessage("continue"),
201+
] as AgentMessage[];
202+
203+
const out = normalizeAssistantReplayContent(messages);
204+
205+
expect(out).toEqual([messages[0], messages[3]]);
206+
});
207+
208+
it("preserves length turns with visible text or tool calls", () => {
209+
const visible = bedrockAssistant(
210+
[
211+
{ type: "thinking", thinking: "partial reasoning", thinkingSignature: "sig_visible" },
212+
{ type: "text", text: "partial visible answer" },
213+
],
214+
"length",
215+
);
216+
const toolCall = bedrockAssistant(
217+
[
218+
{ type: "thinking", thinking: "partial reasoning", thinkingSignature: "sig_tool" },
219+
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
220+
],
221+
"length",
222+
);
223+
const messages = [userMessage("before"), visible, toolCall, userMessage("continue")];
224+
225+
const out = normalizeAssistantReplayContent(messages);
226+
227+
expect(out).toBe(messages);
228+
});
229+
157230
it("wraps legacy string assistant content as a single text block (regression)", () => {
158231
const messages = [userMessage("hi"), bedrockAssistant("plain string content")];
159232
const out = normalizeAssistantReplayContent(messages);

src/agents/embedded-agent-runner/replay-history.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
validateGeminiTurns,
3030
} from "../embedded-agent-helpers.js";
3131
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
32+
import { isReasoningOnlyLengthAssistantTurn } from "../replay-turn-classification.js";
3233
import type { AgentMessage } from "../runtime/index.js";
3334
import {
3435
sanitizeToolCallInputs,
@@ -361,13 +362,20 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent
361362
if (Array.isArray(replayContent)) {
362363
const normalized = normalizeAssistantReplayBlockContent(assistantMessage, replayContent);
363364
if (normalized !== assistantMessage) {
364-
if (normalized) {
365-
out.push(normalized);
366-
}
367365
touched = true;
368-
continue;
366+
if (!normalized) {
367+
continue;
368+
}
369+
assistantMessage = normalized as AssistantReplayMessage;
370+
replayContent = assistantMessage.content;
369371
}
370372
}
373+
if (isReasoningOnlyLengthAssistantTurn(assistantMessage)) {
374+
// Token-limited thinking is incomplete provider state. Replaying it can
375+
// resend a partial signature, while visible text or tool calls remain useful.
376+
touched = true;
377+
continue;
378+
}
371379
if (Array.isArray(replayContent) && replayContent.length === 0) {
372380
// An assistant turn can legitimately end with `content: []` — for
373381
// example the silent-reply / NO_REPLY path locked in by
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
type AssistantTurnLike = {
2+
role?: unknown;
3+
stopReason?: unknown;
4+
content?: unknown;
5+
};
6+
7+
/** Returns true when a token-limited turn contains only incomplete provider reasoning. */
8+
export function isReasoningOnlyLengthAssistantTurn(message: AssistantTurnLike): boolean {
9+
if (message.role !== "assistant" || message.stopReason !== "length") {
10+
return false;
11+
}
12+
const content = Array.isArray(message.content)
13+
? message.content
14+
: message.content != null && typeof message.content === "object"
15+
? [message.content]
16+
: [];
17+
let hasThinking = false;
18+
for (const block of content) {
19+
if (!block || typeof block !== "object") {
20+
return false;
21+
}
22+
const record = block as { type?: unknown; text?: unknown };
23+
if (record.type === "thinking" || record.type === "redacted_thinking") {
24+
hasThinking = true;
25+
continue;
26+
}
27+
if (record.type === "text" && typeof record.text === "string" && !record.text.trim()) {
28+
continue;
29+
}
30+
return false;
31+
}
32+
return hasThinking;
33+
}

src/agents/tool-replay-repair.live.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,34 @@ type TargetModelRef = {
3636
modelId: string;
3737
};
3838

39+
function createDirectTargetModel(target: TargetModelRef): Model | null {
40+
const common = {
41+
id: target.modelId,
42+
name: target.modelId,
43+
provider: target.provider,
44+
reasoning: true,
45+
input: ["text"] as Model["input"],
46+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
47+
contextWindow: 200_000,
48+
maxTokens: 8_192,
49+
};
50+
if (target.provider === "openai") {
51+
return {
52+
...common,
53+
api: "openai-responses",
54+
baseUrl: "https://api.openai.com/v1",
55+
};
56+
}
57+
if (target.provider === "anthropic") {
58+
return {
59+
...common,
60+
api: "anthropic-messages",
61+
baseUrl: "https://api.anthropic.com",
62+
};
63+
}
64+
return null;
65+
}
66+
3967
function parseTargetModelRefs(raw: string | undefined): TargetModelRef[] {
4068
const refs: TargetModelRef[] = [];
4169
for (const item of (raw ?? "").split(",")) {
@@ -102,6 +130,21 @@ function buildReplayMessages(model: Model): AgentMessage[] {
102130
};
103131

104132
return [
133+
{
134+
role: "assistant",
135+
provider: source.provider,
136+
api: source.api,
137+
model: source.model,
138+
stopReason: "length",
139+
timestamp: now - 1,
140+
content: [
141+
{
142+
type: "thinking",
143+
thinking: "partial hidden reasoning",
144+
thinkingSignature: "partial-signature",
145+
},
146+
],
147+
},
105148
{
106149
role: "user",
107150
content: "Use noop.",
@@ -205,7 +248,9 @@ describeLive("tool replay repair live", () => {
205248
const agentDir = resolveDefaultAgentDir(cfg);
206249
const authStorage = discoverAuthStorage(agentDir);
207250
const modelRegistry = discoverModels(authStorage, agentDir);
208-
const model = modelRegistry.find(target.provider, target.modelId) as Model | null;
251+
const model =
252+
(modelRegistry.find(target.provider, target.modelId) as Model | null) ??
253+
createDirectTargetModel(target);
209254

210255
if (!model) {
211256
logProgress(`[tool-replay-repair] model missing from registry: ${target.ref}`);
@@ -316,7 +361,9 @@ describeLive("tool replay repair live", () => {
316361
const agentDir = resolveDefaultAgentDir(cfg);
317362
const authStorage = discoverAuthStorage(agentDir);
318363
const modelRegistry = discoverModels(authStorage, agentDir);
319-
const model = modelRegistry.find(target.provider, target.modelId) as Model | null;
364+
const model =
365+
(modelRegistry.find(target.provider, target.modelId) as Model | null) ??
366+
createDirectTargetModel(target);
320367

321368
if (!model) {
322369
logProgress(`[tool-replay-repair] model missing from registry: ${target.ref}`);

src/agents/transport-message-transform.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,65 @@ describe("transformTransportMessages synthetic tool-result policy", () => {
444444
expect(JSON.stringify(result)).not.toContain("partial error output");
445445
});
446446

447+
it("drops max-token reasoning-only transport assistant turns before replay", () => {
448+
const messages: Context["messages"] = [
449+
{
450+
role: "assistant",
451+
provider: "amazon-bedrock",
452+
api: "bedrock-converse-stream",
453+
model: "global.anthropic.claude-sonnet-4-6",
454+
stopReason: "length",
455+
timestamp: Date.now(),
456+
content: [
457+
{
458+
type: "thinking",
459+
thinking: "partial hidden reasoning",
460+
thinkingSignature: "partial-signature",
461+
},
462+
],
463+
} as Extract<Context["messages"][number], { role: "assistant" }>,
464+
{ role: "user", content: "retry after max token thinking", timestamp: Date.now() },
465+
];
466+
467+
const result = transformTransportMessages(
468+
messages,
469+
makeModel(
470+
"bedrock-converse-stream" as Api,
471+
"amazon-bedrock",
472+
"global.anthropic.claude-sonnet-4-6",
473+
),
474+
);
475+
476+
expect(result.map((msg) => msg.role)).toEqual(["user"]);
477+
expect(JSON.stringify(result)).not.toContain("partial-signature");
478+
});
479+
480+
it("keeps max-token transport turns with visible or tool content", () => {
481+
const messages: Context["messages"] = [
482+
{
483+
role: "assistant",
484+
provider: "anthropic",
485+
api: "anthropic-messages",
486+
model: "claude-sonnet-4-6",
487+
stopReason: "length",
488+
timestamp: Date.now(),
489+
content: [
490+
{ type: "thinking", thinking: "partial", thinkingSignature: "sig-visible" },
491+
{ type: "text", text: "partial visible answer" },
492+
],
493+
},
494+
assistantToolCall("call_length", "exec", "length"),
495+
] as Context["messages"];
496+
497+
const result = transformTransportMessages(
498+
messages,
499+
makeModel("anthropic-messages", "anthropic", "claude-sonnet-4-6"),
500+
);
501+
502+
expect(result[0]).toMatchObject({ role: "assistant", stopReason: "length" });
503+
expect(result[1]).toMatchObject({ role: "assistant", stopReason: "length" });
504+
});
505+
447506
it("drops errored Anthropic transport assistant tool calls and matching results before replay", () => {
448507
const messages: Context["messages"] = [
449508
assistantToolCall("call_error", "exec", "error"),

src/agents/transport-message-transform.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import type { Api, Context, Model } from "../llm/types.js";
77
import { resolveModelBoundThinkingReplayMode } from "../shared/anthropic-model-contract.js";
8+
import { isReasoningOnlyLengthAssistantTurn } from "./replay-turn-classification.js";
89
import { repairToolUseResultPairing } from "./session-transcript-repair.js";
910

1011
const SYNTHETIC_TOOL_RESULT_APIS = new Set<string>([
@@ -40,7 +41,11 @@ function isFailedAssistantTurn(message: Context["messages"][number]): boolean {
4041
if (message.role !== "assistant") {
4142
return false;
4243
}
43-
return message.stopReason === "error" || message.stopReason === "aborted";
44+
return (
45+
message.stopReason === "error" ||
46+
message.stopReason === "aborted" ||
47+
isReasoningOnlyLengthAssistantTurn(message)
48+
);
4449
}
4550

4651
/** Transforms transcript messages into a provider-safe replay context. */
@@ -153,7 +158,10 @@ export function transformTransportMessages(
153158
// Preserve the old transport replay filter: failed streamed turns can contain
154159
// partial text, partial tool calls, or both, and strict providers can treat
155160
// them as valid assistant context on retry unless we drop the whole turn.
156-
const replayable = transformed.filter((msg) => !isFailedAssistantTurn(msg));
161+
const replayable = transformed.filter((_, index) => {
162+
const original = messages[index];
163+
return original ? !isFailedAssistantTurn(original) : true;
164+
});
157165

158166
if (!allowSyntheticToolResults) {
159167
return replayable;

0 commit comments

Comments
 (0)