Skip to content

feat(plugins): add before/after node callbacks - #659

Merged
kalenkevich merged 4 commits into
mainfrom
feat/workflow-node-plugin-hooks
Aug 12, 2026
Merged

feat(plugins): add before/after node callbacks#659
kalenkevich merged 4 commits into
mainfrom
feat/workflow-node-plugin-hooks

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:

BasePlugin has 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-undefined value wins):

  • beforeNodeCallback({node, nodeContext, input}) — returning anything other than undefined skips 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, dynamic ctx.runNode() children, and a nested Workflow (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:

The plugin types import BaseNode/NodeContext as type-only imports. The runtime chain already runs workflow → InvocationContextPluginManager, so a value import would close that cycle.

Testing Plan

Unit Tests:

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

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, b before / a, b, wf after); a throwing node fires no after hook; and a PluginManager with no plugins leaves execution untouched.

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

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 PluginManager in unit tests; what is unverified is a plugin registered on a live Runner against a real model.

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

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.

`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.
@kalenkevich kalenkevich assigned kalenkevich and unassigned Varun-S10 Aug 12, 2026
@kalenkevich
kalenkevich marked this pull request as ready for review August 12, 2026 06:46

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

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.

Comment thread core/src/plugins/base_plugin.ts Outdated
node: BaseNode;
nodeContext: NodeContext;
input: unknown;
}): Promise<unknown | undefined> {

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

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

Copy link
Copy Markdown
Collaborator Author

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. parallel_worker passes 5/5 on the base commit 8611219 and fails 10/10 on 4a7a61c, in isolation, on a clean checkout. Deterministic, not flaky.

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 await ran unconditionally. With zero plugins it still resolves undefined, but it inserts a microtask boundary into executeChildNode before every node body. Invisible in a sequential workflow — but parallel workers share a session, and the extra tick let all three make_upper_case outputs land in the session before the explain_topic workers built their requests. Each worker's prompt gained a For context: block it did not have when the fixture was recorded:

recorded:  [..., find_related_topics ctx, "SQL"]
on my PR:  [..., find_related_topics ctx, "SQL", make_upper_case ctx x3]

The harness matches recorded responses by a fingerprint over request contents, so two of the three workers missed, produced nothing, and aggregate read .topic off undefined. That is what the TypeError was — the real error was the [sample-harness] No recorded model response ... line on stderr just above it.

Fixed in 8175051 by gating both hooks on a plugin actually being registered. Worth noting ?. alone does not do it — await undefined still yields a tick, so the await itself has to sit inside the branch:

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 | undefined from both callbacks in 0892346, and from their mirrored PluginManager methods, which had copied the same redundancy — they now line up with runCallbacks, which has returned plain Promise<unknown> all along. 4c87b06 does the same for the last two pre-existing cases in functions.ts and operations.ts.

CI is green on all three platforms. The one Windows red along the way was unsafe_local_code_executor timing out at 5s, which also fails on main at this PR's base commit (run 31570504892) — unrelated, and I am fixing it separately.

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

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.

@kalenkevich
kalenkevich merged commit e03bbad into main Aug 12, 2026
14 of 15 checks passed
@kalenkevich
kalenkevich deleted the feat/workflow-node-plugin-hooks branch August 12, 2026 08:02
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
* 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.
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