fix: strip reasoning tags from messaging tool text to prevent <think> leakage

This commit is contained in:
MEA
2026-02-07 18:16:17 +08:00
committed by Gustavo Madeira Santana
parent 5fab11198d
commit bb6ebc1eab
2 changed files with 86 additions and 1 deletions

View File

@@ -162,6 +162,80 @@ describe("message tool description", () => {
});
});
describe("message tool reasoning tag sanitization", () => {
it("strips <think> tags from text field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
const tool = createMessageTool({ config: {} as never });
await tool.execute("1", {
action: "send",
target: "signal:+15551234567",
text: "<think>internal reasoning</think>Hello!",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.text).toBe("Hello!");
});
it("strips <think> tags from content field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "discord",
to: "discord:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
const tool = createMessageTool({ config: {} as never });
await tool.execute("1", {
action: "send",
target: "discord:123",
content: "<think>reasoning here</think>Reply text",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.content).toBe("Reply text");
});
it("passes through text without reasoning tags unchanged", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
const tool = createMessageTool({ config: {} as never });
await tool.execute("1", {
action: "send",
target: "signal:+15551234567",
text: "Normal message without any tags",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.text).toBe("Normal message without any tags");
});
});
describe("message tool sandbox passthrough", () => {
it("forwards sandboxRoot to runMessageAction", async () => {
mocks.runMessageAction.mockClear();

View File

@@ -16,6 +16,7 @@ import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { listChannelSupportedActions } from "../channel-tools.js";
@@ -405,7 +406,17 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
err.name = "AbortError";
throw err;
}
const params = args as Record<string, unknown>;
// Shallow-copy so we don't mutate the original event args (used for logging/dedup).
const params = { ...(args as Record<string, unknown>) };
// Strip reasoning tags from text fields — models may include <think>…</think>
// in tool arguments, and the messaging tool send path has no other tag filtering.
for (const field of ["text", "content", "message", "caption"]) {
if (typeof params[field] === "string") {
params[field] = stripReasoningTagsFromText(params[field] as string);
}
}
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", {
required: true,