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}