Skip to content

fix(workflow): say which node failed, and when a route goes nowhere - #650

Merged
kalenkevich merged 2 commits into
mainfrom
fix/workflow-error-reporting
Aug 12, 2026
Merged

fix(workflow): say which node failed, and when a route goes nowhere#650
kalenkevich merged 2 commits into
mainfrom
fix/workflow-error-reporting

Conversation

@kalenkevich

@kalenkevich kalenkevich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Link to Issue or Description of Change

2. Or, if no issue exists, describe the change:

Problem:

Two failure modes in a graph workflow leave the user with nothing to act on.

1. A schema failure does not say which node failed. A node whose input or
output misses its schema throws a bare ZodError. It names the offending
field, but not the node, and the stack is bundler internals:

ZodError: [ { "expected": "string", "path": [ "timezone" ], ... } ]
    at Mme (file:///.../T/adk_agent_loader-uFgc0o/agent.mjs:561:15097)
    at nR.validateOutput (file:///.../agent.mjs:561:38184)

In a graph of any size there is no way to tell which node produced the bad
value. The node name is available as this.name right at the throw site.

2. A route that matches no edge dead-ends in total silence. A node emitting
a route no outgoing edge is keyed to, with no DEFAULT_ROUTE, schedules
nothing — so the invocation can end having produced no events at all. No
output, no error, and nothing at --log_level debug either. A typo in a route
key is indistinguishable from a workflow that legitimately finished.

Solution:

Wrap validation failures in a NodeSchemaValidationError that names the node
and the side that failed, keeping the original error as cause:

NodeSchemaValidationError: Node 'lookup_time_function' output does not match
its outputSchema: [ { "expected": "string", "path": ["timezone"], ... } ]

For the unmatched route, log a warning naming the node, the route it emitted,
and the routes that do have edges:

WARN Node 'router' emitted route "TOTALLY_UNKNOWN_ROUTE", which matches no
outgoing edge, so this branch stops here. Edges from this node are keyed to:
"BUG", "OTHER".

Behaviour is deliberately unchanged here — this is a diagnostic only. I
first implemented it as a thrown error, which broke exactly one thing:
tests/integration/workflows/loop/agent.ts routes back to the generator on
'unrelated' and wires nothing for 'tech-related', so the unmatched route is
the loop's exit condition
. adk-python confirms the silent stop is intended
cross-language behaviour rather than an oversight:

# adk-python v2.0.0a2  src/google/adk/workflow/_trigger_processor.py:89
def _get_next_pending_nodes(node_name, routes_to_match, graph) -> list[str]:
  ...
  return next_pending_nodes      # unmatched => [], silently, no log

So control flow stays byte-for-byte what it was; only the missing diagnostic is
added. A bare boolean false is exempt — an edge keyed to true with nothing
for false is the conditional-continue idiom, where stopping is the intent.

Known trade-off: a graph relying on the loop-exit idiom now emits this
warning on each successful exit. A deliberate exit and a typo'd route key are
indistinguishable at that point, so this is the cost of having any diagnostic at
all. Wiring the terminating value to an explicit node or to DEFAULT_ROUTE
silences it. Happy to drop the warning to debug if reviewers would rather have
silence by default.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Seven new tests: two in schema_validation_test.ts asserting the node name,
the failing side and the preserved cause; five in graph_test.ts covering the
warning, the array case, and the three cases that must stay quiet
(DEFAULT_ROUTE present, no conditional edges, no route emitted). The existing
boolean test now also asserts no warning fires.

npx vitest run --project unit:core
  Test Files  205 passed (205)
       Tests  2832 passed (2832)

npx vitest run --project integration
  Tests  24 failed | 148 passed | 15 skipped (187)

Those 24 integration failures are pre-existing on main at 5742875
identical count and set with these changes stashed (a2a, build_setup,
app_loader, webui, skills). Zero new failures.

Manual End-to-End (E2E) Tests:

Against the repros that turned these up, using the workflow samples:

$ echo hi | npm run sample -- <sample with a deliberately-wrong outputSchema>
[city_generator_agent]: Paris
t [NodeSchemaValidationError]: Node 'lookup_time_function' output does not
match its outputSchema: [ ... ]

$ echo hello | npm run sample -- <sample emitting an unmatched route>
WARN Node 'router' emitted route "TOTALLY_UNKNOWN_ROUTE", which matches no
outgoing edge, so this branch stops here. Edges from this node are keyed to:
"BUG", "OTHER".

And a loop workflow whose routes are all mapped still runs clean, with no
spurious warning:

$ echo "hello world" | npm run sample -- samples/workflows/routes/loop_escalation/agent.ts
[seed_draft]: {"topic":"hello world","bullets":["hello world — point 1"]}
[refine]: ... point 2
[refine]: ... point 3
[finalize]: Approved after 3 bullets:

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Both findings came out of a bug bash against the graph-workflow pages on
adk.dev, working through the samples in #634. They are runtime fixes and
independent of that PR, hence a separate branch off main

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

The red zizmor-output here is not from this PR — it is an unpinned-uses finding in .github/workflows/validation.yaml on main, and this branch touches no workflow file. zizmor scans every workflow, so the gate fails on any PR that does not itself fix the base branch.

#652 pins those three actions and its zizmor-output is green, which confirms the cause. Once #652 lands, a re-run here should go green with no change to this PR.

The test matrix on this PR is passing on all three OSes.

@AmaadMartin AmaadMartin left a comment

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.

The core is sound: cause is preserved (errors.ts:155), getNextPendingNodes control flow is unchanged, and no code reads ZodError.issues, so the wrap breaks no caller. The message construction is not repeated across the six files, so a shared helper is not warranted. One point needs a decision: the warn level fires on the loop-exit idiom that tests/integration/workflows/loop/agent.ts uses. zizmor-output is red on other open PRs too, so it is not from this change.

Comment thread core/src/workflow/graph.ts Outdated
Comment on lines +232 to +236
logger.warn(
`Node '${nodeName}' emitted route ${shown}, which matches no ` +
`outgoing edge, so this branch stops here. Edges from this node ` +
`are keyed to: ${known}.`,
);

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.

Not a nit. Make this logger.debug.

The loop-exit idiom emits an unmatched route on each successful exit. tests/integration/workflows/loop/agent.ts:77 wires only {unrelated: generateHeadline}, so a correct run warns. warn prints at the default level (core/src/utils/logger.ts sets INFO), and this is the only log call in core/src/workflow/. Your own repro ran at --log_level debug and saw nothing, so debug answers the reported problem without noise on a working graph.

Comment thread core/src/workflow/graph.ts Outdated
Comment on lines +215 to +216
const isConditionalContinue =
emitted.length > 0 && emitted.every((r) => r === false);

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. The false exemption is too broad, and it skips the string normalization this function uses everywhere else.

const isConditionalContinue =
  emitted.length > 0 && emitted.every((r) => r === false);

It also silences a node whose only edge is keyed to 'x', where false is a real mistake. Line 189 matches with String(r), so an emitted 'false' warns but false does not.

const isConditionalContinue =
  availableRoutes.includes('true') &&
  emitted.length > 0 &&
  emitted.every((r) => String(r) === 'false');

The boolean test at graph_test.ts:122 still passes: its edge is keyed to true.

expect(err.message).toContain("Node 'squared'");
expect(err.message).toContain('inputSchema');
// The original validation error is preserved for field-level detail.
expect(err.cause).toBeDefined();

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. toBeDefined() does not test what the wrapper promises.

expect(err.cause).toBeDefined();

It passes for any non-undefined value. The output test at line 59 checks no cause at all, and neither test checks that the message keeps the field detail. Assert both, in both tests:

expect(err.cause).toBeInstanceOf(Error);
expect(err.message).toContain((err.cause as Error).message);

@ScottMansfield

Copy link
Copy Markdown
Member

can we instead distinguish between an empty list and one that has a node that does not exist?

@kalenkevich
kalenkevich force-pushed the fix/workflow-error-reporting branch 2 times, most recently from 3db2a5a to 2a5f85d Compare August 12, 2026 06:38
@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main (8611219) and applied all three points.

logger.debug instead of warn — done, and R6 of the bug bash had independently measured the cost you describe: on the loop-exit idiom the warning fires once per successful run.

One thing you should know, because it undercuts the stated rationale: --log_level debug was itself broken. LogLevel.DEBUG is 0, and getLogLevelFromOptions fell back with LOG_LEVEL_MAP[...] || LogLevel.INFO, so the flag resolved to INFO and did nothing. My repro saw nothing at debug because the flag was inert, not because the message was absent. #658 fixes that.

There is a second, deeper gap that #658 does not fix: adk run <file> bundles its own copy of @google/adk into the temp agent module, so core's logger singleton in there is a different instance from the one the CLI configures. Library logs emitted from inside a graph therefore still will not surface via the flag. Debug is the right level and I have kept it, but it is worth knowing the message is not yet reachable through the CLI path. Happy to file that separately.

false exemption — took your version, with availableRoutes.includes('true') and String(r) === 'false'. Added two tests: a bare false against edges keyed to 'x' now logs, and the string 'false' is exempted exactly like the boolean.

toBeDefined() — replaced with toBeInstanceOf(Error) plus expect(err.message).toContain((err.cause as Error).message), in both the input and the output test.

Core suite: 2853 passing, tsc --noEmit clean.

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Good question — I want to fix the right thing here, and I think you're asking for a design change rather than a bug fix, because "a node that does not exist" isn't reachable today. Two versions of it, and they differ a lot in scope.

Where the split already is. The guard in getNextPendingNodes is nextPending.length === 0 && availableRoutes.length > 0 (core/src/workflow/graph.ts:204), so these two are separated already:

  • node has no conditional outgoing edges → terminal, stays silent
  • node has conditional edges and the emitted route matched none → the new debug log

Where it is not. Inside that second bucket, a deliberate loop exit and a typo'd route key are identical states. tests/integration/workflows/loop/agent.ts:77 wires {unrelated: generateHeadline} and lets 'tech-related' fall through — the unmatched route is the exit condition. A misspelled key produces exactly the same thing. That is the only reason the log is at debug and not warn.

On "a node that does not exist". A routing-map target can't currently be missing from the graph. expandRoutingMap rejects any non-NodeLike value (core/src/workflow/utils/graph_parser.ts:70), and Graph derives nodes from the edges themselves (graph.ts:123), so every destination is in the graph by construction — getStaticNode's Node ... not found in graph (workflow.ts:602) is unreachable from routing. So there is no existing "does not exist" case to distinguish from; it has to be introduced first.

Two ways to introduce it:

  1. Explicit terminal route. Let a routing map declare that a route deliberately ends — {'tech-related': [], unrelated: generateHeadline}, or a named END sentinel. A declared stop is silent, and a route with no entry at all becomes unambiguously a mistake, so the diagnostic can go back to warn or throw. This actually resolves the trade-off instead of managing it, at the cost of an authoring-API addition and rewiring the loop samples.

  2. Diagnostic only. Behaviour unchanged; just report which of the two states was reached, with distinct wording for "this node has no conditional edges" and "this node has edges keyed to X, Y but got Z".

I lean towards (1) — it is the only one that lets a correct graph stay quiet while a typo gets a loud error. Happy to do it here or as a follow-up, but I would rather confirm before rewiring the samples. Which did you have in mind?

@AmaadMartin AmaadMartin left a comment

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.

Holding: the PR does not merge cleanly (mergeable_state: dirty). Commits dd1c08b and e0b7281 duplicate PRs #651 and #658, both already merged to main. My three earlier findings are resolved: the route log is now logger.debug, the false exemption requires a true edge, and both schema tests assert the cause. The isolation scope feature (commit b596ea3) is absent from the description but is wired and CI is green on all three runners. Rebase onto main to clear the conflict.

* These paths come from the command line, so an absolute one has to be
* honoured as given.
*/
export function getAbsolutePath(p: string): string {

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.

Not a nit. This duplicates merged PR #651.

getAbsolutePath already exists on main at dev/src/utils/file_utils.ts:19; PR #651 merged it on 2026-08-12. Commit dd1c08b re-adds it here, and commit e0b7281 re-adds the merged #658 ?? LogLevel.INFO fix at dev/src/cli/cli.ts:49. Both now conflict with main.

Rebase onto main. The rebase drops both CLI commits, and the diff returns to the workflow change plus the isolation scope.

Two failures in a graph workflow gave the user nothing to act on.

A node whose input or output misses its schema threw a bare ZodError. It
names the offending field but not the node, and the stack is bundler
internals, so in a graph of any size there is no way to tell which node
produced the bad value. Wrap it in NodeSchemaValidationError, which names
the node and the side that failed and keeps the original error as `cause`.

A node that emits a route no outgoing edge is keyed to schedules nothing,
so the invocation can end having produced no events at all — with no
diagnostic. Behaviour is deliberately unchanged (it matches adk-python's
_get_next_pending_nodes, and routing back on one value while letting the
other fall through is how the loop samples exit), but it now logs the
node, the emitted route and the routes that do have edges.

That log is at debug rather than warn: the loop-exit idiom is correct
code, and at warn every successful run of it would complain. A bare
`false` against a `true` edge is skipped entirely as the
conditional-continue idiom, compared as a string like every other route
match so `false` and 'false' behave alike.

Found while bug-bashing the graph-workflow docs samples.
@kalenkevich
kalenkevich force-pushed the fix/workflow-error-reporting branch from b596ea3 to 5cbef44 Compare August 12, 2026 15:43

@AmaadMartin AmaadMartin left a comment

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.

Re-review at 5cbef44. All four prior findings are fixed: warn is now logger.debug, the false exemption keys on a true edge, both wrapper tests assert cause, and the dev/ CLI duplication is gone after the rebase. CI is green on ubuntu, macOS, and Windows. One new blocker below holds approval.

// --- Errors ---
export {NodeTimeoutError} from './errors.js';
export {
NodeSchemaValidationError,

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.

Not a nit. Export the new error and its guard from core/src/common.ts.

common.ts:361 re-exports the workflow surface and says to keep it in sync with workflow/index.js. This PR adds NodeSchemaValidationError, isNodeSchemaValidationError, and isNodeTimeoutError here, but not there. The package has one entry point (core/package.json exports has only .), so a user of @google/adk cannot import them. The catchable, named error is the point of this change.

Add the three names to the common.ts export block.

@kalenkevich kalenkevich Aug 12, 2026

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.

Done in 6d3387a — all three names added to the common.ts block, keeping it sorted with the rest.

You were right that this was the gap that mattered: index.ts:41 re-exports ./common.js and that is the only route to the package's single . entry, so the error was readable in a log line but not catchable by name, which is the entire point of naming it. Verified against the built entry rather than the source:

$ node -e "import('./core/dist/esm/index.js')..."
message: Node 'n' output does not match its outputSchema: boom
guard: true | timeout guard: true

I took isNodeTimeoutError along with the two you named, as you listed. Worth flagging why it was missing in the first place: NodeTimeoutError was already exported here without its guard, so catching a node timeout from outside the package meant an instanceof check — precisely the cross-package case the name-based guards were written to survive. That one predates this PR; it just surfaced because the two arrive together.

index_web.ts:7 also re-exports common.js, so the web build picks them up with no second change.

The other four threads are already addressed on the current branch and show as outdated:

  • logger.debug (graph.ts:236) — debug, not warn. Your measurement was right, and there is a wrinkle in the rationale I noted above: --log_level debug was itself inert, so my repro saw nothing because the flag was broken, not because the message was absent. fix(cli): make --log_level debug actually set the debug level #658 fixes the flag.
  • false exemption (graph.ts:217) — your version verbatim: availableRoutes.includes('true') plus String(r) === 'false', so it matches by string like every other comparison in the function.
  • toBeDefined()toBeInstanceOf(Error) and expect(err.message).toContain((err.cause as Error).message), in both the input and the output test.
  • Rebase — done; the branch is on 2e58422 and the diff is back to the six workflow files. Both duplicated CLI commits are gone.

Core suite green: 210 files, 2919 tests. tsc --noEmit clean, prettier and eslint clean on the changed file.

ScottMansfield's question about distinguishing an empty list from a missing node is still open on my side — I put two options here and would rather settle that before rewiring the loop samples.

`NodeSchemaValidationError` was added to `workflow/index.ts` but not to
`common.ts`, which is the only path to the package's single entry point
(`core/package.json` `exports` has just `.`, and `index.ts` re-exports
`./common.js`). A user of `@google/adk` therefore could not import it, so the
error could be read in a log message but not caught by name -- which is the
whole point of naming it.

`isNodeTimeoutError` goes with them: `NodeTimeoutError` was already exported
here without its guard, so catching a node timeout meant an `instanceof` check
that breaks across package boundaries, the case the name-based guards exist for.

Keeps the block in sync with `workflow/index.js`, as the comment above it asks.

@AmaadMartin AmaadMartin left a comment

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.

Approve. I verified all five earlier findings against the source at 6d3387a:

  • graph.ts:236 now logs at debug.
  • graph.ts:217 gates on availableRoutes.includes('true') and String(r) === 'false'.
  • schema_validation_test.ts:56,75 assert toBeInstanceOf(Error) and the cause message, in both tests.
  • common.ts:371,385,387 export the three names; index.ts:82 matches.
  • The branch is rebased; the duplicate CLI commits are gone.

The new error and guard follow the name-based guard pattern in errors.ts. No any, subclass instanceof, or suppression was added. All three run-tests jobs pass on ubuntu, macOS, and windows. ScottMansfield's question about an empty route list versus a missing node stays open, but it does not block this diff.

@kalenkevich
kalenkevich merged commit a785f3a into main Aug 12, 2026
12 checks passed
@kalenkevich
kalenkevich deleted the fix/workflow-error-reporting branch August 12, 2026 17:33
@kalenkevich kalenkevich mentioned this pull request Aug 12, 2026

@AmaadMartin AmaadMartin left a comment

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.

All three findings are closed in the source at 6d3387a: the call is logger.debug (graph.ts:236), the exemption now requires a true edge and compares with String(r) (graph.ts:217-220), and both schema tests assert the cause and its message survive. The rebase changed parseWithSchema so a genai Schema is validated too; the wrapper still names the node and keeps cause, so it covers that path as well. The common.ts additions match the sort order and the sync note at common.ts:358. zizmor-output now skips because main pinned its actions, so my earlier note about it no longer applies.

}
return parseWithSchema(this.inputSchema, input);
try {
return parseWithSchema(this.inputSchema, input);

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, optional. The comment above this line is wrong, and it was already wrong before your change.

* enforced for Zod schemas; a genai `Schema` is left unvalidated (see
* `parseWithSchema`).

parseWithSchema validates a genai Schema at utils/schema.ts:106. Your new error fires for that path too, which this text denies. The same two lines sit above validateOutput.

prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…oogle#650)

* fix(workflow): say which node failed, and when a route goes nowhere

Two failures in a graph workflow gave the user nothing to act on.

A node whose input or output misses its schema threw a bare ZodError. It
names the offending field but not the node, and the stack is bundler
internals, so in a graph of any size there is no way to tell which node
produced the bad value. Wrap it in NodeSchemaValidationError, which names
the node and the side that failed and keeps the original error as `cause`.

A node that emits a route no outgoing edge is keyed to schedules nothing,
so the invocation can end having produced no events at all — with no
diagnostic. Behaviour is deliberately unchanged (it matches adk-python's
_get_next_pending_nodes, and routing back on one value while letting the
other fall through is how the loop samples exit), but it now logs the
node, the emitted route and the routes that do have edges.

That log is at debug rather than warn: the loop-exit idiom is correct
code, and at warn every successful run of it would complain. A bare
`false` against a `true` edge is skipped entirely as the
conditional-continue idiom, compared as a string like every other route
match so `false` and 'false' behave alike.

Found while bug-bashing the graph-workflow docs samples.

* fix(workflow): export the schema validation error from the package entry

`NodeSchemaValidationError` was added to `workflow/index.ts` but not to
`common.ts`, which is the only path to the package's single entry point
(`core/package.json` `exports` has just `.`, and `index.ts` re-exports
`./common.js`). A user of `@google/adk` therefore could not import it, so the
error could be read in a log message but not caught by name -- which is the
whole point of naming it.

`isNodeTimeoutError` goes with them: `NodeTimeoutError` was already exported
here without its guard, so catching a node timeout meant an `instanceof` check
that breaks across package boundaries, the case the name-based guards exist for.

Keeps the block in sync with `workflow/index.js`, as the comment above it asks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants