Skip to content

feat(agent-server): A2A protocol server-mode support (Agent Card + JSON-RPC/SSE) - #4590

Open
lukegalea wants to merge 2 commits into
OpenHands:mainfrom
lukegalea:feat/a2a-server
Open

feat(agent-server): A2A protocol server-mode support (Agent Card + JSON-RPC/SSE)#4590
lukegalea wants to merge 2 commits into
OpenHands:mainfrom
lukegalea:feat/a2a-server

Conversation

@lukegalea

@lukegalea lukegalea commented Aug 23, 2026

Copy link
Copy Markdown

HUMAN:

Adding A2A protocol server support so agent-server becomes a discoverable node in A2A meshes — implements #1060. The HUMAN note: I've wanted OpenHands agents callable from my own orchestration stack via A2A for months; this PR makes the agent-server speak the protocol end to end.


AGENT:

Why

agent-server could not be discovered or driven by external A2A (Agent2Agent, Linux Foundation a2a-spec) clients. This PR exposes it as a first-class A2A agent: Agent Card discovery plus the standard JSON-RPC task lifecycle, mapping A2A tasks 1:1 onto existing conversations. Closes #1060 (maintainer-invited community contribution).

Summary

  • GET /.well-known/agent-card.json — Agent Card v0.3 generated from server config + registered agent profiles
  • POST /api/a2a — JSON-RPC 2.0: message/send, message/stream (SSE via subscribe_to_events), tasks/get, tasks/cancel; get_agent_final_response surfaced as text artifact
  • Auth accepts X-Session-API-Key or Authorization: Bearer (same config.session_api_keys)
  • Zero new dependencies (minimal local pydantic models); happy to switch to a2a-sdk as an optional extra if maintainers prefer

Issue Number

#1060

How to Test

End-to-end against a real server (what I ran):

uv run openhands-agent-server  # with a profile registered + a session API key configured
# 1. discovery
curl -s http://localhost:8000/.well-known/agent-card.json | jq .name
# 2. send a task
curl -s -X POST http://localhost:8000/api/a2a \
  -H "Authorization: Bearer $SESSION_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"List the registered agent profiles."}],"messageId":"m1","kind":"message"}}}'
# 3. poll
curl -s -X POST http://localhost:8000/api/a2a -H "Authorization: Bearer $SESSION_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"<taskId from step 2>"}}'

Unit tests: uv run pytest tests/agent_server -q2053 passed, 13 deselected (stress suite excluded by default), including 18 new A2A tests (tests/agent_server/test_a2a_router.py): card shape, both auth headers + rejection, JSON-RPC errors (-32601/-32700/-32602), message/send happy path, task reuse, tasks/get, tasks/cancel, SSE content-type + event sequence. OpenAPI quality gate passes (97 allowlisted weak locations, unchanged from main). A runnable httpx example is at examples/02_remote_agent_server/17_a2a_agent_card.py.

Video/Screenshots

Not applicable (protocol-level backend feature, no UI); the How-to-Test commands + example script reproduce the full flow.

Design Doc

Added .pr/design.html with the object model, endpoint mapping table, and SSE event sequence, linked here:
https://htmlpreview.github.io/?https://github.com/lukegalea/software-agent-sdk/blob/feat/a2a-server/.pr/design.html

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • All changes additive: new router file + 12-line mount in api.py; no existing router/model touched, no REST contract impact.
  • taskId maps 1:1 to conversationId — no separate task store needed.
  • Open question from the issue thread: keep the zero-dependency pydantic models, or adopt a2a-sdk (possibly as an optional extra)? Implementer's preference is zero-dep for the core; will follow maintainer guidance.
  • Draft until maintainer feedback on the dependency question.

…C 2.0 endpoint)

Expose the agent-server as an A2A agent (Linux Foundation a2a-spec,
rev ~0.3, JSON-RPC transport), addressing OpenHands#1060.

- /.well-known/agent-card.json: AgentCard v0.3 discovery document (no auth)
- POST /api/a2a: JSON-RPC 2.0 methods message/send, message/stream (SSE),
  tasks/get, tasks/cancel; taskId maps to conversationId
- Auth accepts Authorization: Bearer or X-Session-API-Key
- Minimal local pydantic models; no a2a-sdk dependency, no new deps
- Tests, example script, additive api.py wiring only
@lukegalea lukegalea mentioned this pull request Aug 23, 2026
6 tasks
@neubig
neubig requested a review from all-hands-bot August 23, 2026 16:28

all-hands-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot 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.

🟡 Taste Rating: Acceptable - Good direction, but the streaming path has a real race with EventService subscription semantics.

[CRITICAL ISSUES]

  • [openhands-agent-server/openhands/agent_server/a2a_router.py, Line 683] Streaming correctness: message/stream subscribes before sending the user message and treats the initial current-state snapshot as terminal. See inline comment.
  • [openhands-agent-server/openhands/agent_server/a2a_router.py, Line 586] JSON-RPC correlation: helper-generated errors drop the request id. See inline comment.

[TESTING GAPS]

  • The message/stream test mocks subscribe_to_events but does not preserve the real initial-state push from EventService.subscribe_to_events. Add coverage that uses the real contract or a fake that enqueues the initial IDLE snapshot before run updates.
  • CI currently reports Validate PR description failing because the PR body does not keep the required HUMAN:, AGENT:, ## Why, and ## How to Test template sections.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    New unauthenticated discovery endpoint plus authenticated JSON-RPC/SSE protocol surface in agent-server. The architecture is isolated and dependency-free, but the stream endpoint can terminate before doing useful work and several error paths break JSON-RPC response correlation.

VERDICT:
Needs rework: Fix the stream terminal-state race and preserve JSON-RPC ids on helper error paths before this should be approved.

KEY INSIGHT:
The API shape is reasonable, but the implementation must respect existing EventService subscription semantics instead of mocking them away in tests.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it is merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

async def event_stream() -> AsyncIterator[str]:
queue: asyncio.Queue = asyncio.Queue()
subscriber = _QueueSubscriber(queue)
subscriber_id = await event_service.subscribe_to_events(subscriber)

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.

🟠 Important: EventService.subscribe_to_events() immediately invokes the subscriber with the current execution status. For a newly created A2A task that initial event is usually IDLE; because this code subscribes before _send_user_message() and later treats idle as terminal, message/stream can emit a final completed task and close before the run actually starts or produces artifacts. Either ignore the pre-send state snapshot, or send the message before subscribing, and add a test that preserves this real EventService behavior.

try:
task_id = _parse_task_id(send_params.message.taskId)
except ValueError as exc:
return _jsonrpc_error(JSONRPC_INVALID_PARAMS, str(exc))

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.

🟠 Important: Errors from _start_or_get_conversation() are returned directly to message/send and message/stream, but this helper does not receive the caller rpc_id, so invalid taskId, missing task, no profile, and unavailable service responses come back with id: null instead of echoing the request id. JSON-RPC clients rely on id correlation. Thread rpc_id through this helper, or have callers wrap the error, and assert ids on these error paths.

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey @lukegalea thank you for the PR! ❤️

This repo has a convention for a temporary .pr/ directory that can be used for live-tests, example runs results, and/or a markdown summary for human readability. Do you think your agents could try to run live and record some trace we can see? The .pr/ directory will not stay committed, we have an automation removing it upon approval or merge.

In this case, I would love it if you could add a tiny flow e.g. from send message to possible outcomes? 🙏

@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. Because this is a fork PR, the directory will be automatically removed from main immediately after merge.

@neubig

neubig commented Aug 24, 2026

Copy link
Copy Markdown
Member

@lukegalea, could you please confirm that the following issue #1060 acceptance criteria are incorporated?

  • A2A support should use an optional SDK dependency rather than local models only.
  • A2A endpoints should require an explicit enablement argument and be disabled by default.
  • The message/stream initial IDLE snapshot must not be treated as the task's terminal state.
  • Helper-generated JSON-RPC errors should preserve the request id.

The current implementation and tests do not yet demonstrate these points.

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.

Google a2a support

4 participants