Merge pull request #688 from theglove44/fix/thinking-blocks-leak
fix(agents): strip <thought> and <antthinking> tags from output
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Agents: strip `<thought>`/`<antthinking>` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.
|
||||||
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
|
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
|
||||||
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
|
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
|
||||||
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
|
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ describe("gateway tool", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
const stateDir = await fs.mkdtemp(
|
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||||
path.join(os.tmpdir(), "clawdbot-test-"),
|
|
||||||
);
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ type StubSession = {
|
|||||||
type SessionEventHandler = (evt: unknown) => void;
|
type SessionEventHandler = (evt: unknown) => void;
|
||||||
|
|
||||||
describe("subscribeEmbeddedPiSession", () => {
|
describe("subscribeEmbeddedPiSession", () => {
|
||||||
|
const THINKING_TAG_CASES = [
|
||||||
|
{ tag: "think", open: "<think>", close: "</think>" },
|
||||||
|
{ tag: "thinking", open: "<thinking>", close: "</thinking>" },
|
||||||
|
{ tag: "thought", open: "<thought>", close: "</thought>" },
|
||||||
|
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
||||||
|
] as const;
|
||||||
it("filters to <final> and falls back when tags are malformed", () => {
|
it("filters to <final> and falls back when tags are malformed", () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
@@ -167,7 +173,12 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
expect(onBlockReply.mock.calls[1][0].text).toBe("Final answer");
|
expect(onBlockReply.mock.calls[1][0].text).toBe("Final answer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("promotes <think> tags to thinking blocks at write-time", () => {
|
it.each(
|
||||||
|
THINKING_TAG_CASES,
|
||||||
|
)("promotes <%s> tags to thinking blocks at write-time", ({
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}) => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -193,7 +204,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
|
text: `${open}\nBecause it helps\n${close}\n\nFinal answer`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as AssistantMessage;
|
} as AssistantMessage;
|
||||||
@@ -212,7 +223,12 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams <think> reasoning via onReasoningStream without leaking into final text", () => {
|
it.each(
|
||||||
|
THINKING_TAG_CASES,
|
||||||
|
)("streams <%s> reasoning via onReasoningStream without leaking into final text", ({
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}) => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -240,7 +256,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
message: { role: "assistant" },
|
message: { role: "assistant" },
|
||||||
assistantMessageEvent: {
|
assistantMessageEvent: {
|
||||||
type: "text_delta",
|
type: "text_delta",
|
||||||
delta: "<think>\nBecause",
|
delta: `${open}\nBecause`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,7 +265,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
message: { role: "assistant" },
|
message: { role: "assistant" },
|
||||||
assistantMessageEvent: {
|
assistantMessageEvent: {
|
||||||
type: "text_delta",
|
type: "text_delta",
|
||||||
delta: " it helps\n</think>\n\nFinal answer",
|
delta: ` it helps\n${close}\n\nFinal answer`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +274,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
|
text: `${open}\nBecause it helps\n${close}\n\nFinal answer`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as AssistantMessage;
|
} as AssistantMessage;
|
||||||
@@ -279,10 +295,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each(
|
||||||
{ tag: "think", open: "<think>", close: "</think>" },
|
THINKING_TAG_CASES,
|
||||||
{ tag: "thinking", open: "<thinking>", close: "</thinking>" },
|
)("suppresses <%s> blocks across chunk boundaries", ({ open, close }) => {
|
||||||
])("suppresses <%s> blocks across chunk boundaries", ({ open, close }) => {
|
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ import {
|
|||||||
promoteThinkingTagsToBlocks,
|
promoteThinkingTagsToBlocks,
|
||||||
} from "./pi-embedded-utils.js";
|
} from "./pi-embedded-utils.js";
|
||||||
|
|
||||||
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||||
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
const THINKING_OPEN_RE = /<\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
|
||||||
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
const THINKING_CLOSE_RE = /<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
|
||||||
const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
|
const THINKING_TAG_SCAN_RE =
|
||||||
|
/<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||||
const log = createSubsystemLogger("agent/embedded");
|
const log = createSubsystemLogger("agent/embedded");
|
||||||
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
|
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ export function splitThinkingTaggedText(
|
|||||||
// with a think tag (common for local/OpenAI-compat providers that emulate
|
// with a think tag (common for local/OpenAI-compat providers that emulate
|
||||||
// reasoning blocks via tags).
|
// reasoning blocks via tags).
|
||||||
if (!trimmedStart.startsWith("<")) return null;
|
if (!trimmedStart.startsWith("<")) return null;
|
||||||
const openRe = /<\s*think(?:ing)?\s*>/i;
|
const openRe = /<\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
|
||||||
const closeRe = /<\s*\/\s*think(?:ing)?\s*>/i;
|
const closeRe = /<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
|
||||||
if (!openRe.test(trimmedStart)) return null;
|
if (!openRe.test(trimmedStart)) return null;
|
||||||
if (!closeRe.test(text)) return null;
|
if (!closeRe.test(text)) return null;
|
||||||
|
|
||||||
const scanRe = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
|
const scanRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||||
let inThinking = false;
|
let inThinking = false;
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let thinkingStart = 0;
|
let thinkingStart = 0;
|
||||||
@@ -136,7 +136,7 @@ export function promoteThinkingTagsToBlocks(message: AssistantMessage): void {
|
|||||||
|
|
||||||
export function extractThinkingFromTaggedText(text: string): string {
|
export function extractThinkingFromTaggedText(text: string): string {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
const scanRe = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
|
const scanRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||||
let result = "";
|
let result = "";
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let inThinking = false;
|
let inThinking = false;
|
||||||
@@ -157,8 +157,8 @@ export function extractThinkingFromTaggedStream(text: string): string {
|
|||||||
const closed = extractThinkingFromTaggedText(text);
|
const closed = extractThinkingFromTaggedText(text);
|
||||||
if (closed) return closed;
|
if (closed) return closed;
|
||||||
|
|
||||||
const openRe = /<\s*think(?:ing)?\s*>/gi;
|
const openRe = /<\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||||
const closeRe = /<\s*\/\s*think(?:ing)?\s*>/gi;
|
const closeRe = /<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||||
const openMatches = [...text.matchAll(openRe)];
|
const openMatches = [...text.matchAll(openRe)];
|
||||||
if (openMatches.length === 0) return "";
|
if (openMatches.length === 0) return "";
|
||||||
const closeMatches = [...text.matchAll(closeRe)];
|
const closeMatches = [...text.matchAll(closeRe)];
|
||||||
|
|||||||
@@ -165,9 +165,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await doctorCommand(defaultRuntime, { nonInteractive: true });
|
await doctorCommand(defaultRuntime, { nonInteractive: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||||
theme.warn(`Doctor failed: ${String(err)}`),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
|
delete process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user