Skip to main content

protocol_droid/application/server/
read.rs

1//! The JSON `resources/read` and `prompts/get` answer with.
2//!
3//! # Why these documents live here and not in the IR
4//!
5//! `GetPromptResult` and `ResourceContents` are pure data with no
6//! serialisation of their own, exactly like `ToolResult`. Assembly is an
7//! application concern because it depends on which revision the session
8//! negotiated — a fact the domain layer is not allowed to know — and a
9//! `to_json` on the IR type would have to take the revision as an argument
10//! anyway, which puts it back where it started.
11//!
12//! # Why a prompt's content goes through the tool codec
13//!
14//! A `prompts/get` message carries a `ContentBlock`, and that union is spelled
15//! differently per revision — `resource_link` arrives in `2025-06-18`, audio
16//! in `2025-03-26`. Rather than answer "how is a content block spelled at this
17//! revision" a second time, this wraps the block in the one-block `ToolResult`
18//! the tool path already lowers and reads the block back out of the answer.
19//!
20//! That looks roundabout and is not. The alternative is a second copy of the
21//! per-revision codecs answering the same question, and the two would agree
22//! until the next revision added a field to one of them and not the other.
23
24use std::future::Future;
25use std::sync::Arc;
26
27use crate::adapters::secondary::codec::result::verbatim;
28use crate::application::server::DispatchError;
29use crate::application::server::builder::{BoxReadFuture, PromptHandler, ResourceHandler};
30use crate::application::server::context::RequestContext;
31use crate::application::server::result::OutcomeError;
32use crate::domain::ProtocolVersion;
33use crate::domain::ir::{
34    Content, GetPromptResult, JsonObject, PromptMessage, ResourceBody, ResourceContents, ToolResult,
35};
36
37/// The `ReadResourceResult` for `contents`.
38///
39/// # Errors
40///
41/// [`OutcomeError::NotAnObject`] when an assembled entry is not a JSON object.
42pub fn read_contents(contents: &[ResourceContents]) -> Result<JsonObject, OutcomeError> {
43    let mut entries = Vec::with_capacity(contents.len());
44    for entry in contents {
45        let mut object = serde_json::Map::new();
46        object.insert("uri".to_owned(), entry.uri.clone().into());
47        match &entry.body {
48            ResourceBody::Text(text) => {
49                object.insert("text".to_owned(), text.clone().into());
50            }
51            ResourceBody::Blob(data) => {
52                object.insert("blob".to_owned(), data.clone().into());
53            }
54        }
55        if let Some(mime_type) = &entry.mime_type {
56            object.insert("mimeType".to_owned(), mime_type.clone().into());
57        }
58        entries.push(serde_json::Value::Object(object));
59    }
60
61    JsonObject::from_value(serde_json::json!({ "contents": entries }))
62        .map_err(OutcomeError::NotAnObject)
63}
64
65/// The `GetPromptResult` for `result`, with content blocks spelled for `version`.
66///
67/// # Errors
68///
69/// [`OutcomeError::NotLowerable`] for a block the negotiated revision cannot
70/// express, and [`OutcomeError::NotAnObject`] when the assembled document is
71/// not an object.
72pub fn prompt_messages(
73    result: &GetPromptResult,
74    version: ProtocolVersion,
75) -> Result<JsonObject, OutcomeError> {
76    let mut messages = Vec::with_capacity(result.messages.len());
77    for message in &result.messages {
78        let block = one_block(&message.content, version)?;
79        // `block` is a validated JSON object held as a string, and `json!`
80        // will not take it without a serde impl it does not need. Re-parsing
81        // here is one parse per prompt message on a path that is not hot.
82        let block: serde_json::Value = serde_json::from_str(block.as_str())
83            .map_err(|error| OutcomeError::Answer(error.to_string()))?;
84        messages.push(serde_json::json!({
85            "role": message.role.as_str(),
86            "content": block,
87        }));
88    }
89
90    let mut document = serde_json::json!({ "messages": messages });
91    if let (serde_json::Value::Object(map), Some(description)) =
92        (&mut document, result.description.as_deref())
93    {
94        map.insert("description".to_string(), description.into());
95    }
96    JsonObject::from_value(document).map_err(OutcomeError::NotAnObject)
97}
98
99/// One content block, lowered for `version` and unwrapped from its result.
100///
101/// The wrapping is invisible on the wire and cannot fail for a reason the
102/// caller could act on differently, so it does not get an error variant of its
103/// own.
104fn one_block(content: &Content, version: ProtocolVersion) -> Result<JsonObject, OutcomeError> {
105    let document = verbatim(version, &ToolResult::new(vec![content.clone()]))?;
106    let parsed: serde_json::Value = serde_json::from_str(document.as_str())
107        .map_err(|error| OutcomeError::Answer(error.to_string()))?;
108    let block = parsed
109        .get("content")
110        .and_then(serde_json::Value::as_array)
111        .and_then(|blocks| blocks.first())
112        .cloned()
113        .ok_or_else(|| OutcomeError::Answer("the lowered result carried no block".to_owned()))?;
114    JsonObject::from_value(block).map_err(OutcomeError::NotAnObject)
115}
116
117/// Adapts a resource reader into the [`ResourceHandler`] a server stores.
118///
119/// Takes the URI by value rather than by reference. A reference here would put
120/// a lifetime on the bound, and a higher-ranked `for<'uri>` in a `where` clause
121/// also binds the *return* type: an `async fn` returns a different future for
122/// each `'uri`, so no single `Fut` can satisfy it and the bound rejects every
123/// async reader. Owned arguments leave the future type independent of any
124/// lifetime. `#[server]`'s expansion wraps a reference-taking reader into this
125/// shape, so `#[resource]` readers are still written as `fn(&str)`.
126///
127/// The reader takes and returns owned values, so the boxed future is
128/// `'static` and does not borrow the request.
129///
130/// # Examples
131///
132/// ```
133/// use protocol_droid::application::server::builder::ResourceHandler;
134/// use protocol_droid::application::server::context::RequestContext;
135/// use protocol_droid::application::server::read::resource_reader;
136/// use protocol_droid::domain::ir::ResourceContents;
137///
138/// // The context is required rather than optional: it carries who is asking,
139/// // and a reader that could not see it would serve every caller as whoever
140/// // opened the connection.
141/// async fn readme(_context: RequestContext, uri: String) -> Vec<ResourceContents> {
142///     vec![ResourceContents::text(uri, "the body")]
143/// }
144///
145/// let handler: ResourceHandler = resource_reader(readme);
146/// assert!(std::sync::Arc::strong_count(&handler) >= 1);
147/// ```
148pub fn resource_reader<F, Fut>(read: F) -> ResourceHandler
149where
150    F: Fn(RequestContext, String) -> Fut + Send + Sync + 'static,
151    Fut: Future<Output = Vec<ResourceContents>> + Send + 'static,
152{
153    // Shared rather than captured. The handler is `Fn`, so it may be called
154    // more than once, and a future that moved the reader out of the closure
155    // would make the second call impossible. An `Arc` clone is cheap and the
156    // future owns it, which is what lets the boxed future stay `'static`.
157    let read = Arc::new(read);
158    let handler: ResourceHandler = Arc::new(move |context, uri: String| {
159        let read = Arc::clone(&read);
160        Box::pin(async move {
161            let contents = read(context, uri).await;
162            read_contents(&contents).map_err(DispatchError::from)
163        }) as BoxReadFuture
164    });
165    handler
166}
167
168/// Adapts a prompt getter into the [`PromptHandler`] a server stores.
169///
170/// Takes a future, as [`resource_reader`] does. A reader that has nothing to
171/// await is written as a plain `fn` and wrapped by `#[server]`'s expansion;
172/// calling the adapter directly means passing an `async fn`, because wrapping
173/// is the macro's job and doing it here would mean every caller writes it.
174///
175/// The revision the content blocks are spelled for comes from the context, not
176/// from a parameter, because it is the session's answer and not the caller's to
177/// guess.
178///
179/// # Examples
180///
181/// ```
182/// use protocol_droid::application::server::builder::PromptHandler;
183/// use protocol_droid::application::server::context::RequestContext;
184/// use protocol_droid::application::server::read::prompt_getter;
185/// use protocol_droid::domain::ir::{
186///     Content, JsonObject, PromptMessage, Role, TextContent,
187/// };
188///
189/// async fn greeting(_context: RequestContext, _arguments: JsonObject) -> Vec<PromptMessage> {
190///     vec![PromptMessage::new(
191///         Role::User,
192///         Content::Text(TextContent::new("hello")),
193///     )]
194/// }
195///
196/// let handler: PromptHandler = prompt_getter(greeting);
197/// assert!(std::sync::Arc::strong_count(&handler) >= 1);
198/// ```
199pub fn prompt_getter<F, Fut>(get: F) -> PromptHandler
200where
201    F: Fn(RequestContext, JsonObject) -> Fut + Send + Sync + 'static,
202    Fut: Future<Output = Vec<PromptMessage>> + Send + 'static,
203{
204    let get = Arc::new(get);
205    let handler: PromptHandler = Arc::new(move |context, arguments: JsonObject| {
206        let get = Arc::clone(&get);
207        Box::pin(async move {
208            // Read before the move, because the revision this result is
209            // spelled for comes from the same context the getter consumes.
210            let version = context.peer().negotiated();
211            let messages = get(context, arguments).await;
212            let result = GetPromptResult::new(messages);
213            prompt_messages(&result, version).map_err(DispatchError::from)
214        }) as BoxReadFuture
215    });
216    handler
217}