Skip to content

Commit 3468180

Browse files
authored
feat(utils): add display_agent_card() utility for human-readable AgentCard inspection (#972)
## Description Adds a `display_agent_card(card)` utility function to `a2a.utils` that prints a structured, human-readable summary of an `AgentCard` proto to stdout. ## Motivation The current proto text format is complete but difficult to read at a glance: name: "Sample Agent" supported_interfaces { url: "http://127.0.0.1:41241/a2a/jsonrpc" protocol_binding: "JSONRPC" protocol_version: "1.0" } ... At least four workarounds exist across `a2a-samples` for printing card contents. This provides a single, simple solution. ## Changes - `src/a2a/utils/agent_card.py` — new file with `display_agent_card(card: AgentCard) -> None` - `src/a2a/utils/__init__.py` — exports `display_agent_card` - `tests/utils/test_agent_card_display.py` — 5 unit tests including a full golden test - `samples/cli.py` — utilize the new display function ## Example output from `sample/cli.py` ``` uv run samples/cli.py Connecting to http://127.0.0.1:41241 (preferred transport: Any) ✓ Agent Card Found: ==================================================== AgentCard ==================================================== --- General --- Name : Sample Agent Description : A sample agent to test the stream functionality. Version : 1.0.0 Provider : A2A Samples (https://example.com) --- Interfaces --- [0] 127.0.0.1:50051 (GRPC 1.0) [1] 127.0.0.1:50052 (GRPC 0.3) [2] http://127.0.0.1:41241/a2a/jsonrpc (JSONRPC 1.0) [3] http://127.0.0.1:41241/a2a/jsonrpc (JSONRPC 0.3) [4] http://127.0.0.1:41241/a2a/rest (HTTP+JSON 1.0) [5] http://127.0.0.1:41241/a2a/rest (HTTP+JSON 0.3) --- Capabilities --- Streaming : True Push notifications : False Extended agent card : False --- I/O Modes --- Input : text Output : text, task-status --- Skills --- ---------------------------------------------------- ID : sample_agent Name : Sample Agent Description : Say hi. Tags : sample Example : hi ==================================================== Picked Transport: JsonRpcTransport ``` ## Notes - No breaking changes. Existing call sites are unaffected. - Optional fields (`documentation_url`, `icon_url`, `provider`) are shown only when set. - Closes #961 Fixes #961 🦕
1 parent 0bfec88 commit 3468180

4 files changed

Lines changed: 289 additions & 18 deletions

File tree

samples/cli.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,16 @@
1111

1212
from a2a.client import A2ACardResolver, ClientConfig, create_client
1313
from a2a.types import Message, Part, Role, SendMessageRequest, TaskState
14+
from a2a.utils import get_artifact_text, get_message_text
15+
from a2a.utils.agent_card import display_agent_card
1416

1517

16-
async def _handle_stream( # noqa: PLR0912
18+
async def _handle_stream(
1719
stream: Any, current_task_id: str | None
1820
) -> str | None:
1921
async for event in stream:
2022
if event.HasField('message'):
21-
print('Message:', end=' ')
22-
for part in event.message.parts:
23-
if part.text:
24-
print(part.text, end=' ')
25-
print()
23+
print('Message:', get_message_text(event.message, delimiter=' '))
2624
return None
2725

2826
if not current_task_id:
@@ -35,12 +33,15 @@ async def _handle_stream( # noqa: PLR0912
3533

3634
if event.HasField('status_update'):
3735
state_name = TaskState.Name(event.status_update.status.state)
38-
print(f'TaskStatusUpdate [state={state_name}]:', end=' ')
39-
if event.status_update.status.HasField('message'):
40-
for part in event.status_update.status.message.parts:
41-
if part.text:
42-
print(part.text, end=' ')
43-
print()
36+
message_text = (
37+
': '
38+
+ get_message_text(
39+
event.status_update.status.message, delimiter=' '
40+
)
41+
if event.status_update.status.HasField('message')
42+
else ''
43+
)
44+
print(f'TaskStatusUpdate [state={state_name}]{message_text}')
4445
if state_name in (
4546
'TASK_STATE_COMPLETED',
4647
'TASK_STATE_FAILED',
@@ -52,12 +53,10 @@ async def _handle_stream( # noqa: PLR0912
5253
elif event.HasField('artifact_update'):
5354
print(
5455
f'TaskArtifactUpdate [name={event.artifact_update.artifact.name}]:',
55-
end=' ',
56+
get_artifact_text(
57+
event.artifact_update.artifact, delimiter=' '
58+
),
5659
)
57-
for part in event.artifact_update.artifact.parts:
58-
if part.text:
59-
print(part.text, end=' ')
60-
print()
6160
return current_task_id
6261

6362

@@ -86,7 +85,7 @@ async def main() -> None:
8685
resolver = A2ACardResolver(httpx_client, args.url)
8786
card = await resolver.get_agent_card()
8887
print('\n✓ Agent Card Found:')
89-
print(f' Name: {card.name}')
88+
display_agent_card(card)
9089

9190
client = await create_client(card, client_config=config)
9291

src/a2a/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Utility functions for the A2A Python SDK."""
22

33
from a2a.utils import proto_utils
4+
from a2a.utils.agent_card import display_agent_card
45
from a2a.utils.artifact import (
56
get_artifact_text,
67
new_artifact,
@@ -44,6 +45,7 @@
4445
'build_text_artifact',
4546
'completed_task',
4647
'create_task_obj',
48+
'display_agent_card',
4749
'get_artifact_text',
4850
'get_data_parts',
4951
'get_file_parts',

src/a2a/utils/agent_card.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Utility functions for inspecting AgentCard instances."""
2+
3+
from a2a.types.a2a_pb2 import AgentCard
4+
5+
6+
def display_agent_card(card: AgentCard) -> None:
7+
"""Print a human-readable summary of an AgentCard to stdout.
8+
9+
Args:
10+
card: The AgentCard proto message to display.
11+
"""
12+
width = 52
13+
sep = '=' * width
14+
thin = '-' * width
15+
16+
lines: list[str] = [sep, 'AgentCard'.center(width), sep]
17+
18+
lines += [
19+
'--- General ---',
20+
f'Name : {card.name}',
21+
f'Description : {card.description}',
22+
f'Version : {card.version}',
23+
]
24+
if card.documentation_url:
25+
lines.append(f'Docs URL : {card.documentation_url}')
26+
if card.icon_url:
27+
lines.append(f'Icon URL : {card.icon_url}')
28+
if card.HasField('provider'):
29+
url_suffix = f' ({card.provider.url})' if card.provider.url else ''
30+
lines.append(f'Provider : {card.provider.organization}{url_suffix}')
31+
32+
lines += ['', '--- Interfaces ---']
33+
for i, iface in enumerate(card.supported_interfaces):
34+
binding = f'{iface.protocol_binding} {iface.protocol_version}'.strip()
35+
parts = [
36+
p
37+
for p in [binding, f'tenant={iface.tenant}' if iface.tenant else '']
38+
if p
39+
]
40+
suffix = f' ({", ".join(parts)})' if parts else ''
41+
line = f' [{i}] {iface.url}{suffix}'
42+
lines.append(line)
43+
44+
lines += [
45+
'',
46+
'--- Capabilities ---',
47+
f'Streaming : {card.capabilities.streaming}',
48+
f'Push notifications : {card.capabilities.push_notifications}',
49+
f'Extended agent card : {card.capabilities.extended_agent_card}',
50+
]
51+
52+
lines += [
53+
'',
54+
'--- I/O Modes ---',
55+
f'Input : {", ".join(card.default_input_modes) or "(none)"}',
56+
f'Output : {", ".join(card.default_output_modes) or "(none)"}',
57+
]
58+
59+
lines += ['', '--- Skills ---']
60+
if card.skills:
61+
for skill in card.skills:
62+
lines += [
63+
thin,
64+
f' ID : {skill.id}',
65+
f' Name : {skill.name}',
66+
f' Description : {skill.description}',
67+
f' Tags : {", ".join(skill.tags) or "(none)"}',
68+
]
69+
if skill.examples:
70+
for ex in skill.examples:
71+
lines.append(f' Example : {ex}')
72+
else:
73+
lines.append(' (none)')
74+
75+
lines.append(sep)
76+
print('\n'.join(lines))
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Tests for display_agent_card utility."""
2+
3+
import pytest
4+
5+
from a2a.types.a2a_pb2 import (
6+
AgentCapabilities,
7+
AgentCard,
8+
AgentInterface,
9+
AgentProvider,
10+
AgentSkill,
11+
)
12+
from a2a.utils.agent_card import display_agent_card
13+
14+
15+
@pytest.fixture
16+
def full_agent_card() -> AgentCard:
17+
return AgentCard(
18+
name='Sample Agent',
19+
description='A sample agent.',
20+
version='1.0.0',
21+
documentation_url='https://docs.example.com',
22+
icon_url='https://example.com/icon.png',
23+
provider=AgentProvider(
24+
organization='Example Org', url='https://example.com'
25+
),
26+
supported_interfaces=[
27+
AgentInterface(
28+
url='http://localhost:9999/a2a/jsonrpc',
29+
protocol_binding='JSONRPC',
30+
protocol_version='1.0',
31+
),
32+
AgentInterface(
33+
url='http://localhost:9999/a2a/rest',
34+
protocol_binding='HTTP+JSON',
35+
protocol_version='1.0',
36+
tenant='tenant-a',
37+
),
38+
],
39+
capabilities=AgentCapabilities(
40+
streaming=True,
41+
push_notifications=False,
42+
extended_agent_card=True,
43+
),
44+
default_input_modes=['text'],
45+
default_output_modes=['text', 'task-status'],
46+
skills=[
47+
AgentSkill(
48+
id='skill-1',
49+
name='My Skill',
50+
description='Does something useful.',
51+
tags=['foo', 'bar'],
52+
examples=['Do the thing', 'Another example'],
53+
),
54+
AgentSkill(
55+
id='skill-2',
56+
name='Other Skill',
57+
description='Does something else.',
58+
tags=['baz'],
59+
),
60+
],
61+
)
62+
63+
64+
class TestDisplayAgentCard:
65+
def test_full_card_output(
66+
self, full_agent_card: AgentCard, capsys: pytest.CaptureFixture[str]
67+
) -> None:
68+
"""Golden test: exact output for a fully-populated card."""
69+
display_agent_card(full_agent_card)
70+
assert capsys.readouterr().out == (
71+
'====================================================\n'
72+
' AgentCard \n'
73+
'====================================================\n'
74+
'--- General ---\n'
75+
'Name : Sample Agent\n'
76+
'Description : A sample agent.\n'
77+
'Version : 1.0.0\n'
78+
'Docs URL : https://docs.example.com\n'
79+
'Icon URL : https://example.com/icon.png\n'
80+
'Provider : Example Org (https://example.com)\n'
81+
'\n'
82+
'--- Interfaces ---\n'
83+
' [0] http://localhost:9999/a2a/jsonrpc (JSONRPC 1.0)\n'
84+
' [1] http://localhost:9999/a2a/rest (HTTP+JSON 1.0, tenant=tenant-a)\n'
85+
'\n'
86+
'--- Capabilities ---\n'
87+
'Streaming : True\n'
88+
'Push notifications : False\n'
89+
'Extended agent card : True\n'
90+
'\n'
91+
'--- I/O Modes ---\n'
92+
'Input : text\n'
93+
'Output : text, task-status\n'
94+
'\n'
95+
'--- Skills ---\n'
96+
'----------------------------------------------------\n'
97+
' ID : skill-1\n'
98+
' Name : My Skill\n'
99+
' Description : Does something useful.\n'
100+
' Tags : foo, bar\n'
101+
' Example : Do the thing\n'
102+
' Example : Another example\n'
103+
'----------------------------------------------------\n'
104+
' ID : skill-2\n'
105+
' Name : Other Skill\n'
106+
' Description : Does something else.\n'
107+
' Tags : baz\n'
108+
'====================================================\n'
109+
)
110+
111+
def test_empty_card_output(
112+
self, capsys: pytest.CaptureFixture[str]
113+
) -> None:
114+
"""Golden test: exact output for a card with only default/empty fields.
115+
116+
An empty supported_interfaces section signals a malformed card —
117+
the bare header with no entries is intentional and visible to the user.
118+
"""
119+
display_agent_card(AgentCard())
120+
assert capsys.readouterr().out == (
121+
'====================================================\n'
122+
' AgentCard \n'
123+
'====================================================\n'
124+
'--- General ---\n'
125+
'Name : \n'
126+
'Description : \n'
127+
'Version : \n'
128+
'\n'
129+
'--- Interfaces ---\n'
130+
'\n'
131+
'--- Capabilities ---\n'
132+
'Streaming : False\n'
133+
'Push notifications : False\n'
134+
'Extended agent card : False\n'
135+
'\n'
136+
'--- I/O Modes ---\n'
137+
'Input : (none)\n'
138+
'Output : (none)\n'
139+
'\n'
140+
'--- Skills ---\n'
141+
' (none)\n'
142+
'====================================================\n'
143+
)
144+
145+
def test_interface_without_protocol_version_has_no_trailing_space(
146+
self, capsys: pytest.CaptureFixture[str]
147+
) -> None:
148+
"""No trailing space in the binding field when protocol_version is not set."""
149+
card = AgentCard(
150+
supported_interfaces=[
151+
AgentInterface(
152+
url='127.0.0.1:50051',
153+
protocol_binding='GRPC',
154+
)
155+
]
156+
)
157+
display_agent_card(card)
158+
assert ' [0] 127.0.0.1:50051 (GRPC)' in capsys.readouterr().out
159+
160+
def test_interface_without_binding_or_version_has_no_parentheses(
161+
self, capsys: pytest.CaptureFixture[str]
162+
) -> None:
163+
"""No parentheses when neither protocol_binding nor protocol_version are set."""
164+
card = AgentCard(
165+
supported_interfaces=[AgentInterface(url='127.0.0.1:50051')]
166+
)
167+
display_agent_card(card)
168+
assert ' [0] 127.0.0.1:50051\n' in capsys.readouterr().out
169+
170+
def test_provider_with_url(
171+
self, capsys: pytest.CaptureFixture[str]
172+
) -> None:
173+
"""Provider shows organization and URL in parentheses when both are set."""
174+
card = AgentCard(
175+
provider=AgentProvider(
176+
organization='Example Org',
177+
url='https://example.com',
178+
),
179+
)
180+
display_agent_card(card)
181+
assert (
182+
'Provider : Example Org (https://example.com)'
183+
in capsys.readouterr().out
184+
)
185+
186+
def test_provider_without_url_has_no_empty_parentheses(
187+
self, capsys: pytest.CaptureFixture[str]
188+
) -> None:
189+
"""No empty parentheses when provider URL is not set."""
190+
card = AgentCard(provider=AgentProvider(organization='Example Org'))
191+
display_agent_card(card)
192+
out = capsys.readouterr().out
193+
assert 'Provider : Example Org' in out
194+
assert '()' not in out

0 commit comments

Comments
 (0)