feat(agent,coding-agent): add prepareArguments hook for pre-validation argument preparation
Add AgentTool.prepareArguments and ToolDefinition.prepareArguments hook that runs before schema validation in the agent loop. This lets tools silently accept legacy argument shapes from resumed old sessions without polluting the public schema. The built-in edit tool uses this to fold legacy top-level oldText/newText into edits[] when resuming sessions that predate the edits-only schema. - AgentTool/ToolDefinition: typed prepareArguments returning Static<TParameters> - agent-loop: prepareToolCallArguments() runs before validateToolArguments() - edit tool: prepareEditArguments folds legacy fields, validateEditInput is strict - Documented in extensions.md with edit-tool example
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `AgentTool.prepareArguments` hook to prepare raw tool call arguments before schema validation, enabling compatibility shims for resumed sessions with outdated tool schemas
|
||||
|
||||
## [0.63.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -455,6 +455,20 @@ type ExecutedToolCallOutcome = {
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
function prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {
|
||||
if (!tool.prepareArguments) {
|
||||
return toolCall;
|
||||
}
|
||||
const preparedArguments = tool.prepareArguments(toolCall.arguments);
|
||||
if (preparedArguments === toolCall.arguments) {
|
||||
return toolCall;
|
||||
}
|
||||
return {
|
||||
...toolCall,
|
||||
arguments: preparedArguments as Record<string, any>,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareToolCall(
|
||||
currentContext: AgentContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
@@ -472,7 +486,8 @@ async function prepareToolCall(
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedArgs = validateToolArguments(tool, toolCall);
|
||||
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
|
||||
const validatedArgs = validateToolArguments(tool, preparedToolCall);
|
||||
if (config.beforeToolCall) {
|
||||
const beforeResult = await config.beforeToolCall(
|
||||
{
|
||||
|
||||
@@ -269,10 +269,13 @@ export interface AgentToolResult<T> {
|
||||
// Callback for streaming tool execution updates
|
||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||
|
||||
// AgentTool extends Tool but adds the execute function
|
||||
// AgentTool extends Tool but adds argument preparation and execution hooks
|
||||
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
|
||||
// A human-readable label for the tool to be displayed in UI
|
||||
label: string;
|
||||
// Optional compatibility shim to prepare raw tool call arguments before schema validation.
|
||||
// Must return an object conforming to TParameters.
|
||||
prepareArguments?: (args: unknown) => Static<TParameters>;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
|
||||
@@ -369,6 +369,86 @@ describe("agentLoop with AgentMessage", () => {
|
||||
expect(executed).toEqual([123]);
|
||||
});
|
||||
|
||||
it("should prepare tool arguments for validation", async () => {
|
||||
const replaceSchema = Type.Object({ oldText: Type.String(), newText: Type.String() });
|
||||
const toolSchema = Type.Object({ edits: Type.Array(replaceSchema) });
|
||||
const executed: Array<Array<{ oldText: string; newText: string }>> = [];
|
||||
const tool: AgentTool<typeof toolSchema, { count: number }> = {
|
||||
name: "edit",
|
||||
label: "Edit",
|
||||
description: "Edit tool",
|
||||
parameters: toolSchema,
|
||||
prepareArguments(args) {
|
||||
if (!args || typeof args !== "object") {
|
||||
return args as { edits: { oldText: string; newText: string }[] };
|
||||
}
|
||||
const input = args as {
|
||||
edits?: Array<{ oldText: string; newText: string }>;
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
};
|
||||
if (typeof input.oldText !== "string" || typeof input.newText !== "string") {
|
||||
return args as { edits: { oldText: string; newText: string }[] };
|
||||
}
|
||||
return {
|
||||
edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }],
|
||||
};
|
||||
},
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.edits);
|
||||
return {
|
||||
content: [{ type: "text", text: `edited ${params.edits.length}` }],
|
||||
details: { count: params.edits.length },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const userPrompt: AgentMessage = createUserMessage("edit something");
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
};
|
||||
|
||||
let callIndex = 0;
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callIndex === 0) {
|
||||
const message = createAssistantMessage(
|
||||
[
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "tool-1",
|
||||
name: "edit",
|
||||
arguments: { oldText: "before", newText: "after" },
|
||||
},
|
||||
],
|
||||
"toolUse",
|
||||
);
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
} else {
|
||||
const message = createAssistantMessage([{ type: "text", text: "done" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
}
|
||||
callIndex++;
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
||||
for await (const _event of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(executed).toEqual([[{ oldText: "before", newText: "after" }]]);
|
||||
});
|
||||
|
||||
it("should execute tool calls in parallel and emit tool results in source order", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
let firstResolved = false;
|
||||
|
||||
Reference in New Issue
Block a user