diff --git a/.changeset/client-tool-local-approval.md b/.changeset/client-tool-local-approval.md new file mode 100644 index 0000000000..97e3caccb0 --- /dev/null +++ b/.changeset/client-tool-local-approval.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Ask for approval of a `needsApproval` client tool when the server does not ask for it, for example a custom AG-UI server or a connection adapter with no TanStack server. Before, the client logged a warning and the tool never ran. Now the call comes in `interrupts` as a `tool-approval` item, the same as with `chat()`. On approve, the client runs the tool and continues the loop. On deny, it sends a denied result and continues. diff --git a/docs/tools/client-tools.md b/docs/tools/client-tools.md index cae9d8675e..4c0fdc9d33 100644 --- a/docs/tools/client-tools.md +++ b/docs/tools/client-tools.md @@ -408,7 +408,12 @@ These calls do not run: - A call with a name that no client tool has. - A call with arguments that are not complete JSON. -- A call to a client tool with `needsApproval: true`, because the server did not ask for approval. + +A call to a client tool with `needsApproval: true` waits for the user. It comes in `interrupts` as a `tool-approval` item, the same as with `chat()`. Show it as in [Tool Approval](./tool-approval): + +- On approve, the client runs the tool with the arguments, or with the edited arguments. Then it starts the next run with the result. +- On deny, the client sends a denied result, `{ error: 'User declined tool execution' }`, and starts the next run. +- The approval exists only in the browser. A page reload loses it. ### Expose client tools through WebMCP diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 3ac64b7cad..e413cd8db6 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1,16 +1,20 @@ import { EventType, + INTERRUPT_BINDING_VERSION, StreamProcessor, cloneAndDeepFreezeJson, convertSchemaToJsonSchema, generateMessageId, + hashSchemaInput, isStandardSchema, mergeMetadata, + normalizeApprovalSchema, normalizeToUIMessage, restoreInboundChunk, subagentInputOf, tanstackMetadata, validateWithStandardSchema, + withInterruptBinding, } from '@tanstack/ai/client' import { ByokBlockedError, @@ -35,6 +39,7 @@ import { InterruptManager } from './interrupt-manager' import type { AnyClientTool, ContentPart, + Interrupt, InterruptDefinition, InterruptSubmissionError, ModelMessage, @@ -309,6 +314,56 @@ function readApprovalApproved(payload: unknown): boolean | undefined { return payload.approved } +/** A client tool call that needs approval, from a server that did not ask. */ +interface LocalApproval { + toolCallId: string + toolName: string + input: unknown + tool: AnyClientTool +} + +/** + * The tool-approval interrupt that chat() sends for a call, built on the + * client for a server that does not send one (issue #1945). + */ +function localApprovalInterrupt( + id: string, + interruptedRunId: string, + call: LocalApproval, +): Interrupt { + const approval = normalizeApprovalSchema( + call.tool.approvalSchema, + call.tool.inputSchema, + ) + return withInterruptBinding( + { + id, + reason: 'tool_call', + message: `Approval required to run ${call.toolName}`, + toolCallId: call.toolCallId, + responseSchema: approval.responseSchema, + metadata: { + kind: 'approval', + toolName: call.toolName, + input: call.input, + }, + }, + { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: id, + interruptedRunId, + generation: 0, + toolName: call.toolName, + toolCallId: call.toolCallId, + originalArgs: call.input, + inputSchemaHash: hashSchemaInput(call.tool.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + }, + ) +} + function readResumeState( snapshot: ChatResumeSnapshot, ): ChatResumeState | undefined { @@ -577,6 +632,13 @@ export class ChatClient< // Track pending client tool executions to await them before stream finalization private readonly pendingToolExecutions: Map> = new Map() private handingOverPendingToolCalls = false + // Handed-over calls that need approval. After the chunk, they become local + // tool-approval interrupts, and their answer stays on the client. + private pendingLocalApprovals: Array = [] + private localApprovals: { + runId: string + calls: Map + } | null = null private activeClientTools: Map | null = null private activeContext: TContext | undefined = undefined // Flag to deduplicate continuation checks during action draining @@ -933,72 +995,16 @@ export class ChatClient< const clientTools = this.activeClientTools ?? this.clientToolsRef.current const clientTool = clientTools.get(args.toolName) - const executeFunc = clientTool?.execute // A success RUN_FINISHED hands over calls that no approval step - // covered. A tool that needs approval does not run from there. + // covered. A tool that needs approval asks the user first. if ( this.handingOverPendingToolCalls && clientTool?.needsApproval === true ) { - console.warn( - `[ChatClient] Did not run tool call ${args.toolCallId}: ${args.toolName} needs approval, and the server did not ask for it`, - ) + this.pendingLocalApprovals.push({ ...args, tool: clientTool }) return } - if (executeFunc) { - const continuationGeneration = this.continuationGeneration - // Capture the run context at execution-start so a tool whose - // result lands AFTER the originating run finishes still reports - // back against the originating run, not whatever run is - // current when the result emits. - const runEventContext = - this.devtoolsBridge.getCurrentRunEventContext() - // Create and track the execution promise - const executionPromise = (async () => { - try { - const context = - this.activeClientTools === null - ? this.context - : this.activeContext - const output = await executeFunc(args.input, { - toolCallId: args.toolCallId, - threadId: this.threadId, - context: context as TContext, - emitCustomEvent: () => {}, - }) - await this.addToolResultForClientTool( - { - toolCallId: args.toolCallId, - tool: args.toolName, - output, - state: 'output-available', - }, - clientTool, - continuationGeneration, - runEventContext, - ) - } catch (error: any) { - await this.addToolResultForClientTool( - { - toolCallId: args.toolCallId, - tool: args.toolName, - output: null, - state: 'output-error', - errorText: error.message, - }, - clientTool, - continuationGeneration, - runEventContext, - ) - } finally { - // Remove from pending when complete - this.pendingToolExecutions.delete(args.toolCallId) - } - })() - - // Track the pending execution - this.pendingToolExecutions.set(args.toolCallId, executionPromise) - } + if (clientTool) this.runClientTool(args, clientTool) }, onApprovalRequest: (args: { toolCallId: string @@ -1713,6 +1719,17 @@ export class ChatClient< private async submitInterruptBatch( submission: InterruptManagerSubmission, ): Promise { + const local = this.localApprovals + if (local?.runId === submission.interruptedRunId) { + // No server asked for these approvals, so no server run resumes. + this.localApprovals = null + this.interruptManager.reset() + for (const resolution of submission.resolutions) { + const call = local.calls.get(resolution.interruptId) + if (call) this.answerLocalApproval(call, resolution.payload) + } + return + } const continuationGeneration = this.continuationGeneration this.activeInterruptSubmission = submission this.interruptSubmissionFailure = undefined @@ -2284,9 +2301,65 @@ export class ChatClient< this.syncSubagentHandles() this.updateRunLifecycle(chunk) this.observeInterruptState(chunk) + this.raiseLocalApprovals() this.resolveJoinedRun(chunk) } + /** + * Show the handed-over calls that need approval as tool-approval + * interrupts. Runs after observeInterruptState(), which clears the + * interrupts on the RUN_FINISHED that handed the calls over. + */ + private raiseLocalApprovals(): void { + if (this.pendingLocalApprovals.length === 0) return + const runId = this.generateUniqueId('local-approval') + const calls = new Map( + this.pendingLocalApprovals.map((call) => [ + `local_approval_${call.toolCallId}`, + call, + ]), + ) + this.pendingLocalApprovals = [] + this.localApprovals = { runId, calls } + this.interruptManager.hydrate({ + threadId: this.threadId, + interruptedRunId: runId, + generation: 0, + interrupts: Array.from(calls, ([id, call]) => + localApprovalInterrupt(id, runId, call), + ), + }) + } + + /** Run an approved local call, or add a denied result. */ + private answerLocalApproval(call: LocalApproval, payload: unknown): void { + // The interrupt manager checked the payload: { approved, editedArgs?, + // payload? }. + const read = (key: string): unknown => + typeof payload === 'object' && payload !== null + ? Reflect.get(payload, key) + : undefined + if (readApprovalApproved(payload) === true) { + this.runClientTool( + { ...call, input: read('editedArgs') ?? call.input }, + call.tool, + ) + return + } + // The same result chat() sends for a denied call. + void this.addToolResultForClientTool( + { + toolCallId: call.toolCallId, + tool: call.toolName, + output: read('payload') ?? { error: 'User declined tool execution' }, + state: 'output-error', + errorText: 'User declined tool execution', + }, + call.tool, + this.continuationGeneration, + ) + } + private isActiveInterruptSubmissionFailure( chunk: Extract, ): boolean { @@ -2823,6 +2896,7 @@ export class ChatClient< } finally { this.handingOverPendingToolCalls = false } + this.raiseLocalApprovals() // Wait for the tools that finalizeStream() started, as above. if (this.pendingToolExecutions.size > 0) { await Promise.all(this.pendingToolExecutions.values()) @@ -3060,6 +3134,64 @@ export class ChatClient< ) } + /** Run a client tool, then add its result and continue the loop. */ + private runClientTool( + args: { toolCallId: string; toolName: string; input: any }, + clientTool: AnyClientTool, + ): void { + const executeFunc = clientTool.execute + if (!executeFunc) return + const continuationGeneration = this.continuationGeneration + // Capture the run context at execution-start so a tool whose + // result lands AFTER the originating run finishes still reports + // back against the originating run, not whatever run is + // current when the result emits. + const runEventContext = this.devtoolsBridge.getCurrentRunEventContext() + // Create and track the execution promise + const executionPromise = (async () => { + try { + const context = + this.activeClientTools === null ? this.context : this.activeContext + const output = await executeFunc(args.input, { + toolCallId: args.toolCallId, + threadId: this.threadId, + context: context as TContext, + emitCustomEvent: () => {}, + }) + await this.addToolResultForClientTool( + { + toolCallId: args.toolCallId, + tool: args.toolName, + output, + state: 'output-available', + }, + clientTool, + continuationGeneration, + runEventContext, + ) + } catch (error: any) { + await this.addToolResultForClientTool( + { + toolCallId: args.toolCallId, + tool: args.toolName, + output: null, + state: 'output-error', + errorText: error.message, + }, + clientTool, + continuationGeneration, + runEventContext, + ) + } finally { + // Remove from pending when complete + this.pendingToolExecutions.delete(args.toolCallId) + } + })() + + // Track the pending execution + this.pendingToolExecutions.set(args.toolCallId, executionPromise) + } + private async addToolResultForClientTool( result: ClientToolResult, clientTool: AnyClientTool | undefined, diff --git a/packages/ai-client/tests/chat-client-foreign-client-tool.test.ts b/packages/ai-client/tests/chat-client-foreign-client-tool.test.ts index 447c361e1d..fa003021b4 100644 --- a/packages/ai-client/tests/chat-client-foreign-client-tool.test.ts +++ b/packages/ai-client/tests/chat-client-foreign-client-tool.test.ts @@ -135,31 +135,47 @@ describe('client tools from a foreign AG-UI server', () => { }, ) - it.each([ + describe.each([ ['a success outcome', { outcome: { type: 'success' } }], ['finishReason tool_calls', { finishReason: 'tool_calls' }], - ])('does not run a tool that needs approval (%s)', async (_, finished) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const execute = vi.fn(() => ({ reply: 'pong' })) - const ping = toolDefinition({ - name: 'ping', - description: 'Reply with pong', - inputSchema: z.object({}), - needsApproval: true, - }).client(execute) - const { connection, requests } = foreignServer(finished) - const client = new ChatClient({ - threadId: 'thread-1', - connection, - tools: [ping], - }) + ])('with %s', (_, finished) => { + it.each([ + [true, '{\\"reply\\":\\"pong\\"}'], + [false, 'User declined tool execution'], + ])( + 'asks for approval of a tool that needs it (approved: %s)', + async (approved, sent) => { + const execute = vi.fn(() => ({ reply: 'pong' })) + const ping = toolDefinition({ + name: 'ping', + description: 'Reply with pong', + inputSchema: z.object({}), + needsApproval: true, + }).client(execute) + const { connection, requests } = foreignServer(finished) + const client = new ChatClient({ + threadId: 'thread-1', + connection, + tools: [ping], + }) - await client.sendMessage('ping me') - await new Promise((resolve) => setTimeout(resolve, 20)) + await client.sendMessage('ping me') + + const interrupt = client.getInterrupts()[0] + if (interrupt?.kind !== 'tool-approval') { + throw new Error('expected a tool-approval interrupt') + } + expect(interrupt.toolName).toBe('ping') + expect(execute).not.toHaveBeenCalled() + expect(requests).toHaveLength(1) + + interrupt.resolveInterrupt(approved) - expect(execute).not.toHaveBeenCalled() - expect(requests).toHaveLength(1) - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() + await vi.waitFor(() => expect(requests).toHaveLength(2)) + expect(execute).toHaveBeenCalledTimes(approved ? 1 : 0) + expect(JSON.stringify(requests[1])).toContain(sent) + expect(client.getInterrupts()).toEqual([]) + }, + ) }) }) diff --git a/testing/e2e/src/routes/foreign-client-tool.tsx b/testing/e2e/src/routes/foreign-client-tool.tsx index e365531b0c..e3553d5500 100644 --- a/testing/e2e/src/routes/foreign-client-tool.tsx +++ b/testing/e2e/src/routes/foreign-client-tool.tsx @@ -9,6 +9,9 @@ import { z } from 'zod' * AG-UI producer that is not TanStack AI. It calls the frontend tool `ping` * and ends the run with a success outcome. The client must run `ping` and * start the next run with its result. + * + * With `?approval=1`, `ping` needs approval. The server does not ask for it, + * so the client shows its own approval (issue #1945). */ const ping = toolDefinition({ @@ -16,6 +19,12 @@ const ping = toolDefinition({ description: 'Reply with pong', inputSchema: z.object({}), }).client(() => ({ reply: 'pong' })) +const pingWithApproval = toolDefinition({ + name: 'ping', + description: 'Reply with pong', + inputSchema: z.object({}), + needsApproval: true, +}).client(() => ({ reply: 'pong' })) export const Route = createFileRoute('/foreign-client-tool')({ component: ForeignClientToolPage, @@ -26,17 +35,20 @@ export const Route = createFileRoute('/foreign-client-tool')({ search.finish === 'tool_calls' ? ('tool_calls' as const) : ('success' as const), + // The router writes the result back to the URL (`approval=true`) and + // checks it again, so `true` must stay `true`. + approval: search.approval === true || search.approval === 1, }), }) function ForeignClientToolPage() { - const { finish } = Route.useSearch() - const { messages, sendMessage } = useChat({ + const { finish, approval } = Route.useSearch() + const { messages, sendMessage, interrupts } = useChat({ threadId: 'foreign-client-tool', connection: fetchServerSentEvents( `/api/foreign-client-tool?finish=${finish}`, ), - tools: [ping], + tools: [approval ? pingWithApproval : ping], }) const assistantText = messages @@ -66,6 +78,18 @@ function ForeignClientToolPage() {
{pingOutputs.join(',')}
{assistantText}
+ {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( +
+ + +
+ ) : null, + )}
) } diff --git a/testing/e2e/tests/foreign-client-tool.spec.ts b/testing/e2e/tests/foreign-client-tool.spec.ts index 0bbd2e9b25..bbe4458456 100644 --- a/testing/e2e/tests/foreign-client-tool.spec.ts +++ b/testing/e2e/tests/foreign-client-tool.spec.ts @@ -33,4 +33,25 @@ test.describe('frontend tool from a spec-compliant AG-UI server', () => { expect(posts).toHaveLength(2) }) } + + for (const finish of ['success', 'tool_calls']) { + for (const [button, sent] of [ + ['Approve', 'ping returned {"reply":"pong"}'], + ['Deny', 'User declined tool execution'], + ]) { + test(`asks for approval of a tool that needs it (${finish}, ${button})`, async ({ + page, + }) => { + await page.goto(`/foreign-client-tool?finish=${finish}&approval=1`) + + await page + .getByTestId('approval') + .getByRole('button', { name: button }) + .click() + + await expect(page.getByTestId('assistant-text')).toContainText(sent) + await expect(page.getByTestId('approval')).toHaveCount(0) + }) + } + } })