|
| 1 | +# PR: feat: add ToolContext.send_progress() for streaming tool progress events |
| 2 | + |
| 3 | +### Summary |
| 4 | + |
| 5 | +Adds `ToolContext.send_progress(data)` — a simple API for function tools to emit intermediate progress events during execution. Events appear in `RunResultStreaming.stream_events()` as `ToolProgressStreamEvent` while the tool is still running. In non-streaming mode (`Runner.run()`), calls are silently ignored. |
| 6 | + |
| 7 | +**Motivation (re: #1333)** |
| 8 | + |
| 9 | +Existing lifecycle hooks (`on_tool_start` / `on_tool_end`) fire at the boundaries of tool execution, but they don't cover cases where a tool needs to emit multiple intermediate updates from inside the tool body. Without framework support, developers resort to external shared state or event buses, which add complexity and couple tool logic to infrastructure concerns. Providing an official way for tools to emit mid-execution progress events improves developer experience and makes responsive UIs and long-running workflows (data processing, web scraping, multi-step API calls) much easier to build. |
| 10 | + |
| 11 | +**New stream event type** |
| 12 | + |
| 13 | +A new `ToolProgressStreamEvent` is added to the `StreamEvent` union alongside the existing `RawResponsesStreamEvent`, `RunItemStreamEvent`, and `AgentUpdatedStreamEvent`. It carries: |
| 14 | + |
| 15 | +- `tool_name: str` — identifies which tool emitted the event |
| 16 | +- `tool_call_id: str` — correlates with a specific tool call (important when parallel tools run) |
| 17 | +- `data: Any` — arbitrary progress payload (dict, string, number, etc.) |
| 18 | +- `type: Literal["tool_progress_stream_event"]` — discriminator for pattern matching |
| 19 | + |
| 20 | +Consumers can filter for progress events via `isinstance(event, ToolProgressStreamEvent)` or `event.type == "tool_progress_stream_event"`. |
| 21 | + |
| 22 | +**Design** |
| 23 | + |
| 24 | +- **Transport**: A `_StreamContext` dataclass (holding `event_queue` and `event_loop`) on `RunContextWrapper` — piggybacking on an object already threaded through the entire execution chain. Zero intermediate function signature changes. |
| 25 | +- **API**: `send_progress()` method on `ToolContext` — scoped to function tools, reads per-tool identity (`tool_name`, `tool_call_id`) from the instance. No shared mutable state. |
| 26 | +- **Thread safety**: Uses `loop.call_soon_threadsafe()` with a stored event loop reference so sync tools (`sync_invoker=True`) running in worker threads can safely call `send_progress()`. The loop is captured at wiring time (on the event loop thread), not at call time. |
| 27 | +- **Nested agent-as-tool**: Each `Runner.run_streamed()` creates a new `RunContextWrapper` with its own `_stream_context`. No cross-contamination between outer and inner runs. |
| 28 | + |
| 29 | +**Usage — basic tool with progress** |
| 30 | + |
| 31 | +```python |
| 32 | +from agents import Agent, Runner, function_tool, ToolProgressStreamEvent |
| 33 | +from agents.tool_context import ToolContext |
| 34 | + |
| 35 | +@function_tool |
| 36 | +async def analyze_data(ctx: ToolContext, query: str) -> str: |
| 37 | + ctx.send_progress({"status": "fetching", "progress": 0.25}) |
| 38 | + # ... work ... |
| 39 | + ctx.send_progress({"status": "processing", "progress": 0.75}) |
| 40 | + # ... more work ... |
| 41 | + return "analysis complete" |
| 42 | + |
| 43 | +agent = Agent(name="Analyst", tools=[analyze_data]) |
| 44 | + |
| 45 | +result = Runner.run_streamed(agent, "Analyze Q4 sales") |
| 46 | +async for event in result.stream_events(): |
| 47 | + if isinstance(event, ToolProgressStreamEvent): |
| 48 | + print(f"[{event.tool_name}] {event.data}") |
| 49 | +``` |
| 50 | + |
| 51 | +**Usage — agent-as-tool with `on_stream` handler** |
| 52 | + |
| 53 | +When an agent is used as a tool via `as_tool()`, inner progress events are delivered to the `on_stream` callback: |
| 54 | + |
| 55 | +```python |
| 56 | +from agents import Agent |
| 57 | +from agents.stream_events import ToolProgressStreamEvent |
| 58 | + |
| 59 | +def handle_inner_stream(payload): |
| 60 | + event = payload["event"] |
| 61 | + if isinstance(event, ToolProgressStreamEvent): |
| 62 | + print(f"Inner tool progress: {event.data}") |
| 63 | + |
| 64 | +inner_agent = Agent(name="Researcher", tools=[analyze_data]) |
| 65 | +outer_agent = Agent( |
| 66 | + name="Orchestrator", |
| 67 | + tools=[inner_agent.as_tool(on_stream=handle_inner_stream)], |
| 68 | +) |
| 69 | +``` |
| 70 | + |
| 71 | +**Usage — non-streaming mode (no-op)** |
| 72 | + |
| 73 | +```python |
| 74 | +# send_progress is silently ignored — no error, no side effects |
| 75 | +result = await Runner.run(agent, "Analyze Q4 sales") |
| 76 | +``` |
| 77 | + |
| 78 | +### Test plan |
| 79 | + |
| 80 | +- Unit tests for `send_progress` with active stream context, without context (no-op), and with broken context (failure isolation) |
| 81 | +- Unit tests for `ToolProgressStreamEvent` field validation and `data: Any` flexibility |
| 82 | +- Propagation tests: `_stream_context` survives `_fork_with_tool_input`, `_fork_without_tool_input`, and `ToolContext.from_agent_context` |
| 83 | +- Integration: streaming run with progress events appearing in `stream_events()` |
| 84 | +- Integration: non-streaming run with `send_progress` as no-op |
| 85 | +- Integration: parallel tools emitting progress with correct `tool_call_id` attribution |
| 86 | +- Integration: progress events arrive before `tool_output` event for the same tool |
| 87 | +- 13 tests total, all passing |
| 88 | + |
| 89 | +### Issue number |
| 90 | + |
| 91 | +Closes #1333 |
| 92 | + |
| 93 | +### Checks |
| 94 | + |
| 95 | +- [x] I've added new tests (if relevant) |
| 96 | +- [x] I've added/updated the relevant documentation |
| 97 | +- [x] I've run `make lint` and `make format` |
| 98 | +- [x] I've made sure tests pass |
0 commit comments