diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cbc28efe2..eaf48b30b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - CLI/update: keep the automatic post-update completion refresh on the core-command tree so it no longer stages bundled plugin runtime deps before the Gateway restart path, avoiding `.24` update hangs and 1006 disconnect cascades. Fixes #72665. Thanks @sakalaboator and @He-Pin. - Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock `ContentBlock` blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu. +- Agents/LM Studio: promote standalone bracketed local-model tool requests into registered tool calls and hide unsupported bracket blocks from visible replies, so MemPalace MCP lookups do not print raw `[tool]` JSON scaffolding in chat. Fixes #66178. Thanks @detroit357. - Agents/LM Studio: strip prior-turn Gemma 4 reasoning from OpenAI-compatible replay while preserving active tool-call continuation reasoning. Fixes #68704. Thanks @chip-snomo and @Kailigithub. - LM Studio: allow interactive onboarding to leave the API key blank for unauthenticated local servers, using local synthetic auth while clearing stale LM Studio auth profiles. Fixes #66937. Thanks @olamedia. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index 338b70f2296..2967a39ed50 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -164,6 +164,11 @@ Compatibility notes for stricter OpenAI-compatible backends: structured content-part arrays. Set `models.providers..models[].compat.requiresStringContent: true` for those endpoints. +- Some local models emit standalone bracketed tool requests as text, such as + `[tool_name]` followed by JSON and `[END_TOOL_REQUEST]`. OpenClaw promotes + those into real tool calls only when the name exactly matches a registered + tool for the turn; otherwise the block is treated as unsupported text and is + hidden from user-visible replies. - Some smaller or stricter local backends are unstable with OpenClaw's full agent-runtime prompt shape, especially when tool schemas are included. If the backend works for tiny direct `/v1/chat/completions` calls but fails on normal diff --git a/extensions/lmstudio/src/plain-text-tool-calls.ts b/extensions/lmstudio/src/plain-text-tool-calls.ts new file mode 100644 index 00000000000..124a38e407c --- /dev/null +++ b/extensions/lmstudio/src/plain-text-tool-calls.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; + +export type LmstudioPlainTextToolCallBlock = { + arguments: Record; + name: string; +}; + +const END_TOOL_REQUEST = "[END_TOOL_REQUEST]"; +const MAX_PAYLOAD_CHARS = 256_000; + +function isToolNameChar(char: string | undefined): boolean { + return Boolean(char && /[A-Za-z0-9_-]/.test(char)); +} + +function skipHorizontalWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && (text[index] === " " || text[index] === "\t")) { + index += 1; + } + return index; +} + +function skipWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && /\s/.test(text[index] ?? "")) { + index += 1; + } + return index; +} + +function consumeLineBreak(text: string, start: number): number | null { + if (text[start] === "\r") { + return text[start + 1] === "\n" ? start + 2 : start + 1; + } + if (text[start] === "\n") { + return start + 1; + } + return null; +} + +function parseOpening(text: string, start: number): { end: number; name: string } | null { + if (text[start] !== "[") { + return null; + } + let cursor = start + 1; + const nameStart = cursor; + while (isToolNameChar(text[cursor])) { + cursor += 1; + } + if (cursor === nameStart || text[cursor] !== "]") { + return null; + } + const name = text.slice(nameStart, cursor); + cursor += 1; + cursor = skipHorizontalWhitespace(text, cursor); + const afterLineBreak = consumeLineBreak(text, cursor); + if (afterLineBreak === null) { + return null; + } + return { end: afterLineBreak, name }; +} + +function consumeJsonObject( + text: string, + start: number, +): { end: number; value: Record } | null { + const cursor = skipWhitespace(text, start); + if (text[cursor] !== "{") { + return null; + } + let depth = 0; + let inString = false; + let escaped = false; + for (let index = cursor; index < text.length; index += 1) { + if (index + 1 - cursor > MAX_PAYLOAD_CHARS) { + return null; + } + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + try { + const parsed = JSON.parse(text.slice(cursor, index + 1)) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return { end: index + 1, value: parsed as Record }; + } catch { + return null; + } + } + } + } + return null; +} + +function parseClosing(text: string, start: number, name: string): number | null { + const cursor = skipWhitespace(text, start); + if (text.startsWith(END_TOOL_REQUEST, cursor)) { + return cursor + END_TOOL_REQUEST.length; + } + const namedClosing = `[/${name}]`; + if (text.startsWith(namedClosing, cursor)) { + return cursor + namedClosing.length; + } + return null; +} + +function parseBlockAt( + text: string, + start: number, + allowedToolNames: Set, +): { block: LmstudioPlainTextToolCallBlock; end: number } | null { + const opening = parseOpening(text, start); + if (!opening || !allowedToolNames.has(opening.name)) { + return null; + } + const payload = consumeJsonObject(text, opening.end); + if (!payload) { + return null; + } + const end = parseClosing(text, payload.end, opening.name); + if (end === null) { + return null; + } + return { + block: { arguments: payload.value, name: opening.name }, + end, + }; +} + +export function parseLmstudioPlainTextToolCalls( + text: string, + allowedToolNames: Set, +): LmstudioPlainTextToolCallBlock[] | null { + const blocks: LmstudioPlainTextToolCallBlock[] = []; + let cursor = skipWhitespace(text, 0); + while (cursor < text.length) { + const parsed = parseBlockAt(text, cursor, allowedToolNames); + if (!parsed) { + return null; + } + blocks.push(parsed.block); + cursor = skipWhitespace(text, parsed.end); + } + return blocks.length > 0 ? blocks : null; +} + +export function createLmstudioSyntheticToolCallId(): string { + return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`; +} diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index 7c61a65721f..a82e7c0b730 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -28,7 +28,7 @@ vi.mock("./runtime.js", async (importOriginal) => { }; }); -type StreamEvent = { type: string }; +type StreamEvent = { type: string } & Record; async function collectEvents(stream: ReturnType): Promise { const resolved = stream instanceof Promise ? await stream : stream; @@ -50,6 +50,19 @@ function buildDoneStreamFn(): StreamFn { }); } +function buildEventStreamFn(events: unknown[]): StreamFn { + return vi.fn((_model, _context, _options) => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + for (const event of events) { + stream.push(event as never); + } + stream.end(); + }); + return stream; + }); +} + function createWrappedLmstudioStream( baseStream: StreamFn, params?: { baseUrl?: string }, @@ -75,6 +88,7 @@ function runWrappedLmstudioStream( wrapped: StreamFn, model: Record, options?: Record, + context?: Record, ) { return wrapped( { @@ -83,7 +97,7 @@ function runWrappedLmstudioStream( id: "lmstudio/qwen3-8b-instruct", ...model, } as never, - { messages: [] } as never, + { messages: [], ...context } as never, options as never, ); } @@ -400,4 +414,99 @@ describe("lmstudio stream wrapper", () => { undefined, ); }); + + it("promotes standalone bracketed local-model tool text to a structured tool call", async () => { + const rawToolText = [ + "[mempalace_mempalace_search]", + '{"query":"codename","wing":"personal","room":"identities"}', + "[END_TOOL_REQUEST]", + ].join("\n"); + const baseStream = buildEventStreamFn([ + { type: "start", partial: { content: [] } }, + { type: "text_start", contentIndex: 0, partial: { content: [{ type: "text", text: "" }] } }, + { type: "text_delta", contentIndex: 0, delta: rawToolText }, + { type: "text_end", contentIndex: 0, content: rawToolText }, + { + type: "done", + reason: "stop", + message: { + role: "assistant", + content: [{ type: "text", text: rawToolText }], + stopReason: "stop", + }, + }, + ]); + const wrapped = createWrappedLmstudioStream(baseStream); + const events = await collectEvents( + runWrappedLmstudioStream(wrapped, {}, undefined, { + tools: [ + { + name: "mempalace_mempalace_search", + description: "Search MemPalace", + parameters: { type: "object", properties: {} }, + }, + ], + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "done", + ]); + expect(events.some((event) => event.type === "text_delta")).toBe(false); + const done = events.find((event) => event.type === "done") as { + message?: { content?: Array>; stopReason?: string }; + reason?: string; + }; + expect(done.reason).toBe("toolUse"); + expect(done.message?.stopReason).toBe("toolUse"); + expect(done.message?.content?.[0]).toMatchObject({ + type: "toolCall", + name: "mempalace_mempalace_search", + arguments: { query: "codename", wing: "personal", room: "identities" }, + }); + expect(String(done.message?.content?.[0]?.id)).toMatch(/^call_[a-f0-9]{24}$/); + }); + + it("passes through bracketed text when the tool is not registered", async () => { + const rawToolText = [ + "[mempalace_mempalace_search]", + '{"query":"codename"}', + "[/mempalace_mempalace_search]", + ].join("\n"); + const baseStream = buildEventStreamFn([ + { type: "start", partial: { content: [] } }, + { type: "text_start", contentIndex: 0, partial: { content: [{ type: "text", text: "" }] } }, + { type: "text_delta", contentIndex: 0, delta: rawToolText }, + { type: "text_end", contentIndex: 0, content: rawToolText }, + { + type: "done", + reason: "stop", + message: { + role: "assistant", + content: [{ type: "text", text: rawToolText }], + stopReason: "stop", + }, + }, + ]); + const wrapped = createWrappedLmstudioStream(baseStream); + const events = await collectEvents( + runWrappedLmstudioStream(wrapped, {}, undefined, { + tools: [{ name: "read", description: "Read", parameters: { type: "object" } }], + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events.find((event) => event.type === "text_delta")).toMatchObject({ + delta: rawToolText, + }); + }); }); diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts index e0c70b7ca94..7631117e5a2 100644 --- a/extensions/lmstudio/src/stream.ts +++ b/extensions/lmstudio/src/stream.ts @@ -1,17 +1,22 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import { streamSimple } from "@mariozechner/pi-ai"; +import { createAssistantMessageEventStream, streamSimple } from "@mariozechner/pi-ai"; import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime"; import { LMSTUDIO_PROVIDER_ID } from "./defaults.js"; import { ensureLmstudioModelLoaded } from "./models.fetch.js"; import { resolveLmstudioInferenceBase } from "./models.js"; +import { + createLmstudioSyntheticToolCallId, + parseLmstudioPlainTextToolCalls, +} from "./plain-text-tool-calls.js"; import { resolveLmstudioProviderHeaders, resolveLmstudioRuntimeApiKey } from "./runtime.js"; const log = createSubsystemLogger("extensions/lmstudio/stream"); type StreamOptions = Parameters[2]; type StreamModel = Parameters[0]; +type StreamContext = Parameters[1]; const preloadInFlight = new Map>(); @@ -112,6 +117,215 @@ function resolveModelHeaders(model: StreamModel): Record | undef return model.headers; } +function toRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function resolveContextToolNames(context: StreamContext): Set { + const tools = (context as { tools?: unknown }).tools; + if (!Array.isArray(tools)) { + return new Set(); + } + const names = tools + .map((tool) => { + const record = toRecord(tool); + return typeof record?.name === "string" && record.name.trim() ? record.name : undefined; + }) + .filter((name): name is string => Boolean(name)); + return new Set(names); +} + +function couldStillBePlainTextToolCall(text: string): boolean { + if (text.length > 256_000) { + return false; + } + const trimmed = text.trimStart(); + return trimmed.length === 0 || trimmed.startsWith("["); +} + +function createLmstudioToolCallBlock(parsed: { + arguments: Record; + name: string; +}): Record { + return { + type: "toolCall", + id: createLmstudioSyntheticToolCallId(), + name: parsed.name, + arguments: parsed.arguments, + partialArgs: JSON.stringify(parsed.arguments), + }; +} + +function promoteLmstudioPlainTextToolCalls( + message: unknown, + toolNames: Set, +): Record | undefined { + const messageRecord = toRecord(message); + if (!messageRecord) { + return undefined; + } + if (!Array.isArray(messageRecord.content)) { + if (typeof messageRecord.content !== "string" || !messageRecord.content.trim()) { + return undefined; + } + const parsed = parseLmstudioPlainTextToolCalls(messageRecord.content, toolNames); + if (!parsed) { + return undefined; + } + return { + ...messageRecord, + content: parsed.map(createLmstudioToolCallBlock), + stopReason: "toolUse", + }; + } + if ( + messageRecord.content.some((block) => toRecord(block)?.type === "toolCall") || + messageRecord.content.length === 0 + ) { + return undefined; + } + + let promoted = false; + const nextContent: Array> = []; + for (const block of messageRecord.content) { + const blockRecord = toRecord(block); + if (!blockRecord) { + return undefined; + } + if (blockRecord.type !== "text") { + nextContent.push(blockRecord); + continue; + } + const text = typeof blockRecord.text === "string" ? blockRecord.text : ""; + if (!text.trim()) { + continue; + } + const parsed = parseLmstudioPlainTextToolCalls(text, toolNames); + if (!parsed) { + return undefined; + } + nextContent.push(...parsed.map(createLmstudioToolCallBlock)); + promoted = true; + } + + if (!promoted) { + return undefined; + } + return { + ...messageRecord, + content: nextContent, + stopReason: "toolUse", + }; +} + +function emitPromotedToolCallEvents( + stream: { push(event: unknown): void }, + message: Record, +): void { + const content = Array.isArray(message.content) ? message.content : []; + content.forEach((block, contentIndex) => { + const record = toRecord(block); + if (record?.type !== "toolCall") { + return; + } + stream.push({ type: "toolcall_start", contentIndex, partial: message }); + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: typeof record.partialArgs === "string" ? record.partialArgs : "{}", + partial: message, + }); + }); +} + +function wrapLmstudioPlainTextToolCalls( + source: ReturnType, + context: StreamContext, +): ReturnType { + const toolNames = resolveContextToolNames(context); + if (toolNames.size === 0) { + return source; + } + const output = createAssistantMessageEventStream(); + const stream = output as unknown as { push(event: unknown): void; end(): void }; + + void (async () => { + const bufferedTextEvents: unknown[] = []; + let bufferedText = ""; + let ended = false; + const endStream = () => { + if (!ended) { + ended = true; + stream.end(); + } + }; + const flushBufferedTextEvents = () => { + for (const event of bufferedTextEvents.splice(0)) { + stream.push(event); + } + bufferedText = ""; + }; + + try { + for await (const event of source as AsyncIterable) { + const record = toRecord(event); + const type = typeof record?.type === "string" ? record.type : ""; + + if (type === "text_start" || type === "text_delta" || type === "text_end") { + bufferedTextEvents.push(event); + if (typeof record?.delta === "string") { + bufferedText += record.delta; + } else if (typeof record?.content === "string" && !bufferedText) { + bufferedText = record.content; + } + if (!couldStillBePlainTextToolCall(bufferedText)) { + flushBufferedTextEvents(); + } + continue; + } + + if (type === "done") { + const promotedMessage = promoteLmstudioPlainTextToolCalls(record?.message, toolNames); + if (promotedMessage) { + bufferedTextEvents.splice(0); + bufferedText = ""; + emitPromotedToolCallEvents(stream, promotedMessage); + stream.push({ ...record, reason: "toolUse", message: promotedMessage }); + } else { + flushBufferedTextEvents(); + stream.push(event); + } + endStream(); + return; + } + + flushBufferedTextEvents(); + stream.push(event); + if (type === "error") { + endStream(); + return; + } + } + flushBufferedTextEvents(); + } catch (error) { + stream.push({ + type: "error", + reason: "error", + error: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + }, + }); + } finally { + endStream(); + } + })(); + + return output as ReturnType; +} + function createPreloadKey(params: { baseUrl: string; modelKey: string; @@ -248,7 +462,8 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext): }, }; const stream = underlying(modelWithUsageCompat, context, options); - return stream instanceof Promise ? await stream : stream; + const resolvedStream = stream instanceof Promise ? await stream : stream; + return wrapLmstudioPlainTextToolCalls(resolvedStream, context); })(); }; } diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index b599b83f03b..b27181710ce 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -152,6 +152,32 @@ describe("stripAssistantInternalScaffolding", () => { ); }); + it("strips standalone bracketed local-model tool blocks", () => { + expectVisibleText( + [ + "Let me check.", + "[mempalace_mempalace_search]", + '{"query":"codename","wing":"personal","room":"identities"}', + "[END_TOOL_REQUEST]", + "Done.", + ].join("\n"), + "Let me check.\n\nDone.", + ); + }); + + it("strips bracketed local-model tool blocks with named closing tags", () => { + expectVisibleText( + [ + "Before", + "[mempalace_mempalace_search]", + '{"query":"codename","limit":1}', + "[/mempalace_mempalace_search]", + "After", + ].join("\n"), + "Before\n\nAfter", + ); + }); + it("strips Qwen-style with nested XML", () => { expectVisibleText( "prefix\n/home/user\nsuffix", diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts index fe63abbc8e7..26eb77f9d68 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -1,6 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "../string-coerce.js"; import { findCodeRegions, isInsideCode } from "./code-regions.js"; import { stripModelSpecialTokens } from "./model-special-tokens.js"; +import { stripPlainTextToolCallBlocks } from "./plain-text-tool-call-blocks.js"; import { stripReasoningTagsFromText, type ReasoningTagMode, @@ -586,6 +587,7 @@ function applyAssistantVisibleTextStagePipeline( cleaned = stripModelSpecialTokens(cleaned); cleaned = stripRelevantMemoriesTags(cleaned); cleaned = stripToolCallXmlTags(cleaned); + cleaned = stripPlainTextToolCallBlocks(cleaned); if (!options.preserveDowngradedToolText) { cleaned = stripDowngradedToolCallText(cleaned); } diff --git a/src/shared/text/plain-text-tool-call-blocks.ts b/src/shared/text/plain-text-tool-call-blocks.ts new file mode 100644 index 00000000000..9d84037034a --- /dev/null +++ b/src/shared/text/plain-text-tool-call-blocks.ts @@ -0,0 +1,211 @@ +export type PlainTextToolCallBlock = { + arguments: Record; + end: number; + name: string; + raw: string; + start: number; +}; + +type ParseOptions = { + allowedToolNames?: Iterable; + maxPayloadBytes?: number; +}; + +const DEFAULT_MAX_PAYLOAD_BYTES = 256_000; +const END_TOOL_REQUEST = "[END_TOOL_REQUEST]"; + +function isToolNameChar(char: string | undefined): boolean { + return Boolean(char && /[A-Za-z0-9_-]/.test(char)); +} + +function skipHorizontalWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && (text[index] === " " || text[index] === "\t")) { + index += 1; + } + return index; +} + +function skipWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && /\s/.test(text[index] ?? "")) { + index += 1; + } + return index; +} + +function consumeLineBreak(text: string, start: number): number | null { + if (text[start] === "\r") { + return text[start + 1] === "\n" ? start + 2 : start + 1; + } + if (text[start] === "\n") { + return start + 1; + } + return null; +} + +function parseOpening(text: string, start: number): { end: number; name: string } | null { + if (text[start] !== "[") { + return null; + } + let cursor = start + 1; + const nameStart = cursor; + while (isToolNameChar(text[cursor])) { + cursor += 1; + } + if (cursor === nameStart || text[cursor] !== "]") { + return null; + } + const name = text.slice(nameStart, cursor); + cursor += 1; + cursor = skipHorizontalWhitespace(text, cursor); + const afterLineBreak = consumeLineBreak(text, cursor); + if (afterLineBreak === null) { + return null; + } + return { end: afterLineBreak, name }; +} + +function consumeJsonObject( + text: string, + start: number, + maxPayloadBytes: number, +): { end: number; value: Record } | null { + let cursor = skipWhitespace(text, start); + if (text[cursor] !== "{") { + return null; + } + let depth = 0; + let inString = false; + let escaped = false; + for (let index = cursor; index < text.length; index += 1) { + const char = text[index]; + if (index + 1 - cursor > maxPayloadBytes) { + return null; + } + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + const rawJson = text.slice(cursor, index + 1); + try { + const parsed = JSON.parse(rawJson) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return { end: index + 1, value: parsed as Record }; + } catch { + return null; + } + } + } + } + return null; +} + +function parseClosing(text: string, start: number, name: string): number | null { + let cursor = skipWhitespace(text, start); + if (text.startsWith(END_TOOL_REQUEST, cursor)) { + return cursor + END_TOOL_REQUEST.length; + } + const namedClosing = `[/${name}]`; + if (text.startsWith(namedClosing, cursor)) { + return cursor + namedClosing.length; + } + return null; +} + +function parseBlockAt( + text: string, + start: number, + options?: ParseOptions, +): PlainTextToolCallBlock | null { + const opening = parseOpening(text, start); + if (!opening) { + return null; + } + const allowedToolNames = options?.allowedToolNames + ? new Set(options.allowedToolNames) + : undefined; + if (allowedToolNames && !allowedToolNames.has(opening.name)) { + return null; + } + const payload = consumeJsonObject( + text, + opening.end, + options?.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES, + ); + if (!payload) { + return null; + } + const end = parseClosing(text, payload.end, opening.name); + if (end === null) { + return null; + } + return { + arguments: payload.value, + end, + name: opening.name, + raw: text.slice(start, end), + start, + }; +} + +export function parseStandalonePlainTextToolCallBlocks( + text: string, + options?: ParseOptions, +): PlainTextToolCallBlock[] | null { + const blocks: PlainTextToolCallBlock[] = []; + let cursor = skipWhitespace(text, 0); + while (cursor < text.length) { + const block = parseBlockAt(text, cursor, options); + if (!block) { + return null; + } + blocks.push(block); + cursor = skipWhitespace(text, block.end); + } + return blocks.length > 0 ? blocks : null; +} + +export function stripPlainTextToolCallBlocks(text: string): string { + if (!text || !/\[[A-Za-z0-9_-]+\]/.test(text)) { + return text; + } + let result = ""; + let cursor = 0; + let index = 0; + while (index < text.length) { + const lineStart = index === 0 || text[index - 1] === "\n"; + if (!lineStart) { + index += 1; + continue; + } + const blockStart = skipHorizontalWhitespace(text, index); + const block = parseBlockAt(text, blockStart); + if (!block) { + index += 1; + continue; + } + result += text.slice(cursor, index); + cursor = block.end; + index = block.end; + } + result += text.slice(cursor); + return result; +}