Skip to main content

pipecrab_lm/
model.rs

1//! The [`LanguageModel`] trait, its error types ([`LmError`], [`LmConfigError`]),
2//! the chat-context types ([`Message`], [`Conversation`], [`GenParams`]), the
3//! provider-neutral [`ToolDefinition`], and the streaming [`ModelDelta`] /
4//! [`ModelStream`] protocol between an implementation and the stage.
5
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use pipecrab_core::ToolCall;
10use pipecrab_runtime::MaybeSendSync;
11
12/// A provider-neutral tool definition: name, description, and a JSON Schema for
13/// the arguments — the shape every hosted provider expects. It carries no
14/// execution callback; tool execution lives outside `pipecrab-lm`.
15///
16/// [`parameters`](ToolDefinition::parameters) is a [`serde_json::Value`] so a
17/// framework's existing schema passes through without a JSON-string round trip
18/// and reaches a hosted adapter unchanged.
19#[derive(Clone, Debug, PartialEq)]
20pub struct ToolDefinition {
21    /// Model-facing tool name.
22    pub name: Arc<str>,
23    /// Human-readable description of what the tool does.
24    pub description: Arc<str>,
25    /// JSON Schema describing the tool arguments; a JSON object.
26    pub parameters: serde_json::Value,
27}
28
29impl ToolDefinition {
30    /// Build a validated tool definition.
31    ///
32    /// Fails if `name` is empty or `parameters` is not a JSON object. The schema
33    /// is not required to carry a draft identifier or a top-level
34    /// `"type": "object"` — providers generate valid schemas with differing
35    /// conventions.
36    pub fn new(
37        name: impl Into<Arc<str>>,
38        description: impl Into<Arc<str>>,
39        parameters: serde_json::Value,
40    ) -> Result<Self, LmConfigError> {
41        let name = name.into();
42        if name.is_empty() {
43            return Err(LmConfigError::EmptyToolName);
44        }
45        if !parameters.is_object() {
46            return Err(LmConfigError::ToolParametersNotObject { name });
47        }
48        Ok(Self {
49            name,
50            description: description.into(),
51            parameters,
52        })
53    }
54}
55
56/// One turn of conversation history, preserving the structure a hosted adapter
57/// needs to reconstruct valid provider history: visible assistant text, the
58/// assistant's tool calls, tool-call IDs, tool results, and external events.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum Message {
61    /// The system prompt: instructions framing the whole conversation.
62    System {
63        /// The prompt text.
64        content: Arc<str>,
65    },
66    /// A turn from the user.
67    User {
68        /// The user's text.
69        content: Arc<str>,
70    },
71    /// A turn generated by the model: its visible text and any tool calls it
72    /// made, in the order the provider must replay them.
73    Assistant {
74        /// Visible assistant text; empty for a tool-call-only turn.
75        content: Arc<str>,
76        /// The tool calls this turn made, correlated to later results by
77        /// [`ToolCall::id`].
78        tool_calls: Arc<[ToolCall]>,
79    },
80    /// The result of a previous [`ToolCall`], correlated by `tool_call_id`.
81    ToolResult {
82        /// The [`ToolCall::id`] this result answers.
83        tool_call_id: Arc<str>,
84        /// Model-facing name of the tool that produced the result.
85        name: Arc<str>,
86        /// The tool's output, as text.
87        content: Arc<str>,
88    },
89    /// An event from another system rather than the user.
90    Event {
91        /// Identifier of the system that produced the event.
92        source: Arc<str>,
93        /// The kind of event, in the producer's own vocabulary.
94        kind: Arc<str>,
95        /// The event payload, as text.
96        content: Arc<str>,
97    },
98}
99
100impl Message {
101    /// A [`System`](Message::System) message — the framing prompt.
102    pub fn system(content: impl Into<Arc<str>>) -> Self {
103        Self::System {
104            content: content.into(),
105        }
106    }
107
108    /// A [`User`](Message::User) message.
109    pub fn user(content: impl Into<Arc<str>>) -> Self {
110        Self::User {
111            content: content.into(),
112        }
113    }
114
115    /// An [`Assistant`](Message::Assistant) message with visible text and no tool
116    /// calls.
117    pub fn assistant(content: impl Into<Arc<str>>) -> Self {
118        Self::Assistant {
119            content: content.into(),
120            tool_calls: Arc::from([]),
121        }
122    }
123
124    /// An [`Assistant`](Message::Assistant) message carrying visible text and the
125    /// turn's tool calls.
126    pub fn assistant_with_tool_calls(
127        content: impl Into<Arc<str>>,
128        tool_calls: impl Into<Arc<[ToolCall]>>,
129    ) -> Self {
130        Self::Assistant {
131            content: content.into(),
132            tool_calls: tool_calls.into(),
133        }
134    }
135}
136
137impl From<pipecrab_core::ModelMessage> for Message {
138    /// Lift a core [`ModelMessage`](pipecrab_core::ModelMessage) — a tool result
139    /// or an external event — into conversation history.
140    fn from(message: pipecrab_core::ModelMessage) -> Self {
141        match message {
142            pipecrab_core::ModelMessage::ToolResult {
143                tool_call_id,
144                name,
145                content,
146            } => Message::ToolResult {
147                tool_call_id,
148                name,
149                content,
150            },
151            pipecrab_core::ModelMessage::Event {
152                source,
153                kind,
154                content,
155            } => Message::Event {
156                source,
157                kind,
158                content,
159            },
160        }
161    }
162}
163
164/// The chat context handed to [`LanguageModel::generate`]: an ordered list of
165/// [`Message`]s, oldest first. The system prompt, if any, is the first message.
166#[derive(Clone, Debug, Default, PartialEq, Eq)]
167pub struct Conversation {
168    /// The turns so far, in order.
169    pub messages: Vec<Message>,
170}
171
172/// Knobs for one [`generate`](LanguageModel::generate) call. All optional; an
173/// engine applies its own default for any left `None`.
174#[derive(Clone, Debug, Default, PartialEq)]
175pub struct GenParams {
176    /// Upper bound on generated tokens, if any.
177    pub max_tokens: Option<u32>,
178    /// Sampling temperature, if any.
179    pub temperature: Option<f32>,
180    /// An optional grammar / JSON-schema constraint the engine interprets (e.g.
181    /// GBNF). Opaque to the pipeline: the trait carries it and the engine
182    /// enforces it.
183    pub grammar: Option<Arc<str>>,
184}
185
186/// One item of the streaming protocol between a [`LanguageModel`] and
187/// [`LmStage`](crate::LmStage): a text delta to append, or one complete tool
188/// call.
189///
190/// Not a pipeline frame. Provider-specific streaming formats, tool-call
191/// fragments, and constrained-output parsing are assembled *inside* the
192/// implementation; the stage sees only normalized text and complete tool calls,
193/// and turns them into [`ModelFrame`](pipecrab_core::ModelFrame)s. Every item is
194/// a cancellation and barge-in preemption point.
195#[derive(Clone, Debug, PartialEq)]
196pub enum ModelDelta {
197    /// Text to append to the current assistant response.
198    Text(Arc<str>),
199    /// One complete model tool call.
200    ToolCall(ToolCall),
201}
202
203impl ModelDelta {
204    /// Build a [`ToolCall`](ModelDelta::ToolCall) delta from structured
205    /// arguments, giving adapters a JSON-value API while the core frame keeps a
206    /// dependency-free JSON-text representation.
207    ///
208    /// Fails if `id` or `name` is empty, if `arguments` is not a JSON object, or
209    /// if serializing the validated object fails.
210    pub fn tool_call(
211        id: impl Into<Arc<str>>,
212        name: impl Into<Arc<str>>,
213        arguments: serde_json::Value,
214    ) -> Result<Self, LmError> {
215        let id = id.into();
216        if id.is_empty() {
217            return Err(LmError::MissingToolCallId);
218        }
219        let name = name.into();
220        if name.is_empty() {
221            return Err(LmError::IncompleteToolCall);
222        }
223        if !arguments.is_object() {
224            return Err(LmError::ToolArgumentsNotObject);
225        }
226        let arguments_json = serde_json::to_string(&arguments)
227            .map_err(|error| LmError::ToolCallSerialization(error.to_string()))?;
228        Ok(ModelDelta::ToolCall(ToolCall {
229            id,
230            name,
231            arguments_json: Arc::from(arguments_json),
232        }))
233    }
234}
235
236/// The output of a [`LanguageModel::generate`] call: a boxed stream of
237/// [`ModelDelta`] results, delivered delta by delta.
238///
239/// `BoxStream` on native and `LocalBoxStream` on `wasm32`, behind one cfg'd alias
240/// — the same `Send`-where-it-exists split as
241/// [`MaybeSend`](pipecrab_runtime::MaybeSend): the pipeline is one logical task
242/// that stays `Send` for a work-stealing executor natively, while on `wasm32`
243/// (one thread, `!Send` JS handles) that bound must vanish.
244#[cfg(not(target_arch = "wasm32"))]
245pub type ModelStream = futures::stream::BoxStream<'static, Result<ModelDelta, LmError>>;
246/// The output of a [`LanguageModel::generate`] call: a boxed stream of
247/// [`ModelDelta`] results, delivered delta by delta.
248#[cfg(target_arch = "wasm32")]
249pub type ModelStream = futures::stream::LocalBoxStream<'static, Result<ModelDelta, LmError>>;
250
251/// The swappable language-model capability: a chat context in, structured
252/// generation out incrementally.
253///
254/// This is the durable interface. A native engine (e.g. a llama.cpp context) and
255/// a hosted engine (a Rig agent, a browser Worker) all implement this one trait,
256/// so [`LmStage`](crate::LmStage) — and the pipeline above it — never names a
257/// concrete model.
258///
259/// # Engines are worker-handles
260///
261/// An implementor is expected to be a thin *handle* to a long-lived worker that
262/// owns the model's mutable decode state: a dedicated thread on native (a
263/// llama.cpp context is `!Send`, so the worker pattern is mandatory there), a Web
264/// Worker on `wasm32`. That is why [`generate`](Self::generate) takes `&self` —
265/// it hands the context to the worker and returns its delta stream — and why the
266/// worker outlives any single call.
267///
268/// # Streaming is a barge-in requirement
269///
270/// [`generate`](Self::generate) yields a [`ModelDelta`] at a time rather than one
271/// buffer at the end: every item of the returned [`ModelStream`] is a preemption
272/// point the run loop can drop an in-flight generation at, so a user barging in
273/// stops the reply within one delta instead of after the whole turn. Dropping the
274/// stream is how the *stage* stops pulling; [`cancel`](Self::cancel) is how the
275/// *engine* stops producing.
276///
277/// [`cancel`](Self::cancel) is a *control call* (see
278/// [`Processor`](pipecrab_core::Processor)'s control-call carve-out): it maps to
279/// the engine's abort callback / an atomic the decode loop checks, so it is
280/// synchronous, non-blocking, and safe to invoke directly from a stage's
281/// `decide_*` where the barge-in is decided.
282///
283/// `?Send` on `wasm32` matches pipecrab's single-threaded execution model, so one
284/// implementation runs unchanged on a current-thread executor and in the browser,
285/// where `Send` bounds cannot be satisfied.
286#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
287#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
288pub trait LanguageModel: MaybeSendSync {
289    /// Generate a reply to `conversation` under `params` with `tools` available,
290    /// yielding [`ModelDelta`]s.
291    ///
292    /// `tools` are the tools configured on the stage. An implementation that
293    /// wraps a higher-level agent (e.g. a Rig agent) keeps its own registered
294    /// tools internal and uses them here directly, alongside any in `tools`.
295    ///
296    /// Takes `&self`: like every [`Stage::perform`](pipecrab_runtime::Stage::perform),
297    /// generation must not mutate observable state, so the run loop can drop an
298    /// in-flight call — at any stream item — on a barge-in interrupt without
299    /// tearing anything. Every item of the returned [`ModelStream`] is such a
300    /// preemption point.
301    async fn generate(
302        &self,
303        conversation: &Conversation,
304        params: &GenParams,
305        tools: &[ToolDefinition],
306    ) -> Result<ModelStream, LmError>;
307
308    /// Control call: abort in-flight generation. Sync, non-blocking, idempotent.
309    ///
310    /// Maps to the engine's abort callback / an atomic the decode loop checks; the
311    /// next [`generate`](Self::generate) starts clean. Safe to call from a stage's
312    /// synchronous `decide_*` — see the trait-level note.
313    fn cancel(&self);
314
315    /// Checkpoint the worker's session state (KV cache and any decode state) to an
316    /// opaque, serialized blob.
317    async fn save_state(&self) -> Result<Vec<u8>, LmError>;
318
319    /// Restore worker session state previously produced by
320    /// [`save_state`](Self::save_state).
321    async fn load_state(&self, blob: &[u8]) -> Result<(), LmError>;
322}
323
324/// Why configuring an [`LmStage`](crate::LmStage) or a [`ToolDefinition`] failed —
325/// a static, provider-neutral error surfaced at construction, not per generation.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum LmConfigError {
328    /// A tool definition had an empty name.
329    EmptyToolName,
330    /// A tool's `parameters` was not a JSON object.
331    ToolParametersNotObject {
332        /// The offending tool's name.
333        name: Arc<str>,
334    },
335    /// Two tools in the effective set shared a name.
336    DuplicateToolName {
337        /// The duplicated name.
338        name: Arc<str>,
339    },
340}
341
342impl std::fmt::Display for LmConfigError {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        match self {
345            LmConfigError::EmptyToolName => write!(f, "tool name must be nonempty"),
346            LmConfigError::ToolParametersNotObject { name } => {
347                write!(f, "parameters of tool {name:?} must be a JSON object")
348            }
349            LmConfigError::DuplicateToolName { name } => {
350                write!(f, "duplicate tool name {name:?}")
351            }
352        }
353    }
354}
355
356impl std::error::Error for LmConfigError {}
357
358/// Why a [`LanguageModel`] call failed.
359///
360/// Provider-neutral: an adapter maps its engine's native error into one of these
361/// kinds, so no provider-specific error type reaches the public interface.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum LmError {
364    /// The generation engine itself failed — an inference error, a worker that
365    /// crashed, a model that never loaded. Carries a human-readable description.
366    Engine(String),
367    /// The provider stream failed mid-generation.
368    ProviderStream(String),
369    /// A tool call's arguments were structurally invalid.
370    InvalidToolArguments(String),
371    /// Tool-call arguments were not a JSON object.
372    ToolArgumentsNotObject,
373    /// A provider tool call was incomplete — missing its name or arguments.
374    IncompleteToolCall,
375    /// A provider tool call carried no identifier.
376    MissingToolCallId,
377    /// Serializing validated tool arguments to JSON text failed.
378    ToolCallSerialization(String),
379}
380
381impl std::fmt::Display for LmError {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            LmError::Engine(msg) => write!(f, "lm engine error: {msg}"),
385            LmError::ProviderStream(msg) => write!(f, "lm provider stream error: {msg}"),
386            LmError::InvalidToolArguments(msg) => write!(f, "invalid tool-call arguments: {msg}"),
387            LmError::ToolArgumentsNotObject => {
388                write!(f, "tool-call arguments must be a JSON object")
389            }
390            LmError::IncompleteToolCall => write!(f, "incomplete provider tool call"),
391            LmError::MissingToolCallId => write!(f, "missing tool-call identifier"),
392            LmError::ToolCallSerialization(msg) => {
393                write!(f, "tool-call serialization failed: {msg}")
394            }
395        }
396    }
397}
398
399impl std::error::Error for LmError {}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use serde_json::json;
405
406    #[test]
407    fn tool_definition_rejects_empty_name() {
408        assert_eq!(
409            ToolDefinition::new("", "d", json!({})),
410            Err(LmConfigError::EmptyToolName)
411        );
412    }
413
414    #[test]
415    fn tool_definition_rejects_non_object_schema() {
416        let err = ToolDefinition::new("t", "d", json!("not an object")).unwrap_err();
417        assert_eq!(
418            err,
419            LmConfigError::ToolParametersNotObject {
420                name: Arc::from("t")
421            }
422        );
423    }
424
425    #[test]
426    fn tool_definition_preserves_the_schema_value_unchanged() {
427        let schema = json!({
428            "type": "object",
429            "properties": { "city": { "type": "string" } },
430            "required": ["city"],
431        });
432        let def = ToolDefinition::new("weather", "look up weather", schema.clone()).unwrap();
433        assert_eq!(
434            def.parameters, schema,
435            "the schema value is stored verbatim"
436        );
437    }
438
439    #[test]
440    fn tool_call_delta_normalizes_object_arguments_to_json_text() {
441        let ModelDelta::ToolCall(call) =
442            ModelDelta::tool_call("call-1", "weather", json!({ "city": "paris" })).unwrap()
443        else {
444            unreachable!("tool_call builds a ToolCall delta")
445        };
446        assert_eq!(&*call.id, "call-1");
447        assert_eq!(&*call.name, "weather");
448        // Core carries JSON *text*; it must parse back to the structured input.
449        let parsed: serde_json::Value = serde_json::from_str(&call.arguments_json).unwrap();
450        assert_eq!(parsed, json!({ "city": "paris" }));
451    }
452
453    #[test]
454    fn tool_call_delta_rejects_non_object_arguments() {
455        assert_eq!(
456            ModelDelta::tool_call("call-1", "t", json!("scalar")),
457            Err(LmError::ToolArgumentsNotObject)
458        );
459        assert_eq!(
460            ModelDelta::tool_call("call-1", "t", json!([1, 2, 3])),
461            Err(LmError::ToolArgumentsNotObject)
462        );
463    }
464
465    #[test]
466    fn tool_call_delta_requires_id_and_name() {
467        assert_eq!(
468            ModelDelta::tool_call("", "t", json!({})),
469            Err(LmError::MissingToolCallId)
470        );
471        assert_eq!(
472            ModelDelta::tool_call("call-1", "", json!({})),
473            Err(LmError::IncompleteToolCall)
474        );
475    }
476}