fix: prevent plain text tool call leaks (#86222)

Prevent plain text tool call leaks from xAI/LM Studio fallback streams.

- Promotes plain-text tool-call fallback chunks into structured tool calls.
- Strips leaked internal tool syntax before user-facing/outbound text.
- Adds regression coverage across provider stream wrappers, tool payload parsing, user-facing sanitization, and outbound send validation.

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
Jason (Json)
2026-05-25 01:15:11 -06:00
committed by GitHub
parent 316d97c938
commit cd627803a0
14 changed files with 439 additions and 255 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Install/update: bypass npm `min-release-age` policies with `--min-release-age=0` instead of `--before` so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.
- WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.
- Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.
- xAI/LM Studio: promote plain-text tool-call fallbacks into structured tool calls and strip leaked internal tool syntax before user-facing delivery. (#86222) Thanks @fuller-stack-dev.
- CLI: suppress benign self-update version-skew warnings during package post-update finalization.
- Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or `n/a` results.
- Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.

View File

@@ -1,24 +0,0 @@
import { randomUUID } from "node:crypto";
import { parseStandalonePlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
type LmstudioPlainTextToolCallBlock = {
arguments: Record<string, unknown>;
name: string;
};
const MAX_PAYLOAD_CHARS = 256_000;
export function parseLmstudioPlainTextToolCalls(
text: string,
allowedToolNames: Set<string>,
): LmstudioPlainTextToolCallBlock[] | null {
const blocks = parseStandalonePlainTextToolCallBlocks(text, {
allowedToolNames,
maxPayloadBytes: MAX_PAYLOAD_CHARS,
});
return blocks?.map((block) => ({ arguments: block.arguments, name: block.name })) ?? null;
}
export function createLmstudioSyntheticToolCallId(): string {
return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
}

View File

@@ -1,23 +1,18 @@
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai";
import { streamSimple } from "@earendil-works/pi-ai";
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { createPlainTextToolCallPromotionWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
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<StreamFn>[2];
type StreamModel = Parameters<StreamFn>[0];
type StreamContext = Parameters<StreamFn>[1];
const preloadInFlight = new Map<string, Promise<void>>();
/**
@@ -137,218 +132,6 @@ function withLmstudioUsageCompat(model: StreamModel): StreamModel {
};
}
function resolveContextToolNames(context: StreamContext): Set<string> {
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("[") ||
trimmed.startsWith("<|channel|>") ||
trimmed.startsWith("commentary") ||
trimmed.startsWith("analysis") ||
trimmed.startsWith("final")
);
}
function createLmstudioToolCallBlock(parsed: {
arguments: Record<string, unknown>;
name: string;
}): Record<string, unknown> {
return {
type: "toolCall",
id: createLmstudioSyntheticToolCallId(),
name: parsed.name,
arguments: parsed.arguments,
partialArgs: JSON.stringify(parsed.arguments),
};
}
function promoteLmstudioPlainTextToolCalls(
message: unknown,
toolNames: Set<string>,
): Record<string, unknown> | 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<Record<string, unknown>> = [];
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<string, unknown>,
): 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<StreamFn>,
context: StreamContext,
): ReturnType<StreamFn> {
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<unknown>) {
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<StreamFn>;
}
function createPreloadKey(params: {
baseUrl: string;
modelKey: string;
@@ -396,6 +179,7 @@ async function ensureLmstudioModelLoadedBestEffort(params: {
export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext): StreamFn {
const underlying = ctx.streamFn ?? streamSimple;
const streamWithPlainTextToolCalls = createPlainTextToolCallPromotionWrapper(underlying);
return (model, context, options) => {
if (model.provider !== LMSTUDIO_PROVIDER_ID) {
return underlying(model, context, options);
@@ -406,11 +190,7 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
}
const providerConfig = ctx.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID];
if (!shouldPreloadLmstudioModels(providerConfig)) {
const stream = underlying(withLmstudioUsageCompat(model), context, options);
return (async () => {
const resolvedStream = stream instanceof Promise ? await stream : stream;
return wrapLmstudioPlainTextToolCalls(resolvedStream, context);
})();
return streamWithPlainTextToolCalls(withLmstudioUsageCompat(model), context, options);
}
const providerBaseUrl = providerConfig?.baseUrl;
const resolvedBaseUrl = resolveLmstudioInferenceBase(
@@ -485,9 +265,9 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
// LM Studio uses OpenAI-compatible streaming usage payloads when requested via
// `stream_options.include_usage`. Force this compat flag at call time so usage
// reporting remains enabled even when catalog entries omitted compat metadata.
const stream = underlying(withLmstudioUsageCompat(model), context, options);
const stream = streamWithPlainTextToolCalls(withLmstudioUsageCompat(model), context, options);
const resolvedStream = stream instanceof Promise ? await stream : stream;
return wrapLmstudioPlainTextToolCalls(resolvedStream, context);
return resolvedStream;
})();
};
}

View File

@@ -14,6 +14,33 @@ import {
runXaiGrok4ResponseStream,
} from "./test-helpers.js";
type XaiStreamApi = Extract<Api, "openai-completions" | "openai-responses">;
type StreamEvent = Record<string, unknown> & { type?: string };
async function collectEvents(stream: ReturnType<StreamFn>): Promise<StreamEvent[]> {
const events: StreamEvent[] = [];
for await (const event of stream as AsyncIterable<StreamEvent>) {
events.push(event);
}
return events;
}
function buildEventStreamFn(events: unknown[]): StreamFn {
return (() =>
({
result: async () => {
const done = events.find((event) => {
const record = event && typeof event === "object" ? (event as { type?: unknown }) : {};
return record.type === "done";
}) as { message?: unknown } | undefined;
return (done?.message ?? { role: "assistant", content: [] }) as never;
},
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event as never;
}
},
}) as unknown as ReturnType<StreamFn>) as StreamFn;
}
function captureWrappedModelId(params: {
modelId: string;
@@ -143,6 +170,62 @@ describe("xai stream wrappers", () => {
expectXaiFastToolStreamShaping(capture);
});
it("promotes standalone Grok-style tool text to a structured tool call", async () => {
const rawToolText = '[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}';
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 = wrapXaiProviderStream({
streamFn: baseStream,
extraParams: { tool_stream: false },
} as never);
const events = await collectEvents(
wrapped!(
{
api: "openai-responses",
provider: "xai",
id: "grok-4.3",
} as Model<"openai-responses">,
{
messages: [],
tools: [{ name: "read", description: "Read", parameters: { type: "object" } }],
} as unknown as Context,
{},
),
);
expect(events.map((event) => event.type)).toEqual([
"start",
"toolcall_start",
"toolcall_delta",
"done",
]);
const done = events.find((event) => event.type === "done") as {
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
reason?: string;
};
expect(done.reason).toBe("toolUse");
expect(done.message?.stopReason).toBe("toolUse");
expect(done.message?.content?.[0]).toMatchObject({
type: "toolCall",
name: "read",
arguments: { path: "/app/skills/meme-maker/SKILL.md" },
});
});
it("strips unsupported strict and reasoning controls from tool payloads", () => {
const payload = {
reasoning: "high",

View File

@@ -3,6 +3,7 @@ import { streamSimple } from "@earendil-works/pi-ai";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
composeProviderStreamWrappers,
createPlainTextToolCallPromotionWrapper,
createToolStreamWrapper,
} from "openclaw/plugin-sdk/provider-stream-shared";
@@ -354,6 +355,7 @@ export function wrapXaiProviderStream(ctx: ProviderWrapStreamFnContext): StreamF
wrappedStreamFn = createXaiFastModeWrapper(wrappedStreamFn, fastMode);
}
wrappedStreamFn = createXaiToolCallArgumentDecodingWrapper(wrappedStreamFn);
wrappedStreamFn = createPlainTextToolCallPromotionWrapper(wrappedStreamFn);
return createToolStreamWrapper(wrappedStreamFn, toolStreamEnabled);
});
}

View File

@@ -258,6 +258,17 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
});
it("strips Grok-style plain-text tool calls before user-facing delivery", () => {
const input = [
"Before",
'[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}',
'[tool:message] {"action":"send","channel":"channel:123","message":"[tool:read] {\\"path\\":\\"/app/skills/meme-maker/SKILL.md\\"}"}',
"After",
].join("\n");
expect(sanitizeUserFacingText(input)).toBe("Before\nAfter");
});
it("strips MiniMax plain-text tool calls before user-facing delivery", () => {
const input = [
"Let me check that.",

View File

@@ -1,4 +1,5 @@
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
import { stripPlainTextToolCallBlocks } from "../../plugin-sdk/tool-payload.js";
import {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
@@ -413,7 +414,9 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
// It is internal scaffolding, so drop standalone placeholder lines before delivery
// while preserving ordinary inline mentions a user may be discussing.
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml);
const withoutToolCallBlocks = stripLegacyBracketToolCallBlocks(withoutPlaceholder);
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(
stripLegacyBracketToolCallBlocks(withoutPlaceholder),
);
const trimmed = withoutToolCallBlocks.trim();
if (!trimmed) {
return "";

View File

@@ -250,6 +250,20 @@ describe("runMessageAction send validation", () => {
expect(JSON.stringify(result.payload)).not.toContain("turn2view0");
});
it("rejects message sends whose body is only leaked plain-text tool calls", async () => {
await expect(
runDrySend({
cfg: workspaceConfig,
actionParams: {
channel: "workspace",
target: "#C12345678",
message: '[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}',
},
toolContext: { currentChannelId: "C12345678" },
}),
).rejects.toThrow(/send requires text or media/i);
});
it.each([
{
name: "structured poll params",

View File

@@ -30,6 +30,7 @@ import {
import type { OutboundMediaAccess } from "../../media/load-options.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
import { stripPlainTextToolCallBlocks } from "../../plugin-sdk/tool-payload.js";
import { hasPollCreationParams } from "../../poll-params.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js";
@@ -864,7 +865,7 @@ async function buildSendPayloadParts(params: {
mergedMediaUrls.length = 0;
mergedMediaUrls.push(...normalizedMediaUrls);
message = stripUnsupportedCitationControlMarkers(parsed.text);
message = stripPlainTextToolCallBlocks(stripUnsupportedCitationControlMarkers(parsed.text));
actionParams.message = message;
if (!actionParams.replyTo && parsed.replyToId) {
actionParams.replyTo = parsed.replyToId;

View File

@@ -218,6 +218,19 @@ describe("stripInternalRuntimeScaffolding", () => {
);
});
it("strips Grok-style tool call text before outbound delivery", () => {
expect(
stripInternalRuntimeScaffolding(
[
"Before",
'[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}',
'[tool:message] {"action":"send","message":"[tool:read] {\\"path\\":\\"/app/skills/meme-maker/SKILL.md\\"}"}',
"After",
].join("\n"),
),
).toBe("Before\nAfter");
});
it("removes stray standalone marker lines", () => {
expect(
stripInternalRuntimeScaffolding(

View File

@@ -11,6 +11,8 @@
* @see https://github.com/openclaw/openclaw/issues/18558
*/
import { stripPlainTextToolCallBlocks } from "../../plugin-sdk/tool-payload.js";
const INTERNAL_RUNTIME_SCAFFOLDING_TAGS = ["system-reminder", "previous_response"] as const;
const INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN = INTERNAL_RUNTIME_SCAFFOLDING_TAGS.join("|");
const INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE = new RegExp(
@@ -111,7 +113,7 @@ export function stripInternalRuntimeScaffolding(text: string): string {
for (const marker of INTERNAL_RUNTIME_MARKER_LINES) {
stripped = stripStandaloneMarkerLine(stripped, marker);
}
return stripped;
return stripPlainTextToolCallBlocks(stripped);
}
/**

View File

@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { streamSimple } from "@earendil-works/pi-ai";
import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai";
import { streamWithPayloadPatch } from "../agents/pi-embedded-runner/stream-payload-utils.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import type { ProviderWrapStreamFnContext } from "./plugin-entry.js";
import { parseStandalonePlainTextToolCallBlocks } from "./tool-payload.js";
export type ProviderStreamWrapperFactory =
| ((streamFn: StreamFn | undefined) => StreamFn | undefined)
@@ -60,6 +62,254 @@ export function createPayloadPatchStreamWrapper(
};
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function resolveContextToolNames(context: Parameters<StreamFn>[1]): Set<string> {
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("[") ||
trimmed.startsWith("<|channel|>") ||
trimmed.startsWith("commentary") ||
trimmed.startsWith("analysis") ||
trimmed.startsWith("final")
);
}
function createSyntheticToolCallId(): string {
return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
}
function createPlainTextToolCallBlock(parsed: {
arguments: Record<string, unknown>;
name: string;
}): Record<string, unknown> {
return {
type: "toolCall",
id: createSyntheticToolCallId(),
name: parsed.name,
arguments: parsed.arguments,
partialArgs: JSON.stringify(parsed.arguments),
};
}
function promotePlainTextToolCalls(
message: unknown,
toolNames: Set<string>,
): Record<string, unknown> | 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 = parseStandalonePlainTextToolCallBlocks(messageRecord.content, {
allowedToolNames: toolNames,
});
if (!parsed) {
return undefined;
}
return {
...messageRecord,
content: parsed.map(createPlainTextToolCallBlock),
stopReason: "toolUse",
};
}
if (
messageRecord.content.some((block) => toRecord(block)?.type === "toolCall") ||
messageRecord.content.length === 0
) {
return undefined;
}
let promoted = false;
const nextContent: Array<Record<string, unknown>> = [];
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 = parseStandalonePlainTextToolCallBlocks(text, {
allowedToolNames: toolNames,
});
if (!parsed) {
return undefined;
}
nextContent.push(...parsed.map(createPlainTextToolCallBlock));
promoted = true;
}
if (!promoted) {
return undefined;
}
return {
...messageRecord,
content: nextContent,
stopReason: "toolUse",
};
}
function emitPromotedToolCallEvents(
stream: { push(event: unknown): void },
message: Record<string, unknown>,
): 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 wrapPlainTextToolCallStream(
source: ReturnType<StreamFn>,
context: Parameters<StreamFn>[1],
): ReturnType<StreamFn> {
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<unknown>) {
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 = promotePlainTextToolCalls(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<StreamFn>;
}
/**
* Promotes standalone plain-text tool call fallbacks into structured tool calls.
*
* Some providers occasionally return tool-use syntax as assistant text even when
* native tool calling is enabled. This keeps that text out of user-facing chat
* surfaces and lets the normal tool runner handle it.
*
* @deprecated Bundled provider stream helper; do not use from third-party plugins.
*/
export function createPlainTextToolCallPromotionWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const maybeStream = underlying(model, context, options);
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
return Promise.resolve(maybeStream).then((stream) =>
wrapPlainTextToolCallStream(stream, context),
) as ReturnType<StreamFn>;
}
return wrapPlainTextToolCallStream(maybeStream, context);
};
}
function isAnthropicThinkingEnabled(payload: Record<string, unknown>): boolean {
const thinking = payload.thinking;
if (!thinking || typeof thinking !== "object") {

View File

@@ -97,6 +97,30 @@ describe("parseStandalonePlainTextToolCallBlocks", () => {
]);
});
it("parses Grok-style bracketed tool calls", () => {
const firstRaw = '[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}';
const secondRaw = '[tool:message] {"action":"send","channel":"channel:123","message":"done"}';
const raw = [firstRaw, "", secondRaw].join("\n");
const blocks = parseStandalonePlainTextToolCallBlocks(raw);
expect(blocks).toEqual([
{
name: "read",
arguments: { path: "/app/skills/meme-maker/SKILL.md" },
start: 0,
end: firstRaw.length,
raw: firstRaw,
},
{
name: "message",
arguments: { action: "send", channel: "channel:123", message: "done" },
start: firstRaw.length + 2,
end: raw.length,
raw: secondRaw,
},
]);
});
it("respects allowed tool names for Harmony calls", () => {
const blocks = parseStandalonePlainTextToolCallBlocks(
'commentary to=write code {"path":"/tmp/file.txt","content":"x"}',
@@ -123,4 +147,17 @@ describe("stripPlainTextToolCallBlocks", () => {
),
).toBe("before\nafter");
});
it("strips standalone Grok-style tool calls", () => {
expect(
stripPlainTextToolCallBlocks(
[
"before",
'[tool:read] {"path":"/tmp/file.txt"}',
'[tool:message] {"action":"send","message":"[tool:read] {\\"path\\":\\"/tmp/file.txt\\"}"}',
"after",
].join("\n"),
),
).toBe("before\nafter");
});
});

View File

@@ -102,6 +102,17 @@ function parseBracketOpening(text: string, start: number): PlainTextToolCallOpen
return null;
}
let cursor = start + 1;
if (text.startsWith("tool:", cursor)) {
cursor += "tool:".length;
const nameStart = cursor;
while (isToolNameChar(text[cursor])) {
cursor += 1;
}
if (cursor === nameStart || text[cursor] !== "]") {
return null;
}
return { end: cursor + 1, name: text.slice(nameStart, cursor), requiresClosing: false };
}
const nameStart = cursor;
while (isToolNameChar(text[cursor])) {
cursor += 1;
@@ -291,7 +302,7 @@ export function parseStandalonePlainTextToolCallBlocks(
export function stripPlainTextToolCallBlocks(text: string): string {
if (
!text ||
(!/\[[A-Za-z0-9_-]+\]/.test(text) &&
(!/\[(?:tool:)?[A-Za-z0-9_-]+\]/.test(text) &&
!/(?:^|\n)\s*(?:<\|channel\|>)?(?:commentary|analysis|final)\s+to=/.test(text))
) {
return text;