Skip to main content

rap_protocol/
lib.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::future::Future;
4use std::panic::AssertUnwindSafe;
5
6// ── Identifier kinds ──
7
8strkind::strkind! {
9    /// Identifies a conversation thread.
10    ///
11    /// This is the identifier RAP calls `group_id` on the wire: every
12    /// invocation and callback carries the thread it belongs to. In the
13    /// embedded daemon runtime thread IDs are UUIDs; in the Lambda runtime a
14    /// root thread ID is the caller-chosen conversation key (which doubles as
15    /// the SQS FIFO `MessageGroupId`). Treat the contents as opaque.
16    pub ThreadId;
17}
18
19// ── RAP protocol types ──
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct RapInvocation {
23    pub operation: String,
24    #[serde(default)]
25    pub arguments: serde_json::Value,
26    pub id: String,
27    pub call_id: Option<String>,
28    pub callback_url: String,
29    pub group_id: ThreadId,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub user_id: Option<String>,
32    /// Ordered ancestor thread group IDs, from root to the parent of the current thread.
33    #[serde(default)]
34    pub thread_ancestors: Option<Vec<ThreadId>>,
35}
36
37/// A segment of display content for human-facing UIs.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(tag = "type", content = "content")]
40pub enum DisplaySegment {
41    /// Plain text display.
42    #[serde(rename = "text")]
43    Text(String),
44    /// A unified diff.
45    #[serde(rename = "diff")]
46    Diff(DiffContent),
47    /// An inline image (e.g. an image file read by a tool). Clients that
48    /// cannot render images should skip this segment and fall back to the
49    /// next supported one.
50    #[serde(rename = "image")]
51    Image(ImageContent),
52}
53
54/// Content for a diff display segment.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct DiffContent {
57    /// File path the diff applies to.
58    pub path: String,
59    /// Unified diff string.
60    pub patch: String,
61}
62
63/// Content for an image display segment.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ImageContent {
66    /// Base64-encoded image bytes.
67    pub data: String,
68    /// MIME type of the image, e.g. `image/png`.
69    #[serde(rename = "mediaType")]
70    pub media_type: String,
71}
72
73/// Build the prioritized display segments list: tool-provided `display_as`
74/// segments first, then the raw `text` as a trailing `Text` fallback.
75pub fn build_display_segments(
76    display_as: Option<&[DisplaySegment]>,
77    text: &str,
78) -> Vec<DisplaySegment> {
79    let mut segments: Vec<DisplaySegment> = display_as.map(|s| s.to_vec()).unwrap_or_default();
80    segments.push(DisplaySegment::Text(text.to_owned()));
81    segments
82}
83
84/// A single item of structured tool-result content.
85///
86/// A [`RapToolResult`] carries either a plain `text` result or a structured
87/// `content` list (which may include images) for runtimes and models that
88/// support multimodal inputs.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(tag = "type")]
91pub enum RapToolResultContent {
92    /// Plain text content.
93    #[serde(rename = "text")]
94    Text { text: String },
95    /// An image, base64-encoded.
96    #[serde(rename = "image")]
97    Image {
98        /// Base64-encoded image bytes.
99        data: String,
100        /// MIME type of the image, e.g. `image/png`.
101        #[serde(rename = "mediaType")]
102        media_type: String,
103    },
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RapToolResult {
108    pub group_id: ThreadId,
109    pub id: String,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub call_id: Option<String>,
112    /// Plain-text result — the shorthand for a text-only result. A tool MUST
113    /// provide either `text` or `content`. When `content` is also present it
114    /// supersedes `text` for the model-facing result.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub text: Option<String>,
117    /// Structured content (text and images) for multimodal results. A tool
118    /// MUST provide either `text` or `content`. When present, the runtime
119    /// builds the model-facing tool result from these items (replacing each
120    /// `image` item with a text placeholder for models without image support).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub content: Option<Vec<RapToolResultContent>>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub display_as: Option<Vec<DisplaySegment>>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub subscription: Option<bool>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RapUserChoice {
131    pub group_id: ThreadId,
132    pub id: String,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub call_id: Option<String>,
135    pub prompt: String,
136    pub choices: Vec<String>,
137    #[serde(default)]
138    pub default: usize,
139    pub response_url: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct RapSubscriptionEvent {
144    pub group_id: ThreadId,
145    pub tool_call_id: String,
146    pub text: String,
147    #[serde(default)]
148    pub associative: bool,
149    /// When `true`, this is the final event for this subscription. The runtime
150    /// SHOULD remove the subscription from its active tracking.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub r#final: Option<bool>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct RapViewUpdate {
157    pub group_id: ThreadId,
158    /// The type of view being updated (e.g. "diff").
159    pub view_type: String,
160    /// View-specific payload. The runtime passes this through to clients without interpretation.
161    pub content: serde_json::Value,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct RapOAuth {
166    pub group_id: ThreadId,
167    pub id: String,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub call_id: Option<String>,
170    pub auth_url: String,
171}
172
173/// Tagged enum for all RAP callback message types.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(tag = "type")]
176pub enum RapCallback {
177    #[serde(rename = "tool_result")]
178    ToolResult(RapToolResult),
179    #[serde(rename = "subscription_event")]
180    SubscriptionEvent(RapSubscriptionEvent),
181    #[serde(rename = "oauth")]
182    OAuth(RapOAuth),
183    #[serde(rename = "user_choice")]
184    UserChoice(RapUserChoice),
185    #[serde(rename = "view_update")]
186    ViewUpdate(RapViewUpdate),
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ToolsetManifest {
191    pub name: String,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub description: Option<String>,
194    pub endpoint: String,
195    pub tools: Vec<ToolDef>,
196    #[serde(default, rename = "needsMigration")]
197    pub needs_migration: bool,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ToolDef {
202    pub name: String,
203    pub description: String,
204    #[serde(rename = "inputSchema")]
205    pub input_schema: serde_json::Value,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub annotations: Option<serde_json::Value>,
208    #[serde(
209        default,
210        rename = "displayScript",
211        skip_serializing_if = "Option::is_none"
212    )]
213    pub display_script: Option<String>,
214}
215
216// ── Callback client ──
217
218/// Trait for sending tool results back to the RAP callback URL.
219#[async_trait]
220pub trait CallbackClient: Send + Sync + 'static {
221    async fn post_json(
222        &self,
223        url: &str,
224        body: &str,
225    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
226}
227
228/// Plain reqwest client for local mode (no auth needed).
229pub struct PlainCallbackClient {
230    client: reqwest::Client,
231}
232
233impl Default for PlainCallbackClient {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl PlainCallbackClient {
240    pub fn new() -> Self {
241        Self {
242            client: reqwest::Client::new(),
243        }
244    }
245}
246
247#[async_trait]
248impl CallbackClient for PlainCallbackClient {
249    async fn post_json(
250        &self,
251        url: &str,
252        body: &str,
253    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
254        self.client
255            .post(url)
256            .header("content-type", "application/json")
257            .body(body.to_owned())
258            .send()
259            .await?;
260        Ok(())
261    }
262}
263
264// ── Helper functions for sending RAP callbacks ──
265
266/// Send a `tool_result` callback.
267pub async fn send_tool_result<C: CallbackClient>(
268    client: &C,
269    invocation: &RapInvocation,
270    text: &str,
271    display_as: Option<Vec<DisplaySegment>>,
272    subscription: bool,
273) {
274    let result = RapCallback::ToolResult(RapToolResult {
275        group_id: invocation.group_id.clone(),
276        id: invocation.id.clone(),
277        call_id: invocation.call_id.clone(),
278        text: Some(text.to_owned()),
279        content: None,
280        display_as,
281        subscription: if subscription { Some(true) } else { None },
282    });
283    let body = serde_json::to_string(&result).expect("bug: failed to serialize tool result");
284    if let Err(e) = client.post_json(&invocation.callback_url, &body).await {
285        tracing::error!("failed to send tool result: {e}");
286    }
287}
288
289/// Send a `user_choice` callback requesting user confirmation.
290pub async fn send_user_choice<C: CallbackClient>(
291    client: &C,
292    invocation: &RapInvocation,
293    prompt: &str,
294    choices: Vec<String>,
295    default: usize,
296    response_url: &str,
297) {
298    let msg = RapCallback::UserChoice(RapUserChoice {
299        group_id: invocation.group_id.clone(),
300        id: invocation.id.clone(),
301        call_id: invocation.call_id.clone(),
302        prompt: prompt.to_owned(),
303        choices,
304        default,
305        response_url: response_url.to_owned(),
306    });
307    let body = serde_json::to_string(&msg).expect("bug: failed to serialize user_choice");
308    if let Err(e) = client.post_json(&invocation.callback_url, &body).await {
309        tracing::error!("failed to send user_choice: {e}");
310    }
311}
312
313/// Send a `subscription_event` callback.
314pub async fn send_subscription_event<C: CallbackClient>(
315    client: &C,
316    callback_url: &str,
317    group_id: ThreadId,
318    tool_call_id: String,
319    text: &str,
320    associative: bool,
321    r#final: bool,
322) {
323    let event = RapCallback::SubscriptionEvent(RapSubscriptionEvent {
324        group_id,
325        tool_call_id,
326        text: text.to_owned(),
327        associative,
328        r#final: if r#final { Some(true) } else { None },
329    });
330    let body = serde_json::to_string(&event).expect("bug: failed to serialize subscription event");
331    if let Err(e) = client.post_json(callback_url, &body).await {
332        tracing::error!("failed to send subscription event: {e}");
333    }
334}
335
336/// Send a `view_update` callback.
337pub async fn send_view_update<C: CallbackClient>(
338    client: &C,
339    callback_url: &str,
340    group_id: ThreadId,
341    view_type: &str,
342    content: serde_json::Value,
343) {
344    let msg = RapCallback::ViewUpdate(RapViewUpdate {
345        group_id,
346        view_type: view_type.to_owned(),
347        content,
348    });
349    let body = serde_json::to_string(&msg).expect("bug: failed to serialize view_update");
350    if let Err(e) = client.post_json(callback_url, &body).await {
351        tracing::error!("failed to send view_update: {e}");
352    }
353}
354
355/// Run a future and log if it panics, instead of silently swallowing the panic.
356/// Use this to wrap fire-and-forget spawned tasks.
357pub async fn log_panic<F: Future<Output = ()>>(name: &'static str, f: F) {
358    use futures_util::FutureExt;
359    if let Err(e) = AssertUnwindSafe(f).catch_unwind().await {
360        let msg = if let Some(s) = e.downcast_ref::<&str>() {
361            *s
362        } else if let Some(s) = e.downcast_ref::<String>() {
363            &**s
364        } else {
365            "unknown panic payload"
366        };
367        tracing::error!("task '{name}' panicked: {msg}");
368    }
369}