diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 623b2fc0b552..b9b3a5fe5868 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -597,6 +597,7 @@ describe("runBtwSideQuestion", () => { { type: "text", text: "Let me check." }, { type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }, { type: "toolUse", id: "call_legacy", name: "read", input: { path: "README.md" } }, + { type: "tool_call", id: "call_snake", name: "read", arguments: { path: "README.md" } }, ], provider: DEFAULT_PROVIDER, api: "anthropic-messages", @@ -636,9 +637,132 @@ describe("runBtwSideQuestion", () => { expect.arrayContaining([ expect.objectContaining({ role: "assistant", - content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]), + content: expect.arrayContaining([ + expect.objectContaining({ type: "toolCall" }), + expect.objectContaining({ type: "toolUse" }), + expect.objectContaining({ type: "tool_call" }), + ]), }), ]), ); }); + + it("drops assistant messages that contain only tool calls", async () => { + getActiveEmbeddedRunSnapshotMock.mockReturnValue({ + transcriptLeafId: "assistant-1", + messages: [ + { + role: "user", + content: [{ type: "text", text: "seed" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + provider: DEFAULT_PROVIDER, + api: "anthropic-messages", + model: DEFAULT_MODEL, + stopReason: "toolUse", + usage: { + input: 1, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 2, + }, + ], + }); + mockDoneAnswer(MATH_ANSWER); + + await runMathSideQuestion(); + + const [, context] = streamSimpleMock.mock.calls[0] ?? []; + expect( + (context as { messages?: Array<{ role?: string }> }).messages?.filter( + (message) => message.role === "assistant", + ), + ).toHaveLength(0); + }); + + it("strips embedded user tool results from BTW context", async () => { + getActiveEmbeddedRunSnapshotMock.mockReturnValue({ + transcriptLeafId: "assistant-1", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "seed" }, + { + type: "toolResult", + toolUseId: "call_1", + content: [{ type: "text", text: "secret" }], + }, + { + type: "tool_result", + toolUseId: "call_2", + content: [{ type: "text", text: "secret-2" }], + }, + ], + timestamp: 1, + }, + ], + }); + mockDoneAnswer(MATH_ANSWER); + + await runMathSideQuestion(); + + const [, context] = streamSimpleMock.mock.calls[0] ?? []; + expect(context).toMatchObject({ + messages: [ + expect.objectContaining({ + role: "user", + content: [{ type: "text", text: "seed" }], + }), + expect.objectContaining({ role: "user" }), + ], + }); + }); + + it("normalizes malformed assistant content before stripping tool blocks", async () => { + getActiveEmbeddedRunSnapshotMock.mockReturnValue({ + transcriptLeafId: "assistant-1", + messages: [ + { + role: "user", + content: [{ type: "text", text: "seed" }], + timestamp: 1, + }, + { + role: "assistant", + content: { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + provider: DEFAULT_PROVIDER, + api: "anthropic-messages", + model: DEFAULT_MODEL, + stopReason: "toolUse", + usage: { + input: 1, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 2, + }, + ], + }); + mockDoneAnswer(MATH_ANSWER); + + await runMathSideQuestion(); + + const [, context] = streamSimpleMock.mock.calls[0] ?? []; + expect( + (context as { messages?: Array<{ role?: string }> }).messages?.filter( + (message) => message.role === "assistant", + ), + ).toHaveLength(0); + }); }); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 957b1549b7a9..661f38f8ed06 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -15,6 +15,7 @@ import { type SessionEntry, } from "../config/sessions.js"; import { diagnosticLogger as diag } from "../logging/diagnostic.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -83,27 +84,71 @@ function buildBtwQuestionPrompt(question: string, inFlightPrompt?: string): stri return lines.join("\n"); } -const BTW_TOOL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); +const BTW_ALLOWED_USER_BLOCK_TYPES = new Set(["text", "image"]); +const BTW_ALLOWED_ASSISTANT_BLOCK_TYPES = new Set(["text", "thinking"]); -function sanitizeBtwAssistantMessage( - message: Extract, -): Extract | undefined { - const originalContent = Array.isArray(message.content) ? message.content : []; - const content = originalContent.filter((block) => { +function normalizeBtwContentBlocks(content: unknown): unknown[] | undefined { + if (Array.isArray(content)) { + return content; + } + if (content && typeof content === "object") { + return [content]; + } + return undefined; +} + +function sanitizeBtwContentBlocks( + content: unknown, + allowedTypes: Set, +): unknown[] | undefined { + const blocks = normalizeBtwContentBlocks(content); + if (!blocks) { + return undefined; + } + const sanitizedBlocks = blocks.filter((block) => { if (!block || typeof block !== "object") { - return true; + return false; } - return !BTW_TOOL_BLOCK_TYPES.has((block as { type?: unknown }).type as string); + return allowedTypes.has(normalizeLowercaseStringOrEmpty((block as { type?: unknown }).type)); }); - if (content.length === originalContent.length) { + return sanitizedBlocks.length > 0 ? sanitizedBlocks : undefined; +} + +function sanitizeBtwUserMessage( + message: Extract, +): Extract | undefined { + if (typeof message.content === "string") { return message; } - if (content.length === 0) { + const content = sanitizeBtwContentBlocks(message.content, BTW_ALLOWED_USER_BLOCK_TYPES); + if (!content) { return undefined; } return { ...message, - content, + content: content as Extract["content"], + }; +} + +function sanitizeBtwAssistantMessage( + message: Extract, +): Extract | undefined { + const rawContent = (message as { content?: unknown }).content; + if (typeof rawContent === "string") { + return rawContent.trim().length > 0 + ? { + ...message, + content: [{ type: "text", text: rawContent }], + } + : undefined; + } + const content = sanitizeBtwContentBlocks(rawContent, BTW_ALLOWED_ASSISTANT_BLOCK_TYPES); + if (!content) { + return undefined; + } + return { + ...message, + content: content as Extract["content"], }; } @@ -114,13 +159,16 @@ function toSimpleContextMessages(messages: unknown[]): Message[] { } const role = (message as { role?: unknown }).role; if (role === "user") { - return [message as Extract]; + const sanitizedMessage = sanitizeBtwUserMessage( + message as Extract, + ); + return sanitizedMessage ? [sanitizedMessage] : []; } if (role !== "assistant") { return []; } - // BTW is a no-tools path, so strip replay-only tool calls from assistant - // context before handing history to strict providers like Bedrock. + // BTW is a no-tools path, so keep only user/assistant blocks that remain + // readable without replaying tool calls or tool results. const sanitizedMessage = sanitizeBtwAssistantMessage( message as Extract, );