Skip to main content

kernel/capabilities/
chat.rs

1//! The chat-wire layer: the `ChatMessage`/`ChatAttachment` request model, the
2//! lenient and strict parsers that turn an incoming OpenAI/Ollama payload into
3//! messages, the outbound `payload_value` rendering, tool-spec decoding, and the
4//! generic ChatML prompt fallback. This is what a gateway uses to bridge a wire
5//! request to the kernel's dispatch surface and back.
6
7use std::collections::BTreeMap;
8
9use crate::capabilities::{ToolCall, ToolSpec};
10use crate::records::JsonValue;
11use base64::prelude::{BASE64_STANDARD, Engine as _};
12
13const TOOL_SHAPE_HINT: &str = "each tool must be {type: \"function\", function: {name}}";
14const TOOLS_ARRAY_HINT: &str = "tools must be an array of function tools";
15const NO_MESSAGES_HINT: &str = "chat payload must carry a messages array or a prompt";
16
17/// Why a chat-wire payload could not be parsed.
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum ChatWireError {
20    /// The payload was structurally invalid; the message explains how.
21    #[error("{0}")]
22    PayloadInvalid(String),
23}
24
25/// A chat message's author.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum ChatRole {
28    /// The system prompt.
29    System,
30    /// A user turn.
31    User,
32    /// An assistant turn.
33    Assistant,
34    /// A tool result turn.
35    Tool,
36}
37
38impl ChatRole {
39    /// The wire string for this role.
40    pub fn as_str(self) -> &'static str {
41        match self {
42            ChatRole::System => "system",
43            ChatRole::User => "user",
44            ChatRole::Assistant => "assistant",
45            ChatRole::Tool => "tool",
46        }
47    }
48
49    /// The role for a wire string, if it names one.
50    pub fn from_value(value: &str) -> Option<Self> {
51        match value {
52            "system" => Some(ChatRole::System),
53            "user" => Some(ChatRole::User),
54            "assistant" => Some(ChatRole::Assistant),
55            "tool" => Some(ChatRole::Tool),
56            _ => None,
57        }
58    }
59}
60
61/// What kind of thing a [`ChatAttachment`] carries.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum AttachmentKind {
64    /// An image, sent to a vision model as base64.
65    Image,
66    /// A document, inlined into the prompt as text.
67    Document,
68}
69
70/// A file attached to a chat message: an image forwarded to a vision model, or a
71/// document inlined into the prompt text.
72#[derive(Debug, Clone, PartialEq)]
73pub struct ChatAttachment {
74    /// Whether this is an image or a document.
75    pub kind: AttachmentKind,
76    /// The raw bytes.
77    pub data: Vec<u8>,
78    /// The MIME type.
79    pub mime_type: String,
80    /// An optional file name.
81    pub name: Option<String>,
82}
83
84impl ChatAttachment {
85    /// The prompt-inlined block for a document attachment (wrapping its UTF-8
86    /// text in an `<attached-file>` element), or `None` for an image.
87    pub fn inline_block(&self) -> Option<String> {
88        if self.kind != AttachmentKind::Document {
89            return None;
90        }
91        let text = String::from_utf8_lossy(&self.data);
92        let open = match &self.name {
93            Some(name) => format!("<attached-file name=\"{name}\">"),
94            None => "<attached-file>".to_owned(),
95        };
96        Some(format!("{open}\n{text}\n</attached-file>"))
97    }
98}
99
100/// A chat turn: role and content plus optional tool calls, tool-result routing,
101/// and attachments.
102#[derive(Debug, Clone, PartialEq)]
103pub struct ChatMessage {
104    /// The author.
105    pub role: ChatRole,
106    /// The visible text content.
107    pub content: String,
108    /// Tool calls this (assistant) turn emitted.
109    pub tool_calls: Vec<ToolCall>,
110    /// The id of the tool call this (tool) turn answers.
111    pub tool_call_id: Option<String>,
112    /// The name of the tool this (tool) turn answers.
113    pub tool_name: Option<String>,
114    /// Attachments (images forwarded, documents inlined).
115    pub attachments: Vec<ChatAttachment>,
116    /// Content-addressed references to attachments held elsewhere.
117    pub attachment_refs: Vec<String>,
118}
119
120impl ChatMessage {
121    /// A plain message with just a role and content.
122    pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
123        Self {
124            role,
125            content: content.into(),
126            tool_calls: Vec::new(),
127            tool_call_id: None,
128            tool_name: None,
129            attachments: Vec::new(),
130            attachment_refs: Vec::new(),
131        }
132    }
133
134    /// The outbound wire object: document blocks prepended to the content, tool
135    /// calls / tool routing carried through, and images base64-encoded into an
136    /// `images` array.
137    pub fn payload_value(&self) -> JsonValue {
138        let mut parts: Vec<String> = self
139            .attachments
140            .iter()
141            .filter_map(ChatAttachment::inline_block)
142            .collect();
143        parts.push(self.content.clone());
144        let body = parts
145            .into_iter()
146            .filter(|part| !part.is_empty())
147            .collect::<Vec<_>>()
148            .join("\n\n");
149
150        let mut object = BTreeMap::new();
151        object.insert(
152            "role".to_owned(),
153            JsonValue::String(self.role.as_str().to_owned()),
154        );
155        object.insert("content".to_owned(), JsonValue::String(body));
156        if !self.tool_calls.is_empty() {
157            object.insert(
158                "tool_calls".to_owned(),
159                JsonValue::Array(
160                    self.tool_calls
161                        .iter()
162                        .map(ToolCall::payload_value)
163                        .collect(),
164                ),
165            );
166        }
167        if let Some(id) = &self.tool_call_id {
168            object.insert("tool_call_id".to_owned(), JsonValue::String(id.clone()));
169        }
170        if let Some(name) = &self.tool_name {
171            object.insert("tool_name".to_owned(), JsonValue::String(name.clone()));
172        }
173        let images: Vec<JsonValue> = self
174            .attachments
175            .iter()
176            .filter(|attachment| attachment.kind == AttachmentKind::Image)
177            .map(|attachment| JsonValue::String(BASE64_STANDARD.encode(&attachment.data)))
178            .collect();
179        if !images.is_empty() {
180            object.insert("images".to_owned(), JsonValue::Array(images));
181        }
182        JsonValue::Object(object)
183    }
184
185    /// Leniently parse one message object, returning `None` if it lacks a valid
186    /// role. Unknown fields and malformed tool calls are dropped, not rejected.
187    pub fn from_payload(value: &JsonValue) -> Option<Self> {
188        let fields = value.as_object()?;
189        let role = ChatRole::from_value(fields.get("role")?.as_str()?)?;
190        let content = fields
191            .get("content")
192            .and_then(JsonValue::as_str)
193            .unwrap_or("");
194        let mut message = ChatMessage::new(role, content);
195        message.apply_tool_routing(fields);
196        Some(message)
197    }
198
199    /// Strictly parse the message at `index`, rejecting a non-object, a missing
200    /// or unknown role, or non-string content.
201    pub fn parse_strict(value: &JsonValue, index: usize) -> Result<Self, ChatWireError> {
202        let Some(fields) = value.as_object() else {
203            return Err(ChatWireError::PayloadInvalid(format!(
204                "message at index {index} is not an object"
205            )));
206        };
207        let role = fields
208            .get("role")
209            .and_then(JsonValue::as_str)
210            .and_then(ChatRole::from_value)
211            .ok_or_else(|| {
212                ChatWireError::PayloadInvalid(format!(
213                    "message at index {index} has a missing or unknown role"
214                ))
215            })?;
216        let content = match fields.get("content") {
217            None | Some(JsonValue::Null) => "",
218            Some(JsonValue::String(text)) => text,
219            Some(_) => {
220                return Err(ChatWireError::PayloadInvalid(format!(
221                    "message at index {index} has non-string content"
222                )));
223            }
224        };
225        let mut message = ChatMessage::new(role, content);
226        message.apply_tool_routing(fields);
227        Ok(message)
228    }
229
230    /// Fill `tool_calls`/`tool_call_id`/`tool_name` from a message object. Shared
231    /// by the lenient and strict parsers, which differ only in role/content rules.
232    fn apply_tool_routing(&mut self, fields: &BTreeMap<String, JsonValue>) {
233        self.tool_calls = parse_tool_calls(fields.get("tool_calls"));
234        self.tool_call_id = fields
235            .get("tool_call_id")
236            .and_then(JsonValue::as_str)
237            .map(str::to_owned);
238        self.tool_name = fields
239            .get("tool_name")
240            .and_then(JsonValue::as_str)
241            .map(str::to_owned);
242    }
243
244    /// Parse the whole chat request: a `messages` array (strictly, index by
245    /// index) or, failing that, a single `prompt` string as one user turn.
246    pub fn parse_all(payload: &JsonValue) -> Result<Vec<Self>, ChatWireError> {
247        let fields = payload
248            .as_object()
249            .ok_or_else(|| ChatWireError::PayloadInvalid(NO_MESSAGES_HINT.to_owned()))?;
250        if let Some(JsonValue::Array(messages)) = fields.get("messages") {
251            return messages
252                .iter()
253                .enumerate()
254                .map(|(index, value)| Self::parse_strict(value, index))
255                .collect();
256        }
257        if let Some(JsonValue::String(prompt)) = fields.get("prompt") {
258            return Ok(vec![ChatMessage::new(ChatRole::User, prompt.clone())]);
259        }
260        Err(ChatWireError::PayloadInvalid(NO_MESSAGES_HINT.to_owned()))
261    }
262
263    /// This message with any assistant tool calls folded into the visible text as
264    /// `<tool_call>{…}</tool_call>` blocks — the transcript form for a model that
265    /// takes tool history as plain text. A non-assistant or call-less message is
266    /// returned unchanged.
267    pub fn inlined_tool_transcript(&self) -> ChatMessage {
268        if self.role != ChatRole::Assistant || self.tool_calls.is_empty() {
269            return self.clone();
270        }
271        let mut parts = vec![self.content.clone()];
272        for call in &self.tool_calls {
273            let mut object = BTreeMap::new();
274            object.insert("name".to_owned(), JsonValue::String(call.name.clone()));
275            object.insert("arguments".to_owned(), call.arguments.clone());
276            parts.push(format!(
277                "<tool_call>{}</tool_call>",
278                json_string(&JsonValue::Object(object))
279            ));
280        }
281        let joined = parts
282            .into_iter()
283            .filter(|part| !part.is_empty())
284            .collect::<Vec<_>>()
285            .join("\n");
286        ChatMessage::new(ChatRole::Assistant, joined)
287    }
288}
289
290/// Decode OpenAI-style function tools from a request's `tools` value: an array of
291/// `{type: "function", function: {name, description?, parameters?}}`. Absent →
292/// empty; malformed → [`ChatWireError`].
293pub fn decode_tool_specs(value: Option<&JsonValue>) -> Result<Vec<ToolSpec>, ChatWireError> {
294    let Some(value) = value else {
295        return Ok(Vec::new());
296    };
297    let JsonValue::Array(entries) = value else {
298        return Err(ChatWireError::PayloadInvalid(TOOLS_ARRAY_HINT.to_owned()));
299    };
300    // A non-object element fails the array shape wholesale (not the per-tool
301    // shape): the array is validated all-or-nothing.
302    if entries.iter().any(|entry| entry.as_object().is_none()) {
303        return Err(ChatWireError::PayloadInvalid(TOOLS_ARRAY_HINT.to_owned()));
304    }
305    let mut specs = Vec::with_capacity(entries.len());
306    for entry in entries {
307        let object = entry.as_object();
308        let kind = object
309            .and_then(|fields| fields.get("type"))
310            .and_then(JsonValue::as_str)
311            .unwrap_or("function");
312        let function = object
313            .and_then(|fields| fields.get("function"))
314            .and_then(JsonValue::as_object);
315        let name = function
316            .and_then(|fields| fields.get("name"))
317            .and_then(JsonValue::as_str)
318            .filter(|name| !name.is_empty());
319        let (Some(function), Some(name)) = (function, name) else {
320            return Err(ChatWireError::PayloadInvalid(TOOL_SHAPE_HINT.to_owned()));
321        };
322        if kind != "function" {
323            return Err(ChatWireError::PayloadInvalid(TOOL_SHAPE_HINT.to_owned()));
324        }
325        let description = function
326            .get("description")
327            .and_then(JsonValue::as_str)
328            .unwrap_or("")
329            .to_owned();
330        let parameters = match function.get("parameters") {
331            Some(JsonValue::Object(fields)) => JsonValue::Object(fields.clone()),
332            _ => JsonValue::Object(BTreeMap::new()),
333        };
334        specs.push(ToolSpec::new(name, description, parameters));
335    }
336    Ok(specs)
337}
338
339/// The generic ChatML fallback prompt for a model with no chat template.
340pub struct ChatMlPrompt;
341
342impl ChatMlPrompt {
343    /// Shown when a model declares no chat template and this format is used.
344    pub const NO_TEMPLATE_NOTICE: &'static str =
345        "this model has no chat template — using a generic format";
346
347    /// Render `messages` as a ChatML prompt ending with an open assistant turn.
348    pub fn render(messages: &[ChatMessage]) -> String {
349        let mut prompt = String::new();
350        for message in messages {
351            prompt.push_str("<|im_start|>");
352            prompt.push_str(message.role.as_str());
353            prompt.push('\n');
354            prompt.push_str(&message.content);
355            prompt.push_str("<|im_end|>\n");
356        }
357        prompt.push_str("<|im_start|>assistant\n");
358        prompt
359    }
360}
361
362fn parse_tool_calls(value: Option<&JsonValue>) -> Vec<ToolCall> {
363    match value {
364        Some(JsonValue::Array(calls)) => calls.iter().filter_map(ToolCall::from_payload).collect(),
365        _ => Vec::new(),
366    }
367}
368
369/// Serialize `value` to a compact JSON string with sorted keys. `JsonValue`'s
370/// object is a `BTreeMap`, so serialization is already key-sorted.
371fn json_string(value: &JsonValue) -> String {
372    serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned())
373}