Skip to content

Commit 051e827

Browse files
committed
fix: apply text transforms to tool calls
1 parent db2488b commit 051e827

6 files changed

Lines changed: 340 additions & 8 deletions

File tree

packages/agent-core/src/agent-loop.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ function removeNonExecutableToolCalls(message: AssistantMessage): AssistantMessa
8888
return content.length === message.content.length ? message : { ...message, content };
8989
}
9090

91+
async function finalizeAssistantMessageForLoop(
92+
message: AssistantMessage,
93+
config: AgentLoopConfig,
94+
signal: AbortSignal | undefined,
95+
): Promise<AssistantMessage> {
96+
const executableMessage = removeNonExecutableToolCalls(message);
97+
return config.transformAssistantMessage
98+
? await config.transformAssistantMessage(executableMessage, signal)
99+
: executableMessage;
100+
}
101+
91102
/**
92103
* Start an agent loop with a new prompt message.
93104
* The prompt is added to the context and events are emitted for it.
@@ -516,7 +527,11 @@ async function streamAssistantResponse(
516527

517528
case "done":
518529
case "error": {
519-
const finalMessage = removeNonExecutableToolCalls(await response.result());
530+
const finalMessage = await finalizeAssistantMessageForLoop(
531+
await response.result(),
532+
config,
533+
signal,
534+
);
520535
if (addedPartial) {
521536
context.messages[context.messages.length - 1] = finalMessage;
522537
} else {
@@ -531,7 +546,11 @@ async function streamAssistantResponse(
531546
}
532547
}
533548

534-
const finalMessage = removeNonExecutableToolCalls(await response.result());
549+
const finalMessage = await finalizeAssistantMessageForLoop(
550+
await response.result(),
551+
config,
552+
signal,
553+
);
535554
if (addedPartial) {
536555
context.messages[context.messages.length - 1] = finalMessage;
537556
} else {

packages/agent-core/src/agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export interface AgentOptions {
110110
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
111111
/** Optionally rewrite context before each provider request. */
112112
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
113+
/** Optionally rewrite finalized assistant messages before emitting/executing. */
114+
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
113115
/** Injected stream runtime used when streamFn is not supplied. */
114116
runtime?: AgentCoreStreamRuntimeDeps;
115117
/** Explicit stream implementation, preferred over runtime.streamSimple. */
@@ -214,6 +216,7 @@ export class Agent {
214216
messages: AgentMessage[],
215217
signal?: AbortSignal,
216218
) => Promise<AgentMessage[]>;
219+
public transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
217220
public runtime?: AgentCoreStreamRuntimeDeps;
218221
public streamFn: StreamFn;
219222
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
@@ -247,6 +250,7 @@ export class Agent {
247250
this.mutableState = createMutableAgentState(options.initialState);
248251
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
249252
this.transformContext = options.transformContext;
253+
this.transformAssistantMessage = options.transformAssistantMessage;
250254
this.runtime = options.runtime;
251255
this.streamFn = resolveAgentCoreStreamFn(options.runtime, options.streamFn);
252256
this.getApiKey = options.getApiKey;
@@ -495,6 +499,7 @@ export class Agent {
495499
: undefined,
496500
convertToLlm: this.convertToLlm,
497501
transformContext: this.transformContext,
502+
transformAssistantMessage: this.transformAssistantMessage,
498503
getApiKey: this.getApiKey,
499504
getSteeringMessages: async () => {
500505
if (skipInitialSteeringPoll) {

packages/agent-core/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,18 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
195195
*/
196196
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
197197

198+
/**
199+
* Optional transform applied to each finalized assistant message before it is
200+
* emitted, stored in loop state, or used for tool execution.
201+
*
202+
* Contract: must not throw or reject. Return the original message or another
203+
* safe fallback value instead.
204+
*/
205+
transformAssistantMessage?: (
206+
message: AssistantMessage,
207+
signal?: AbortSignal,
208+
) => Promise<AssistantMessage> | AssistantMessage;
209+
198210
/**
199211
* Resolves an API key dynamically for each LLM call.
200212
*

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summar
4747
import { getMachineDisplayName } from "../../../infra/machine-name.js";
4848
import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js";
4949
import type { AssistantMessage } from "../../../llm/types.js";
50+
import type { PluginTextReplacement } from "../../../plugins/cli-backend.types.js";
5051
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
5152
import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js";
5253
import {
@@ -178,7 +179,10 @@ import {
178179
import { resolveModelAuthMode } from "../../model-auth.js";
179180
import { resolveDefaultModelForAgent } from "../../model-selection.js";
180181
import { supportsModelTools } from "../../model-tool-support.js";
181-
import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js";
182+
import {
183+
transformPluginMessageText,
184+
wrapStreamFnTextTransforms,
185+
} from "../../plugin-text-transforms.js";
182186
import { resolveAgentPromptSurfaceForSessionKey } from "../../prompt-surface.js";
183187
import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js";
184188
import { registerProviderStreamForModel } from "../../provider-stream.js";
@@ -534,6 +538,37 @@ export {
534538
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
535539
const PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER = 4;
536540

541+
type AssistantMessageTransform = (
542+
message: AssistantMessage,
543+
signal?: AbortSignal,
544+
) => AssistantMessage | Promise<AssistantMessage>;
545+
type AssistantMessageTransformAgent = {
546+
transformAssistantMessage?: AssistantMessageTransform;
547+
};
548+
549+
const originalAssistantMessageTransformByAgent = new WeakMap<
550+
AssistantMessageTransformAgent,
551+
AssistantMessageTransform | undefined
552+
>();
553+
554+
function installAttemptAssistantMessageTextTransform(
555+
agent: AssistantMessageTransformAgent,
556+
output?: PluginTextReplacement[],
557+
): void {
558+
if (!originalAssistantMessageTransformByAgent.has(agent)) {
559+
originalAssistantMessageTransformByAgent.set(agent, agent.transformAssistantMessage);
560+
}
561+
const originalTransform = originalAssistantMessageTransformByAgent.get(agent);
562+
if (!output || output.length === 0) {
563+
agent.transformAssistantMessage = originalTransform;
564+
return;
565+
}
566+
agent.transformAssistantMessage = async (message, signal) => {
567+
const baseMessage = originalTransform ? await originalTransform(message, signal) : message;
568+
return transformPluginMessageText(baseMessage, output);
569+
};
570+
}
571+
537572
function pluginMetadataSnapshotCoversProvider(
538573
snapshot: PluginMetadataSnapshot | undefined,
539574
provider: string,
@@ -2876,8 +2911,13 @@ export async function runEmbeddedAttempt(
28762911
input: providerTextTransforms.input,
28772912
output: providerTextTransforms.output,
28782913
transformSystemPrompt: false,
2914+
transformFinalResult: false,
28792915
});
28802916
}
2917+
installAttemptAssistantMessageTextTransform(
2918+
activeSession.agent,
2919+
providerTextTransforms?.output,
2920+
);
28812921
const nativeWebSearchPolicyContext = {
28822922
sessionKey: sandboxSessionKey,
28832923
sandboxToolPolicy: sandbox?.tools,

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

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
// Verifies plugin text transforms rewrite prompts and streamed assistant output.
2-
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
2+
import { runAgentLoop, type AgentEvent, type StreamFn } from "openclaw/plugin-sdk/agent-core";
33
import {
44
createAssistantMessageEventStream,
55
type AssistantMessage,
66
type Context,
77
type Model,
8+
type ToolCall,
89
} from "openclaw/plugin-sdk/llm";
10+
import { Type } from "typebox";
911
import { describe, expect, it } from "vitest";
1012
import {
1113
applyPluginTextReplacements,
1214
mergePluginTextTransforms,
15+
transformPluginMessageText,
1316
wrapStreamFnTextTransforms,
1417
} from "./plugin-text-transforms.js";
1518

@@ -40,6 +43,14 @@ function makeAssistantMessage(text: string): AssistantMessage {
4043
};
4144
}
4245

46+
function makeAssistantToolMessage(toolCall: ToolCall): AssistantMessage {
47+
return {
48+
...makeAssistantMessage("unused"),
49+
content: [toolCall],
50+
stopReason: "toolUse",
51+
};
52+
}
53+
4354
describe("plugin text transforms", () => {
4455
it("merges registered transform groups in order", () => {
4556
const merged = mergePluginTextTransforms(
@@ -180,4 +191,180 @@ describe("plugin text transforms", () => {
180191
expect(firstEvent?.delta).toBe("red basket on the left shelf");
181192
expect(result.content).toEqual([{ type: "text", text: "final red basket on the left shelf" }]);
182193
});
194+
195+
it("wraps streamed tool call deltas and argument strings with outbound replacements", async () => {
196+
const streamedToolCall: ToolCall = {
197+
type: "toolCall",
198+
id: "call_[MASKED]",
199+
name: "send_[MASKED]",
200+
arguments: {
201+
text: "Message for [MASKED]",
202+
nested: { title: "[MASKED] follow-up", count: 2 },
203+
recipients: ["[MASKED]", "ops", true],
204+
},
205+
partialArgs: '{"text":"Message for [MASKED]"}',
206+
partialJson: '{"text":"Message for [MASKED]"}',
207+
} as ToolCall;
208+
const finalToolCall: ToolCall = {
209+
...streamedToolCall,
210+
arguments: {
211+
text: "Final message for [MASKED]",
212+
nested: { title: "[MASKED] final", enabled: false },
213+
},
214+
partialArgs: '{"text":"Final message for [MASKED]"}',
215+
partialJson: '{"text":"Final message for [MASKED]"}',
216+
} as ToolCall;
217+
const partial = makeAssistantToolMessage(streamedToolCall);
218+
const finalMessage = makeAssistantToolMessage(finalToolCall);
219+
const baseStreamFn: StreamFn = () => {
220+
const stream = createAssistantMessageEventStream();
221+
queueMicrotask(() => {
222+
stream.push({
223+
type: "toolcall_delta",
224+
contentIndex: 0,
225+
delta: '{"text":"Message for [MASKED]"}',
226+
partial,
227+
});
228+
stream.push({
229+
type: "toolcall_end",
230+
contentIndex: 0,
231+
toolCall: streamedToolCall,
232+
partial,
233+
});
234+
stream.push({
235+
type: "done",
236+
reason: "toolUse",
237+
message: finalMessage,
238+
});
239+
stream.end();
240+
});
241+
return stream;
242+
};
243+
244+
const wrapped = wrapStreamFnTextTransforms({
245+
streamFn: baseStreamFn,
246+
output: [{ from: /\[MASKED\]/g, to: "John Smith" }],
247+
});
248+
const stream = await Promise.resolve(wrapped(model, { messages: [] } as Context, undefined));
249+
const events = [];
250+
for await (const event of stream) {
251+
events.push(event);
252+
}
253+
const result = await stream.result();
254+
255+
const delta = events[0] as { type?: string; delta?: string; partial?: AssistantMessage };
256+
expect(delta.type).toBe("toolcall_delta");
257+
expect(delta.delta).toBe('{"text":"Message for John Smith"}');
258+
expect(delta.partial?.content[0]).toMatchObject({
259+
id: "call_[MASKED]",
260+
name: "send_[MASKED]",
261+
partialArgs: '{"text":"Message for John Smith"}',
262+
partialJson: '{"text":"Message for John Smith"}',
263+
arguments: {
264+
text: "Message for John Smith",
265+
nested: { title: "John Smith follow-up", count: 2 },
266+
recipients: ["John Smith", "ops", true],
267+
},
268+
});
269+
270+
const end = events[1] as { type?: string; toolCall?: ToolCall; partial?: AssistantMessage };
271+
expect(end.type).toBe("toolcall_end");
272+
expect(end.toolCall).toMatchObject({
273+
id: "call_[MASKED]",
274+
name: "send_[MASKED]",
275+
partialArgs: '{"text":"Message for John Smith"}',
276+
partialJson: '{"text":"Message for John Smith"}',
277+
arguments: {
278+
text: "Message for John Smith",
279+
nested: { title: "John Smith follow-up", count: 2 },
280+
recipients: ["John Smith", "ops", true],
281+
},
282+
});
283+
expect(result.content[0]).toMatchObject({
284+
id: "call_[MASKED]",
285+
name: "send_[MASKED]",
286+
partialArgs: '{"text":"Final message for John Smith"}',
287+
partialJson: '{"text":"Final message for John Smith"}',
288+
arguments: {
289+
text: "Final message for John Smith",
290+
nested: { title: "John Smith final", enabled: false },
291+
},
292+
});
293+
});
294+
295+
it("rewrites finalized tool call arguments before agent tool execution", async () => {
296+
const output = [{ from: /cat/g, to: "black cat" }];
297+
const capturedEvents: AgentEvent[] = [];
298+
const executedArgs: unknown[] = [];
299+
let turn = 0;
300+
const baseStreamFn: StreamFn = () => {
301+
turn += 1;
302+
const stream = createAssistantMessageEventStream();
303+
queueMicrotask(() => {
304+
const message =
305+
turn === 1
306+
? makeAssistantToolMessage({
307+
type: "toolCall",
308+
id: "call_read",
309+
name: "read",
310+
arguments: { path: "cat.txt" },
311+
partialArgs: '{"path":"cat.txt"}',
312+
} as ToolCall)
313+
: makeAssistantMessage("done");
314+
stream.push({
315+
type: "done",
316+
reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
317+
message,
318+
});
319+
stream.end();
320+
});
321+
return stream;
322+
};
323+
const wrapped = wrapStreamFnTextTransforms({
324+
streamFn: baseStreamFn,
325+
output,
326+
transformFinalResult: false,
327+
});
328+
329+
await runAgentLoop(
330+
[{ role: "user", content: "read it", timestamp: 1 }],
331+
{
332+
systemPrompt: "",
333+
messages: [],
334+
tools: [
335+
{
336+
name: "read",
337+
label: "read",
338+
description: "read",
339+
parameters: Type.Object({ path: Type.String() }, { additionalProperties: false }),
340+
execute: async (_toolCallId, args) => {
341+
executedArgs.push(args);
342+
return {
343+
content: [{ type: "text", text: "ok" }],
344+
details: args,
345+
terminate: true,
346+
};
347+
},
348+
},
349+
],
350+
},
351+
{
352+
model,
353+
convertToLlm: (messages) => messages as never,
354+
transformAssistantMessage: (message) => transformPluginMessageText(message, output),
355+
},
356+
(event) => {
357+
capturedEvents.push(event);
358+
},
359+
undefined,
360+
wrapped,
361+
);
362+
363+
const toolStart = capturedEvents.find(
364+
(event): event is Extract<AgentEvent, { type: "tool_execution_start" }> =>
365+
event.type === "tool_execution_start",
366+
);
367+
expect(toolStart?.args).toEqual({ path: "black cat.txt" });
368+
expect(executedArgs).toEqual([{ path: "black cat.txt" }]);
369+
});
183370
});

0 commit comments

Comments
 (0)