Skip to content

Commit 098bf4b

Browse files
committed
fix(agents): drop incomplete reasoning replay turns
1 parent 979b0e4 commit 098bf4b

7 files changed

Lines changed: 207 additions & 38 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: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -477,31 +477,30 @@ describe("transformTransportMessages synthetic tool-result policy", () => {
477477
expect(JSON.stringify(result)).not.toContain("partial-signature");
478478
});
479479

480-
it("keeps max-token assistant turns that have visible text before replay", () => {
480+
it("keeps max-token transport turns with visible or tool content", () => {
481481
const messages: Context["messages"] = [
482482
{
483483
role: "assistant",
484-
provider: "amazon-bedrock",
485-
api: "bedrock-converse-stream",
486-
model: "global.anthropic.claude-sonnet-4-6",
484+
provider: "anthropic",
485+
api: "anthropic-messages",
486+
model: "claude-sonnet-4-6",
487487
stopReason: "length",
488488
timestamp: Date.now(),
489-
content: [{ type: "text", text: "partial visible answer" }],
490-
} as Extract<Context["messages"][number], { role: "assistant" }>,
491-
{ role: "user", content: "continue", timestamp: Date.now() },
492-
];
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"];
493496

494497
const result = transformTransportMessages(
495498
messages,
496-
makeModel(
497-
"bedrock-converse-stream" as Api,
498-
"amazon-bedrock",
499-
"global.anthropic.claude-sonnet-4-6",
500-
),
499+
makeModel("anthropic-messages", "anthropic", "claude-sonnet-4-6"),
501500
);
502501

503-
expect(result.map((msg) => msg.role)).toEqual(["assistant", "user"]);
504-
expect(JSON.stringify(result)).toContain("partial visible answer");
502+
expect(result[0]).toMatchObject({ role: "assistant", stopReason: "length" });
503+
expect(result[1]).toMatchObject({ role: "assistant", stopReason: "length" });
505504
});
506505

507506
it("drops errored Anthropic transport assistant tool calls and matching results before replay", () => {

src/agents/transport-message-transform.ts

Lines changed: 10 additions & 17 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,22 +41,11 @@ function isFailedAssistantTurn(message: Context["messages"][number]): boolean {
4041
if (message.role !== "assistant") {
4142
return false;
4243
}
43-
if (message.stopReason === "error" || message.stopReason === "aborted") {
44-
return true;
45-
}
46-
if (message.stopReason !== "length") {
47-
return false;
48-
}
49-
const content = Array.isArray(message.content) ? message.content : [];
50-
return !content.some((block) => {
51-
if (block.type === "toolCall") {
52-
return true;
53-
}
54-
if (block.type === "text") {
55-
return block.text.trim().length > 0;
56-
}
57-
return false;
58-
});
44+
return (
45+
message.stopReason === "error" ||
46+
message.stopReason === "aborted" ||
47+
isReasoningOnlyLengthAssistantTurn(message)
48+
);
5949
}
6050

6151
/** Transforms transcript messages into a provider-safe replay context. */
@@ -168,7 +158,10 @@ export function transformTransportMessages(
168158
// Preserve the old transport replay filter: failed streamed turns can contain
169159
// partial text, partial tool calls, or both, and strict providers can treat
170160
// them as valid assistant context on retry unless we drop the whole turn.
171-
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+
});
172165

173166
if (!allowSyntheticToolResults) {
174167
return replayable;

0 commit comments

Comments
 (0)