Skip to content

feat: add plugin framework - #1352

Draft
mykytanetipa wants to merge 9 commits into
mainfrom
feat/a2a-plugins
Draft

mykytanetipa wants to merge 9 commits into
mainfrom
feat/a2a-plugins

Conversation

@mykytanetipa

Copy link
Copy Markdown
Collaborator

Adds plugins to the Python SDK. A plugin is one object that hooks into both the client and DefaultRequestHandlerV2, so extension points like telemetry, auth or tenant propagation need one registration call instead of per-SDK interceptor wiring.

Example:

from a2a import plugins

class TaskAudit(plugins.TaskObserverPlugin):
    async def on_task_event(self, info: plugins.TaskEventInfo) -> None:
        print(info.task_id, info.from_state, '->', info.to_state)

plugins.set_global(plugins.sequential(TaskAudit()))

Interfaces

A plugin implements any of:

  • ServerPlugin (before_agent_run, after_agent_run)
  • ClientPlugin (before_agent_call, after_agent_call)
  • ExecutionPlugin (before_execution / on_execution_event / after_execution)
  • TaskObserverPlugin (on_task_event)
  • PushPlugin (before_push_send, on_push_sent)

SequentialPluginManager runs before-hooks in registration order and after-hooks in reverse; a raising before-hook rejects the call, while after-hook and observer failures are logged and swallowed.

Notes

  • No behavior change when no plugin is registered.
  • LegacyRequestHandler is not wired for plugins.

Tested

  • Registered plugins that implement every hook: log each call, inject headers.

…Manager

Implements the plugin model from go/a2a-plugins for the Python SDK: a
plugin is one object that implements any of ServerPlugin, ClientPlugin,
ExecutionPlugin, TaskObserverPlugin and PushPlugin, so cross-cutting
concerns such as telemetry or auth plug in with one registration call
instead of per-SDK interceptor wiring.

SequentialPluginManager runs before-hooks in registration order and
after-hooks in reverse, unwinds plugins that already ran when a
before-hook rejects a call, and logs and swallows after-hook and
observer failures so a buggy plugin cannot break a request. Its has_*
properties let call sites skip all plugin work when nothing is
registered.
DefaultRequestHandlerV2 takes a plugins manager (defaulting to the global
one) and runs:

- before/after_agent_run around every public on_* method, with a call per
  stream event and a final call on completion, error or disconnect;
- before/after_execution and on_execution_event around the agent's
  execute(), via a wrapping executor and event queue, so plugins see the
  detached execution and the time of each agent event;
- on_task_event after every successful task write. TaskManager tracks the
  persisted state itself because it mutates the cached task in place
  before saving, which made the store's prev argument useless for
  from_state;
- before_push_send/on_push_sent per webhook delivery, scoped to the V2
  path through a contextvar so the shared push sender stays hook-free for
  LegacyRequestHandler.

Transports record the protocol in ServerCallContext.state['a2a.transport']
and gRPC exposes invocation metadata as state['headers'], so plugins can
read trace and tenant headers on every transport. With no plugins
registered nothing is wrapped and behavior is unchanged.
Every client call now runs before_agent_call before the interceptors and
after_agent_call after them, with one call per stream event, a final call
when the stream ends, and the error when the transport fails. The hooks
get the RPC name used on the server side and the protocol binding and
URL ClientFactory selected, so a plugin can tag calls and add headers
such as trace context.

send_message and subscribe now close the inner stream with aclosing() so
an early break by the caller releases the transport stream and runs the
final hook right away instead of at garbage collection.
Runs client and server hooks against in-process servers on every
transport, checks that a header added by a client plugin reaches the
server, that v0.3 JSON-RPC requests reach the V2 hooks, and that state
set in before_execution propagates to calls the agent makes to a second
agent.
Replace the separate _post_with_plugins method with a single delivery
path in _dispatch_notification. Header building moves to _build_headers
and the on_push_sent call to _report, which both paths share. Without a
push plugin manager the only added cost per delivery is constructing a
PushRequest and reading a monotonic clock; behavior is unchanged.
The plugin hooks do not depend on these additions; they were added ahead
of a telemetry plugin that is not part of this change and can be added
back compatibly when it lands:

- ClientRequest.transport/url and the ClientFactory wiring that filled
  them;
- ServerCallContext.state['a2a.transport'] written by the JSON-RPC, REST,
  gRPC and v0.3 dispatchers;
- gRPC invocation metadata copied into state['headers']. Plugins can
  still read it through state['grpc_context'].

The dispatchers, v0.3 adapters, gRPC handler, ClientFactory and
ServerCallContext are back to their state on main.
Replace the lookup table that mapped client method names to A2A RPC
names with an rpc_method argument passed by each BaseClient method, the
same way DefaultRequestHandlerV2 names its methods in
@server_plugin_hooks. Interceptors keep receiving the existing method
strings; rpc_method is keyword-only and defaults to method, so callers of
the private helpers are unaffected.
BasePushNotificationSender now takes a plugins argument (global manager
by default) and runs push hooks for every delivery, whichever request
handler uses it. This replaces the contextvar that let only
DefaultRequestHandlerV2 enable push hooks: not wiring the legacy handler
for plugins does not require shared components to block them.

ActiveTask is back to its state on main, and a2a.plugins._runtime is
removed.
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/client/base_client.py 95.56% 96.67% 🟢 +1.11%
src/a2a/server/agent_execution/active_task_registry.py 97.37% 97.40% 🟢 +0.03%
src/a2a/server/request_handlers/default_request_handler_v2.py 87.46% 88.49% 🟢 +1.03%
src/a2a/server/tasks/base_push_notification_sender.py 95.38% 94.95% 🔴 -0.44%
src/a2a/server/tasks/task_manager.py 98.86% 98.94% 🟢 +0.08%
src/a2a/plugins/base.py (new) — 100.00% —
src/a2a/plugins/manager.py (new) — 100.00% —
src/a2a/plugins/types.py (new) — 100.00% —
src/a2a/server/agent_execution/plugin_agent_executor.py (new) — 93.75% —
src/a2a/server/request_handlers/_plugin_hooks.py (new) — 94.83% —
Total 93.14% 93.36% 🟢 +0.23%

Generated by coverage-comment.yml

This branch has not been deployed

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

1 participant