diff --git a/core/src/agents/processors/request_confirmation_llm_request_processor.ts b/core/src/agents/processors/request_confirmation_llm_request_processor.ts index bff4d1a95..96551feb4 100644 --- a/core/src/agents/processors/request_confirmation_llm_request_processor.ts +++ b/core/src/agents/processors/request_confirmation_llm_request_processor.ts @@ -99,6 +99,23 @@ export class RequestConfirmationLlmRequestProcessor extends BaseLlmRequestProces } } + // Plain-text fallback: an interactive user (e.g. `adk run`) can approve or + // deny a pending confirmation by simply typing a reply (yes/no) instead of + // sending a structured confirmation response. Opt-in only + // (`runConfig.plainTextToolConfirmation`) so that on a web/API surface an + // ordinary chat message is never silently reinterpreted as a tool-gate + // decision — that binding is what the structured path exists to guarantee. + if ( + Object.keys(requestConfirmationFunctionResponses).length === 0 && + invocationContext.runConfig?.plainTextToolConfirmation + ) { + const fallback = mapPlainTextConfirmation(events); + Object.assign(requestConfirmationFunctionResponses, fallback.responses); + if (fallback.turnIndex >= 0) { + confirmationEventIndex = fallback.turnIndex; + } + } + if (Object.keys(requestConfirmationFunctionResponses).length === 0) { return; } @@ -190,5 +207,131 @@ export class RequestConfirmationLlmRequestProcessor extends BaseLlmRequestProces } } +/** Words interpreted as an approval when a user confirms by plain text. */ +const AFFIRMATIVE = new Set([ + 'yes', + 'y', + 'true', + 'approve', + 'approved', + 'ok', + 'okay', + 'confirm', + 'confirmed', +]); + +/** Words interpreted as an explicit denial when a user confirms by plain text. */ +const NEGATIVE = new Set([ + 'no', + 'n', + 'false', + 'reject', + 'rejected', + 'deny', + 'denied', + 'cancel', + 'cancelled', +]); + +/** + * Maps a plain-text user reply to a confirmation for the single pending + * `adk_request_confirmation` call it is answering, so an interactive user can + * approve/deny by typing. Deliberately conservative (see the security review on + * PR #594): + * + * - Only the SINGLE most-recent pending confirmation is resolved — never a + * broadcast across every unanswered gate in the history. + * - The plain-text reply must IMMEDIATELY follow the confirmation request (no + * intervening user turn), so an unrelated later message can't resolve a stale + * gate. + * - Only recognized affirmative/negative words decide; any other text (a + * question, a typo, an answer to something else) is left as NO decision so the + * gate stays pending rather than being silently denied. + * + * Returns the synthesized confirmation keyed by the confirmation call id, and + * the index of the plain-text user turn (or -1 when not applicable). + */ +function mapPlainTextConfirmation(events: Event[]): { + responses: Record; + turnIndex: number; +} { + const none = {responses: {}, turnIndex: -1}; + + // The reply is the most recent user turn, and only if it is plain text. + let turnIndex = -1; + let text = ''; + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.author !== 'user') { + continue; + } + const parts = event.content?.parts ?? []; + const isPlainText = + parts.length > 0 && parts.every((p) => typeof p.text === 'string'); + if (isPlainText) { + turnIndex = i; + text = parts.map((p) => p.text).join(''); + } + break; + } + if (turnIndex < 0) { + return none; + } + + const answered = new Set(); + for (const event of events) { + if (event.author !== 'user') { + continue; + } + for (const fr of getFunctionResponses(event)) { + if (fr.id) { + answered.add(fr.id); + } + } + } + + // Find the pending confirmation call the reply is answering: scan back from + // the reply for the most recent unanswered `adk_request_confirmation`, and + // require it to immediately precede the reply (stop at any other user turn). + let pendingId: string | undefined; + for (let i = turnIndex - 1; i >= 0; i--) { + const event = events[i]; + if (event.author === 'user') { + break; // another user turn between request and reply -> not immediate + } + for (const fc of getFunctionCalls(event)) { + if ( + fc.name === REQUEST_CONFIRMATION_FUNCTION_CALL_NAME && + fc.id && + !answered.has(fc.id) + ) { + pendingId = fc.id; + break; + } + } + if (pendingId) { + break; + } + } + if (!pendingId) { + return none; + } + + const normalized = text.trim().toLowerCase(); + let confirmed: boolean; + if (AFFIRMATIVE.has(normalized)) { + confirmed = true; + } else if (NEGATIVE.has(normalized)) { + confirmed = false; + } else { + return none; // unrecognized -> no decision, leave the gate pending + } + + return { + responses: {[pendingId]: new ToolConfirmation({confirmed})}, + turnIndex, + }; +} + export const REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR = new RequestConfirmationLlmRequestProcessor(); diff --git a/core/src/agents/run_config.ts b/core/src/agents/run_config.ts index 597d41ea2..92fddd7c3 100644 --- a/core/src/agents/run_config.ts +++ b/core/src/agents/run_config.ts @@ -99,6 +99,14 @@ export interface RunConfig { * to intercept and execute tools (Client-Side Tool Execution). */ pauseOnToolCalls?: boolean; + + /** + * If true, a plain-text user reply (e.g. "yes"/"no") may resolve a pending + * `requireConfirmation` tool gate. Off by default so an ordinary chat message + * on a web/API surface is never silently reinterpreted as a security + * decision; interactive front-ends (e.g. `adk run`) opt in explicitly. + */ + plainTextToolConfirmation?: boolean; } /** diff --git a/core/src/common.ts b/core/src/common.ts index 2931d78a4..b821364d2 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -258,6 +258,7 @@ export { } from './tools/finish_task_tool.js'; export {FunctionTool, isFunctionTool} from './tools/function_tool.js'; export type { + RequireConfirmation, ToolExecuteArgument, ToolExecuteFunction, ToolInputParameters, diff --git a/core/src/tools/function_tool.ts b/core/src/tools/function_tool.ts index c51a15c13..7eef25296 100644 --- a/core/src/tools/function_tool.ts +++ b/core/src/tools/function_tool.ts @@ -40,9 +40,21 @@ export type ToolExecuteArgument = */ export type ToolExecuteFunction = ( input: ToolExecuteArgument, - tool_context?: Context, + toolContext?: Context, ) => Promise | unknown; +/** + * Whether a {@link FunctionTool} requires user confirmation before it runs: a + * boolean, or a predicate over the (validated) call arguments and tool context. + * See {@link ToolOptions.requireConfirmation}. + */ +export type RequireConfirmation = + | boolean + | (( + input: ToolExecuteArgument, + toolContext?: Context, + ) => boolean | Promise); + /** * The configuration options for creating a function-based tool. * The `name`, `description` and `parameters` fields are used to generate the @@ -57,6 +69,24 @@ export type ToolOptions = { parameters?: TParameters; execute: ToolExecuteFunction; isLongRunning?: boolean; + /** + * Whether this tool requires user confirmation before it runs. A boolean, or + * a predicate over the (validated) call arguments and tool context returning + * a boolean. + * + * The HITL gate is enforced when the tool is invoked through an `LlmAgent` + * turn: `agents/functions.ts` surfaces an `adk_request_confirmation` + * interrupt from the tool's `requestedToolConfirmations`, and the tool only + * executes once the user approves (via the + * `RequestConfirmationLlmRequestProcessor`). + * + * NOTE: a workflow `ToolNode` does not yet route through that path, so a + * `requireConfirmation` tool used directly as a node does not pause — it + * returns the "requires confirmation" error as its node output. Approval for + * workflow nodes is not wired up. Mirrors Python's + * `FunctionTool(require_confirmation=...)`. + */ + requireConfirmation?: RequireConfirmation; }; function toSchema( @@ -111,6 +141,8 @@ export class FunctionTool< private readonly execute: ToolExecuteFunction; // Typed input parameters. private readonly parameters?: TParameters; + // Whether the tool requires user confirmation before running. + private readonly requireConfirmation: RequireConfirmation; /** * The constructor acts as the user-friendly factory. @@ -130,6 +162,7 @@ export class FunctionTool< }); this.execute = options.execute; this.parameters = options.parameters; + this.requireConfirmation = options.requireConfirmation ?? false; } /** @@ -157,6 +190,15 @@ export class FunctionTool< if (isZodObject(this.parameters)) { validatedArgs = this.parameters.parse(req.args); } + + const pending = await this.checkConfirmation( + validatedArgs as ToolExecuteArgument, + req.toolContext, + ); + if (pending !== undefined) { + return pending; + } + return await this.execute( validatedArgs as ToolExecuteArgument, req.toolContext, @@ -167,4 +209,45 @@ export class FunctionTool< throw new Error(`Error in tool '${this.name}': ${errorMessage}`); } } + + /** + * Evaluates the confirmation gate. Returns `undefined` if the tool may + * proceed; otherwise returns the function response payload to surface instead + * of running (a request-for-confirmation on the first pass, or a rejection + * once the user declined). + */ + private async checkConfirmation( + input: ToolExecuteArgument, + toolContext?: Context, + ): Promise<{error: string} | undefined> { + const requireConfirmation = + typeof this.requireConfirmation === 'function' + ? await this.requireConfirmation(input, toolContext) + : this.requireConfirmation; + if (!requireConfirmation) { + return undefined; + } + if (!toolContext) { + throw new Error( + `Tool '${this.name}' requires confirmation but no tool context was provided.`, + ); + } + if (!toolContext.toolConfirmation) { + toolContext.requestConfirmation({ + hint: + `Please approve or reject the tool call ${this.name}() by ` + + 'responding with a FunctionResponse with an expected ' + + 'ToolConfirmation payload.', + }); + toolContext.actions.skipSummarization = true; + return { + error: + 'This tool call requires confirmation, please approve or reject.', + }; + } + if (!toolContext.toolConfirmation.confirmed) { + return {error: 'This tool call is rejected.'}; + } + return undefined; + } } diff --git a/core/test/tools/function_tool_confirmation_test.ts b/core/test/tools/function_tool_confirmation_test.ts new file mode 100644 index 000000000..02fc92cc0 --- /dev/null +++ b/core/test/tools/function_tool_confirmation_test.ts @@ -0,0 +1,357 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + Event, + FunctionTool, + InvocationContext, + LlmAgent, + PluginManager, + REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + RunConfig, + ToolConfirmation, + createEvent, + createSession, +} from '@google/adk'; +import {FunctionCall} from '@google/genai'; +import {describe, expect, it} from 'vitest'; +import {z} from 'zod/v3'; +import {REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR} from '../../src/agents/processors/request_confirmation_llm_request_processor.js'; + +function makeContext(options: { + functionCallId?: string; + toolConfirmation?: ToolConfirmation; +}): Context { + const session = createSession({ + id: 's1', + appName: 'app', + userId: 'u1', + }); + const invocationContext = new InvocationContext({ + invocationId: 'inv-1', + // A real agent instance, so the fixture breaks if InvocationContext's + // contract changes (rather than being silenced by `as never`). + agent: new LlmAgent({name: 'a', model: 'gemini-2.5-flash'}), + session, + pluginManager: new PluginManager([]), + }); + return new Context({invocationContext, ...options}); +} + +describe('FunctionTool require_confirmation', () => { + function makeTool() { + let ran = false; + const tool = new FunctionTool({ + name: 'delete_file', + description: 'Deletes a file.', + parameters: z.object({path: z.string()}), + execute: () => { + ran = true; + return 'deleted'; + }, + requireConfirmation: true, + }); + return {tool, didRun: () => ran}; + } + + it('pauses and requests confirmation on first call', async () => { + const {tool, didRun} = makeTool(); + const ctx = makeContext({functionCallId: 'fc-1'}); + + const result = await tool.runAsync({ + args: {path: '/tmp/x'}, + toolContext: ctx, + }); + + expect(result).toEqual({ + error: 'This tool call requires confirmation, please approve or reject.', + }); + expect(didRun()).toBe(false); + expect(ctx.actions.requestedToolConfirmations['fc-1']).toBeDefined(); + expect(ctx.actions.skipSummarization).toBe(true); + }); + + it('runs the tool once the call is confirmed', async () => { + const {tool, didRun} = makeTool(); + const ctx = makeContext({ + functionCallId: 'fc-1', + toolConfirmation: new ToolConfirmation({confirmed: true}), + }); + + const result = await tool.runAsync({ + args: {path: '/tmp/x'}, + toolContext: ctx, + }); + + expect(result).toBe('deleted'); + expect(didRun()).toBe(true); + }); + + it('rejects the tool call when confirmation is denied', async () => { + const {tool, didRun} = makeTool(); + const ctx = makeContext({ + functionCallId: 'fc-1', + toolConfirmation: new ToolConfirmation({confirmed: false}), + }); + + const result = await tool.runAsync({ + args: {path: '/tmp/x'}, + toolContext: ctx, + }); + + expect(result).toEqual({error: 'This tool call is rejected.'}); + expect(didRun()).toBe(false); + }); + + it('runs immediately when confirmation is not required', async () => { + let ran = false; + const tool = new FunctionTool({ + name: 'noop', + description: 'no-op', + execute: () => { + ran = true; + return 'ok'; + }, + }); + const ctx = makeContext({functionCallId: 'fc-1'}); + + const result = await tool.runAsync({args: {}, toolContext: ctx}); + + expect(result).toBe('ok'); + expect(ran).toBe(true); + }); + + it('supports a predicate to decide confirmation per-args', async () => { + let ran = false; + const tool = new FunctionTool({ + name: 'transfer', + description: 'Transfers money.', + parameters: z.object({amount: z.number()}), + execute: () => { + ran = true; + return 'sent'; + }, + requireConfirmation: (input) => input.amount > 100, + }); + + // Small amount: no confirmation required, runs directly. + const smallCtx = makeContext({functionCallId: 'fc-small'}); + expect( + await tool.runAsync({args: {amount: 10}, toolContext: smallCtx}), + ).toBe('sent'); + expect(ran).toBe(true); + + // Large amount: confirmation required, pauses. + ran = false; + const largeCtx = makeContext({functionCallId: 'fc-large'}); + const result = await tool.runAsync({ + args: {amount: 1000}, + toolContext: largeCtx, + }); + expect(result).toEqual({ + error: 'This tool call requires confirmation, please approve or reject.', + }); + expect(ran).toBe(false); + }); +}); + +// --- End-to-end resume through RequestConfirmationLlmRequestProcessor -------- +// +// The tests above assert the two ends of the gate in isolation. These drive a +// real session event list back through the processor with a real LlmAgent + a +// real FunctionTool (no mocks), asserting the original tool is actually +// re-invoked with the right decision — the step where an id mismatch on resume +// would show up, and the only coverage of the plain-text fallback. + +/** A tool that records whether (and how) it was executed on resume. */ +function makeGatedTool() { + const calls: Array<{path: string}> = []; + const tool = new FunctionTool({ + name: 'delete_file', + description: 'Deletes a file.', + parameters: z.object({path: z.string()}), + execute: (args) => { + calls.push(args); + return `deleted ${args.path}`; + }, + requireConfirmation: true, + }); + return {tool, calls}; +} + +/** The engine-emitted `adk_request_confirmation` call wrapping the original. */ +function confirmationRequestEvent( + confirmId: string, + originalFunctionCall: FunctionCall, +): Event { + return createEvent({ + invocationId: 'inv-1', + author: 'agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + id: confirmId, + name: REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + args: {originalFunctionCall}, + }, + }, + ], + }, + }); +} + +/** A structured user confirmation response addressed to `confirmId`. */ +function structuredConfirmationEvent( + confirmId: string, + confirmed: boolean, +): Event { + return createEvent({ + invocationId: 'inv-1', + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + id: confirmId, + name: REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + response: {confirmed, hint: ''}, + }, + }, + ], + }, + }); +} + +/** A plain-text user reply. */ +function plainTextEvent(text: string): Event { + return createEvent({ + invocationId: 'inv-1', + author: 'user', + content: {role: 'user', parts: [{text}]}, + }); +} + +async function resume( + tool: FunctionTool>, + events: Event[], + runConfig?: RunConfig, +): Promise { + const agent = new LlmAgent({ + name: 'agent', + model: 'gemini-2.5-flash', + tools: [tool], + }); + const invocationContext = new InvocationContext({ + invocationId: 'inv-1', + agent, + session: createSession({id: 's1', appName: 'app', userId: 'u1', events}), + pluginManager: new PluginManager([]), + runConfig, + }); + const out: Event[] = []; + for await (const event of REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR.runAsync( + invocationContext, + )) { + out.push(event); + } + return out; +} + +const originalCall: FunctionCall = { + id: 'orig-1', + name: 'delete_file', + args: {path: '/tmp/x'}, +}; + +describe('RequestConfirmation resume round-trip', () => { + it('re-invokes the tool when a structured approval arrives', async () => { + const {tool, calls} = makeGatedTool(); + const out = await resume(tool, [ + confirmationRequestEvent('confirm-1', originalCall), + structuredConfirmationEvent('confirm-1', true), + ]); + + expect(calls).toEqual([{path: '/tmp/x'}]); + expect(out).toHaveLength(1); + expect(out[0].content?.parts?.[0].functionResponse?.id).toBe('orig-1'); + }); + + it('does not run the tool when the structured decision is a denial', async () => { + const {tool, calls} = makeGatedTool(); + await resume(tool, [ + confirmationRequestEvent('confirm-1', originalCall), + structuredConfirmationEvent('confirm-1', false), + ]); + expect(calls).toEqual([]); + }); + + it('ignores a plain-text reply unless the run opts in', async () => { + const {tool, calls} = makeGatedTool(); + // Same yes reply, but plainTextToolConfirmation is not set. + await resume(tool, [ + confirmationRequestEvent('confirm-1', originalCall), + plainTextEvent('yes'), + ]); + expect(calls).toEqual([]); + }); + + it('resumes on a plain-text approval when opted in', async () => { + const {tool, calls} = makeGatedTool(); + const out = await resume( + tool, + [ + confirmationRequestEvent('confirm-1', originalCall), + plainTextEvent('yes'), + ], + {plainTextToolConfirmation: true}, + ); + expect(calls).toEqual([{path: '/tmp/x'}]); + expect(out).toHaveLength(1); + }); + + it('leaves the gate pending on unrecognized plain text (no silent denial)', async () => { + const {tool, calls} = makeGatedTool(); + const out = await resume( + tool, + [ + confirmationRequestEvent('confirm-1', originalCall), + plainTextEvent('what does that do?'), + ], + {plainTextToolConfirmation: true}, + ); + // Unrecognized text is treated as no decision: the tool is neither run nor + // recorded as rejected — the gate simply stays pending. + expect(calls).toEqual([]); + expect(out).toHaveLength(0); + }); + + it('does not broadcast one plain-text reply across multiple pending gates', async () => { + const {tool, calls} = makeGatedTool(); + const secondCall: FunctionCall = { + id: 'orig-2', + name: 'delete_file', + args: {path: '/tmp/y'}, + }; + // Two separate pending confirmations; a single "yes" must resolve only the + // most recent one it immediately follows, not both. + const out = await resume( + tool, + [ + confirmationRequestEvent('confirm-1', originalCall), + confirmationRequestEvent('confirm-2', secondCall), + plainTextEvent('yes'), + ], + {plainTextToolConfirmation: true}, + ); + expect(calls).toEqual([{path: '/tmp/y'}]); + expect(out).toHaveLength(1); + expect(out[0].content?.parts?.[0].functionResponse?.id).toBe('orig-2'); + }); +}); diff --git a/dev/src/cli/cli_run.ts b/dev/src/cli/cli_run.ts index b322e1e28..8ed8e29b1 100644 --- a/dev/src/cli/cli_run.ts +++ b/dev/src/cli/cli_run.ts @@ -81,6 +81,9 @@ async function runFromInputFile( userId: session.userId, sessionId: session.id, newMessage: {role: 'user', parts: [{text: query}]}, + // Interactive CLI: let a plain-text "yes"/"no" resolve a pending tool + // confirmation (opt-in; off by default on non-interactive surfaces). + runConfig: {plainTextToolConfirmation: true}, }; for await (const event of runner.runAsync(runOptions)) { @@ -147,6 +150,9 @@ async function runInteractively( userId: options.session.userId, sessionId: options.session.id, newMessage: {role: 'user', parts: [{text: query}]}, + // Interactive CLI: let a plain-text "yes"/"no" resolve a pending tool + // confirmation (opt-in; off by default on non-interactive surfaces). + runConfig: {plainTextToolConfirmation: true}, })) { if (event.content && event.content.parts) { const text = event.content.parts