setCity(e.target.value)}
+ className="mt-2 px-2 py-1 border rounded"
+ />
+ )}
+
+ onClick={() => {
+ const trimmed = city?.trim();
addToolApprovalResponse({
id: invocation.approval.id,
approved: true,
- })
- }
+ override:
+ trimmed && trimmed !== invocation.input.city
+ ? { input: { city: trimmed } }
+ : undefined,
+ });
+ }}
>
Approve
@@ -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';
} {