Skip to content

feat(core): add agent and run error hooks and finalize aborted runs - #1156

Open
ZaidLT wants to merge 2 commits into
google:mainfrom
ZaidLT:feat/plugin-error-hooks-1132
Open

ZaidLT wants to merge 2 commits into
google:mainfrom
ZaidLT:feat/plugin-error-hooks-1132

Conversation

@ZaidLT

@ZaidLT ZaidLT commented Oct 26, 2026 •

Copy link
Copy Markdown

Closes #1132.

Plugins retaining per-invocation state can miss completion notifications on abort or early iterator close, and lack agent/run error notifications. This adds onAgentErrorCallback and onRunErrorCallback, and runs afterRunCallback when a started runAsync or runLive invocation completes, aborts, or closes early without an execution error, following ADK Python's runner.

  • Recognized cancellation still propagates to the caller, but triggers completion instead of error hooks. It requires an aborted invocation signal and an exact signal reason, AbortError or InvocationAbortedError, including wrapped causes. Unrelated failures remain errors.
  • Error hooks notify every plugin in registration order. Their failures are logged, and the original thrown value propagates unchanged, including non-Error values.
  • Completion is skipped after an execution error. If afterRunCallback throws, it is reported as a run error; during caller close, it is logged without replacing the close.
  • The lifecycle starts at beforeRunCallback; session setup stays outside. Existing abort-history sealing and resource cleanup are preserved.

This supplies the core error notifications needed by @adriantr's #1134, whose existing error methods can become overrides.

Behavior note for release: afterRunCallback now runs after abort and early iterator close. Plugins must not interpret it as proof that a run succeeded.

Tests: the real-runner reproduction had 11 failures and 3 passing controls on unchanged main; all 14 pass with this fix. The revised tests cover 93 runner lifecycle cases, 3 plugin API/dispatch cases and 35 shared error-helper cases, plus extended abort-history and live-connection regressions. The full local suite, explicitly including the integrations workspace, reports 6,581 passed, 55 skipped. Focused and surrounding suites: 387 passed; compiled-package smoke: 16 scenarios passed. Coverage thresholds, build, types, lint, format, docs, license and secrets checks passed. Platform CI remains pending.

npx vitest run --project unit:core core/test/runner/runner_plugin_lifecycle_test.ts

@Varun-S10

Copy link
Copy Markdown
Contributor

Hi @ZaidLT, thank you for raising the PR to fix the issue. We have reviewed the code changes and it does resolve the issue. I will escalate the PR to the team to review, thank you for your patience and understanding.
Hi @kalenkevich , could you please take a look?

@Varun-S10 Varun-S10 self-assigned this Oct 27, 2026
@Varun-S10 Varun-S10 added the needs review [Status] The PR/issue is awaiting review from the maintainer label Oct 27, 2026

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

Thanks for this, it's a careful port of adk-python's _exec_with_plugin / _handle_agent_error_callback. Dispatch order, the closingEarly handling when afterRunCallback throws, and sealing the aborted invocation before afterRun all match upstream. The runner, plugin, abort, and live suites pass locally.

The one I'd like resolved, or explicitly deferred, before merge is the abort-as-thrown-error parity gap in runWithPluginLifecycle. The extraction in runner.ts is a recommendation, and the rest are nits.

Also, since afterRunCallback now fires on abort and early close (matching Python), plugins that treated it as "run succeeded" will see a behavior change. Please mention it in the release notes. A follow-up could pass an outcome/reason to afterRunCallback, because plugins currently can't tell an early close from a normal finish. Implementing the two new hooks in LoggingPlugin would also be a nice follow-up.

Comment thread core/src/runner/runner.ts
try {
yield* execute.call(this);
completed = true;
} catch (error) {

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.

Parity gap: Python treats a CancelledError raised while ctx.is_aborted (or on caller close) as an early close, so after_run still runs and no run error is reported. Here, if a custom agent, tool or node throws (e.g. an AbortError / signal.reason) after abortSignal fires, it's classified as a failure: onAgentErrorCallback + onRunErrorCallback fire and afterRunCallback is skipped. Model errors avoid this because runAndHandleError turns them into events, but other paths don't.

Could we treat a throw while invocationContext.abortSignal?.aborted as an abort/close here (and correspondingly in BaseAgent), or at least document the decision and add a test for it?

Comment thread core/src/runner/runner.ts Outdated
let failed = false;
let completed = false;
try {
yield* execute.call(this);

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.

Most of the runner.ts diff is re-indentation from inlining the bodies as async function* and rebinding with execute.call(this) / this: Runner. Extracting the two bodies into private generator methods (e.g. runAsyncWithPlugins / runLiveWithPlugins) and passing a thunk would shrink the diff, drop the this rebinding, and make the lifecycle wrapper easier to read.

Comment thread core/src/runner/runner.ts Outdated
error:
error instanceof Error
? error
: new Error('Run execution threw a non-Error value.', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the non-Error wrapping is duplicated in BaseAgent.handleAgentErrorCallback, and each level creates its own new Error(..., {cause}), so the agent-level and run-level notifications for the same thrown value aren't identity-equal. A small shared helper (ideally one that wraps once) would tidy this up.

'error',
] as const;

describe.each(['runAsync', 'runLive'] as const)('issue #1132: %s', (mode) => {

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.

This reproduction block mostly overlaps the plugin lifecycle edge cases suite below. Could we keep one of them to keep the file smaller? I'd also avoid issue numbers in test names and describe the behavior instead.

}
}
const h = await setupLifecycle('runAsync', new FailingNode({name: 'node'}));
await expect(collect(h.stream)).rejects.toThrow();

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: please assert the specific error here (as the other cases do) rather than a bare toThrow(). Also check that onRunErrorCallback receives it.

expect(error?.cause).toBe(originalException);
});

afterEach(() => vi.restoreAllMocks());

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: move this afterEach up next to the existing beforeEach at the top of the describe. Also, a few for loops in the new tests have no braces, while the surrounding code uses them.

@ZaidLT

ZaidLT commented Oct 28, 2026

Copy link
Copy Markdown
Author

Thanks @kalenkevich for the careful review, and @Varun-S10 for the triage.

Cancellation: Runner and BaseAgent now recognize cancellation only when the invocation signal is aborted and the thrown value matches its exact reason, is an AbortError or InvocationAbortedError, or wraps one through cause. Completion runs once, neither execution-error hook fires, and cancellation still propagates to the caller. Unrelated failures remain failures.

Deliberately, any AbortError qualifies once the invocation signal is aborted, including one from another controller. Cause inspection includes cycle detection and a 32-level limit.

The revision also extracts the runner methods, shares error normalization without a cache, consolidates tests, and addresses the assertion and style comments.

The behavior note explains that afterRunCallback fires on abort and early close, so it does not imply success. Since release-please generates the changelog, would you like that note in the squash-commit message too? The outcome/reason parameter and LoggingPlugin hooks remain follow-ups.

Validation: 34 cancellation regressions fail on the previous revision and pass with this fix. Full local suite: 6,581 passed, 55 skipped, with coverage thresholds, build, types, lint, formatting and docs checks passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs review [Status] The PR/issue is awaiting review from the maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(core): add onAgentErrorCallback / onRunErrorCallback plugin hooks, and run afterRunCallback on aborted runs

3 participants