Skip to content

Commit cf57ead

Browse files
committed
feat: add broadcast SendLiveMessage support via slim shared-responses
Adds support for the broadcast extension to SendLiveMessage as described in the slimrpc-broadcast-live spec. Every StreamResponse from any agent in a group channel is delivered to all other participants via SLIM's shared-responses transport primitive (slim-bindings PR #85). - a2a_pb2_slimrpc.py: add A2AServiceSharedServicer base class, _A2AServiceServicer_SendLiveMessage_SharedHandler extending StreamStreamSharedHandler, and add_A2AServiceServicer_to_server_shared() - handler.py: add SRPCSharedHandler with asyncio.Queue fan-in merge of client request stream and translated peer StreamResponse events; add _translate_peer_response() implementing spec Section 5 translation (task/status_update/artifact_update/message_update -> StreamRequest) - client_transport.py: add slimrpc_group_shared_channel_factory() using Channel.new_group_shared_with_connection; add field to ClientConfig - __init__.py: export slimrpc_group_shared_channel_factory - examples/echo_agent/server.py: use Server.new_with_shared_responses_and_connection and register both SRPCHandler and SRPCSharedHandler so the server accepts both standard and broadcast sessions without any flags - examples/echo_agent/client.py: add --broadcast flag that uses the shared channel factory and routes to MulticastClient.send_live_message Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com>
1 parent 1888e4f commit cf57ead

8 files changed

Lines changed: 316 additions & 24 deletions

File tree

examples/echo_agent/client.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
AGENT_CARD_WELL_KNOWN_PATH,
1919
)
2020

21-
from slima2a import setup_slim_client
21+
from slima2a import setup_slim_client, slimrpc_group_shared_channel_factory
2222
from slima2a.client_transport import (
2323
ClientConfig,
2424
MultiAgentClientFactory,
@@ -62,12 +62,18 @@ async def main() -> None:
6262
secret="my_shared_secret_for_testing_purposes_only",
6363
)
6464

65+
group_factory = (
66+
slimrpc_group_shared_channel_factory(slim_local_app, conn_id)
67+
if args.broadcast
68+
else slimrpc_group_channel_factory(slim_local_app, conn_id)
69+
)
6570
client_config = ClientConfig(
6671
supported_protocol_bindings=["slimrpc"],
6772
streaming=args.stream,
6873
httpx_client=httpx_client,
6974
slimrpc_channel_factory=slimrpc_channel_factory(slim_local_app, conn_id),
70-
slimrpc_group_channel_factory=slimrpc_group_channel_factory(
75+
slimrpc_group_channel_factory=group_factory,
76+
slimrpc_group_shared_channel_factory=slimrpc_group_shared_channel_factory(
7177
slim_local_app, conn_id
7278
),
7379
)
@@ -97,7 +103,10 @@ async def main() -> None:
97103

98104
client = client_factory.create(card=cards)
99105

100-
if isinstance(client, MulticastClient):
106+
if isinstance(client, MulticastClient) and args.broadcast:
107+
print(f"> {args.text} (broadcast live to {agent_names})")
108+
await send_live_message_broadcast(client, args.text)
109+
elif isinstance(client, MulticastClient):
101110
print(f"> {args.text} (multicast to {agent_names})")
102111
await send_message_multicast(client, args.text)
103112
elif args.live:
@@ -133,6 +142,13 @@ def parse_arguments() -> argparse.Namespace:
133142
default=False,
134143
help="Use SendLiveMessage bidirectional streaming",
135144
)
145+
parser.add_argument(
146+
"--broadcast",
147+
action="store_true",
148+
required=False,
149+
default=False,
150+
help="Use broadcast SendLiveMessage (shared-responses) — requires multiple --agents",
151+
)
136152
parser.add_argument(
137153
"--text",
138154
type=str,
@@ -267,5 +283,31 @@ async def _requests():
267283
print(output)
268284

269285

286+
async def send_live_message_broadcast(client: MulticastClient, text: str) -> None:
287+
message = new_text_message(text, role=Role.ROLE_USER)
288+
289+
async def _requests():
290+
yield StreamRequest(message=message)
291+
292+
try:
293+
async for source, stream_response in client.send_live_message(_requests()):
294+
which = stream_response.WhichOneof("payload")
295+
output = ""
296+
if which == "message":
297+
for part in stream_response.message.parts:
298+
if part.WhichOneof("content") == "text":
299+
output += part.text
300+
elif which == "artifact_update":
301+
artifact = stream_response.artifact_update.artifact
302+
for part in artifact.parts:
303+
if part.WhichOneof("content") == "text":
304+
output += part.text
305+
if output:
306+
print(f" [{source}] {output}")
307+
except Exception as e:
308+
logger.error(f"failed sending broadcast live message: {e}", exc_info=True)
309+
raise RuntimeError("failed sending broadcast live message") from e
310+
311+
270312
if __name__ == "__main__":
271313
asyncio.run(main())

examples/echo_agent/server.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ async def main() -> None:
6565
secret="my_shared_secret_for_testing_purposes_only",
6666
)
6767

68-
# Create server
69-
server = slim_bindings.Server.new_with_connection(
68+
# Create server — always use shared-responses so broadcast clients work
69+
server = slim_bindings.Server.new_with_shared_responses_and_connection(
7070
local_app, local_name, conn_id
7171
)
7272

@@ -80,13 +80,16 @@ async def main() -> None:
8080
add_v0(compat_handler, server)
8181

8282
if args.a2a_version in ("v1", "both"):
83-
from slima2a.handler import SRPCHandler
83+
from slima2a.handler import SRPCHandler, SRPCSharedHandler
8484
from slima2a.types.v1.a2a_pb2_slimrpc import (
8585
add_A2AServiceServicer_to_server as add_v1,
86+
add_A2AServiceServicer_to_server_shared as add_v1_shared,
8687
)
8788

8889
handler = SRPCHandler(agent_card, default_request_handler)
8990
add_v1(handler, server)
91+
shared_handler = SRPCSharedHandler(agent_card, default_request_handler)
92+
add_v1_shared(shared_handler, server)
9093

9194
# Run server
9295
await server.serve_async()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ default-groups = ["linting", "testing", "examples"]
4747

4848
[tool.uv.sources]
4949
a2a-sdk = { git = "https://github.com/Tehsmash/a2a-python", branch = "feat/send-live-message" }
50+
slim-bindings = { path = "../slim-bindings/python", editable = true }
5051

5152
[tool.ruff.format]
5253
exclude = ["*_pb2.py", "*_pb2.pyi", "*_pb2_slimrpc.py"]

slima2a/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
"""SLIMA2A - A2A protocol over slimrpc."""
55

6+
from slima2a.client_transport import slimrpc_group_shared_channel_factory
67
from slima2a.slim_helper import (
78
connect_and_subscribe,
89
initialize_slim_service,
@@ -13,4 +14,5 @@
1314
"connect_and_subscribe",
1415
"initialize_slim_service",
1516
"setup_slim_client",
17+
"slimrpc_group_shared_channel_factory",
1618
]

slima2a/client_transport.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,38 @@ def factory(remotes: list[str]) -> slim_bindings.Channel:
8585
return factory
8686

8787

88+
def slimrpc_group_shared_channel_factory(
89+
local_app: slim_bindings.App,
90+
conn_id: int,
91+
) -> Callable[[list[str]], slim_bindings.Channel]:
92+
def factory(remotes: list[str]) -> slim_bindings.Channel:
93+
members = []
94+
for remote in remotes:
95+
remote_parts = remote.split("/")
96+
if len(remote_parts) != 3:
97+
raise ValueError(
98+
f"Invalid remote format: '{remote}'. Expected format: 'component1/component2/component'"
99+
)
100+
members.append(
101+
slim_bindings.Name(remote_parts[0], remote_parts[1], remote_parts[2])
102+
)
103+
104+
return slim_bindings.Channel.new_group_shared_with_connection(
105+
local_app, members, conn_id
106+
)
107+
108+
return factory
109+
110+
88111
@dataclass
89112
class ClientConfig(A2AClientConfig):
90113
slimrpc_channel_factory: Callable[[str], slim_bindings.Channel] | None = None
91114
slimrpc_group_channel_factory: (
92115
Callable[[list[str]], slim_bindings.Channel] | None
93116
) = None
117+
slimrpc_group_shared_channel_factory: (
118+
Callable[[list[str]], slim_bindings.Channel] | None
119+
) = None
94120

95121

96122
@trace_class(kind=SpanKind.CLIENT)

slima2a/handler.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
# ruff: noqa: N802
5+
import asyncio
56
from abc import ABC, abstractmethod
67
from collections.abc import AsyncIterable, Callable
78

@@ -320,3 +321,137 @@ async def GetExtendedAgentCard(
320321
if self.card_modifier:
321322
card_to_serve = self.card_modifier(card_to_serve)
322323
return card_to_serve
324+
325+
326+
class SRPCSharedHandler(a2a_pb2_slimrpc.A2AServiceSharedServicer):
327+
"""Maps incoming broadcast-live SlimRPC SendLiveMessage calls to the request handler.
328+
329+
Peer agent StreamResponse events are translated to StreamRequest items and
330+
merged into the unified inbound stream before on_live_message_send is called,
331+
so application code sees a single mixed stream of client and peer messages.
332+
"""
333+
334+
def __init__(
335+
self,
336+
agent_card: AgentCard,
337+
request_handler: RequestHandler,
338+
context_builder: CallContextBuilder | None = None,
339+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
340+
) -> None:
341+
self.agent_card = agent_card
342+
self.request_handler = request_handler
343+
self.context_builder = context_builder or DefaultCallContextBuilder()
344+
self.card_modifier = card_modifier
345+
346+
async def raise_error_response(self, error: A2AError) -> None:
347+
code = _SLIM_ERROR_CODE_MAP.get(type(error), code_pb2.UNKNOWN)
348+
raise slim_bindings.RpcError.Rpc(
349+
code=code,
350+
message=f"{type(error).__name__}: {error.message}",
351+
details=None,
352+
)
353+
354+
async def SendLiveMessage(
355+
self,
356+
request_stream: slim_bindings.RequestStream,
357+
context: slim_bindings.Context,
358+
sink: slim_bindings.ResponseSink,
359+
peer_responses: AsyncIterable,
360+
) -> AsyncIterable[a2a_pb2.StreamResponse]:
361+
"""Handles broadcast SendLiveMessage: merges client stream with translated peer events."""
362+
server_context = self.context_builder.build(context)
363+
364+
queue: asyncio.Queue[a2a_pb2.StreamRequest | None] = asyncio.Queue()
365+
366+
async def _feed_client() -> None:
367+
try:
368+
while True:
369+
msg = await request_stream.next_async()
370+
if msg.is_end():
371+
break
372+
if msg.is_error():
373+
await queue.put(None)
374+
return
375+
if msg.is_data():
376+
req = a2a_pb2.StreamRequest.FromString(msg[0])
377+
if not server_context.tenant:
378+
server_context.tenant = req.tenant
379+
await queue.put(req)
380+
finally:
381+
await queue.put(None)
382+
383+
async def _feed_peers() -> None:
384+
try:
385+
async for source, stream_response in peer_responses:
386+
src_str = str(source)
387+
translated = _translate_peer_response(src_str, stream_response)
388+
if translated is not None:
389+
await queue.put(translated)
390+
finally:
391+
await queue.put(None)
392+
393+
async def _merged_stream() -> AsyncIterable[a2a_pb2.StreamRequest]:
394+
client_task = asyncio.ensure_future(_feed_client())
395+
peer_task = asyncio.ensure_future(_feed_peers())
396+
pending = 2
397+
try:
398+
while pending > 0:
399+
item = await queue.get()
400+
if item is None:
401+
pending -= 1
402+
else:
403+
yield item
404+
finally:
405+
client_task.cancel()
406+
peer_task.cancel()
407+
408+
try:
409+
async for event in self.request_handler.on_live_message_send(
410+
_merged_stream(), server_context
411+
):
412+
yield event
413+
except A2AError as e:
414+
await self.raise_error_response(e)
415+
416+
417+
def _translate_peer_response(
418+
source: str,
419+
response: a2a_pb2.StreamResponse,
420+
) -> a2a_pb2.StreamRequest | None:
421+
"""Translate a peer StreamResponse to a StreamRequest per spec Section 5."""
422+
which = response.WhichOneof("payload")
423+
meta = {"slim-src": source}
424+
425+
if which == "task":
426+
task = response.task
427+
meta["slim-peer-task-id"] = task.id
428+
msg = a2a_pb2.Message(
429+
role=a2a_pb2.Role.ROLE_USER,
430+
parts=[a2a_pb2.Part(text=f"peer task started: {task.id}")],
431+
)
432+
return a2a_pb2.StreamRequest(message=msg, metadata=meta)
433+
434+
if which == "status_update":
435+
update = response.status_update
436+
meta["slim-peer-task-id"] = update.task_id
437+
if update.status.HasField("message"):
438+
return a2a_pb2.StreamRequest(message=update.status.message, metadata=meta)
439+
state_name = a2a_pb2.TaskState.Name(update.status.state)
440+
meta["slim-peer-state"] = state_name
441+
msg = a2a_pb2.Message(
442+
role=a2a_pb2.Role.ROLE_USER,
443+
parts=[a2a_pb2.Part(text=f"peer task state: {state_name}")],
444+
)
445+
return a2a_pb2.StreamRequest(message=msg, metadata=meta)
446+
447+
if which == "artifact_update":
448+
update = response.artifact_update
449+
meta["slim-peer-task-id"] = update.task_id
450+
return a2a_pb2.StreamRequest(artifact_update=update, metadata=meta)
451+
452+
if which == "message_update":
453+
update = response.message_update
454+
meta["slim-peer-task-id"] = update.task_id
455+
return a2a_pb2.StreamRequest(message=update.message, metadata=meta)
456+
457+
return None

slima2a/types/v1/a2a_pb2_slimrpc.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,3 +779,68 @@ def add_A2AServiceServicer_to_server(servicer, server: slim_bindings.Server):
779779
method_name="SendLiveMessage",
780780
handler=_A2AServiceServicer_SendLiveMessage_Handler(servicer),
781781
)
782+
783+
784+
class A2AServiceSharedServicer:
785+
"""Shared-responses server servicer for A2AService.
786+
787+
Register with ``add_A2AServiceServicer_to_server_shared`` on a server created
788+
with ``Server.new_with_shared_responses_and_connection``. Each method receives
789+
an extra ``peer_responses`` argument — an async iterable of
790+
``(source, StreamResponse)`` tuples yielded by peer agents in the GROUP.
791+
"""
792+
793+
def SendLiveMessage(self, request_stream, context, sink, peer_responses):
794+
"""Method for SendLiveMessage (shared-responses). Implement your service logic here."""
795+
raise slim_bindings.RpcError.Rpc(
796+
code=slim_bindings.RpcCode.UNIMPLEMENTED,
797+
message="Method not implemented!",
798+
details=None
799+
)
800+
801+
802+
class _A2AServiceServicer_SendLiveMessage_SharedHandler(slim_bindings.StreamStreamSharedHandler):
803+
def __init__(self, servicer):
804+
self.servicer = servicer
805+
806+
async def handle(self, stream: slim_bindings.RequestStream, context: slim_bindings.Context, sink: slim_bindings.ResponseSink, peer_stream: slim_bindings.PeerResponseStream):
807+
try:
808+
async def request_iterator():
809+
while True:
810+
stream_msg = await stream.next_async()
811+
if stream_msg.is_end():
812+
break
813+
if stream_msg.is_error():
814+
raise stream_msg[0]
815+
if stream_msg.is_data():
816+
yield a2a__pb2.StreamRequest.FromString(stream_msg[0])
817+
818+
async def peer_iterator():
819+
while True:
820+
msg = await peer_stream.next_async()
821+
if msg.is_end():
822+
break
823+
if msg.is_data():
824+
yield msg.source, a2a__pb2.StreamResponse.FromString(msg.payload)
825+
826+
response_iter = self.servicer.SendLiveMessage(request_iterator(), context, sink, peer_iterator())
827+
async for response in response_iter:
828+
await sink.send_async(a2a__pb2.StreamResponse.SerializeToString(response))
829+
await sink.close_async()
830+
except slim_bindings.RpcError as e:
831+
await sink.send_error_async(e)
832+
except Exception as e:
833+
rpc_error = slim_bindings.RpcError.Rpc(
834+
code=slim_bindings.RpcCode.INTERNAL,
835+
message=str(e),
836+
details=None
837+
)
838+
await sink.send_error_async(rpc_error)
839+
840+
841+
def add_A2AServiceServicer_to_server_shared(servicer, server: slim_bindings.Server):
842+
server.register_stream_stream_shared(
843+
service_name="lf.a2a.v1.A2AService",
844+
method_name="SendLiveMessage",
845+
handler=_A2AServiceServicer_SendLiveMessage_SharedHandler(servicer),
846+
)

0 commit comments

Comments
 (0)