Skip to content

Commit 870c2c2

Browse files
committed
feat: add poc example with A2AStarletteRouteBuilder and A2AStarletteBuilder
Signed-off-by: Shingo OKAWA <[email protected]>
1 parent b2a5676 commit 870c2c2

File tree

8 files changed

+370
-1
lines changed

8 files changed

+370
-1
lines changed

examples/apicatalog/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# API Catalog Example
2+
3+
Example showcasing multi-agent open discovery using a single base URL and an [API Catalog](https://www.ietf.org/archive/id/draft-ietf-httpapi-api-catalog-08.html). This example defines multiple agents under the same domain and exposes their metadata via `.well-known/api-catalog.json`.
4+
5+
The agents provided in this example are minimal and behave similarly to the one in the `helloworld` example — they simply return predefined `Message` events in response to a request. The focus of this example is not on agent logic, but on demonstrating multi-agent discovery and resolution using an [API Catalog](https://www.ietf.org/archive/id/draft-ietf-httpapi-api-catalog-08.html).
6+
7+
## Getting started
8+
9+
1. Start the server
10+
11+
```bash
12+
uv run .
13+
```
14+
15+
2. Run the test client
16+
17+
```bash
18+
uv run test_client.py
19+
```

examples/apicatalog/__init__.py

Whitespace-only changes.

examples/apicatalog/__main__.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import logging
2+
import os
3+
import sys
4+
import traceback
5+
6+
import click
7+
import uvicorn
8+
9+
from agent_executors import (
10+
HelloWorldAgentExecutor, # type: ignore[import-untyped]
11+
EchoAgentExecutor, # type: ignore[import-untyped]
12+
)
13+
14+
from a2a.server.apps import A2AStarletteRouteBuilder, A2AStarletteBuilder
15+
from a2a.server.request_handlers import DefaultRequestHandler
16+
from a2a.server.tasks import InMemoryTaskStore
17+
from a2a.types import (
18+
AgentCapabilities,
19+
AgentCard,
20+
AgentSkill,
21+
)
22+
from dotenv import load_dotenv
23+
24+
25+
load_dotenv()
26+
logging.basicConfig()
27+
28+
29+
@click.command()
30+
@click.option('--host', 'host', default='localhost')
31+
@click.option('--port', 'port', default=9999)
32+
def main(host: str, port: int):
33+
hello_skill = AgentSkill(
34+
id='hello_world',
35+
name='Returns hello world',
36+
description='just returns hello world',
37+
tags=['hello world'],
38+
examples=['hi', 'hello world'],
39+
)
40+
hello_card = AgentCard(
41+
name='Hello World Agent',
42+
description='Just a hello world agent',
43+
url=f'http://{host}:{port}/a2a/hello',
44+
version='1.0.0',
45+
defaultInputModes=['text'],
46+
defaultOutputModes=['text'],
47+
capabilities=AgentCapabilities(streaming=True),
48+
skills=[hello_skill],
49+
supportsAuthenticatedExtendedCard=False,
50+
)
51+
hello_handler = DefaultRequestHandler(
52+
agent_executor=HelloWorldAgentExecutor(),
53+
task_store=InMemoryTaskStore(),
54+
)
55+
hello_agent = A2AStarletteRouteBuilder(
56+
agent_card=hello_card,
57+
http_handler=hello_handler,
58+
)
59+
60+
echo_skill = AgentSkill(
61+
id="echo",
62+
name="Echo input",
63+
description="Returns the input text as is",
64+
tags=["echo"],
65+
examples=["Hello!", "Repeat after me"],
66+
)
67+
echo_card = AgentCard(
68+
name="Echo Agent",
69+
description="An agent that echoes back your input.",
70+
url=f"http://{host}:{port}/a2a/echo",
71+
version="1.0.0",
72+
defaultInputModes=["text"],
73+
defaultOutputModes=["text"],
74+
capabilities=AgentCapabilities(streaming=True),
75+
skills=[echo_skill],
76+
supportsAuthenticatedExtendedCard=False,
77+
)
78+
echo_handler = DefaultRequestHandler(
79+
agent_executor=EchoAgentExecutor(),
80+
task_store=InMemoryTaskStore(),
81+
)
82+
echo_agent = A2AStarletteRouteBuilder(
83+
agent_card=echo_card,
84+
http_handler=echo_handler,
85+
)
86+
87+
server = (
88+
A2AStarletteBuilder()
89+
.mount(hello_agent)
90+
.mount(echo_agent)
91+
.build()
92+
)
93+
uvicorn.run(server, host=host, port=port)
94+
95+
96+
if __name__ == "__main__":
97+
try:
98+
main()
99+
except Exception as _:
100+
print(traceback.format_exc(), file=sys.stderr)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from typing_extensions import override
2+
3+
from a2a.server.agent_execution import AgentExecutor, RequestContext
4+
from a2a.server.events import EventQueue
5+
from a2a.utils import new_agent_text_message
6+
7+
8+
class HelloWorldAgent:
9+
"""A simple agent that returns a static 'Hello, world!' message."""
10+
11+
async def invoke(self) -> str:
12+
"""Invokes the agent's main logic and returns a response message.
13+
14+
Returns:
15+
str: The fixed message 'Hello, world!'.
16+
"""
17+
return 'Hello, world!'
18+
19+
20+
class HelloWorldAgentExecutor(AgentExecutor):
21+
"""AgentExecutor implementation for the HelloWorldAgent.
22+
23+
This executor wraps a HelloWorldAgent and, when executed, sends
24+
a single text message event with the message "Hello World".
25+
26+
Intended for demonstration, testing, or HelloWorld scaffolding purposes.
27+
"""
28+
29+
def __init__(self):
30+
"""Initializes the executor with a HelloWorldAgent instance."""
31+
self.agent = HelloWorldAgent()
32+
33+
@override
34+
async def execute(
35+
self,
36+
context: RequestContext,
37+
event_queue: EventQueue,
38+
) -> None:
39+
"""Executes the agent by invoking it and emitting the result as a text message event.
40+
41+
Args:
42+
context: The request context provided by the framework.
43+
event_queue: The event queue to which agent messages should be enqueued.
44+
"""
45+
result = await self.agent.invoke()
46+
event_queue.enqueue_event(new_agent_text_message(result))
47+
48+
@override
49+
async def cancel(
50+
self, context: RequestContext, event_queue: EventQueue
51+
) -> None:
52+
"""Raises an exception because cancelation is not supported for this example agent.
53+
54+
Args:
55+
context: The request context (not used in this method).
56+
event_queue: The event queue (not used in this method).
57+
58+
Raises:
59+
Exception: Always raised, indicating cancel is not supported.
60+
"""
61+
raise Exception('cancel not supported')
62+
63+
64+
class EchoAgent:
65+
"""An agent that returns the input message as-is."""
66+
67+
async def invoke(self, message: str) -> str:
68+
"""Invokes the agent's main logic and returns the input message unchanged.
69+
70+
This method simulates an echo behavior by returning
71+
the same message that was passed as input.
72+
73+
Args:
74+
message: The input string to echo.
75+
76+
Returns:
77+
The same string that was provided as input.
78+
"""
79+
return message
80+
81+
82+
class EchoAgentExecutor(AgentExecutor):
83+
"""AgentExecutor implementation for the EchoAgent.
84+
85+
This executor wraps an EchoAgent and, when executed, it sends back
86+
the same message it receives, simulating a basic echo response.
87+
88+
Intended for demonstration, testing, or HelloWorld scaffolding purposes.
89+
"""
90+
91+
def __init__(self):
92+
"""Initializes the executor with a EchoAgent instance."""
93+
self.agent = EchoAgent()
94+
95+
@override
96+
async def execute(
97+
self,
98+
context: RequestContext,
99+
event_queue: EventQueue,
100+
) -> None:
101+
"""Executes the agent by invoking it and emitting the result as a text message event.
102+
103+
Args:
104+
context: The request context provided by the framework.
105+
event_queue: The event queue to which agent messages should be enqueued.
106+
"""
107+
message = context.get_user_input()
108+
result = await self.agent.invoke(message)
109+
event_queue.enqueue_event(new_agent_text_message(result))
110+
111+
@override
112+
async def cancel(
113+
self, context: RequestContext, event_queue: EventQueue
114+
) -> None:
115+
"""Raises an exception because cancelation is not supported for this example agent.
116+
117+
Args:
118+
context: The request context (not used in this method).
119+
event_queue: The event queue (not used in this method).
120+
121+
Raises:
122+
Exception: Always raised, indicating cancel is not supported.
123+
"""
124+
raise Exception('cancel not supported')

examples/apicatalog/pyproject.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[project]
2+
name = "apicatalog"
3+
version = "0.1.0"
4+
description = "Minimal example agent with API Catalog support, similar to HelloWorld"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
dependencies = [
8+
"a2a-sdk",
9+
"click>=8.1.8",
10+
"dotenv>=0.9.9",
11+
"httpx>=0.28.1",
12+
"uvicorn>=0.34.2",
13+
"python-dotenv>=1.1.0",
14+
]
15+
16+
[tool.hatch.build.targets.wheel]
17+
packages = ["."]
18+
19+
[tool.uv.sources]
20+
a2a-sdk = { workspace = true }
21+
22+
[build-system]
23+
requires = ["hatchling"]
24+
build-backend = "hatchling.build"

examples/apicatalog/test_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import asyncio
2+
import logging
3+
import os
4+
import sys
5+
import traceback
6+
7+
import click
8+
import httpx
9+
10+
from dotenv import load_dotenv
11+
12+
13+
load_dotenv()
14+
logging.basicConfig()
15+
16+
17+
async def fetch_api_catalog(base_url: str):
18+
url = f'{base_url.rstrip("/")}/.well-known/api-catalog.json'
19+
async with httpx.AsyncClient() as client:
20+
response = await client.get(url)
21+
response.raise_for_status()
22+
return response.json()
23+
24+
25+
@click.command()
26+
@click.option('--host', 'host', default='localhost')
27+
@click.option('--port', 'port', default=9999)
28+
def main(host: str, port: int):
29+
base = f'http://{host}:{port}'
30+
catalog = asyncio.run(fetch_api_catalog(base))
31+
print(catalog)
32+
33+
34+
if __name__ == "__main__":
35+
try:
36+
main()
37+
except Exception as _:
38+
print(traceback.format_exc(), file=sys.stderr)

src/a2a/server/apps/starlette_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ def _join_url(base: str, *paths: str) -> str:
827827
joined_path = posixpath.join(parsed.path.rstrip('/'), *clean_paths)
828828
return urlunparse(parsed._replace(path='/' + joined_path))
829829

830+
830831
def _get_path_from_url(url: str) -> str:
831832
"""Extracts and returns the path component from a full URL.
832833
@@ -839,6 +840,7 @@ def _get_path_from_url(url: str) -> str:
839840
path = urlparse(url).path
840841
return path if path else '/'
841842

843+
842844
class A2AStarletteBuilder:
843845
"""Builder class for assembling a Starlette application with A2A protocol routes.
844846

0 commit comments

Comments
 (0)