Skip to content

Commit d2ae57b

Browse files
authored
feat(tools): FunctionTool require_confirmation — HITL approval (Part 7) (#594)
* 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. * fix(tools): gate and harden plain-text tool confirmation (PR #594) 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<TParameters>` 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. * test(tools): cover the confirmation resume round-trip (PR #594) - 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.
1 parent 5334b45 commit d2ae57b

6 files changed

Lines changed: 599 additions & 1 deletion

File tree

core/src/agents/processors/request_confirmation_llm_request_processor.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@ export class RequestConfirmationLlmRequestProcessor extends BaseLlmRequestProces
9999
}
100100
}
101101

102+
// Plain-text fallback: an interactive user (e.g. `adk run`) can approve or
103+
// deny a pending confirmation by simply typing a reply (yes/no) instead of
104+
// sending a structured confirmation response. Opt-in only
105+
// (`runConfig.plainTextToolConfirmation`) so that on a web/API surface an
106+
// ordinary chat message is never silently reinterpreted as a tool-gate
107+
// decision — that binding is what the structured path exists to guarantee.
108+
if (
109+
Object.keys(requestConfirmationFunctionResponses).length === 0 &&
110+
invocationContext.runConfig?.plainTextToolConfirmation
111+
) {
112+
const fallback = mapPlainTextConfirmation(events);
113+
Object.assign(requestConfirmationFunctionResponses, fallback.responses);
114+
if (fallback.turnIndex >= 0) {
115+
confirmationEventIndex = fallback.turnIndex;
116+
}
117+
}
118+
102119
if (Object.keys(requestConfirmationFunctionResponses).length === 0) {
103120
return;
104121
}
@@ -190,5 +207,131 @@ export class RequestConfirmationLlmRequestProcessor extends BaseLlmRequestProces
190207
}
191208
}
192209

210+
/** Words interpreted as an approval when a user confirms by plain text. */
211+
const AFFIRMATIVE = new Set([
212+
'yes',
213+
'y',
214+
'true',
215+
'approve',
216+
'approved',
217+
'ok',
218+
'okay',
219+
'confirm',
220+
'confirmed',
221+
]);
222+
223+
/** Words interpreted as an explicit denial when a user confirms by plain text. */
224+
const NEGATIVE = new Set([
225+
'no',
226+
'n',
227+
'false',
228+
'reject',
229+
'rejected',
230+
'deny',
231+
'denied',
232+
'cancel',
233+
'cancelled',
234+
]);
235+
236+
/**
237+
* Maps a plain-text user reply to a confirmation for the single pending
238+
* `adk_request_confirmation` call it is answering, so an interactive user can
239+
* approve/deny by typing. Deliberately conservative (see the security review on
240+
* PR #594):
241+
*
242+
* - Only the SINGLE most-recent pending confirmation is resolved — never a
243+
* broadcast across every unanswered gate in the history.
244+
* - The plain-text reply must IMMEDIATELY follow the confirmation request (no
245+
* intervening user turn), so an unrelated later message can't resolve a stale
246+
* gate.
247+
* - Only recognized affirmative/negative words decide; any other text (a
248+
* question, a typo, an answer to something else) is left as NO decision so the
249+
* gate stays pending rather than being silently denied.
250+
*
251+
* Returns the synthesized confirmation keyed by the confirmation call id, and
252+
* the index of the plain-text user turn (or -1 when not applicable).
253+
*/
254+
function mapPlainTextConfirmation(events: Event[]): {
255+
responses: Record<string, ToolConfirmation>;
256+
turnIndex: number;
257+
} {
258+
const none = {responses: {}, turnIndex: -1};
259+
260+
// The reply is the most recent user turn, and only if it is plain text.
261+
let turnIndex = -1;
262+
let text = '';
263+
for (let i = events.length - 1; i >= 0; i--) {
264+
const event = events[i];
265+
if (event.author !== 'user') {
266+
continue;
267+
}
268+
const parts = event.content?.parts ?? [];
269+
const isPlainText =
270+
parts.length > 0 && parts.every((p) => typeof p.text === 'string');
271+
if (isPlainText) {
272+
turnIndex = i;
273+
text = parts.map((p) => p.text).join('');
274+
}
275+
break;
276+
}
277+
if (turnIndex < 0) {
278+
return none;
279+
}
280+
281+
const answered = new Set<string>();
282+
for (const event of events) {
283+
if (event.author !== 'user') {
284+
continue;
285+
}
286+
for (const fr of getFunctionResponses(event)) {
287+
if (fr.id) {
288+
answered.add(fr.id);
289+
}
290+
}
291+
}
292+
293+
// Find the pending confirmation call the reply is answering: scan back from
294+
// the reply for the most recent unanswered `adk_request_confirmation`, and
295+
// require it to immediately precede the reply (stop at any other user turn).
296+
let pendingId: string | undefined;
297+
for (let i = turnIndex - 1; i >= 0; i--) {
298+
const event = events[i];
299+
if (event.author === 'user') {
300+
break; // another user turn between request and reply -> not immediate
301+
}
302+
for (const fc of getFunctionCalls(event)) {
303+
if (
304+
fc.name === REQUEST_CONFIRMATION_FUNCTION_CALL_NAME &&
305+
fc.id &&
306+
!answered.has(fc.id)
307+
) {
308+
pendingId = fc.id;
309+
break;
310+
}
311+
}
312+
if (pendingId) {
313+
break;
314+
}
315+
}
316+
if (!pendingId) {
317+
return none;
318+
}
319+
320+
const normalized = text.trim().toLowerCase();
321+
let confirmed: boolean;
322+
if (AFFIRMATIVE.has(normalized)) {
323+
confirmed = true;
324+
} else if (NEGATIVE.has(normalized)) {
325+
confirmed = false;
326+
} else {
327+
return none; // unrecognized -> no decision, leave the gate pending
328+
}
329+
330+
return {
331+
responses: {[pendingId]: new ToolConfirmation({confirmed})},
332+
turnIndex,
333+
};
334+
}
335+
193336
export const REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR =
194337
new RequestConfirmationLlmRequestProcessor();

core/src/agents/run_config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ export interface RunConfig {
9999
* to intercept and execute tools (Client-Side Tool Execution).
100100
*/
101101
pauseOnToolCalls?: boolean;
102+
103+
/**
104+
* If true, a plain-text user reply (e.g. "yes"/"no") may resolve a pending
105+
* `requireConfirmation` tool gate. Off by default so an ordinary chat message
106+
* on a web/API surface is never silently reinterpreted as a security
107+
* decision; interactive front-ends (e.g. `adk run`) opt in explicitly.
108+
*/
109+
plainTextToolConfirmation?: boolean;
102110
}
103111

104112
/**

core/src/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export {
258258
} from './tools/finish_task_tool.js';
259259
export {FunctionTool, isFunctionTool} from './tools/function_tool.js';
260260
export type {
261+
RequireConfirmation,
261262
ToolExecuteArgument,
262263
ToolExecuteFunction,
263264
ToolInputParameters,

core/src/tools/function_tool.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,21 @@ export type ToolExecuteArgument<TParameters extends ToolInputParameters> =
4040
*/
4141
export type ToolExecuteFunction<TParameters extends ToolInputParameters> = (
4242
input: ToolExecuteArgument<TParameters>,
43-
tool_context?: Context,
43+
toolContext?: Context,
4444
) => Promise<unknown> | unknown;
4545

46+
/**
47+
* Whether a {@link FunctionTool} requires user confirmation before it runs: a
48+
* boolean, or a predicate over the (validated) call arguments and tool context.
49+
* See {@link ToolOptions.requireConfirmation}.
50+
*/
51+
export type RequireConfirmation<TParameters extends ToolInputParameters> =
52+
| boolean
53+
| ((
54+
input: ToolExecuteArgument<TParameters>,
55+
toolContext?: Context,
56+
) => boolean | Promise<boolean>);
57+
4658
/**
4759
* The configuration options for creating a function-based tool.
4860
* The `name`, `description` and `parameters` fields are used to generate the
@@ -57,6 +69,24 @@ export type ToolOptions<TParameters extends ToolInputParameters> = {
5769
parameters?: TParameters;
5870
execute: ToolExecuteFunction<TParameters>;
5971
isLongRunning?: boolean;
72+
/**
73+
* Whether this tool requires user confirmation before it runs. A boolean, or
74+
* a predicate over the (validated) call arguments and tool context returning
75+
* a boolean.
76+
*
77+
* The HITL gate is enforced when the tool is invoked through an `LlmAgent`
78+
* turn: `agents/functions.ts` surfaces an `adk_request_confirmation`
79+
* interrupt from the tool's `requestedToolConfirmations`, and the tool only
80+
* executes once the user approves (via the
81+
* `RequestConfirmationLlmRequestProcessor`).
82+
*
83+
* NOTE: a workflow `ToolNode` does not yet route through that path, so a
84+
* `requireConfirmation` tool used directly as a node does not pause — it
85+
* returns the "requires confirmation" error as its node output. Approval for
86+
* workflow nodes is not wired up. Mirrors Python's
87+
* `FunctionTool(require_confirmation=...)`.
88+
*/
89+
requireConfirmation?: RequireConfirmation<TParameters>;
6090
};
6191

6292
function toSchema<TParameters extends ToolInputParameters>(
@@ -111,6 +141,8 @@ export class FunctionTool<
111141
private readonly execute: ToolExecuteFunction<TParameters>;
112142
// Typed input parameters.
113143
private readonly parameters?: TParameters;
144+
// Whether the tool requires user confirmation before running.
145+
private readonly requireConfirmation: RequireConfirmation<TParameters>;
114146

115147
/**
116148
* The constructor acts as the user-friendly factory.
@@ -130,6 +162,7 @@ export class FunctionTool<
130162
});
131163
this.execute = options.execute;
132164
this.parameters = options.parameters;
165+
this.requireConfirmation = options.requireConfirmation ?? false;
133166
}
134167

135168
/**
@@ -157,6 +190,15 @@ export class FunctionTool<
157190
if (isZodObject(this.parameters)) {
158191
validatedArgs = this.parameters.parse(req.args);
159192
}
193+
194+
const pending = await this.checkConfirmation(
195+
validatedArgs as ToolExecuteArgument<TParameters>,
196+
req.toolContext,
197+
);
198+
if (pending !== undefined) {
199+
return pending;
200+
}
201+
160202
return await this.execute(
161203
validatedArgs as ToolExecuteArgument<TParameters>,
162204
req.toolContext,
@@ -167,4 +209,45 @@ export class FunctionTool<
167209
throw new Error(`Error in tool '${this.name}': ${errorMessage}`);
168210
}
169211
}
212+
213+
/**
214+
* Evaluates the confirmation gate. Returns `undefined` if the tool may
215+
* proceed; otherwise returns the function response payload to surface instead
216+
* of running (a request-for-confirmation on the first pass, or a rejection
217+
* once the user declined).
218+
*/
219+
private async checkConfirmation(
220+
input: ToolExecuteArgument<TParameters>,
221+
toolContext?: Context,
222+
): Promise<{error: string} | undefined> {
223+
const requireConfirmation =
224+
typeof this.requireConfirmation === 'function'
225+
? await this.requireConfirmation(input, toolContext)
226+
: this.requireConfirmation;
227+
if (!requireConfirmation) {
228+
return undefined;
229+
}
230+
if (!toolContext) {
231+
throw new Error(
232+
`Tool '${this.name}' requires confirmation but no tool context was provided.`,
233+
);
234+
}
235+
if (!toolContext.toolConfirmation) {
236+
toolContext.requestConfirmation({
237+
hint:
238+
`Please approve or reject the tool call ${this.name}() by ` +
239+
'responding with a FunctionResponse with an expected ' +
240+
'ToolConfirmation payload.',
241+
});
242+
toolContext.actions.skipSummarization = true;
243+
return {
244+
error:
245+
'This tool call requires confirmation, please approve or reject.',
246+
};
247+
}
248+
if (!toolContext.toolConfirmation.confirmed) {
249+
return {error: 'This tool call is rejected.'};
250+
}
251+
return undefined;
252+
}
170253
}

0 commit comments

Comments
 (0)