Skip to content

Commit eb8e7ff

Browse files
Steve-Dustyclaude
andcommitted
feat: add arun_stream and run_stream to HierarchicalSwarm
Bridge the existing callback-based streaming in run() to async/sync generators so HierarchicalSwarm can be consumed the same way as AgentRearrange and SequentialWorkflow. arun_stream(with_events=True) emits agent_start / token / agent_end structured events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0b6016 commit eb8e7ff

1 file changed

Lines changed: 177 additions & 7 deletions

File tree

swarms/structs/hiearchical_swarm.py

Lines changed: 177 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
import asyncio
2424
import json
2525
import os
26+
import queue as _queue
27+
import threading
2628
import time
2729
import traceback
2830
from concurrent.futures import ThreadPoolExecutor, as_completed
29-
from typing import Any, Callable, List, Optional, Union
31+
from typing import Any, Callable, Dict, List, Optional, Union
3032

3133
from loguru import logger
3234
from pydantic import BaseModel, Field
@@ -1528,12 +1530,18 @@ def agent_streaming_callback(chunk: str):
15281530
f"{error_msg}\n[TRACE] Traceback: {traceback.format_exc()}"
15291531
)
15301532

1531-
output = agent.run(
1532-
task=f"History: {self.conversation.get_str()} \n\n Task: {task}",
1533-
streaming_callback=agent_streaming_callback,
1534-
*args,
1535-
**kwargs,
1536-
)
1533+
# Temporarily enable streaming so call_llm honours the callback
1534+
original_streaming_on = getattr(agent, "streaming_on", False)
1535+
agent.streaming_on = True
1536+
try:
1537+
output = agent.run(
1538+
task=f"History: {self.conversation.get_str()} \n\n Task: {task}",
1539+
streaming_callback=agent_streaming_callback,
1540+
*args,
1541+
**kwargs,
1542+
)
1543+
finally:
1544+
agent.streaming_on = original_streaming_on
15371545

15381546
# Call completion callback
15391547
try:
@@ -1894,3 +1902,165 @@ async def arun(
18941902
*args,
18951903
**kwargs,
18961904
)
1905+
1906+
async def arun_stream(
1907+
self,
1908+
task: Optional[str] = None,
1909+
img: Optional[str] = None,
1910+
with_events: bool = False,
1911+
**kwargs,
1912+
):
1913+
"""Async generator that streams tokens from worker agents.
1914+
1915+
Bridges the existing callback-based streaming in ``run()`` to an
1916+
async generator using an ``asyncio.Queue``. The synchronous
1917+
``run()`` executes in a background thread; each token callback
1918+
pushes an item to the queue which this generator yields.
1919+
1920+
Args:
1921+
task: The task to be processed by the swarm.
1922+
img: Optional image input for the agents.
1923+
with_events: When False (default), yield ``(agent_name, token)``
1924+
tuples. When True, yield structured event dicts:
1925+
``{"type": "agent_start", "agent": ...}``,
1926+
``{"type": "token", "agent": ..., "token": ...}``,
1927+
``{"type": "agent_end", "agent": ...}``.
1928+
1929+
Yields:
1930+
tuple | dict: Per-token streaming items.
1931+
"""
1932+
q: asyncio.Queue = asyncio.Queue()
1933+
DONE = object()
1934+
ERROR = object()
1935+
loop = asyncio.get_running_loop()
1936+
1937+
# Track which agents have started and accumulate their tokens
1938+
agent_chunks: Dict[str, List[str]] = {}
1939+
1940+
def _streaming_callback(
1941+
agent_name: str, chunk: str, is_final: bool
1942+
):
1943+
loop.call_soon_threadsafe(
1944+
q.put_nowait, (agent_name, chunk, is_final)
1945+
)
1946+
1947+
async def _run_in_thread():
1948+
try:
1949+
await asyncio.to_thread(
1950+
self.run,
1951+
task=task,
1952+
img=img,
1953+
streaming_callback=_streaming_callback,
1954+
**kwargs,
1955+
)
1956+
except Exception as e:
1957+
loop.call_soon_threadsafe(
1958+
q.put_nowait, (ERROR, e, False)
1959+
)
1960+
finally:
1961+
loop.call_soon_threadsafe(
1962+
q.put_nowait, (DONE, None, False)
1963+
)
1964+
1965+
runner = asyncio.create_task(_run_in_thread())
1966+
1967+
try:
1968+
while True:
1969+
item = await q.get()
1970+
kind, payload, is_final = item
1971+
1972+
if kind is DONE:
1973+
break
1974+
if kind is ERROR:
1975+
raise payload
1976+
1977+
agent_name: str = kind
1978+
chunk: str = payload
1979+
1980+
if agent_name not in agent_chunks:
1981+
agent_chunks[agent_name] = []
1982+
if with_events:
1983+
yield {
1984+
"type": "agent_start",
1985+
"agent": agent_name,
1986+
}
1987+
1988+
if is_final:
1989+
agent_output = "".join(agent_chunks.pop(agent_name, []))
1990+
if with_events:
1991+
yield {
1992+
"type": "agent_end",
1993+
"agent": agent_name,
1994+
"output": agent_output,
1995+
}
1996+
else:
1997+
if chunk:
1998+
agent_chunks[agent_name].append(chunk)
1999+
if with_events:
2000+
yield {
2001+
"type": "token",
2002+
"agent": agent_name,
2003+
"token": chunk,
2004+
}
2005+
else:
2006+
yield (agent_name, chunk)
2007+
finally:
2008+
await runner
2009+
2010+
def run_stream(
2011+
self,
2012+
task: Optional[str] = None,
2013+
img: Optional[str] = None,
2014+
with_events: bool = False,
2015+
**kwargs,
2016+
):
2017+
"""Sync generator version of ``arun_stream``.
2018+
2019+
Bridges the async generator to a sync iterator using a thread
2020+
and a ``queue.Queue``. Use ``arun_stream`` directly when you
2021+
already have a running event loop (e.g. inside a FastAPI handler).
2022+
2023+
Args:
2024+
task: The task to be processed by the swarm.
2025+
img: Optional image input for the agents.
2026+
with_events: When False (default), yield ``(agent_name, token)``
2027+
tuples. When True, yield structured event dicts.
2028+
2029+
Yields:
2030+
tuple | dict: Per-token streaming items.
2031+
"""
2032+
sync_q: _queue.Queue = _queue.Queue()
2033+
DONE = object()
2034+
exc_holder: List[Optional[Exception]] = [None]
2035+
2036+
def _runner():
2037+
async def _consume():
2038+
async for evt in self.arun_stream(
2039+
task=task,
2040+
img=img,
2041+
with_events=with_events,
2042+
**kwargs,
2043+
):
2044+
sync_q.put(evt)
2045+
2046+
try:
2047+
asyncio.run(_consume())
2048+
except Exception as e:
2049+
exc_holder[0] = e
2050+
finally:
2051+
sync_q.put(DONE)
2052+
2053+
t = threading.Thread(target=_runner, daemon=True)
2054+
t.start()
2055+
2056+
try:
2057+
while True:
2058+
item = sync_q.get()
2059+
if item is DONE:
2060+
break
2061+
yield item
2062+
finally:
2063+
t.join(timeout=5)
2064+
2065+
if exc_holder[0] is not None:
2066+
raise exc_holder[0]

0 commit comments

Comments
 (0)