7.9 KiB
10. A2A Server — Conductor Workflows as A2A Agents (Direction A)
Status: implemented. The other half of 08-conductor-implications.md §8.3: Conductor as an A2A server. Any A2A client (Google ADK, CrewAI, LangGraph, another Conductor) can discover and invoke a Conductor workflow as an A2A agent. This is where Conductor's durability is native — a workflow execution is a durable, resumable, observable A2A task (see 09-durable-a2a.md).
10.1 Model — one A2A agent per workflow
Each exposed workflow is its own focused A2A agent. The URL path is the router — no skill-selection convention to invent, and each Agent Card describes exactly one capability.
GET {basePath}/{workflow}/.well-known/agent-card.json (+ /agent.json, v0.2.x) → discovery
POST {basePath}/{workflow} → JSON-RPC: message/send | message/stream | tasks/get | tasks/cancel
GET {basePath} → convenience listing of exposed agents
basePath defaults to /a2a (conductor.a2a.server.basePath). Lives in the ai module
(org.conductoross.conductor.ai.a2a.server), component-scanned by the server, gated by
conductor.a2a.server.enabled=true (independent of the LLM/AI integration flag — the server side
only needs core WorkflowService/MetadataService).
sequenceDiagram
autonumber
participant Client as A2A client
participant R as A2AServerResource
participant A as A2AWorkflowAgent
participant E as Conductor engine
Client->>R: POST {basePath}/{wf} message/send
R->>A: sendMessage
A->>E: startWorkflow (idempotencyKey = A2A messageId)
E-->>A: workflowId
A-->>Client: Task { id = workflowId, state: working }
alt message/stream (SSE)
R->>A: streamMessage (dedicated daemon pool)
A-->>Client: task → status-update → artifact-update → final
else tasks/get polling
Client->>R: tasks/get
R->>A: getExecutionStatus
A-->>Client: Task { state, artifacts }
end
note over Client,E: blocked on HUMAN/WAIT → input-required;<br/>follow-up message/send (carrying the task id) resumes the same execution
10.2 Exposure — opt-in, never everything
A workflow is exposed iff either:
- its name is in
conductor.a2a.server.exposed-workflows, or - its
WorkflowDef.metadatacarriesa2a.enabled: true.
Neither set ⇒ nothing is exposed. Optional WorkflowDef.metadata."a2a.tags" populates the skill
tags. (A2AWorkflowAgent.isExposed.)
10.3 Mapping
message/send → start workflow. Input = the first DataPart.data (structured case) merged
with {_a2a_text, _a2a_message_id, _a2a_context_id} so the workflow can read the raw message;
correlationId = contextId. Returns an A2A Task { id=workflowId, contextId=correlationId, status }.
WorkflowStatus → A2A TaskState (the reverse of the client mapping in §8.4):
| Conductor | A2A | Notes |
|---|---|---|
RUNNING, blocked on HUMAN/WAIT |
input-required |
non-terminal HUMAN/WAIT task present; a follow-up message/send carrying this task's id resumes the execution (see §10.4) |
| RUNNING (not blocked) | working |
|
| COMPLETED | completed |
workflow.getOutput() → an Artifact (a DataPart) |
| FAILED / TIMED_OUT | failed |
reasonForIncompletion in the status message |
| TERMINATED | canceled |
|
| PAUSED | working |
admin pause has no clean A2A analog |
tasks/get → getExecutionStatus(id, true) → map as above. tasks/cancel →
terminateWorkflow(id) → return the canceled task. An agent only manages its own workflow's
executions (the task's workflow name must match the path agent).
WorkflowDef → Agent Card: one skill (id/name = workflow name, description from the def,
tags from metadata, input/output modes from properties); url = {publicUrl|request-derived}{basePath}/{name};
version = def version; protocolVersion 0.3.0; capabilities.streaming=true (message/stream
via SSE), capabilities.pushNotifications=false (push-config endpoints are a follow-up).
10.4 Durability — why this is the differentiator
A Conductor workflow execution is already crash-safe, resumable, retryable, and observable. By mapping an A2A task onto a workflow execution, the A2A agent inherits all of that for free — no host loop to lose state. Two concrete properties:
- Crash-safe agent tasks. If Conductor restarts mid-execution, the A2A task survives and
tasks/getkeeps returning correct state — the in-memory reference hosts (the purchasing-concierge demo) lose the task on crash. - Effectively-once start (idempotent
message/send). The inbound A2AmessageIdis used as the workflowStartWorkflowRequest.idempotencyKey(namespaced with the workflow name) withidempotencyStrategy = RETURN_EXISTING, so a client's retriedmessage/sendreturns the existing execution instead of starting a duplicate — the server-side mirror of the client's deterministic-messageIdwork (§09 P3).tasks/getis a read;tasks/cancelno-ops once terminal. So the whole surface is safe under at-least-once delivery. - Durable multi-turn (input-required → resume). When the workflow blocks on a
HUMAN/WAITtask the agent reportsinput-required. A follow-upmessage/sendcarrying that task's id (the workflow id) completes the pending task with the message content and resumes the same execution — no duplicate workflow is started; an already-terminal or non-blocked workflow just returns its current state.A2AWorkflowAgent.resume()viaTaskService.updateTask. - Observability. Server requests and resumes are counted (
a2a_server_requests{method},a2a_server_resumes) through the sharedMonitorsregistry (counter emitted only for recognized methods — never the client-controlledmethodstring), and the dispatch path sets MDC correlation keys (a2aAgent/a2aMethod/a2aMessageId/a2aContextId/a2aRemoteTaskId) for greppable logs.
10.5 Security
OSS Conductor REST is open by default; the A2A server matches that — open by default, front it
with a gateway/firewall/mTLS to control access. Inbound authentication (API keys, OAuth/OIDC,
mTLS, per-skill scopes, signed Agent Cards) is an enterprise concern, not shipped in OSS (A2A
puts identity in HTTP headers — see 05-security.md). The client→remote direction
still supports per-call auth headers (e.g. Bearer tokens) in OSS.
10.6 Configuration
conductor.a2a.server.enabled=true
conductor.a2a.server.basePath=/a2a
conductor.a2a.server.exposed-workflows=order_pizza,book_flight
conductor.a2a.server.public-url=https://conductor.example.com # optional; else request-derived
conductor.a2a.server.provider-organization=Acme
Or per-workflow opt-in in the definition: "metadata": { "a2a.enabled": true, "a2a.tags": [...] }
(see ai/examples/12-a2a-server-workflow.json).
10.7 Code & tests
ai/.../a2a/server/:A2AServerProperties,A2AWorkflowAgent(service),A2AServerResource(@RestController),A2AServerException;config/A2AServerEnabledCondition.- Tests:
A2AWorkflowAgentTest(exposure, card, send-with-idempotency-key, multi-turn resume, status mapping incl. blocked→input-required, wrong-agent isolation, cancel),A2AServerResourceTest(JSON-RPC dispatch, error codes, card serving), andA2ALoopbackTest(Conductor calling Conductor over A2A end-to-end against a stateful fake engine).
10.8 Out of scope (v1, follow-ups)
- Push-notification config endpoints (
tasks/pushNotificationConfig/*). Server-side streaming (message/streamSSE) now ships — it is listed in the methods table in §10.1/§10.3. - Per-workflow OAuth scopes; Agent Card JWS signing.
- Full client↔server loopback e2e through the real engine (decider/sweeper/persistence) in
test-harness— the mocked-engine loopback (A2ALoopbackTest) ships now.