Skip to main content

merlion_core/
message.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "lowercase")]
5pub enum Role {
6    System,
7    User,
8    Assistant,
9    Tool,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ToolCall {
14    pub id: String,
15    pub name: String,
16    /// Raw JSON arguments as the model emitted them.
17    pub arguments: serde_json::Value,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ToolResult {
22    pub tool_call_id: String,
23    pub name: String,
24    pub content: String,
25    #[serde(default)]
26    pub is_error: bool,
27}
28
29/// One turn in the conversation.
30///
31/// Models the OpenAI chat-completion shape closely so that mapping to the wire
32/// format is mechanical: an assistant turn may carry `content` and/or
33/// `tool_calls`; tool turns carry `tool_call_id` + content.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Message {
36    pub role: Role,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub content: Option<String>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub tool_calls: Vec<ToolCall>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub tool_call_id: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub name: Option<String>,
45}
46
47impl Message {
48    pub fn system(text: impl Into<String>) -> Self {
49        Self {
50            role: Role::System,
51            content: Some(text.into()),
52            tool_calls: Vec::new(),
53            tool_call_id: None,
54            name: None,
55        }
56    }
57
58    pub fn user(text: impl Into<String>) -> Self {
59        Self {
60            role: Role::User,
61            content: Some(text.into()),
62            tool_calls: Vec::new(),
63            tool_call_id: None,
64            name: None,
65        }
66    }
67
68    pub fn assistant_text(text: impl Into<String>) -> Self {
69        Self {
70            role: Role::Assistant,
71            content: Some(text.into()),
72            tool_calls: Vec::new(),
73            tool_call_id: None,
74            name: None,
75        }
76    }
77
78    pub fn assistant_tool_calls(calls: Vec<ToolCall>) -> Self {
79        Self {
80            role: Role::Assistant,
81            content: None,
82            tool_calls: calls,
83            tool_call_id: None,
84            name: None,
85        }
86    }
87
88    pub fn tool_response(result: ToolResult) -> Self {
89        Self {
90            role: Role::Tool,
91            content: Some(result.content),
92            tool_calls: Vec::new(),
93            tool_call_id: Some(result.tool_call_id),
94            name: Some(result.name),
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn assistant_with_tool_calls_serializes_without_content() {
105        let m = Message::assistant_tool_calls(vec![ToolCall {
106            id: "call_1".into(),
107            name: "bash".into(),
108            arguments: serde_json::json!({"cmd": "ls"}),
109        }]);
110        let v = serde_json::to_value(&m).unwrap();
111        assert_eq!(v["role"], "assistant");
112        assert!(v.get("content").is_none(), "content should be omitted");
113        assert_eq!(v["tool_calls"][0]["name"], "bash");
114    }
115}