Skip to main content

pipecrab_core/frame/
model.rs

1//! Native language-model protocol frames: one generation's boundaries and the
2//! structured items it consumes or produces. See [`ModelFrame`] for the
3//! ordering contract.
4
5use std::sync::Arc;
6
7/// A complete tool call emitted by a language model. Only *complete* calls enter
8/// the pipeline: an adapter assembles provider-specific fragments into one
9/// [`arguments_json`](ToolCall::arguments_json), synthesizes an [`id`](ToolCall::id)
10/// where the provider has none, and owns all JSON validation — core does none.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct ToolCall {
13    /// Correlates the call with a later tool result.
14    pub id: Arc<str>,
15    /// Model-facing tool name.
16    pub name: Arc<str>,
17    /// Complete tool arguments as one JSON object; never a fragment.
18    pub arguments_json: Arc<str>,
19}
20
21/// Non-user input to the LM conversation — from another stage or external
22/// system. User speech stays a final [`Role::User`](crate::Role::User)
23/// [`Transcript`](crate::Transcript) and assistant messages are built
24/// internally, so v1 has no `User` or `Assistant` variant.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum ModelMessage {
27    /// The result corresponding to a previous model [`ToolCall`], correlated by
28    /// `tool_call_id`.
29    ToolResult {
30        /// The [`ToolCall::id`] this result answers.
31        tool_call_id: Arc<str>,
32        /// Model-facing name of the tool that produced the result.
33        name: Arc<str>,
34        /// The tool's output, as text.
35        content: Arc<str>,
36    },
37    /// An event generated by another system rather than the user.
38    Event {
39        /// Identifier of the system that produced the event.
40        source: Arc<str>,
41        /// The kind of event, in the producer's own vocabulary.
42        kind: Arc<str>,
43        /// The event payload, as text.
44        content: Arc<str>,
45    },
46}
47
48/// Non-user input to an LM stage, tagged with whether it triggers a generation.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum ModelInput {
51    /// Add the message to conversation history without generating.
52    Context(ModelMessage),
53    /// Add the message and begin a new generation.
54    Respond(ModelMessage),
55}
56
57/// Native language-model protocol frames: one generation's boundaries and the
58/// structured items it consumes or produces. On the data lane the exact
59/// interleaving is preserved:
60///
61/// ```text
62/// Model(GenerationStarted)
63/// Transcript(agent partial)
64/// Model(ToolCall)
65/// Transcript(agent final)
66/// Model(GenerationFinished)
67/// ```
68///
69/// Text may come *before or after* a tool call, and a tool-call-only generation
70/// emits no transcript — so don't assume text precedes tool calls. No generation
71/// id in v1: a stage runs one at a time, bounded by
72/// [`GenerationStarted`](ModelFrame::GenerationStarted) /
73/// [`GenerationFinished`](ModelFrame::GenerationFinished).
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum ModelFrame {
76    /// Add non-user information to the LM conversation; see [`ModelInput`].
77    Input(ModelInput),
78    /// Marks the beginning of one LM generation.
79    GenerationStarted,
80    /// A complete structured [`ToolCall`] emitted by the model.
81    ToolCall(ToolCall),
82    /// Marks successful completion of the generation stream.
83    GenerationFinished,
84}
85
86impl ModelFrame {
87    /// Model frames that outlive an interrupt flush: [`Input`](ModelFrame::Input)
88    /// (external input) and [`ToolCall`](ModelFrame::ToolCall) (a completed call).
89    /// The generation boundaries belong to the interrupted turn and are dropped.
90    /// Consulted by [`DataFrame::survives_flush`](crate::DataFrame::survives_flush).
91    pub fn survives_flush(&self) -> bool {
92        matches!(self, ModelFrame::Input(_) | ModelFrame::ToolCall(_))
93    }
94}
95
96impl From<ToolCall> for ModelFrame {
97    /// Wrap a complete [`ToolCall`] as [`ModelFrame::ToolCall`].
98    fn from(call: ToolCall) -> Self {
99        ModelFrame::ToolCall(call)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::DataFrame;
107
108    fn call() -> ToolCall {
109        ToolCall {
110            id: Arc::from("call-1"),
111            name: Arc::from("lookup"),
112            arguments_json: Arc::from(r#"{"q":"weather"}"#),
113        }
114    }
115
116    #[test]
117    fn subtypes_construct_and_compare() {
118        // Equal field-for-field; distinct when any field differs.
119        assert_eq!(call(), call());
120        assert_ne!(
121            call(),
122            ToolCall {
123                name: Arc::from("other"),
124                ..call()
125            }
126        );
127
128        let ctx = ModelInput::Context(ModelMessage::Event {
129            source: Arc::from("clock"),
130            kind: Arc::from("tick"),
131            content: Arc::from("12:00"),
132        });
133        assert_eq!(ctx.clone(), ctx);
134        // Context and Respond wrapping the same message are distinct.
135        let respond = match &ctx {
136            ModelInput::Context(m) => ModelInput::Respond(m.clone()),
137            ModelInput::Respond(m) => ModelInput::Context(m.clone()),
138        };
139        assert_ne!(ctx, respond);
140
141        let result = ModelMessage::ToolResult {
142            tool_call_id: Arc::from("call-1"),
143            name: Arc::from("lookup"),
144            content: Arc::from("sunny"),
145        };
146        assert_eq!(result.clone(), result);
147    }
148
149    #[test]
150    fn tool_call_holds_complete_argument_json_verbatim() {
151        // Core stores whatever complete JSON text the adapter assembled; it does
152        // not parse, split, or re-encode it.
153        let json = r#"{"city":"paris","units":"c"}"#;
154        let c = ToolCall {
155            id: Arc::from("call-9"),
156            name: Arc::from("weather"),
157            arguments_json: Arc::from(json),
158        };
159        assert_eq!(&*c.arguments_json, json);
160    }
161
162    #[test]
163    fn tool_call_converts_to_model_frame_and_data_frame() {
164        assert_eq!(ModelFrame::from(call()), ModelFrame::ToolCall(call()));
165
166        // ToolCall reaches the data lane as a Model frame, not a Custom one.
167        match DataFrame::from(call()) {
168            DataFrame::Model(ModelFrame::ToolCall(c)) => assert_eq!(c, call()),
169            other => panic!("expected Model(ToolCall), got {other:?}"),
170        }
171    }
172
173    #[test]
174    fn model_frame_rides_the_data_lane() {
175        // The only home for a ModelFrame is DataFrame::Model — it is never a
176        // SystemFrame (there is no such variant to construct).
177        match DataFrame::from(ModelFrame::GenerationStarted) {
178            DataFrame::Model(ModelFrame::GenerationStarted) => {}
179            other => panic!("expected Model(GenerationStarted), got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn survival_matches_the_interrupt_contract() {
185        // Survive: external input and completed tool calls.
186        assert!(
187            ModelFrame::Input(ModelInput::Respond(ModelMessage::Event {
188                source: Arc::from("cal"),
189                kind: Arc::from("reminder"),
190                content: Arc::from("standup"),
191            }))
192            .survives_flush()
193        );
194        assert!(ModelFrame::ToolCall(call()).survives_flush());
195
196        // Do not survive: in-progress generation bookkeeping.
197        assert!(!ModelFrame::GenerationStarted.survives_flush());
198        assert!(!ModelFrame::GenerationFinished.survives_flush());
199
200        // The same decision through the data-lane entry point.
201        assert!(DataFrame::from(call()).survives_flush());
202        assert!(!DataFrame::from(ModelFrame::GenerationFinished).survives_flush());
203    }
204}