Skip to content

Commit ced59ca

Browse files
authored
feat(harness): invoke_stream + sub-agent delta propagation + scripted mock streaming (#17)
* feat(harness): invoke_stream — caller-consumable run event stream Adds AgentHarness::invoke_stream, the streaming counterpart to invoke_streaming that returns an async Stream of the run's events instead of only the final AgentRun. The run's EventSink already fans out every AgentEvent in a single ordered sequence (model/reasoning deltas, ToolStarted/ToolCompleted lifecycle, SubAgentStarted/Completed — the latter reach the stream because sub-agents share the parent's sink). invoke_stream subscribes a ChannelListener that forwards each EventRecord into an unbounded channel, drives the shared streaming loop path, and yields AgentStreamItem::Event(..) for each event followed by exactly one terminal AgentStreamItem::Completed(run) / Failed(err). It is a thin projection over the same emit fan-out, not a parallel event system; the run advances only while the stream is polled. Lineage rides the events themselves (ModelDelta stamps run_id; sub-agent events stamp depth). Streaming a child agent's own model deltas into the parent — which needs the streaming flag routed through the sub-agent runner (children run the non-streaming path today) — is tracked as follow-up. Tests: event-then-Completed ordering, tool-lifecycle surfacing (ToolStarted before ToolCompleted), and the Failed terminal on a limit trip. Ref: docs/tinyagents-vendor-improvement-plan/03-streaming.md Step 1 (V3) * feat(subagent): propagate child model deltas to the parent stream A streaming parent (invoke_stream) previously saw a sub-agent's start/complete lifecycle but none of the child's own model/reasoning deltas: run_child always drove the child through the unary invoke_in_context path. Thread the drive mode down to the child so it matches the parent: - RunContext gains a streaming flag, set by the loop driver (drive). - ToolExecutionContext carries it (from_run_context), so a tool sees whether its caller run is streaming. - SubAgentTool::call_with_context passes context.streaming to run_child, and invoke_in_parent forwards parent.streaming; run_child then drives the child through invoke_streaming_in_context when streaming. Because sub-agents already share the parent's EventSink, the child's deltas then land on the parent stream stamped with the child's own run_id and depth. A non-streaming parent leaves streaming=false, so its event stream is unchanged (direct SubAgent::invoke/invoke_with_events stay non-streaming). Tests: nested run asserts child ModelDeltas (run id 'researcher-d1…') arrive before the parent's RunCompleted, plus SubAgentStarted/Completed at depth 1; and that a non-streaming parent emits no child deltas. Ref: docs/tinyagents-vendor-improvement-plan/03-streaming.md Step 2 (V3) * feat(mock): MockModel::streaming_script for incremental streaming tests Adds a MockBehavior::StreamScript(Vec<ModelStreamItem>) variant and the MockModel::streaming_script constructor. stream() emits the scripted items verbatim — so tests exercise truly incremental streaming (fine-grained text/reasoning/tool-call deltas in an exact order) rather than the one-or-two synthetic deltas the other constructors replay — while invoke() folds the same items through a StreamAccumulator to produce the equivalent unary response. Tests: invoke_stream over a scripted reasoning+text stream surfaces the reasoning delta and multiple incremental text deltas; invoke folds a delta-only script into the reconstructed response. Ref: docs/tinyagents-vendor-improvement-plan/03-streaming.md Step 4 (V3)
1 parent 9442bd2 commit ced59ca

13 files changed

Lines changed: 607 additions & 5 deletions

File tree

src/graph/goals/test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ mod tool_tests {
274274
depth: 0,
275275
max_turn_output_tokens: None,
276276
events: EventSink::new(),
277+
streaming: false,
277278
workspace: None,
278279
}
279280
}

src/graph/todos/test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ mod tool_tests {
376376
depth: 0,
377377
max_turn_output_tokens: None,
378378
events: EventSink::new(),
379+
streaming: false,
379380
workspace: None,
380381
}
381382
}

src/harness/agent_loop/entry.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
177177
) -> Result<AgentLoopResult> {
178178
let run_id = ctx.config.run_id.clone();
179179
let thread_id = ctx.config.thread_id.clone();
180+
// Record the drive mode so tool execution contexts (and the sub-agents
181+
// they spawn) can match it — child deltas then propagate to the parent
182+
// stream via the shared sink.
183+
ctx.streaming = streaming;
180184

181185
let mut status = HarnessRunStatus::new(run_id.clone(), ComponentId::new("agent_loop"));
182186
if let Some(thread) = thread_id {

src/harness/agent_loop/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,10 @@ use serde_json::Value;
9494
mod entry;
9595
mod model_call;
9696
mod run_loop;
97+
mod stream;
9798
mod tools;
9899

100+
pub use stream::AgentStreamItem;
101+
99102
#[cfg(test)]
100103
mod test;

src/harness/agent_loop/stream.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! Caller-consumable streaming entry point ([`AgentHarness::invoke_stream`]).
2+
//!
3+
//! [`AgentHarness::invoke_streaming`] drives each model call through
4+
//! [`ChatModel::stream`][crate::harness::model::ChatModel::stream] but only
5+
//! returns the fully-accumulated [`AgentRun`] once the run is over — a caller
6+
//! that wants to *watch* the run unfold has to attach an
7+
//! [`EventListener`][crate::harness::events::EventListener] or middleware.
8+
//!
9+
//! `invoke_stream` closes that gap: it returns an async
10+
//! [`Stream`][futures::Stream] of [`AgentStreamItem`]s that yields every
11+
//! [`AgentEvent`][crate::harness::events::AgentEvent] emitted during the run —
12+
//! model/reasoning deltas, tool-call `ToolStarted`/`ToolCompleted` lifecycle
13+
//! events, and sub-agent `SubAgentStarted`/`SubAgentCompleted` events (which
14+
//! reach the stream because sub-agents share the parent's
15+
//! [`EventSink`][crate::harness::events::EventSink]) — and finishes with a
16+
//! terminal item carrying the completed [`AgentRun`] (or the failure).
17+
//!
18+
//! It is a thin projection over the same ordered
19+
//! [`EventSink::emit`][crate::harness::events::EventSink::emit] fan-out the
20+
//! `EventListener` path already uses, not a parallel event system: a listener
21+
//! forwards each [`EventRecord`] into an unbounded channel that the returned
22+
//! stream drains. Lineage is carried by the events themselves (`ModelDelta`
23+
//! stamps `run_id`; sub-agent events stamp `depth`). Streaming a child agent's
24+
//! own model deltas into the parent requires routing the streaming flag through
25+
//! the sub-agent runner and is tracked as follow-up work.
26+
27+
use std::future::Future;
28+
use std::pin::Pin;
29+
use std::sync::Arc;
30+
31+
use crate::error::Result;
32+
use crate::harness::context::{RunConfig, RunContext};
33+
use crate::harness::events::{EventListener, EventRecord};
34+
use crate::harness::message::Message;
35+
use crate::harness::middleware::AgentRun;
36+
use crate::harness::runtime::AgentHarness;
37+
38+
use super::AgentLoopResult;
39+
40+
/// One item yielded by [`AgentHarness::invoke_stream`].
41+
///
42+
/// The stream yields zero or more [`AgentStreamItem::Event`]s in emission
43+
/// order, followed by exactly one terminal item — either
44+
/// [`AgentStreamItem::Completed`] carrying the final [`AgentRun`], or
45+
/// [`AgentStreamItem::Failed`] carrying the error string. No items are produced
46+
/// after the terminal item.
47+
#[derive(Clone, Debug)]
48+
pub enum AgentStreamItem {
49+
/// A live event emitted during the run. Carries the full
50+
/// [`EventRecord`] (id + offset + [`AgentEvent`][crate::harness::events::AgentEvent])
51+
/// so consumers keep ordering and lineage fields (`run_id`, `depth`).
52+
Event(EventRecord),
53+
/// Terminal: the run completed successfully. Boxed because [`AgentRun`] is
54+
/// large relative to the event variant.
55+
Completed(Box<AgentRun>),
56+
/// Terminal: the run failed; carries the error rendered as a string.
57+
Failed(String),
58+
}
59+
60+
/// An [`EventListener`] that forwards every [`EventRecord`] into an unbounded
61+
/// channel. `on_event` is synchronous and must not block, so the channel is
62+
/// unbounded; in practice its depth is bounded by run progress (the loop awaits
63+
/// the network between emits). Send failures (receiver dropped) are ignored so
64+
/// a dropped stream never disturbs the run.
65+
struct ChannelListener {
66+
tx: tokio::sync::mpsc::UnboundedSender<EventRecord>,
67+
}
68+
69+
impl EventListener for ChannelListener {
70+
fn on_event(&self, record: &EventRecord) {
71+
let _ = self.tx.send(record.clone());
72+
}
73+
}
74+
75+
/// Maps a finished run result onto its terminal [`AgentStreamItem`].
76+
fn terminal_item(result: Result<AgentLoopResult>) -> AgentStreamItem {
77+
match result {
78+
Ok(loop_result) => AgentStreamItem::Completed(Box::new(loop_result.run)),
79+
Err(error) => AgentStreamItem::Failed(error.to_string()),
80+
}
81+
}
82+
83+
/// Drive-phase for the streaming state machine.
84+
enum Phase<'a> {
85+
/// The run future is still executing.
86+
Running(Pin<Box<dyn Future<Output = Result<AgentLoopResult>> + 'a>>),
87+
/// The run has finished; drain any buffered events, then emit `terminal`.
88+
Draining(AgentStreamItem),
89+
/// Terminal item already emitted; the stream is exhausted.
90+
Done,
91+
}
92+
93+
impl<State: Send + Sync, Ctx: Send + Sync + 'static> AgentHarness<State, Ctx> {
94+
/// Runs the agent loop while streaming every emitted event to the caller.
95+
///
96+
/// Returns a [`Stream`][futures::Stream] of [`AgentStreamItem`]s: live
97+
/// [`AgentStreamItem::Event`]s in emission order (model/reasoning deltas,
98+
/// tool-call lifecycle, sub-agent lifecycle, usage, …) followed by a single
99+
/// terminal [`AgentStreamItem::Completed`] / [`AgentStreamItem::Failed`].
100+
/// Driving the loop and consuming the stream are the same task: the run
101+
/// only makes progress while the stream is polled, so a caller that stops
102+
/// polling pauses the run (and dropping the stream cancels it).
103+
///
104+
/// This is the streaming counterpart of
105+
/// [`AgentHarness::invoke_streaming`]; the run itself is identical (each
106+
/// model call is driven through
107+
/// [`ChatModel::stream`][crate::harness::model::ChatModel::stream]).
108+
pub fn invoke_stream<'a>(
109+
&'a self,
110+
state: &'a State,
111+
ctx_data: Ctx,
112+
config: RunConfig,
113+
input: Vec<Message>,
114+
) -> impl futures::Stream<Item = AgentStreamItem> + 'a {
115+
let ctx = RunContext::new(config, ctx_data);
116+
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
117+
// Subscribe before driving so no event (starting with `RunStarted`) is
118+
// missed. The listener rides the run's `EventSink`, which sub-agents
119+
// clone, so their lifecycle events reach this stream too.
120+
ctx.events.subscribe(Arc::new(ChannelListener { tx }));
121+
122+
// `invoke_streaming_in_context_with_status` is the public wrapper over
123+
// the shared `drive(.., streaming = true)` path; it drives *our* `ctx`
124+
// (with the listener already attached) and hands back the terminal
125+
// `AgentLoopResult`.
126+
let run_fut: Pin<Box<dyn Future<Output = Result<AgentLoopResult>> + 'a>> =
127+
Box::pin(self.invoke_streaming_in_context_with_status(state, ctx, input));
128+
129+
futures::stream::unfold(
130+
(Phase::Running(run_fut), rx),
131+
|(phase, mut rx)| async move {
132+
match phase {
133+
Phase::Running(mut run_fut) => {
134+
tokio::select! {
135+
biased;
136+
// Prefer draining ready events so the consumer sees
137+
// fine-grained progress rather than a late burst.
138+
maybe = rx.recv() => match maybe {
139+
Some(record) => {
140+
Some((AgentStreamItem::Event(record), (Phase::Running(run_fut), rx)))
141+
}
142+
None => {
143+
// All senders dropped (the run's context —
144+
// and every sub-agent clone of the sink —
145+
// is gone): the run is finishing. Await it
146+
// for the terminal item.
147+
let terminal = terminal_item(run_fut.await);
148+
Some((terminal, (Phase::Done, rx)))
149+
}
150+
},
151+
result = &mut run_fut => {
152+
// The run finished. Events emitted during this
153+
// final poll may still be buffered; drain them
154+
// ahead of the terminal item.
155+
let terminal = terminal_item(result);
156+
match rx.try_recv() {
157+
Ok(record) => Some((
158+
AgentStreamItem::Event(record),
159+
(Phase::Draining(terminal), rx),
160+
)),
161+
Err(_) => Some((terminal, (Phase::Done, rx))),
162+
}
163+
}
164+
}
165+
}
166+
Phase::Draining(terminal) => match rx.try_recv() {
167+
Ok(record) => Some((
168+
AgentStreamItem::Event(record),
169+
(Phase::Draining(terminal), rx),
170+
)),
171+
Err(_) => Some((terminal, (Phase::Done, rx))),
172+
},
173+
Phase::Done => None,
174+
}
175+
},
176+
)
177+
}
178+
}

0 commit comments

Comments
 (0)