From b93dd6ecaf3f5b077c9bb3be8dc84e6e51b3f973 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Thu, 30 Jul 2026 19:36:37 -0700 Subject: [PATCH 1/3] feat(tools): add FunctionTool require_confirmation (human-in-the-loop approval) Part 7/9 of the feature/workflows split. - tools/function_tool: a `requireConfirmation` option so a FunctionTool pauses for human approval before executing. - agents/processors/request_confirmation_llm_request_processor: handles the confirmation request/resume round-trip for such tools. This tool-approval HITL is independent of the workflow engine (it works for any FunctionTool), so it is a small, self-contained slice. Tests: tools/function_tool_confirmation_test (5). Full core suite green (2481), docs:check + tsc clean. --- ...uest_confirmation_llm_request_processor.ts | 90 +++++++++++ core/src/tools/function_tool.ts | 76 +++++++++ .../tools/function_tool_confirmation_test.ts | 151 ++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 core/test/tools/function_tool_confirmation_test.ts 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 bff4d1a9..bcc74ab6 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,17 @@ 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. + if (Object.keys(requestConfirmationFunctionResponses).length === 0) { + 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 +201,84 @@ 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', +]); + +/** + * Maps a plain-text user reply to confirmations for any still-pending + * `adk_request_confirmation` calls, so a user can approve/deny by typing. + * Returns the synthesized confirmations 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 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); + } + } + } + const pendingIds: string[] = []; + for (const event of events) { + for (const fc of getFunctionCalls(event)) { + if ( + fc.name === REQUEST_CONFIRMATION_FUNCTION_CALL_NAME && + fc.id && + !answered.has(fc.id) + ) { + pendingIds.push(fc.id); + } + } + } + if (pendingIds.length === 0) { + return {responses: {}, turnIndex: -1}; + } + + // Only the most recent user turn is considered, 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 {responses: {}, turnIndex: -1}; + } + + const confirmed = AFFIRMATIVE.has(text.trim().toLowerCase()); + const responses: Record = {}; + for (const id of pendingIds) { + responses[id] = new ToolConfirmation({confirmed}); + } + return {responses, turnIndex}; +} + export const REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR = new RequestConfirmationLlmRequestProcessor(); diff --git a/core/src/tools/function_tool.ts b/core/src/tools/function_tool.ts index c51a15c1..638de576 100644 --- a/core/src/tools/function_tool.ts +++ b/core/src/tools/function_tool.ts @@ -57,6 +57,20 @@ 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. When confirmation is required the tool pauses the run (HITL): + * the framework emits an `adk_request_confirmation` interrupt, and the tool + * only executes once the user approves. Mirrors Python's + * `FunctionTool(require_confirmation=...)`. + */ + requireConfirmation?: + | boolean + | (( + input: ToolExecuteArgument, + tool_context?: Context, + ) => boolean | Promise); }; function toSchema( @@ -111,6 +125,13 @@ 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: + | boolean + | (( + input: ToolExecuteArgument, + tool_context?: Context, + ) => boolean | Promise); /** * The constructor acts as the user-friendly factory. @@ -130,6 +151,7 @@ export class FunctionTool< }); this.execute = options.execute; this.parameters = options.parameters; + this.requireConfirmation = options.requireConfirmation ?? false; } /** @@ -157,6 +179,19 @@ export class FunctionTool< if (isZodObject(this.parameters)) { validatedArgs = this.parameters.parse(req.args); } + + // HITL confirmation gate (Python `require_confirmation`). On the first + // pass we record a confirmation request and pause; on resume the tool + // context carries the user's decision. + const confirmationResult = this.checkConfirmation( + validatedArgs as ToolExecuteArgument, + req.toolContext, + ); + const pending = await confirmationResult; + if (pending !== undefined) { + return pending; + } + return await this.execute( validatedArgs as ToolExecuteArgument, req.toolContext, @@ -167,4 +202,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 00000000..de9cfa91 --- /dev/null +++ b/core/test/tools/function_tool_confirmation_test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + createSession, + FunctionTool, + InvocationContext, + PluginManager, + ToolConfirmation, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; +import {z} from 'zod/v3'; + +function makeContext(options: { + functionCallId?: string; + toolConfirmation?: ToolConfirmation; +}): Context { + const session = createSession({ + id: 's1', + appName: 'app', + userId: 'u1', + }); + const invocationContext = new InvocationContext({ + invocationId: 'inv-1', + agent: {name: 'a', runAsync: async function* () {}} as never, + 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); + }); +}); From 7eb624a63c197bae0524efe4f84ea9e26327cc18 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Mon, 3 Aug 2026 17:40:43 -0700 Subject: [PATCH 2/3] fix(tools): gate and harden plain-text tool confirmation (PR #594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the security/API review on FunctionTool require_confirmation: - The plain-text confirmation fallback no longer runs on every LlmAgent invocation. It is now opt-in via a new `RunConfig.plainTextToolConfirmation` flag (default off), which the interactive `adk run` CLI sets — so on a web/API surface an ordinary chat message is never silently reinterpreted as a tool-gate decision. The structured FunctionResponse path is unchanged. - Harden the fallback itself: resolve only the SINGLE most-recent pending confirmation (never a broadcast across every unanswered gate), require the reply to IMMEDIATELY follow the request (no intervening user turn), and treat unrecognized text as NO decision — the gate stays pending instead of being silently denied (only explicit negatives deny). - Extract a `RequireConfirmation` type with a `toolContext` (not snake_case `tool_context`) parameter, reuse it for both the option and the field, and export it from common.ts. - Correct the `requireConfirmation` doc: the HITL gate is enforced on the LlmAgent path; a workflow ToolNode does not yet route through it (it returns the "requires confirmation" error as node output rather than pausing). - Inline the redundant `await` in runAsync and drop the stale comment. --- ...uest_confirmation_llm_request_processor.ts | 123 +++++++++++++----- core/src/agents/run_config.ts | 8 ++ core/src/common.ts | 1 + core/src/tools/function_tool.ts | 49 ++++--- dev/src/cli/cli_run.ts | 6 + 5 files changed, 131 insertions(+), 56 deletions(-) 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 bcc74ab6..96551feb 100644 --- a/core/src/agents/processors/request_confirmation_llm_request_processor.ts +++ b/core/src/agents/processors/request_confirmation_llm_request_processor.ts @@ -101,8 +101,14 @@ 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. - if (Object.keys(requestConfirmationFunctionResponses).length === 0) { + // 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) { @@ -214,16 +220,64 @@ const AFFIRMATIVE = new Set([ '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 confirmations for any still-pending - * `adk_request_confirmation` calls, so a user can approve/deny by typing. - * Returns the synthesized confirmations keyed by the confirmation call id, and + * 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') { @@ -235,49 +289,48 @@ function mapPlainTextConfirmation(events: Event[]): { } } } - const pendingIds: string[] = []; - for (const event of events) { + + // 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) ) { - pendingIds.push(fc.id); + pendingId = fc.id; + break; } } - } - if (pendingIds.length === 0) { - return {responses: {}, turnIndex: -1}; - } - - // Only the most recent user turn is considered, 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; + if (pendingId) { + break; } - 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 {responses: {}, turnIndex: -1}; + if (!pendingId) { + return none; } - const confirmed = AFFIRMATIVE.has(text.trim().toLowerCase()); - const responses: Record = {}; - for (const id of pendingIds) { - responses[id] = new ToolConfirmation({confirmed}); + 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, turnIndex}; + + return { + responses: {[pendingId]: new ToolConfirmation({confirmed})}, + turnIndex, + }; } export const REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR = diff --git a/core/src/agents/run_config.ts b/core/src/agents/run_config.ts index 597d41ea..92fddd7c 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 2931d78a..b821364d 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 638de576..7eef2529 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 @@ -60,17 +72,21 @@ export type ToolOptions = { /** * 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. When confirmation is required the tool pauses the run (HITL): - * the framework emits an `adk_request_confirmation` interrupt, and the tool - * only executes once the user approves. Mirrors Python's + * 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?: - | boolean - | (( - input: ToolExecuteArgument, - tool_context?: Context, - ) => boolean | Promise); + requireConfirmation?: RequireConfirmation; }; function toSchema( @@ -126,12 +142,7 @@ export class FunctionTool< // Typed input parameters. private readonly parameters?: TParameters; // Whether the tool requires user confirmation before running. - private readonly requireConfirmation: - | boolean - | (( - input: ToolExecuteArgument, - tool_context?: Context, - ) => boolean | Promise); + private readonly requireConfirmation: RequireConfirmation; /** * The constructor acts as the user-friendly factory. @@ -180,14 +191,10 @@ export class FunctionTool< validatedArgs = this.parameters.parse(req.args); } - // HITL confirmation gate (Python `require_confirmation`). On the first - // pass we record a confirmation request and pause; on resume the tool - // context carries the user's decision. - const confirmationResult = this.checkConfirmation( + const pending = await this.checkConfirmation( validatedArgs as ToolExecuteArgument, req.toolContext, ); - const pending = await confirmationResult; if (pending !== undefined) { return pending; } diff --git a/dev/src/cli/cli_run.ts b/dev/src/cli/cli_run.ts index b322e1e2..8ed8e29b 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 From 34ed5af23de52665536796329469e2d62342d63a Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Mon, 3 Aug 2026 17:40:46 -0700 Subject: [PATCH 3/3] test(tools): cover the confirmation resume round-trip (PR #594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add end-to-end tests that drive a session event list back through RequestConfirmationLlmRequestProcessor with a real LlmAgent + real FunctionTool (no mocks) and assert the original tool is actually re-invoked with the right decision — the step where an id mismatch on resume would show up, and the first coverage of the plain-text fallback: opt-in gating, single-gate binding, unrecognized-text-stays-pending, and no cross-gate broadcast. - Replace the `agent: ... as never` fixture with a real LlmAgent instance so it breaks if InvocationContext's contract changes. --- .../tools/function_tool_confirmation_test.ts | 210 +++++++++++++++++- 1 file changed, 208 insertions(+), 2 deletions(-) diff --git a/core/test/tools/function_tool_confirmation_test.ts b/core/test/tools/function_tool_confirmation_test.ts index de9cfa91..02fc92cc 100644 --- a/core/test/tools/function_tool_confirmation_test.ts +++ b/core/test/tools/function_tool_confirmation_test.ts @@ -6,14 +6,21 @@ import { Context, - createSession, + 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; @@ -26,7 +33,9 @@ function makeContext(options: { }); const invocationContext = new InvocationContext({ invocationId: 'inv-1', - agent: {name: 'a', runAsync: async function* () {}} as never, + // 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([]), }); @@ -149,3 +158,200 @@ describe('FunctionTool require_confirmation', () => { 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'); + }); +});