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}