Skip to content

feat: support open agent discovery under shared base URL via API Catalog [PoC: DO NOT MERGE] #109

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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 .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ GVsb
INR
JPY
JSONRPCt
Linkset
Llm
POSTGRES
RUF
Expand All @@ -25,6 +26,7 @@ adk
agentic
aio
aiomysql
apicatalog
aproject
autouse
backticks
Expand All @@ -43,20 +45,25 @@ fetchval
genai
getkwargs
gle
httpapi
ietf
initdb
inmemory
isready
kwarg
langgraph
lifecycles
linkset
linting
lstrips
mockurl
notif
oauthoidc
ognis
oidc
opensource
otherurl
poc
postgres
postgresql
protoc
Expand Down
19 changes: 19 additions & 0 deletions examples/apicatalog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# API Catalog Example

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`.

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).

## Getting started

1. Start the server

```bash
uv run .
```

2. Run the test client

```bash
uv run test_client.py
```
Empty file.
91 changes: 91 additions & 0 deletions examples/apicatalog/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import logging
import sys
import traceback

import click
import uvicorn

from agent_executors import ( # type: ignore[import-untyped]
EchoAgentExecutor,
HelloWorldAgentExecutor,
)
from dotenv import load_dotenv

from a2a.server.apps import StarletteBuilder, StarletteRouteBuilder
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill


load_dotenv()
logging.basicConfig()


@click.command()
@click.option('--host', 'host', default='localhost')
@click.option('--port', 'port', default=9999)
def main(host: str, port: int) -> None:
"""Start the API catalog server with the given host and port."""
hello_skill = AgentSkill(
id='hello_world',
name='Returns hello world',
description='just returns hello world',
tags=['hello world'],
examples=['hi', 'hello world'],
)
hello_card = AgentCard(
name='Hello World Agent',
description='Just a hello world agent',
url=f'http://{host}:{port}/a2a/hello',
version='1.0.0',
defaultInputModes=['text'],
defaultOutputModes=['text'],
capabilities=AgentCapabilities(streaming=True),
skills=[hello_skill],
supportsAuthenticatedExtendedCard=False,
)
hello_handler = DefaultRequestHandler(
agent_executor=HelloWorldAgentExecutor(),
task_store=InMemoryTaskStore(),
)
hello_agent = StarletteRouteBuilder(
agent_card=hello_card,
http_handler=hello_handler,
)

echo_skill = AgentSkill(
id='echo',
name='Echo input',
description='Returns the input text as is',
tags=['echo'],
examples=['Hello!', 'Repeat after me'],
)
echo_card = AgentCard(
name='Echo Agent',
description='An agent that echoes back your input.',
url=f'http://{host}:{port}/a2a/echo',
version='1.0.0',
defaultInputModes=['text'],
defaultOutputModes=['text'],
capabilities=AgentCapabilities(streaming=True),
skills=[echo_skill],
supportsAuthenticatedExtendedCard=False,
)
echo_handler = DefaultRequestHandler(
agent_executor=EchoAgentExecutor(),
task_store=InMemoryTaskStore(),
)
echo_agent = StarletteRouteBuilder(
agent_card=echo_card,
http_handler=echo_handler,
)

server = StarletteBuilder().mount(hello_agent).mount(echo_agent).build()
uvicorn.run(server, host=host, port=port)


if __name__ == '__main__':
try:
main()
except Exception as _:
print(traceback.format_exc(), file=sys.stderr)
124 changes: 124 additions & 0 deletions examples/apicatalog/agent_executors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from typing_extensions import override

from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message


class HelloWorldAgent:
"""A simple agent that returns a static 'Hello, world!' message."""

async def invoke(self) -> str:
"""Invokes the agent's main logic and returns a response message.

Returns:
str: The fixed message 'Hello, world!'.
"""
return 'Hello, world!'


class HelloWorldAgentExecutor(AgentExecutor):
"""AgentExecutor implementation for the HelloWorldAgent.

This executor wraps a HelloWorldAgent and, when executed, sends
a single text message event with the message "Hello World".

Intended for demonstration, testing, or HelloWorld scaffolding purposes.
"""

def __init__(self) -> None:
"""Initializes the executor with a HelloWorldAgent instance."""
self.agent = HelloWorldAgent()

@override
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
"""Executes the agent by invoking it and emitting the result as a text message event.

Args:
context: The request context provided by the framework.
event_queue: The event queue to which agent messages should be enqueued.
"""
result = await self.agent.invoke()
event_queue.enqueue_event(new_agent_text_message(result))

@override
async def cancel(
self, context: RequestContext, event_queue: EventQueue
) -> None:
"""Raises an exception because cancelation is not supported for this example agent.

Args:
context: The request context (not used in this method).
event_queue: The event queue (not used in this method).

Raises:
Exception: Always raised, indicating cancel is not supported.
"""
raise Exception('cancel not supported')


class EchoAgent:
"""An agent that returns the input message as-is."""

async def invoke(self, message: str) -> str:
"""Invokes the agent's main logic and returns the input message unchanged.

This method simulates an echo behavior by returning
the same message that was passed as input.

Args:
message: The input string to echo.

Returns:
The same string that was provided as input.
"""
return message


class EchoAgentExecutor(AgentExecutor):
"""AgentExecutor implementation for the EchoAgent.

This executor wraps an EchoAgent and, when executed, it sends back
the same message it receives, simulating a basic echo response.

Intended for demonstration, testing, or HelloWorld scaffolding purposes.
"""

def __init__(self) -> None:
"""Initializes the executor with a EchoAgent instance."""
self.agent = EchoAgent()

@override
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
"""Executes the agent by invoking it and emitting the result as a text message event.

Args:
context: The request context provided by the framework.
event_queue: The event queue to which agent messages should be enqueued.
"""
message = context.get_user_input()
result = await self.agent.invoke(message)
event_queue.enqueue_event(new_agent_text_message(result))

@override
async def cancel(
self, context: RequestContext, event_queue: EventQueue
) -> None:
"""Raises an exception because cancelation is not supported for this example agent.

Args:
context: The request context (not used in this method).
event_queue: The event queue (not used in this method).

Raises:
Exception: Always raised, indicating cancel is not supported.
"""
raise Exception('cancel not supported')
24 changes: 24 additions & 0 deletions examples/apicatalog/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[project]
name = "apicatalog"
version = "0.1.0"
description = "Minimal example agent with API Catalog support, similar to HelloWorld"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"a2a-sdk",
"click>=8.1.8",
"dotenv>=0.9.9",
"httpx>=0.28.1",
"uvicorn>=0.34.2",
"python-dotenv>=1.1.0",
]

[tool.hatch.build.targets.wheel]
packages = ["."]

[tool.uv.sources]
a2a-sdk = { workspace = true }

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
38 changes: 38 additions & 0 deletions examples/apicatalog/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import asyncio
import json
import logging
import sys
import traceback

import click
import httpx

from dotenv import load_dotenv


load_dotenv()
logging.basicConfig()


async def fetch_api_catalog(base_url: str):
url = f'{base_url.rstrip("/")}/.well-known/api-catalog'
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.json()


@click.command()
@click.option('--host', 'host', default='localhost')
@click.option('--port', 'port', default=9999)
def main(host: str, port: int):
base = f'http://{host}:{port}'
catalog = asyncio.run(fetch_api_catalog(base))
print(json.dumps(catalog))


if __name__ == '__main__':
try:
main()
except Exception as _:
print(traceback.format_exc(), file=sys.stderr)
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ exclude = ["tests/"]
vcs = "git"
style = "pep440"

[tool.uv.workspace]
members = [
"examples/apicatalog",
]

[dependency-groups]
dev = [
"datamodel-code-generator>=0.30.0",
Expand Down
8 changes: 6 additions & 2 deletions src/a2a/server/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
A2AFastAPIApplication,
A2AStarletteApplication,
CallContextBuilder,
JSONRPCApplication,
JSONRPCApplicationBuilder,
StarletteBuilder,
StarletteRouteBuilder,
)


__all__ = [
'A2AFastAPIApplication',
'A2AStarletteApplication',
'CallContextBuilder',
'JSONRPCApplication',
'JSONRPCApplicationBuilder',
'StarletteBuilder',
'StarletteRouteBuilder',
]
12 changes: 9 additions & 3 deletions src/a2a/server/apps/jsonrpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
from a2a.server.apps.jsonrpc.jsonrpc_app import (
CallContextBuilder,
JSONRPCApplication,
JSONRPCApplicationBuilder,
)
from a2a.server.apps.jsonrpc.starlette_app import (
A2AStarletteApplication,
StarletteBuilder,
StarletteRouteBuilder,
)
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication


__all__ = [
'A2AFastAPIApplication',
'A2AStarletteApplication',
'CallContextBuilder',
'JSONRPCApplication',
'JSONRPCApplicationBuilder',
'StarletteBuilder',
'StarletteRouteBuilder',
]
Loading
Loading