Nomearod Claude Opus 4.6 (1M context) commited on
Commit
d0f0142
·
1 Parent(s): 1d16fd9

feat: orchestrator.run_stream emits per-stage SSE events

Browse files

Yields 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 CHANGED
@@ -176,11 +176,11 @@ class Orchestrator:
176
  strategy: str = "hybrid",
177
  history: list[dict] | None = None,
178
  ) -> AsyncIterator[StreamEvent]:
179
- """Stream the final synthesis. Tool-use iterations are NOT streamed.
180
 
181
- Tool calls (retrieval, calculator) are fast (~100ms each). The slow
182
- part is the final LLM synthesis (~3-4s). Streaming only the final
183
- answer keeps the tool-use loop simple and deterministic.
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: loop never ran, no response yet
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
- # Step 2: Emit sources
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="done",
250
- metadata={"estimated_cost_usd": total_cost},
 
 
 
 
 
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