1c9a7c0b05
Co-authored-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Wendy Tang <wendytang@squareup.com> Co-authored-by: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Co-authored-by: Alex Hancock <alex.hancock@example.com> Co-authored-by: Alex Hancock <alexhancock@block.xyz> Co-authored-by: Lifei Zhou <lifei@squareup.com> Co-authored-by: Wes <141185334+wesrblock@users.noreply.github.com> Co-authored-by: Max Novich <maksymstepanenko1990@gmail.com> Co-authored-by: Zaki Ali <zaki@squareup.com> Co-authored-by: Salman Mohammed <smohammed@squareup.com> Co-authored-by: Kalvin C <kalvinnchau@users.noreply.github.com> Co-authored-by: Alec Thomas <alec@swapoff.org> Co-authored-by: lily-de <119957291+lily-de@users.noreply.github.com> Co-authored-by: kalvinnchau <kalvin@block.xyz> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Rizel Scarlett <rizel@squareup.com> Co-authored-by: bwrage <bwrage@squareup.com> Co-authored-by: Kalvin Chau <kalvin@squareup.com> Co-authored-by: Alice Hau <110418948+ahau-square@users.noreply.github.com> Co-authored-by: Alistair Gray <ajgray@stripe.com> Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com> Co-authored-by: Alex Hancock <alexhancock@squareup.com> Co-authored-by: Nahiyan Khan <nahiyan@squareup.com> Co-authored-by: marcelle <1852848+laanak08@users.noreply.github.com> Co-authored-by: Yingjie He <yingjiehe@block.xyz> Co-authored-by: Yingjie He <yingjiehe@squareup.com> Co-authored-by: Lily Delalande <ldelalande@block.xyz> Co-authored-by: Adewale Abati <acekyd01@gmail.com> Co-authored-by: Ebony Louis <ebony774@gmail.com> Co-authored-by: Angie Jones <jones.angie@gmail.com> Co-authored-by: Ebony Louis <55366651+EbonyLouis@users.noreply.github.com>
311 lines
9.9 KiB
Rust
311 lines
9.9 KiB
Rust
/// Content sent around agents, extensions, and LLMs
|
|
/// The various content types can be display to humans but also understood by models
|
|
/// They include optional annotations used to help inform agent usage
|
|
use super::role::Role;
|
|
use crate::resource::ResourceContents;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Annotations {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub audience: Option<Vec<Role>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub priority: Option<f32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub timestamp: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl Annotations {
|
|
/// Creates a new Annotations instance specifically for resources
|
|
/// optional priority, and a timestamp (defaults to now if None)
|
|
pub fn for_resource(priority: f32, timestamp: DateTime<Utc>) -> Self {
|
|
assert!(
|
|
(0.0..=1.0).contains(&priority),
|
|
"Priority {priority} must be between 0.0 and 1.0"
|
|
);
|
|
Annotations {
|
|
priority: Some(priority),
|
|
timestamp: Some(timestamp),
|
|
audience: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TextContent {
|
|
pub text: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub annotations: Option<Annotations>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ImageContent {
|
|
pub data: String,
|
|
pub mime_type: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub annotations: Option<Annotations>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct EmbeddedResource {
|
|
pub resource: ResourceContents,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub annotations: Option<Annotations>,
|
|
}
|
|
|
|
impl EmbeddedResource {
|
|
pub fn get_text(&self) -> String {
|
|
match &self.resource {
|
|
ResourceContents::TextResourceContents { text, .. } => text.clone(),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum Content {
|
|
Text(TextContent),
|
|
Image(ImageContent),
|
|
Resource(EmbeddedResource),
|
|
}
|
|
|
|
impl Content {
|
|
pub fn text<S: Into<String>>(text: S) -> Self {
|
|
Content::Text(TextContent {
|
|
text: text.into(),
|
|
annotations: None,
|
|
})
|
|
}
|
|
|
|
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
|
|
Content::Image(ImageContent {
|
|
data: data.into(),
|
|
mime_type: mime_type.into(),
|
|
annotations: None,
|
|
})
|
|
}
|
|
|
|
pub fn resource(resource: ResourceContents) -> Self {
|
|
Content::Resource(EmbeddedResource {
|
|
resource,
|
|
annotations: None,
|
|
})
|
|
}
|
|
|
|
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
|
|
Content::Resource(EmbeddedResource {
|
|
resource: ResourceContents::TextResourceContents {
|
|
uri: uri.into(),
|
|
mime_type: Some("text".to_string()),
|
|
text: content.into(),
|
|
},
|
|
annotations: None,
|
|
})
|
|
}
|
|
|
|
/// Get the text content if this is a TextContent variant
|
|
pub fn as_text(&self) -> Option<&str> {
|
|
match self {
|
|
Content::Text(text) => Some(&text.text),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Get the image content if this is an ImageContent variant
|
|
pub fn as_image(&self) -> Option<(&str, &str)> {
|
|
match self {
|
|
Content::Image(image) => Some((&image.data, &image.mime_type)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Set the audience for the content
|
|
pub fn with_audience(mut self, audience: Vec<Role>) -> Self {
|
|
let annotations = match &mut self {
|
|
Content::Text(text) => &mut text.annotations,
|
|
Content::Image(image) => &mut image.annotations,
|
|
Content::Resource(resource) => &mut resource.annotations,
|
|
};
|
|
*annotations = Some(match annotations.take() {
|
|
Some(mut a) => {
|
|
a.audience = Some(audience);
|
|
a
|
|
}
|
|
None => Annotations {
|
|
audience: Some(audience),
|
|
priority: None,
|
|
timestamp: None,
|
|
},
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Set the priority for the content
|
|
/// # Panics
|
|
/// Panics if priority is not between 0.0 and 1.0 inclusive
|
|
pub fn with_priority(mut self, priority: f32) -> Self {
|
|
if !(0.0..=1.0).contains(&priority) {
|
|
panic!("Priority must be between 0.0 and 1.0");
|
|
}
|
|
let annotations = match &mut self {
|
|
Content::Text(text) => &mut text.annotations,
|
|
Content::Image(image) => &mut image.annotations,
|
|
Content::Resource(resource) => &mut resource.annotations,
|
|
};
|
|
*annotations = Some(match annotations.take() {
|
|
Some(mut a) => {
|
|
a.priority = Some(priority);
|
|
a
|
|
}
|
|
None => Annotations {
|
|
audience: None,
|
|
priority: Some(priority),
|
|
timestamp: None,
|
|
},
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Get the audience if set
|
|
pub fn audience(&self) -> Option<&Vec<Role>> {
|
|
match self {
|
|
Content::Text(text) => text.annotations.as_ref().and_then(|a| a.audience.as_ref()),
|
|
Content::Image(image) => image.annotations.as_ref().and_then(|a| a.audience.as_ref()),
|
|
Content::Resource(resource) => resource
|
|
.annotations
|
|
.as_ref()
|
|
.and_then(|a| a.audience.as_ref()),
|
|
}
|
|
}
|
|
|
|
/// Get the priority if set
|
|
pub fn priority(&self) -> Option<f32> {
|
|
match self {
|
|
Content::Text(text) => text.annotations.as_ref().and_then(|a| a.priority),
|
|
Content::Image(image) => image.annotations.as_ref().and_then(|a| a.priority),
|
|
Content::Resource(resource) => resource.annotations.as_ref().and_then(|a| a.priority),
|
|
}
|
|
}
|
|
|
|
pub fn unannotated(&self) -> Self {
|
|
match self {
|
|
Content::Text(text) => Content::text(text.text.clone()),
|
|
Content::Image(image) => Content::image(image.data.clone(), image.mime_type.clone()),
|
|
Content::Resource(resource) => Content::resource(resource.resource.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_content_text() {
|
|
let content = Content::text("hello");
|
|
assert_eq!(content.as_text(), Some("hello"));
|
|
assert_eq!(content.as_image(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_image() {
|
|
let content = Content::image("data", "image/png");
|
|
assert_eq!(content.as_text(), None);
|
|
assert_eq!(content.as_image(), Some(("data", "image/png")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_annotations_basic() {
|
|
let content = Content::text("hello")
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.5);
|
|
assert_eq!(content.audience(), Some(&vec![Role::User]));
|
|
assert_eq!(content.priority(), Some(0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_annotations_order_independence() {
|
|
let content1 = Content::text("hello")
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.5);
|
|
let content2 = Content::text("hello")
|
|
.with_priority(0.5)
|
|
.with_audience(vec![Role::User]);
|
|
|
|
assert_eq!(content1.audience(), content2.audience());
|
|
assert_eq!(content1.priority(), content2.priority());
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_annotations_overwrite() {
|
|
let content = Content::text("hello")
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.5)
|
|
.with_audience(vec![Role::Assistant])
|
|
.with_priority(0.8);
|
|
|
|
assert_eq!(content.audience(), Some(&vec![Role::Assistant]));
|
|
assert_eq!(content.priority(), Some(0.8));
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_annotations_image() {
|
|
let content = Content::image("data", "image/png")
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.5);
|
|
|
|
assert_eq!(content.audience(), Some(&vec![Role::User]));
|
|
assert_eq!(content.priority(), Some(0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_annotations_preservation() {
|
|
let text_content = Content::text("hello")
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.5);
|
|
|
|
match &text_content {
|
|
Content::Text(TextContent { annotations, .. }) => {
|
|
assert!(annotations.is_some());
|
|
let ann = annotations.as_ref().unwrap();
|
|
assert_eq!(ann.audience, Some(vec![Role::User]));
|
|
assert_eq!(ann.priority, Some(0.5));
|
|
}
|
|
_ => panic!("Expected Text content"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "Priority must be between 0.0 and 1.0")]
|
|
fn test_invalid_priority() {
|
|
Content::text("hello").with_priority(1.5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unannotated() {
|
|
let content = Content::text("hello")
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.5);
|
|
let unannotated = content.unannotated();
|
|
assert_eq!(unannotated.audience(), None);
|
|
assert_eq!(unannotated.priority(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_partial_annotations() {
|
|
let content = Content::text("hello").with_priority(0.5);
|
|
assert_eq!(content.audience(), None);
|
|
assert_eq!(content.priority(), Some(0.5));
|
|
|
|
let content = Content::text("hello").with_audience(vec![Role::User]);
|
|
assert_eq!(content.audience(), Some(&vec![Role::User]));
|
|
assert_eq!(content.priority(), None);
|
|
}
|
|
}
|