Skip to main content

graphflow_stream/
stream.rs

1#![doc = include_str!("../README.md")]
2
3use graph_flow::{
4    Context, ExecutionResult, ExecutionStatus, FlowRunner, Graph, InMemorySessionStorage,
5    NextAction, Session, SessionStorage, Task, TaskResult,
6    error::{GraphError, Result},
7};
8use serde::{Deserialize, Serialize};
9use std::future::Future;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::Duration;
13use tokio::sync::mpsc;
14use tokio::task::JoinHandle;
15
16/// Sending half of a stream channel; a running task's `emit_*` calls write here.
17pub type StreamSender = mpsc::Sender<StreamEvent>;
18/// Receiving half of a stream channel; drain this to observe a run in progress.
19pub type StreamReceiver = mpsc::Receiver<StreamEvent>;
20
21/// An incremental event emitted while a task or graph run is in flight.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub enum StreamEvent {
24    /// A task started running.
25    TaskStarted { task_id: String },
26    /// A partial chunk of output (e.g. one LLM token).
27    Token { task_id: String, delta: String },
28    /// A task finished successfully.
29    TaskFinished { task_id: String },
30    /// A task returned an error instead of finishing normally.
31    TaskFailed { task_id: String, error: String },
32}
33
34tokio::task_local! {
35    static STREAM_TX: StreamSender;
36}
37
38/// Send an event on the ambient stream, if one is active; a no-op otherwise.
39pub async fn emit(event: StreamEvent) {
40    if let Ok(tx) = STREAM_TX.try_with(|tx| tx.clone()) {
41        let _ = tx.send(event).await;
42    }
43}
44
45/// Emit a [`StreamEvent::TaskStarted`] on the ambient stream.
46pub async fn emit_started(task_id: impl Into<String>) {
47    emit(StreamEvent::TaskStarted {
48        task_id: task_id.into(),
49    })
50    .await;
51}
52
53/// Emit a [`StreamEvent::Token`] on the ambient stream.
54pub async fn emit_token(task_id: impl Into<String>, delta: impl Into<String>) {
55    emit(StreamEvent::Token {
56        task_id: task_id.into(),
57        delta: delta.into(),
58    })
59    .await;
60}
61
62/// Emit a [`StreamEvent::TaskFinished`] on the ambient stream.
63pub async fn emit_finished(task_id: impl Into<String>) {
64    emit(StreamEvent::TaskFinished {
65        task_id: task_id.into(),
66    })
67    .await;
68}
69
70/// Emit a [`StreamEvent::TaskFailed`] on the ambient stream.
71pub async fn emit_failed(task_id: impl Into<String>, error: impl Into<String>) {
72    emit(StreamEvent::TaskFailed {
73        task_id: task_id.into(),
74        error: error.into(),
75    })
76    .await;
77}
78
79/// Drain any `Stream<Item = String>` (e.g. a mapped LLM completion) into `emit_token` calls.
80pub async fn forward_text_stream<S>(task_id: impl Into<String>, mut stream: S)
81where
82    S: futures_core::Stream<Item = String> + Unpin,
83{
84    use tokio_stream::StreamExt;
85
86    let task_id = task_id.into();
87    while let Some(delta) = stream.next().await {
88        emit_token(task_id.clone(), delta).await;
89    }
90    emit_finished(task_id).await;
91}
92
93/// Run `fut` with an ambient stream scope active; the primitive `spawn_task`/`spawn_graph` use.
94pub fn run_streaming<F>(buffer: usize, fut: F) -> (StreamReceiver, JoinHandle<F::Output>)
95where
96    F: Future + Send + 'static,
97    F::Output: Send + 'static,
98{
99    let (tx, rx) = mpsc::channel(buffer);
100    let handle = tokio::spawn(STREAM_TX.scope(tx, fut));
101    (rx, handle)
102}
103
104/// Run a single [`Task`], streaming its `emit_*` calls out through the returned receiver.
105pub fn spawn_task<T>(
106    task: Arc<T>,
107    context: Context,
108    buffer: usize,
109) -> (StreamReceiver, JoinHandle<Result<TaskResult>>)
110where
111    T: Task + Send + Sync + 'static,
112{
113    run_streaming(buffer, async move { task.run(context).await })
114}
115
116/// Run a [`Task`] and join its streamed [`StreamEvent::Token`] deltas into one `String`.
117pub async fn collect_text<T>(task: Arc<T>, context: Context, buffer: usize) -> Result<String>
118where
119    T: Task + Send + Sync + 'static,
120{
121    let (mut rx, handle) = spawn_task(task, context, buffer);
122    let mut text = String::new();
123    while let Some(event) = rx.recv().await {
124        if let StreamEvent::Token { delta, .. } = event {
125            text.push_str(&delta);
126        }
127    }
128    handle.await.map_err(|e| GraphError::Other(e.into()))??;
129    Ok(text)
130}
131
132/// Drive a [`FlowRunner`] to completion, streaming every task's `emit_*` calls out.
133pub fn spawn_graph(
134    flow_runner: FlowRunner,
135    session_id: impl Into<String>,
136    buffer: usize,
137) -> (StreamReceiver, JoinHandle<Result<ExecutionResult>>) {
138    let session_id = session_id.into();
139    run_streaming(buffer, async move {
140        run_to_completion(&flow_runner, &session_id).await
141    })
142}
143
144async fn run_to_completion(flow_runner: &FlowRunner, session_id: &str) -> Result<ExecutionResult> {
145    loop {
146        let result = flow_runner.run(session_id).await?;
147        if !matches!(result.status, ExecutionStatus::Paused { .. }) {
148            return Ok(result);
149        }
150    }
151}
152
153static SUBGRAPH_SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
154
155/// A [`Task`] that drives a whole inner [`Graph`], so a graph can be a node in another graph.
156pub struct SubgraphTask {
157    id: String,
158    graph: Arc<Graph>,
159    storage: Arc<dyn SessionStorage>,
160}
161
162impl SubgraphTask {
163    /// Wrap `graph` as a task with the given id, using in-memory session storage.
164    pub fn new(id: impl Into<String>, graph: Arc<Graph>) -> Self {
165        Self {
166            id: id.into(),
167            graph,
168            storage: Arc::new(InMemorySessionStorage::new()),
169        }
170    }
171
172    /// Use a custom [`SessionStorage`] backend for the inner graph's sessions.
173    pub fn with_storage(mut self, storage: Arc<dyn SessionStorage>) -> Self {
174        self.storage = storage;
175        self
176    }
177}
178
179#[async_trait::async_trait]
180impl Task for SubgraphTask {
181    fn id(&self) -> &str {
182        &self.id
183    }
184
185    async fn run(&self, context: Context) -> Result<TaskResult> {
186        let start_task_id = self.graph.start_task_id().ok_or_else(|| {
187            GraphError::TaskNotFound(format!("subgraph '{}' has no start task", self.graph.id))
188        })?;
189
190        let suffix = SUBGRAPH_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
191        let session_id = format!("{}:{}", self.id, suffix);
192
193        let mut session = Session::new_from_task(session_id.clone(), start_task_id)
194            .with_graph_id(self.graph.id.clone());
195        session.context = context;
196        self.storage.save(session).await?;
197
198        let runner = FlowRunner::new(self.graph.clone(), self.storage.clone());
199        let result = run_to_completion(&runner, &session_id).await?;
200
201        let next_action = match result.status {
202            ExecutionStatus::WaitingForInput => NextAction::WaitForInput,
203            _ => NextAction::Continue,
204        };
205        Ok(TaskResult::new(result.response, next_action))
206    }
207}
208
209/// A [`StreamEvent`] plus its offset from the start of the recording.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct RecordedEvent {
212    pub event: StreamEvent,
213    pub at: Duration,
214}
215
216/// A recorded run: every [`StreamEvent`] a `StreamReceiver` produced, with timing.
217///
218/// Serializable, so a recording can be saved and replayed later — e.g. to debug a
219/// past run, or demo a UI without hitting an LLM again.
220#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
221pub struct Recording {
222    pub events: Vec<RecordedEvent>,
223}
224
225impl Recording {
226    /// Replay this recording on a fresh channel, preserving the original timing between events.
227    pub fn replay(self, buffer: usize) -> StreamReceiver {
228        let (tx, rx) = mpsc::channel(buffer);
229        tokio::spawn(async move {
230            let mut last = Duration::ZERO;
231            for recorded in self.events {
232                let wait = recorded.at.saturating_sub(last);
233                if !wait.is_zero() {
234                    tokio::time::sleep(wait).await;
235                }
236                last = recorded.at;
237                if tx.send(recorded.event).await.is_err() {
238                    return;
239                }
240            }
241        });
242        rx
243    }
244}
245
246/// Drain a `StreamReceiver` into a [`Recording`], timestamping each event from the start.
247pub async fn record(mut rx: StreamReceiver) -> Recording {
248    let start = std::time::Instant::now();
249    let mut events = Vec::new();
250    while let Some(event) = rx.recv().await {
251        events.push(RecordedEvent {
252            event,
253            at: start.elapsed(),
254        });
255    }
256    Recording { events }
257}
258
259fn map_key(prefix: &Option<String>, child_id: &str, field: &str) -> String {
260    match prefix {
261        Some(p) => format!("{p}.{child_id}.{field}"),
262        None => format!("map.{child_id}.{field}"),
263    }
264}
265
266/// Fans out over a list of child tasks built from `context` at run time — the
267/// same shape as `graph_flow::fanout::FanOutTask`, except the number and
268/// identity of children isn't fixed until the task actually runs. This is
269/// what LangGraph's `Send` API covers in Python: "run this step once per
270/// item in a list I only know at runtime" (once per retrieved document, once
271/// per subtask an LLM just planned out), which a construction-time `Vec` of
272/// children can't express.
273pub struct DynamicMapTask<F> {
274    id: String,
275    children_fn: F,
276    prefix: Option<String>,
277    next_action: NextAction,
278}
279
280impl<F> DynamicMapTask<F>
281where
282    F: Fn(&Context) -> Vec<Arc<dyn Task>> + Send + Sync,
283{
284    /// `children_fn` inspects `context` and returns the child tasks to run
285    /// concurrently this time — typically one per item in a list `context`
286    /// already holds.
287    pub fn new(id: impl Into<String>, children_fn: F) -> Self {
288        Self {
289            id: id.into(),
290            children_fn,
291            prefix: None,
292            next_action: NextAction::Continue,
293        }
294    }
295
296    /// Store aggregated child results under `<prefix>.<child_id>.response`
297    /// instead of the default `map.<child_id>.response`.
298    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
299        self.prefix = Some(prefix.into());
300        self
301    }
302
303    pub fn with_next_action(mut self, next_action: NextAction) -> Self {
304        self.next_action = next_action;
305        self
306    }
307}
308
309#[async_trait::async_trait]
310impl<F> Task for DynamicMapTask<F>
311where
312    F: Fn(&Context) -> Vec<Arc<dyn Task>> + Send + Sync,
313{
314    fn id(&self) -> &str {
315        &self.id
316    }
317
318    async fn run(&self, context: Context) -> Result<TaskResult> {
319        let children = (self.children_fn)(&context);
320        let mut set = tokio::task::JoinSet::new();
321
322        for child in children {
323            let ctx = context.clone();
324            set.spawn(async move {
325                let child_id = child.id().to_string();
326                (child_id, child.run(ctx).await)
327            });
328        }
329
330        let mut first_error = None;
331        let mut completed = 0usize;
332
333        while let Some(joined) = set.join_next().await {
334            match joined {
335                Err(join_err) => {
336                    first_error.get_or_insert_with(|| {
337                        GraphError::TaskExecutionFailed(format!(
338                            "DynamicMapTask child join error: {join_err}"
339                        ))
340                    });
341                }
342                Ok((child_id, Err(e))) => {
343                    first_error.get_or_insert_with(|| {
344                        GraphError::TaskExecutionFailed(format!(
345                            "DynamicMapTask child '{child_id}' failed: {e}"
346                        ))
347                    });
348                }
349                Ok((child_id, Ok(result))) => {
350                    if let Some(response) = result.response {
351                        context.set(map_key(&self.prefix, &child_id, "response"), response)?;
352                    }
353                    completed += 1;
354                }
355            }
356        }
357
358        if let Some(err) = first_error {
359            return Err(err);
360        }
361
362        let summary = format!(
363            "DynamicMapTask '{}' mapped over {completed} item(s)",
364            self.id
365        );
366        Ok(TaskResult::new(Some(summary), self.next_action.clone()))
367    }
368}
369
370/// Runs the same [`Task`] `runs` times concurrently and reduces the
371/// responses into one — self-consistency prompting: sample an LLM call
372/// several times (usually with temperature > 0) and combine the answers
373/// instead of trusting a single draw.
374pub struct EnsembleTask<T, R> {
375    id: String,
376    inner: Arc<T>,
377    runs: usize,
378    reducer: R,
379    next_action: NextAction,
380}
381
382impl<T, R> EnsembleTask<T, R>
383where
384    T: Task + 'static,
385    R: Fn(Vec<String>) -> String + Send + Sync,
386{
387    /// Run `inner` `runs` times (at least 1) and combine the responses with `reducer`.
388    pub fn new(id: impl Into<String>, inner: T, runs: usize, reducer: R) -> Self {
389        Self {
390            id: id.into(),
391            inner: Arc::new(inner),
392            runs: runs.max(1),
393            reducer,
394            next_action: NextAction::Continue,
395        }
396    }
397
398    pub fn with_next_action(mut self, next_action: NextAction) -> Self {
399        self.next_action = next_action;
400        self
401    }
402}
403
404#[async_trait::async_trait]
405impl<T, R> Task for EnsembleTask<T, R>
406where
407    T: Task + 'static,
408    R: Fn(Vec<String>) -> String + Send + Sync,
409{
410    fn id(&self) -> &str {
411        &self.id
412    }
413
414    async fn run(&self, context: Context) -> Result<TaskResult> {
415        let mut set = tokio::task::JoinSet::new();
416        for _ in 0..self.runs {
417            let inner = self.inner.clone();
418            let ctx = context.clone();
419            set.spawn(async move { inner.run(ctx).await });
420        }
421
422        let mut responses = Vec::with_capacity(self.runs);
423        let mut first_error = None;
424
425        while let Some(joined) = set.join_next().await {
426            match joined {
427                Err(join_err) => {
428                    first_error.get_or_insert_with(|| {
429                        GraphError::TaskExecutionFailed(format!(
430                            "EnsembleTask run join error: {join_err}"
431                        ))
432                    });
433                }
434                Ok(Err(e)) => {
435                    first_error.get_or_insert_with(|| {
436                        GraphError::TaskExecutionFailed(format!("EnsembleTask run failed: {e}"))
437                    });
438                }
439                Ok(Ok(result)) => {
440                    if let Some(response) = result.response {
441                        responses.push(response);
442                    }
443                }
444            }
445        }
446
447        if let Some(err) = first_error {
448            return Err(err);
449        }
450
451        let combined = (self.reducer)(responses);
452        Ok(TaskResult::new(Some(combined), self.next_action.clone()))
453    }
454}
455
456/// A ready-made [`EnsembleTask`] reducer: the most common response wins,
457/// ties broken by whichever came first. Fits self-consistency prompting,
458/// where every run should converge on the same discrete answer.
459pub fn majority_vote(responses: Vec<String>) -> String {
460    let mut counts: Vec<(String, usize)> = Vec::new();
461    for response in responses {
462        match counts.iter_mut().find(|(r, _)| *r == response) {
463            Some(entry) => entry.1 += 1,
464            None => counts.push((response, 1)),
465        }
466    }
467    counts
468        .into_iter()
469        .max_by_key(|(_, count)| *count)
470        .map(|(response, _)| response)
471        .unwrap_or_default()
472}