Spaces:
Running
Running
feat: orchestrator.run_stream emits per-stage SSE events
Browse filesYields retrieval, reranking, and llm stage events during the tool-use
loop with iteration counters. Tool call events include arguments for
dashboard display. Legacy sources/chunk events preserved at end.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- agent_bench/agents/orchestrator.py +84 -15
- tests/test_stream_stages.py +112 -0
agent_bench/agents/orchestrator.py
CHANGED
|
@@ -176,11 +176,11 @@ class Orchestrator:
|
|
| 176 |
strategy: str = "hybrid",
|
| 177 |
history: list[dict] | None = None,
|
| 178 |
) -> AsyncIterator[StreamEvent]:
|
| 179 |
-
"""Stream
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
"""
|
| 185 |
from agent_bench.serving.schemas import StreamEvent
|
| 186 |
|
|
@@ -198,16 +198,38 @@ class Orchestrator:
|
|
| 198 |
tools = self.registry.get_definitions()
|
| 199 |
all_sources: list[str] = []
|
| 200 |
total_cost = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
# Step 1: Run tool-use loop normally (non-streamed)
|
| 203 |
-
for _ in range(self.max_iterations):
|
| 204 |
response = await self.provider.complete(
|
| 205 |
messages, tools=tools, temperature=self.temperature
|
| 206 |
)
|
| 207 |
total_cost += response.usage.estimated_cost_usd
|
|
|
|
|
|
|
|
|
|
| 208 |
if not response.tool_calls:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
break
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
messages.append(
|
| 212 |
Message(
|
| 213 |
role=Role.ASSISTANT,
|
|
@@ -215,39 +237,86 @@ class Orchestrator:
|
|
| 215 |
tool_calls=response.tool_calls,
|
| 216 |
)
|
| 217 |
)
|
|
|
|
|
|
|
| 218 |
for tc in response.tool_calls:
|
| 219 |
kwargs = dict(tc.arguments)
|
| 220 |
if tc.name == "search_documents":
|
| 221 |
kwargs.setdefault("top_k", req_top_k)
|
| 222 |
kwargs["_strategy"] = req_strategy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
result = await self.registry.execute(tc.name, **kwargs)
|
|
|
|
| 224 |
messages.append(
|
| 225 |
Message(role=Role.TOOL, content=result.result, tool_call_id=tc.id)
|
| 226 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
if "sources" in result.metadata:
|
| 228 |
all_sources.extend(result.metadata["sources"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
# Handle max_iterations=0
|
| 231 |
if self.max_iterations == 0:
|
| 232 |
response = await self.provider.complete(
|
| 233 |
messages, tools=None, temperature=self.temperature
|
| 234 |
)
|
| 235 |
total_cost += response.usage.estimated_cost_usd
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
#
|
| 238 |
yield StreamEvent(
|
| 239 |
type="sources",
|
| 240 |
sources=[{"source": s} for s in dict.fromkeys(all_sources)],
|
| 241 |
)
|
| 242 |
-
|
| 243 |
-
# Step 3: Emit the final answer as a single chunk.
|
| 244 |
-
# The loop's last complete() already produced the synthesis — reuse it
|
| 245 |
-
# instead of making a redundant stream_complete() call.
|
| 246 |
yield StreamEvent(type="chunk", content=response.content)
|
| 247 |
-
|
| 248 |
yield StreamEvent(
|
| 249 |
-
type="
|
| 250 |
-
metadata={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
)
|
| 252 |
|
| 253 |
|
|
|
|
| 176 |
strategy: str = "hybrid",
|
| 177 |
history: list[dict] | None = None,
|
| 178 |
) -> AsyncIterator[StreamEvent]:
|
| 179 |
+
"""Stream with per-stage events for the showcase dashboard.
|
| 180 |
|
| 181 |
+
Yields stage events during the tool-use loop, then the legacy
|
| 182 |
+
sources/chunk/done events. Stage events are additive — existing
|
| 183 |
+
consumers that only handle sources/chunk/done are unaffected.
|
| 184 |
"""
|
| 185 |
from agent_bench.serving.schemas import StreamEvent
|
| 186 |
|
|
|
|
| 198 |
tools = self.registry.get_definitions()
|
| 199 |
all_sources: list[str] = []
|
| 200 |
total_cost = 0.0
|
| 201 |
+
total_input_tokens = 0
|
| 202 |
+
total_output_tokens = 0
|
| 203 |
+
iteration = 0
|
| 204 |
+
|
| 205 |
+
for iteration in range(1, self.max_iterations + 1):
|
| 206 |
+
# --- LLM stage: running ---
|
| 207 |
+
yield StreamEvent(type="stage", metadata={
|
| 208 |
+
"stage": "llm", "status": "running", "iteration": iteration,
|
| 209 |
+
})
|
| 210 |
|
|
|
|
|
|
|
| 211 |
response = await self.provider.complete(
|
| 212 |
messages, tools=tools, temperature=self.temperature
|
| 213 |
)
|
| 214 |
total_cost += response.usage.estimated_cost_usd
|
| 215 |
+
total_input_tokens += response.usage.input_tokens
|
| 216 |
+
total_output_tokens += response.usage.output_tokens
|
| 217 |
+
|
| 218 |
if not response.tool_calls:
|
| 219 |
+
# --- LLM stage: done (final answer) ---
|
| 220 |
+
yield StreamEvent(type="stage", metadata={
|
| 221 |
+
"stage": "llm", "status": "done", "iteration": iteration,
|
| 222 |
+
})
|
| 223 |
break
|
| 224 |
|
| 225 |
+
# --- LLM stage: tool_call ---
|
| 226 |
+
for tc in response.tool_calls:
|
| 227 |
+
yield StreamEvent(type="stage", metadata={
|
| 228 |
+
"stage": "llm", "status": "tool_call", "iteration": iteration,
|
| 229 |
+
"tool": tc.name,
|
| 230 |
+
"arguments": tc.arguments,
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
messages.append(
|
| 234 |
Message(
|
| 235 |
role=Role.ASSISTANT,
|
|
|
|
| 237 |
tool_calls=response.tool_calls,
|
| 238 |
)
|
| 239 |
)
|
| 240 |
+
|
| 241 |
+
# Execute each tool call
|
| 242 |
for tc in response.tool_calls:
|
| 243 |
kwargs = dict(tc.arguments)
|
| 244 |
if tc.name == "search_documents":
|
| 245 |
kwargs.setdefault("top_k", req_top_k)
|
| 246 |
kwargs["_strategy"] = req_strategy
|
| 247 |
+
|
| 248 |
+
# --- Retrieval stage: running ---
|
| 249 |
+
if tc.name == "search_documents":
|
| 250 |
+
yield StreamEvent(type="stage", metadata={
|
| 251 |
+
"stage": "retrieval", "status": "running", "iteration": iteration,
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
result = await self.registry.execute(tc.name, **kwargs)
|
| 255 |
+
|
| 256 |
messages.append(
|
| 257 |
Message(role=Role.TOOL, content=result.result, tool_call_id=tc.id)
|
| 258 |
)
|
| 259 |
+
|
| 260 |
+
if tc.name == "search_documents":
|
| 261 |
+
pre_rerank = result.metadata.get("pre_rerank_count", 0)
|
| 262 |
+
|
| 263 |
+
# --- Retrieval stage: done ---
|
| 264 |
+
yield StreamEvent(type="stage", metadata={
|
| 265 |
+
"stage": "retrieval", "status": "done", "iteration": iteration,
|
| 266 |
+
"chunks_pre_rerank": pre_rerank,
|
| 267 |
+
})
|
| 268 |
+
|
| 269 |
+
# --- Reranking stage (if reranking happened) ---
|
| 270 |
+
if pre_rerank > 0:
|
| 271 |
+
yield StreamEvent(type="stage", metadata={
|
| 272 |
+
"stage": "reranking", "status": "running", "iteration": iteration,
|
| 273 |
+
})
|
| 274 |
+
yield StreamEvent(type="stage", metadata={
|
| 275 |
+
"stage": "reranking", "status": "done", "iteration": iteration,
|
| 276 |
+
"chunks": result.metadata.get("chunks", []),
|
| 277 |
+
})
|
| 278 |
+
|
| 279 |
if "sources" in result.metadata:
|
| 280 |
all_sources.extend(result.metadata["sources"])
|
| 281 |
+
else:
|
| 282 |
+
# Max iterations hit — force text answer without tools
|
| 283 |
+
yield StreamEvent(type="stage", metadata={
|
| 284 |
+
"stage": "llm", "status": "running", "iteration": iteration,
|
| 285 |
+
})
|
| 286 |
+
response = await self.provider.complete(
|
| 287 |
+
messages, tools=None, temperature=self.temperature
|
| 288 |
+
)
|
| 289 |
+
total_cost += response.usage.estimated_cost_usd
|
| 290 |
+
total_input_tokens += response.usage.input_tokens
|
| 291 |
+
total_output_tokens += response.usage.output_tokens
|
| 292 |
+
yield StreamEvent(type="stage", metadata={
|
| 293 |
+
"stage": "llm", "status": "done", "iteration": iteration,
|
| 294 |
+
})
|
| 295 |
|
| 296 |
+
# Handle max_iterations=0
|
| 297 |
if self.max_iterations == 0:
|
| 298 |
response = await self.provider.complete(
|
| 299 |
messages, tools=None, temperature=self.temperature
|
| 300 |
)
|
| 301 |
total_cost += response.usage.estimated_cost_usd
|
| 302 |
+
total_input_tokens += response.usage.input_tokens
|
| 303 |
+
total_output_tokens += response.usage.output_tokens
|
| 304 |
|
| 305 |
+
# --- Legacy events (backward-compatible) ---
|
| 306 |
yield StreamEvent(
|
| 307 |
type="sources",
|
| 308 |
sources=[{"source": s} for s in dict.fromkeys(all_sources)],
|
| 309 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
yield StreamEvent(type="chunk", content=response.content)
|
| 311 |
+
# done event emitted by route handler (has latency)
|
| 312 |
yield StreamEvent(
|
| 313 |
+
type="_orchestrator_done",
|
| 314 |
+
metadata={
|
| 315 |
+
"estimated_cost_usd": total_cost,
|
| 316 |
+
"tokens_in": total_input_tokens,
|
| 317 |
+
"tokens_out": total_output_tokens,
|
| 318 |
+
"iterations": iteration if iteration else 1,
|
| 319 |
+
},
|
| 320 |
)
|
| 321 |
|
| 322 |
|
tests/test_stream_stages.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for SSE stage events emitted by the orchestrator."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from agent_bench.agents.orchestrator import Orchestrator
|
| 6 |
+
from agent_bench.core.provider import MockProvider
|
| 7 |
+
from agent_bench.tools.registry import ToolRegistry
|
| 8 |
+
|
| 9 |
+
from tests.test_agent import FakeSearchTool
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestOrchestratorStageEvents:
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def orchestrator(self):
|
| 15 |
+
registry = ToolRegistry()
|
| 16 |
+
registry.register(FakeSearchTool())
|
| 17 |
+
return Orchestrator(
|
| 18 |
+
provider=MockProvider(),
|
| 19 |
+
registry=registry,
|
| 20 |
+
max_iterations=3,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
@pytest.mark.asyncio
|
| 24 |
+
async def test_stream_emits_retrieval_stage(self, orchestrator):
|
| 25 |
+
events = []
|
| 26 |
+
async for event in orchestrator.run_stream(
|
| 27 |
+
question="How do path params work?",
|
| 28 |
+
system_prompt="You are a test assistant.",
|
| 29 |
+
):
|
| 30 |
+
events.append(event)
|
| 31 |
+
|
| 32 |
+
stage_events = [e for e in events if e.type == "stage"]
|
| 33 |
+
retrieval_events = [e for e in stage_events if e.metadata.get("stage") == "retrieval"]
|
| 34 |
+
assert len(retrieval_events) >= 2 # running + done
|
| 35 |
+
done = [e for e in retrieval_events if e.metadata.get("status") == "done"]
|
| 36 |
+
assert len(done) >= 1
|
| 37 |
+
assert "chunks_pre_rerank" in done[0].metadata
|
| 38 |
+
|
| 39 |
+
@pytest.mark.asyncio
|
| 40 |
+
async def test_stream_emits_reranking_stage(self, orchestrator):
|
| 41 |
+
events = []
|
| 42 |
+
async for event in orchestrator.run_stream(
|
| 43 |
+
question="How do path params work?",
|
| 44 |
+
system_prompt="You are a test assistant.",
|
| 45 |
+
):
|
| 46 |
+
events.append(event)
|
| 47 |
+
|
| 48 |
+
stage_events = [e for e in events if e.type == "stage"]
|
| 49 |
+
reranking_events = [e for e in stage_events if e.metadata.get("stage") == "reranking"]
|
| 50 |
+
assert len(reranking_events) >= 1 # at least done (running may be instant)
|
| 51 |
+
|
| 52 |
+
@pytest.mark.asyncio
|
| 53 |
+
async def test_stream_emits_llm_stage(self, orchestrator):
|
| 54 |
+
events = []
|
| 55 |
+
async for event in orchestrator.run_stream(
|
| 56 |
+
question="How do path params work?",
|
| 57 |
+
system_prompt="You are a test assistant.",
|
| 58 |
+
):
|
| 59 |
+
events.append(event)
|
| 60 |
+
|
| 61 |
+
stage_events = [e for e in events if e.type == "stage"]
|
| 62 |
+
llm_events = [e for e in stage_events if e.metadata.get("stage") == "llm"]
|
| 63 |
+
assert len(llm_events) >= 1 # at least done
|
| 64 |
+
|
| 65 |
+
@pytest.mark.asyncio
|
| 66 |
+
async def test_stream_stage_events_have_iteration(self, orchestrator):
|
| 67 |
+
events = []
|
| 68 |
+
async for event in orchestrator.run_stream(
|
| 69 |
+
question="How do path params work?",
|
| 70 |
+
system_prompt="You are a test assistant.",
|
| 71 |
+
):
|
| 72 |
+
events.append(event)
|
| 73 |
+
|
| 74 |
+
stage_events = [e for e in events if e.type == "stage"]
|
| 75 |
+
for e in stage_events:
|
| 76 |
+
if e.metadata.get("stage") in ("retrieval", "reranking", "llm"):
|
| 77 |
+
assert "iteration" in e.metadata
|
| 78 |
+
|
| 79 |
+
@pytest.mark.asyncio
|
| 80 |
+
async def test_stream_preserves_sources_chunk_done_order(self, orchestrator):
|
| 81 |
+
events = []
|
| 82 |
+
async for event in orchestrator.run_stream(
|
| 83 |
+
question="How do path params work?",
|
| 84 |
+
system_prompt="You are a test assistant.",
|
| 85 |
+
):
|
| 86 |
+
events.append(event)
|
| 87 |
+
|
| 88 |
+
# Filter to legacy event types
|
| 89 |
+
legacy = [e for e in events if e.type in ("sources", "chunk", "_orchestrator_done")]
|
| 90 |
+
assert len(legacy) >= 3
|
| 91 |
+
types = [e.type for e in legacy]
|
| 92 |
+
assert types[0] == "sources"
|
| 93 |
+
assert types[-1] == "_orchestrator_done"
|
| 94 |
+
|
| 95 |
+
@pytest.mark.asyncio
|
| 96 |
+
async def test_stream_tool_call_includes_arguments(self, orchestrator):
|
| 97 |
+
"""MockProvider emits a search_documents tool call on first iteration."""
|
| 98 |
+
events = []
|
| 99 |
+
async for event in orchestrator.run_stream(
|
| 100 |
+
question="How do path params work?",
|
| 101 |
+
system_prompt="You are a test assistant.",
|
| 102 |
+
):
|
| 103 |
+
events.append(event)
|
| 104 |
+
|
| 105 |
+
stage_events = [e for e in events if e.type == "stage"]
|
| 106 |
+
llm_tool_calls = [e for e in stage_events
|
| 107 |
+
if e.metadata.get("stage") == "llm"
|
| 108 |
+
and e.metadata.get("status") == "tool_call"]
|
| 109 |
+
# MockProvider returns tool calls when tools are provided
|
| 110 |
+
if llm_tool_calls:
|
| 111 |
+
assert "tool" in llm_tool_calls[0].metadata
|
| 112 |
+
assert "arguments" in llm_tool_calls[0].metadata
|