mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user