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}