feat(plugins): add before/after node callbacks - #659
Conversation
`BasePlugin` had hooks for runs, agents, models and tools, but nothing for workflow nodes -- so a plugin could observe every layer of an agent's execution except the graph it was running inside. A node-level cache, a per-node audit trail, or stubbing one node out in a test all had nowhere to attach. `beforeNodeCallback` runs before a node's body and can short-circuit it: returning anything other than `undefined` skips execution and uses that value as the node's output, which is how a plugin implements a cache or a stub. `afterNodeCallback` runs on success and can replace the output. Both follow the early-exit convention the other plugin hooks use -- the first plugin to return a non-`undefined` value wins. They hang off `executeChildNode`, so they cover graph nodes, dynamic `ctx.runNode()` children, and a nested workflow (which is itself a node) alike. The before hook fires outside the retry loop, so a node that retries reports one before/after pair rather than one per attempt. Two deliberate omissions: a node whose body is skipped fires no after callback (nothing ran to report on), and a node that throws fires none either -- failures are reported as a NodeErrorEvent rather than through this pair. The plugin types import `BaseNode`/`NodeContext` as type-only imports: the runtime chain already runs workflow -> InvocationContext -> PluginManager, so a value import would close that cycle.
AmaadMartin
left a comment
There was a problem hiding this comment.
Held on CI, not on the code. The diff reads clean: both hooks are behavior-neutral when no plugin is registered (runCallbacks returns undefined on an empty set), the eslint-disable no-unused-vars matches every other no-op callback in base_plugin.ts, and the type-only imports correctly avoid the runtime cycle.
CI is red on tests/integration/workflows/parallel_worker/parallel_worker_test.ts (Cannot read properties of undefined (reading 'topic') in the sample agent). This is a live-model integration test; the PR does not touch it and cannot change its path with no plugin set. The macOS and Windows jobs were fail-fast cancellations, not failures. I re-ran the failed jobs; they are in progress.
| node: BaseNode; | ||
| nodeContext: NodeContext; | ||
| input: unknown; | ||
| }): Promise<unknown | undefined> { |
There was a problem hiding this comment.
Nit, optional. Promise<unknown | undefined> is the same type as Promise<unknown> — unknown already includes undefined, so the | undefined is redundant here. The sibling callbacks differ: their type is Content | undefined, where the union matters. Same at line 279 (afterNodeCallback). Drop it, or keep it for symmetry.
The two hooks were awaited unconditionally, so registering no plugin at all still inserted a microtask boundary into `executeChildNode` before every node body and after every success. That is not observable in a sequential workflow, but parallel workers share a session: the extra tick let every sibling's output land in the session before the workers built their model requests, so each worker's prompt gained a "For context:" block it did not have before. CI caught it as `parallel_worker` failing with a property read on `undefined` — the sample harness matches recorded responses by a fingerprint over request contents, the contents had shifted, and the agent that missed produced nothing. Gate both calls on a plugin actually being registered. `?.` alone does not do it: `await undefined` still yields a tick, so the `await` itself has to sit inside the branch.
AmaadMartin
left a comment
There was a problem hiding this comment.
I re-checked the code at 8175051. It is clean. The two new node hooks follow the existing plugin-callback pattern, the pluginManager?.hasPlugins gate keeps the awaits off the scheduling path when no plugin runs, and the before hook sits outside the retry loop as intended. No any, no instanceof; the two new eslint-disable lines match every other callback stub in base_plugin.ts.
I cannot approve yet: run-tests on ubuntu, macOS, and Windows is still pending. My only open item is the optional nit on the redundant | undefined, which does not block. I will approve once the three test jobs pass.
`unknown` already admits `undefined`, so `Promise<unknown | undefined>` was just `Promise<unknown>` spelled longer. The union carries weight on the sibling callbacks, whose `Content | undefined` genuinely widens `Content`; here it only suggested a distinction the type system does not make. This also lines the two hooks up with `runCallbacks`, which has returned plain `Promise<unknown>` all along. The TSDoc still says what a returned value means and that `undefined` opts out, which is where that contract belongs.
Same redundancy the node hooks had, in two spots that predate them. `string | unknown | undefined` and `unknown | undefined` both reduce to `unknown`, so the extra members only implied a precision the annotations never had. `functionResponseError` really is `unknown`: it takes `e.message` on one branch and the raw caught `e` on the other, and `string` named just the first of those. `driver` holds whichever MikroORM driver class the URI scheme selected. Neither is left undefined by accident — `unknown` already admits it, and both are guarded before use.
|
Thanks for the review — the nit is fixed, but I have to correct the CI call: the red run was mine, not a live-model flake. I bisected it. The mechanism is the one thing we both assumed away — that the hooks are inert with no plugin registered: const skipOutput = await pluginManager?.runBeforeNodeCallback({...});That The harness matches recorded responses by a fingerprint over request contents, so two of the three workers missed, produced nothing, and Fixed in 8175051 by gating both hooks on a plugin actually being registered. Worth noting if (pluginManager?.hasPlugins) {
const skipOutput = await pluginManager.runBeforeNodeCallback({...});
...
}Re-recording the fixture would also have gone green, but it would have baked in a prompt change that an unregistered plugin has no business causing. The hooks are now genuinely zero-impact when unused, which is what the PR claimed in the first place. On the nit: dropped CI is green on all three platforms. The one Windows red along the way was |
AmaadMartin
left a comment
There was a problem hiding this comment.
Approve. My earlier nit is fixed: both node hooks now return Promise<unknown> (base_plugin.ts:258,279), and the author swept the same redundant unknown | undefined in functions.ts and operations.ts.
I verified the source at head 4c87b06. The two eslint-disable no-unused-vars lines match the pattern every other default hook in base_plugin.ts uses, so they are not new suppressions. No any, as any, @ts-expect-error, or instanceof in the diff. No new named public type needs an export. The before/after calls sit behind hasPlugins, so an empty PluginManager adds no await.
CI is green on ubuntu, macOS, and Windows run-tests.
…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.
* feat(plugins): add before/after node callbacks `BasePlugin` had hooks for runs, agents, models and tools, but nothing for workflow nodes -- so a plugin could observe every layer of an agent's execution except the graph it was running inside. A node-level cache, a per-node audit trail, or stubbing one node out in a test all had nowhere to attach. `beforeNodeCallback` runs before a node's body and can short-circuit it: returning anything other than `undefined` skips execution and uses that value as the node's output, which is how a plugin implements a cache or a stub. `afterNodeCallback` runs on success and can replace the output. Both follow the early-exit convention the other plugin hooks use -- the first plugin to return a non-`undefined` value wins. They hang off `executeChildNode`, so they cover graph nodes, dynamic `ctx.runNode()` children, and a nested workflow (which is itself a node) alike. The before hook fires outside the retry loop, so a node that retries reports one before/after pair rather than one per attempt. Two deliberate omissions: a node whose body is skipped fires no after callback (nothing ran to report on), and a node that throws fires none either -- failures are reported as a NodeErrorEvent rather than through this pair. The plugin types import `BaseNode`/`NodeContext` as type-only imports: the runtime chain already runs workflow -> InvocationContext -> PluginManager, so a value import would close that cycle. * fix(plugins): keep the node hooks off the scheduling path when unused The two hooks were awaited unconditionally, so registering no plugin at all still inserted a microtask boundary into `executeChildNode` before every node body and after every success. That is not observable in a sequential workflow, but parallel workers share a session: the extra tick let every sibling's output land in the session before the workers built their model requests, so each worker's prompt gained a "For context:" block it did not have before. CI caught it as `parallel_worker` failing with a property read on `undefined` — the sample harness matches recorded responses by a fingerprint over request contents, the contents had shifted, and the agent that missed produced nothing. Gate both calls on a plugin actually being registered. `?.` alone does not do it: `await undefined` still yields a tick, so the `await` itself has to sit inside the branch. * refactor(plugins): drop the redundant undefined from the node hook types `unknown` already admits `undefined`, so `Promise<unknown | undefined>` was just `Promise<unknown>` spelled longer. The union carries weight on the sibling callbacks, whose `Content | undefined` genuinely widens `Content`; here it only suggested a distinction the type system does not make. This also lines the two hooks up with `runCallbacks`, which has returned plain `Promise<unknown>` all along. The TSDoc still says what a returned value means and that `undefined` opts out, which is where that contract belongs. * refactor(core): collapse the last two unknown unions that widen nothing Same redundancy the node hooks had, in two spots that predate them. `string | unknown | undefined` and `unknown | undefined` both reduce to `unknown`, so the extra members only implied a precision the annotations never had. `functionResponseError` really is `unknown`: it takes `e.message` on one branch and the raw caught `e` on the other, and `string` named just the first of those. `driver` holds whichever MikroORM driver class the URI scheme selected. Neither is left undefined by accident — `unknown` already admits it, and both are guarded before use.
…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:
BasePluginhas hooks for runs, agents, models and tools — but nothing for workflow nodes. A plugin could observe every layer of an agent's execution except the graph it was running inside. A node-level cache, a per-node audit trail, or stubbing one node out in a test all had nowhere to attach.Solution:
Two hooks, following the early-exit convention the other plugin callbacks already use (first plugin to return a non-
undefinedvalue wins):beforeNodeCallback({node, nodeContext, input})— returning anything other thanundefinedskips the node's body and uses that value as its output. This is how a plugin implements a node cache or a stub.afterNodeCallback({node, nodeContext, output})— runs on success; a returned value replaces the node's output.They hang off
executeChildNode, so they cover graph nodes, dynamicctx.runNode()children, and a nestedWorkflow(itself a node) alike. The before hook sits outside the retry loop, so a node that retries reports one before/after pair rather than one per attempt.Two deliberate omissions:
NodeErrorEvent(feat(workflow): record a failed node as a NodeErrorEvent #657) rather than through this pair.The plugin types import
BaseNode/NodeContextas type-only imports. The runtime chain already runs workflow →InvocationContext→PluginManager, so a value import would close that cycle.Testing Plan
Unit Tests:
New
core/test/workflow/node_plugin_hooks_test.ts, 6 tests: both hooks fire with the right node/input/output; a returned before-value skips the body (asserted by the body setting a flag that stays false) and suppresses the after hook; a returned after-value replaces the output; hooks fire for every node of a graph and for the workflow node itself, in the expected order (wf, a, bbefore /a, b, wfafter); a throwing node fires no after hook; and aPluginManagerwith no plugins leaves execution untouched.tsc --noEmit: 0 errors repo-wide. eslint and prettier clean on all 4 files.Manual End-to-End (E2E) Tests:
Not run. The hooks are exercised through the real
PluginManagerin unit tests; what is unverified is a plugin registered on a liveRunneragainst a real model.Checklist
Additional context
Only the TSDoc on the two new public callbacks is present; per repo-owner instruction the change carries no other code comments.
Independent of the other workflow PRs in flight (#649, #653, #654, #656, #657); it touches
node_runner.ts, which #653 and #657 also touch, so expect a small conflict for whichever lands last.