Skip to content

feat(workflow): trace workflow and node execution with OpenTelemetry - #653

Merged
kalenkevich merged 2 commits into
mainfrom
feat/workflow-node-otel-spans
Aug 12, 2026
Merged

feat(workflow): trace workflow and node execution with OpenTelemetry#653
kalenkevich merged 2 commits into
mainfrom
feat/workflow-node-otel-spans

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:

core/src/workflow/** emitted no OpenTelemetry spans at all. A graph run was invisible between the enclosing invoke_agent span and whatever model/tool spans its nodes happened to open — no way to see which node was slow, which failed, or how a retry storm unfolded. Everything else in ADK (agents, models, tools) is traced.

Solution:

Three spans:

Span Scope
invoke_workflow <name> the orchestration loop in Workflow.runImpl
execute_node <name> one per executeChildNode, covering the whole retry loop
execute_node_attempt <name> one per attempt, only when the node declares a retry config

Dynamic ctx.runNode() children route through the same function so they are covered too, and a nested workflow reads execute_node wf → invoke_workflow wf → …. Attempt number is an attribute, not part of the span name, to keep names low-cardinality.

Attributes are namespaced adk.workflow.* / adk.node.* (node path, run id, attempt, status, interrupt count) rather than invented under gen_ai.*. A failed node also sets span status ERROR.

Two parenting subtleties, both easy to get wrong in a way that produces a plausible-looking but incorrect trace:

  • Nodes run concurrently and are raced in runLoop, so the span is started synchronously in executeChildNode before the first await — its parent is the span active when the workflow scheduled it. A parent captured any later nests concurrent siblings inside whichever task resolved first. Nothing is inferred at event-drain time either, since child events leave via ctx.channel on a different async stack.
  • executeChildNode returns a promise, not an async generator, so the runAsyncGeneratorWithOtelContext binding used by BaseAgent.runAsync does not apply. context.with is used instead.

Testing Plan

Unit Tests:

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

New core/test/workflow/telemetry_test.ts (5 tests) registers a real AsyncLocalStorageContextManager — without one, context.active() is always root, every span is parentless, and span-parenting assertions pass vacuously.

The fan-out test forces the two nodes to genuinely overlap via a mutual start barrier and asserts their span intervals intersect, so "siblings" is not trivially true.

Mutation-checked: removing the context.with binding from executeChildNode fails 4 of the 5 tests. Verified independently of the authoring pass.

npx vitest run --project unit:core core/test/workflow
 Test Files  32 passed (32)
      Tests  267 passed (267)

npx vitest run --project unit:core
 Test Files  208 passed (208)
      Tests  2848 passed (2848)

tsc --noEmit: 0 errors repo-wide (rebased onto main after #648). eslint and prettier clean on all 4 files.

Manual End-to-End (E2E) Tests:

Not run against a live exporter — the tests use an in-memory exporter rather than Cloud Trace. Reviewers can verify end-to-end by running any workflow sample with OTel export configured and checking the span tree in Cloud Trace.

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

Known gaps, called out rather than hidden:

  • gen_ai.operation.name values invoke_workflow / execute_node are not in the semconv well-known set. Set deliberately; a consumer filtering on standard values simply will not match them. One line per helper to drop if unwanted.
  • waiting status is interrupt-only. executeChildNode cannot see the waitForOutput join barrier — the workflow decides that after the runner returns — so a node parked on a fan-in barrier reports completed. Documented in code, untested.
  • Fast-forwarded nodes get no span. On resume, startNodeTask short-circuits before executeChildNode, so a node replayed from cached output produces none. Intentional (nothing executed), but a resume trace will show fewer node spans than the graph has nodes.
  • @opentelemetry/context-async-hooks is used only by the new test and is currently a transitive dep of @opentelemetry/sdk-trace-node, not declared in core/package.json.

@kalenkevich kalenkevich assigned kalenkevich and unassigned Varun-S10 Aug 12, 2026
@kalenkevich
kalenkevich force-pushed the feat/workflow-node-otel-spans branch from 769a9bc to 72f168a Compare August 12, 2026 06:31
@kalenkevich
kalenkevich marked this pull request as ready for review August 12, 2026 06:36
@kalenkevich
kalenkevich force-pushed the feat/workflow-node-otel-spans branch from 72f168a to 041f4b7 Compare August 12, 2026 06:37

@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 span design is correct. The execute_node span starts synchronously at schedule time under the active invoke_workflow span, so concurrent siblings parent correctly. One new test fails on ubuntu CI, which blocks approval. macOS and windows were fail-fast cancelled, not independent failures.

Comment thread core/test/workflow/telemetry_test.ts Outdated
Comment on lines +121 to +122
expect(micros(leftSpan.startTime)).toBeLessThan(micros(rightSpan.endTime));
expect(micros(rightSpan.startTime)).toBeLessThan(micros(leftSpan.endTime));

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 test fails on the ubuntu CI run.

expect(micros(leftSpan.startTime)).toBeLessThan(micros(rightSpan.endTime));
expect(micros(rightSpan.startTime)).toBeLessThan(micros(leftSpan.endTime));

micros(rightSpan.startTime) was 1786516781208000. micros(leftSpan.endTime) was 1786516781207336.8. The spans do not overlap at microsecond resolution, so line 122 fails. OpenTelemetry HrTime is too coarse for a sub-millisecond overlap check.

The expectChildOf and parentSpanContext checks below already prove the siblings. Remove the interval-overlap assertion.

*/

import {context, trace} from '@opentelemetry/api';
import {AsyncLocalStorageContextManager} from '@opentelemetry/context-async-hooks';

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. @opentelemetry/context-async-hooks is not declared in core/package.json. It resolves today only as a transitive dependency of @opentelemetry/sdk-trace-node. The test breaks if that link changes. Add it to devDependencies.

Comment thread core/src/workflow/node_runner.ts Outdated
}

return child;
function errorMessage(err: unknown): 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.

Nit, optional. This helper repeats formatError in core/src/utils/error_utils.ts:159, which returns the same message and also unwraps cause and aggregate errors.

function errorMessage(err: unknown): string {
  return err instanceof Error ? err.message : String(err);
}

Reuse formatError for richer span messages. The inline pattern already exists at other call sites, so keeping it is also fine.

@kalenkevich
kalenkevich force-pushed the feat/workflow-node-otel-spans branch from 041f4b7 to 913615d 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.

Holding, not approving. The keeps concurrently scheduled nodes as siblings test still fails on the ubuntu run-tests job at telemetry_test.ts:122 (expected 1786516781208000 to be less than 1786516781207336.8); the head commit leaves lines 121-122 unchanged. This is a real failure in a test the PR adds, not the known macOS or Windows flake. CI for the head commit is also still running. Two earlier nits stay open: @opentelemetry/context-async-hooks is still absent from core/package.json though the test imports it, and the errorMessage helper still duplicates formatError (optional).

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

CI: parallel_worker integration failure is a fixture/interleaving issue, not a conflict

Flagging the diagnosis so the rebase isn't blamed. This failure predates the rebase — it failed identically on the previous push (run 31570686597), and main is green.

Root cause. Wrapping executeChildNode in context.with(..., async () => …) adds a microtask boundary before the node body runs. That is enough to change when a predecessor's output events become visible to concurrently-scheduled siblings.

tests/integration/workflows/parallel_worker recorded the old interleaving, in which the first two explain_topic items (maxParallelWorkers: 2) raced ahead of make_upper_case and saw no predecessor-output block:

recorded item 0: [databases] [ctx: find_related_topics …] [SQL]
actual   item 0: [databases] [ctx: find_related_topics …] [SQL] [ctx: make_upper_case said SQL / DATA MODELING / DATABASE ADMINISTRATION]

Different contents → different request fingerprint → replay miss:

[sample-harness] No recorded model response for request 117102d47b64596d …
[sample-harness] No recorded model response for request 635046fd0822967d …

The harness throws, LlmAgentWrapper never sets event.output, and the holes surface far away as Cannot read properties of undefined (reading 'topic') in the aggregate node.

Item 2 passes because it was already slow enough to see that context when recorded.

Confirmed by bisection — reverting only core/src/workflow/node_runner.ts to main makes it pass; stubbing the span, dropping context.with, and bypassing runAttempt all still fail. So it is the extra microtask boundary, not the tracing calls.

Options

  1. Re-record the fixture (npm run record:samples, needs an API key). The new behaviour is arguably the better one — items now consistently see their predecessors' outputs instead of racing them.
  2. Decide the ordering is a real semantic change worth keeping deliberate, and note it in the PR.

Worth a look either way: that a parallel item sees its predecessor's outputs depending on timing is pre-existing nondeterminism this PR merely exposes.

Investigated by CloudCode — session ses_009687a50ffeHGKf2wlpeoew2M.

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Pushed c912f3b, addressing all four open points. Summary of what changed and why.

The parallel_worker failure: fixed in the code, fixture untouched

I'd previously diagnosed this as an interleaving shift and offered "re-record the fixture" as an option. That was the wrong call — re-recording would have baked in a behaviour change that a tracing PR has no business making. The extra microtask hops were removable, so I removed them.

The hops came from the async wrappers, not from the tracing calls:

Site Before After
executeChildNode async fn returning context.with(ctx, async () => …) plain fn returning context.with(ctx, () => runChildNode(…))
runAttempt async fn, return runOnce(params) plain fn, same body
Workflow.runImpl context.with(ctx, async () => { await this.orchestrate(…) }) context.with(ctx, () => this.orchestrate(…))

context.with hands its callback's return value straight back, so with a sync callback the child settles on exactly the microtask it did before tracing existed. Each async wrapper was costing promise-adoption ticks on top.

The body of executeChildNode moved into a new runChildNode; the span is still started synchronously in executeChildNode at schedule time, so the sibling-parenting property you signed off on is unchanged.

tests/integration/workflows/parallel_worker now passes with the recorded fixture as-is — verified 3 consecutive runs of the whole tests/integration/workflows folder (37 files, 84 tests).

The pre-existing nondeterminism I flagged earlier is still there and still worth a separate look; this PR no longer perturbs it either way.

telemetry_test.ts:122 — removed, and replaced with something stronger

Removed the two interval-overlap assertions and the micros helper. You're right that HrTime is too coarse; the mutual start barrier already guarantees the overlap structurally, since neither node can return until the other has started. That's now a comment rather than an assertion.

While checking that removal I found the sibling test was weaker than its name suggested: it passed even with the context.with binding deleted, because tracer.startSpan picks up the ambient workflow context anyway. Both nodes now run a dynamic child via ctx.runNode while the sibling is in flight, and the test asserts each inner span nests under its own node. That is what the binding actually buys.

Correction to my own PR description: I claimed removing the binding failed 4 of 5 tests. Re-measured, the original code failed 3 of 5 — the claim was overstated. With the new inner-span assertion it is genuinely 4 of 5.

The two nits

  • @opentelemetry/context-async-hooks: ^2.1.0 added to core devDependencies, matching the ^2.1.0 the other OTel v2 packages use. package-lock.json synced (one line).
  • errorMessage deleted in favour of formatError, so span error messages unwrap cause chains and aggregate errors.

Verification

npx vitest run --project unit:core --project unit:dev --project integration
 Test Files  301 passed (301)
      Tests  3387 passed | 8 skipped (3395)

tsc --noEmit clean for core/, eslint and prettier clean on all touched files.

CloudCode — session ses_00908b31dffeTTyMnewq75XlYS.

@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 c912f3b. All three earlier findings are fixed and verified in the source at this head:

  1. The timestamp overlap assertion is gone. The fan-out test now proves siblings with parent-span checks (telemetry_test.ts:126-139). run-tests (ubuntu-latest) now passes.
  2. @opentelemetry/context-async-hooks is declared in core/package.json devDependencies.
  3. formatError replaces the local errorMessage helper (node_runner.ts:281,326).

No new issues. The diff adds no type suppressions and no instanceof. The new tracing.ts exports match the existing internal module pattern, so they do not belong in index.ts.

One gate blocks approval: run-tests (windows-latest) is still queued, not green. ubuntu and macOS pass. This is approvable once windows is green.

@kalenkevich

kalenkevich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

run-tests (windows-latest) is now green on c912f3brun 31622499393 completed with all three matrix jobs passing (windows 11m41s, ubuntu 7m4s, macOS 5m10s). It was still queued when you looked; nothing was pushed since.

All checks on the head commit are green, so the gate is satisfied.

The workflow engine emitted no spans at all, so a graph run was invisible
between the enclosing `invoke_agent` span and whatever model/tool spans its
nodes happened to open. There was no way to see which node was slow, which one
failed, or how a retry storm unfolded.

Three spans now:

- `invoke_workflow <name>` around the orchestration loop.
- `execute_node <name>` around each node's whole run, retries included. Dynamic
  `ctx.runNode()` children route through the same function, so they are covered
  too, and a nested workflow reads `execute_node wf -> invoke_workflow wf -> ...`.
- `execute_node_attempt <name>` per attempt, but only for a node that declares a
  retry config -- otherwise the single attempt span would just duplicate its
  parent. The attempt number is an attribute rather than part of the name, to
  keep span names low-cardinality.

Two things about the parenting are easy to get wrong and are worth calling out,
since a mis-parented trace looks plausible and is wrong:

- Nodes run concurrently and are raced against each other in `runLoop`, so the
  span is started synchronously in `executeChildNode`, before the first `await`.
  Its parent is therefore the span active when the workflow SCHEDULED the node.
  A parent captured any later nests concurrent siblings inside whichever task
  resolved first. Nothing is inferred at event-drain time either -- child events
  leave through `ctx.channel` on a different async stack.
- `executeChildNode` returns a promise, not an async generator, so the
  `runAsyncGeneratorWithOtelContext` binding used by `BaseAgent.runAsync` does
  not apply. `context.with` is used instead, so every `await` inside the node --
  and every span the node body itself opens -- nests correctly.

Attributes are namespaced `adk.workflow.*` / `adk.node.*` rather than invented
under `gen_ai.*`: node path, run id, attempt, status and interrupt count. A
failed node also sets the span status to ERROR.

The new tests register a real `AsyncLocalStorageContextManager`; without one
`context.active()` is always root, every span is parentless and the assertions
would pass vacuously. The fan-out test forces the two nodes to actually overlap
via a mutual barrier and asserts their span intervals intersect, so "siblings"
is not trivially true. Removing the `context.with` binding fails 4 of the 5.
Wrapping the node runner in `context.with(..., async () => …)` made the
tracing change observable in execution order, not just in the trace: each
async wrapper adds promise-adoption ticks, and that was enough to shift when
a predecessor's output events become visible to concurrently scheduled
siblings. The `parallel_worker` integration sample caught it — two mapped
items that used to race ahead of `make_upper_case` now saw its output, which
changed their request fingerprints and missed the recorded responses.

An observability change must not move execution around, so the wrappers are
now microtask-neutral: `executeChildNode` and `runAttempt` are plain
functions handing the inner promise straight back, and `Workflow.runImpl`
passes a sync callback that returns `orchestrate`'s promise. The span is
still started synchronously at schedule time, so sibling parenting is
unchanged. The recorded fixture is left alone.

Also from review:

- Drop the span-interval overlap assertion in the fan-out test. OTel HrTime
  is too coarse for a sub-millisecond comparison and it failed on ubuntu CI;
  the mutual start barrier already guarantees the overlap structurally.
- Assert instead that a dynamic child started inside each concurrent node
  nests under that node. This is what the context binding actually buys, and
  it makes the test fail when the binding is removed.
- Declare `@opentelemetry/context-async-hooks` in core's devDependencies
  rather than relying on it resolving through `@opentelemetry/sdk-trace-node`.
- Reuse `formatError` for span error messages instead of a local helper, so
  `cause` chains and aggregate errors are unwrapped.
@kalenkevich
kalenkevich force-pushed the feat/workflow-node-otel-spans branch from c912f3b to 9952259 Compare August 12, 2026 19:24
@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (c912f3b9952259) to clear the conflict. Force-push, so the earlier SHAs are gone — apologies for any stale comment anchors.

Four commits landed on main since the previous base; one conflicted:

#657 (NodeErrorEvent) added an optional nodeState param to executeChildNode, letting a caller supply the state instead of the function always creating one. That collided with this PR splitting executeChildNode into a sync prologue (span start + context.with) and an async runChildNode body. nodeState is consumed by the body, so it moved to runChildNode's destructuring alongside input/abortSignal; the callerNodeState ?? createNodeState(…) logic from main is unchanged. No behaviour from either side was dropped.

workflow.ts auto-merged; #650, #660 and #654 did not touch anything here.

Re-verified on the rebased head:

npx vitest run --project unit:core --project unit:dev --project integration
 Test Files  302 passed (302)
      Tests  3435 passed | 8 skipped (3443)
  • tests/integration/workflows green 3 consecutive runs (84 tests) — the timing-sensitive parallel_worker fixture still passes untouched.
  • Mutation check still holds: deleting the context.with binding fails 4 of the 5 telemetry tests.
  • tsc --noEmit clean for core/, eslint and prettier clean.

CloudCode — session ses_00908b31dffeTTyMnewq75XlYS.

@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 9952259. All three earlier findings are fixed and verified in the source at this head:

  1. The timestamp overlap assertion is gone. The fan-out test proves siblings with parent-span checks (telemetry_test.ts:546-559). run-tests (ubuntu-latest) now passes.
  2. @opentelemetry/context-async-hooks is declared in core/package.json devDependencies.
  3. formatError replaces the local helper (node_runner.ts:291,336).

The microtask refactor adds no type suppressions and no instanceof. The span always ends in the finally block. The tracing exports stay internal, so they do not belong in index.ts.

One gate blocks approval: run-tests (macos-latest) and run-tests (windows-latest) are still pending. Approvable once both are green.

@kalenkevich

kalenkevich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Both are green now on 9952259run 31632493250 completed successfully with all three matrix jobs passing (macOS 10m9s, windows 12m15s, ubuntu 7m10s). They were still pending when you looked; nothing was pushed since.

All 8 checks on the head commit pass, so the gate is satisfied.

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

Approving. My earlier review listed no code defects and blocked only on two still-pending CI jobs. That was wrong of the reviewer, and it then never came back when they went green — the bot only re-reviews when the head SHA moves, so a passing check could not wake it. Fixed: CI is no longer an approval gate.

Re-verified at the current head: @opentelemetry/context-async-hooks is in core/package.json devDependencies, formatError replaces the local helper (node_runner.ts:291,336), the span always ends in the finally block, and the tracing exports stay internal so they correctly do not appear in index.ts. The microtask refactor adds no type suppressions and no instanceof across all six files. All checks green. LGTM.

Thanks for flagging this rather than working around it — the failure mode was invisible from my side.

@kalenkevich
kalenkevich merged commit d0f19c5 into main Aug 12, 2026
12 checks passed
@kalenkevich
kalenkevich deleted the feat/workflow-node-otel-spans branch August 12, 2026 21:51
@kalenkevich kalenkevich mentioned this pull request Aug 12, 2026
kalenkevich added a commit that referenced this pull request Aug 12, 2026
…an agent

With `InvocationContext.agent` optional, nothing forces a workflow to be an
agent any more. The runner now keeps the `Workflow` it was handed and drives it
directly; `ic.agent` is simply unset for that invocation, which is what
adk-python does (`agent=self.agent if isinstance(self.agent, BaseAgent) else
None`).

The bridge moves rather than gets rewritten. `WorkflowAgent.runAsyncImpl` was
already the whole of "run a node as an invocation" -- make a root NodeContext,
derive the input from the user message, pump a channel -- so it becomes
`runNodeAsInvocation`, and `WorkflowAgent` shrinks to a four-line delegation
(227 lines to 143). Two callers need that function now, and neither should own
it.

Only two things in the run loop actually differ between a node root and an
agent root, so only those two branch:

  - resumption, which resolves an event author against the agent tree and is
    meaningless for a node subtree that was never in it;
  - the execution call itself, behind `runRoot`.

Everything else -- the before/after run callbacks, `onEvent`, session
persistence, cancellation -- stays on one path. adk-python has a second loop
for this, with a TODO noting that loop lacks tracing and plugins; there is
nothing to lack if there is only one loop.

Losing the wrapper does lose `BaseAgent.runAsync`'s `invoke_agent` span, which
is only acceptable because node execution is traced and plugged in its own
right (#653, #659). Both are now asserted rather than assumed: a workflow run
as a root still produces `invoke_workflow` and `execute_node` spans in the right
tree, and still fires the node hooks. The hooks bracket the workflow node too,
not just the nodes inside it.

BREAKING CHANGE: `Runner.agent` is `BaseAgent | BaseNode`, and is no longer
wrapped when given a `Workflow` -- code reading `runner.agent` and expecting an
agent must narrow. `InvocationContext.agent` is unset while a node root runs.
kalenkevich added a commit that referenced this pull request Aug 13, 2026
…an agent

With `InvocationContext.agent` optional, nothing forces a workflow to be an
agent any more. The runner now keeps the `Workflow` it was handed and drives it
directly; `ic.agent` is simply unset for that invocation, which is what
adk-python does (`agent=self.agent if isinstance(self.agent, BaseAgent) else
None`).

The bridge moves rather than gets rewritten. `WorkflowAgent.runAsyncImpl` was
already the whole of "run a node as an invocation" -- make a root NodeContext,
derive the input from the user message, pump a channel -- so it becomes
`runNodeAsInvocation`, and `WorkflowAgent` shrinks to a four-line delegation
(227 lines to 143). Two callers need that function now, and neither should own
it.

Only two things in the run loop actually differ between a node root and an
agent root, so only those two branch:

  - resumption, which resolves an event author against the agent tree and is
    meaningless for a node subtree that was never in it;
  - the execution call itself, behind `runRoot`.

Everything else -- the before/after run callbacks, `onEvent`, session
persistence, cancellation -- stays on one path. adk-python has a second loop
for this, with a TODO noting that loop lacks tracing and plugins; there is
nothing to lack if there is only one loop.

Losing the wrapper does lose `BaseAgent.runAsync`'s `invoke_agent` span, which
is only acceptable because node execution is traced and plugged in its own
right (#653, #659). Both are now asserted rather than assumed: a workflow run
as a root still produces `invoke_workflow` and `execute_node` spans in the right
tree, and still fires the node hooks. The hooks bracket the workflow node too,
not just the nodes inside it.

BREAKING CHANGE: `Runner.agent` is `BaseAgent | BaseNode`, and is no longer
wrapped when given a `Workflow` -- code reading `runner.agent` and expecting an
agent must narrow. `InvocationContext.agent` is unset while a node root runs.
kalenkevich added a commit that referenced this pull request Aug 13, 2026
…an agent

With `InvocationContext.agent` optional, nothing forces a workflow to be an
agent any more. The runner now keeps the `Workflow` it was handed and drives it
directly; `ic.agent` is simply unset for that invocation, which is what
adk-python does (`agent=self.agent if isinstance(self.agent, BaseAgent) else
None`).

The bridge moves rather than gets rewritten. `WorkflowAgent.runAsyncImpl` was
already the whole of "run a node as an invocation" -- make a root NodeContext,
derive the input from the user message, pump a channel -- so it becomes
`runNodeAsInvocation`, and `WorkflowAgent` shrinks to a four-line delegation
(227 lines to 143). Two callers need that function now, and neither should own
it.

Only two things in the run loop actually differ between a node root and an
agent root, so only those two branch:

  - resumption, which resolves an event author against the agent tree and is
    meaningless for a node subtree that was never in it;
  - the execution call itself, behind `runRoot`.

Everything else -- the before/after run callbacks, `onEvent`, session
persistence, cancellation -- stays on one path. adk-python has a second loop
for this, with a TODO noting that loop lacks tracing and plugins; there is
nothing to lack if there is only one loop.

Losing the wrapper does lose `BaseAgent.runAsync`'s `invoke_agent` span, which
is only acceptable because node execution is traced and plugged in its own
right (#653, #659). Both are now asserted rather than assumed: a workflow run
as a root still produces `invoke_workflow` and `execute_node` spans in the right
tree, and still fires the node hooks. The hooks bracket the workflow node too,
not just the nodes inside it.

BREAKING CHANGE: `Runner.agent` is `BaseAgent | BaseNode`, and is no longer
wrapped when given a `Workflow` -- code reading `runner.agent` and expecting an
agent must narrow. `InvocationContext.agent` is unset while a node root runs.
kalenkevich added a commit that referenced this pull request Aug 13, 2026
…an agent

With `InvocationContext.agent` optional, nothing forces a workflow to be an
agent any more. The runner now keeps the `Workflow` it was handed and drives it
directly; `ic.agent` is simply unset for that invocation, which is what
adk-python does (`agent=self.agent if isinstance(self.agent, BaseAgent) else
None`).

The bridge moves rather than gets rewritten. `WorkflowAgent.runAsyncImpl` was
already the whole of "run a node as an invocation" -- make a root NodeContext,
derive the input from the user message, pump a channel -- so it becomes
`runNodeAsInvocation`, and `WorkflowAgent` shrinks to a four-line delegation
(227 lines to 143). Two callers need that function now, and neither should own
it.

Only two things in the run loop actually differ between a node root and an
agent root, so only those two branch:

  - resumption, which resolves an event author against the agent tree and is
    meaningless for a node subtree that was never in it;
  - the execution call itself, behind `runRoot`.

Everything else -- the before/after run callbacks, `onEvent`, session
persistence, cancellation -- stays on one path. adk-python has a second loop
for this, with a TODO noting that loop lacks tracing and plugins; there is
nothing to lack if there is only one loop.

Losing the wrapper does lose `BaseAgent.runAsync`'s `invoke_agent` span, which
is only acceptable because node execution is traced and plugged in its own
right (#653, #659). Both are now asserted rather than assumed: a workflow run
as a root still produces `invoke_workflow` and `execute_node` spans in the right
tree, and still fires the node hooks. The hooks bracket the workflow node too,
not just the nodes inside it.

BREAKING CHANGE: `Runner.agent` is `BaseAgent | BaseNode`, and is no longer
wrapped when given a `Workflow` -- code reading `runner.agent` and expecting an
agent must narrow. `InvocationContext.agent` is unset while a node root runs.
kalenkevich added a commit that referenced this pull request Aug 13, 2026
* refactor(agents)!: let an invocation have no agent, ahead of node roots

`WorkflowAgent` exists for one reason: `InvocationContext.agent` is
non-optional, the runner needs something to put in it, and only an agent fits —
so a `Workflow` gets one manufactured for it. adk-python has no such class
because it has no such constraint: its field is `BaseAgent | BaseNode | None`,
and `_new_invocation_context` passes `agent=self.agent if isinstance(self.agent,
BaseAgent) else None`. Nothing else about the adapter is load-bearing.

So this makes the field optional. On its own that changes no behaviour —
nothing constructs a context without an agent yet — but it is the whole of the
blocker, and it is worth landing separately from the runner path that will
exploit it.

Nineteen sites had to say what they assume. All of them sit in code that only
runs *because* an agent is running (an LLM flow, agent transfer, a tool call),
so they now go through `requireAgent(ctx)`, which fails by name instead of
surfacing as a property access on `undefined` several frames away. The two
exceptions are the logging and replay plugins, which observe rather than
participate: a logger that throws because there is no agent to name is worse
than one that prints nothing, so those fall back instead.

`requireAgent` is a free function, not an accessor. A getter is more idiomatic,
but a good deal of code — and most of the tests — passes a duck-typed context
object, where a getter is simply absent and fails less clearly than the missing
agent it is meant to report. Eleven tests found that the direct way.

BREAKING CHANGE: `InvocationContext.agent` is now optional. Code reading it
outside an agent's own execution must handle `undefined`; inside one, prefer
`requireAgent(ctx)`.

* feat(runner)!: drive a Workflow as a node, instead of dressing it as an agent

With `InvocationContext.agent` optional, nothing forces a workflow to be an
agent any more. The runner now keeps the `Workflow` it was handed and drives it
directly; `ic.agent` is simply unset for that invocation, which is what
adk-python does (`agent=self.agent if isinstance(self.agent, BaseAgent) else
None`).

The bridge moves rather than gets rewritten. `WorkflowAgent.runAsyncImpl` was
already the whole of "run a node as an invocation" -- make a root NodeContext,
derive the input from the user message, pump a channel -- so it becomes
`runNodeAsInvocation`, and `WorkflowAgent` shrinks to a four-line delegation
(227 lines to 143). Two callers need that function now, and neither should own
it.

Only two things in the run loop actually differ between a node root and an
agent root, so only those two branch:

  - resumption, which resolves an event author against the agent tree and is
    meaningless for a node subtree that was never in it;
  - the execution call itself, behind `runRoot`.

Everything else -- the before/after run callbacks, `onEvent`, session
persistence, cancellation -- stays on one path. adk-python has a second loop
for this, with a TODO noting that loop lacks tracing and plugins; there is
nothing to lack if there is only one loop.

Losing the wrapper does lose `BaseAgent.runAsync`'s `invoke_agent` span, which
is only acceptable because node execution is traced and plugged in its own
right (#653, #659). Both are now asserted rather than assumed: a workflow run
as a root still produces `invoke_workflow` and `execute_node` spans in the right
tree, and still fires the node hooks. The hooks bracket the workflow node too,
not just the nodes inside it.

BREAKING CHANGE: `Runner.agent` is `BaseAgent | BaseNode`, and is no longer
wrapped when given a `Workflow` -- code reading `runner.agent` and expecting an
agent must narrow. `InvocationContext.agent` is unset while a node root runs.

* feat(workflow)!: remove WorkflowAgent [WIP: 2 integration tests red]

Removes the adapter outright rather than deprecating it. With the runner able
to drive a node, nothing needed a workflow dressed as an agent, and every seam
that assumed one now takes `RunnableRoot` (`BaseAgent | Workflow`):

  - `App` and `AgentLoader` hold the root as given, no longer wrapping;
  - the dev graph renderer reads a `Workflow` directly via `isWorkflow`;
  - the a2a card describes a workflow as a single `workflow` skill, since it
    has nodes rather than sub-agents;
  - `cli_run`, the api server and `InMemoryRunner` thread the wider type.

`asRootAgent` becomes `asRunnableRoot`, and keeps taking what an edge takes
rather than narrowing to a root: an agent or a workflow passes through as
itself, and any other node-like value still becomes the single node of a
one-node workflow — the wrapper it built was a `WorkflowAgent`, so only the
thing built changes. `isRunnableRoot` replaces `isRootAgentLike` as the
narrower *discovery* guard, unchanged in what it matches.

`isGraphWorkflowAgent` goes with it; `isWorkflow` covers the same ground. The
a2a card's local `isWorkflowAgent` — which actually meant Loop/Sequential/
Parallel, and sat confusingly next to the real thing — is now
`isCompositeShellAgent`. All 26 samples and the tests build their root with
`new Workflow({...})`, which is the API we want them demonstrating anyway.

`workflow_agent_test.ts` became `run_node_as_invocation_test.ts`, keeping the
plain-text resume and output-once coverage and dropping only the suites that
described the class itself.

KNOWN FAILING, and the reason this is marked WIP: two integration tests. The
cause is identified. `BaseAgent.runAsync` used to build a child context with
`agent: this`, so inside a workflow run `ic.agent` was the WorkflowAgent. Drive
the workflow as a node and there is no agent, so `functions.ts` — which authors
tool events as `requireAgent(invocationContext).name` at four sites — throws for
a `ToolNode` under a node root. `parallel_worker` fails downstream of the same
thing. The fix is to decide what authors a tool event when no agent is running;
the node runner already stamps an author, so these sites likely should not be
asserting one.

Also lost: a workflow can no longer be a sub-agent of a composite agent, since
`subAgents` takes `BaseAgent`. That was the escape hatch the wrapper provided,
and the graph test covering it is removed. Worth a deliberate decision before
this ships.

BREAKING CHANGE: `WorkflowAgent`, `WorkflowAgentConfig` and
`isGraphWorkflowAgent` are removed. Use `Workflow` directly as a root.

* fix(agents): let a tool event take its author from the node when no agent runs

`functions.ts` authored every event it creates as `requireAgent(ctx).name`.
That held while a workflow was wrapped in an agent, because the wrapper put
itself in `ic.agent`. Driving the workflow as a node leaves no agent at that
level, so a `ToolNode` under a node root threw on an assumption that had simply
stopped being true.

The node runner already stamps a node's own name onto any event that leaves
without an author, so these four sites defer to it instead of asserting. Inside
an agent's own turn — every other caller — the agent is set and nothing changes.

* test(workflows): re-record the parallel_worker fixture

The recorded requests stopped matching, and the miss surfaced far from its
cause: the harness throws "No recorded model response", the agent turn swallows
it into an empty event, and `aggregate` then reads `.topic` off `undefined`.
This was the second of the two integration failures this branch carried.

What changed is which predecessor outputs a worker sees. `explain_topic` builds
its request from the node outputs already committed to the session, and the old
fixture caught that mid-flight: workers 0 and 1 were recorded with no
`make_upper_case` context at all, while worker 2 had all three. But
`make_upper_case` is a predecessor node — it has finished before any worker
starts — so every worker should see all three of its outputs, and driving the
workflow as a node is what makes every worker actually do so. The old fixture
was pinning a race, not a contract.

Re-recorded with `npm run record:samples`, which rewrites every sample's
fixture; only this one is kept, since the rest were unaffected.

* test(workflow): pin the ParallelWorker fan-in without a model

Review raised the right objection to the fixture re-record one commit back: if
the only thing watching parallel-worker output is a recorded-response sample,
then a re-record can absorb a genuine fan-in regression and the suite stays
green.

So assert the contract where no fixture can reach it. Both cases run the sample's
shape — seed, a bounded parallel worker over three items, an aggregate — through
the real `Runner` with a `Workflow` root, and assert on the list the aggregate is
actually handed rather than on anything the model said. One uses a function
worker, one an agent worker; the agent case is the one that broke, since a worker
that produced nothing left `undefined` in the list and the aggregate read a
property off it.

Checked by mutation, not just by passing: dropping a worker's output in
`ParallelWorker` fails both, and suppressing the agent wrapper's output
promotion fails only the agent case.

* style(cli): wrap the auth-scheme cast the way the pinned Prettier wants

The union in `renderUserInputRequest` was left inline, which Prettier 3.8.4 —
the version the lockfile pins, and the one CI runs — breaks onto separate lines.
Newer Prettier accepts the inline form, so a local `format:check` against a
node_modules that has drifted ahead of the lockfile passes while CI's
`run-tests` matrix fails on this one file.

No behaviour change; formatting only.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…oogle#653)

* feat(workflow): trace workflow and node execution with OpenTelemetry

The workflow engine emitted no spans at all, so a graph run was invisible
between the enclosing `invoke_agent` span and whatever model/tool spans its
nodes happened to open. There was no way to see which node was slow, which one
failed, or how a retry storm unfolded.

Three spans now:

- `invoke_workflow <name>` around the orchestration loop.
- `execute_node <name>` around each node's whole run, retries included. Dynamic
  `ctx.runNode()` children route through the same function, so they are covered
  too, and a nested workflow reads `execute_node wf -> invoke_workflow wf -> ...`.
- `execute_node_attempt <name>` per attempt, but only for a node that declares a
  retry config -- otherwise the single attempt span would just duplicate its
  parent. The attempt number is an attribute rather than part of the name, to
  keep span names low-cardinality.

Two things about the parenting are easy to get wrong and are worth calling out,
since a mis-parented trace looks plausible and is wrong:

- Nodes run concurrently and are raced against each other in `runLoop`, so the
  span is started synchronously in `executeChildNode`, before the first `await`.
  Its parent is therefore the span active when the workflow SCHEDULED the node.
  A parent captured any later nests concurrent siblings inside whichever task
  resolved first. Nothing is inferred at event-drain time either -- child events
  leave through `ctx.channel` on a different async stack.
- `executeChildNode` returns a promise, not an async generator, so the
  `runAsyncGeneratorWithOtelContext` binding used by `BaseAgent.runAsync` does
  not apply. `context.with` is used instead, so every `await` inside the node --
  and every span the node body itself opens -- nests correctly.

Attributes are namespaced `adk.workflow.*` / `adk.node.*` rather than invented
under `gen_ai.*`: node path, run id, attempt, status and interrupt count. A
failed node also sets the span status to ERROR.

The new tests register a real `AsyncLocalStorageContextManager`; without one
`context.active()` is always root, every span is parentless and the assertions
would pass vacuously. The fan-out test forces the two nodes to actually overlap
via a mutual barrier and asserts their span intervals intersect, so "siblings"
is not trivially true. Removing the `context.with` binding fails 4 of the 5.

* fix(workflow): keep node tracing off the microtask critical path

Wrapping the node runner in `context.with(..., async () => …)` made the
tracing change observable in execution order, not just in the trace: each
async wrapper adds promise-adoption ticks, and that was enough to shift when
a predecessor's output events become visible to concurrently scheduled
siblings. The `parallel_worker` integration sample caught it — two mapped
items that used to race ahead of `make_upper_case` now saw its output, which
changed their request fingerprints and missed the recorded responses.

An observability change must not move execution around, so the wrappers are
now microtask-neutral: `executeChildNode` and `runAttempt` are plain
functions handing the inner promise straight back, and `Workflow.runImpl`
passes a sync callback that returns `orchestrate`'s promise. The span is
still started synchronously at schedule time, so sibling parenting is
unchanged. The recorded fixture is left alone.

Also from review:

- Drop the span-interval overlap assertion in the fan-out test. OTel HrTime
  is too coarse for a sub-millisecond comparison and it failed on ubuntu CI;
  the mutual start barrier already guarantees the overlap structurally.
- Assert instead that a dynamic child started inside each concurrent node
  nests under that node. This is what the context binding actually buys, and
  it makes the test fail when the binding is removed.
- Declare `@opentelemetry/context-async-hooks` in core's devDependencies
  rather than relying on it resolving through `@opentelemetry/sdk-trace-node`.
- Reuse `formatError` for span error messages instead of a local helper, so
  `cause` chains and aggregate errors are unwrapped.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…ogle#688)

* refactor(agents)!: let an invocation have no agent, ahead of node roots

`WorkflowAgent` exists for one reason: `InvocationContext.agent` is
non-optional, the runner needs something to put in it, and only an agent fits —
so a `Workflow` gets one manufactured for it. adk-python has no such class
because it has no such constraint: its field is `BaseAgent | BaseNode | None`,
and `_new_invocation_context` passes `agent=self.agent if isinstance(self.agent,
BaseAgent) else None`. Nothing else about the adapter is load-bearing.

So this makes the field optional. On its own that changes no behaviour —
nothing constructs a context without an agent yet — but it is the whole of the
blocker, and it is worth landing separately from the runner path that will
exploit it.

Nineteen sites had to say what they assume. All of them sit in code that only
runs *because* an agent is running (an LLM flow, agent transfer, a tool call),
so they now go through `requireAgent(ctx)`, which fails by name instead of
surfacing as a property access on `undefined` several frames away. The two
exceptions are the logging and replay plugins, which observe rather than
participate: a logger that throws because there is no agent to name is worse
than one that prints nothing, so those fall back instead.

`requireAgent` is a free function, not an accessor. A getter is more idiomatic,
but a good deal of code — and most of the tests — passes a duck-typed context
object, where a getter is simply absent and fails less clearly than the missing
agent it is meant to report. Eleven tests found that the direct way.

BREAKING CHANGE: `InvocationContext.agent` is now optional. Code reading it
outside an agent's own execution must handle `undefined`; inside one, prefer
`requireAgent(ctx)`.

* feat(runner)!: drive a Workflow as a node, instead of dressing it as an agent

With `InvocationContext.agent` optional, nothing forces a workflow to be an
agent any more. The runner now keeps the `Workflow` it was handed and drives it
directly; `ic.agent` is simply unset for that invocation, which is what
adk-python does (`agent=self.agent if isinstance(self.agent, BaseAgent) else
None`).

The bridge moves rather than gets rewritten. `WorkflowAgent.runAsyncImpl` was
already the whole of "run a node as an invocation" -- make a root NodeContext,
derive the input from the user message, pump a channel -- so it becomes
`runNodeAsInvocation`, and `WorkflowAgent` shrinks to a four-line delegation
(227 lines to 143). Two callers need that function now, and neither should own
it.

Only two things in the run loop actually differ between a node root and an
agent root, so only those two branch:

  - resumption, which resolves an event author against the agent tree and is
    meaningless for a node subtree that was never in it;
  - the execution call itself, behind `runRoot`.

Everything else -- the before/after run callbacks, `onEvent`, session
persistence, cancellation -- stays on one path. adk-python has a second loop
for this, with a TODO noting that loop lacks tracing and plugins; there is
nothing to lack if there is only one loop.

Losing the wrapper does lose `BaseAgent.runAsync`'s `invoke_agent` span, which
is only acceptable because node execution is traced and plugged in its own
right (google#653, google#659). Both are now asserted rather than assumed: a workflow run
as a root still produces `invoke_workflow` and `execute_node` spans in the right
tree, and still fires the node hooks. The hooks bracket the workflow node too,
not just the nodes inside it.

BREAKING CHANGE: `Runner.agent` is `BaseAgent | BaseNode`, and is no longer
wrapped when given a `Workflow` -- code reading `runner.agent` and expecting an
agent must narrow. `InvocationContext.agent` is unset while a node root runs.

* feat(workflow)!: remove WorkflowAgent [WIP: 2 integration tests red]

Removes the adapter outright rather than deprecating it. With the runner able
to drive a node, nothing needed a workflow dressed as an agent, and every seam
that assumed one now takes `RunnableRoot` (`BaseAgent | Workflow`):

  - `App` and `AgentLoader` hold the root as given, no longer wrapping;
  - the dev graph renderer reads a `Workflow` directly via `isWorkflow`;
  - the a2a card describes a workflow as a single `workflow` skill, since it
    has nodes rather than sub-agents;
  - `cli_run`, the api server and `InMemoryRunner` thread the wider type.

`asRootAgent` becomes `asRunnableRoot`, and keeps taking what an edge takes
rather than narrowing to a root: an agent or a workflow passes through as
itself, and any other node-like value still becomes the single node of a
one-node workflow — the wrapper it built was a `WorkflowAgent`, so only the
thing built changes. `isRunnableRoot` replaces `isRootAgentLike` as the
narrower *discovery* guard, unchanged in what it matches.

`isGraphWorkflowAgent` goes with it; `isWorkflow` covers the same ground. The
a2a card's local `isWorkflowAgent` — which actually meant Loop/Sequential/
Parallel, and sat confusingly next to the real thing — is now
`isCompositeShellAgent`. All 26 samples and the tests build their root with
`new Workflow({...})`, which is the API we want them demonstrating anyway.

`workflow_agent_test.ts` became `run_node_as_invocation_test.ts`, keeping the
plain-text resume and output-once coverage and dropping only the suites that
described the class itself.

KNOWN FAILING, and the reason this is marked WIP: two integration tests. The
cause is identified. `BaseAgent.runAsync` used to build a child context with
`agent: this`, so inside a workflow run `ic.agent` was the WorkflowAgent. Drive
the workflow as a node and there is no agent, so `functions.ts` — which authors
tool events as `requireAgent(invocationContext).name` at four sites — throws for
a `ToolNode` under a node root. `parallel_worker` fails downstream of the same
thing. The fix is to decide what authors a tool event when no agent is running;
the node runner already stamps an author, so these sites likely should not be
asserting one.

Also lost: a workflow can no longer be a sub-agent of a composite agent, since
`subAgents` takes `BaseAgent`. That was the escape hatch the wrapper provided,
and the graph test covering it is removed. Worth a deliberate decision before
this ships.

BREAKING CHANGE: `WorkflowAgent`, `WorkflowAgentConfig` and
`isGraphWorkflowAgent` are removed. Use `Workflow` directly as a root.

* fix(agents): let a tool event take its author from the node when no agent runs

`functions.ts` authored every event it creates as `requireAgent(ctx).name`.
That held while a workflow was wrapped in an agent, because the wrapper put
itself in `ic.agent`. Driving the workflow as a node leaves no agent at that
level, so a `ToolNode` under a node root threw on an assumption that had simply
stopped being true.

The node runner already stamps a node's own name onto any event that leaves
without an author, so these four sites defer to it instead of asserting. Inside
an agent's own turn — every other caller — the agent is set and nothing changes.

* test(workflows): re-record the parallel_worker fixture

The recorded requests stopped matching, and the miss surfaced far from its
cause: the harness throws "No recorded model response", the agent turn swallows
it into an empty event, and `aggregate` then reads `.topic` off `undefined`.
This was the second of the two integration failures this branch carried.

What changed is which predecessor outputs a worker sees. `explain_topic` builds
its request from the node outputs already committed to the session, and the old
fixture caught that mid-flight: workers 0 and 1 were recorded with no
`make_upper_case` context at all, while worker 2 had all three. But
`make_upper_case` is a predecessor node — it has finished before any worker
starts — so every worker should see all three of its outputs, and driving the
workflow as a node is what makes every worker actually do so. The old fixture
was pinning a race, not a contract.

Re-recorded with `npm run record:samples`, which rewrites every sample's
fixture; only this one is kept, since the rest were unaffected.

* test(workflow): pin the ParallelWorker fan-in without a model

Review raised the right objection to the fixture re-record one commit back: if
the only thing watching parallel-worker output is a recorded-response sample,
then a re-record can absorb a genuine fan-in regression and the suite stays
green.

So assert the contract where no fixture can reach it. Both cases run the sample's
shape — seed, a bounded parallel worker over three items, an aggregate — through
the real `Runner` with a `Workflow` root, and assert on the list the aggregate is
actually handed rather than on anything the model said. One uses a function
worker, one an agent worker; the agent case is the one that broke, since a worker
that produced nothing left `undefined` in the list and the aggregate read a
property off it.

Checked by mutation, not just by passing: dropping a worker's output in
`ParallelWorker` fails both, and suppressing the agent wrapper's output
promotion fails only the agent case.

* style(cli): wrap the auth-scheme cast the way the pinned Prettier wants

The union in `renderUserInputRequest` was left inline, which Prettier 3.8.4 —
the version the lockfile pins, and the one CI runs — breaks onto separate lines.
Newer Prettier accepts the inline form, so a local `format:check` against a
node_modules that has drifted ahead of the lockfile passes while CI's
`run-tests` matrix fails on this one file.

No behaviour change; formatting only.
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.

3 participants