Skip to content

Commit 0c35f76

Browse files
committed
feat: add SendLiveMessage bidirectional streaming support
Implements the SendLiveMessage bidi streaming RPC from the A2A v1.1 proposal (https://github.com/Tehsmash/a2a-python/tree/feat/send-live-message). - Add SendLiveMessage to A2AServiceStub (client, via call_stream_stream), A2AServiceServicer (UNIMPLEMENTED stub), _A2AServiceServicer_SendLiveMessage_Handler (StreamStreamHandler), and register_stream_stream in add_A2AServiceServicer_to_server - Add SRPCHandler.SendLiveMessage: bridges RequestStream to decoded async iterable of StreamRequest protos, delegates to request_handler.on_live_message_send - Add SRPCTransport.send_live_message: opens BidiStreamHandler, concurrently sends serialized StreamRequests and yields decoded StreamResponse events - Point a2a-sdk dependency to Tehsmash/a2a-python@feat/send-live-message (the branch adding StreamRequest and on_live_message_send) - Add --live flag to echo_agent client example demonstrating the new method Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com>
1 parent a7a3cd2 commit 0c35f76

6 files changed

Lines changed: 144 additions & 22 deletions

File tree

examples/echo_agent/client.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
minimal_agent_card,
1414
)
1515
from a2a.helpers import new_text_message
16-
from a2a.types.a2a_pb2 import AgentCard, Role, SendMessageRequest
16+
from a2a.types.a2a_pb2 import AgentCard, Role, SendMessageRequest, StreamRequest
1717
from a2a.utils.constants import (
1818
AGENT_CARD_WELL_KNOWN_PATH,
1919
)
@@ -100,6 +100,10 @@ async def main() -> None:
100100
if isinstance(client, MulticastClient):
101101
print(f"> {args.text} (multicast to {agent_names})")
102102
await send_message_multicast(client, args.text)
103+
elif args.live:
104+
logger.info("A2AClient initialized.")
105+
print(f"> {args.text} (live)")
106+
await send_live_message(client, args.text)
103107
else:
104108
logger.info("A2AClient initialized.")
105109
response_text = await send_message(client, args.text)
@@ -122,6 +126,13 @@ def parse_arguments() -> argparse.Namespace:
122126
required=False,
123127
default=False,
124128
)
129+
parser.add_argument(
130+
"--live",
131+
action="store_true",
132+
required=False,
133+
default=False,
134+
help="Use SendLiveMessage bidirectional streaming",
135+
)
125136
parser.add_argument(
126137
"--text",
127138
type=str,
@@ -230,5 +241,31 @@ async def send_message_multicast(
230241
raise RuntimeError("failed sending multicast message") from e
231242

232243

244+
async def send_live_message(client: Client, text: str) -> None:
245+
message = new_text_message(text, role=Role.ROLE_USER)
246+
247+
async def _requests():
248+
yield StreamRequest(message=message)
249+
250+
output = ""
251+
try:
252+
async for stream_response in client.send_live_message(_requests()):
253+
which = stream_response.WhichOneof("payload")
254+
if which == "message":
255+
for part in stream_response.message.parts:
256+
if part.WhichOneof("content") == "text":
257+
output += part.text
258+
elif which == "artifact_update":
259+
artifact = stream_response.artifact_update.artifact
260+
for part in artifact.parts:
261+
if part.WhichOneof("content") == "text":
262+
output += part.text
263+
except Exception as e:
264+
logger.error(f"failed sending live message: {e}", exc_info=True)
265+
raise RuntimeError("failed sending live message") from e
266+
267+
print(output)
268+
269+
233270
if __name__ == "__main__":
234271
asyncio.run(main())

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ description = "A2A protocol over slimrpc"
88
readme = "README.md"
99
requires-python = ">=3.10, <4.0"
1010
dependencies = [
11-
"a2a-sdk[telemetry,sqlite]==1.1.0",
11+
"a2a-sdk[telemetry,sqlite]",
1212
"slim-bindings~=2.0",
1313
]
1414
classifiers = [
@@ -45,6 +45,9 @@ examples = [
4545
[tool.uv]
4646
default-groups = ["linting", "testing", "examples"]
4747

48+
[tool.uv.sources]
49+
a2a-sdk = { git = "https://github.com/Tehsmash/a2a-python", branch = "feat/send-live-message" }
50+
4851
[tool.ruff.format]
4952
exclude = ["*_pb2.py", "*_pb2.pyi", "*_pb2_slimrpc.py"]
5053

slima2a/client_transport.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright AGNTCY Contributors (https://github.com/agntcy)
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import asyncio
45
import logging
56
from collections.abc import AsyncGenerator
67
from dataclasses import dataclass
@@ -242,6 +243,31 @@ async def get_extended_agent_card(
242243
return card
243244
return await self.stub.GetExtendedAgentCard(request)
244245

246+
async def send_live_message(
247+
self,
248+
request_stream: "AsyncGenerator[Any, None]",
249+
*,
250+
context: ClientCallContext | None = None,
251+
) -> AsyncGenerator[StreamResponse, None]:
252+
"""Sends a bidirectional streaming live message request to the agent."""
253+
bidi = self.stub.SendLiveMessage()
254+
async def _send():
255+
async for req in request_stream:
256+
await bidi.send_async(req.SerializeToString())
257+
await bidi.close_send_async()
258+
send_task = asyncio.ensure_future(_send())
259+
try:
260+
while True:
261+
msg = await bidi.recv_async()
262+
if msg.is_end():
263+
break
264+
if msg.is_error():
265+
raise msg[0]
266+
if msg.is_data():
267+
yield StreamResponse.FromString(msg[0])
268+
finally:
269+
await send_task
270+
245271
async def close(self) -> None:
246272
"""Closes the transport and releases any resources."""
247273
pass

slima2a/handler.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,36 @@ async def DeleteTaskPushNotificationConfig(
280280
await self.raise_error_response(e)
281281
return empty_pb2.Empty()
282282

283+
async def SendLiveMessage(
284+
self,
285+
request_stream: slim_bindings.RequestStream,
286+
context: slim_bindings.Context,
287+
sink: slim_bindings.ResponseSink,
288+
) -> AsyncIterable[a2a_pb2.StreamResponse]:
289+
"""Handles the 'SendLiveMessage' SlimRPC bidi streaming method."""
290+
server_context = self.context_builder.build(context)
291+
292+
async def _decoded_stream() -> AsyncIterable[a2a_pb2.StreamRequest]:
293+
while True:
294+
msg = await request_stream.next_async()
295+
if msg.is_end():
296+
break
297+
if msg.is_error():
298+
raise msg[0]
299+
if msg.is_data():
300+
req = a2a_pb2.StreamRequest.FromString(msg[0])
301+
if not server_context.tenant:
302+
server_context.tenant = req.tenant
303+
yield req
304+
305+
try:
306+
async for event in self.request_handler.on_live_message_send(
307+
_decoded_stream(), server_context
308+
):
309+
yield event
310+
except A2AError as e:
311+
await self.raise_error_response(e)
312+
283313
async def GetExtendedAgentCard(
284314
self,
285315
request: a2a_pb2.GetExtendedAgentCardRequest,

slima2a/types/v1/a2a_pb2_slimrpc.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ async def DeleteTaskPushNotificationConfig(self, request: a2a__pb2.DeleteTaskPus
158158
)
159159
return google__protobuf__empty_pb2.Empty.FromString(response_bytes)
160160

161+
def SendLiveMessage(self, timeout: Optional[timedelta] = None, metadata: Optional[dict[str, str]] = None) -> slim_bindings.BidiStreamHandler:
162+
"""Open a bidirectional streaming SendLiveMessage call."""
163+
return self._channel.call_stream_stream(
164+
"lf.a2a.v1.A2AService",
165+
"SendLiveMessage",
166+
timeout,
167+
metadata,
168+
)
161169

162170

163171
class A2AServiceGroupStub:
@@ -467,7 +475,13 @@ def DeleteTaskPushNotificationConfig(self, request, context):
467475
details=None
468476
)
469477

470-
478+
def SendLiveMessage(self, request_stream, context, sink):
479+
"""Method for SendLiveMessage. Implement your service logic here."""
480+
raise slim_bindings.RpcError.Rpc(
481+
code=slim_bindings.RpcCode.UNIMPLEMENTED,
482+
message="Method not implemented!",
483+
details=None
484+
)
471485

472486

473487
class _A2AServiceServicer_SendMessage_Handler(slim_bindings.UnaryUnaryHandler):
@@ -675,6 +689,27 @@ async def handle(self, request: bytes, context: slim_bindings.Context) -> bytes:
675689
)
676690

677691

692+
class _A2AServiceServicer_SendLiveMessage_Handler(slim_bindings.StreamStreamHandler):
693+
def __init__(self, servicer):
694+
self.servicer = servicer
695+
696+
async def handle(self, stream: slim_bindings.RequestStream, context: slim_bindings.Context, sink: slim_bindings.ResponseSink):
697+
try:
698+
response_iter = self.servicer.SendLiveMessage(stream, context, sink)
699+
async for response in response_iter:
700+
await sink.send_async(a2a__pb2.StreamResponse.SerializeToString(response))
701+
await sink.close_async()
702+
except slim_bindings.RpcError as e:
703+
await sink.send_error_async(e)
704+
except Exception as e:
705+
rpc_error = slim_bindings.RpcError.Rpc(
706+
code=slim_bindings.RpcCode.INTERNAL,
707+
message=str(e),
708+
details=None
709+
)
710+
await sink.send_error_async(rpc_error)
711+
712+
678713
def add_A2AServiceServicer_to_server(servicer, server: slim_bindings.Server):
679714
server.register_unary_unary(
680715
service_name="lf.a2a.v1.A2AService",
@@ -731,3 +766,8 @@ def add_A2AServiceServicer_to_server(servicer, server: slim_bindings.Server):
731766
method_name="DeleteTaskPushNotificationConfig",
732767
handler=_A2AServiceServicer_DeleteTaskPushNotificationConfig_Handler(servicer),
733768
)
769+
server.register_stream_stream(
770+
service_name="lf.a2a.v1.A2AService",
771+
method_name="SendLiveMessage",
772+
handler=_A2AServiceServicer_SendLiveMessage_Handler(servicer),
773+
)

uv.lock

Lines changed: 5 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)