Skip to main content

pipecrab_lm/
stage.rs

1//! [`LmStage`]: the generic adapter from any [`LanguageModel`] to a pipeline
2//! [`Stage`], tracking the running [`Conversation`] and turning a model's
3//! [`ModelDelta`] stream into native [`ModelFrame`]s and agent transcripts.
4
5use std::collections::HashSet;
6use std::sync::Mutex;
7
8use async_trait::async_trait;
9use futures::stream::StreamExt;
10use pipecrab_core::{
11    DataFrame, Decision, Direction, Finality, ModelFrame, ModelInput, Processor, Role, SystemFrame,
12    ToolCall, Transcript,
13};
14use pipecrab_runtime::{Outbound, Stage, StageError};
15
16use crate::{
17    Conversation, GenParams, LanguageModel, LmConfigError, LmError, Message, ModelDelta,
18    ToolDefinition,
19};
20
21/// Adapts any [`LanguageModel`] into a pipeline [`Stage`]: it accumulates the
22/// running [`Conversation`] and, when a turn triggers generation, converts the
23/// model's [`ModelDelta`] stream into native frames — visible text as agent
24/// [`Transcript`]s (partials as the deltas arrive, then a final), tool calls as
25/// [`ModelFrame::ToolCall`], each generation bracketed by
26/// [`GenerationStarted`](ModelFrame::GenerationStarted) /
27/// [`GenerationFinished`](ModelFrame::GenerationFinished).
28///
29/// # Inputs
30///
31/// Three inputs drive a turn:
32///
33/// * A **final user** [`Transcript`] appends a user message and generates.
34/// * [`ModelInput::Context`](pipecrab_core::ModelInput::Context) appends a
35///   non-user message *without* generating (background context for a later turn).
36/// * [`ModelInput::Respond`](pipecrab_core::ModelInput::Respond) appends a
37///   non-user message *and* generates.
38///
39/// The system prompt is injected at construction as the first message.
40///
41/// # Tools
42///
43/// Tools configured via [`with_tools`](Self::with_tools) /
44/// [`add_tools`](Self::add_tools) are validated once (duplicate names rejected)
45/// and passed to every generation. An adapter that wraps a higher-level agent
46/// (e.g. Rig) keeps its own registered tools internal; the stage neither reads nor
47/// copies them.
48///
49/// # State and the decide/perform split
50///
51/// The [`Conversation`] is the stage's state. `decide_data` (sync, `&mut self`)
52/// appends the incoming turn and emits a [`Generate`] effect where a generation
53/// is wanted; `perform` (`&self`) snapshots the conversation, runs the
54/// generation, and — only *after* the stream completes — locks again to append
55/// the structured assistant turn (visible text plus every tool call). Because the
56/// conversation is mutated only in synchronous critical sections (the
57/// Mutex-after-await idiom), a barge-in that drops an in-flight `perform` leaves
58/// no half-written turn.
59///
60/// # Barge-in
61///
62/// Each `.await` in `perform` is a point the run loop can drop `perform` at, so a
63/// barge-in [`Interrupt`](SystemFrame::Interrupt) stops the reply within one
64/// delta. The interrupt also reaches [`decide_system`](Processor::decide_system),
65/// which issues the [`cancel`](LanguageModel::cancel) control call so the engine's
66/// worker stops decoding too. An interrupted generation commits no assistant turn
67/// and emits no [`GenerationFinished`](ModelFrame::GenerationFinished); the
68/// [`ToolCall`](ModelFrame::ToolCall) frames already emitted are ordinary
69/// surviving pipeline data.
70pub struct LmStage<M: LanguageModel> {
71    model: M,
72    params: GenParams,
73    tools: Vec<ToolDefinition>,
74    convo: Mutex<Conversation>,
75}
76
77impl<M: LanguageModel> LmStage<M> {
78    /// Wrap `model` as a stage seeded with `system_prompt`, using default
79    /// [`GenParams`] and no stage tools.
80    pub fn new(model: M, system_prompt: impl Into<std::sync::Arc<str>>) -> Self {
81        Self::build(model, system_prompt, GenParams::default())
82    }
83
84    /// Wrap `model` as a stage seeded with `system_prompt` and explicit `params`.
85    pub fn with_params(
86        model: M,
87        system_prompt: impl Into<std::sync::Arc<str>>,
88        params: GenParams,
89    ) -> Self {
90        Self::build(model, system_prompt, params)
91    }
92
93    /// Wrap `model` as a stage seeded with `system_prompt` and stage `tools`.
94    ///
95    /// Fails if the set has an invalid or duplicate tool.
96    pub fn with_tools(
97        model: M,
98        system_prompt: impl Into<std::sync::Arc<str>>,
99        tools: impl IntoIterator<Item = ToolDefinition>,
100    ) -> Result<Self, LmConfigError> {
101        Self::build(model, system_prompt, GenParams::default()).add_tools(tools)
102    }
103
104    /// Add stage tools to the effective set, revalidating it.
105    ///
106    /// Fails if any resulting tool is invalid or two share a name.
107    pub fn add_tools(
108        mut self,
109        tools: impl IntoIterator<Item = ToolDefinition>,
110    ) -> Result<Self, LmConfigError> {
111        self.tools.extend(tools);
112        validate_tool_set(&self.tools)?;
113        Ok(self)
114    }
115
116    /// The stage tools passed to every generation.
117    pub fn tools(&self) -> &[ToolDefinition] {
118        &self.tools
119    }
120
121    /// Seed a stage with the system prompt and no tools.
122    fn build(model: M, system_prompt: impl Into<std::sync::Arc<str>>, params: GenParams) -> Self {
123        Self {
124            convo: Mutex::new(Conversation {
125                messages: vec![Message::system(system_prompt)],
126            }),
127            tools: Vec::new(),
128            params,
129            model,
130        }
131    }
132
133    /// Append a message to conversation history under the lock.
134    fn append(&self, message: Message) {
135        self.convo
136            .lock()
137            .expect("LmStage conversation mutex poisoned")
138            .messages
139            .push(message);
140    }
141}
142
143/// Reject an empty name, a non-object schema, or a duplicate name anywhere in the
144/// effective tool set. The tool fields are public, so the set is revalidated
145/// rather than trusting each [`ToolDefinition`] was built through
146/// [`ToolDefinition::new`].
147fn validate_tool_set(tools: &[ToolDefinition]) -> Result<(), LmConfigError> {
148    let mut seen: HashSet<&str> = HashSet::with_capacity(tools.len());
149    for tool in tools {
150        if tool.name.is_empty() {
151            return Err(LmConfigError::EmptyToolName);
152        }
153        if !tool.parameters.is_object() {
154            return Err(LmConfigError::ToolParametersNotObject {
155                name: tool.name.clone(),
156            });
157        }
158        if !seen.insert(tool.name.as_ref()) {
159            return Err(LmConfigError::DuplicateToolName {
160                name: tool.name.clone(),
161            });
162        }
163    }
164    Ok(())
165}
166
167/// Run a generation over the current conversation: [`LmStage`]'s
168/// [`Processor::Effect`]. A unit — the conversation to generate from is read from
169/// the stage's own state in `perform`, not carried in the effect.
170pub struct Generate;
171
172impl<M: LanguageModel> Processor for LmStage<M> {
173    type Effect = Generate;
174
175    fn decide_data(&mut self, frame: &DataFrame) -> Decision<Generate> {
176        match frame {
177            // The user's finished utterance: append it and generate a reply. The
178            // text is Arc-backed, so this clone is a refcount bump.
179            DataFrame::Transcript(Transcript {
180                role: Role::User,
181                finality: Finality::Final,
182                text,
183            }) => {
184                self.append(Message::user(text.clone()));
185                Decision::drop().emit(Generate)
186            }
187            // An in-progress user transcript is not yet actionable in v1 — consume
188            // it. Speculative prefill will hook here to warm the KV cache from the
189            // partial before the final arrives.
190            DataFrame::Transcript(Transcript {
191                role: Role::User,
192                finality: Finality::Partial { .. },
193                ..
194            }) => Decision::drop(),
195            // Non-user context: append it, but do not generate.
196            DataFrame::Model(ModelFrame::Input(ModelInput::Context(message))) => {
197                self.append(Message::from(message.clone()));
198                Decision::drop()
199            }
200            // Non-user input that warrants a reply: append it and generate.
201            DataFrame::Model(ModelFrame::Input(ModelInput::Respond(message))) => {
202                self.append(Message::from(message.clone()));
203                Decision::drop().emit(Generate)
204            }
205            // Agent transcripts (our own output looping back), our own generation
206            // frames, audio, custom frames: not ours to consume.
207            _ => Decision::forward(),
208        }
209    }
210
211    fn decide_system(&mut self, _dir: Direction, frame: &SystemFrame) -> Decision<Generate> {
212        // Barge-in: abort the engine's decode at once via the control call. The
213        // run loop separately drops the in-flight `perform`; forwarding the
214        // Interrupt lets downstream stages reset too.
215        if matches!(frame, SystemFrame::Interrupt) {
216            self.model.cancel();
217        }
218        Decision::forward()
219    }
220}
221
222#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
223#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
224impl<M: LanguageModel> Stage for LmStage<M> {
225    async fn perform(&self, _effect: Generate, out: &Outbound) -> Result<(), StageError> {
226        // Snapshot the conversation under the lock, then release it before the
227        // awaited generation — the guard must not cross an `.await`.
228        let convo = {
229            self.convo
230                .lock()
231                .expect("LmStage conversation mutex poisoned")
232                .clone()
233        };
234
235        // Only start the generation boundary once the stream is in hand: a
236        // failed start emits nothing.
237        let mut stream = self
238            .model
239            .generate(&convo, &self.params, &self.tools)
240            .await?;
241        let _ = out.send_data(ModelFrame::GenerationStarted.into()).await;
242
243        let mut reply = String::new();
244        let mut tool_calls: Vec<ToolCall> = Vec::new();
245        // Each `.next().await` and each send is a preemption point: a barge-in
246        // drops this future here. Because the assistant turn is committed only
247        // after the stream completes (below), a dropped generation leaves the
248        // conversation untouched — though any tool call already emitted survives.
249        while let Some(item) = stream.next().await {
250            match item? {
251                ModelDelta::Text(delta) => {
252                    reply.push_str(&delta);
253                    // LM output is append-only, so the whole accumulated reply is
254                    // stable.
255                    let partial = Transcript::agent_partial(reply.clone());
256                    debug_assert!(
257                        matches!(partial.finality, Finality::Partial { stable } if stable == partial.text.len()),
258                        "agent partial must be append-only (stable == text.len())",
259                    );
260                    // Ignore the send error: it only happens once the sink has gone
261                    // away during shutdown, matching the runtime's own forward path.
262                    let _ = out.send_data(partial.into()).await;
263                }
264                ModelDelta::ToolCall(call) => {
265                    tool_calls.push(call.clone());
266                    let _ = out.send_data(ModelFrame::ToolCall(call).into()).await;
267                }
268            }
269        }
270
271        // No empty final transcript — a tool-call-only turn emits none.
272        if !reply.is_empty() {
273            let _ = out
274                .send_data(Transcript::agent_final(reply.clone()).into())
275                .await;
276        }
277        let _ = out.send_data(ModelFrame::GenerationFinished.into()).await;
278
279        // Commit the structured assistant turn now — the Mutex-after-await idiom.
280        // This runs synchronously after the final send's await resolves (no await
281        // between), so a barge-in either drops us earlier — committing nothing — or
282        // lets the whole turn commit; it never leaves a partially recorded message.
283        self.append(Message::assistant_with_tool_calls(reply, tool_calls));
284        Ok(())
285    }
286}
287
288impl From<LmError> for StageError {
289    fn from(e: LmError) -> Self {
290        // A failed generation is recoverable: skip this reply and keep the
291        // pipeline alive. The run loop surfaces it as an Error frame upstream.
292        StageError::new(e.to_string())
293    }
294}