Skip to content

Commit 5a73361

Browse files
ZOOWHPeter Steinberger
andauthored
fix(plugins): apply output text transforms to toolcall_delta and toolcall_end events (#97769)
* fix(plugins): apply output text transforms to toolcall_delta and toolcall_end events toolcall_delta and toolcall_end events bypassed the output replacement pipeline, leaking masked tokens (e.g. PII placeholders) into tool call arguments and external systems. Closes #97761 * fix(plugins): transform nested tool call arguments in output replacements Add recursive transformToolCallArgumentText to handle strings, arrays, and nested objects in tool call arguments, not just the name field. * fix(plugins): keep tool names unchanged and cover result path - Remove toolCall.name transformation to prevent breaking tool routing - Add arguments transform to stream.result()/done.message via transformContentText for tool-call content blocks * fix(plugins): narrow argument transform to toolCall content blocks only Only transform arguments on content blocks with type=toolCall to avoid touching non-tool-call blocks that happen to have an arguments field. * test(plugins): assert stream.result() tool-call content block is transformed * test(plugins): fix result-path fixture to actually contain toolCall block The previous fixture used makeAssistantMessage('final') which produces a plain text content block, not a toolCall block. The stream.result() assertion was vacuously passing. * style(plugins): fix type cast in result-path test assertion * fix(agents): preserve tool argument transforms after repair * refactor(agents): keep tool transform boundary narrow --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 5e572dc commit 5a73361

5 files changed

Lines changed: 202 additions & 4 deletions

File tree

docs/gateway/cli-backends.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,11 @@ api.registerTextTransforms({
382382
```
383383

384384
`input` rewrites the system prompt and user prompt passed to the CLI. `output`
385-
rewrites streamed assistant deltas and parsed final text before OpenClaw handles
386-
its own control markers and channel delivery.
385+
rewrites streamed assistant text and parsed final text before OpenClaw handles
386+
its own control markers and channel delivery. For provider-backed model calls,
387+
`output` also restores string values inside structured tool-call arguments after
388+
stream repair and before tool execution. Raw provider JSON fragments remain
389+
unchanged; consumers should use the structured partial, end, or result payload.
387390

388391
For CLIs that emit provider-specific JSONL events, set `jsonlDialect` on that
389392
backend's config. Supported dialects are `claude-stream-json` for Claude

src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Coverage for repairing malformed streamed tool-call arguments.
22
import { describe, expect, it } from "vitest";
3+
import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js";
34
import {
45
shouldRepairMalformedToolCallArguments,
56
wrapStreamFnRepairMalformedToolCallArguments,
@@ -213,6 +214,61 @@ describe("shouldRepairMalformedToolCallArguments", () => {
213214
});
214215

215216
describe("openai-completions malformed tool-call argument repair", () => {
217+
it("restores split replacement tokens after argument repair", async () => {
218+
const partialToolCall = { type: "toolCall", name: "send", arguments: {} };
219+
const streamedToolCall = { type: "toolCall", name: "send", arguments: {} };
220+
const finalToolCall = { type: "toolCall", name: "send", arguments: {} };
221+
const partialMessage = { role: "assistant", content: [partialToolCall] };
222+
const finalMessage = { role: "assistant", content: [finalToolCall] };
223+
const baseFn: FakeStreamFn = () =>
224+
createFakeStream({
225+
events: [
226+
{
227+
type: "toolcall_delta",
228+
contentIndex: 0,
229+
delta: '{"text":"[MAS',
230+
partial: partialMessage,
231+
},
232+
{
233+
type: "toolcall_delta",
234+
contentIndex: 0,
235+
delta: 'KED]"}',
236+
partial: partialMessage,
237+
},
238+
{
239+
type: "toolcall_end",
240+
contentIndex: 0,
241+
toolCall: streamedToolCall,
242+
partial: partialMessage,
243+
},
244+
],
245+
resultMessage: finalMessage,
246+
});
247+
const repairedFn = wrapStreamFnRepairMalformedToolCallArguments(baseFn as never);
248+
const transformedFn = wrapStreamFnTextTransforms({
249+
streamFn: repairedFn,
250+
output: [{ from: /\[MASKED\]/g, to: "John Smith" }],
251+
}) as FakeStreamFn;
252+
const stream = await Promise.resolve(transformedFn({} as never, {} as never, {} as never));
253+
const events = [];
254+
for await (const event of stream) {
255+
events.push(event);
256+
}
257+
258+
expect(
259+
events
260+
.filter((event) => (event as { type?: string }).type === "toolcall_delta")
261+
.map((event) => (event as { delta?: string }).delta),
262+
).toEqual(['{"text":"[MAS', 'KED]"}']);
263+
const endEvent = events.find(
264+
(event) => (event as { type?: string }).type === "toolcall_end",
265+
) as { toolCall?: { arguments?: unknown } } | undefined;
266+
expect(endEvent?.toolCall?.arguments).toEqual({ text: "John Smith" });
267+
await expect(stream.result()).resolves.toMatchObject({
268+
content: [{ arguments: { text: "John Smith" } }],
269+
});
270+
});
271+
216272
it.each([
217273
["openai-completions", "sglang"],
218274
["openai-chatgpt-responses", "openai"],

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2872,11 +2872,10 @@ export async function runEmbeddedAttempt(
28722872
workspaceDir: effectiveWorkspace,
28732873
runtimeHandle: getProviderRuntimeHandle(),
28742874
});
2875-
if (providerTextTransforms) {
2875+
if (providerTextTransforms?.input?.length) {
28762876
activeSession.agent.streamFn = wrapStreamFnTextTransforms({
28772877
streamFn: activeSession.agent.streamFn,
28782878
input: providerTextTransforms.input,
2879-
output: providerTextTransforms.output,
28802879
transformSystemPrompt: false,
28812880
});
28822881
}
@@ -3101,6 +3100,15 @@ export async function runEmbeddedAttempt(
31013100
);
31023101
}
31033102

3103+
// Tool-call repair can replace structured arguments from fragmented deltas.
3104+
// Restore provider-masked text afterward so executable args stay canonical.
3105+
if (providerTextTransforms?.output?.length) {
3106+
activeSession.agent.streamFn = wrapStreamFnTextTransforms({
3107+
streamFn: activeSession.agent.streamFn,
3108+
output: providerTextTransforms.output,
3109+
});
3110+
}
3111+
31043112
if (anthropicPayloadLogger) {
31053113
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
31063114
activeSession.agent.streamFn,

src/agents/plugin-text-transforms.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type AssistantMessage,
66
type Context,
77
type Model,
8+
type ToolCall,
89
} from "openclaw/plugin-sdk/llm";
910
import { describe, expect, it } from "vitest";
1011
import {
@@ -40,6 +41,14 @@ function makeAssistantMessage(text: string): AssistantMessage {
4041
};
4142
}
4243

44+
function makeAssistantToolMessage(toolCall: ToolCall): AssistantMessage {
45+
return {
46+
...makeAssistantMessage(""),
47+
content: [toolCall],
48+
stopReason: "toolUse",
49+
};
50+
}
51+
4352
describe("plugin text transforms", () => {
4453
it("merges registered transform groups in order", () => {
4554
const merged = mergePluginTextTransforms(
@@ -180,4 +189,91 @@ describe("plugin text transforms", () => {
180189
expect(firstEvent?.delta).toBe("red basket on the left shelf");
181190
expect(result.content).toEqual([{ type: "text", text: "final red basket on the left shelf" }]);
182191
});
192+
193+
it("applies output replacements to structured tool-call arguments", async () => {
194+
const partialToolCall: ToolCall = {
195+
type: "toolCall",
196+
id: "call-1",
197+
name: "search",
198+
arguments: {
199+
query: "[MASKED]",
200+
nested: { note: "ask [MASKED] again" },
201+
entries: ["[MASKED]", 7],
202+
},
203+
};
204+
const finalToolCall: ToolCall = {
205+
type: "toolCall",
206+
id: "call-2",
207+
name: "send_msg",
208+
arguments: { text: "[MASKED]" },
209+
};
210+
const partial = makeAssistantToolMessage(partialToolCall);
211+
const baseStreamFn: StreamFn = (_model, _context) => {
212+
const stream = createAssistantMessageEventStream();
213+
queueMicrotask(() => {
214+
stream.push({
215+
type: "toolcall_delta",
216+
contentIndex: 0,
217+
delta: '{"query":"[MASKED]"}',
218+
partial,
219+
});
220+
stream.push({
221+
type: "toolcall_end",
222+
contentIndex: 0,
223+
toolCall: partialToolCall,
224+
partial,
225+
});
226+
stream.push({
227+
type: "done",
228+
reason: "toolUse",
229+
message: makeAssistantToolMessage(finalToolCall),
230+
});
231+
stream.end();
232+
});
233+
return stream;
234+
};
235+
236+
const wrapped = wrapStreamFnTextTransforms({
237+
streamFn: baseStreamFn,
238+
output: [{ from: /\[MASKED\]/g, to: "John" }],
239+
});
240+
const stream = await Promise.resolve(wrapped(model, {} as Context, undefined));
241+
const events = [];
242+
for await (const event of stream) {
243+
events.push(event);
244+
}
245+
246+
const deltaEvent = events.find((event) => event.type === "toolcall_delta") as
247+
| { delta?: string; partial?: AssistantMessage }
248+
| undefined;
249+
// Raw JSON fragments are provider bytes and may split a replacement token.
250+
// Structured arguments are the safe, canonical transform surface.
251+
expect(deltaEvent?.delta).toBe('{"query":"[MASKED]"}');
252+
expect(deltaEvent?.partial?.content[0]).toMatchObject({
253+
name: "search",
254+
arguments: {
255+
query: "John",
256+
nested: { note: "ask John again" },
257+
entries: ["John", 7],
258+
},
259+
});
260+
261+
const endEvent = events.find((event) => event.type === "toolcall_end") as
262+
| { toolCall?: { name?: string; arguments?: Record<string, unknown> } }
263+
| undefined;
264+
// Tool name is preserved — only arguments are transformed to avoid
265+
// breaking tool routing by renaming a registered tool identifier.
266+
expect(endEvent?.toolCall?.name).toBe("search");
267+
expect(endEvent?.toolCall?.arguments).toEqual({
268+
query: "John",
269+
nested: { note: "ask John again" },
270+
entries: ["John", 7],
271+
});
272+
273+
const result = await stream.result();
274+
expect(result.content[0]).toMatchObject({
275+
name: "send_msg",
276+
arguments: { text: "John" },
277+
});
278+
});
183279
});

src/agents/plugin-text-transforms.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ function transformContentText(content: unknown, replacements?: PluginTextReplace
6262
if (Object.hasOwn(next, "content")) {
6363
next.content = transformContentText(next.content, replacements);
6464
}
65+
if (next.type === "toolCall" && Object.hasOwn(next, "arguments")) {
66+
next.arguments = transformToolCallArgumentText(next.arguments, replacements);
67+
}
6568
return next;
6669
}
6770

@@ -79,6 +82,27 @@ function transformMessageText(message: unknown, replacements?: PluginTextReplace
7982
return next;
8083
}
8184

85+
function transformToolCallArgumentText(
86+
value: unknown,
87+
replacements?: PluginTextReplacement[],
88+
): unknown {
89+
if (typeof value === "string") {
90+
return applyPluginTextReplacements(value, replacements);
91+
}
92+
if (Array.isArray(value)) {
93+
return value.map((entry) => transformToolCallArgumentText(entry, replacements));
94+
}
95+
if (!isRecord(value)) {
96+
return value;
97+
}
98+
return Object.fromEntries(
99+
Object.entries(value).map(([key, entry]) => [
100+
key,
101+
transformToolCallArgumentText(entry, replacements),
102+
]),
103+
);
104+
}
105+
82106
/** Apply input text replacements to a stream context. */
83107
function transformStreamContextText(
84108
context: Parameters<StreamFn>[1],
@@ -114,6 +138,17 @@ function transformAssistantEventText(
114138
if (next.type === "text_end" && typeof next.content === "string") {
115139
next.content = applyPluginTextReplacements(next.content, replacements);
116140
}
141+
if (
142+
next.type === "toolcall_end" &&
143+
isRecord(next.toolCall) &&
144+
Object.hasOwn(next.toolCall, "arguments")
145+
) {
146+
// Tool names are routing identifiers; only argument values are text.
147+
next.toolCall = {
148+
...next.toolCall,
149+
arguments: transformToolCallArgumentText(next.toolCall.arguments, replacements),
150+
};
151+
}
117152
if (Object.hasOwn(next, "partial")) {
118153
next.partial = transformMessageText(next.partial, replacements);
119154
}

0 commit comments

Comments
 (0)