Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string, ToolConfirmation>;
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<string>();
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();
8 changes: 8 additions & 0 deletions core/src/agents/run_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
1 change: 1 addition & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 84 additions & 1 deletion core/src/tools/function_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,21 @@ export type ToolExecuteArgument<TParameters extends ToolInputParameters> =
*/
export type ToolExecuteFunction<TParameters extends ToolInputParameters> = (
input: ToolExecuteArgument<TParameters>,
tool_context?: Context,
toolContext?: Context,
) => Promise<unknown> | 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<TParameters extends ToolInputParameters> =
| boolean
| ((
input: ToolExecuteArgument<TParameters>,
toolContext?: Context,
) => boolean | Promise<boolean>);

/**
* The configuration options for creating a function-based tool.
* The `name`, `description` and `parameters` fields are used to generate the
Expand All @@ -57,6 +69,24 @@ export type ToolOptions<TParameters extends ToolInputParameters> = {
parameters?: TParameters;
execute: ToolExecuteFunction<TParameters>;
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<TParameters>;
};

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

/**
* The constructor acts as the user-friendly factory.
Expand All @@ -130,6 +162,7 @@ export class FunctionTool<
});
this.execute = options.execute;
this.parameters = options.parameters;
this.requireConfirmation = options.requireConfirmation ?? false;
}

/**
Expand Down Expand Up @@ -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<TParameters>,
req.toolContext,
);
if (pending !== undefined) {
return pending;
}

return await this.execute(
validatedArgs as ToolExecuteArgument<TParameters>,
req.toolContext,
Expand All @@ -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<TParameters>,
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;
}
}
Loading
Loading