Skip to content

Commit f65a674

Browse files
committed
feat: support open agent discovery under shared base URL via API Catalog [PoC: DO NOT MERGE]
Signed-off-by: Shingo OKAWA <[email protected]>
1 parent e0c625a commit f65a674

File tree

15 files changed

+686
-47
lines changed

15 files changed

+686
-47
lines changed

.github/actions/spelling/allow.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ GBP
1414
INR
1515
JPY
1616
JSONRPCt
17+
Linkset
1718
Llm
1819
RUF
1920
aconnect
2021
adk
2122
agentic
23+
<<<<<<< HEAD
2224
aio
25+
=======
26+
apicatalog
27+
>>>>>>> 5886b54 (chore(format): fix spelling)
2328
autouse
2429
cla
2530
cls
@@ -32,16 +37,21 @@ euo
3237
genai
3338
getkwargs
3439
gle
40+
httpapi
41+
ietf
3542
inmemory
3643
kwarg
3744
langgraph
3845
lifecycles
46+
linkset
3947
linting
4048
lstrips
4149
mockurl
4250
oauthoidc
51+
ognis
4352
oidc
4453
opensource
54+
poc
4555
protoc
4656
pyi
4757
pyversions

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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import logging
2+
import sys
3+
import traceback
4+
5+
import click
6+
import uvicorn
7+
8+
from agent_executors import ( # type: ignore[import-untyped]
9+
EchoAgentExecutor,
10+
HelloWorldAgentExecutor,
11+
)
12+
from dotenv import load_dotenv
13+
14+
from a2a.server.apps import StarletteBuilder, StarletteRouteBuilder
15+
from a2a.server.request_handlers import DefaultRequestHandler
16+
from a2a.server.tasks import InMemoryTaskStore
17+
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
18+
19+
20+
load_dotenv()
21+
logging.basicConfig()
22+
23+
24+
@click.command()
25+
@click.option('--host', 'host', default='localhost')
26+
@click.option('--port', 'port', default=9999)
27+
def main(host: str, port: int) -> None:
28+
"""Start the API catalog server with the given host and port."""
29+
hello_skill = AgentSkill(
30+
id='hello_world',
31+
name='Returns hello world',
32+
description='just returns hello world',
33+
tags=['hello world'],
34+
examples=['hi', 'hello world'],
35+
)
36+
hello_card = AgentCard(
37+
name='Hello World Agent',
38+
description='Just a hello world agent',
39+
url=f'http://{host}:{port}/a2a/hello',
40+
version='1.0.0',
41+
defaultInputModes=['text'],
42+
defaultOutputModes=['text'],
43+
capabilities=AgentCapabilities(streaming=True),
44+
skills=[hello_skill],
45+
supportsAuthenticatedExtendedCard=False,
46+
)
47+
hello_handler = DefaultRequestHandler(
48+
agent_executor=HelloWorldAgentExecutor(),
49+
task_store=InMemoryTaskStore(),
50+
)
51+
hello_agent = StarletteRouteBuilder(
52+
agent_card=hello_card,
53+
http_handler=hello_handler,
54+
)
55+
56+
echo_skill = AgentSkill(
57+
id='echo',
58+
name='Echo input',
59+
description='Returns the input text as is',
60+
tags=['echo'],
61+
examples=['Hello!', 'Repeat after me'],
62+
)
63+
echo_card = AgentCard(
64+
name='Echo Agent',
65+
description='An agent that echoes back your input.',
66+
url=f'http://{host}:{port}/a2a/echo',
67+
version='1.0.0',
68+
defaultInputModes=['text'],
69+
defaultOutputModes=['text'],
70+
capabilities=AgentCapabilities(streaming=True),
71+
skills=[echo_skill],
72+
supportsAuthenticatedExtendedCard=False,
73+
)
74+
echo_handler = DefaultRequestHandler(
75+
agent_executor=EchoAgentExecutor(),
76+
task_store=InMemoryTaskStore(),
77+
)
78+
echo_agent = StarletteRouteBuilder(
79+
agent_card=echo_card,
80+
http_handler=echo_handler,
81+
)
82+
83+
server = StarletteBuilder().mount(hello_agent).mount(echo_agent).build()
84+
uvicorn.run(server, host=host, port=port)
85+
86+
87+
if __name__ == '__main__':
88+
try:
89+
main()
90+
except Exception as _:
91+
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) -> None:
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) -> None:
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 json
3+
import logging
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'
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(json.dumps(catalog))
32+
33+
34+
if __name__ == '__main__':
35+
try:
36+
main()
37+
except Exception as _:
38+
print(traceback.format_exc(), file=sys.stderr)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ exclude = ["tests/"]
6969
vcs = "git"
7070
style = "pep440"
7171

72+
[tool.uv.workspace]
73+
members = [
74+
"examples/apicatalog",
75+
]
76+
7277
[dependency-groups]
7378
dev = [
7479
"datamodel-code-generator>=0.30.0",

src/a2a/server/apps/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
A2AFastAPIApplication,
55
A2AStarletteApplication,
66
CallContextBuilder,
7-
JSONRPCApplication,
7+
JSONRPCApplicationBuilder,
8+
StarletteBuilder,
9+
StarletteRouteBuilder,
810
)
911

1012

1113
__all__ = [
1214
'A2AFastAPIApplication',
1315
'A2AStarletteApplication',
1416
'CallContextBuilder',
15-
'JSONRPCApplication',
17+
'JSONRPCApplicationBuilder',
18+
'StarletteBuilder',
19+
'StarletteRouteBuilder',
1620
]

src/a2a/server/apps/jsonrpc/__init__.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
44
from a2a.server.apps.jsonrpc.jsonrpc_app import (
55
CallContextBuilder,
6-
JSONRPCApplication,
6+
JSONRPCApplicationBuilder,
7+
)
8+
from a2a.server.apps.jsonrpc.starlette_app import (
9+
A2AStarletteApplication,
10+
StarletteBuilder,
11+
StarletteRouteBuilder,
712
)
8-
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication
913

1014

1115
__all__ = [
1216
'A2AFastAPIApplication',
1317
'A2AStarletteApplication',
1418
'CallContextBuilder',
15-
'JSONRPCApplication',
19+
'JSONRPCApplicationBuilder',
20+
'StarletteBuilder',
21+
'StarletteRouteBuilder',
1622
]

0 commit comments

Comments
 (0)