-
Notifications
You must be signed in to change notification settings - Fork 120
feat(node): add A2A support #347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
62e5f45
api: add the a2a service type
kaisoz 43eeedb
node: accept a2a services with URL backends
kaisoz 3e9c515
node: gate raw a2a egress with labels and rewrite agent cards for mes…
kaisoz b29eef0
node: cover a2a egress edge cases in unit tests
kaisoz 2f21b7a
docs: document a2a service routing and the egress labels gate
kaisoz 4a09790
tests: cover the a2a mesh CUJ end to end
kaisoz 830d0ba
node: gate a2a egress case-insensitively to match ingress
kaisoz 84fc8ea
node: dispatch egress middleware by service type instead of hardcodin…
kaisoz e403974
refactor for readibility
kaisoz 226740b
node: move parseRequiredLabels next to the label gate
kaisoz 20e5799
examples: add chat-a2a, a Gemini-backed A2A agent with a chat REPL fo…
kaisoz 8529ef8
Use gemini-3.5-flash-lite for the agent
kaisoz 69cbe36
docs: add the chat-a2a use case page and wire GEMINI_MODEL through th…
kaisoz 9bebe59
tests: connect peers via the debug endpoint helper in the a2a CUJ
kaisoz fb5c349
node: regenerate a2a agent cards with the official SDK instead of liv…
aojea a3d9c4f
a2a: cover the CUJ with stock SDKs at every level and fix what that f…
aojea File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| FROM python:3.12-slim | ||
|
|
||
| WORKDIR /srv | ||
| COPY requirements.txt /srv/ | ||
| RUN pip install --no-cache-dir -r requirements.txt | ||
| COPY agent.py /srv/ | ||
|
|
||
| ENV GEMINI_API_KEY=<API_KEY> | ||
| ENV GEMINI_MODEL=models/gemini-3.5-flash-lite | ||
|
|
||
| CMD ["python3", "/srv/agent.py"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| # chat-a2a | ||
|
|
||
| A Gemini-backed A2A agent plus a tiny chat REPL, exercising the mesh's a2a | ||
| support end to end: agent-card fetch with caller-side rewrite, `message/send` | ||
| routing over libp2p, and `contextId` continuity across turns. | ||
|
|
||
| ## 1. Set your Gemini key | ||
|
|
||
| Edit `Dockerfile` and replace `<API_KEY>` in `ENV GEMINI_API_KEY=<API_KEY>` | ||
| (same pattern as `gemini-buddy-mcp`). Optionally override the model with | ||
| `GEMINI_MODEL` (default `models/gemini-3.5-flash-lite`). | ||
|
|
||
| ## 2. Host the agent on a mesh node | ||
|
|
||
| Assign it in `development/kind/mesh-config.yaml`: | ||
|
|
||
| ```yaml | ||
| node-a: | ||
| node-b: chat-a2a | ||
| ``` | ||
|
|
||
| Then bring the mesh up: | ||
|
|
||
| ```sh | ||
| make build | ||
| make kind-up | ||
| ``` | ||
|
|
||
| ## 3. Enroll a local caller node | ||
|
|
||
| ```sh | ||
| ./development/kind/run-local-node.sh | ||
| ``` | ||
|
|
||
| Sidecar API lands on `127.0.0.1:9099` with token `devtoken`. | ||
|
|
||
| ## 4. Find the provider peer | ||
|
|
||
| ```sh | ||
| ./bin/mcp-client -url http://127.0.0.1:9099/mcp -token devtoken \ | ||
| -tool discover_remote_services -args '{"type":"a2a","name":"chat"}' | ||
| ``` | ||
|
|
||
| Note the peer ID and export it: `export PEER=<peer-id>`. Discovery is | ||
| gossip-fed; retry for a few seconds after startup if the list comes back empty. | ||
|
|
||
| ## 5. See the card rewrite | ||
|
|
||
| ```sh | ||
| curl -s -H 'X-Sam-Authentication: Bearer devtoken' \ | ||
| "http://127.0.0.1:9099/sam/$PEER/a2a/chat/.well-known/agent-card.json" | jq | ||
| ``` | ||
|
|
||
| The interface URLs point back at this mesh path (not the agent's own address) | ||
| and `capabilities.streaming` is forced to `false` — that rewrite is what lets | ||
| a stock A2A client work against the mesh unmodified. | ||
|
|
||
| ## 6. Chat | ||
|
|
||
| Requires [`uv`](https://docs.astral.sh/uv/). | ||
|
|
||
| ```sh | ||
| cd development/examples/chat-a2a | ||
| uv run --with-requirements requirements.txt chat.py "http://127.0.0.1:9099/sam/$PEER/a2a/chat" | ||
| ``` | ||
|
|
||
| Tell the agent your name, then ask for it back a couple of turns later: the | ||
| client carries the `contextId` the server minted on the first reply, and the | ||
| agent keeps one Gemini chat session per context, so the answer proves the | ||
| conversation survived the mesh hop. Each turn is still its own short-lived | ||
| A2A task — `taskId` changes every turn, `contextId` is what persists. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| """Gemini-backed A2A chat agent hosted by a node in the local dev mesh.""" | ||
| import os | ||
| import time | ||
| import uuid | ||
|
|
||
| import uvicorn | ||
| from a2a.server.agent_execution.agent_executor import AgentExecutor | ||
| from a2a.server.agent_execution.context import RequestContext | ||
| from a2a.server.events.event_queue import EventQueue | ||
| from a2a.server.request_handlers import DefaultRequestHandler | ||
| from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes | ||
| from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore | ||
| from a2a.types import ( | ||
| AgentCapabilities, | ||
| AgentCard, | ||
| AgentInterface, | ||
| AgentSkill, | ||
| Message, | ||
| Part, | ||
| Role, | ||
| ) | ||
| from google import genai | ||
| from google.genai import types | ||
| from starlette.applications import Starlette | ||
|
|
||
| PORT = 7777 | ||
| MODEL = os.environ.get("GEMINI_MODEL", "models/gemini-3.5-flash-lite") | ||
|
|
||
| class ChatExecutor(AgentExecutor): | ||
| """One Gemini chat session per A2A contextId; the session carries the history.""" | ||
|
|
||
| def __init__(self): | ||
| self.gemini = genai.Client() | ||
| self.chats = {} | ||
|
|
||
| async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: | ||
| chat = self.chats.get(context.context_id) | ||
| if chat is None: | ||
| # Gemini 3 Flash defaults to thinking_level=high, which dominates latency. | ||
| chat = self.gemini.aio.chats.create( | ||
| model=MODEL, | ||
| config=types.GenerateContentConfig( | ||
| thinking_config=types.ThinkingConfig(thinking_level="minimal") | ||
| ), | ||
| ) | ||
| self.chats[context.context_id] = chat | ||
| started = time.monotonic() | ||
| reply = await chat.send_message(context.get_user_input()) | ||
| print( | ||
| f"[chat] context={context.context_id} gemini took " | ||
| f"{time.monotonic() - started:.1f}s usage={reply.usage_metadata}", | ||
| flush=True, | ||
| ) | ||
| await event_queue.enqueue_event( | ||
| Message( | ||
| role=Role.ROLE_AGENT, | ||
| message_id=str(uuid.uuid4()), | ||
| parts=[Part(text=reply.text or "")], | ||
| context_id=context.context_id, | ||
| task_id=context.task_id, | ||
| ) | ||
| ) | ||
|
|
||
| async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: | ||
| pass | ||
|
|
||
|
|
||
| agent_card = AgentCard( | ||
| name="chat", | ||
| description="Gemini-backed conversational agent; remembers the conversation per contextId", | ||
| version="0.1.0", | ||
| capabilities=AgentCapabilities(streaming=False), | ||
| default_input_modes=["text"], | ||
| default_output_modes=["text"], | ||
| skills=[ | ||
| AgentSkill( | ||
| id="chat", | ||
| name="chat", | ||
| description="Multi-turn small talk", | ||
| tags=["chat"], | ||
| examples=["hi, my name is Ada", "what is my name?"], | ||
| ) | ||
| ], | ||
| supported_interfaces=[ | ||
| AgentInterface( | ||
| protocol_binding="JSONRPC", | ||
| protocol_version="1.0", | ||
| url=f"http://127.0.0.1:{PORT}/", | ||
| ) | ||
| ], | ||
| ) | ||
|
|
||
| handler = DefaultRequestHandler( | ||
| agent_executor=ChatExecutor(), | ||
| task_store=InMemoryTaskStore(), | ||
| agent_card=agent_card, | ||
| ) | ||
| # Starlette over FastAPI: the SDK generates the routes, so FastAPI would add nothing. | ||
| # JSON-RPC at "/": the mesh card regeneration drops URL subpaths, so clients land on the root. | ||
| app = Starlette( | ||
| routes=[ | ||
| *create_jsonrpc_routes(request_handler=handler, rpc_url="/"), | ||
| *create_agent_card_routes(agent_card=agent_card), | ||
| ] | ||
| ) | ||
|
|
||
| if __name__ == "__main__": | ||
| uvicorn.run(app, host="0.0.0.0", port=PORT) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| """Minimal A2A chat REPL: resolves the agent card through the mesh and talks to it.""" | ||
| import asyncio | ||
| import os | ||
| import sys | ||
| import uuid | ||
|
|
||
| import httpx | ||
| from a2a.client import A2ACardResolver, ClientConfig, create_client | ||
| from a2a.helpers import get_message_text | ||
| from a2a.types import Message, Part, Role, SendMessageRequest | ||
|
|
||
|
|
||
| async def main(url: str) -> None: | ||
| token = os.environ.get("SAM_API_TOKEN", "devtoken") | ||
| async with httpx.AsyncClient(timeout=120, headers={"X-Sam-Authentication": f"Bearer {token}"}) as http: | ||
| card = await A2ACardResolver(http, url).get_agent_card() | ||
| print(f"{card.name}: {card.description}") | ||
| for iface in card.supported_interfaces: | ||
| print(f" {iface.protocol_binding} -> {iface.url}") | ||
| client = await create_client(card, client_config=ClientConfig(httpx_client=http)) | ||
| context_id = None | ||
| while True: | ||
| try: | ||
| text = input("you> ") | ||
| except EOFError: | ||
| return | ||
| if not text.strip(): | ||
| continue | ||
| message = Message( | ||
| role=Role.ROLE_USER, | ||
| message_id=str(uuid.uuid4()), | ||
| parts=[Part(text=text)], | ||
| context_id=context_id, | ||
| ) | ||
| async for event in client.send_message(SendMessageRequest(message=message)): | ||
| if event.HasField("message"): | ||
| context_id = event.message.context_id or context_id | ||
| print(f"agent> {get_message_text(event.message)}") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| if len(sys.argv) != 2: | ||
| sys.exit("usage: chat.py http://127.0.0.1:9099/sam/<peer-id>/a2a/chat") | ||
| asyncio.run(main(sys.argv[1])) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| a2a-sdk>=1.0 | ||
| google-genai>=1.0 | ||
| httpx>=0.27 | ||
| sse-starlette>=2.0 | ||
| starlette>=0.40 | ||
| uvicorn>=0.30 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| version: "v1alpha1" | ||
| attenuation: | ||
| policies: [] | ||
| services: | ||
| - type: "a2a" | ||
| name: "chat" | ||
| description: "Gemini-backed conversational A2A agent" | ||
| target_url: "http://127.0.0.1:7777" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
client.aio.chats.createmethod in thegoogle-genaiSDK is asynchronous and returns a coroutine. It must be awaited, otherwisechatwill be a coroutine object, leading to anAttributeErrorwhensend_messageis called on it later.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
client.aio.chats.create()is a synchronous factory ingoogle-genai: it builds and returns an AsyncChat without doing any I/O. Besides, the example has been run end to end with multi-turn conversations, which would crash on the firstsend_messageif create returned a coroutine.