feat(workflow): trace workflow and node execution with OpenTelemetry - #653
Conversation
769a9bc to
72f168a
Compare
72f168a to
041f4b7
Compare
AmaadMartin
left a comment
There was a problem hiding this comment.
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.
| expect(micros(leftSpan.startTime)).toBeLessThan(micros(rightSpan.endTime)); | ||
| expect(micros(rightSpan.startTime)).toBeLessThan(micros(leftSpan.endTime)); |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| return child; | ||
| function errorMessage(err: unknown): string { |
There was a problem hiding this comment.
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.
041f4b7 to
913615d
Compare
AmaadMartin
left a comment
There was a problem hiding this comment.
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).
CI:
|
|
Pushed The
|
| 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.0added tocoredevDependencies, matching the^2.1.0the other OTel v2 packages use.package-lock.jsonsynced (one line).errorMessagedeleted in favour offormatError, so span error messages unwrapcausechains 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
left a comment
There was a problem hiding this comment.
Re-review at c912f3b. All three earlier findings are fixed and verified in the source at this head:
- 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. @opentelemetry/context-async-hooksis declared incore/package.jsondevDependencies.formatErrorreplaces the localerrorMessagehelper (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.
|
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.
c912f3b to
9952259
Compare
|
Rebased onto Four commits landed on #657 (
Re-verified on the rebased head:
CloudCode — session |
AmaadMartin
left a comment
There was a problem hiding this comment.
Re-review at 9952259. All three earlier findings are fixed and verified in the source at this head:
- 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. @opentelemetry/context-async-hooksis declared incore/package.jsondevDependencies.formatErrorreplaces 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.
|
Both are green now on All 8 checks on the head commit pass, so the gate is satisfied. |
AmaadMartin
left a comment
There was a problem hiding this comment.
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.
…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.
…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.
…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.
…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.
* 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.
…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.
…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.
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 enclosinginvoke_agentspan 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:
invoke_workflow <name>Workflow.runImplexecute_node <name>executeChildNode, covering the whole retry loopexecute_node_attempt <name>Dynamic
ctx.runNode()children route through the same function so they are covered too, and a nested workflow readsexecute_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 undergen_ai.*. A failed node also sets span statusERROR.Two parenting subtleties, both easy to get wrong in a way that produces a plausible-looking but incorrect trace:
runLoop, so the span is started synchronously inexecuteChildNodebefore the firstawait— 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 viactx.channelon a different async stack.executeChildNodereturns a promise, not an async generator, so therunAsyncGeneratorWithOtelContextbinding used byBaseAgent.runAsyncdoes not apply.context.withis used instead.Testing Plan
Unit Tests:
New
core/test/workflow/telemetry_test.ts(5 tests) registers a realAsyncLocalStorageContextManager— 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.withbinding fromexecuteChildNodefails 4 of the 5 tests. Verified independently of the authoring pass.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
Additional context
Known gaps, called out rather than hidden:
gen_ai.operation.namevaluesinvoke_workflow/execute_nodeare 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.waitingstatus is interrupt-only.executeChildNodecannot see thewaitForOutputjoin barrier — the workflow decides that after the runner returns — so a node parked on a fan-in barrier reportscompleted. Documented in code, untested.startNodeTaskshort-circuits beforeexecuteChildNode, 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-hooksis used only by the new test and is currently a transitive dep of@opentelemetry/sdk-trace-node, not declared incore/package.json.