diff --git a/.changeset/rare-students-approve.md b/.changeset/rare-students-approve.md new file mode 100644 index 000000000000..f9466a918ed1 --- /dev/null +++ b/.changeset/rare-students-approve.md @@ -0,0 +1,6 @@ +--- +'@ai-sdk/provider-utils': patch +'ai': patch +--- + +Support modifying tool inputs during approval diff --git a/examples/ai-core/src/generate-text/openai-tool-approval.ts b/examples/ai-core/src/generate-text/openai-tool-approval.ts index 76f4cef05326..e1157098a5b6 100644 --- a/examples/ai-core/src/generate-text/openai-tool-approval.ts +++ b/examples/ai-core/src/generate-text/openai-tool-approval.ts @@ -25,6 +25,7 @@ const weatherTool = tool({ temperature: 72 + Math.floor(Math.random() * 21) - 10, }), needsApproval: true, + allowsInputEditing: true, }); run(async () => { @@ -61,14 +62,28 @@ run(async () => { if (part.type === 'tool-approval-request') { if (part.toolCall.toolName === 'weather' && !part.toolCall.dynamic) { const answer = await terminal.question( - `\nCan I retrieve the weather for ${part.toolCall.input.location} (y/n)?`, + `\nCan I retrieve the weather for ${part.toolCall.input.location} (y/n/e)?`, ); + const approved = ['y', 'yes', 'e', 'edit'].includes( + answer.toLowerCase(), + ); + const edit = ['e', 'edit'].includes(answer.toLowerCase()); + + let override = undefined; + + if (edit) { + const newLocation = await terminal.question( + `Enter new location (current: ${part.toolCall.input.location}): `, + ); + override = { input: { location: newLocation } }; + } + approvals.push({ type: 'tool-approval-response', approvalId: part.approvalId, - approved: - answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes', + approved, + override, }); } } diff --git a/examples/next-openai/components/tool/weather-with-approval-view.tsx b/examples/next-openai/components/tool/weather-with-approval-view.tsx index 48ebecf3eeac..f33b2ba4457c 100644 --- a/examples/next-openai/components/tool/weather-with-approval-view.tsx +++ b/examples/next-openai/components/tool/weather-with-approval-view.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { WeatherUIToolWithApprovalInvocation } from '@/tool/weather-tool-with-approval'; import type { ChatAddToolApproveResponseFunction } from 'ai'; @@ -8,20 +9,34 @@ export default function WeatherWithApprovalView({ invocation: WeatherUIToolWithApprovalInvocation; addToolApprovalResponse: ChatAddToolApproveResponseFunction; }) { + const [city, setCity] = useState(invocation.input?.city ?? ''); + switch (invocation.state) { case 'approval-requested': return (
- Can I retrieve the weather for {invocation.input.city}? -
+
Can I retrieve the weather for {invocation.input.city}?
+ {invocation.approval.allowsInputEditing && ( + setCity(e.target.value)} + className="mt-2 px-2 py-1 border rounded" + /> + )} +
@@ -42,7 +57,8 @@ export default function WeatherWithApprovalView({ case 'approval-responded': return (
- Can I retrieve the weather for {invocation.input.city}? + Can I retrieve the weather for{' '} + {invocation.approval.override?.input.city ?? invocation.input.city}?
{invocation.approval.approved ? 'Approved' : 'Denied'}
); @@ -52,7 +68,7 @@ export default function WeatherWithApprovalView({
{invocation.output.state === 'loading' ? 'Fetching weather information...' - : `Weather in ${invocation.input.city}: ${invocation.output.weather}`} + : `Weather in ${invocation.approval?.override?.input.city ?? invocation.input.city}: ${invocation.output.weather}`}
); case 'output-denied': diff --git a/examples/next-openai/tool/weather-tool-with-approval.ts b/examples/next-openai/tool/weather-tool-with-approval.ts index b191e89d5d7e..de71016629a6 100644 --- a/examples/next-openai/tool/weather-tool-with-approval.ts +++ b/examples/next-openai/tool/weather-tool-with-approval.ts @@ -10,6 +10,7 @@ export const weatherToolWithApproval = tool({ description: 'Get the weather in a location', inputSchema: z.object({ city: z.string() }), needsApproval: true, + allowsInputEditing: true, async *execute() { yield { state: 'loading' as const }; diff --git a/packages/ai/src/generate-text/generate-text.test.ts b/packages/ai/src/generate-text/generate-text.test.ts index c81eb8f4aa42..4bbbeb77f5c0 100644 --- a/packages/ai/src/generate-text/generate-text.test.ts +++ b/packages/ai/src/generate-text/generate-text.test.ts @@ -3928,6 +3928,7 @@ describe('generateText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCall": { "input": { @@ -3962,6 +3963,7 @@ describe('generateText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -4066,6 +4068,7 @@ describe('generateText', () => { "type": "tool-result", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCall": { "input": { @@ -4110,6 +4113,7 @@ describe('generateText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCallId": "call-1", "type": "tool-approval-request", diff --git a/packages/ai/src/generate-text/generate-text.ts b/packages/ai/src/generate-text/generate-text.ts index b13ccb4ce81d..fa34979692cd 100644 --- a/packages/ai/src/generate-text/generate-text.ts +++ b/packages/ai/src/generate-text/generate-text.ts @@ -36,6 +36,7 @@ import { DownloadFunction } from '../util/download/download-function'; import { prepareRetries } from '../util/prepare-retries'; import { VERSION } from '../version'; import { collectToolApprovals } from './collect-tool-approvals'; +import { validateAndApplyToolInputOverrides } from './validate-and-apply-tool-input-overrides'; import { ContentPart } from './content-part'; import { executeToolCall } from './execute-tool-call'; import { extractTextContent } from './extract-text-content'; @@ -347,10 +348,13 @@ A function that attempts to repair a tool call that failed to parse. deniedToolApprovals.length > 0 || approvedToolApprovals.length > 0 ) { + const { validToolCalls, invalidToolErrors } = + validateAndApplyToolInputOverrides({ + approvals: approvedToolApprovals, + }); + const toolOutputs = await executeTools({ - toolCalls: approvedToolApprovals.map( - toolApproval => toolApproval.toolCall, - ), + toolCalls: validToolCalls, tools: tools as TOOLS, tracer, telemetry, @@ -359,11 +363,13 @@ A function that attempts to repair a tool call that failed to parse. experimental_context, }); + const allToolOutputs = [...toolOutputs, ...invalidToolErrors]; + responseMessages.push({ role: 'tool', content: [ // add regular tool results for approved tool calls: - ...toolOutputs.map(output => ({ + ...allToolOutputs.map(output => ({ type: 'tool-result' as const, toolCallId: output.toolCallId, toolName: output.toolName, @@ -595,6 +601,7 @@ A function that attempts to repair a tool call that failed to parse. type: 'tool-approval-request', approvalId: generateId(), toolCall, + allowsInputEditing: tool.allowsInputEditing, }; } } diff --git a/packages/ai/src/generate-text/run-tools-transformation.test.ts b/packages/ai/src/generate-text/run-tools-transformation.test.ts index 59f2630bb18c..b6085cbacc01 100644 --- a/packages/ai/src/generate-text/run-tools-transformation.test.ts +++ b/packages/ai/src/generate-text/run-tools-transformation.test.ts @@ -590,6 +590,7 @@ describe('runToolsTransformation', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-0", "toolCall": { "input": { diff --git a/packages/ai/src/generate-text/run-tools-transformation.ts b/packages/ai/src/generate-text/run-tools-transformation.ts index 4811abf23632..a5ef966fca77 100644 --- a/packages/ai/src/generate-text/run-tools-transformation.ts +++ b/packages/ai/src/generate-text/run-tools-transformation.ts @@ -272,6 +272,7 @@ export function runToolsTransformation({ type: 'tool-approval-request', approvalId: generateId(), toolCall, + allowsInputEditing: tool.allowsInputEditing, }); break; } diff --git a/packages/ai/src/generate-text/stream-text.test.ts b/packages/ai/src/generate-text/stream-text.test.ts index b635a13073a9..8c6ef2660007 100644 --- a/packages/ai/src/generate-text/stream-text.test.ts +++ b/packages/ai/src/generate-text/stream-text.test.ts @@ -14595,6 +14595,7 @@ describe('streamText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCall": { "input": { @@ -14661,6 +14662,7 @@ describe('streamText', () => { "type": "tool-input-available", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -14691,6 +14693,7 @@ describe('streamText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCall": { "input": { @@ -14725,6 +14728,7 @@ describe('streamText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -14811,6 +14815,7 @@ describe('streamText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCall": { "input": { @@ -14898,6 +14903,7 @@ describe('streamText', () => { "type": "tool-input-available", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -14949,6 +14955,7 @@ describe('streamText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCall": { "input": { @@ -15004,6 +15011,7 @@ describe('streamText', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "id-1", "toolCallId": "call-1", "type": "tool-approval-request", diff --git a/packages/ai/src/generate-text/stream-text.ts b/packages/ai/src/generate-text/stream-text.ts index 7f85024ee86d..08dd08a798ae 100644 --- a/packages/ai/src/generate-text/stream-text.ts +++ b/packages/ai/src/generate-text/stream-text.ts @@ -63,6 +63,7 @@ import { DownloadFunction } from '../util/download/download-function'; import { now as originalNow } from '../util/now'; import { prepareRetries } from '../util/prepare-retries'; import { collectToolApprovals } from './collect-tool-approvals'; +import { validateAndApplyToolInputOverrides } from './validate-and-apply-tool-input-overrides'; import { ContentPart } from './content-part'; import { executeToolCall } from './execute-tool-call'; import { Output, text } from './output'; @@ -88,6 +89,7 @@ import { import { toResponseMessages } from './to-response-messages'; import { TypedToolCall } from './tool-call'; import { ToolCallRepairFunction } from './tool-call-repair-function'; +import { TypedToolError } from './tool-error'; import { ToolOutput } from './tool-output'; import { StaticToolOutputDenied } from './tool-output-denied'; import { ToolSet } from './tool-set'; @@ -1106,12 +1108,17 @@ class DefaultStreamTextResult } as StaticToolOutputDenied); } + const { validToolCalls, invalidToolErrors } = + validateAndApplyToolInputOverrides({ + approvals: approvedToolApprovals, + }); + const toolOutputs: Array> = []; await Promise.all( - approvedToolApprovals.map(async toolApproval => { + validToolCalls.map(async toolCall => { const result = await executeToolCall({ - toolCall: toolApproval.toolCall, + toolCall, tools, tracer, telemetry, @@ -1130,11 +1137,13 @@ class DefaultStreamTextResult }), ); + const allToolOutputs = [...toolOutputs, ...invalidToolErrors]; + initialResponseMessages.push({ role: 'tool', content: [ // add regular tool results for approved tool calls: - ...toolOutputs.map(output => ({ + ...allToolOutputs.map(output => ({ type: 'tool-result' as const, toolCallId: output.toolCallId, toolName: output.toolName, @@ -2071,6 +2080,7 @@ However, the LLM results are expected to be small enough to not cause issues. type: 'tool-approval-request', approvalId: part.approvalId, toolCallId: part.toolCall.toolCallId, + allowsInputEditing: part.allowsInputEditing, }); break; } diff --git a/packages/ai/src/generate-text/to-response-messages.test.ts b/packages/ai/src/generate-text/to-response-messages.test.ts index 01964dbfbb80..2a5120a8b4b4 100644 --- a/packages/ai/src/generate-text/to-response-messages.test.ts +++ b/packages/ai/src/generate-text/to-response-messages.test.ts @@ -802,4 +802,69 @@ describe('toResponseMessages', () => { ] `); }); + + it('should include tool approval requests with allowsInputEditing', () => { + const result = toResponseMessages({ + content: [ + { + type: 'text', + text: 'I need approval to call this tool.', + }, + { + type: 'tool-call', + toolCallId: '123', + toolName: 'testTool', + input: { location: 'London' }, + }, + { + type: 'tool-approval-request', + approvalId: 'approval-123', + toolCall: { + type: 'tool-call', + toolCallId: '123', + toolName: 'testTool', + input: { location: 'London' }, + }, + allowsInputEditing: true, + }, + ], + tools: { + testTool: tool({ + description: 'A test tool', + inputSchema: z.object({ location: z.string() }), + }), + }, + }); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "providerOptions": undefined, + "text": "I need approval to call this tool.", + "type": "text", + }, + { + "input": { + "location": "London", + }, + "providerExecuted": undefined, + "providerOptions": undefined, + "toolCallId": "123", + "toolName": "testTool", + "type": "tool-call", + }, + { + "allowsInputEditing": true, + "approvalId": "approval-123", + "toolCallId": "123", + "type": "tool-approval-request", + }, + ], + "role": "assistant", + }, + ] + `); + }); }); diff --git a/packages/ai/src/generate-text/to-response-messages.ts b/packages/ai/src/generate-text/to-response-messages.ts index b6da3282ef3d..4d2756ae06e6 100644 --- a/packages/ai/src/generate-text/to-response-messages.ts +++ b/packages/ai/src/generate-text/to-response-messages.ts @@ -88,6 +88,7 @@ export function toResponseMessages({ type: 'tool-approval-request', approvalId: part.approvalId, toolCallId: part.toolCall.toolCallId, + allowsInputEditing: part.allowsInputEditing, }; } }); diff --git a/packages/ai/src/generate-text/tool-approval-request-output.ts b/packages/ai/src/generate-text/tool-approval-request-output.ts index 2880b67e0a78..0116c2c4d28c 100644 --- a/packages/ai/src/generate-text/tool-approval-request-output.ts +++ b/packages/ai/src/generate-text/tool-approval-request-output.ts @@ -18,4 +18,9 @@ export type ToolApprovalRequestOutput = { * Tool call that the approval request is for. */ toolCall: TypedToolCall; + + /** + * Whether the tool allows input modification during approval. + */ + allowsInputEditing?: boolean; }; diff --git a/packages/ai/src/generate-text/validate-and-apply-tool-input-overrides.test.ts b/packages/ai/src/generate-text/validate-and-apply-tool-input-overrides.test.ts new file mode 100644 index 000000000000..af7ec4450b2b --- /dev/null +++ b/packages/ai/src/generate-text/validate-and-apply-tool-input-overrides.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest'; +import { validateAndApplyToolInputOverrides } from './validate-and-apply-tool-input-overrides'; +import { CollectedToolApprovals } from './collect-tool-approvals'; + +describe('validateAndApplyToolInputOverrides', () => { + it('should return valid tool calls when no override is provided', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'London' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: true, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls).toEqual([ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'London' }, + }, + ]); + expect(result.invalidToolErrors).toEqual([]); + }); + + it('should return valid tool calls when override is provided and allowed', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'London' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: true, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + override: { input: { city: 'Paris' } }, + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls).toEqual([ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Paris' }, + }, + ]); + expect(result.invalidToolErrors).toEqual([]); + }); + + it('should return invalid tool error when override is provided but not allowed', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'calculator', + input: { expression: '1+1' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: false, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + override: { input: { expression: '2+2' } }, + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls).toEqual([]); + expect(result.invalidToolErrors).toMatchInlineSnapshot(` + [ + { + "dynamic": undefined, + "error": "Tool 'calculator' does not allow input modification.", + "input": { + "expression": "2+2", + }, + "toolCallId": "call-1", + "toolName": "calculator", + "type": "tool-error", + }, + ] + `); + }); + + it('should return invalid tool error when override is provided but inputEditable is undefined', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'timer', + input: { duration: 60 }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: undefined, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + override: { input: { duration: 120 } }, + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls).toEqual([]); + expect(result.invalidToolErrors).toMatchInlineSnapshot(` + [ + { + "dynamic": undefined, + "error": "Tool 'timer' does not allow input modification.", + "input": { + "duration": 120, + }, + "toolCallId": "call-1", + "toolName": "timer", + "type": "tool-error", + }, + ] + `); + }); + + it('should handle multiple approvals with mixed valid and invalid', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'London' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: true, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + override: { input: { city: 'Paris' } }, + }, + }, + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'calculator', + input: { expression: '1+1' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-2', + toolCallId: 'call-2', + allowsInputEditing: false, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-2', + approved: true, + override: { input: { expression: '2+2' } }, + }, + }, + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-3', + toolName: 'weather', + input: { city: 'Berlin' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-3', + toolCallId: 'call-3', + allowsInputEditing: true, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-3', + approved: true, + // No override + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls).toEqual([ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Paris' }, + }, + { + type: 'tool-call', + toolCallId: 'call-3', + toolName: 'weather', + input: { city: 'Berlin' }, + }, + ]); + expect(result.invalidToolErrors).toHaveLength(1); + expect(result.invalidToolErrors[0].toolCallId).toBe('call-2'); + expect(result.invalidToolErrors[0].toolName).toBe('calculator'); + }); + + it('should handle empty approvals array', () => { + const result = validateAndApplyToolInputOverrides({ approvals: [] }); + + expect(result.validToolCalls).toEqual([]); + expect(result.invalidToolErrors).toEqual([]); + }); + + it('should preserve tool call properties when valid', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'London' }, + providerExecuted: true, + providerMetadata: { test: 'data' } as any, + dynamic: true, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: true, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + override: { input: { city: 'Paris' } }, + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls[0]).toMatchObject({ + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Paris' }, + providerExecuted: true, + providerMetadata: { test: 'data' }, + dynamic: true, + }); + }); + + it('should use original input when override is not provided', () => { + const approvals: CollectedToolApprovals[] = [ + { + toolCall: { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'London' }, + }, + approvalRequest: { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-1', + allowsInputEditing: true, + }, + approvalResponse: { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + // No override + }, + }, + ]; + + const result = validateAndApplyToolInputOverrides({ approvals }); + + expect(result.validToolCalls[0].input).toEqual({ city: 'London' }); + }); +}); diff --git a/packages/ai/src/generate-text/validate-and-apply-tool-input-overrides.ts b/packages/ai/src/generate-text/validate-and-apply-tool-input-overrides.ts new file mode 100644 index 000000000000..3a34a09af498 --- /dev/null +++ b/packages/ai/src/generate-text/validate-and-apply-tool-input-overrides.ts @@ -0,0 +1,44 @@ +import { TypedToolCall } from './tool-call'; +import { TypedToolError } from './tool-error'; +import { ToolSet } from './tool-set'; +import { CollectedToolApprovals } from './collect-tool-approvals'; + +/** + * Validates that tool input overrides are allowed and applies them. + */ +export function validateAndApplyToolInputOverrides({ + approvals, +}: { + approvals: Array>; +}): { + validToolCalls: Array>; + invalidToolErrors: Array>; +} { + const validToolCalls: Array> = []; + const invalidToolErrors: Array> = []; + + for (const approval of approvals) { + if ( + approval.approvalResponse.override !== undefined && + !approval.approvalRequest.allowsInputEditing + ) { + invalidToolErrors.push({ + type: 'tool-error' as const, + toolCallId: approval.toolCall.toolCallId, + toolName: approval.toolCall.toolName, + input: approval.approvalResponse.override.input, + error: `Tool '${approval.toolCall.toolName}' does not allow input modification.`, + dynamic: approval.toolCall.dynamic, + } as TypedToolError); + continue; + } + + validToolCalls.push({ + ...approval.toolCall, + input: + approval.approvalResponse.override?.input ?? approval.toolCall.input, + }); + } + + return { validToolCalls, invalidToolErrors }; +} diff --git a/packages/ai/src/ui-message-stream/ui-message-chunks.ts b/packages/ai/src/ui-message-stream/ui-message-chunks.ts index d5bb34d3f221..96b44a4f1d0a 100644 --- a/packages/ai/src/ui-message-stream/ui-message-chunks.ts +++ b/packages/ai/src/ui-message-stream/ui-message-chunks.ts @@ -74,6 +74,7 @@ export const uiMessageChunkSchema = lazySchema(() => type: z.literal('tool-approval-request'), approvalId: z.string(), toolCallId: z.string(), + allowsInputEditing: z.boolean().optional(), }), z.strictObject({ type: z.literal('tool-output-available'), @@ -252,6 +253,7 @@ export type UIMessageChunk< type: 'tool-approval-request'; approvalId: string; toolCallId: string; + allowsInputEditing?: boolean; } | { type: 'tool-output-available'; diff --git a/packages/ai/src/ui/chat.test.ts b/packages/ai/src/ui/chat.test.ts index e0c1a58faf7c..038d2c0f5840 100644 --- a/packages/ai/src/ui/chat.test.ts +++ b/packages/ai/src/ui/chat.test.ts @@ -2385,6 +2385,7 @@ describe('Chat', () => { "approval": { "approved": true, "id": "approval-1", + "override": undefined, "reason": undefined, }, "input": { @@ -2496,6 +2497,7 @@ describe('Chat', () => { "approval": { "approved": true, "id": "approval-1", + "override": undefined, "reason": undefined, }, "errorText": undefined, @@ -2529,6 +2531,91 @@ describe('Chat', () => { `); }); }); + + describe('approved with override', () => { + let chat: TestChat; + + beforeEach(async () => { + chat = new TestChat({ + id: '123', + generateId: mockId({ prefix: 'newid' }), + transport: new DefaultChatTransport({ + api: 'http://localhost:3000/api/chat', + }), + messages: [ + { + id: 'id-0', + role: 'user', + parts: [{ text: 'What is the weather in Tokyo?', type: 'text' }], + }, + { + id: 'id-1', + role: 'assistant', + parts: [ + { type: 'step-start' }, + { + type: 'tool-weather', + toolCallId: 'call-1', + state: 'approval-requested', + input: { city: 'Tokyo' }, + approval: { id: 'approval-1' }, + }, + ], + }, + ], + }); + + await chat.addToolApprovalResponse({ + id: 'approval-1', + approved: true, + override: { input: { city: 'Paris' } }, + }); + }); + + it('should update tool invocation to show the modified input', () => { + expect(chat.messages).toMatchInlineSnapshot(` + [ + { + "id": "id-0", + "parts": [ + { + "text": "What is the weather in Tokyo?", + "type": "text", + }, + ], + "role": "user", + }, + { + "id": "id-1", + "parts": [ + { + "type": "step-start", + }, + { + "approval": { + "approved": true, + "id": "approval-1", + "override": { + "input": { + "city": "Paris", + }, + }, + "reason": undefined, + }, + "input": { + "city": "Tokyo", + }, + "state": "approval-responded", + "toolCallId": "call-1", + "type": "tool-weather", + }, + ], + "role": "assistant", + }, + ] + `); + }); + }); }); describe('addToolResult', () => { diff --git a/packages/ai/src/ui/chat.ts b/packages/ai/src/ui/chat.ts index 1b505741289d..9ef16eb25971 100644 --- a/packages/ai/src/ui/chat.ts +++ b/packages/ai/src/ui/chat.ts @@ -69,6 +69,7 @@ export type ChatAddToolApproveResponseFunction = ({ id, approved, reason, + override, }: { id: string; @@ -81,6 +82,16 @@ export type ChatAddToolApproveResponseFunction = ({ * Optional reason for the approval or denial. */ reason?: string; + + /** + * Optional override for the tool input. + */ + override?: { + /** + * The modified input to use instead of the original. + */ + input: unknown; + }; }) => void | PromiseLike; export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error'; @@ -433,6 +444,7 @@ export abstract class AbstractChat { id, approved, reason, + override, }) => this.jobExecutor.run(async () => { const messages = this.state.messages; @@ -447,7 +459,7 @@ export abstract class AbstractChat { ? { ...part, state: 'approval-responded', - approval: { id, approved, reason }, + approval: { id, approved, reason, override }, } : part; diff --git a/packages/ai/src/ui/convert-to-model-messages.test.ts b/packages/ai/src/ui/convert-to-model-messages.test.ts index 7ff698e5efff..a655f01a1d75 100644 --- a/packages/ai/src/ui/convert-to-model-messages.test.ts +++ b/packages/ai/src/ui/convert-to-model-messages.test.ts @@ -1400,6 +1400,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1412,6 +1413,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": true, + "override": undefined, "reason": undefined, "type": "tool-approval-response", }, @@ -1480,6 +1482,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1492,6 +1495,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": true, + "override": undefined, "reason": undefined, "type": "tool-approval-response", }, @@ -1565,6 +1569,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1577,6 +1582,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": false, + "override": undefined, "reason": "I don't want to approve this", "type": "tool-approval-response", }, @@ -1660,6 +1666,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1672,6 +1679,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": false, + "override": undefined, "reason": "I don't want to approve this", "type": "tool-approval-response", }, @@ -1748,6 +1756,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1760,6 +1769,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": false, + "override": undefined, "reason": "I don't want to approve this", "type": "tool-approval-response", }, @@ -1837,6 +1847,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1849,6 +1860,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": false, + "override": undefined, "reason": "I don't want to approve this", "type": "tool-approval-response", }, @@ -1932,6 +1944,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -1944,6 +1957,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": true, + "override": undefined, "reason": undefined, "type": "tool-approval-response", }, @@ -2036,6 +2050,7 @@ describe('convertToModelMessages', () => { "type": "tool-call", }, { + "allowsInputEditing": undefined, "approvalId": "approval-1", "toolCallId": "call-1", "type": "tool-approval-request", @@ -2048,6 +2063,7 @@ describe('convertToModelMessages', () => { { "approvalId": "approval-1", "approved": true, + "override": undefined, "reason": undefined, "type": "tool-approval-response", }, @@ -2075,6 +2091,177 @@ describe('convertToModelMessages', () => { ] `); }); + + it('should convert approved tool approval with override (static tool)', () => { + const result = convertToModelMessages([ + { + parts: [ + { + text: 'What is the weather in Tokyo?', + type: 'text', + }, + ], + role: 'user', + }, + { + parts: [ + { + type: 'step-start', + }, + { + approval: { + approved: true, + id: 'approval-1', + override: { input: { city: 'Paris' } }, + }, + input: { + city: 'Tokyo', + }, + state: 'approval-responded', + toolCallId: 'call-1', + type: 'tool-weather', + }, + ], + role: 'assistant', + }, + ]); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "What is the weather in Tokyo?", + "type": "text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "input": { + "city": "Tokyo", + }, + "providerExecuted": undefined, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-call", + }, + { + "allowsInputEditing": undefined, + "approvalId": "approval-1", + "toolCallId": "call-1", + "type": "tool-approval-request", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "approvalId": "approval-1", + "approved": true, + "override": { + "input": { + "city": "Paris", + }, + }, + "reason": undefined, + "type": "tool-approval-response", + }, + ], + "role": "tool", + }, + ] + `); + }); + + it('should convert approved tool approval with override (dynamic tool)', () => { + const result = convertToModelMessages([ + { + parts: [ + { + text: 'What is the weather in Tokyo?', + type: 'text', + }, + ], + role: 'user', + }, + { + parts: [ + { + type: 'step-start', + }, + { + approval: { + approved: true, + id: 'approval-1', + override: { input: { city: 'London' } }, + }, + input: { + city: 'Tokyo', + }, + state: 'approval-responded', + toolCallId: 'call-1', + type: 'dynamic-tool', + toolName: 'weather', + }, + ], + role: 'assistant', + }, + ]); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "What is the weather in Tokyo?", + "type": "text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "input": { + "city": "Tokyo", + }, + "providerExecuted": undefined, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-call", + }, + { + "allowsInputEditing": undefined, + "approvalId": "approval-1", + "toolCallId": "call-1", + "type": "tool-approval-request", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "approvalId": "approval-1", + "approved": true, + "override": { + "input": { + "city": "London", + }, + }, + "reason": undefined, + "type": "tool-approval-response", + }, + ], + "role": "tool", + }, + ] + `); + }); }); describe('data part conversion', () => { diff --git a/packages/ai/src/ui/convert-to-model-messages.ts b/packages/ai/src/ui/convert-to-model-messages.ts index 454b247d7480..06b717e95a78 100644 --- a/packages/ai/src/ui/convert-to-model-messages.ts +++ b/packages/ai/src/ui/convert-to-model-messages.ts @@ -193,6 +193,10 @@ export function convertToModelMessages( type: 'tool-approval-request' as const, approvalId: part.approval.id, toolCallId: part.toolCallId, + allowsInputEditing: + part.state === 'approval-requested' + ? part.approval.allowsInputEditing + : undefined, }); } @@ -266,6 +270,7 @@ export function convertToModelMessages( approvalId: toolPart.approval.id, approved: toolPart.approval.approved, reason: toolPart.approval.reason, + override: toolPart.approval.override, }); } diff --git a/packages/ai/src/ui/process-ui-message-stream.test.ts b/packages/ai/src/ui/process-ui-message-stream.test.ts index dfacec9a8841..3e0cd7642283 100644 --- a/packages/ai/src/ui/process-ui-message-stream.test.ts +++ b/packages/ai/src/ui/process-ui-message-stream.test.ts @@ -6652,6 +6652,7 @@ describe('processUIMessageStream', () => { }, { "approval": { + "allowsInputEditing": undefined, "id": "id-1", }, "errorText": undefined, @@ -6683,6 +6684,7 @@ describe('processUIMessageStream', () => { }, { "approval": { + "allowsInputEditing": undefined, "id": "id-1", }, "errorText": undefined, @@ -6789,6 +6791,7 @@ describe('processUIMessageStream', () => { }, { "approval": { + "allowsInputEditing": undefined, "id": "id-1", }, "errorText": undefined, @@ -6820,6 +6823,7 @@ describe('processUIMessageStream', () => { }, { "approval": { + "allowsInputEditing": undefined, "id": "id-1", }, "errorText": undefined, diff --git a/packages/ai/src/ui/process-ui-message-stream.ts b/packages/ai/src/ui/process-ui-message-stream.ts index bde25d2eacd1..9962c0723a5b 100644 --- a/packages/ai/src/ui/process-ui-message-stream.ts +++ b/packages/ai/src/ui/process-ui-message-stream.ts @@ -537,7 +537,10 @@ export function processUIMessageStream({ case 'tool-approval-request': { const toolInvocation = getToolInvocation(chunk.toolCallId); toolInvocation.state = 'approval-requested'; - toolInvocation.approval = { id: chunk.approvalId }; + toolInvocation.approval = { + id: chunk.approvalId, + allowsInputEditing: chunk.allowsInputEditing, + }; write(); break; } diff --git a/packages/ai/src/ui/ui-messages.ts b/packages/ai/src/ui/ui-messages.ts index 138c11790662..0b52e107250f 100644 --- a/packages/ai/src/ui/ui-messages.ts +++ b/packages/ai/src/ui/ui-messages.ts @@ -253,6 +253,7 @@ export type UIToolInvocation = { id: string; approved?: never; reason?: never; + allowsInputEditing?: boolean; }; } | { @@ -265,6 +266,7 @@ export type UIToolInvocation = { id: string; approved: boolean; reason?: string; + override?: { input: asUITool['input'] }; }; } | { @@ -278,6 +280,7 @@ export type UIToolInvocation = { id: string; approved: true; reason?: string; + override?: { input: asUITool['input'] }; }; } | { @@ -291,6 +294,7 @@ export type UIToolInvocation = { id: string; approved: true; reason?: string; + override?: { input: asUITool['input'] }; }; } | { @@ -303,6 +307,7 @@ export type UIToolInvocation = { id: string; approved: false; reason?: string; + override?: { input: asUITool['input'] }; }; } ); @@ -357,6 +362,7 @@ export type DynamicToolUIPart = { id: string; approved?: never; reason?: never; + allowsInputEditing?: boolean; }; } | { @@ -369,6 +375,7 @@ export type DynamicToolUIPart = { id: string; approved: boolean; reason?: string; + override?: { input: unknown }; }; } | { @@ -382,6 +389,7 @@ export type DynamicToolUIPart = { id: string; approved: true; reason?: string; + override?: { input: unknown }; }; } | { @@ -394,6 +402,7 @@ export type DynamicToolUIPart = { id: string; approved: true; reason?: string; + override?: { input: unknown }; }; } | { @@ -406,6 +415,7 @@ export type DynamicToolUIPart = { id: string; approved: false; reason?: string; + override?: { input: unknown }; }; } ); diff --git a/packages/ai/src/ui/validate-ui-messages.ts b/packages/ai/src/ui/validate-ui-messages.ts index 0ae0300862f5..a7d25cb28f59 100644 --- a/packages/ai/src/ui/validate-ui-messages.ts +++ b/packages/ai/src/ui/validate-ui-messages.ts @@ -108,6 +108,7 @@ const uiMessagesSchema = lazySchema(() => id: z.string(), approved: z.never().optional(), reason: z.never().optional(), + allowsInputEditing: z.boolean().optional(), }), }), z.object({ @@ -213,6 +214,7 @@ const uiMessagesSchema = lazySchema(() => id: z.string(), approved: z.never().optional(), reason: z.never().optional(), + allowsInputEditing: z.boolean().optional(), }), }), z.object({ diff --git a/packages/provider-utils/src/types/tool-approval-request.ts b/packages/provider-utils/src/types/tool-approval-request.ts index 25d390bc56c1..997870559adc 100644 --- a/packages/provider-utils/src/types/tool-approval-request.ts +++ b/packages/provider-utils/src/types/tool-approval-request.ts @@ -13,4 +13,9 @@ export type ToolApprovalRequest = { * ID of the tool call that the approval request is for. */ toolCallId: string; + + /** + * Whether the tool allows input modification during approval. + */ + allowsInputEditing?: boolean; }; diff --git a/packages/provider-utils/src/types/tool-approval-response.ts b/packages/provider-utils/src/types/tool-approval-response.ts index c13e87c4842f..efd5a199ccb0 100644 --- a/packages/provider-utils/src/types/tool-approval-response.ts +++ b/packages/provider-utils/src/types/tool-approval-response.ts @@ -18,4 +18,14 @@ export type ToolApprovalResponse = { * Optional reason for the approval or denial. */ reason?: string; + + /** + * Optional override for the tool input. + */ + override?: { + /** + * The modified input to use instead of the original. + */ + input: unknown; + }; }; diff --git a/packages/provider-utils/src/types/tool.ts b/packages/provider-utils/src/types/tool.ts index 557e70c72936..e3c99603eea2 100644 --- a/packages/provider-utils/src/types/tool.ts +++ b/packages/provider-utils/src/types/tool.ts @@ -143,6 +143,11 @@ functionality that can be fully encapsulated in the provider. | boolean | ToolNeedsApprovalFunction<[INPUT] extends [never] ? unknown : INPUT>; + /** +Whether the tool allows input modification during approval. + */ + allowsInputEditing?: boolean; + /** * Strict mode setting for the tool. * @@ -261,6 +266,11 @@ export function dynamicTool(tool: { * Whether the tool needs approval before it can be executed. */ needsApproval?: boolean | ToolNeedsApprovalFunction; + + /** + * Whether the tool allows input modification during approval. + */ + allowsInputEditing?: boolean; }): Tool & { type: 'dynamic'; } {