Skip to content

Feat: Workflow graph, chain syntax and validation (Part 2/3) - #321

Closed
AmaadMartin wants to merge 2 commits into
feat/workflow-graph-core-part1from
feat/workflow-graph-core-part2
Closed

Feat: Workflow graph, chain syntax and validation (Part 2/3)#321
AmaadMartin wants to merge 2 commits into
feat/workflow-graph-core-part1from
feat/workflow-graph-core-part2

Conversation

@AmaadMartin

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Related: Support for Workflows google/adk-js#366
  2. Or, if no issue exists, describe the change:
    Problem: With the node model from Part 1 in place, there is still no way to describe how nodes connect: no edges, no conditional routing, and no validation to catch a graph that cannot run (a missing entry point, an unreachable node, a cycle with no way out).

Solution: Add Graph, Edge and the chain syntax, plus the structural validators, ported from adk-python's workflow/_graph.py, utils/_graph_parser.py and utils/_graph_validation.py.

  • Graph derives its nodes from its edges — deduplicated by object identity, in first-seen order — and computes its terminal nodes (those with no outgoing edge).
  • getNextPendingNodes(name, routes) selects the edges to follow: unrouted edges always fire; a routed edge fires when the emitted route matches any of its declared routes; the DEFAULT_ROUTE edge fires only when nothing else matched; and if a node has routed edges but nothing matched at all, it logs a warning and ends that branch.
  • Chain syntax: [START, a, [b, c], d] expands to unrouted edges from every node of one step to every node of the next, so fan-out and fan-in are one array. A bare function in a chain is wrapped once and memoised by identity, so referencing it twice yields one graph node.
  • validate() rejects, in order: duplicate node names, a missing START, routed edges out of START, nodes unreachable from START, incoming edges into START, duplicate edges, DEFAULT_ROUTE misuse (in a list, or twice from one node), and cycles made only of unrouted edges. Every message starts with Graph validation failed. so callers can match on it. An empty graph is trivially valid, which is what makes an empty workflow run and produce nothing.

Stacked PR — this is Part 2/3. It targets feat/workflow-graph-core-part1 (#320), not main. Part 3 adds runNode and the executable Workflow.

Collision checkgh pr list --repo AmaadMartin/adk-js --state open --limit 200 was scanned for workflow|graph|orchestrat. The four hits are unrelated (#306 and #296 are CI npm caching, #184 is the eval service, #153 is context-cache orchestration). No open PR touches core/src/workflow/.

Deliberate deviations from adk-python

  • getNextPendingNodes returns BaseNode[], not names. The caller always needs the node itself; returning names forced a reverse lookup whose "node not found" arm was unreachable and therefore untestable. This is internal to the process, so local convention wins over wire-shape parity.
  • RoutingMap chain sugar ({route: node} inside a chain) is not ported. A JS object literal coerces numeric and boolean keys to strings, which would silently break strict-equality route matching. Routed edges are written as explicit Edge objects, which covers every routing behaviour the reference tests exercise. Queued as a follow-up.
  • An explicit Edge takes nodes, not NodeLike. Matching adk-python, where pydantic validates from_node/to_node as BaseNode. Wrap a function with node(fn) first. Chains still accept bare functions.
  • _validate_static_schemas and _validate_chat_agent_wiring are not ported — schema validation and LlmAgent-as-node are out of scope for this port.
  • Terminal nodes are computed in the constructor, not as a side effect of validate() as in adk-python, so validate() is a pure check and terminalNodeNames is always populated.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

npx vitest run --project unit:core core/test/workflow — 7 files, 66 tests, all passing (33 new here, on top of Part 1's 33).

New suites: graph_test.ts (24 cases: node derivation and ordering, terminal nodes, all seven route-selection behaviours including the unmatched-route warning captured through setLogger, and each of the eight validation failures asserted on its message) and graph_parser_test.ts (9 cases: chain expansion, fan-out, fan-in, the 'START' literal, function memoisation by identity, mixing explicit edges with chains, and edge copying).

Coverage. Measured with
npx vitest run --project unit:core --coverage --coverage.include='core/src/workflow/**' core/test/workflow:
graph.ts, graph_parser.ts, graph_validation.ts and route.ts are at 100% statements / branches / functions / lines, as is everything from Part 1. retry_config.ts still reports 0% at this point in the stack because it contains only type declarations, which v8 erases before it can count them; it reaches 100% in Part 3.

Proof the new tests can fail. Each was run against mutated source and confirmed to fail:

Mutation Failing test Message
graph.ts: drop the !matchedSpecificRoute guard so DEFAULT_ROUTE always fires Graph.getNextPendingNodes > skips the DEFAULT_ROUTE edge when a specific route matched expected [ 'b', 'c' ] to deeply equal [ 'b' ]
graph_validation.ts: skip detectUnconditionalCycles Graph.validate > rejects a cycle made only of unrouted edges expected [Function] to throw an error
graph_validation.ts: make the cycle walk's path.indexOf always return -1 Graph.validate > rejects a cycle made only of unrouted edges expected [Function] to throw error including 'Graph validation failed. Unconditiona…' but got 'Maximum call stack size exceeded'

One mutation deliberately survived and led to a code change rather than a new test: excluding START from the terminal-node computation turned out to be unreachable, because a graph that passes validation always has at least one edge out of START. The guard was removed instead of pinning dead code with a test.

No suppressions were added: no any, as any, as never, @ts-expect-error, @ts-ignore, eslint-disable or coverage ignores, in src/ or in the tests. graph_parser_test.ts imports everything relatively, with a comment explaining why: parseEdgeItems is internal, and mixing a relative import with @google/adk would load a second copy of the node classes that TypeScript treats as unrelated.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

No network, credentials or model access are needed.

  1. npm install
  2. npm run build
  3. npx vitest run --project unit:core core/test/workflow
  4. npm run lint && npm run format:check
  5. npm run docs:check — confirms every new public type is exported and documented.

Graph construction can be inspected directly in a REPL:

import {Graph, JoinNode, START} from '@google/adk';

const a = new JoinNode({name: 'a'});
const b = new JoinNode({name: 'b'});
const g = Graph.fromEdgeItems([[START, a, b]]);
g.validate();
console.log(g.nodes.map((n) => n.name));            // [ '__START__', 'a', 'b' ]
console.log([...g.terminalNodeNames]);               // [ 'b' ]
console.log(g.getNextPendingNodes('a').map((n) => n.name)); // [ 'b' ]

The graph is exercised end to end by the executable Workflow in Part 3 of this stack.

CI status: absent. This PR targets feat/workflow-graph-core-part1 rather than main, and .github/workflows/validation.yaml triggers on pull_request: branches: [main], so the test job never fires for a stacked base. Validated locally instead, on the exact pushed commit: npm run build, npm run lint, npm run format:check, npm run docs:check and npx vitest run --project unit:core core/test/workflow all pass.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 2 commits July 30, 2026 11:08
Introduces core/src/workflow/ with the node contract a graph-based
workflow is built from: BaseNode and the START sentinel, FunctionNode
for wrapping a plain function, JoinNode for fan-in, NodeContext (the
per-run context nodes report output and route on), and the node()
factory.

Ported from adk-python's src/google/adk/workflow/. Node output and
route travel on the context rather than on Event, because adk-js's
Event carries neither field.

Part 1 of 3; the graph and the executable Workflow follow.
Adds Graph, Edge and the chain syntax that expands [START, a, [b, c]]
into edges, conditional route selection with a DEFAULT_ROUTE fallback,
and the structural validators: duplicate names and edges, a missing or
re-entered START, routed START edges, unreachable nodes, DEFAULT_ROUTE
misuse, and cycles made only of unrouted edges.

Ported from adk-python's workflow/_graph.py, utils/_graph_parser.py and
utils/_graph_validation.py.

Part 2 of 3; the executable Workflow follows.
@AmaadMartin
AmaadMartin force-pushed the feat/workflow-graph-core-part2 branch from 9a2653b to 132829a Compare July 30, 2026 18:08
@AmaadMartin

Copy link
Copy Markdown
Owner Author

Closing: a teammate is actively working on workflow orchestration, and this stack would duplicate that work.

Closed by the Foundry supervisor at the owner's request — not a quality judgement. The branch feat/workflow-graph-core-part2 is left intact, so this can be reopened if the teammate's work turns out not to overlap.

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.

1 participant