diff --git a/core/src/agents/functions.ts b/core/src/agents/functions.ts index 386e4f54b9..08357598a0 100644 --- a/core/src/agents/functions.ts +++ b/core/src/agents/functions.ts @@ -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'; @@ -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({ diff --git a/core/src/agents/processors/content_processor_utils.ts b/core/src/agents/processors/content_processor_utils.ts index 70d91dcd80..3b5d139466 100644 --- a/core/src/agents/processors/content_processor_utils.ts +++ b/core/src/agents/processors/content_processor_utils.ts @@ -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'; /** @@ -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; } diff --git a/core/src/agents/user_input_request.ts b/core/src/agents/user_input_request.ts new file mode 100644 index 0000000000..c6fb6fd26a --- /dev/null +++ b/core/src/agents/user_input_request.ts @@ -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(); + 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(); + 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 | undefined, +): Record { + if (!args) { + return {}; + } + return functionCallName === REQUEST_CREDENTIAL_FUNCTION_CALL_NAME + ? (camelCaseKeys(args) as Record) + : 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; +} diff --git a/core/src/auth/auth_preprocessor.ts b/core/src/auth/auth_preprocessor.ts index a5a9979cb6..fea832caf4 100644 --- a/core/src/auth/auth_preprocessor.ts +++ b/core/src/auth/auth_preprocessor.ts @@ -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'; @@ -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; @@ -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, @@ -130,7 +130,7 @@ export class AuthPreprocessor extends BaseLlmRequestProcessor { const authResponses: Record = {}; 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) { diff --git a/core/src/common.ts b/core/src/common.ts index b821364d26..12c9adce61 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -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, @@ -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'; +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 { @@ -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'; diff --git a/core/src/plugins/security_plugin.ts b/core/src/plugins/security_plugin.ts index 64e988c43a..cb87ee5dc5 100644 --- a/core/src/plugins/security_plugin.ts +++ b/core/src/plugins/security_plugin.ts @@ -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 = diff --git a/core/src/tools/request_input_tool.ts b/core/src/tools/request_input_tool.ts index 7c97f66fb5..44b1d82b0b 100644 --- a/core/src/tools/request_input_tool.ts +++ b/core/src/tools/request_input_tool.ts @@ -5,6 +5,7 @@ */ import {z} from 'zod'; +import {REQUEST_INPUT_FUNCTION_CALL_NAME} from '../agents/functions.js'; import {LongRunningFunctionTool} from './long_running_tool.js'; /** @@ -12,7 +13,7 @@ import {LongRunningFunctionTool} from './long_running_tool.js'; * 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({ diff --git a/core/src/workflow/index.ts b/core/src/workflow/index.ts index 47deb74147..8cce96f1da 100644 --- a/core/src/workflow/index.ts +++ b/core/src/workflow/index.ts @@ -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'; // --- Retry --- export {normalizeRetryExceptions, prepareRetryConfig} from './retry_config.js'; diff --git a/core/src/workflow/utils/hitl_utils.ts b/core/src/workflow/utils/hitl_utils.ts index f87d5a7d60..4bb7eebcb1 100644 --- a/core/src/workflow/utils/hitl_utils.ts +++ b/core/src/workflow/utils/hitl_utils.ts @@ -12,6 +12,10 @@ */ import {Part} from '@google/genai'; +import { + REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, + REQUEST_INPUT_FUNCTION_CALL_NAME, +} from '../../agents/functions.js'; import { AuthCredential, AuthCredentialTypes, @@ -23,11 +27,10 @@ import {State} from '../../sessions/state.js'; import {toJsonSchema} from '../../utils/schema.js'; import {RequestInput} from '../request_input.js'; -/** Function-call name marking a request-for-input interrupt. */ -export const REQUEST_INPUT_FUNCTION_CALL_NAME = 'adk_request_input'; - -/** Function-call name marking a request-for-credential interrupt. */ -export const REQUEST_CREDENTIAL_FUNCTION_CALL_NAME = 'adk_request_credential'; +export { + REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, + REQUEST_INPUT_FUNCTION_CALL_NAME, +} from '../../agents/functions.js'; /** * Creates an interrupt {@link Event} from a {@link RequestInput}. The event diff --git a/core/test/agents/user_input_request_test.ts b/core/test/agents/user_input_request_test.ts new file mode 100644 index 0000000000..a58782fcc5 --- /dev/null +++ b/core/test/agents/user_input_request_test.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createEventActions, + functionsExportedForTestingOnly, + InvocationContext, + LlmAgent, + PluginManager, + Session, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; +import { + getPendingUserInputRequests, + getUserInputRequests, + requiresUserInput, +} from '../../src/agents/user_input_request.js'; +import {AuthConfig} from '../../src/auth/auth_tool.js'; +import {createEvent, Event} from '../../src/events/event.js'; + +/** An `adk_request_input` interrupt, as raised by a workflow `RequestInput`. */ +function requestInputEvent( + interruptId: string, + args: Record = {}, +): Event { + return createEvent({ + author: 'step1', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'adk_request_input', + id: interruptId, + args: {interruptId, message: 'Enter a number:', ...args}, + }, + }, + ], + }, + longRunningToolIds: [interruptId], + }); +} + +/** The user's reply to an interrupt, as recorded on resume. */ +function responseEvent(interruptId: string, name: string): Event { + return createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + {functionResponse: {id: interruptId, name, response: {result: 21}}}, + ], + }, + }); +} + +describe('getUserInputRequests', () => { + it('summarizes a request for input', () => { + const [request] = getUserInputRequests( + requestInputEvent('i1', { + payload: {draft: 'hello'}, + responseSchema: {type: 'object'}, + }), + ); + + expect(request).toEqual({ + kind: 'input', + interruptId: 'i1', + functionCallName: 'adk_request_input', + author: 'step1', + message: 'Enter a number:', + payload: {draft: 'hello'}, + responseSchema: {type: 'object'}, + }); + }); + + it('summarizes a request for a credential', () => { + const authConfig = { + authScheme: {type: 'apiKey', in: 'header', name: 'X-Api-Key'}, + credentialKey: 'weather_api_key', + }; + const event = createEvent({ + author: 'fetch_weather', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'adk_request_credential', + id: 'weather_api_key', + args: { + functionCallId: 'weather_api_key', + authConfig, + message: 'Please provide your API key.', + }, + }, + }, + ], + }, + }); + + const [request] = getUserInputRequests(event); + + expect(request.kind).toBe('credential'); + expect(request.interruptId).toBe('weather_api_key'); + expect(request.message).toBe('Please provide your API key.'); + expect(request.authConfig).toEqual(authConfig); + }); + + it('summarizes a credential request raised by the agent auth flow', () => { + // The agent/tool flow writes snake_case args and no message, unlike the + // workflow flow above; build it with the real producer so the two + // encodings cannot drift apart from this test. + const authConfig = { + authScheme: {type: 'apiKey', in: 'header', name: 'X-Api-Key'}, + credentialKey: 'weather_api_key', + } as unknown as AuthConfig; + const functionResponseEvent = createEvent({ + actions: createEventActions({ + requestedAuthConfigs: {'call_1': authConfig}, + }), + content: {role: 'model', parts: []}, + }); + const event = functionsExportedForTestingOnly.generateAuthEvent( + new InvocationContext({ + invocationId: 'inv_123', + session: {} as Session, + agent: new LlmAgent({name: 'fetch_weather', model: 'test_model'}), + pluginManager: new PluginManager(), + }), + functionResponseEvent, + )!; + + const [request] = getUserInputRequests(event); + + expect(request.kind).toBe('credential'); + expect(request.author).toBe('fetch_weather'); + expect(request.interruptId).toBe( + event.content!.parts![0].functionCall!.id!, + ); + expect(request.authConfig).toEqual(authConfig); + }); + + it('summarizes a tool-confirmation request, exposing the hint as the message', () => { + const event = createEvent({ + author: 'generate_instruction', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'adk_request_confirmation', + id: 'confirm-1', + args: { + originalFunctionCall: {name: 'find_orders', args: {}}, + toolConfirmation: { + hint: 'This reads patient records.', + confirmed: false, + }, + }, + }, + }, + ], + }, + }); + + const [request] = getUserInputRequests(event); + + expect(request.kind).toBe('confirmation'); + expect(request.toolName).toBe('find_orders'); + expect(request.message).toBe('This reads patient records.'); + }); + + it('falls back to the args id when the function call has none', () => { + const event = createEvent({ + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'adk_request_input', + args: {interruptId: 'from-args'}, + }, + }, + ], + }, + }); + + expect(getUserInputRequests(event)[0].interruptId).toBe('from-args'); + }); + + it('reports every request when one event raises several', () => { + const event = createEvent({ + content: { + role: 'model', + parts: [ + {functionCall: {name: 'adk_request_input', id: 'a', args: {}}}, + {functionCall: {name: 'adk_request_input', id: 'b', args: {}}}, + ], + }, + }); + + expect(getUserInputRequests(event).map((r) => r.interruptId)).toEqual([ + 'a', + 'b', + ]); + }); + + it('ignores ordinary tool calls and plain text', () => { + const event = createEvent({ + author: 'agent', + content: { + role: 'model', + parts: [ + {text: 'Looking that up.'}, + {functionCall: {name: 'get_weather', id: 'c1', args: {city: 'SF'}}}, + ], + }, + }); + + expect(getUserInputRequests(event)).toEqual([]); + expect(requiresUserInput(event)).toBe(false); + }); + + it('ignores an interrupt-shaped call with no id to answer', () => { + const event = createEvent({ + content: { + role: 'model', + parts: [{functionCall: {name: 'adk_request_input', args: {}}}], + }, + }); + + expect(getUserInputRequests(event)).toEqual([]); + }); + + it('tolerates an event with no content', () => { + expect(getUserInputRequests(createEvent({author: 'a'}))).toEqual([]); + expect(requiresUserInput(createEvent({author: 'a'}))).toBe(false); + }); +}); + +describe('requiresUserInput', () => { + it('is true for an event that asks for something', () => { + expect(requiresUserInput(requestInputEvent('i1'))).toBe(true); + }); +}); + +describe('getPendingUserInputRequests', () => { + it('returns a request that has not been answered', () => { + const pending = getPendingUserInputRequests([requestInputEvent('i1')]); + + expect(pending.map((r) => r.interruptId)).toEqual(['i1']); + }); + + it('drops a request once a matching function response arrives', () => { + const pending = getPendingUserInputRequests([ + requestInputEvent('i1'), + responseEvent('i1', 'adk_request_input'), + ]); + + expect(pending).toEqual([]); + }); + + it('keeps unanswered requests when only one of several is answered', () => { + const pending = getPendingUserInputRequests([ + requestInputEvent('i1'), + requestInputEvent('i2'), + responseEvent('i1', 'adk_request_input'), + ]); + + expect(pending.map((r) => r.interruptId)).toEqual(['i2']); + }); + + it('reports a re-raised interrupt id only once', () => { + // A `rerunOnResume` node re-raises the same id on every attempt, but the + // user still owes exactly one answer. + const pending = getPendingUserInputRequests([ + requestInputEvent('i1'), + requestInputEvent('i1'), + ]); + + expect(pending.map((r) => r.interruptId)).toEqual(['i1']); + }); + + it('preserves the order requests were raised in', () => { + const pending = getPendingUserInputRequests([ + requestInputEvent('first'), + requestInputEvent('second'), + ]); + + expect(pending.map((r) => r.interruptId)).toEqual(['first', 'second']); + }); + + it('returns nothing for a session with no interrupts', () => { + const event = createEvent({ + author: 'agent', + content: {role: 'model', parts: [{text: 'all done'}]}, + }); + + expect(getPendingUserInputRequests([event])).toEqual([]); + }); +}); diff --git a/core/test/auth/auth_preprocessor_test.ts b/core/test/auth/auth_preprocessor_test.ts index a9a29b34ad..1e4b0b2173 100644 --- a/core/test/auth/auth_preprocessor_test.ts +++ b/core/test/auth/auth_preprocessor_test.ts @@ -11,7 +11,7 @@ import { createEvent, } from '@google/adk'; import {Mock, describe, expect, it, vi} from 'vitest'; -import {REQUEST_EUC_FUNCTION_CALL_NAME} from '../../src/agents/functions.js'; +import {REQUEST_CREDENTIAL_FUNCTION_CALL_NAME} from '../../src/agents/functions.js'; vi.mock('../../src/agents/functions.js', async (importOriginal) => { const actual = (await importOriginal()) as { @@ -135,7 +135,7 @@ describe('AuthPreprocessor', () => { { functionCall: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, args: { authConfig: {credentialKey: 'testKey'}, functionCallId: 'toolFc1', @@ -152,7 +152,7 @@ describe('AuthPreprocessor', () => { { functionResponse: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, response: {authType: 'apiKey', apiKey: 'test'}, }, }, @@ -209,7 +209,7 @@ describe('AuthPreprocessor', () => { { functionCall: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, args: { auth_config: {credentialKey: 'testKey'}, function_call_id: 'toolFc1', @@ -226,7 +226,7 @@ describe('AuthPreprocessor', () => { { functionResponse: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, response: {authType: 'apiKey', apiKey: 'test'}, }, }, @@ -283,7 +283,7 @@ describe('AuthPreprocessor', () => { { functionCall: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, args: { auth_config: {credential_key: 'testKey'}, function_call_id: 'toolFc1', @@ -300,7 +300,7 @@ describe('AuthPreprocessor', () => { { functionResponse: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, response: {authType: 'apiKey', apiKey: 'test'}, }, }, @@ -369,7 +369,7 @@ describe('AuthPreprocessor', () => { { functionCall: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, args: { authConfig: {credentialKey: 'testKey'}, functionCallId: '_adk_toolset_auth_something', @@ -386,7 +386,7 @@ describe('AuthPreprocessor', () => { { functionResponse: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, response: {authType: 'apiKey', apiKey: 'test'}, }, }, @@ -421,7 +421,7 @@ describe('AuthPreprocessor', () => { { functionResponse: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, response: {authType: 'apiKey', apiKey: 'test'}, }, }, @@ -470,7 +470,7 @@ describe('AuthPreprocessor', () => { { functionCall: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, args: { authConfig: {credentialKey: 'testKey'}, functionCallId: 'toolFc1', @@ -487,7 +487,7 @@ describe('AuthPreprocessor', () => { { functionResponse: { id: 'fc1', - name: REQUEST_EUC_FUNCTION_CALL_NAME, + name: REQUEST_CREDENTIAL_FUNCTION_CALL_NAME, response: {authType: 'apiKey', apiKey: 'test'}, }, }, diff --git a/dev/src/cli/cli_run.ts b/dev/src/cli/cli_run.ts index 8ed8e29b1a..fa60829c3b 100644 --- a/dev/src/cli/cli_run.ts +++ b/dev/src/cli/cli_run.ts @@ -10,12 +10,18 @@ import { BaseArtifactService, BaseMemoryService, BaseSessionService, + Event, + getPendingUserInputRequests, + getUserInputRequests, InMemoryArtifactService, InMemoryMemoryService, InMemorySessionService, isApp, + requiresUserInput, Runner, Session, + UserInputKind, + UserInputRequest, } from '@google/adk'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -26,6 +32,99 @@ import {loadFileData, saveToFile} from '../utils/file_utils.js'; const dirname = process.cwd(); +const HOW_TO_ANSWER: Record = { + input: 'Type your reply at the next prompt to continue.', + credential: 'Type the credential at the next prompt to continue.', + confirmation: "Reply 'yes' to approve or 'no' to reject.", +}; + +/** + * Formatting only — detection lives in `getUserInputRequests`. This decides how + * the CLI words a pause and how the user is told to answer it. + */ +function renderUserInputRequest(request: UserInputRequest): string { + const author = request.author ?? 'agent'; + const lines: string[] = []; + + switch (request.kind) { + case 'input': + lines.push(`--- [${author}] is waiting for your input ---`); + break; + case 'credential': + lines.push(`--- [${author}] is waiting for a credential ---`); + break; + case 'confirmation': + lines.push( + `--- [${author}] is waiting for confirmation ---` + + (request.toolName ? `\nTool: ${request.toolName}` : ''), + ); + break; + default: + break; + } + + if (request.message) { + lines.push(request.message); + } + if (request.payload != null) { + lines.push(`Payload: ${JSON.stringify(request.payload)}`); + } + if (request.responseSchema != null) { + lines.push(`Expected response: ${JSON.stringify(request.responseSchema)}`); + } + + const scheme = request.authConfig?.authScheme as + | {type?: string; in?: string; name?: string} + | undefined; + if (scheme?.type) { + const where = + scheme.in && scheme.name ? ` (${scheme.in} ${scheme.name})` : ''; + lines.push(`Auth scheme: ${scheme.type}${where}`); + } + + lines.push(HOW_TO_ANSWER[request.kind]); + + return lines.join('\n'); +} + +interface PrintEventOptions { + /** + * Whether to announce the pauses this event raised. Off when replaying a + * saved transcript, where a pause shown per event would re-ask questions the + * user already answered; the still-open ones are printed once afterwards. + */ + announcePauses?: boolean; +} + +/** Prints one event's text, plus anything the user would otherwise not see. */ +function printEvent(event: Event, options: PrintEventOptions = {}): void { + const {announcePauses = true} = options; + const author = event.author ?? 'agent'; + + const text = (event.content?.parts ?? []) + .map((part) => part.text || '') + .join(''); + if (text) { + console.log(`[${author}]: ${text}`); + } + + // Reported on the event, not as a text part, so text-only printing drops it. + if (event.errorCode || event.errorMessage) { + const detail = [event.errorCode, event.errorMessage] + .filter(Boolean) + .join(': '); + console.error(`[${author}] error: ${detail}`); + } + + if (!announcePauses) { + return; + } + + for (const request of getUserInputRequests(event)) { + console.log(renderUserInputRequest(request)); + } +} + interface InputFile { state: Record; queries: string[]; @@ -73,6 +172,7 @@ async function runFromInputFile( }); const runner = new Runner(options); + let waitingOnUser = false; for (const query of fileContent.queries) { console.log(`[user]: ${query}`); @@ -86,18 +186,22 @@ async function runFromInputFile( runConfig: {plainTextToolConfirmation: true}, }; + waitingOnUser = false; for await (const event of runner.runAsync(runOptions)) { - if (event.content && event.content.parts) { - const text = event.content.parts - .map((part) => part.text || '') - .join(''); - if (text) { - console.log(`[${event.author}]: ${text}`); - } - } + printEvent(event); + // A scripted run has no prompt to answer at: whatever the pause asked + // for has to be the next query in the file. + waitingOnUser = requiresUserInput(event) || waitingOnUser; } } + if (waitingOnUser) { + console.error( + 'The run ended while still waiting for user input. ' + + 'Add the answer as the next query in the input file.', + ); + } + return session; } @@ -154,14 +258,7 @@ async function runInteractively( // confirmation (opt-in; off by default on non-interactive surfaces). runConfig: {plainTextToolConfirmation: true}, })) { - if (event.content && event.content.parts) { - const text = event.content.parts - .map((part) => part.text || '') - .join(''); - if (text) { - console.log(`[${event.author}]: ${text}`); - } - } + printEvent(event); } } } @@ -248,15 +345,15 @@ export async function runAgent(options: RunAgentOptions): Promise { if (loadedSession) { for (const event of loadedSession.events) { await sessionService.appendEvent({session, event}); - const content = event.content; - if (content && content.parts?.length) { - const text = content.parts - .map((part) => part.text || '') - .join(''); - if (text) { - console.log(`[${event.author}]: ${text}`); - } - } + printEvent(event, {announcePauses: false}); + } + + // Only the pauses the transcript never answered are still live, and + // they are what the prompt below is waiting on. + for (const request of getPendingUserInputRequests( + loadedSession.events, + )) { + console.log(renderUserInputRequest(request)); } } diff --git a/dev/src/utils/agent_loader.ts b/dev/src/utils/agent_loader.ts index d5097c4826..f56950dc87 100644 --- a/dev/src/utils/agent_loader.ts +++ b/dev/src/utils/agent_loader.ts @@ -61,10 +61,24 @@ interface FileMetadata { } /** - * Error class for agent file loading. + * Signals "this file is not an agent file" (missing, empty, or exporting no + * agent), which is normal in a directory that also holds helper modules, so it + * is swallowed silently. Any OTHER error means the file *is* an agent that + * failed to construct — see {@link AgentLoadFailure}. */ class AgentFileLoadingError extends Error {} +/** + * An agent that could not be loaded. Recorded rather than thrown, so one broken + * agent cannot take the whole server down with it. + */ +export interface AgentLoadFailure { + /** The app name the broken file would have been served under. */ + name: string; + filePath: string; + error: Error; +} + /** * Options for loading an agent file. */ @@ -358,6 +372,7 @@ export class AgentFile { export class AgentLoader { private agentsAlreadyPreloaded = false; private readonly preloadedAgents: Record = {}; + private readonly loadFailures: Record = {}; private watcher?: fs.FSWatcher; constructor( @@ -431,9 +446,25 @@ export class AgentLoader { delete this.preloadedAgents[key]; } + for (const key of Object.keys(this.loadFailures)) { + delete this.loadFailures[key]; + } + this.agentsAlreadyPreloaded = false; } + /** + * The agents that failed to load. They are excluded from {@link listAgents}, + * and {@link getAgentFile} rethrows the original error for one by name. + */ + async listLoadFailures(): Promise { + await this.preloadAgents(); + + return Object.values(this.loadFailures).sort((a, b) => + a.name.localeCompare(b.name), + ); + } + async listAgents(): Promise { await this.preloadAgents(); @@ -461,7 +492,25 @@ export class AgentLoader { async getAgentFile(agentName: string): Promise { await this.preloadAgents(); - return this.preloadedAgents[agentName]; + const agentFile = this.preloadedAgents[agentName]; + if (agentFile) { + return agentFile; + } + + // Report the real reason rather than returning undefined and letting the + // caller fail later with "cannot read properties of undefined". + const failure = this.loadFailures[agentName]; + if (failure) { + throw new Error( + `Agent '${agentName}' failed to load from ${failure.filePath}: ${failure.error.message}`, + {cause: failure.error}, + ); + } + + throw new Error( + `Agent '${agentName}' not found in ${this.agentsDirPath}. ` + + `Available agents: ${Object.keys(this.preloadedAgents).sort().join(', ') || '(none)'}`, + ); } async getAppFile(appName: string): Promise { @@ -512,10 +561,7 @@ export class AgentLoader { await agentFile.load(); this.preloadedAgents[file.name] = agentFile; } catch (e) { - if (e instanceof AgentFileLoadingError) { - return; - } - throw e; + this.recordLoadFailure(file.name, file.path, e); } } @@ -534,12 +580,26 @@ export class AgentLoader { await agentFile.load(); this.preloadedAgents[dir.name] = agentFile; } catch (e) { - if (e instanceof AgentFileLoadingError) { - return; - } - throw e; + this.recordLoadFailure(dir.name, possibleEntryFile.path, e); } } + + /** + * Propagating here would reject the `Promise.all` in `preloadAgents`, failing + * every endpoint that lists or resolves agents — so record instead of throw. + */ + private recordLoadFailure(name: string, filePath: string, e: unknown): void { + if (e instanceof AgentFileLoadingError) { + return; + } + + const error = e instanceof Error ? e : new Error(String(e)); + this.loadFailures[name] = {name, filePath, error}; + logger.error( + `Failed to load agent '${name}' from ${filePath}: ${error.message}. ` + + `Skipping it; the other agents are unaffected.`, + ); + } } function isJsFile(fileExt?: string): boolean { diff --git a/dev/test/cli/cli_run_test.ts b/dev/test/cli/cli_run_test.ts index e5bdcb2317..99517e0a4e 100644 --- a/dev/test/cli/cli_run_test.ts +++ b/dev/test/cli/cli_run_test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {BaseAgent, BaseSessionService} from '@google/adk'; +import {BaseAgent, BaseSessionService, Runner} from '@google/adk'; import * as readline from 'node:readline'; import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; import {runAgent} from '../../src/cli/cli_run.js'; @@ -21,14 +21,24 @@ vi.mock('../../src/utils/file_utils.js', () => ({ saveToFile: vi.fn(), })); -vi.mock('@google/adk', () => { +/** Events the mocked Runner yields for a turn; set per test. */ +const runnerState = vi.hoisted(() => ({ + events: [ + {author: 'model', content: {parts: [{text: 'Response from model'}]}}, + ] as unknown[], +})); + +// Only the Runner and services are faked, so interrupt detection under test is +// the real `getUserInputRequests` rather than a stand-in. +vi.mock('@google/adk', async (importOriginal) => { + const actual = await importOriginal(); return { + ...actual, Runner: vi.fn().mockImplementation(() => ({ runAsync: vi.fn().mockImplementation(async function* () { - yield { - author: 'model', - content: {parts: [{text: 'Response from model'}]}, - }; + for (const event of runnerState.events) { + yield event; + } }), })), InMemoryArtifactService: vi.fn(), @@ -65,6 +75,20 @@ describe('cli_run', () => { vi.clearAllMocks(); vi.spyOn(console, 'log').mockImplementation(() => {}); + runnerState.events = [ + {author: 'model', content: {parts: [{text: 'Response from model'}]}}, + ]; + + // `restoreAllMocks` in afterEach strips the implementation set in the + // module factory, so re-establish it for every test. + (Runner as unknown as Mock).mockImplementation(() => ({ + runAsync: async function* () { + for (const event of runnerState.events) { + yield event; + } + }, + })); + mockRootAgent = { name: 'test-agent', } as unknown as BaseAgent; @@ -173,6 +197,83 @@ describe('cli_run', () => { expect(readline.createInterface).toHaveBeenCalled(); }); + /** An `adk_request_input` interrupt, as a saved transcript records it. */ + const savedInterrupt = (interruptId: string) => ({ + author: 'step1', + content: { + parts: [ + { + functionCall: { + name: 'adk_request_input', + id: interruptId, + args: {interruptId, message: 'Enter a number:'}, + }, + }, + ], + }, + }); + + /** The user's reply to an interrupt, as a saved transcript records it. */ + const savedReply = (interruptId: string) => ({ + author: 'user', + content: { + parts: [ + { + functionResponse: { + id: interruptId, + name: 'adk_request_input', + response: {result: 21}, + }, + }, + ], + }, + }); + + async function replaySavedSession(events: unknown[]): Promise { + (loadFileData as Mock).mockResolvedValue({ + id: 'old-session', + appName: 'test-agent', + userId: 'test_user', + events, + }); + + await runAgent({ + agentPath: 'agent.ts', + savedSessionFile: 'session.json', + sessionService: createMockSessionService(), + }); + + return (console.log as Mock).mock.calls + .map((call) => call.join(' ')) + .join('\n'); + } + + it('does not re-announce a pause the saved session already answered', async () => { + const output = await replaySavedSession([ + {author: 'user', content: {parts: [{text: 'start'}]}}, + savedInterrupt('interrupt-1'), + savedReply('interrupt-1'), + {author: 'step2', content: {parts: [{text: '42'}]}}, + ]); + + expect(output).toContain('[user]: start'); + expect(output).toContain('[step2]: 42'); + expect(output).not.toContain('is waiting'); + }); + + it('announces a pause the saved session left unanswered', async () => { + const output = await replaySavedSession([ + {author: 'user', content: {parts: [{text: 'start'}]}}, + savedInterrupt('interrupt-1'), + savedReply('interrupt-1'), + savedInterrupt('interrupt-2'), + ]); + + const announcements = output.match(/is waiting for your input/g) ?? []; + expect(announcements).toHaveLength(1); + expect(output).toContain('Enter a number:'); + }); + it('should save session when requested', async () => { const mockSessionService = createMockSessionService(); // Run interactively then exit @@ -210,4 +311,179 @@ describe('cli_run', () => { expect.anything(), ); }); + + /** + * An interrupt has no `text` part, so without explicit rendering the REPL + * prints nothing and the user never learns a reply is expected. + */ + describe('interrupt rendering', () => { + /** Drives one interactive turn, then exits, and returns what was printed. */ + async function runOneTurn(event: unknown): Promise { + runnerState.events = [event]; + (mockRl.question as Mock) + .mockImplementationOnce((_p: string, cb: (a: string) => void) => + cb('hello'), + ) + .mockImplementationOnce((_p: string, cb: (a: string) => void) => + cb('exit'), + ); + + await runAgent({ + agentPath: 'agent.ts', + sessionService: createMockSessionService(), + }); + + return (console.log as Mock).mock.calls + .map((call) => call.join(' ')) + .join('\n'); + } + + it('renders a request-for-input pause with its message and schema', async () => { + const output = await runOneTurn({ + author: 'step1', + content: { + parts: [ + { + functionCall: { + name: 'adk_request_input', + id: 'interrupt-1', + args: { + interruptId: 'interrupt-1', + message: 'Enter a number:', + payload: {draft: 'hi'}, + responseSchema: {type: 'object'}, + }, + }, + }, + ], + }, + }); + + expect(output).toContain('[step1] is waiting for your input'); + expect(output).toContain('Enter a number:'); + expect(output).toContain('Payload: {"draft":"hi"}'); + expect(output).toContain('Expected response: {"type":"object"}'); + expect(output).toContain('Type your reply at the next prompt'); + }); + + it('renders a credential pause with its auth scheme', async () => { + const output = await runOneTurn({ + author: 'fetch_weather', + content: { + parts: [ + { + functionCall: { + name: 'adk_request_credential', + id: 'weather_api_key', + args: { + message: 'Please provide your API key.', + authConfig: { + authScheme: { + type: 'apiKey', + in: 'header', + name: 'X-Api-Key', + }, + }, + }, + }, + }, + ], + }, + }); + + expect(output).toContain('[fetch_weather] is waiting for a credential'); + expect(output).toContain('Please provide your API key.'); + expect(output).toContain('Auth scheme: apiKey (header X-Api-Key)'); + }); + + it('renders a tool-confirmation pause with the tool name and hint', async () => { + const output = await runOneTurn({ + author: 'generate_instruction', + content: { + parts: [ + { + functionCall: { + name: 'adk_request_confirmation', + id: 'confirm-1', + args: { + originalFunctionCall: {name: 'find_orders', args: {}}, + toolConfirmation: { + hint: 'This reads patient records.', + confirmed: false, + }, + }, + }, + }, + ], + }, + }); + + expect(output).toContain( + '[generate_instruction] is waiting for confirmation', + ); + expect(output).toContain('Tool: find_orders'); + expect(output).toContain('This reads patient records.'); + expect(output).toContain("Reply 'yes' to approve or 'no' to reject."); + }); + + it('does not announce a pause for an ordinary function call', async () => { + const output = await runOneTurn({ + author: 'agent', + content: { + parts: [ + {text: 'Looking that up.'}, + {functionCall: {name: 'get_weather', id: 'c1', args: {city: 'SF'}}}, + ], + }, + }); + + expect(output).toContain('[agent]: Looking that up.'); + expect(output).not.toContain('is waiting'); + }); + + it('reports an error carried on the event', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await runOneTurn({ + author: 'draft_email', + errorCode: 'SAFETY', + errorMessage: 'Blocked by safety filters.', + }); + + const errors = (console.error as Mock).mock.calls + .map((call) => call.join(' ')) + .join('\n'); + expect(errors).toContain( + '[draft_email] error: SAFETY: Blocked by safety filters.', + ); + }); + + it('warns when a scripted run ends still waiting on the user', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + (loadFileData as Mock).mockResolvedValue({state: {}, queries: ['start']}); + runnerState.events = [savedInterrupt('interrupt-1')]; + + await runAgent({ + agentPath: 'agent.ts', + inputFile: 'input.json', + sessionService: createMockSessionService(), + }); + + const errors = (console.error as Mock).mock.calls + .map((call) => call.join(' ')) + .join('\n'); + expect(errors).toContain( + 'The run ended while still waiting for user input.', + ); + }); + + it('does not announce a pause for an unnamed function call', async () => { + const output = await runOneTurn({ + author: 'agent', + content: {parts: [{functionCall: {args: {}}}]}, + }); + + expect(output).not.toContain('is waiting'); + }); + }); }); diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index 2285df895c..e90c20fc30 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -717,6 +717,70 @@ describe('AgentLoader', () => { await agentLoader.disposeAll(); }); + /** + * An agent whose module throws while constructing (a malformed workflow + * graph, a bad config) must not stop the other agents from loading — + * otherwise a single broken file takes the whole server down with it. + */ + describe('when one agent fails to construct', () => { + beforeEach(async () => { + await fs.mkdir(path.join(tempAgentsDir, 'broken'), {recursive: true}); + await fs.writeFile( + path.join(tempAgentsDir, 'broken', 'agent.js'), + `throw new Error('boom during construction');`, + ); + }); + + it('still lists the healthy agents', async () => { + const loader = new AgentLoader(tempAgentsDir); + + const agents = await loader.listAgents(); + + expect(agents).toEqual(['agent1', 'agent2', 'agent3']); + await loader.disposeAll(); + }); + + it('still loads a healthy agent', async () => { + const loader = new AgentLoader(tempAgentsDir); + + const agentFile = await loader.getAgentFile('agent1'); + const agent = await agentFile.load(); + + expect(agent.name).toEqual('agent1'); + await loader.disposeAll(); + }); + + it('reports the failure against the agent that caused it', async () => { + const loader = new AgentLoader(tempAgentsDir); + + const failures = await loader.listLoadFailures(); + + expect(failures).toHaveLength(1); + expect(failures[0].name).toBe('broken'); + expect(failures[0].filePath).toContain('broken'); + expect(failures[0].error.message).toContain('boom during construction'); + await loader.disposeAll(); + }); + + it('rethrows the original error when the broken agent is requested', async () => { + const loader = new AgentLoader(tempAgentsDir); + + await expect(loader.getAgentFile('broken')).rejects.toThrow( + /Agent 'broken' failed to load[\s\S]*boom during construction/, + ); + await loader.disposeAll(); + }); + + it('reports available agents when the name is simply unknown', async () => { + const loader = new AgentLoader(tempAgentsDir); + + await expect(loader.getAgentFile('nope')).rejects.toThrow( + /Agent 'nope' not found[\s\S]*Available agents: agent1, agent2, agent3/, + ); + await loader.disposeAll(); + }); + }); + it('disposes all agent files', async () => { const agentLoader = new AgentLoader(tempAgentsDir); await agentLoader.listAgents(); diff --git a/tests/integration/app_loader/app_loader_test.ts b/tests/integration/app_loader/app_loader_test.ts index 4367307b85..59794679c5 100644 --- a/tests/integration/app_loader/app_loader_test.ts +++ b/tests/integration/app_loader/app_loader_test.ts @@ -15,7 +15,7 @@ import {sendInput} from '../test_case_utils.js'; const execAsync = promisify(exec); const dirname = process.cwd(); -const TEST_EXECUTION_TIMEOUT = 40000; +const TEST_EXECUTION_TIMEOUT = 60000; describe('App loader CLI integration', () => { describe.each(['app_ts', 'app_js', 'app_default'])(