use crate::error::{Result, SomaError};
use crate::value::Value;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Role {
System,
User,
Assistant,
}
impl Role {
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::User => "user",
Self::Assistant => "assistant",
}
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ContentBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: String,
#[serde(default)]
is_error: bool,
},
}
impl ContentBlock {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
pub fn tool_use(
id: impl Into<String>,
name: impl Into<String>,
input: serde_json::Value,
) -> Self {
Self::ToolUse {
id: id.into(),
name: name.into(),
input,
}
}
pub fn tool_result(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
Self::ToolResult {
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error: false,
}
}
pub fn tool_error(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
Self::ToolResult {
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error: true,
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentBlock>,
}
impl Message {
pub fn new(role: Role, content: Vec<ContentBlock>) -> Self {
Self { role, content }
}
pub fn system(text: impl Into<String>) -> Self {
Self::new(Role::System, vec![ContentBlock::text(text)])
}
pub fn user(text: impl Into<String>) -> Self {
Self::new(Role::User, vec![ContentBlock::text(text)])
}
pub fn assistant(text: impl Into<String>) -> Self {
Self::new(Role::Assistant, vec![ContentBlock::text(text)])
}
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(ContentBlock::as_text)
.collect::<Vec<_>>()
.join("")
}
pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
self.content.iter().filter_map(|b| match b {
ContentBlock::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
_ => None,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Messages(pub Vec<Message>);
impl Messages {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, message: Message) {
self.0.push(message);
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> std::slice::Iter<'_, Message> {
self.0.iter()
}
pub fn last(&self) -> Option<&Message> {
self.0.last()
}
pub fn to_value(&self) -> Value {
Value::json(serde_json::to_value(self).unwrap_or(serde_json::Value::Null))
}
pub fn from_value(value: &Value) -> Result<Self> {
match value {
Value::Text(s) => Ok(Self(vec![Message::user(s.as_ref())])),
Value::Json(j) => {
if let Some(s) = j.as_str() {
return Ok(Self(vec![Message::user(s)]));
}
serde_json::from_value((**j).clone()).map_err(|e| SomaError::SchemaMismatch {
expected: "messages".into(),
got: format!("json that is not a conversation: {e}"),
})
}
other => Err(SomaError::SchemaMismatch {
expected: "messages".into(),
got: other.type_name().to_string(),
}),
}
}
}
impl From<Vec<Message>> for Messages {
fn from(v: Vec<Message>) -> Self {
Self(v)
}
}
impl IntoIterator for Messages {
type Item = Message;
type IntoIter = std::vec::IntoIter<Message>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrips_through_a_value() {
let mut msgs = Messages::new();
msgs.push(Message::system("You are terse."));
msgs.push(Message::user("What is 2+2?"));
msgs.push(Message::new(
Role::Assistant,
vec![
ContentBlock::text("Let me compute that."),
ContentBlock::tool_use("t1", "calc", serde_json::json!({"expr": "2+2"})),
],
));
msgs.push(Message::new(
Role::User,
vec![ContentBlock::tool_result("t1", "4")],
));
let decoded = Messages::from_value(&msgs.to_value()).unwrap();
assert_eq!(decoded, msgs);
}
#[test]
fn promotes_a_bare_string_to_a_user_turn() {
for v in [
Value::text("Summarize this."),
Value::json(serde_json::json!("Summarize this.")),
] {
let msgs = Messages::from_value(&v).unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs.0[0].role, Role::User);
assert_eq!(msgs.0[0].text(), "Summarize this.");
}
}
#[test]
fn rejects_values_that_are_not_conversations() {
let err = Messages::from_value(&Value::tensor(vec![1.0], vec![1])).unwrap_err();
assert!(err.to_string().contains("messages"), "{err}");
let err = Messages::from_value(&Value::json(serde_json::json!({"a": 1}))).unwrap_err();
assert!(err.to_string().contains("messages"), "{err}");
}
#[test]
fn text_concatenates_prose_and_skips_tool_blocks() {
let m = Message::new(
Role::Assistant,
vec![
ContentBlock::text("a"),
ContentBlock::tool_use("t", "n", serde_json::json!({})),
ContentBlock::text("b"),
],
);
assert_eq!(m.text(), "ab");
assert_eq!(m.tool_uses().count(), 1);
}
#[test]
fn tool_errors_are_marked() {
let ok = ContentBlock::tool_result("t", "fine");
let bad = ContentBlock::tool_error("t", "boom");
assert!(matches!(
ok,
ContentBlock::ToolResult {
is_error: false,
..
}
));
assert!(matches!(
bad,
ContentBlock::ToolResult { is_error: true, .. }
));
}
}