Skip to content

Commit 95b224e

Browse files
committed
Add README.md, task_id persistence
1 parent eb30ac3 commit 95b224e

5 files changed

Lines changed: 311 additions & 24 deletions

File tree

samples/README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# A2A Python SDK — Samples
2+
3+
This directory contains runnable examples demonstrating how to build and interact with an A2A-compliant agent using the Python SDK.
4+
5+
## Contents
6+
7+
| File | Role | Description |
8+
|---|---|---|
9+
| `hello_world_agent.py` | **Server** | A2A agent server |
10+
| `cli.py` | **Client** | Interactive terminal client |
11+
| `text_client_cli.py` | **Client** | Simplified text-only interactive terminal client |
12+
13+
All three samples are designed to work together out of the box: the agent listens on `http://127.0.0.1:41241`, which is the default URL used by both clients.
14+
---
15+
16+
## `hello_world_agent.py` — Agent Server
17+
18+
Implements an A2A agent that responds to simple greeting messages (e.g., "hello", "how are you", "bye") with text replies, simulating a 1-second processing delay.
19+
20+
Demonstrates:
21+
- Subclassing `AgentExecutor` and implementing `execute()` / `cancel()`
22+
- Publishing streaming status updates and artifacts via `TaskUpdater`
23+
- Exposing all three transports in both protocol versions (v1.0 and v0.3 compat) simultaneously:
24+
- **JSON-RPC** (v1.0 and v0.3) at `http://127.0.0.1:41241/a2a/jsonrpc`
25+
- **HTTP+JSON (REST)** (v1.0 and v0.3) at `http://127.0.0.1:41241/a2a/rest`
26+
- **gRPC v1.0** on port `50051`
27+
- **gRPC v0.3 (compat)** on port `50052`
28+
- Serving the agent card at `http://127.0.0.1:41241/.well-known/agent-card.json`
29+
30+
**Run:**
31+
32+
```bash
33+
uv run python samples/hello_world_agent.py
34+
```
35+
36+
---
37+
38+
## `cli.py` — Client
39+
40+
An interactive terminal client with full visibility into the streaming event flow. Each `TaskStatusUpdate` and `TaskArtifactUpdate` event is printed as it arrives.
41+
42+
Features:
43+
- Transport selection via `--transport` flag (`JSONRPC`, `HTTP+JSON`, `GRPC`)
44+
- Session management (`context_id` persisted across messages, `task_id` per task)
45+
- Graceful error handling for HTTP and gRPC failures
46+
47+
**Run:**
48+
49+
```bash
50+
# Connect to the local hello_world_agent (default):
51+
uv run python samples/cli.py
52+
53+
# Connect to a different URL, using gRPC:
54+
uv run python samples/cli.py --url http://192.168.1.10:41241 --transport GRPC
55+
```
56+
57+
Type `/quit` or `/exit` to stop, or press `Ctrl+C`.
58+
59+
---
60+
61+
## `text_client_cli.py` — Simple Text Client
62+
63+
A stripped-down interactive client using the high-level `TextClient` abstraction. It hides all streaming and event mechanics, presenting a simple request/response interface.
64+
65+
Ideal for understanding the **minimum code required** to call an A2A agent.
66+
67+
**Run:**
68+
69+
```bash
70+
# Connect to the local hello_world_agent (default):
71+
uv run python samples/text_client_cli.py
72+
73+
# Connect to a different URL:
74+
uv run python samples/text_client_cli.py --url http://192.168.1.10:41241
75+
76+
# Use a specific transport:
77+
uv run python samples/text_client_cli.py --transport GRPC
78+
```
79+
80+
Type `/quit` or `/exit` to stop, or press `Ctrl+C`.
81+
82+
---
83+
84+
85+
## Quick Start
86+
87+
In two separate terminals:
88+
89+
```bash
90+
# Terminal 1 — start the agent
91+
uv run python samples/hello_world_agent.py
92+
93+
# Terminal 2 — start the client
94+
uv run python samples/cli.py
95+
```
96+
97+
Then type a message like `hello` and press Enter.

samples/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ async def main() -> None:
6565
config = ClientConfig()
6666
if args.transport:
6767
config.supported_protocol_bindings = [args.transport]
68+
if args.transport == 'GRPC':
69+
config.grpc_channel_factory = grpc.aio.insecure_channel
6870

6971
print(
7072
f'Connecting to {args.url} (preferred transport: {args.transport or "Any"})'

samples/text_client_cli.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import grpc
55
import httpx
66

7-
from a2a.client import A2ACardResolver, create_text_client
7+
from a2a.client import A2ACardResolver, ClientConfig, create_text_client
88

99

1010
async def main() -> None:
@@ -13,16 +13,35 @@ async def main() -> None:
1313
parser.add_argument(
1414
'--url', default='http://127.0.0.1:41241', help='Agent base URL'
1515
)
16+
parser.add_argument(
17+
'--transport',
18+
default=None,
19+
help='Preferred transport (JSONRPC, HTTP+JSON, GRPC)',
20+
)
1621
args = parser.parse_args()
1722

18-
print(f'Connecting to {args.url}')
23+
config = ClientConfig()
24+
if args.transport:
25+
config.supported_protocol_bindings = [args.transport]
26+
if args.transport == 'GRPC':
27+
config.grpc_channel_factory = grpc.aio.insecure_channel
28+
29+
print(
30+
f'Connecting to {args.url} (preferred transport: {args.transport or "Any"})'
31+
)
1932

2033
async with httpx.AsyncClient() as httpx_client:
2134
resolver = A2ACardResolver(httpx_client, args.url)
2235
card = await resolver.get_agent_card()
23-
print(f'\n✓ Agent Card Found: {card.name}')
36+
print('\n✓ Agent Card Found:')
37+
print(f' Name: {card.name}')
38+
39+
text_client = await create_text_client(card, client_config=config)
2440

25-
text_client = await create_text_client(card)
41+
actual_transport = getattr(
42+
text_client.client, '_transport', text_client.client
43+
)
44+
print(f' Picked Transport: {actual_transport.__class__.__name__}')
2645

2746
print('\nConnected! Send a message or type /quit to exit.')
2847

src/a2a/client/text_client.py

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,113 @@
11
import uuid
22

3+
from types import TracebackType
4+
5+
from typing_extensions import Self
6+
37
from a2a.client.client import Client, ClientCallContext
4-
from a2a.types import Message, Part, Role, SendMessageRequest
8+
from a2a.types import Message, Part, Role, SendMessageRequest, TaskState
9+
from a2a.utils import get_artifact_text, get_message_text
10+
11+
12+
_TERMINAL_STATES: frozenset[TaskState] = frozenset(
13+
{
14+
TaskState.TASK_STATE_COMPLETED,
15+
TaskState.TASK_STATE_FAILED,
16+
TaskState.TASK_STATE_CANCELED,
17+
TaskState.TASK_STATE_REJECTED,
18+
}
19+
)
520

621

722
class TextClient:
823
"""A facade around Client that simplifies text-based communication.
924
1025
Wraps an underlying Client instance and exposes a simplified interface
1126
for sending plain-text messages and receiving aggregated text responses.
27+
Maintains session state (context_id, task_id) automatically across calls.
1228
For full Client API access, use the underlying client directly via
1329
the `client` property.
1430
"""
1531

1632
def __init__(self, client: Client):
1733
self._client = client
34+
self._context_id: str = str(uuid.uuid4())
35+
self._task_id: str | None = None
36+
37+
async def __aenter__(self) -> Self:
38+
"""Enters the async context manager."""
39+
return self
40+
41+
async def __aexit__(
42+
self,
43+
exc_type: type[BaseException] | None,
44+
exc_val: BaseException | None,
45+
exc_tb: TracebackType | None,
46+
) -> None:
47+
"""Exits the async context manager and closes the client."""
48+
await self.close()
1849

1950
@property
2051
def client(self) -> Client:
2152
"""Returns the underlying Client instance for full API access."""
2253
return self._client
2354

55+
def reset_session(self) -> None:
56+
"""Starts a new session by generating a fresh context ID and clearing the task ID."""
57+
self._context_id = str(uuid.uuid4())
58+
self._task_id = None
59+
2460
async def send_text_message(
2561
self,
2662
text: str,
2763
*,
64+
delimiter: str = ' ',
2865
context: ClientCallContext | None = None,
2966
) -> str:
30-
"""Sends a text message and returns the aggregated text response."""
67+
"""Sends a text message and returns the aggregated text response.
68+
69+
Session state (context_id, task_id) is managed automatically across
70+
calls. Use reset_session() to start a new conversation.
71+
72+
Args:
73+
text: The plain-text message to send.
74+
delimiter: String used to join response parts. Defaults to a
75+
single space. Use '' for token-streamed responses or '\\n'
76+
for paragraph-separated chunks.
77+
context: Optional call-level context.
78+
"""
3179
request = SendMessageRequest(
3280
message=Message(
3381
role=Role.ROLE_USER,
3482
message_id=str(uuid.uuid4()),
83+
context_id=self._context_id,
84+
task_id=self._task_id,
3585
parts=[Part(text=text)],
3686
)
3787
)
3888

3989
response_parts: list[str] = []
4090

4191
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-
)
92+
if event.HasField('task'):
93+
self._task_id = event.task.id
94+
elif event.HasField('message'):
95+
response_parts.append(get_message_text(event.message))
4696
elif event.HasField('status_update'):
97+
if event.status_update.task_id:
98+
self._task_id = event.status_update.task_id
99+
if event.status_update.status.state in _TERMINAL_STATES:
100+
self._task_id = None
47101
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
102+
response_parts.append(
103+
get_message_text(event.status_update.status.message)
52104
)
53105
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
106+
response_parts.append(
107+
get_artifact_text(event.artifact_update.artifact)
58108
)
59109

60-
return ' '.join(response_parts)
110+
return delimiter.join(response_parts)
61111

62112
async def close(self) -> None:
63113
"""Closes the underlying client."""

0 commit comments

Comments
 (0)