Skip to main content

oino_ai/
message.rs

1//! The LLM `Message` and the coding-agent `AgentMessage` union.
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6use crate::content::{AssistantContent, UserContent, UserContentField, UserTextField};
7use crate::json::{JsonObject, JsonValue};
8use crate::primitives::{StopReason, ThinkingLevel, TimestampMs, Usage};
9
10/// Tool declaration carried by system messages.
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ToolDecl {
14    /// Tool name.
15    pub name: String,
16    /// Model-facing description.
17    pub description: String,
18    /// JSON schema of the arguments.
19    #[serde(default)]
20    pub parameters: JsonValue,
21    /// Constrained-sampling preference.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub constrained_sampling: Option<ConstrainedSampling>,
24}
25
26/// F a l s e   |   C o n s t r a i n e d   S a m p l i n g   C o n f i g.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(untagged)]
29pub enum ConstrainedSampling {
30    /// Literal `false`: disabled.
31    Disabled(FalseLiteral),
32    /// Configuration object.
33    Config(ConstrainedSamplingConfig),
34}
35
36/// The JSON literal `false`.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub struct FalseLiteral;
39
40impl Serialize for FalseLiteral {
41    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
42        serializer.serialize_bool(false)
43    }
44}
45
46impl<'de> Deserialize<'de> for FalseLiteral {
47    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
48        if bool::deserialize(deserializer)? {
49            Err(serde::de::Error::custom("expected `false`"))
50        } else {
51            Ok(Self)
52        }
53    }
54}
55
56/// `{type:"json_schema", strict}` or `{type:"grammar", variants}`.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct ConstrainedSamplingConfig {
60    /// `"json_schema"` or `"grammar"`.
61    #[serde(rename = "type")]
62    pub kind: String,
63    /// Strictness for `json_schema`.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub strict: Option<StrictMode>,
66    /// Grammar per format (`openai_lark`, `openai_regex`) for `grammar`.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub variants: Option<IndexMap<String, String>>,
69}
70
71/// Constrained-sampling strictness.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum StrictMode {
75    /// Use strict mode when supported.
76    Prefer,
77    /// Fail when strict mode is unsupported.
78    Require,
79    /// Never strict.
80    Off,
81}
82
83/// `{name}` reference used by `toolsRemoved`.
84#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub struct ToolRef {
86    /// Tool name.
87    pub name: String,
88}
89
90/// Ordered prompt sections; `None` (JSON `null`) removes a section during replay.
91pub type Sections = IndexMap<String, Option<String>>;
92
93/// System prompt and tool loadout.
94#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct SystemMessage {
97    /// Free-form prompt text.
98    #[serde(default)]
99    pub content: UserTextField,
100    /// Section patch, order significant.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub sections: Option<Sections>,
103    /// Tools declared (or redeclared) by this message.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub tools_added: Option<Vec<ToolDecl>>,
106    /// Tools removed by this message.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub tools_removed: Option<Vec<ToolRef>>,
109    /// Creation time.
110    #[serde(default)]
111    pub timestamp: TimestampMs,
112}
113
114/// User message.
115#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct UserMessage {
118    /// String or blocks.
119    #[serde(default)]
120    pub content: UserContentField,
121    /// Creation time.
122    #[serde(default)]
123    pub timestamp: TimestampMs,
124}
125
126/// Assistant message.
127#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct AssistantMessage {
130    /// Content blocks.
131    #[serde(default)]
132    pub content: Vec<AssistantContent>,
133    /// Wire API (`anthropic-messages`, …).
134    pub api: String,
135    /// Provider id.
136    pub provider: String,
137    /// Model id that answered.
138    pub model: String,
139    /// Model reported by the provider response.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub response_model: Option<String>,
142    /// Provider response id.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub response_id: Option<String>,
145    /// Provider-specific effort string applied.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub provider_thinking_level: Option<String>,
148    /// Thinking level requested for this response.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub thinking_level: Option<ThinkingLevel>,
151    /// Non-fatal diagnostics.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub diagnostics: Option<Vec<AssistantDiagnostic>>,
154    /// Usage (required).
155    #[serde(default)]
156    pub usage: Usage,
157    /// Stop reason (required).
158    pub stop_reason: StopReason,
159    /// Deferred response handle.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub deferred: Option<DeferredHandle>,
162    /// Error text for `error`/`aborted`.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub error_message: Option<String>,
165    /// Provider's raw stop reason.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub raw_stop_reason: Option<String>,
168    /// Provider end-of-turn flag.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub end_turn: Option<bool>,
171    /// Creation time.
172    #[serde(default)]
173    pub timestamp: TimestampMs,
174    /// Wall time of the request.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub duration_ms: Option<u64>,
177}
178
179impl AssistantMessage {
180    /// Empty assistant message for the given model (`stopReason: "pending"`), the starting
181    /// point for streamed partials.
182    #[must_use]
183    pub fn empty(
184        api: impl Into<String>,
185        provider: impl Into<String>,
186        model: impl Into<String>,
187        timestamp: TimestampMs,
188    ) -> Self {
189        Self {
190            content: Vec::new(),
191            api: api.into(),
192            provider: provider.into(),
193            model: model.into(),
194            response_model: None,
195            response_id: None,
196            provider_thinking_level: None,
197            thinking_level: None,
198            diagnostics: None,
199            usage: Usage::default(),
200            stop_reason: StopReason::Pending,
201            deferred: None,
202            error_message: None,
203            raw_stop_reason: None,
204            end_turn: None,
205            timestamp,
206            duration_ms: None,
207        }
208    }
209}
210
211/// Assistant diagnostic.
212#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct AssistantDiagnostic {
215    /// Diagnostic kind.
216    #[serde(rename = "type")]
217    pub kind: String,
218    /// Time recorded.
219    pub timestamp: TimestampMs,
220    /// Error info.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub error: Option<DiagnosticError>,
223    /// Extra details.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub details: Option<JsonObject>,
226}
227
228/// The diagnostic error info record.
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct DiagnosticError {
232    /// Error class name.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub name: Option<String>,
235    /// Message.
236    pub message: String,
237    /// Stack trace.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub stack: Option<String>,
240    /// String or number code.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub code: Option<JsonValue>,
243}
244
245/// Deferred response handle.
246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct DeferredHandle {
249    /// Provider id.
250    pub provider: String,
251    /// Model id.
252    pub model_id: String,
253    /// Wire API.
254    pub api: String,
255    /// Provider-side id.
256    pub id: String,
257    /// Expiry (epoch ms).
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub expires_at: Option<i64>,
260    /// Suggested poll delay.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub poll_after_ms: Option<u64>,
263    /// Opaque provider data.
264    #[serde(
265        default,
266        skip_serializing_if = "Option::is_none",
267        deserialize_with = "crate::json::keep_null"
268    )]
269    pub data: Option<JsonValue>,
270}
271
272/// Status of a nested tool call.
273#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "lowercase")]
275pub enum NestedStatus {
276    /// Succeeded.
277    Ok,
278    /// Failed.
279    Error,
280    /// Did not finish.
281    Unfinished,
282}
283
284/// One nested tool call.
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct NestedToolCallRecord {
288    /// Call id.
289    pub id: String,
290    /// Tool name.
291    pub name: String,
292    /// Arguments (omitted when too large).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub arguments: Option<JsonObject>,
295    /// Size of omitted arguments.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub arguments_bytes: Option<u64>,
298    /// Outcome.
299    pub status: NestedStatus,
300    /// Wall time.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub duration_ms: Option<u64>,
303    /// Error text.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub error: Option<String>,
306}
307
308/// The nested tool calls record.
309#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
310pub struct NestedToolCalls {
311    /// Recorded calls.
312    pub calls: Vec<NestedToolCallRecord>,
313    /// `false` when records were dropped.
314    pub complete: bool,
315}
316
317/// Tool result.
318#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct ToolResultMessage {
321    /// Id of the answered tool call.
322    pub tool_call_id: String,
323    /// Tool name.
324    pub tool_name: String,
325    /// Text/image blocks.
326    #[serde(default)]
327    pub content: Vec<UserContent>,
328    /// Tool-specific details (not sent to the LLM).
329    #[serde(
330        default,
331        skip_serializing_if = "Option::is_none",
332        deserialize_with = "crate::json::keep_null"
333    )]
334    pub details: Option<JsonValue>,
335    /// Usage of nested model calls.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub usage: Option<Usage>,
338    /// Nested tool calls.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub nested_calls: Option<NestedToolCalls>,
341    /// Error flag.
342    pub is_error: bool,
343    /// Creation time.
344    #[serde(default)]
345    pub timestamp: TimestampMs,
346    /// Wall time.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub duration_ms: Option<u64>,
349}
350
351/// LLM-level message (`Message`).
352#[allow(
353    clippy::large_enum_variant,
354    reason = "variants mirror the wire shapes; boxing would leak into every match across crates"
355)]
356#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
357#[serde(tag = "role")]
358pub enum Message {
359    /// System prompt / tools.
360    #[serde(rename = "system")]
361    System(SystemMessage),
362    /// User turn.
363    #[serde(rename = "user")]
364    User(UserMessage),
365    /// Assistant turn.
366    #[serde(rename = "assistant")]
367    Assistant(AssistantMessage),
368    /// Tool result.
369    #[serde(rename = "toolResult")]
370    ToolResult(ToolResultMessage),
371}
372
373impl Message {
374    /// Message timestamp (epoch ms).
375    #[must_use]
376    pub const fn timestamp(&self) -> TimestampMs {
377        match self {
378            Self::System(m) => m.timestamp,
379            Self::User(m) => m.timestamp,
380            Self::Assistant(m) => m.timestamp,
381            Self::ToolResult(m) => m.timestamp,
382        }
383    }
384
385    /// Role string.
386    #[must_use]
387    pub const fn role(&self) -> &'static str {
388        match self {
389            Self::System(_) => "system",
390            Self::User(_) => "user",
391            Self::Assistant(_) => "assistant",
392            Self::ToolResult(_) => "toolResult",
393        }
394    }
395}
396
397/// `!` bash execution recorded by the coding agent.
398#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "camelCase")]
400pub struct BashExecutionMessage {
401    /// Command line.
402    pub command: String,
403    /// Combined output.
404    #[serde(default)]
405    pub output: String,
406    /// Exit code (`None` when killed / unknown).
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub exit_code: Option<i32>,
409    /// Cancelled by the user.
410    #[serde(default)]
411    pub cancelled: bool,
412    /// Output was truncated.
413    #[serde(default)]
414    pub truncated: bool,
415    /// Path of the full output when truncated.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub full_output_path: Option<String>,
418    /// Creation time.
419    #[serde(default)]
420    pub timestamp: TimestampMs,
421    /// `!!` commands: never sent to the LLM.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub exclude_from_context: Option<bool>,
424}
425
426/// Extension-injected message (always in LLM context; `display` is UI only).
427#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CustomMessage {
430    /// Extension-defined type.
431    pub custom_type: String,
432    /// String or blocks.
433    #[serde(default)]
434    pub content: UserContentField,
435    /// Whether frontends render it.
436    #[serde(default)]
437    pub display: bool,
438    /// Extension metadata (not sent to the LLM).
439    #[serde(
440        default,
441        skip_serializing_if = "Option::is_none",
442        deserialize_with = "crate::json::keep_null"
443    )]
444    pub details: Option<JsonValue>,
445    /// Creation time.
446    #[serde(default)]
447    pub timestamp: TimestampMs,
448}
449
450/// Summary of an abandoned branch.
451#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase")]
453pub struct BranchSummaryMessage {
454    /// Summary text.
455    pub summary: String,
456    /// Previous leaf id; serialised as `null` when `None` (field always present).
457    #[serde(default)]
458    pub from_id: Option<String>,
459    /// Creation time.
460    #[serde(default)]
461    pub timestamp: TimestampMs,
462}
463
464/// Summary that replaces compacted history.
465#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct CompactionSummaryMessage {
468    /// Summary text.
469    pub summary: String,
470    /// Context tokens before compaction.
471    #[serde(default)]
472    pub tokens_before: u64,
473    /// Creation time.
474    #[serde(default)]
475    pub timestamp: TimestampMs,
476}
477
478/// Coding-agent message union.
479#[allow(
480    clippy::large_enum_variant,
481    reason = "variants mirror the wire shapes; boxing would leak into every match across crates"
482)]
483#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
484#[serde(tag = "role")]
485pub enum AgentMessage {
486    /// System prompt / tools.
487    #[serde(rename = "system")]
488    System(SystemMessage),
489    /// User turn.
490    #[serde(rename = "user")]
491    User(UserMessage),
492    /// Assistant turn.
493    #[serde(rename = "assistant")]
494    Assistant(AssistantMessage),
495    /// Tool result.
496    #[serde(rename = "toolResult")]
497    ToolResult(ToolResultMessage),
498    /// `!` command.
499    #[serde(rename = "bashExecution")]
500    BashExecution(BashExecutionMessage),
501    /// Extension message.
502    #[serde(rename = "custom")]
503    Custom(CustomMessage),
504    /// Branch summary.
505    #[serde(rename = "branchSummary")]
506    BranchSummary(BranchSummaryMessage),
507    /// Compaction summary.
508    #[serde(rename = "compactionSummary")]
509    CompactionSummary(CompactionSummaryMessage),
510}
511
512impl AgentMessage {
513    /// The LLM message when this is one of the four LLM roles.
514    #[must_use]
515    pub fn as_llm(&self) -> Option<Message> {
516        match self {
517            Self::System(m) => Some(Message::System(m.clone())),
518            Self::User(m) => Some(Message::User(m.clone())),
519            Self::Assistant(m) => Some(Message::Assistant(m.clone())),
520            Self::ToolResult(m) => Some(Message::ToolResult(m.clone())),
521            _ => None,
522        }
523    }
524
525    /// Role string.
526    #[must_use]
527    pub const fn role(&self) -> &'static str {
528        match self {
529            Self::System(_) => "system",
530            Self::User(_) => "user",
531            Self::Assistant(_) => "assistant",
532            Self::ToolResult(_) => "toolResult",
533            Self::BashExecution(_) => "bashExecution",
534            Self::Custom(_) => "custom",
535            Self::BranchSummary(_) => "branchSummary",
536            Self::CompactionSummary(_) => "compactionSummary",
537        }
538    }
539
540    /// Message timestamp (epoch ms).
541    #[must_use]
542    pub const fn timestamp(&self) -> TimestampMs {
543        match self {
544            Self::System(m) => m.timestamp,
545            Self::User(m) => m.timestamp,
546            Self::Assistant(m) => m.timestamp,
547            Self::ToolResult(m) => m.timestamp,
548            Self::BashExecution(m) => m.timestamp,
549            Self::Custom(m) => m.timestamp,
550            Self::BranchSummary(m) => m.timestamp,
551            Self::CompactionSummary(m) => m.timestamp,
552        }
553    }
554
555    /// The system message, if this is one.
556    #[must_use]
557    pub const fn as_system(&self) -> Option<&SystemMessage> {
558        match self {
559            Self::System(m) => Some(m),
560            _ => None,
561        }
562    }
563}
564
565impl From<Message> for AgentMessage {
566    fn from(message: Message) -> Self {
567        match message {
568            Message::System(m) => Self::System(m),
569            Message::User(m) => Self::User(m),
570            Message::Assistant(m) => Self::Assistant(m),
571            Message::ToolResult(m) => Self::ToolResult(m),
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use serde_json::json;
580
581    fn round_trip(raw: &JsonValue) -> Option<JsonValue> {
582        let message: AgentMessage = serde_json::from_value(raw.clone()).ok()?;
583        serde_json::to_value(message).ok()
584    }
585
586    #[test]
587    fn doc_examples_round_trip() {
588        for raw in [
589            json!({"role":"system","content":"","sections":{"preamble":"You are…","tools":"<tools>","cwd":"/p"},
590                   "toolsAdded":[{"name":"read","description":"...","parameters":{}}],"timestamp":1_733_234_400_000_i64}),
591            json!({"role":"system","content":"","sections":{"skills":"<skills>","gone":null},"toolsRemoved":[{"name":"write"}],"timestamp":1}),
592            json!({"role":"user","content":"Hello","timestamp":1}),
593            json!({"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic",
594                   "model":"claude-sonnet-4-5","usage":{"input":1,"output":2,"cacheRead":0,"cacheWrite":0,"totalTokens":3,
595                   "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}),
596            json!({"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":3}),
597            json!({"role":"bashExecution","command":"ls","output":"","exitCode":0,"cancelled":false,"truncated":false,"timestamp":4}),
598            json!({"role":"custom","customType":"x","content":"c","display":true,"details":{"a":1},"timestamp":5}),
599            json!({"role":"branchSummary","summary":"s","fromId":null,"timestamp":6}),
600            json!({"role":"compactionSummary","summary":"s","tokensBefore":10,"timestamp":7}),
601        ] {
602            assert_eq!(round_trip(&raw), Some(raw));
603        }
604    }
605
606    #[test]
607    fn constrained_sampling_false_and_config() {
608        let decl: ToolDecl = serde_json::from_value(
609            json!({"name":"a","description":"b","parameters":{},"constrainedSampling":false}),
610        )
611        .unwrap_or_else(|_| ToolDecl {
612            name: String::new(),
613            description: String::new(),
614            parameters: JsonValue::Null,
615            constrained_sampling: None,
616        });
617        assert_eq!(
618            decl.constrained_sampling,
619            Some(ConstrainedSampling::Disabled(FalseLiteral))
620        );
621        let raw = json!({"type":"json_schema","strict":"prefer"});
622        let config: Option<ConstrainedSampling> = serde_json::from_value(raw.clone()).ok();
623        assert!(matches!(config, Some(ConstrainedSampling::Config(_))));
624        assert_eq!(serde_json::to_value(config).ok(), Some(raw));
625        assert!(serde_json::from_value::<FalseLiteral>(json!(true)).is_err());
626    }
627}