-
Notifications
You must be signed in to change notification settings - Fork 216
fix(cli): keep one broken agent from killing the dev server; surface HITL prompts and errors in adk run
#633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
658944e
fix(cli): isolate agent load failures so one bad agent can't down the…
kalenkevich d5a7383
fix(cli): show human-in-the-loop prompts in `adk run`
kalenkevich 4e78e11
feat(core): add helpers to inspect pending requests for user input
kalenkevich 905696c
fix(cli): report event errors in `adk run`
kalenkevich 5b550d8
docs: trim redundant comments
kalenkevich 6cc1a5a
fix(core): read both credential arg encodings for user input requests
kalenkevich e72da44
fix(cli): don't re-ask pauses a saved session already answered
kalenkevich aed31d5
chore: increase test timeout to reduce flakiness
kalenkevich 5113073
revert(cli): drop the /list-app-errors endpoint
kalenkevich File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
getUserInputRequestsis used bycli_run.ts.requiresUserInputandgetPendingUserInputRequestsare 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, andadk_api_server.tsis not in this diff.My other comment gives
getPendingUserInputRequestsa 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.There was a problem hiding this comment.
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—runFromInputFileuses it to warn when a scripted--input_filerun 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 atGET /list-app-errors(name, file path, reason). That is the gap you are pointing at:/list-appsomits a broken agent, so it disappears from the dev UI and the only trace is a server log line./list-appskeeps returningstring[], so the UI client is untouched. Three server tests cover the route.There was a problem hiding this comment.
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-errorsendpoint 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 isdev-package API rather than a@google/adkexport, 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:getAgentFilerethrows 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:
getPendingUserInputRequestsin the saved-session replay,requiresUserInputin the--input_filewarning.