Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ const (

// ServiceTypeStringInference is the string identifier for Inference services.
ServiceTypeStringInference = "inference"

// ServiceTypeStringA2A is the string identifier for A2A (Agent2Agent) services.
ServiceTypeStringA2A = "a2a"
)

// ParseServiceType converts a string identifier (e.g. from JSON or REST) to the ServiceType protobuf enum.
Expand All @@ -180,6 +183,8 @@ func ParseServiceType(s string) (ServiceType, error) {
return ServiceType_SERVICE_TYPE_MCP, nil
case ServiceTypeStringInference:
return ServiceType_SERVICE_TYPE_INFERENCE, nil
case ServiceTypeStringA2A:
return ServiceType_SERVICE_TYPE_A2A, nil
default:
return ServiceType_SERVICE_TYPE_UNSPECIFIED, fmt.Errorf("invalid service type: %s", s)
}
Expand All @@ -192,6 +197,8 @@ func ServiceTypeToString(t ServiceType) (string, error) {
return ServiceTypeStringMCP, nil
case ServiceType_SERVICE_TYPE_INFERENCE:
return ServiceTypeStringInference, nil
case ServiceType_SERVICE_TYPE_A2A:
return ServiceTypeStringA2A, nil
default:
return "", fmt.Errorf("invalid or unspecified service type")
}
Expand Down
17 changes: 17 additions & 0 deletions api/network_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,20 @@ func TestParseServiceTarget(t *testing.T) {
})
}
}

func TestServiceTypeA2ARoundTrip(t *testing.T) {
st, err := ParseServiceType("a2a")
if err != nil {
t.Fatalf("ParseServiceType(a2a): %v", err)
}
if st != ServiceType_SERVICE_TYPE_A2A {
t.Fatalf("ParseServiceType(a2a) = %v, want SERVICE_TYPE_A2A", st)
}
s, err := ServiceTypeToString(st)
if err != nil {
t.Fatalf("ServiceTypeToString: %v", err)
}
if s != ServiceTypeStringA2A {
t.Fatalf("ServiceTypeToString = %q, want %q", s, ServiceTypeStringA2A)
}
}
8 changes: 6 additions & 2 deletions api/sam.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/sam.proto
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ enum ServiceType {
SERVICE_TYPE_UNSPECIFIED = 0;
SERVICE_TYPE_MCP = 1;
SERVICE_TYPE_INFERENCE = 2;
SERVICE_TYPE_A2A = 3;
}

message ServiceInfo {
Expand Down
2 changes: 1 addition & 1 deletion charts/sam-mesh/templates/bootstrap-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ spec:
{"name": "sam-admin", "allowed_services": ["*"], "allowed_targets": ["*"]},
{"name": "sam:role:sambox", "allowed_services": ["*"], "allowed_targets": ["*"]},
{"name": "sam:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]},
{"name": "sam:role:node", "allowed_services": {{ toJson .Values.bootstrap.nodeServices }}, "allowed_targets": ["*"]}
{"name": "sam:role:node", "allowed_services": {{ toJson .Values.bootstrap.nodeServices }}, "allowed_targets": ["*"], "allowed_labels": {{ toJson .Values.bootstrap.nodeLabels }}}
],
"bindings": {{ toJson $bindings }}
}' \
Expand Down
21 changes: 21 additions & 0 deletions charts/sam-mesh/tests/bootstrap-job_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,24 @@ tests:
value: 100
- exists:
path: spec.template.spec.containers[0].resources.limits

- it: node role grants no labels by default (fail closed)
documentSelector:
path: kind
value: Job
asserts:
- matchRegex:
path: spec.template.spec.containers[0].args[0]
pattern: '"name": "sam:role:node".*"allowed_labels": \[\]'

- it: node role grants only the configured label patterns
documentSelector:
path: kind
value: Job
set:
bootstrap:
nodeLabels: ["region=*", "team=platform"]
asserts:
- matchRegex:
path: spec.template.spec.containers[0].args[0]
pattern: '"name": "sam:role:node".*"allowed_labels": \["region=\*","team=platform"\]'
3 changes: 3 additions & 0 deletions charts/sam-mesh/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,6 @@ bootstrap:
bindings: []
# Services sam:role:node may call
nodeServices: ["*"]
# Label patterns ("key=value" or "key=*") sam:role:node may attest with
# --labels. Empty means nodes cannot enroll with any label (fail closed).
nodeLabels: []
11 changes: 11 additions & 0 deletions development/examples/chat-a2a/Dockerfile
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"]
71 changes: 71 additions & 0 deletions development/examples/chat-a2a/README.md
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.
108 changes: 108 additions & 0 deletions development/examples/chat-a2a/agent.py
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")
),
)
Comment on lines +40 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The client.aio.chats.create method in the google-genai SDK is asynchronous and returns a coroutine. It must be awaited, otherwise chat will be a coroutine object, leading to an AttributeError when send_message is called on it later.

Suggested change
chat = self.gemini.aio.chats.create(
model=MODEL,
config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_level="minimal")
),
)
chat = await self.gemini.aio.chats.create(
model=MODEL,
config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_level="minimal")
),
)

@kaisoz kaisoz Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

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 in google-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 first send_message if create returned a coroutine.

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)
44 changes: 44 additions & 0 deletions development/examples/chat-a2a/chat.py
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]))
6 changes: 6 additions & 0 deletions development/examples/chat-a2a/requirements.txt
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
8 changes: 8 additions & 0 deletions development/examples/chat-a2a/sam-node-config.yaml
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"
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/google/sam
go 1.25.7

require (
github.com/a2aproject/a2a-go/v2 v2.5.0
github.com/biscuit-auth/biscuit-go/v2 v2.2.0
github.com/coreos/go-oidc/v3 v3.20.0
github.com/golang-jwt/jwt/v5 v5.3.1
Expand Down
Loading
Loading