mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 19:51:11 +00:00
fix(btw): strip embedded tool blocks from side-question context
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Message, { role: "assistant" }>,
|
||||
): Extract<Message, { role: "assistant" }> | 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<string>,
|
||||
): 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<Message, { role: "user" }>,
|
||||
): Extract<Message, { role: "user" }> | 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<Message, { role: "user" }>["content"],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeBtwAssistantMessage(
|
||||
message: Extract<Message, { role: "assistant" }>,
|
||||
): Extract<Message, { role: "assistant" }> | 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<Message, { role: "assistant" }>["content"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,13 +159,16 @@ function toSimpleContextMessages(messages: unknown[]): Message[] {
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
if (role === "user") {
|
||||
return [message as Extract<Message, { role: "user" }>];
|
||||
const sanitizedMessage = sanitizeBtwUserMessage(
|
||||
message as Extract<Message, { role: "user" }>,
|
||||
);
|
||||
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<Message, { role: "assistant" }>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user