use rustlavel_core::Json;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
Tool,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
}
}
pub fn parse(text: &str) -> Option<Role> {
match text {
"system" => Some(Role::System),
"user" => Some(Role::User),
"assistant" => Some(Role::Assistant),
"tool" => Some(Role::Tool),
_ => None,
}
}
}
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)]
pub enum Content {
Text(String),
ToolUse { id: String, name: String, input: Json },
ToolResult { id: String, name: String, output: Json, is_error: bool },
}
impl Content {
pub fn text(text: impl Into<String>) -> Content {
Content::Text(text.into())
}
pub fn as_text(&self) -> Option<&str> {
match self {
Content::Text(text) => Some(text),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Message {
pub role: Role,
pub content: Vec<Content>,
}
impl Message {
pub fn new(role: Role, content: Vec<Content>) -> Message {
Message { role, content }
}
pub fn system(text: impl Into<String>) -> Message {
Message::new(Role::System, vec![Content::text(text)])
}
pub fn user(text: impl Into<String>) -> Message {
Message::new(Role::User, vec![Content::text(text)])
}
pub fn assistant(text: impl Into<String>) -> Message {
Message::new(Role::Assistant, vec![Content::text(text)])
}
pub fn tool_result(id: impl Into<String>, name: impl Into<String>, output: Json) -> Message {
Message::new(
Role::Tool,
vec![Content::ToolResult {
id: id.into(),
name: name.into(),
output,
is_error: false,
}],
)
}
pub fn tool_error(
id: impl Into<String>,
name: impl Into<String>,
message: impl Into<String>,
) -> Message {
Message::new(
Role::Tool,
vec![Content::ToolResult {
id: id.into(),
name: name.into(),
output: Json::from(message.into()),
is_error: true,
}],
)
}
pub fn with(mut self, part: Content) -> Message {
self.content.push(part);
self
}
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(Content::as_text)
.collect::<Vec<_>>()
.join("")
}
pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &Json)> {
self.content.iter().filter_map(|part| match part {
Content::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
_ => None,
})
}
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Conversation {
messages: Vec<Message>,
}
impl Conversation {
pub fn new() -> Conversation {
Conversation::default()
}
pub fn system(mut self, text: impl Into<String>) -> Conversation {
self.messages.push(Message::system(text));
self
}
pub fn user(mut self, text: impl Into<String>) -> Conversation {
self.messages.push(Message::user(text));
self
}
pub fn assistant(mut self, text: impl Into<String>) -> Conversation {
self.messages.push(Message::assistant(text));
self
}
pub fn push(&mut self, message: Message) {
self.messages.push(message);
}
pub fn extend(&mut self, messages: impl IntoIterator<Item = Message>) {
self.messages.extend(messages);
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn last(&self) -> Option<&Message> {
self.messages.last()
}
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn into_messages(self) -> Vec<Message> {
self.messages
}
}
impl From<Conversation> for Vec<Message> {
fn from(conversation: Conversation) -> Vec<Message> {
conversation.messages
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_message_joins_its_text_parts_and_keeps_tool_parts_apart() {
let message = Message::assistant("Let me check. ")
.with(Content::text("One moment."))
.with(Content::ToolUse {
id: "toolu_1".into(),
name: "get_weather".into(),
input: Json::object([("city", "Oslo".into())]),
});
assert_eq!(message.text(), "Let me check. One moment.");
let uses: Vec<_> = message.tool_uses().collect();
assert_eq!(uses.len(), 1);
assert_eq!(uses[0].0, "toolu_1");
assert_eq!(uses[0].1, "get_weather");
assert_eq!(uses[0].2.get("city").unwrap().as_str(), Some("Oslo"));
}
#[test]
fn a_failing_tool_becomes_a_message_rather_than_an_error() {
let message = Message::tool_error("toolu_1", "get_weather", "no such city");
assert_eq!(message.role, Role::Tool);
match &message.content[0] {
Content::ToolResult { is_error, output, .. } => {
assert!(is_error);
assert_eq!(output.as_str(), Some("no such city"));
}
other => panic!("expected a tool result, got {other:?}"),
}
}
#[test]
fn a_conversation_builds_up_in_order() {
let mut conversation = Conversation::new().system("Be brief.").user("Hello");
conversation.push(Message::assistant("Hi."));
assert_eq!(conversation.len(), 3);
assert_eq!(conversation.messages()[0].role, Role::System);
assert_eq!(conversation.last().unwrap().text(), "Hi.");
assert!(!conversation.is_empty());
}
#[test]
fn roles_round_trip_through_their_wire_names() {
for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
assert_eq!(Role::parse(role.as_str()), Some(role));
assert_eq!(role.to_string(), role.as_str());
}
assert_eq!(Role::parse("wizard"), None);
}
}