Skip to main content

rpi_cli/remote/
protocol.rs

1//! Shared wire protocol for rpi's headless RPC mode (`--mode rpc`, the server)
2//! and the remote client (`--connect`).
3//!
4//! Single source of truth: the server serializes these types and the client
5//! deserializes them, so the contract cannot drift between the two ends.
6//!
7//! Wire shape (one JSON object per line):
8//! - the server opens with `{"type":"ready"}`, then
9//! - streams [`RemoteEvent`]s (`{"type":"agent_start"}`, `{"type":"message_update",…}`, …), and
10//! - answers each [`RemoteCommand`] with a [`RemoteResponse`] (`{"type":"response",…}`).
11//!
12//! These are intentionally **not** the core `pi-agent`/`pi-ai` types: `AgentEvent`
13//! is documented as process-local (see `pi-agent/src/events.rs`) and deliberately
14//! has no serde impl. This module projects the process-local events into a
15//! stable, wire-safe shape, and translates back only the subset the client needs.
16//! That keeps the core crates untouched and the client free of local resources.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use rpi_agent::events::AgentEvent;
22use rpi_agent::types::AgentToolResult;
23
24/// A fine-grained lifecycle event streamed from the server to the client.
25///
26/// Mirrors the shape produced by the previous hand-rolled projection in
27/// `modes.rs`; kept field-for-field compatible so existing clients keep working.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum RemoteEvent {
31    AgentStart,
32    AgentEnd {
33        #[serde(rename = "messageCount")]
34        message_count: usize,
35        messages: Value,
36    },
37    RetryScheduled {
38        attempt: u32,
39        #[serde(rename = "maxRetries")]
40        max_retries: u32,
41        #[serde(rename = "delayMs")]
42        delay_ms: u64,
43        error: String,
44    },
45    TurnStart,
46    TurnEnd {
47        message: Value,
48        #[serde(rename = "toolResultCount")]
49        tool_result_count: usize,
50        #[serde(rename = "toolResults")]
51        tool_results: Value,
52    },
53    MessageStart {
54        #[serde(default)]
55        message: Value,
56    },
57    MessageEnd {
58        #[serde(default)]
59        message: Value,
60    },
61    MessageUpdate {
62        #[serde(default)]
63        message: Value,
64        #[serde(rename = "assistantMessageEvent", default)]
65        assistant_message_event: Value,
66        #[serde(rename = "eventType", default)]
67        event_type: String,
68        /// Convenience mirror of the delta's content index (text/thinking/tool-call
69        /// deltas only), so the client can apply deltas without re-parsing the
70        /// nested event.
71        #[serde(
72            rename = "contentIndex",
73            default,
74            skip_serializing_if = "Option::is_none"
75        )]
76        content_index: Option<u64>,
77        /// Convenience mirror of the appended text for text/thinking deltas.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        delta: Option<String>,
80    },
81    ToolExecutionStart {
82        #[serde(rename = "toolCallId")]
83        tool_call_id: String,
84        #[serde(rename = "toolName")]
85        tool_name: String,
86        #[serde(default)]
87        args: Value,
88    },
89    ToolExecutionUpdate {
90        #[serde(rename = "toolCallId")]
91        tool_call_id: String,
92        #[serde(rename = "toolName")]
93        tool_name: String,
94        #[serde(default)]
95        args: Value,
96        #[serde(rename = "partialResult")]
97        partial_result: Value,
98    },
99    ToolExecutionEnd {
100        #[serde(rename = "toolCallId")]
101        tool_call_id: String,
102        #[serde(rename = "toolName")]
103        tool_name: String,
104        #[serde(rename = "isError")]
105        is_error: bool,
106        result: Value,
107    },
108}
109
110impl RemoteEvent {
111    /// The wire `type` tag (kept in sync with the serde `rename_all`).
112    pub fn type_tag(&self) -> &'static str {
113        match self {
114            RemoteEvent::AgentStart => "agent_start",
115            RemoteEvent::AgentEnd { .. } => "agent_end",
116            RemoteEvent::RetryScheduled { .. } => "retry_scheduled",
117            RemoteEvent::TurnStart => "turn_start",
118            RemoteEvent::TurnEnd { .. } => "turn_end",
119            RemoteEvent::MessageStart { .. } => "message_start",
120            RemoteEvent::MessageUpdate { .. } => "message_update",
121            RemoteEvent::MessageEnd { .. } => "message_end",
122            RemoteEvent::ToolExecutionStart { .. } => "tool_execution_start",
123            RemoteEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
124            RemoteEvent::ToolExecutionEnd { .. } => "tool_execution_end",
125        }
126    }
127
128    /// True for the terminal `AgentEnd` event.
129    pub fn is_terminal(&self) -> bool {
130        matches!(self, RemoteEvent::AgentEnd { .. })
131    }
132
133    /// Project a process-local [`AgentEvent`] onto the wire shape.
134    pub fn from_agent_event(event: &AgentEvent) -> Self {
135        use rpi_ai::types::AssistantMessageEvent;
136        match event {
137            AgentEvent::AgentStart => RemoteEvent::AgentStart,
138            AgentEvent::AgentEnd { messages } => RemoteEvent::AgentEnd {
139                message_count: messages.len(),
140                messages: serde_json::to_value(messages).unwrap_or(Value::Null),
141            },
142            AgentEvent::RetryScheduled {
143                attempt,
144                max_retries,
145                delay_ms,
146                error,
147            } => RemoteEvent::RetryScheduled {
148                attempt: *attempt,
149                max_retries: *max_retries,
150                delay_ms: *delay_ms,
151                error: error.clone(),
152            },
153            AgentEvent::TurnStart => RemoteEvent::TurnStart,
154            AgentEvent::TurnEnd {
155                message,
156                tool_results,
157            } => RemoteEvent::TurnEnd {
158                message: serde_json::to_value(message).unwrap_or(Value::Null),
159                tool_result_count: tool_results.len(),
160                tool_results: serde_json::to_value(tool_results).unwrap_or(Value::Null),
161            },
162            AgentEvent::MessageStart { message } => RemoteEvent::MessageStart {
163                message: serde_json::to_value(message).unwrap_or(Value::Null),
164            },
165            AgentEvent::MessageEnd { message } => RemoteEvent::MessageEnd {
166                message: serde_json::to_value(message).unwrap_or(Value::Null),
167            },
168            AgentEvent::MessageUpdate {
169                message,
170                assistant_message_event,
171            } => {
172                let (content_index, delta) = match assistant_message_event {
173                    AssistantMessageEvent::TextDelta {
174                        content_index,
175                        delta,
176                        ..
177                    }
178                    | AssistantMessageEvent::ThinkingDelta {
179                        content_index,
180                        delta,
181                        ..
182                    }
183                    | AssistantMessageEvent::ToolCallDelta {
184                        content_index,
185                        delta,
186                        ..
187                    } => (Some(*content_index as u64), Some(delta.clone())),
188                    _ => (None, None),
189                };
190                RemoteEvent::MessageUpdate {
191                    // `assistant_json` keeps this identical to the
192                    // `AgentMessage` encoding without copying the message.
193                    message: rpi_agent::message::assistant_json(message),
194                    assistant_message_event: serde_json::to_value(assistant_message_event)
195                        .unwrap_or(Value::Null),
196                    event_type: assistant_message_event.type_tag().to_string(),
197                    content_index,
198                    delta,
199                }
200            }
201            AgentEvent::ToolExecutionStart {
202                tool_call_id,
203                tool_name,
204                args,
205            } => RemoteEvent::ToolExecutionStart {
206                tool_call_id: tool_call_id.clone(),
207                tool_name: tool_name.clone(),
208                args: args.clone(),
209            },
210            AgentEvent::ToolExecutionUpdate {
211                tool_call_id,
212                tool_name,
213                args,
214                partial_result,
215            } => RemoteEvent::ToolExecutionUpdate {
216                tool_call_id: tool_call_id.clone(),
217                tool_name: tool_name.clone(),
218                args: args.clone(),
219                partial_result: tool_result_json(partial_result),
220            },
221            AgentEvent::ToolExecutionEnd {
222                tool_call_id,
223                tool_name,
224                result,
225                is_error,
226            } => RemoteEvent::ToolExecutionEnd {
227                tool_call_id: tool_call_id.clone(),
228                tool_name: tool_name.clone(),
229                is_error: *is_error,
230                result: tool_result_json(result),
231            },
232        }
233    }
234}
235
236/// Serialize a tool result into the stable wire shape (content blocks, details,
237/// usage, and the batch hints).
238pub fn tool_result_json(result: &AgentToolResult) -> Value {
239    let content: Vec<Value> = result
240        .content
241        .iter()
242        .map(|item| match item {
243            rpi_agent::types::TextContentOrImage::Text(text) => serde_json::json!({
244                "type": "text",
245                "text": text.text,
246            }),
247            rpi_agent::types::TextContentOrImage::Image(image) => {
248                serde_json::to_value(image).unwrap_or(Value::Null)
249            }
250        })
251        .collect();
252    serde_json::json!({
253        "content": content,
254        "details": result.details,
255        "usage": result.usage.as_ref().and_then(|usage| serde_json::to_value(usage).ok()),
256        "addedToolNames": result.added_tool_names,
257        "terminate": result.terminate,
258    })
259}
260
261/// A command sent from the client to the server (`{"id":…,"type":…,…}`).
262///
263/// The `id` is attached by the client when sending (see
264/// [`with_id`](RemoteCommand::with_id)) so responses can be correlated.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(tag = "type", rename_all = "snake_case")]
267pub enum RemoteCommand {
268    /// Run a prompt on the remote `main` lane; events stream until `agent_end`.
269    Prompt { content: String },
270    /// Abort the in-flight run.
271    Abort,
272    /// Fetch the current session state (model, thinking, tools, ids).
273    GetState,
274    /// Switch the active model by id.
275    SetModel { model: String },
276    /// Set the thinking level (`off`…`max`).
277    SetThinkingLevel { level: String },
278    /// Replace the active tool set.
279    SetActiveTools { tools: Vec<String> },
280    /// Liveness probe.
281    Ping,
282    /// Ask the server to end the session loop.
283    Stop,
284}
285
286impl RemoteCommand {
287    /// Serialize the command with an `id` field for request/response correlation.
288    pub fn with_id(&self, id: impl Into<Value>) -> Value {
289        let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
290        if let Some(object) = value.as_object_mut() {
291            object.insert("id".to_string(), id.into());
292        }
293        value
294    }
295}
296
297/// Response status for a [`RemoteResponse`].
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "lowercase")]
300pub enum ResponseStatus {
301    Ok,
302    Error,
303}
304
305/// The server's answer to a single [`RemoteCommand`].
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct RemoteResponse {
308    #[serde(default)]
309    pub id: Value,
310    pub status: ResponseStatus,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub result: Option<Value>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub error: Option<String>,
315}
316
317impl RemoteResponse {
318    /// A successful response carrying an optional result payload.
319    pub fn ok(id: Value, result: Value) -> Self {
320        Self {
321            id,
322            status: ResponseStatus::Ok,
323            result: Some(result),
324            error: None,
325        }
326    }
327
328    /// A failed response carrying an error message.
329    pub fn error(id: Value, error: impl Into<String>) -> Self {
330        Self {
331            id,
332            status: ResponseStatus::Error,
333            result: None,
334            error: Some(error.into()),
335        }
336    }
337
338    /// True when the request succeeded.
339    pub fn is_ok(&self) -> bool {
340        matches!(self.status, ResponseStatus::Ok)
341    }
342
343    /// Serialize to the wire shape, adding the `"type":"response"` tag.
344    pub fn to_json(&self) -> Value {
345        let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
346        if let Some(object) = value.as_object_mut() {
347            object.insert("type".to_string(), Value::String("response".to_string()));
348        }
349        value
350    }
351}
352
353/// The `get_state` result payload, as a typed view over the response.
354#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
355pub struct SessionState {
356    #[serde(default)]
357    pub model: Option<String>,
358    #[serde(rename = "thinkingLevel", default)]
359    pub thinking_level: Option<Value>,
360    #[serde(rename = "activeTools", default)]
361    pub active_tools: Vec<String>,
362    #[serde(rename = "leafId", default)]
363    pub leaf_id: Option<String>,
364    #[serde(rename = "sessionId", default)]
365    pub session_id: Option<String>,
366}
367
368/// The `prompt` result payload (`{outcome, finalText}`).
369#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
370pub struct PromptOutcome {
371    #[serde(default)]
372    pub outcome: Option<String>,
373    #[serde(rename = "finalText", default)]
374    pub final_text: Option<String>,
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use rpi_agent::types::AgentToolResult;
381
382    #[test]
383    fn event_projection_matches_wire_shape() {
384        let end = serde_json::to_value(RemoteEvent::from_agent_event(&AgentEvent::AgentEnd {
385            messages: vec![],
386        }))
387        .unwrap();
388        assert_eq!(end["type"], "agent_end");
389        assert_eq!(end["messages"], serde_json::json!([]));
390        assert_eq!(end["messageCount"], 0);
391
392        let tool = serde_json::to_value(RemoteEvent::from_agent_event(
393            &AgentEvent::ToolExecutionEnd {
394                tool_call_id: "call-1".into(),
395                tool_name: "read".into(),
396                result: AgentToolResult::text("hello"),
397                is_error: false,
398            },
399        ))
400        .unwrap();
401        assert_eq!(tool["type"], "tool_execution_end");
402        assert_eq!(tool["result"]["content"][0]["text"], "hello");
403        assert_eq!(tool["result"]["terminate"], false);
404
405        let retry =
406            serde_json::to_value(RemoteEvent::from_agent_event(&AgentEvent::RetryScheduled {
407                attempt: 3,
408                max_retries: 10,
409                delay_ms: 8_000,
410                error: "503 service unavailable".into(),
411            }))
412            .unwrap();
413        assert_eq!(retry["type"], "retry_scheduled");
414        assert_eq!(retry["attempt"], 3);
415        assert_eq!(retry["maxRetries"], 10);
416        assert_eq!(retry["delayMs"], 8_000);
417    }
418
419    #[test]
420    fn events_round_trip_over_the_wire() {
421        for event in [
422            RemoteEvent::AgentStart,
423            RemoteEvent::MessageUpdate {
424                message: serde_json::json!({"role": "assistant"}),
425                assistant_message_event: serde_json::json!({"type": "text_delta"}),
426                event_type: "text_delta".into(),
427                content_index: Some(0),
428                delta: Some("hi".into()),
429            },
430            RemoteEvent::ToolExecutionEnd {
431                tool_call_id: "c1".into(),
432                tool_name: "read".into(),
433                is_error: true,
434                result: tool_result_json(&AgentToolResult::text("nope")),
435            },
436        ] {
437            let json = serde_json::to_value(&event).unwrap();
438            let back: RemoteEvent = serde_json::from_value(json).unwrap();
439            assert_eq!(event, back);
440        }
441    }
442
443    #[test]
444    fn command_and_response_round_trip() {
445        let cmd = RemoteCommand::Prompt {
446            content: "hi".into(),
447        };
448        let value = cmd.with_id("r1");
449        assert_eq!(value["type"], "prompt");
450        assert_eq!(value["content"], "hi");
451        assert_eq!(value["id"], "r1");
452
453        let response =
454            RemoteResponse::ok(serde_json::json!("r1"), serde_json::json!({"pong": true}));
455        let json = response.to_json();
456        assert_eq!(json["type"], "response");
457        assert_eq!(json["id"], "r1");
458        assert_eq!(json["status"], "ok");
459        let parsed: RemoteResponse = serde_json::from_value(json).unwrap();
460        assert!(parsed.is_ok());
461
462        let err = RemoteResponse::error(Value::Null, "boom").to_json();
463        assert_eq!(err["status"], "error");
464        assert_eq!(err["error"], "boom");
465    }
466}