Skip to content

feat(workflow): event model extensions and shared node primitives (Part 1) - #587

Open
kalenkevich wants to merge 5 commits into
mainfrom
feat/workflows_part1
Open

feat(workflow): event model extensions and shared node primitives (Part 1)#587
kalenkevich wants to merge 5 commits into
mainfrom
feat/workflows_part1

Conversation

@kalenkevich

@kalenkevich kalenkevich commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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):

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

Problem:
The feature/workflows branch is a single, very large change (41 commits, ~15k lines across ~155 files) that is impractical to review or merge as one unit. It also carries an initial workflow implementation that a later rewrite fully replaced, so a straight commit-by-commit review would surface dead code.

Solution:
Split the work into small, stacked, dependency-ordered PRs recomposed from the final tree state (not a replay of the 41 commits), so each PR is coherent and reviewable and reviewers never see the superseded implementation.

This is Part 1 of 9. It adds only the leaf primitives the engine builds on — none depend on the engine core — plus the additive event-model changes the engine needs. There is no orchestration, node, or runner wiring yet, so there is nothing user-facing to exercise beyond the new types.

Included:

  • Workflow leaf primitives (core/src/workflow/): errors.ts (NodeInterruptedError, NodeTimeoutError, DynamicNodeFailError), node_status.ts (NodeStatus), node_state.ts (NodeState, createNodeState, isNodeState), retry_config.ts (RetryConfig, ErrorClass, normalizeRetryExceptions), utils/retry_utils.ts (shouldRetryNode, getRetryDelaySeconds), trigger.ts (Trigger), branch_path.ts (BranchPath), utils/event_channel.ts (EventChannel).
  • Event model (additive, mirrors adk-python): events/event.ts adds Event.{output, route, nodeInfo, isolationScope}, the NodeInfo type, CreateEventParams, an isEvent guard, and PRESERVE_KEYS entries so node output and checkpointed agentState survive snake/camel round-trips; events/event_actions.ts adds optional fields (output, agentState, endOfAgent, route, …); common.ts exports the new event types.

Note for reviewers: event.ts changes are purely additive vs main. The branch predates main's relocation of the client-function-call-id helpers into event.ts; the file was 3-way merged so those helpers are preserved and only the workflow fields are added (the 2 removed lines are the intended createEvent signature change, not a reversion).

Intentionally deferred to later parts: engine core (Part 2), node types (Part 3), parallelism (Part 4), dynamic scheduling (Part 5), workflow orchestrator + runner + public barrel export (Part 6), LLM-as-node + task mode (Part 7), HITL (Part 8), samples (Part 9). The finish_task_tool export in common.ts and the workflow/index.js export in core/src/index.ts are deferred to the PRs that introduce those files.

Testing Plan

Unit Tests:

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

Bundled tests: workflow/foundations_test.ts, workflow/event_channel_test.ts, workflow/event_model_test.ts, events/event_test.ts.

$ npx vitest run --project unit:core \
    core/test/events/event_test.ts \
    core/test/workflow/event_model_test.ts \
    core/test/workflow/event_channel_test.ts \
    core/test/workflow/foundations_test.ts

 ✓ core/test/workflow/event_model_test.ts   (2 tests)
 ✓ core/test/workflow/event_channel_test.ts (6 tests)
 ✓ core/test/workflow/foundations_test.ts   (17 tests)
 ✓ core/test/events/event_test.ts           (38 tests)

 Test Files  4 passed (4)
      Tests  63 passed (63)

Typecheck is clean: npx tsc --noEmit -p core/tsconfig.json.

Manual End-to-End (E2E) Tests:

N/A — this part adds only leaf primitives and additive types with no user-facing behavior. End-to-end coverage arrives with the runner integration in Part 6.

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

@kalenkevich kalenkevich linked an issue Jul 30, 2026 that may be closed by this pull request
@kalenkevich kalenkevich self-assigned this Jul 30, 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.

Read the whole thing against the head tree. The stacking strategy is the right call — recomposing from the final tree instead of replaying 41 commits is what makes this reviewable, and the note about the 3-way merge on event.ts saved me from misreading the 2 removed lines as a reversion. Tests are real (the retry tests inject the RNG rather than asserting on randomness), and CI is green on all three OS legs.

One real bug, then a few type-discipline points that I'm raising because they're the same standard this repo holds contributors to.

There's also a behaviour change in here that the description undersells — see the note on event.ts:135. Reviewers of Parts 2-9 will want to know it landed in Part 1.

Comment thread core/src/events/event_actions.ts Outdated
Comment thread core/src/events/event_actions.ts Outdated
Comment thread core/src/events/event_actions.ts Outdated
Comment thread core/src/events/event.ts
Comment thread core/src/events/event.ts
constructor(message = 'Node interrupted (awaiting resume input).') {
super(message);
this.name = 'NodeInterruptedError';
// Restore prototype chain for `instanceof` across transpilation targets.

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 — instanceof as the dispatch mechanism, in the one place it's known to break.

    // Restore prototype chain for `instanceof` across transpilation targets.
    Object.setPrototypeOf(this, NodeInterruptedError.prototype);

The setPrototypeOf calls are correct for what they do. The question is the strategy: this is the objection you raised on #364"Problem with instanceof is that when user will have multiple adk-js packages in their runtime it will not able to mix objects from one to another" — which is why isGeminiModel-style guards exist instead.

These three errors are caught across the node/engine boundary (NodeInterruptedError by the parent's NodeRunner, DynamicNodeFailError likewise), which is exactly where two copies of the package would meet: the throw site resolves one class, the catch site another, and the instanceof silently returns false — the node hangs instead of resuming.

Exporting guards alongside the classes would keep the catch sites copy-safe:

export function isNodeInterruptedError(e: unknown): e is NodeInterruptedError {
  return e instanceof Error && e.name === 'NodeInterruptedError';
}

Fine to defer to Part 2 where the catch sites actually land, but better decided before three more parts are written against instanceof.

Comment thread core/src/workflow/branch_path.ts Outdated
Comment thread core/src/workflow/utils/event_channel.ts Outdated
Comment thread core/src/workflow/utils/retry_utils.ts Outdated
Comment thread core/src/workflow/utils/retry_utils.ts Outdated

@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.

Follow-up: one finding I missed on the first pass. I checked each new primitive against what already exists in core/src and EventChannel has a near-twin — details inline on event_channel.ts.

I also chased three other hypotheses and they came back clean, so recording them here rather than filing noise: the RetryConfig.exceptions string-or-class union and the exact-name (non-subclass) matching in shouldRetryNode both faithfully mirror _retry_config.py / _retry_utils.py — Python does exactly the same via field_validator and type(exception).__name__, so no parity divergence; the new Trigger collides with nothing on main (only ContextCompactionTrigger exists, unrelated); and a diff-hygiene scan is clean — no CHANGELOG/lockfile churn, no stray artifacts, zero console.log, zero .only/.skip, zero type suppressions, and event_test.ts is +45/-0 so no existing test was rewritten.

Comment thread core/src/workflow/utils/event_channel.ts Outdated
kalenkevich added a commit that referenced this pull request Jul 30, 2026
Resolves review comments on #587:

- events: drop 6 unused EventActions fields (output, joinCompleted,
  toolExecution, requestInput, nodeExecutionReplay, route) that were dead
  across the whole branch and left arbitrary `unknown` payloads unpreserved;
  removing them also eliminates the round-trip hole and the ambiguous second
  `route` carrier. Add shared RouteKey/Route types for Event.route.
- errors: add name-based guards (isNodeInterruptedError, isNodeTimeoutError,
  isDynamicNodeFailError) so catch sites stay correct across duplicate-package
  boundaries instead of relying on `instanceof`.
- branch_path: convert createSubBranch/commonPrefixOf from static methods to
  standalone util functions (kept fromString/commonPrefix as factories).
- async_queue: fold EventChannel into the existing AsyncQueue instead of
  shipping a duplicate queue — port drain-before-error, sticky failure, and
  error()-closes; add isClosed/size getters and a fail() method (error()
  retained as an alias). Delete event_channel.ts and move its tests. This also
  fixes the close()-then-fail() error-swallow bug.
- retry: introduce prepareRetryConfig/PreparedRetryConfig so a node's exception
  filter is normalized+validated once at construction, not re-normalized (and
  potentially thrown) on every retry check; make shouldRetryNode/
  getRetryDelaySeconds take a required prepared config and drop the unreachable
  no-config branches (and the misleading test that pinned one).

Full core suite green (2338 tests).
@kalenkevich kalenkevich changed the title feat(workflow): event model extensions and shared node primitives (Part 1/9) feat(workflow): event model extensions and shared node primitives (Part 1) Jul 30, 2026
@kalenkevich
kalenkevich force-pushed the feat/workflows_part1 branch from 92b4c12 to 953242b Compare July 30, 2026 23:54
First slice of the workflow engine split (Part 1/9). Adds the leaf
primitives the engine builds on, none of which depend on the engine core:

- workflow/errors, node_status, node_state, retry_config,
  utils/retry_utils, trigger, branch_path, and utils/event_channel
- Event model extensions mirroring adk-python: Event.{output, route,
  nodeInfo, isolationScope}, the NodeInfo type, CreateEventParams, an
  isEvent guard, and additive optional EventActions fields; PRESERVE_KEYS
  entries so node output and checkpointed state survive snake/camel
  round-trips
- export the new event types from common.ts

Bundled tests: foundations, event_channel, event_model, and event.
Recomposed from the final feature/workflows tree onto current main
(3-way merged event.ts to preserve main's relocated function-call-id
helpers). Stacked PRs follow.
Address PR1 review feedback: identify Event objects via a
Symbol.for('google.adk.event') brand — set by createEvent and checked by
isEvent — matching the signature-symbol guards used across ADK (isBaseTool,
isBaseAgent, ...) instead of structural duck-typing.

The brand is declared optional on the Event interface and is a
non-serializable runtime marker, so events reconstructed from storage/session
payloads are intentionally unbranded (documented on the interface and covered
by a round-trip test). Adds isEvent unit tests (branded event, impostor
rejection, non-object rejection, round-trip brand drop).
Resolves review comments on #587:

- events: drop 6 unused EventActions fields (output, joinCompleted,
  toolExecution, requestInput, nodeExecutionReplay, route) that were dead
  across the whole branch and left arbitrary `unknown` payloads unpreserved;
  removing them also eliminates the round-trip hole and the ambiguous second
  `route` carrier. Add shared RouteKey/Route types for Event.route.
- errors: add name-based guards (isNodeInterruptedError, isNodeTimeoutError,
  isDynamicNodeFailError) so catch sites stay correct across duplicate-package
  boundaries instead of relying on `instanceof`.
- branch_path: convert createSubBranch/commonPrefixOf from static methods to
  standalone util functions (kept fromString/commonPrefix as factories).
- async_queue: fold EventChannel into the existing AsyncQueue instead of
  shipping a duplicate queue — port drain-before-error, sticky failure, and
  error()-closes; add isClosed/size getters and a fail() method (error()
  retained as an alias). Delete event_channel.ts and move its tests. This also
  fixes the close()-then-fail() error-swallow bug.
- retry: introduce prepareRetryConfig/PreparedRetryConfig so a node's exception
  filter is normalized+validated once at construction, not re-normalized (and
  potentially thrown) on every retry check; make shouldRetryNode/
  getRetryDelaySeconds take a required prepared config and drop the unreachable
  no-config branches (and the misleading test that pinned one).

Full core suite green (2338 tests).
typedoc `docs:check` runs with --treatWarningsAsErrors and flagged the
[EVENT_SIGNATURE_SYMBOL] doc comment (inherited by Event, CreateEventParams and
CompactedEvent) for linking to `isEvent`, which is not part of the exported/
documented API. Reference it as inline code instead of a doc link. No API or
behavior change.
@kalenkevich
kalenkevich force-pushed the feat/workflows_part1 branch from e8abbf4 to 9aee489 Compare August 3, 2026 18:27
@kalenkevich
kalenkevich requested a review from AmaadMartin August 3, 2026 20:16
Aligns the Part 1 shared primitives with the workflow conventions applied
across the stack.

- branch_path: convert the BranchPath.fromString / BranchPath.commonPrefix
  static methods to plain functions (branchPathFromString /
  commonPrefixOfPaths); keep the instance methods. Internal callers
  (createSubBranch, commonPrefixOf) updated.
- retry_utils: shouldRetryNode and getRetryDelaySeconds now take a single
  params object; errorName is duck-typed (no `instanceof Error`) so it
  works for error-like values across package copies.
- Add branch_path unit coverage (previously untested) and update the
  retry_utils call sites in foundations_test to the params-object form.

Error type guards keep the name-based `instanceof Error` form by design
(the one sanctioned use of instanceof).

@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-checked at 3e927fd. All ten items from the last round are addressed, and the EventChannel one landed the right way round: the file is gone and AsyncQueue picked up drain-before-error, sticky failure, fail-after-close and the six tests. I verified the PRESERVE_KEYS hole is actually closed (the five untyped actions.* fields were deleted rather than papered over), route now has a single carrier with a shared Route/RouteKey type, and prepareRetryConfig moved normalization to construction time so the retry path can't throw over the node's real error. Two small things below.

One housekeeping item: the description is now stale — it still lists utils/event_channel.ts (EventChannel) as included, claims event_actions.ts adds output/route, and the test plan reports an event_channel_test.ts run. None of those exist at head.

Comment thread core/src/events/event.ts
* @param obj The object to check.
* @returns True if the object matches the Event structure.
*/
export function isEvent(obj: unknown): obj is Event {

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 brand answers "was this object made by createEvent in this process", which is narrower than "is this an Event".

    EVENT_SIGNATURE_SYMBOL in obj &&

Three paths in the repo hand back Event-typed objects with no brand: transformToCamelCaseEvent below (toCamelCase(...) as Event), sessions/vertex_ai_session_service.ts:455 (JSON.parse(JSON.stringify(rawEvent)) as Event), and the plugin callback return cast in plugins/plugin_manager.ts:180. Your own test pins the first one — "drops the (non-serializable) brand across a snake/camel round-trip".

Nothing is broken today: Part 2's only caller, BaseNode.toEvent, only ever sees events it just created. But it fails in the unhelpful direction — a node that yields an event it loaded from a session takes the else path and gets re-wrapped as the output of a new event instead of being emitted as-is, and that's silent.

Cheapest fix is to brand at the rehydration boundary, since that is where events legitimately re-enter the process:

export function transformToCamelCaseEvent(event: Record<string, unknown>): Event {
  const restored = toCamelCase(event, PRESERVE_KEYS_SNAKE_CASE) as Event;
  return {...restored, [EVENT_SIGNATURE_SYMBOL]: true};
}

That inverts the expectation in your round-trip test (isEvent(rehydrated) becomes true), which I think is the behaviour you want. I didn't check every deep-equality assertion on rehydrated events for symbol-key sensitivity, so worth a full run rather than just that file.

}

/** @deprecated Alias for {@link fail}; kept for existing callers. */
error(err: unknown) {

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 alias has exactly one caller, so the deprecation could just be the fix.

  /** @deprecated Alias for {@link fail}; kept for existing callers. */
  error(err: unknown) {

AsyncQueue isn't exported from common.ts, and the only .error( call site in the repo is models/google_llm.ts:326 in the live onerror callback. Flipping that one line to fail(error) lets you delete the alias instead of shipping a deprecated method on day one.

Worth noting either way that this call site is the reason the AsyncQueue change is a real fix and not just a refactor: onerror followed by onclose used to clear the error after the first rejection and drop buffered messages ahead of it. Both are now correct.

@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-reviewed at 3e927fd. Independently verified the five load-bearing items from my earlier rounds rather than taking the prior pass on trust: utils/event_channel.ts is gone and AsyncQueue absorbed the drain-before-error / sticky-failure / fail-after-close fixes plus the getters; the five untyped actions.* fields were deleted rather than added to PRESERVE_KEYS, which closes the round-trip hole at the source; EventActions.route is removed so routing has a single carrier behind a shared Route/RouteKey type; and isEvent is now brand-based rather than the typeof actions === 'object' check that accepted null.

The AsyncQueue outcome is the one I want to call out: I asked for the duplicate to be resolved and it landed the right way round — the better semantics were ported into the shared utility and the copy deleted, so google_llm.ts picks up the ordering fix for free.

CI is green across ubuntu/macOS/Windows (verified the individual check list, not the rollup). The two open nits from the last pass are non-blocking. LGTM.

One housekeeping item, non-blocking: the PR description still lists utils/event_channel.ts in the included-files section, which no longer exists — worth a quick edit so the merge commit message is accurate.

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.

Support for Workflows

3 participants