use protocol_droid_schema::wire::v2024_11_05 as wire;
use super::{IMAGE_TYPE, META_KEY, RESOURCE_TYPE, TEXT_TYPE};
use crate::domain::ir::Codec;
use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
use crate::domain::ir::{
Annotations, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent, Role,
TextContent, ToolResult,
};
use crate::domain::{
CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
resolve,
};
const RAW_ANNOTATIONS_KEY: &str = "annotations";
fn lift_annotations(value: &serde_json::Value) -> Annotations {
let Some(object) = value.as_object() else {
return Annotations {
extensions: Extensions::empty()
.with(RAW_ANNOTATIONS_KEY, ExtensionValue::from(value.clone())),
..Annotations::empty()
};
};
let mut annotations = Annotations::empty();
if let Some(priority) = object.get("priority").and_then(serde_json::Value::as_f64) {
annotations.priority = Some(priority);
}
if let Some(last_modified) = object
.get("lastModified")
.and_then(serde_json::Value::as_str)
{
annotations.last_modified = Some(last_modified.to_owned());
}
if let Some(roles) = object.get("audience").and_then(serde_json::Value::as_array) {
annotations.audience = roles
.iter()
.filter_map(|role| role.as_str())
.filter_map(|role| {
if role == Role::User.as_str() {
Some(Role::User)
} else if role == Role::Assistant.as_str() {
Some(Role::Assistant)
} else {
None
}
})
.collect();
}
for (key, value) in object {
if !matches!(key.as_str(), "priority" | "lastModified" | "audience") {
annotations
.extensions
.insert(key.clone(), ExtensionValue::from(value.clone()));
}
}
annotations
}
fn lower_annotations(annotations: Option<&Annotations>) -> Option<serde_json::Value> {
let annotations = annotations?;
let mut object = annotations.extensions.to_json_object();
if let Some(raw) = object.remove(RAW_ANNOTATIONS_KEY) {
return Some(raw);
}
if !annotations.audience.is_empty() {
object.insert(
"audience".to_owned(),
serde_json::Value::Array(
annotations
.audience
.iter()
.map(|role| serde_json::Value::String((*role).as_str().to_owned()))
.collect(),
),
);
}
if let Some(priority) = annotations.priority {
object.insert(
"priority".to_owned(),
serde_json::Number::from_f64(priority)
.map_or(serde_json::Value::Null, serde_json::Value::Number),
);
}
if let Some(last_modified) = &annotations.last_modified {
object.insert(
"lastModified".to_owned(),
serde_json::Value::String(last_modified.clone()),
);
}
if object.is_empty() {
return None;
}
Some(serde_json::Value::Object(object))
}
#[derive(Debug, Clone, Copy)]
pub struct V2024_11_05;
impl Codec for V2024_11_05 {
type Wire = wire::CallToolResult;
fn version(&self) -> ProtocolVersion {
ProtocolVersion::V2024_11_05
}
fn capabilities(&self) -> FeatureProfile {
let caps = CapabilitySet::all_for(self.version());
resolve(Negotiation {
version: self.version(),
client_caps: caps,
server_caps: caps,
transport: TransportKind::Stdio,
})
}
fn lift(&self, wire: &Self::Wire) -> ToolResult {
let mut extensions = Extensions::from_json_object(wire.extra.clone());
if let Some(meta) = &wire.meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
ToolResult {
content: wire.content.iter().map(lift_block).collect(),
structured_content: None,
is_error: wire.is_error.unwrap_or(false),
result_type: None,
extensions,
}
}
fn lower(
&self,
value: ToolResult,
policy: DegradePolicy,
report: &mut TranslationReport,
) -> Result<Self::Wire, LowerError> {
let (value, lowered) =
lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
report.absorb(&lowered);
let extra = value.extensions.to_json_object();
let meta = value.extensions.get(META_KEY).map(serde_json::Value::from);
let content = value
.content
.iter()
.map(lower_block)
.collect::<Result<Vec<_>, _>>()?;
Ok(wire::CallToolResult {
meta,
content,
is_error: value.is_error.then_some(true),
extra,
})
}
}
fn lift_block(block: &wire::CallToolResultContentChoice) -> Content {
match block {
wire::CallToolResultContentChoice::Member0(text) => Content::Text(TextContent {
text: text.text.clone(),
annotations: text.annotations.as_ref().map(lift_annotations),
extensions: Extensions::from_json_object(text.extra.clone()),
}),
wire::CallToolResultContentChoice::Member1(image) => Content::Image(ImageContent {
data: image.data.clone(),
mime_type: image.mime_type.clone(),
annotations: image.annotations.as_ref().map(lift_annotations),
extensions: Extensions::from_json_object(image.extra.clone()),
}),
wire::CallToolResultContentChoice::Member2(resource) => {
let wire::EmbeddedResource {
annotations,
resource,
extra,
..
} = resource;
let (uri, mime_type, text, blob) = match resource {
wire::EmbeddedResourceResourceChoice::Member0(text) => (
text.uri.clone(),
text.mime_type.clone(),
Some(text.text.clone()),
None,
),
wire::EmbeddedResourceResourceChoice::Member1(blob) => (
blob.uri.clone(),
blob.mime_type.clone(),
None,
Some(blob.blob.clone()),
),
};
Content::EmbeddedResource(EmbeddedResource {
uri,
mime_type,
text,
blob,
annotations: annotations.as_ref().map(lift_annotations),
extensions: Extensions::from_json_object(extra.clone()),
})
}
}
}
fn lower_block(block: &Content) -> Result<wire::CallToolResultContentChoice, LowerError> {
let lowered = match block {
Content::Text(text) => wire::CallToolResultContentChoice::Member0(wire::TextContent {
annotations: lower_annotations(text.annotations.as_ref()),
text: text.text.clone(),
r#type: TEXT_TYPE.to_owned(),
extra: text.extensions.to_json_object(),
}),
Content::Image(image) => wire::CallToolResultContentChoice::Member1(wire::ImageContent {
annotations: lower_annotations(image.annotations.as_ref()),
data: image.data.clone(),
mime_type: image.mime_type.clone(),
r#type: IMAGE_TYPE.to_owned(),
extra: image.extensions.to_json_object(),
}),
Content::EmbeddedResource(resource) => {
let contents = if let Some(text) = &resource.text {
wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
mime_type: resource.mime_type.clone(),
text: text.clone(),
uri: resource.uri.clone(),
extra: serde_json::Map::new(),
})
} else if let Some(blob) = &resource.blob {
wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
blob: blob.clone(),
mime_type: resource.mime_type.clone(),
uri: resource.uri.clone(),
extra: serde_json::Map::new(),
})
} else {
return Err(LowerError::FieldNotRepresentable {
field: "resource".to_owned(),
expected: "a text or blob body",
detail: "the resource has neither `text` nor `blob`".to_owned(),
target: ProtocolVersion::V2024_11_05,
});
};
wire::CallToolResultContentChoice::Member2(wire::EmbeddedResource {
annotations: lower_annotations(resource.annotations.as_ref()),
resource: contents,
r#type: RESOURCE_TYPE.to_owned(),
extra: resource.extensions.to_json_object(),
})
}
Content::Unknown(unknown) => {
return Err(LowerError::FieldNotRepresentable {
field: unknown.type_name.clone(),
expected: "a 2024-11-05 content block",
detail: "this build does not model the block, and this revision has no \
member that could carry it"
.to_owned(),
target: ProtocolVersion::V2024_11_05,
});
}
Content::Audio(_) | Content::ResourceLink(_) => {
return Err(LowerError::FieldNotRepresentable {
field: "content".to_owned(),
expected: "a 2024-11-05 content block",
detail: "the gate admitted a block this revision cannot express".to_owned(),
target: ProtocolVersion::V2024_11_05,
});
}
};
Ok(lowered)
}
#[cfg(test)]
mod tests {
type TestError = Box<dyn std::error::Error>;
use super::{V2024_11_05, lift_annotations, lower_annotations, wire};
use crate::domain::DegradePolicy;
use crate::domain::ir::Codec;
use crate::domain::ir::lower::{TranslationReport, lower_tool_result};
use crate::domain::ir::{
Annotations, Content, EmbeddedResource, ExtensionValue, ImageContent, JsonObject, Role,
TextContent, ToolResult, UnknownContent,
};
fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
let codec = V2024_11_05;
let wire: wire::CallToolResult = serde_json::from_str(json)?;
let mut report = TranslationReport::new(codec.version());
let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
Ok(serde_json::to_value(&lowered)?)
}
fn parsed(json: &str) -> Result<serde_json::Value, TestError> {
Ok(serde_json::from_str(json)?)
}
#[test]
fn a_result_round_trips_through_json() -> Result<(), TestError> {
for json in [
r#"{"content":[{"type":"text","text":"hello"}]}"#,
r#"{"content":[{"type":"text","text":"hi","annotations":{"audience":["user"],"priority":0.5}}]}"#,
r#"{"content":[{"type":"image","data":"AAA","mimeType":"image/png"}]}"#,
r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","blob":"AAA"}}]}"#,
r#"{"content":[],"isError":true}"#,
] {
assert_eq!(
round_trip(json)?,
parsed(json)?,
"did not survive the crossing"
);
}
Ok(())
}
#[test]
fn meta_survives_as_a_field_rather_than_a_bag_member() -> Result<(), TestError> {
let json = r#"{"_meta":{"progress":0.5},"content":[]}"#;
let codec = V2024_11_05;
let wire: wire::CallToolResult = serde_json::from_str(json)?;
let ir = codec.lift(&wire);
assert!(
ir.extensions
.contains(crate::adapters::secondary::codec::result::META_KEY)
);
let mut report = TranslationReport::new(codec.version());
let lowered = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
assert_eq!(lowered.meta, Some(serde_json::json!({"progress": 0.5})));
assert_eq!(serde_json::to_value(&lowered)?, parsed(json)?);
Ok(())
}
#[test]
fn unmodelled_annotations_are_returned_whole() -> Result<(), TestError> {
let json = r#"{"content":[{"type":"text","text":"x","annotations":{"priority":0.5,"unheardOf":true}}]}"#;
assert_eq!(round_trip(json)?, parsed(json)?);
Ok(())
}
#[test]
fn modelled_annotations_are_typed_in_the_ir() {
let value = serde_json::json!({"audience": ["user", "assistant"], "priority": 0.25});
let annotations = lift_annotations(&value);
assert_eq!(annotations.audience, vec![Role::User, Role::Assistant]);
assert_eq!(annotations.priority, Some(0.25));
assert_eq!(lower_annotations(Some(&annotations)), Some(value));
}
#[test]
fn an_unrecognised_member_is_kept_alongside_the_modelled_ones() {
let value = serde_json::json!({"priority": 0.5, "unheardOf": true});
let annotations = lift_annotations(&value);
assert_eq!(annotations.priority, Some(0.5));
assert_eq!(
annotations.extensions.get("unheardOf"),
Some(&ExtensionValue::Bool(true))
);
assert_eq!(lower_annotations(Some(&annotations)), Some(value));
}
#[test]
fn annotations_that_are_not_an_object_survive_whole() {
let value = serde_json::json!("just a string");
let annotations = lift_annotations(&value);
assert!(annotations.audience.is_empty());
assert_eq!(lower_annotations(Some(&annotations)), Some(value));
}
#[test]
fn priority_is_not_narrowed_on_the_way_through() -> Result<(), TestError> {
let json = r#"{"content":[{"type":"text","text":"x","annotations":{"priority":0.1}}]}"#;
assert_eq!(round_trip(json)?, parsed(json)?);
Ok(())
}
#[test]
fn an_unmodelled_block_is_refused_rather_than_dropped() -> Result<(), TestError> {
let codec = V2024_11_05;
let mut ir = ToolResult::new(vec![Content::Text(TextContent::new("x"))]);
ir.content.push(Content::Unknown(UnknownContent {
type_name: "video".to_owned(),
raw: JsonObject::new(r#"{"type":"video"}"#.to_owned())?,
extensions: crate::domain::ir::Extensions::empty(),
}));
let mut report = TranslationReport::new(codec.version());
let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
return Err("this must be refused".into());
};
assert!(
error.to_string().contains("video"),
"the error must name the block: {error}"
);
Ok(())
}
#[test]
fn audio_is_refused_by_the_gate_before_the_codec_sees_it() -> Result<(), TestError> {
let codec = V2024_11_05;
let ir = ToolResult::new(vec![Content::Audio(crate::domain::ir::AudioContent::new(
"AAA",
"audio/wav",
))]);
let mut report = TranslationReport::new(codec.version());
let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
return Err("this must be refused".into());
};
let gate = lower_tool_result(
ToolResult::new(vec![Content::Audio(crate::domain::ir::AudioContent::new(
"AAA",
"audio/wav",
))]),
&codec.capabilities(),
codec.version(),
DegradePolicy::strict(),
);
assert!(gate.is_err(), "the gate is what refuses, not the codec");
assert!(!error.to_string().is_empty());
Ok(())
}
#[test]
fn a_typed_result_survives_lift_and_lower() -> Result<(), TestError> {
let codec = V2024_11_05;
let ir = ToolResult::new(vec![
Content::Text(TextContent::new("hello")),
Content::Image(ImageContent::new("AAA", "image/png")),
Content::EmbeddedResource(EmbeddedResource::text("file:///a", "body")),
]);
let mut report = TranslationReport::new(codec.version());
let wire = codec.lower(ir.clone(), DegradePolicy::strict(), &mut report)?;
assert!(report.is_empty());
assert_eq!(codec.lift(&wire), ir);
Ok(())
}
#[test]
fn absent_annotations_stay_absent() {
assert_eq!(lower_annotations(None), None);
assert_eq!(lower_annotations(Some(&Annotations::empty())), None);
}
}