Skip to content
Merged
11 changes: 6 additions & 5 deletions core/src/agents/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export {
generateClientFunctionCallId,
populateClientFunctionCallId,
} from '../events/event.js';
export const REQUEST_EUC_FUNCTION_CALL_NAME = 'adk_request_credential';
export const REQUEST_INPUT_FUNCTION_CALL_NAME = 'adk_request_input';
export const REQUEST_CREDENTIAL_FUNCTION_CALL_NAME = 'adk_request_credential';
export const REQUEST_CONFIRMATION_FUNCTION_CALL_NAME =
'adk_request_confirmation';

Expand Down Expand Up @@ -91,16 +92,16 @@ export function generateAuthEvent(
for (const [functionCallId, authConfig] of Object.entries(
functionResponseEvent.actions.requestedAuthConfigs,
)) {
const requestEucFunctionCall: FunctionCall = {
name: REQUEST_EUC_FUNCTION_CALL_NAME,
const requestCredentialFunctionCall: FunctionCall = {
name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
args: {
'function_call_id': functionCallId,
'auth_config': authConfig,
},
id: generateClientFunctionCallId(),
};
longRunningToolIds.add(requestEucFunctionCall.id!);
parts.push({functionCall: requestEucFunctionCall});
longRunningToolIds.add(requestCredentialFunctionCall.id!);
parts.push({functionCall: requestCredentialFunctionCall});
}

return createEvent({
Expand Down
6 changes: 3 additions & 3 deletions core/src/agents/processors/content_processor_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {isSegmentPrefix} from '../../utils/branch_trie.js';
import {
AF_FUNCTION_CALL_ID_PREFIX,
REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
REQUEST_EUC_FUNCTION_CALL_NAME,
REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
} from '../functions.js';

/**
Expand Down Expand Up @@ -154,8 +154,8 @@ function isAuthEvent(event: Event): boolean {
}
for (const part of event.content.parts) {
if (
part.functionCall?.name === REQUEST_EUC_FUNCTION_CALL_NAME ||
part.functionResponse?.name === REQUEST_EUC_FUNCTION_CALL_NAME
part.functionCall?.name === REQUEST_CREDENTIAL_FUNCTION_CALL_NAME ||
part.functionResponse?.name === REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
) {
return true;
}
Expand Down
224 changes: 224 additions & 0 deletions core/src/agents/user_input_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Inspection helpers for the "paused, waiting on a human" state.
*
* A pause is not visible in an event's text: it is carried in a `functionCall`
* part named `adk_request_*`, with the prompt buried in that call's `args`. A
* client that renders only text parts therefore shows the user nothing while
* the run sits blocked. These helpers flatten the three encodings into one
* shape so a caller need not know how each kind stores its id and prompt.
*/

import {AuthConfig} from '../auth/auth_tool.js';
import {Event} from '../events/event.js';
import {camelCaseKeys} from '../utils/case_utils.js';
import {
REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
REQUEST_INPUT_FUNCTION_CALL_NAME,
} from './functions.js';

/**
* What a paused run is waiting for.
*
* - `input`: free-form or structured data (`RequestInput`).
* - `credential`: an auth credential, e.g. an API key or an OAuth flow.
* - `confirmation`: approval to run a tool guarded by `requireConfirmation`.
*/
export type UserInputKind = 'input' | 'credential' | 'confirmation';

/** A single request for user input carried by an event. */
export interface UserInputRequest {
kind: UserInputKind;

/**
* The id that answers this request: reply with a `functionResponse` carrying
* this same id (and {@link functionCallName}).
*/
interruptId: string;

/** The name to answer with, alongside {@link interruptId}. */
functionCallName: string;

/** The node or agent that raised the request. */
author?: string;

/**
* Human-readable prompt for the user. Populated for every kind: the
* `RequestInput` message, the credential prompt, or the tool-confirmation
* hint. Absent when the raiser supplied none.
*/
message?: string;

/** Structured data attached to the request (`RequestInput.payload`). */
payload?: unknown;

/** JSON schema the reply is expected to satisfy, when declared. */
responseSchema?: unknown;

/** `confirmation` only: the tool awaiting approval. */
toolName?: string;

/** `credential` only: the auth config to complete. */
authConfig?: AuthConfig;
}

/**
* Returns every user-input request carried by a single event, in part order.
*
* This reports what the event *asks for*; it does not know whether the request
* was later answered. Use {@link getPendingUserInputRequests} over a session's
* events to get only the ones still outstanding.
*/
export function getUserInputRequests(event: Event): UserInputRequest[] {
const requests: UserInputRequest[] = [];

for (const part of event.content?.parts ?? []) {
const functionCall = part.functionCall;
if (!functionCall?.name) {
continue;
}

const args = normalizeArgs(functionCall.name, functionCall.args);
// Every interrupt kind stashes its id somewhere slightly different.
const interruptId =
functionCall.id ??
asString(args['interruptId']) ??
asString(args['functionCallId']);
if (!interruptId) {
continue;
}

const base = {
interruptId,
functionCallName: functionCall.name,
author: event.author,
};

switch (functionCall.name) {
case REQUEST_INPUT_FUNCTION_CALL_NAME:
requests.push({
...base,
kind: 'input',
message: asString(args['message']),
payload: args['payload'] ?? undefined,
responseSchema: args['responseSchema'] ?? undefined,
});
break;

case REQUEST_CREDENTIAL_FUNCTION_CALL_NAME:
requests.push({
...base,
kind: 'credential',
message: asString(args['message']),
authConfig: (args['authConfig'] as AuthConfig) ?? undefined,
});
break;

case REQUEST_CONFIRMATION_FUNCTION_CALL_NAME: {
const confirmation = args['toolConfirmation'] as
| {hint?: unknown; payload?: unknown}
| undefined;
const originalCall = args['originalFunctionCall'] as
| {name?: unknown}
| undefined;
requests.push({
...base,
kind: 'confirmation',
// Surfaced as `message` so callers can render any kind uniformly.
message: asNonEmptyString(confirmation?.hint),
payload: confirmation?.payload ?? undefined,
toolName: asString(originalCall?.name),
});
break;
}

default:
break;
}
}

return requests;
}

/** Whether this event asks the user for something. */
export function requiresUserInput(event: Event): boolean {
return getUserInputRequests(event).length > 0;
}

/**
* Returns the requests across a sequence of events that have not been answered
* yet, in the order they were raised.
*
* A request is answered by a later `functionResponse` part carrying the same
* id, which is how a resumed session records the user's reply. Pass a session's
* events to answer "is this session waiting on the user right now, and for
* what?".
*/
export function getPendingUserInputRequests(
events: readonly Event[],
): UserInputRequest[] {
const answeredIds = new Set<string>();
for (const event of events) {
for (const part of event.content?.parts ?? []) {
const id = part.functionResponse?.id;
if (id) {
answeredIds.add(id);
}
}
}

const pending: UserInputRequest[] = [];
const seenIds = new Set<string>();
for (const event of events) {
for (const request of getUserInputRequests(event)) {
// A re-run node can raise the same interrupt id more than once; the user
// still only owes one answer.
if (
answeredIds.has(request.interruptId) ||
seenIds.has(request.interruptId)
) {
continue;
}
seenIds.add(request.interruptId);
pending.push(request);
}
}

return pending;
}

/**
* The two producers of an `adk_request_credential` call disagree on casing:
* the agent/tool auth flow writes snake_case (`functions.ts` `generateAuthEvent`
* -> `function_call_id`, `auth_config`) while the workflow auth gate writes
* camelCase (`hitl_utils.ts` `createAuthRequestEvent`). Normalize that kind the
* same way `auth_preprocessor` does, so both render.
*
* Only credential args are rewritten: the other kinds carry a caller-supplied
* `payload` whose own keys must survive untouched.
*/
function normalizeArgs(
functionCallName: string,
args: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!args) {
return {};
}
return functionCallName === REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
? (camelCaseKeys(args) as Record<string, unknown>)
: args;
}

function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

function asNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined;
}
8 changes: 4 additions & 4 deletions core/src/auth/auth_preprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import {
REQUEST_EUC_FUNCTION_CALL_NAME,
REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
handleFunctionCallsAsync,
} from '../agents/functions.js';
import {InvocationContext} from '../agents/invocation_context.js';
Expand Down Expand Up @@ -43,7 +43,7 @@ async function storeAuthAndCollectResumeTargets(
if (
functionCall.id &&
authFcIds.has(functionCall.id) &&
functionCall.name === REQUEST_EUC_FUNCTION_CALL_NAME
functionCall.name === REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
) {
const args = camelCaseKeys(functionCall.args) as RequestCredentialArgs;
const authConfig = args?.authConfig;
Expand Down Expand Up @@ -74,7 +74,7 @@ async function storeAuthAndCollectResumeTargets(
for (const functionCall of eventFunctionCalls) {
if (
functionCall.id === fcId &&
functionCall.name === REQUEST_EUC_FUNCTION_CALL_NAME
functionCall.name === REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
) {
const args = camelCaseKeys(
functionCall.args,
Expand Down Expand Up @@ -130,7 +130,7 @@ export class AuthPreprocessor extends BaseLlmRequestProcessor {
const authResponses: Record<string, unknown> = {};

for (const functionCallResponse of responses) {
if (functionCallResponse.name !== REQUEST_EUC_FUNCTION_CALL_NAME) {
if (functionCallResponse.name !== REQUEST_CREDENTIAL_FUNCTION_CALL_NAME) {
continue;
}
if (functionCallResponse.id) {
Expand Down
13 changes: 12 additions & 1 deletion core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type {
} from './agents/base_agent.js';
export {Context} from './agents/context.js';
export {
REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
REQUEST_INPUT_FUNCTION_CALL_NAME,
findEventByFunctionCallId,
findMatchingFunctionCall,
functionsExportedForTestingOnly,
Expand Down Expand Up @@ -65,6 +68,15 @@ export {StreamingMode} from './agents/run_config.js';
export type {RunConfig} from './agents/run_config.js';
export {SequentialAgent, isSequentialAgent} from './agents/sequential_agent.js';
export type {TranscriptionEntry} from './agents/transcription_entry.js';
export {
getPendingUserInputRequests,
getUserInputRequests,
requiresUserInput,
} from './agents/user_input_request.js';
Comment on lines +71 to +75

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Two of these three exports have no caller.

export {
  getPendingUserInputRequests,
  getUserInputRequests,
  requiresUserInput,
} from './agents/user_input_request.js';

getUserInputRequests is used by cli_run.ts. requiresUserInput and getPendingUserInputRequests are referenced only by their own definitions and this line. AgentLoader.listLoadFailures() is in the same position: the description says a failure "is reported against the app it belongs to", but no server route calls it, and adk_api_server.ts is not in this diff.

My other comment gives getPendingUserInputRequests a real caller. For the remaining two, either add the caller in this PR or hold them back — a public export is hard to withdraw later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. All three have real callers now rather than being held back:

  • getPendingUserInputRequests — the saved-session replay in your other comment.
  • requiresUserInput — runFromInputFile uses it to warn when a scripted --input_file run ends blocked on a human. That run has no prompt to answer at, so before this it just stopped mid-workflow with no output explaining why.
  • AgentLoader.listLoadFailures() — served at GET /list-app-errors (name, file path, reason). That is the gap you are pointing at: /list-apps omits a broken agent, so it disappears from the dev UI and the only trace is a server log line. /list-apps keeps returning string[], so the UI client is untouched. Three server tests cover the route.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the above: the /list-app-errors endpoint is out again (5113073). Exposing load failures over HTTP is a product decision, not something a bug-fix branch should settle, so it should not ride along here.

That leaves listLoadFailures() where your comment found it — a loader method with no server caller. Keeping it deliberately: it is dev-package API rather than a @google/adk export, so it is far cheaper to withdraw than the core helpers, and the surface it will feed is the route decision above. The behaviour that matters is unchanged either way: getAgentFile rethrows the original error for a named app, and every skipped agent is logged with its file and reason.

The other two callers in that reply stand: getPendingUserInputRequests in the saved-session replay, requiresUserInput in the --input_file warning.

export type {
UserInputKind,
UserInputRequest,
} from './agents/user_input_request.js';
export {createResumabilityConfig} from './apps/resumability_config.js';
export type {ResumabilityConfig} from './apps/resumability_config.js';
export type {
Expand Down Expand Up @@ -202,7 +214,6 @@ export {PluginManager} from './plugins/plugin_manager.js';
export {
InMemoryPolicyEngine,
PolicyOutcome,
REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
SecurityPlugin,
getAskUserConfirmationFunctionCalls,
} from './plugins/security_plugin.js';
Expand Down
5 changes: 3 additions & 2 deletions core/src/plugins/security_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
import {FunctionCall} from '@google/genai';

import {Context} from '../agents/context.js';
import {REQUEST_CONFIRMATION_FUNCTION_CALL_NAME} from '../agents/functions.js';
import {Event} from '../events/event.js';
import {BasePlugin} from '../plugins/base_plugin.js';
import {BaseTool} from '../tools/base_tool.js';
import {ToolConfirmation} from '../tools/tool_confirmation.js';

// Constants
export const REQUEST_CONFIRMATION_FUNCTION_CALL_NAME =
'adk_request_confirmation';
// Re-exported, not redefined: `agents/functions.ts` is the single definition.
export {REQUEST_CONFIRMATION_FUNCTION_CALL_NAME};

const TOOL_CALL_SECURITY_CHECK_STATES = 'orcas_tool_call_security_check_states';
const INTERMEDIATE_REQUIRE_TOOL_CALL_CONFIRMATION_ERROR =
Expand Down
3 changes: 2 additions & 1 deletion core/src/tools/request_input_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
*/

import {z} from 'zod';
import {REQUEST_INPUT_FUNCTION_CALL_NAME} from '../agents/functions.js';
import {LongRunningFunctionTool} from './long_running_tool.js';

/**
* A long-running tool that presents a custom message to the user and awaits
* unstructured or structured input.
*/
export const requestInputTool = new LongRunningFunctionTool({
name: 'adk_request_input',
name: REQUEST_INPUT_FUNCTION_CALL_NAME,
description:
'Presents a custom message to the user and awaits unstructured or structured input.',
parameters: z.object({
Expand Down
4 changes: 4 additions & 0 deletions core/src/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export type {
// --- HITL ---
export {RequestInput, isRequestInput} from './request_input.js';
export type {RequestInputParams} from './request_input.js';
export {
REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
REQUEST_INPUT_FUNCTION_CALL_NAME,
} from './utils/hitl_utils.js';
Comment thread
kalenkevich marked this conversation as resolved.

// --- Retry ---
export {normalizeRetryExceptions, prepareRetryConfig} from './retry_config.js';
Expand Down
Loading
Loading