Skip to content

Commit eb30ac3

Browse files
committed
feat: add a TextClient class for a simplified text-based communication
1 parent 6b56511 commit eb30ac3

6 files changed

Lines changed: 277 additions & 8 deletions

File tree

samples/cli.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,22 @@
1111

1212
from a2a.client import A2ACardResolver, ClientConfig, create_client
1313
from a2a.types import Message, Part, Role, SendMessageRequest, TaskState
14+
from a2a.utils.message import get_message_text
1415

1516

1617
async def _handle_stream(
1718
stream: Any, current_task_id: str | None
1819
) -> str | None:
19-
async for event, task in stream:
20-
if not task:
21-
continue
20+
async for event in stream:
2221
if not current_task_id:
23-
current_task_id = task.id
24-
22+
current_task_id = event.task.id
2523
if event:
2624
if event.HasField('status_update'):
2725
state_name = TaskState.Name(event.status_update.status.state)
2826
print(f'TaskStatusUpdate [state={state_name}]:', end=' ')
2927
if event.status_update.status.HasField('message'):
30-
for part in event.status_update.status.message.parts:
31-
if part.text:
32-
print(part.text, end=' ')
28+
message = event.status_update.status.message
29+
print(get_message_text(message, delimiter=' '))
3330
print()
3431

3532
if (

samples/text_client_cli.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import argparse
2+
import asyncio
3+
4+
import grpc
5+
import httpx
6+
7+
from a2a.client import A2ACardResolver, create_text_client
8+
9+
10+
async def main() -> None:
11+
"""Run the simple A2A terminal client using TextClient."""
12+
parser = argparse.ArgumentParser(description='A2A Simple Text Client')
13+
parser.add_argument(
14+
'--url', default='http://127.0.0.1:41241', help='Agent base URL'
15+
)
16+
args = parser.parse_args()
17+
18+
print(f'Connecting to {args.url}')
19+
20+
async with httpx.AsyncClient() as httpx_client:
21+
resolver = A2ACardResolver(httpx_client, args.url)
22+
card = await resolver.get_agent_card()
23+
print(f'\n✓ Agent Card Found: {card.name}')
24+
25+
text_client = await create_text_client(card)
26+
27+
print('\nConnected! Send a message or type /quit to exit.')
28+
29+
while True:
30+
try:
31+
loop = asyncio.get_running_loop()
32+
user_input = await loop.run_in_executor(None, input, 'You: ')
33+
except KeyboardInterrupt:
34+
break
35+
36+
if user_input.lower() in ('/quit', '/exit'):
37+
break
38+
if not user_input.strip():
39+
continue
40+
41+
try:
42+
response = await text_client.send_text_message(user_input)
43+
print(f'Agent: {response}')
44+
except (httpx.RequestError, grpc.RpcError) as e:
45+
print(f'Error communicating with agent: {e}')
46+
47+
await text_client.close()
48+
49+
50+
if __name__ == '__main__':
51+
asyncio.run(main())

src/a2a/client/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from a2a.client.client_factory import (
1616
ClientFactory,
1717
create_client,
18+
create_text_client,
1819
minimal_agent_card,
1920
)
2021
from a2a.client.errors import (
@@ -24,6 +25,7 @@
2425
)
2526
from a2a.client.helpers import create_text_message_object
2627
from a2a.client.interceptors import ClientCallInterceptor
28+
from a2a.client.text_client import TextClient
2729

2830

2931
__all__ = [
@@ -40,7 +42,9 @@
4042
'ClientFactory',
4143
'CredentialService',
4244
'InMemoryContextCredentialStore',
45+
'TextClient',
4346
'create_client',
47+
'create_text_client',
4448
'create_text_message_object',
4549
'minimal_agent_card',
4650
]

src/a2a/client/client_factory.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from a2a.client.base_client import BaseClient
1313
from a2a.client.card_resolver import A2ACardResolver
1414
from a2a.client.client import Client, ClientConfig
15+
from a2a.client.text_client import TextClient
1516
from a2a.client.transports.base import ClientTransport
1617
from a2a.client.transports.jsonrpc import JsonRpcTransport
1718
from a2a.client.transports.rest import RestTransport
@@ -406,6 +407,47 @@ async def create_client( # noqa: PLR0913
406407
return factory.create(agent, interceptors)
407408

408409

410+
async def create_text_client( # noqa: PLR0913
411+
agent: str | AgentCard,
412+
client_config: ClientConfig | None = None,
413+
interceptors: list[ClientCallInterceptor] | None = None,
414+
relative_card_path: str | None = None,
415+
resolver_http_kwargs: dict[str, Any] | None = None,
416+
signature_verifier: Callable[[AgentCard], None] | None = None,
417+
) -> TextClient:
418+
"""Create a `TextClient` for an agent from a URL or `AgentCard`.
419+
420+
Convenience function that constructs a `ClientFactory` internally.
421+
For reusing a factory across multiple agents or registering custom
422+
transports, use `ClientFactory` directly instead.
423+
424+
Args:
425+
agent: The base URL of the agent, or an `AgentCard` to use
426+
directly.
427+
client_config: Optional `ClientConfig`. A default config is
428+
created if not provided.
429+
interceptors: A list of interceptors to use for each request.
430+
relative_card_path: The relative path when resolving the agent
431+
card. Only used when `agent` is a URL.
432+
resolver_http_kwargs: Dictionary of arguments to provide to the
433+
httpx client when resolving the agent card.
434+
signature_verifier: A callable used to verify the agent card's
435+
signatures.
436+
437+
Returns:
438+
A `TextClient` wrapping the constructed `Client`.
439+
"""
440+
client = await create_client(
441+
agent=agent,
442+
client_config=client_config,
443+
interceptors=interceptors,
444+
relative_card_path=relative_card_path,
445+
resolver_http_kwargs=resolver_http_kwargs,
446+
signature_verifier=signature_verifier,
447+
)
448+
return TextClient(client)
449+
450+
409451
def minimal_agent_card(
410452
url: str, transports: list[str] | None = None
411453
) -> AgentCard:

src/a2a/client/text_client.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import uuid
2+
3+
from a2a.client.client import Client, ClientCallContext
4+
from a2a.types import Message, Part, Role, SendMessageRequest
5+
6+
7+
class TextClient:
8+
"""A facade around Client that simplifies text-based communication.
9+
10+
Wraps an underlying Client instance and exposes a simplified interface
11+
for sending plain-text messages and receiving aggregated text responses.
12+
For full Client API access, use the underlying client directly via
13+
the `client` property.
14+
"""
15+
16+
def __init__(self, client: Client):
17+
self._client = client
18+
19+
@property
20+
def client(self) -> Client:
21+
"""Returns the underlying Client instance for full API access."""
22+
return self._client
23+
24+
async def send_text_message(
25+
self,
26+
text: str,
27+
*,
28+
context: ClientCallContext | None = None,
29+
) -> str:
30+
"""Sends a text message and returns the aggregated text response."""
31+
request = SendMessageRequest(
32+
message=Message(
33+
role=Role.ROLE_USER,
34+
message_id=str(uuid.uuid4()),
35+
parts=[Part(text=text)],
36+
)
37+
)
38+
39+
response_parts: list[str] = []
40+
41+
async for event in self._client.send_message(request, context=context):
42+
if event.HasField('message'):
43+
response_parts.extend(
44+
part.text for part in event.message.parts if part.text
45+
)
46+
elif event.HasField('status_update'):
47+
if event.status_update.status.HasField('message'):
48+
response_parts.extend(
49+
part.text
50+
for part in event.status_update.status.message.parts
51+
if part.text
52+
)
53+
elif event.HasField('artifact_update'):
54+
response_parts.extend(
55+
part.text
56+
for part in event.artifact_update.artifact.parts
57+
if part.text
58+
)
59+
60+
return ' '.join(response_parts)
61+
62+
async def close(self) -> None:
63+
"""Closes the underlying client."""
64+
await self._client.close()

tests/client/test_text_client.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from unittest.mock import AsyncMock
2+
3+
import pytest
4+
5+
from a2a.client import (
6+
Client,
7+
ClientConfig,
8+
ClientCallContext,
9+
create_text_client,
10+
minimal_agent_card,
11+
TextClient,
12+
)
13+
from a2a.types import Part, StreamResponse
14+
15+
16+
@pytest.fixture
17+
def mock_client() -> AsyncMock:
18+
return AsyncMock(spec=Client)
19+
20+
21+
@pytest.fixture
22+
def text_client(mock_client: AsyncMock) -> TextClient:
23+
return TextClient(mock_client)
24+
25+
26+
def test_client_property(
27+
text_client: TextClient, mock_client: AsyncMock
28+
) -> None:
29+
assert text_client.client is mock_client
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_create_client_and_wrap() -> None:
34+
# Create a minimal card
35+
card = minimal_agent_card(url='http://test.com', transports=['JSONRPC'])
36+
37+
config = ClientConfig(supported_protocol_bindings=['JSONRPC'])
38+
39+
text_client = await create_text_client(card, client_config=config)
40+
41+
assert isinstance(text_client, TextClient)
42+
assert isinstance(text_client.client, Client)
43+
44+
# Clean up
45+
await text_client.close()
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_send_text_message(
50+
text_client: TextClient, mock_client: AsyncMock
51+
) -> None:
52+
async def create_stream(*args, **kwargs):
53+
# Event 0: task (ignored)
54+
resp0 = StreamResponse()
55+
resp0.task.id = 'task-1'
56+
yield resp0
57+
58+
# Event 1: direct message
59+
resp1 = StreamResponse()
60+
resp1.message.parts.append(Part(text='Hello'))
61+
yield resp1
62+
63+
# Event 2: status update without message
64+
resp2 = StreamResponse()
65+
resp2.status_update.status.state = 1
66+
yield resp2
67+
68+
# Event 3: status update with message
69+
resp3 = StreamResponse()
70+
resp3.status_update.status.message.parts.append(Part(text='Processing'))
71+
yield resp3
72+
73+
# Event 4: artifact update
74+
resp4 = StreamResponse()
75+
resp4.artifact_update.artifact.parts.append(Part(text='World!'))
76+
yield resp4
77+
78+
mock_client.send_message.return_value = create_stream()
79+
80+
response = await text_client.send_text_message('Hi')
81+
82+
assert response == 'Hello Processing World!'
83+
mock_client.send_message.assert_called_once()
84+
# Verify request construction
85+
args, _ = mock_client.send_message.call_args
86+
request = args[0]
87+
assert request.message.parts[0].text == 'Hi'
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_send_text_message_forwards_context(
92+
text_client: TextClient, mock_client: AsyncMock
93+
) -> None:
94+
95+
async def empty_stream(*args, **kwargs):
96+
return
97+
yield
98+
99+
mock_client.send_message.return_value = empty_stream()
100+
context = ClientCallContext()
101+
102+
await text_client.send_text_message('Hi', context=context)
103+
104+
_, kwargs = mock_client.send_message.call_args
105+
assert kwargs['context'] is context
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_close(text_client: TextClient, mock_client: AsyncMock) -> None:
110+
await text_client.close()
111+
mock_client.close.assert_awaited_once()

0 commit comments

Comments
 (0)