Skip to main content

pi_codes/
rpc.rs

1//! The `pi --mode rpc` command protocol: JSON objects on stdin (one per
2//! line, LF-delimited), `{"type":"response",...}` acknowledgements and
3//! streamed events on stdout.
4//!
5//! Framing contract (from `docs/rpc.md`): split records on `\n` ONLY,
6//! tolerate a trailing `\r` — never use a splitter that also breaks on
7//! U+2028/U+2029, which are legal inside JSON strings.
8
9use crate::io::{Model, PiMessage};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// An image attached to a `prompt`/`steer`/`follow_up` command.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ImageContent {
16    /// Always `"image"`.
17    #[serde(rename = "type")]
18    pub content_type: String,
19    /// Base64-encoded bytes.
20    pub data: String,
21    #[serde(rename = "mimeType")]
22    pub mime_type: String,
23}
24
25/// How a `prompt` sent mid-stream should queue.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum StreamingBehavior {
28    /// Deliver after the current assistant turn's tool calls, before the
29    /// next LLM call.
30    #[serde(rename = "steer")]
31    Steer,
32    /// Deliver only once the agent stops.
33    #[serde(rename = "followUp")]
34    FollowUp,
35}
36
37/// A client → agent command. Serializes with the wire's `type` tag; the
38/// optional `id` correlates the matching [`RpcResponse`].
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum RpcCommand {
42    Prompt {
43        #[serde(skip_serializing_if = "Option::is_none")]
44        id: Option<String>,
45        message: String,
46        #[serde(skip_serializing_if = "Option::is_none")]
47        images: Option<Vec<ImageContent>>,
48        #[serde(rename = "streamingBehavior", skip_serializing_if = "Option::is_none")]
49        streaming_behavior: Option<StreamingBehavior>,
50    },
51    Steer {
52        #[serde(skip_serializing_if = "Option::is_none")]
53        id: Option<String>,
54        message: String,
55        #[serde(skip_serializing_if = "Option::is_none")]
56        images: Option<Vec<ImageContent>>,
57    },
58    FollowUp {
59        #[serde(skip_serializing_if = "Option::is_none")]
60        id: Option<String>,
61        message: String,
62        #[serde(skip_serializing_if = "Option::is_none")]
63        images: Option<Vec<ImageContent>>,
64    },
65    Abort {
66        #[serde(skip_serializing_if = "Option::is_none")]
67        id: Option<String>,
68    },
69    ClearQueue {
70        #[serde(skip_serializing_if = "Option::is_none")]
71        id: Option<String>,
72    },
73    NewSession {
74        #[serde(skip_serializing_if = "Option::is_none")]
75        id: Option<String>,
76    },
77    GetState {
78        #[serde(skip_serializing_if = "Option::is_none")]
79        id: Option<String>,
80    },
81    GetMessages {
82        #[serde(skip_serializing_if = "Option::is_none")]
83        id: Option<String>,
84    },
85    SetModel {
86        #[serde(skip_serializing_if = "Option::is_none")]
87        id: Option<String>,
88        provider: String,
89        #[serde(rename = "modelId")]
90        model_id: String,
91    },
92    CycleModel {
93        #[serde(skip_serializing_if = "Option::is_none")]
94        id: Option<String>,
95    },
96    GetAvailableModels {
97        #[serde(skip_serializing_if = "Option::is_none")]
98        id: Option<String>,
99    },
100    SetThinkingLevel {
101        #[serde(skip_serializing_if = "Option::is_none")]
102        id: Option<String>,
103        level: String,
104    },
105    CycleThinkingLevel {
106        #[serde(skip_serializing_if = "Option::is_none")]
107        id: Option<String>,
108    },
109    GetAvailableThinkingLevels {
110        #[serde(skip_serializing_if = "Option::is_none")]
111        id: Option<String>,
112    },
113    SetSteeringMode {
114        #[serde(skip_serializing_if = "Option::is_none")]
115        id: Option<String>,
116        mode: String,
117    },
118    SetFollowUpMode {
119        #[serde(skip_serializing_if = "Option::is_none")]
120        id: Option<String>,
121        mode: String,
122    },
123    Compact {
124        #[serde(skip_serializing_if = "Option::is_none")]
125        id: Option<String>,
126        #[serde(skip_serializing_if = "Option::is_none")]
127        instructions: Option<String>,
128    },
129    SetAutoCompaction {
130        #[serde(skip_serializing_if = "Option::is_none")]
131        id: Option<String>,
132        enabled: bool,
133    },
134    SetAutoRetry {
135        #[serde(skip_serializing_if = "Option::is_none")]
136        id: Option<String>,
137        enabled: bool,
138    },
139    AbortRetry {
140        #[serde(skip_serializing_if = "Option::is_none")]
141        id: Option<String>,
142    },
143    /// Run a shell command outside the model; output joins context on the
144    /// NEXT prompt. Streams `bash_execution_update` events carrying this
145    /// command's `id`.
146    Bash {
147        #[serde(skip_serializing_if = "Option::is_none")]
148        id: Option<String>,
149        command: String,
150    },
151    AbortBash {
152        #[serde(skip_serializing_if = "Option::is_none")]
153        id: Option<String>,
154    },
155    GetSessionStats {
156        #[serde(skip_serializing_if = "Option::is_none")]
157        id: Option<String>,
158    },
159    ExportHtml {
160        #[serde(skip_serializing_if = "Option::is_none")]
161        id: Option<String>,
162        #[serde(skip_serializing_if = "Option::is_none")]
163        path: Option<String>,
164    },
165    SetSessionName {
166        #[serde(skip_serializing_if = "Option::is_none")]
167        id: Option<String>,
168        name: String,
169    },
170    /// Anything this crate doesn't model yet — serialized as its raw
171    /// object so new commands work without a crate release.
172    #[serde(untagged)]
173    Raw(Value),
174}
175
176impl RpcCommand {
177    /// Serialize to one LF-terminated wire line.
178    pub fn to_line(&self) -> Result<String, serde_json::Error> {
179        Ok(format!("{}\n", serde_json::to_string(self)?))
180    }
181}
182
183/// The `{"type":"response"}` acknowledgement for one command.
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub struct RpcResponse {
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub id: Option<String>,
188    /// Which command this answers (`"parse"` for unparseable input).
189    #[serde(default)]
190    pub command: String,
191    #[serde(default)]
192    pub success: bool,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub data: Option<Value>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub error: Option<String>,
197}
198
199impl RpcResponse {
200    /// What pi did with the input a successful `prompt`, `steer` or
201    /// `follow_up` submitted (`data.disposition`, pi ≥ 0.99.0). `None` on
202    /// other commands, on failures, and from older CLIs.
203    pub fn disposition(&self) -> Option<InputDisposition> {
204        self.data
205            .as_ref()?
206            .get("disposition")?
207            .as_str()
208            .map(InputDisposition::from)
209    }
210}
211
212/// What pi did with a submitted `prompt`/`steer`/`follow_up` input
213/// ([`RpcResponse::disposition`], pi ≥ 0.99.0). It describes the submitted
214/// input only — not independent work an extension started, and not a
215/// guarantee of completion or that a queued message stays queued.
216#[derive(Debug, Clone, PartialEq, Eq, Hash)]
217pub enum InputDisposition {
218    /// An extension command or input handler consumed the input. No run
219    /// started for it, so don't wait for `agent_settled`.
220    Handled,
221    /// Pi queued the input during a run (including after an input handler
222    /// transformed it).
223    Queued,
224    /// Pi accepted the prompt to start a run. `prompt` only.
225    Started,
226    /// A disposition not yet known to this version of the crate.
227    Unknown(String),
228}
229
230impl InputDisposition {
231    pub fn as_str(&self) -> &str {
232        match self {
233            Self::Handled => "handled",
234            Self::Queued => "queued",
235            Self::Started => "started",
236            Self::Unknown(s) => s.as_str(),
237        }
238    }
239}
240
241impl std::fmt::Display for InputDisposition {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.write_str(self.as_str())
244    }
245}
246
247impl From<&str> for InputDisposition {
248    fn from(s: &str) -> Self {
249        match s {
250            "handled" => Self::Handled,
251            "queued" => Self::Queued,
252            "started" => Self::Started,
253            other => Self::Unknown(other.to_string()),
254        }
255    }
256}
257
258impl Serialize for InputDisposition {
259    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
260        serializer.serialize_str(self.as_str())
261    }
262}
263
264impl<'de> Deserialize<'de> for InputDisposition {
265    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
266        let s = String::deserialize(deserializer)?;
267        Ok(Self::from(s.as_str()))
268    }
269}
270
271/// Typed view of `get_state` response data.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
273pub struct AgentState {
274    /// `None` when no provider resolved; may also be a placeholder with
275    /// `id: "unknown"` — see [`Model`].
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub model: Option<Model>,
278    #[serde(rename = "thinkingLevel", default)]
279    pub thinking_level: String,
280    #[serde(rename = "isStreaming", default)]
281    pub is_streaming: bool,
282    #[serde(rename = "isCompacting", default)]
283    pub is_compacting: bool,
284    #[serde(rename = "steeringMode", default)]
285    pub steering_mode: String,
286    #[serde(rename = "followUpMode", default)]
287    pub follow_up_mode: String,
288    #[serde(
289        rename = "sessionFile",
290        default,
291        skip_serializing_if = "Option::is_none"
292    )]
293    pub session_file: Option<String>,
294    #[serde(rename = "sessionId", default)]
295    pub session_id: String,
296    #[serde(
297        rename = "sessionName",
298        default,
299        skip_serializing_if = "Option::is_none"
300    )]
301    pub session_name: Option<String>,
302    #[serde(rename = "autoCompactionEnabled", default)]
303    pub auto_compaction_enabled: bool,
304    #[serde(rename = "messageCount", default)]
305    pub message_count: i64,
306    #[serde(rename = "pendingMessageCount", default)]
307    pub pending_message_count: i64,
308    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
309    pub extra: serde_json::Map<String, Value>,
310}
311
312/// Typed view of `bash` response data.
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
314pub struct BashResult {
315    #[serde(default)]
316    pub output: String,
317    #[serde(rename = "exitCode", default, skip_serializing_if = "Option::is_none")]
318    pub exit_code: Option<i64>,
319    #[serde(default)]
320    pub cancelled: bool,
321    #[serde(default)]
322    pub truncated: bool,
323    #[serde(
324        rename = "fullOutputPath",
325        default,
326        skip_serializing_if = "Option::is_none"
327    )]
328    pub full_output_path: Option<String>,
329}
330
331/// Typed view of `get_messages` response data.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
333pub struct Messages {
334    #[serde(default)]
335    pub messages: Vec<PiMessage>,
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn commands_serialize_with_snake_case_type_tags() {
344        let c = RpcCommand::GetState {
345            id: Some("r1".into()),
346        };
347        let v = serde_json::to_value(&c).unwrap();
348        assert_eq!(v["type"], "get_state");
349        assert_eq!(v["id"], "r1");
350
351        let c = RpcCommand::Prompt {
352            id: None,
353            message: "hi".into(),
354            images: None,
355            streaming_behavior: Some(StreamingBehavior::Steer),
356        };
357        let v = serde_json::to_value(&c).unwrap();
358        assert_eq!(v["type"], "prompt");
359        assert_eq!(v["streamingBehavior"], "steer");
360        assert!(v.get("images").is_none());
361    }
362
363    #[test]
364    fn response_envelope_parses_success_and_error() {
365        let r: RpcResponse = serde_json::from_str(
366            r#"{"id":"r1","type":"response","command":"get_state","success":true,"data":{}}"#,
367        )
368        .unwrap();
369        assert!(r.success);
370        assert_eq!(r.command, "get_state");
371
372        let r: RpcResponse = serde_json::from_str(
373            r#"{"type":"response","command":"set_model","success":false,"error":"Model not found: invalid/model"}"#,
374        )
375        .unwrap();
376        assert!(!r.success);
377        assert!(r.error.unwrap().contains("Model not found"));
378    }
379
380    #[test]
381    fn input_disposition_reads_from_response_data() {
382        for (command, wire, expected) in [
383            ("prompt", "started", InputDisposition::Started),
384            ("prompt", "handled", InputDisposition::Handled),
385            ("steer", "queued", InputDisposition::Queued),
386            (
387                "follow_up",
388                "deferred",
389                InputDisposition::Unknown("deferred".into()),
390            ),
391        ] {
392            let r: RpcResponse = serde_json::from_value(serde_json::json!({
393                "id": "r1", "type": "response", "command": command,
394                "success": true, "data": {"disposition": wire}
395            }))
396            .unwrap();
397            let got = r.disposition().expect("disposition present");
398            assert_eq!(got, expected);
399            assert_eq!(got.to_string(), wire);
400            assert_eq!(serde_json::to_value(&got).unwrap(), wire);
401        }
402
403        // pi < 0.99.0 acknowledged these commands with no data at all.
404        let legacy: RpcResponse = serde_json::from_str(
405            r#"{"id":"r1","type":"response","command":"prompt","success":true}"#,
406        )
407        .unwrap();
408        assert_eq!(legacy.disposition(), None);
409    }
410
411    #[test]
412    fn agent_state_parses_credential_free_placeholder() {
413        let s: AgentState = serde_json::from_value(serde_json::json!({
414            "model": {"id":"unknown","name":"unknown","api":"unknown","provider":"unknown",
415                      "baseUrl":"","reasoning":false,"input":[],
416                      "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},
417                      "contextWindow":0,"maxTokens":0},
418            "thinkingLevel":"off","isStreaming":false,"isCompacting":false,
419            "steeringMode":"one-at-a-time","followUpMode":"one-at-a-time",
420            "sessionId":"01a05ec0-4e0e-7dd4-a225-2e78cccd0059",
421            "autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0
422        }))
423        .unwrap();
424        assert_eq!(s.model.unwrap().context_window, 0);
425        assert_eq!(s.message_count, 0);
426    }
427
428    #[test]
429    fn raw_command_passes_through_unmodeled_types() {
430        let c = RpcCommand::Raw(serde_json::json!({"type": "get_tree", "id": "r9"}));
431        let line = c.to_line().unwrap();
432        assert!(line.ends_with('\n'));
433        assert!(line.contains("\"get_tree\""));
434    }
435}