Skip to main content

polyc_llm/
turn.rs

1//! Turn helpers: a [`StubProvider`] for wiring/tests and [`collect_turn`],
2//! which folds a provider's [`Chunk`] stream into a single [`TurnOutput`].
3//!
4//! `collect_turn` is the output half of the bridge between this crate's
5//! streaming vocabulary and the message-granular wire types: the harness drains
6//! a provider stream into a `TurnOutput`, then maps that to wire `Message`s.
7
8use async_trait::async_trait;
9use futures::{Stream, StreamExt, stream};
10
11use crate::{
12    Chunk, CompletionRequest, LlmProvider, StopReason, Usage, error::DummyError, request::ToolCall,
13};
14
15/// An incremental event observed while folding a turn, for live streaming.
16///
17/// Surfaces like Slack `chat.appendStream` or a streaming CLI consume these;
18/// the buffered [`TurnOutput`] is still returned in full — this is a side
19/// channel, not a replacement.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TurnStreamEvent {
22    /// A freshly-generated piece of answer text (concatenate to reconstruct).
23    TextDelta(String),
24    /// A freshly-generated piece of model reasoning ("thinking") text, distinct
25    /// from the answer. Observers may render it as a collapsed thought; it is
26    /// never concatenated into the answer text.
27    ReasoningDelta(String),
28    /// The model has begun a tool call (`id` + `name` known up front).
29    ToolStarted {
30        /// Provider-assigned call id.
31        id: String,
32        /// Name of the tool being called.
33        name: String,
34    },
35}
36
37/// The fully-assembled result of one turn, folded from a [`Chunk`] stream.
38#[derive(Debug, Default, Clone)]
39pub struct TurnOutput {
40    /// Concatenated text deltas.
41    pub text: String,
42    /// Concatenated reasoning ("thinking") deltas, kept separate from `text`.
43    /// Empty for providers/models that don't expose reasoning.
44    pub reasoning: String,
45    /// Completed tool calls, in arrival order.
46    pub tool_calls: Vec<ToolCall>,
47    /// Final token accounting (last [`Chunk::Usage`] seen).
48    pub usage: Usage,
49    /// Why the turn ended, if the stream reported it.
50    pub stop: Option<StopReason>,
51}
52
53/// Drain a provider stream into a [`TurnOutput`].
54///
55/// Text deltas concatenate; a tool call accretes from its
56/// `ToolCallStart`/`ToolCallArgsDelta`/`ToolCallEnd` run (matched by `id`);
57/// usage and stop reason are taken from their chunks.
58///
59/// # Errors
60///
61/// Propagates the first `Err` item from the stream.
62pub async fn collect_turn<S, E>(stream: S) -> Result<TurnOutput, E>
63where
64    S: Stream<Item = Result<Chunk, E>> + Unpin,
65{
66    collect_turn_observed(stream, |_| {}).await
67}
68
69/// Like [`collect_turn`], but observes each streamable event as it arrives.
70///
71/// Invokes `on_event` for each text delta / tool start while still folding and
72/// returning the complete [`TurnOutput`]. `on_event` is synchronous and must
73/// not block (e.g. an unbounded-channel `send`).
74///
75/// # Errors
76///
77/// Propagates the first `Err` item from the stream.
78pub async fn collect_turn_observed<S, E, F>(mut stream: S, mut on_event: F) -> Result<TurnOutput, E>
79where
80    S: Stream<Item = Result<Chunk, E>> + Unpin,
81    F: FnMut(TurnStreamEvent),
82{
83    let mut out = TurnOutput::default();
84    // In-progress tool calls, kept in start order and matched by id. A provider
85    // may interleave several calls (OpenAI's `parallel_tool_calls` defaults to
86    // true) and/or defer all their `ToolCallEnd`s to the end of the stream, so a
87    // single `Option` would let a second `ToolCallStart` clobber the first and
88    // an `ToolCallEnd` close the wrong call. Matching by id throughout keeps
89    // every parallel call intact regardless of emission order.
90    let mut pending: Vec<ToolCall> = Vec::new();
91    while let Some(item) = stream.next().await {
92        match item? {
93            Chunk::TextDelta(s) => {
94                on_event(TurnStreamEvent::TextDelta(s.clone()));
95                out.text.push_str(&s);
96            }
97            Chunk::ReasoningDelta(s) => {
98                on_event(TurnStreamEvent::ReasoningDelta(s.clone()));
99                out.reasoning.push_str(&s);
100            }
101            Chunk::ToolCallStart {
102                id,
103                name,
104                signature,
105            } => {
106                on_event(TurnStreamEvent::ToolStarted {
107                    id: id.clone(),
108                    name: name.clone(),
109                });
110                pending.push(ToolCall {
111                    id,
112                    name,
113                    args_json: String::new(),
114                    signature,
115                });
116            }
117            Chunk::ToolCallArgsDelta {
118                id,
119                args_json_delta,
120            } => {
121                if let Some(tc) = pending.iter_mut().find(|tc| tc.id == id) {
122                    tc.args_json.push_str(&args_json_delta);
123                }
124            }
125            Chunk::ToolCallEnd { id } => {
126                // Move the matching call to the output in completion order. An
127                // unmatched id is ignored (defensive); calls still open at EOF
128                // are flushed after the loop so none are silently dropped.
129                if let Some(pos) = pending.iter().position(|tc| tc.id == id) {
130                    out.tool_calls.push(pending.remove(pos));
131                }
132            }
133            Chunk::Usage(u) => out.usage = u,
134            // A `ToolUse` stop is sticky against a *later* `EndTurn`. Some
135            // providers stream the tool call in one event and then a separate
136            // trailing terminator event carrying an end-of-turn finish reason;
137            // letting that later `EndTurn` overwrite the `ToolUse` stop would
138            // make the agent loop skip executing the tool and end the turn with
139            // no output.
140            //
141            // A *hard* stop (MaxTokens / Refusal / StopSequence) is the
142            // opposite: it means the turn was truncated or refused, so it must
143            // win over an earlier `ToolUse` — the tool call may be incomplete
144            // and must not be executed.
145            Chunk::Stop(r) => {
146                let keep_tool_use =
147                    out.stop == Some(StopReason::ToolUse) && matches!(r, StopReason::EndTurn);
148                if !keep_tool_use {
149                    out.stop = Some(r);
150                }
151            }
152        }
153    }
154    // Flush any call that started (and may have accreted args) but whose
155    // `ToolCallEnd` never arrived — a provider that omits the terminator must
156    // not lose the call.
157    out.tool_calls.append(&mut pending);
158    Ok(out)
159}
160
161/// Env var: emit a synthetic tool call for `<name>` on the stub provider.
162///
163/// First `complete()` of a turn emits a synthetic tool call for the named
164/// tool, subsequent calls (once a `tool_result` has landed in the
165/// transcript) fall back to canned `EndTurn` text. Empty / unset keeps the
166/// canned-text behaviour. Used by the HITL resume loopback verification to
167/// drive the data path without a real provider backend.
168pub const STUB_TOOL_CALL_ENV: &str = "POLYCHROME_STUB_TOOL_CALL";
169
170fn stub_tool_name() -> Option<String> {
171    std::env::var(STUB_TOOL_CALL_ENV)
172        .ok()
173        .filter(|s| !s.is_empty())
174}
175
176/// A canned [`LlmProvider`] for wiring and tests.
177///
178/// Emits two text deltas, a usage tally, and an end-of-turn stop. No
179/// network, no credentials.
180///
181/// When [`STUB_TOOL_CALL_ENV`] is set, the first `complete()` of a turn
182/// emits a synthetic tool call (id `stub-call-1`) for that tool name and
183/// the caller's function-calling loop drives the rest. Subsequent calls
184/// in the same turn fall back to the `EndTurn` text path. Used by the
185/// HITL resume loopback verification.
186#[derive(Clone, Copy, Default)]
187pub struct StubProvider;
188
189#[async_trait]
190impl LlmProvider for StubProvider {
191    type Error = DummyError;
192
193    async fn complete(
194        &self,
195        req: CompletionRequest,
196    ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
197        // If POLYCHROME_STUB_TOOL_CALL is set and we haven't yet seen a
198        // matching tool_result in the transcript, emit the synthetic tool
199        // call. Otherwise fall through to canned text.
200        if let Some(tool_name) = stub_tool_name() {
201            let saw_result = req.messages.iter().any(|m| {
202                m.content
203                    .iter()
204                    .any(|c| matches!(c, crate::Content::ToolResult(_)))
205            });
206            if !saw_result {
207                let chunks = vec![
208                    Ok(Chunk::tool_call_start("stub-call-1", &tool_name)),
209                    Ok(Chunk::tool_call_args_delta("stub-call-1", "{}")),
210                    Ok(Chunk::tool_call_end("stub-call-1")),
211                    Ok(Chunk::Stop(StopReason::ToolUse)),
212                ];
213                return Ok(stream::iter(chunks).boxed());
214            }
215        }
216        let chunks = vec![
217            Ok(Chunk::text_delta("Hello from the ")),
218            Ok(Chunk::text_delta("stub provider.")),
219            Ok(Chunk::Usage(Usage {
220                input_tokens: 5,
221                output_tokens: 4,
222                ..Default::default()
223            })),
224            Ok(Chunk::Stop(StopReason::EndTurn)),
225        ];
226        Ok(stream::iter(chunks).boxed())
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
233
234    use super::*;
235
236    #[tokio::test]
237    async fn stub_provider_collects_into_text() {
238        let stream = StubProvider
239            .complete(CompletionRequest::new("stub"))
240            .await
241            .expect("stream opens");
242        let out = collect_turn(stream).await.expect("collect");
243        assert_eq!(out.text, "Hello from the stub provider.");
244        assert!(out.tool_calls.is_empty());
245        assert_eq!(out.usage.output_tokens, 4);
246        assert_eq!(out.stop, Some(StopReason::EndTurn));
247    }
248
249    #[tokio::test]
250    async fn collect_assembles_tool_call_from_deltas() {
251        let chunks: Vec<Result<Chunk, DummyError>> = vec![
252            Ok(Chunk::text_delta("calling ")),
253            Ok(Chunk::tool_call_start("c1", "search")),
254            Ok(Chunk::tool_call_args_delta("c1", r#"{"q":"#)),
255            Ok(Chunk::tool_call_args_delta("c1", r#""rust"}"#)),
256            Ok(Chunk::tool_call_end("c1")),
257            Ok(Chunk::Stop(StopReason::ToolUse)),
258        ];
259        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
260        assert_eq!(out.text, "calling ");
261        assert_eq!(out.tool_calls.len(), 1);
262        assert_eq!(out.tool_calls[0].name, "search");
263        assert_eq!(out.tool_calls[0].args_json, r#"{"q":"rust"}"#);
264        assert_eq!(out.stop, Some(StopReason::ToolUse));
265    }
266
267    #[tokio::test]
268    async fn collect_keeps_parallel_tool_calls_with_deferred_ends() {
269        // Two interleaved calls whose `ToolCallEnd`s are both deferred to the
270        // end of the stream (the OpenAI-compatible provider's shape). A single
271        // `Option` would drop call 0 and close the survivor with the wrong end;
272        // id-matching must preserve both, in completion order.
273        let chunks: Vec<Result<Chunk, DummyError>> = vec![
274            Ok(Chunk::tool_call_start("c0", "search")),
275            Ok(Chunk::tool_call_args_delta("c0", r#"{"q":"a"}"#)),
276            Ok(Chunk::tool_call_start("c1", "fetch")),
277            Ok(Chunk::tool_call_args_delta("c1", r#"{"u":"b"}"#)),
278            Ok(Chunk::tool_call_end("c0")),
279            Ok(Chunk::tool_call_end("c1")),
280            Ok(Chunk::Stop(StopReason::ToolUse)),
281        ];
282        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
283        assert_eq!(out.tool_calls.len(), 2, "both parallel calls preserved");
284        assert_eq!(out.tool_calls[0].id, "c0");
285        assert_eq!(out.tool_calls[0].name, "search");
286        assert_eq!(out.tool_calls[0].args_json, r#"{"q":"a"}"#);
287        assert_eq!(out.tool_calls[1].id, "c1");
288        assert_eq!(out.tool_calls[1].name, "fetch");
289        assert_eq!(out.tool_calls[1].args_json, r#"{"u":"b"}"#);
290        assert_eq!(out.stop, Some(StopReason::ToolUse));
291    }
292
293    #[tokio::test]
294    async fn collect_flushes_a_call_left_open_at_eof() {
295        // A provider that omits the terminal `ToolCallEnd` must not lose the
296        // call — it is flushed when the stream ends.
297        let chunks: Vec<Result<Chunk, DummyError>> = vec![
298            Ok(Chunk::tool_call_start("c0", "search")),
299            Ok(Chunk::tool_call_args_delta("c0", r#"{"q":"a"}"#)),
300            Ok(Chunk::Stop(StopReason::ToolUse)),
301        ];
302        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
303        assert_eq!(out.tool_calls.len(), 1);
304        assert_eq!(out.tool_calls[0].args_json, r#"{"q":"a"}"#);
305    }
306
307    #[tokio::test]
308    async fn tool_use_stop_is_sticky_against_later_end_turn() {
309        // Provider streams the tool call (ToolUse) then a trailing terminator
310        // event (EndTurn). The terminator must NOT clobber ToolUse, else the
311        // agent loop skips the tool.
312        let chunks: Vec<Result<Chunk, DummyError>> = vec![
313            Ok(Chunk::tool_call_start("c1", "search")),
314            Ok(Chunk::tool_call_end("c1")),
315            Ok(Chunk::Stop(StopReason::ToolUse)),
316            Ok(Chunk::Stop(StopReason::EndTurn)),
317        ];
318        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
319        assert_eq!(out.stop, Some(StopReason::ToolUse));
320    }
321
322    #[tokio::test]
323    async fn hard_stop_wins_over_earlier_tool_use() {
324        // A later MaxTokens (truncation) MUST override an earlier ToolUse so
325        // the agent doesn't execute a tool call with truncated arguments.
326        let chunks: Vec<Result<Chunk, DummyError>> = vec![
327            Ok(Chunk::tool_call_start("c1", "search")),
328            Ok(Chunk::tool_call_end("c1")),
329            Ok(Chunk::Stop(StopReason::ToolUse)),
330            Ok(Chunk::Stop(StopReason::MaxTokens)),
331        ];
332        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
333        assert_eq!(out.stop, Some(StopReason::MaxTokens));
334    }
335
336    #[tokio::test]
337    async fn collect_folds_reasoning_separately_from_text() {
338        // Reasoning deltas accumulate into `reasoning`, never into `text`.
339        let chunks: Vec<Result<Chunk, DummyError>> = vec![
340            Ok(Chunk::reasoning_delta("first ")),
341            Ok(Chunk::reasoning_delta("thought")),
342            Ok(Chunk::text_delta("the answer")),
343            Ok(Chunk::Stop(StopReason::EndTurn)),
344        ];
345        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
346        assert_eq!(out.reasoning, "first thought");
347        assert_eq!(out.text, "the answer");
348    }
349
350    #[tokio::test]
351    async fn observed_reasoning_deltas_are_emitted() {
352        let chunks: Vec<Result<Chunk, DummyError>> = vec![
353            Ok(Chunk::reasoning_delta("hmm")),
354            Ok(Chunk::text_delta("ok")),
355            Ok(Chunk::Stop(StopReason::EndTurn)),
356        ];
357        let mut events = Vec::new();
358        let out = collect_turn_observed(stream::iter(chunks), |e| events.push(e))
359            .await
360            .expect("collect");
361        assert_eq!(out.reasoning, "hmm");
362        assert!(events.contains(&TurnStreamEvent::ReasoningDelta("hmm".to_owned())));
363        assert!(events.contains(&TurnStreamEvent::TextDelta("ok".to_owned())));
364    }
365
366    #[tokio::test]
367    async fn collect_propagates_error() {
368        let chunks: Vec<Result<Chunk, DummyError>> = vec![
369            Ok(Chunk::text_delta("partial")),
370            Err(DummyError::Other("mid-stream fault".to_owned())),
371        ];
372        let res = collect_turn(stream::iter(chunks)).await;
373        assert!(res.is_err());
374    }
375}