Integrate Agent Mesh with A2A
Add resumable task updates to an A2A Python application.
Use the Agent Mesh software development kit (SDK) to let clients resume Agent2Agent (A2A) task updates after a disconnect, including through another serving replica. Redis retains the updates and a current Task snapshot. Your application runs the agent and controls access.
For a runnable browser example, start with the A2A quickstart. This guide explains how to add the integration to an existing application.
Install the integration
You need Python 3.11 or later, Redis 8.6.2 or later with native producer idempotency (XADD IDMP) and JavaScript Object Notation (JSON) support, and the official A2A Python SDK 1.2.x.
pip install 'redis-iris[a2a]'
To use both integrations in one application, install redis-iris[a2a,fastapi-sse]. Their shared redis-py range is >=8.0.1,<9; separate Python environments are not required.
Use the quickstart's database setup for a local or Redis Cloud connection. Keep database credentials in application configuration.
Connect your request handler
The application factory in this example accepts your existing AgentExecutor, AgentCard, and Starlette authentication backend. The authentication backend must verify credentials and populate request.user. Set your card's streaming capability to True before constructing the application.
import os
from contextlib import asynccontextmanager
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from redis.asyncio import Redis
from redis_iris.agent_mesh.a2a import RedisTaskEventStream, RedisTaskStore
from redis_iris.agent_mesh.a2a.routes import (
create_resumable_rest_routes,
resumable_streams_extension,
)
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
def create_app(agent_executor, agent_card, auth_backend):
commands = Redis.from_url(
os.environ["REDIS_URL"], max_connections=16, socket_timeout=5
)
readers = Redis.from_url(
os.environ["REDIS_URL"], max_connections=64, socket_timeout=5
)
store = RedisTaskStore(commands, namespace="myapp:a2a")
event_stream = RedisTaskEventStream(readers, store=store)
handler = store.request_handler(
agent_executor, agent_card=agent_card, event_stream=event_stream
)
agent_card.capabilities.extensions.append(resumable_streams_extension())
@asynccontextmanager
async def lifespan(app):
async with commands, readers:
try:
yield
finally:
await handler.aclose()
return Starlette(
routes=[
*create_agent_card_routes(agent_card),
*create_jsonrpc_routes(handler, rpc_url="/"),
*create_resumable_rest_routes(
handler, agent_card=agent_card, store=store, event_stream=event_stream
),
],
middleware=[Middleware(AuthenticationMiddleware, backend=auth_backend)],
lifespan=lifespan,
)
Use the same Redis database, namespace, and ownership policy on every replica. Use primary reads for both snapshots and events. Your application owns credentials, Transport Layer Security (TLS), connection pools, timeouts, and shutdown.
Each waiting subscription occupies a Redis connection. A separate reader pool keeps blocked subscriptions from exhausting connections needed for writes and heartbeats. Set its socket timeout above the event stream's block_ms interval, which defaults to at most 4,000 milliseconds and adapts to a shorter client socket timeout.
RedisTaskStore commits the Task snapshot, version, and event together. The paired event stream reads that log; its publish() method does not append another copy. Keep writing through the official request handler and its task store.
Enforce ownership
By default, the store requires an authenticated user. Behind the SDK's Starlette routes, ownership uses the user's display_name. Ensure this value uniquely identifies a principal across your tenants.
For a different identity model, pass owner_resolver to RedisTaskStore. The callable receives the official ServerCallContext and must return a nonempty, stable owner derived from verified identity. Never derive it from request bodies, task IDs, or unverified headers.
The official handler and resumable routes check ownership before subscribing. The internal RedisTaskEventStream reader has no request context: do not expose it directly to callers without that check. Task IDs and event cursors grant no access.
Start and resume a subscription
Pass the handler's same agent_card to create_resumable_rest_routes(); its streaming capability must be enabled. The factory adds event cursors to the HTTP+JSON binding's POST /message:stream and GET or POST /tasks/{id}:subscribe routes. Other REST routes retain the official SDK's behavior.
To follow an existing active task, send an authenticated request to /tasks/<task-id>:subscribe with A2A-Version: 1.0. Without a cursor, the response starts with the current Task snapshot and follows new updates.
Task frames use server-sent events (SSE) and carry an id: containing the Task version. After applying a frame, save its ID. On reconnect, send that value in Last-Event-ID to receive retained updates strictly after it. The routes also accept the after query parameter.
For example, these are the request headers for a reconnect using your application's bearer authentication:
GET /tasks/<task-id>:subscribe HTTP/1.1
Accept: text/event-stream
A2A-Version: 1.0
Authorization: Bearer <application-access-token>
Last-Event-ID: <last-applied-task-version>
Use an SSE client that parses frames and handles reconnect status codes. A fetch-based browser client can supply both the version and authorization headers. Native EventSource cannot set those headers. To use it, you need an explicit fixed-version server policy and browser-compatible authentication, as demonstrated by the local quickstart. The route factory does not install that policy.
A missing version header is interpreted as A2A 0.3 and rejected by the resumable routes. Declaring resumable_streams_extension() in the card advertises support; it does not select the request version. The official A2A client does not send Last-Event-ID, so it receives standard snapshot-then-live subscriptions.
Message-only replies and error frames have no Task-version ID. They must not advance the saved cursor. Task versions are not raw Redis stream entry IDs.
The route factory's credential_expiry callback and max_connection_duration limit also bound the wait for the first frame. If credentials expire before the server sends response headers, the request returns HTTP 401; refresh credentials before reconnecting. If the maximum connection duration expires first, the request returns HTTP 503 with Retry-After: 1; wait one second, then reconnect with your saved cursor.
Handle replay and completion
| Condition | Behavior |
|---|---|
| Retained cursor | Replay updates after the cursor, then follow new updates. |
| Trimmed history or compact Task replacement | Return the current Task snapshot, then follow events after its version. Intermediate updates covered by the snapshot are not reproduced. |
| Final cursor already consumed | Return HTTP 204; stop reconnecting. |
| No cursor for a terminal task | Reject the subscription; use the normal task retrieval API. |
| Malformed, repeated, or future cursor | Return HTTP 400. |
| Missing or expired snapshot | A new request receives TaskNotFound (HTTP 404). After streaming headers, send a TaskNotFound error frame. |
An update that moves a Task to a terminal, input-required, or auth-required state ends the HTTP subscription. If the Task already requires input or authentication when you subscribe, the stream can remain open. Without a cursor, it sends the waiting Task snapshot and then waits. With a cursor matching the current version, it waits without sending a Task frame.
Act on the Task's state and provide the required input or credentials through your application's A2A interaction. Do not wait for the stream to close. Disconnecting a subscriber does not cancel execution.
For cancellation, use the official handler's cancellation path. A cancellation can supersede a stale version while the stored Task is nonterminal. A later write cannot revive a terminal Task. These guards protect Redis updates; they do not undo external effects.
Monitor executing agents
store.request_handler() applies store.heartbeat(executor, interval=5.0, alive_ttl=15.0) to track the running agent under a process lease. It checks stored terminal state and stops local execution when another replica cancels or finishes the Task. Set alive_ttl greater than interval.
A store-backed subscriber or get() marks a submitted or working Task failed with a producer_lost status message when its producer lease expires. Tasks waiting for input or authentication, and tasks without tracked heartbeats, are excluded. To resume such a Task in another process instead, see Hand off a task to another process.
The wrapper does not restart computation. Without it, an agent on another replica learns of cancellation at its next write. With A2A SDK 1.2.3, continued emission after that cancellation can also leave handler shutdown waiting indefinitely; the wrapper stops the agent first.
Hand off a task to another process
One process can start a Task and another process can finish it. Both write to the same Task and its ordered event log, so subscribers on any replica see one sequence of updates, whichever process produced them.
Before the heartbeat wrapper runs your executor, it claims the Task for its process. Each save carries that claim. After another process claims the Task, the store rejects saves from the previous process with ConcurrentTaskModificationError, so a process that stalls and later wakes up cannot overwrite newer work. That process also stops its executor at its next successful heartbeat.
The wrapper grants a claim when the Task is new, when no process runs it, when it waits for input or authentication, or when the process that ran it stopped sending heartbeats. It refuses a claim for a terminal or deleted Task, a Task the caller doesn't own, and a Task that a process sending heartbeats is running or has just claimed. After a refused claim, the executor doesn't run, the Task keeps its current state, and the message is not added to the Task's history. If your executor's first update on a resumed Task is a status or artifact update, and you don't limit history_length, check the returned Task's history to tell whether your message was accepted.
Continue a waiting task in another process
A Task that waits for input or authentication is not running anywhere. The next message can reach any replica. That replica's wrapper claims the Task and runs your executor with the stored Task as context.current_task. No extra configuration is required. While one replica is processing a reply, other replicas refuse a second reply to the same Task.
Take over a long-running task
By default, a reader that notices a lost heartbeat marks a submitted or working Task failed with producer_lost. This includes the task read that a resume request makes. To let another process resume the Task instead, create the store with takeover=True on every replica:
store = RedisTaskStore(commands, namespace="myapp:a2a", takeover=True)
To resume a Task, send it a message through the official request handler in the process that takes it over. The handler reloads the Task, the wrapper claims it, and your executor runs with the stored Task. A worker can make that call in-process, using a ServerCallContext that carries the Task owner's verified identity:
import uuid
from a2a.types import Message, Part, Role, SendMessageRequest
async def resume(handler, task_id, context_id, owner_context):
request = SendMessageRequest(
message=Message(
message_id=f"resume-{uuid.uuid4()}",
role=Role.ROLE_USER,
task_id=task_id,
context_id=context_id,
parts=[Part(text="resume")],
)
)
request.configuration.return_immediately = True
return await handler.on_message_send(request, owner_context)
Build owner_context in trusted worker code from the identity that owns the Task. Never build it from request input. The message becomes part of the Task's history.
Have your executor record its progress in the Task, for example as one artifact per completed step, and skip completed steps when context.current_task already contains them:
from a2a.server.agent_execution import AgentExecutor
class TripPlanner(AgentExecutor):
async def execute(self, context, event_queue):
task = context.current_task
done = {artifact.artifact_id for artifact in task.artifacts} if task else set()
if "flights" not in done:
... # book flights, then emit the "flights" artifact
if "hotels" not in done:
... # book hotels, then emit the "hotels" artifact
... # emit the completed status
async def cancel(self, context, event_queue):
... # emit the canceled status
The integration does not detect stopped processes or schedule resumption. Track the Tasks your workers run, for example in your job queue, and resume or cancel those whose process stopped. With takeover=True, a running Task that nobody resumes stays working until active_ttl expires, and its subscribers stay connected until then. A step that the first process performed but did not record runs again in the next process, so make external effects safe to repeat.
Configure retention and limits
Pass these options to RedisTaskStore:
| Option | Default | Purpose |
|---|---|---|
namespace |
iris:a2a |
Isolate applications and deployments. |
active_ttl |
604,800 seconds (7 days) | Active snapshot and event retention, refreshed by saves. |
terminal_ttl |
86,400 seconds (1 day) | Terminal snapshot and event retention. |
metadata_ttl |
86,400 seconds | Additional ownership and version retention after snapshot expiry. |
max_task_bytes |
1,048,576 | Serialized snapshot size limit. |
max_event_bytes |
1,048,576 | Serialized event size limit. |
max_events |
10,000 | Approximate retained log length. |
max_attempts |
5 | Maximum attempts under transaction contention. |
takeover |
False |
Keep a submitted or working Task claimable after its process stops sending heartbeats, instead of marking it failed. See Hand off a task to another process. |
Heartbeats do not extend Task retention. Set active_ttl longer than the longest interval without a saved update.
The byte limits count serialized protocol buffer bytes; stored JSON payloads use more space. Size the event limit and log length for your memory budget. An oversized payload raises A2ACapacityExceeded without changing the snapshot or log.
Owner metadata prevents another owner from reusing a Task ID until it expires. Deleting an owned Task removes its snapshot and events while retaining ownership metadata for metadata_ttl. After metadata expiry, reuse cannot be detected.
| Public API | Use it to |
|---|---|
RedisTaskStore |
Store owner-scoped snapshots and versioned updates through the official SDK. |
store.request_handler() |
Build the official handler with paired events and heartbeat tracking. |
store.event_stream() |
Build the matching event reader, with producer-loss recovery. |
RedisTaskEventStream |
Configure a reader explicitly with store=store and a separate client. |
store.heartbeat() |
Track execution liveness, claim Tasks for hand-off, and notice remote cancellation. |
create_resumable_rest_routes() |
Expose cursor-enabled REST streaming. |
resumable_streams_extension() |
Advertise cursor support in the agent card. |
Storage and internal reader calls can raise A2AStorageError, A2ACapacityExceeded, or A2AStreamExpired. Stale or conflicting writes raise the official SDK's ConcurrentTaskModificationError. Let the handler and routes translate these into protocol responses rather than exposing storage exceptions to clients.
Task listing scans an owner's Task index and suits modest inventories per owner. It is not a search index or a consistent snapshot during concurrent writes.
See the beta limits for deployment qualification. Redis durability remains application-owned; replay does not guarantee exactly-once external effects.