Skip to main content

pi_codes/
io.rs

1//! Typed models of pi's wire vocabulary: the messages and events shared
2//! by `--mode json` (one-shot JSONL event stream) and `--mode rpc`
3//! (command protocol; see [`crate::rpc`]).
4//!
5//! Shapes mirror the documented TypeScript types
6//! (`packages/ai/src/types.ts`, `packages/agent/src/types.ts`, and
7//! `packages/coding-agent/docs/{json,rpc}.md` in earendil-works/pi).
8//! Unknown event types are preserved verbatim in [`PiEvent::Unknown`]
9//! rather than erroring, so a newer CLI degrades soft.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Per-request/response token accounting on assistant and tool messages.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
16pub struct Usage {
17    #[serde(default)]
18    pub input: f64,
19    #[serde(default)]
20    pub output: f64,
21    #[serde(rename = "cacheRead", default)]
22    pub cache_read: f64,
23    #[serde(rename = "cacheWrite", default)]
24    pub cache_write: f64,
25    #[serde(
26        rename = "totalTokens",
27        default,
28        skip_serializing_if = "Option::is_none"
29    )]
30    pub total_tokens: Option<f64>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub cost: Option<UsageCost>,
33    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
34    pub extra: serde_json::Map<String, Value>,
35}
36
37/// Dollar cost breakdown inside [`Usage`].
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
39pub struct UsageCost {
40    #[serde(default)]
41    pub input: f64,
42    #[serde(default)]
43    pub output: f64,
44    #[serde(rename = "cacheRead", default)]
45    pub cache_read: f64,
46    #[serde(rename = "cacheWrite", default)]
47    pub cache_write: f64,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub total: Option<f64>,
50}
51
52/// A model catalog entry (`get_state`/`set_model`/`get_available_models`).
53/// With no provider configured, `get_state` returns a placeholder with
54/// `id: "unknown"` and zeroed limits — treat `contextWindow == 0` as
55/// "no real model resolved".
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
57pub struct Model {
58    #[serde(default)]
59    pub id: String,
60    #[serde(default)]
61    pub name: String,
62    #[serde(default)]
63    pub api: String,
64    #[serde(default)]
65    pub provider: String,
66    #[serde(rename = "baseUrl", default)]
67    pub base_url: String,
68    #[serde(default)]
69    pub reasoning: bool,
70    #[serde(default)]
71    pub input: Vec<String>,
72    #[serde(rename = "contextWindow", default)]
73    pub context_window: i64,
74    #[serde(rename = "maxTokens", default)]
75    pub max_tokens: i64,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub cost: Option<UsageCost>,
78    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
79    pub extra: serde_json::Map<String, Value>,
80}
81
82/// One block of assistant content: text, thinking, or a tool call.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(tag = "type", rename_all = "camelCase")]
85pub enum ContentBlock {
86    Text {
87        text: String,
88    },
89    Thinking {
90        thinking: String,
91    },
92    ToolCall {
93        id: String,
94        name: String,
95        arguments: Value,
96        /// Set when the model's full arguments text was not a valid JSON
97        /// object (pi 1.3.0+). `arguments` is then `{}`, the message
98        /// describes the problem and quotes the text the model sent, and
99        /// the call fails tool argument validation instead of running.
100        #[serde(
101            rename = "argumentsError",
102            default,
103            skip_serializing_if = "Option::is_none"
104        )]
105        arguments_error: Option<String>,
106    },
107    Image {
108        data: String,
109        #[serde(rename = "mimeType")]
110        mime_type: String,
111    },
112}
113
114/// A conversation message, discriminated on `role`. This is pi's
115/// `AgentMessage` union plus the RPC-only `bashExecution` role.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(tag = "role", rename_all = "camelCase")]
118pub enum PiMessage {
119    /// The prompt and tool loadout (pi ≥ 0.86.0). The first request of a
120    /// session emits one carrying every prompt section and tool
121    /// declaration; later changes emit further system messages that
122    /// patch `sections` by name (`null` removes one) and list
123    /// `toolsAdded` / `toolsRemoved`. Replaying them in order yields the
124    /// current prompt and tools. Streams as `message_start` /
125    /// `message_end` ahead of the user message and leads `agent_end`'s
126    /// `messages`.
127    System {
128        /// A plain string or an array of text blocks; empty when the
129        /// prompt lives entirely in `sections`.
130        content: Value,
131        /// Named prompt sections (`preamble`, `tools`, `rules`, `cwd`,
132        /// `docs`, `project_context`, ...) — string bodies, or `null`
133        /// to remove a section declared earlier.
134        #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
135        sections: serde_json::Map<String, Value>,
136        /// Full tool declarations (`name`, `description`, `parameters`)
137        /// now available to the model.
138        #[serde(rename = "toolsAdded", default, skip_serializing_if = "Vec::is_empty")]
139        tools_added: Vec<Value>,
140        /// `{ "name": ... }` references for tools withdrawn.
141        #[serde(
142            rename = "toolsRemoved",
143            default,
144            skip_serializing_if = "Vec::is_empty"
145        )]
146        tools_removed: Vec<Value>,
147        #[serde(default)]
148        timestamp: f64,
149        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
150        extra: serde_json::Map<String, Value>,
151    },
152    User {
153        /// A plain string or an array of content blocks — both are legal.
154        content: Value,
155        #[serde(default)]
156        timestamp: f64,
157        #[serde(default, skip_serializing_if = "Vec::is_empty")]
158        attachments: Vec<Value>,
159        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
160        extra: serde_json::Map<String, Value>,
161    },
162    Assistant {
163        content: Vec<ContentBlock>,
164        #[serde(default)]
165        api: String,
166        #[serde(default)]
167        provider: String,
168        #[serde(default)]
169        model: String,
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        usage: Option<Usage>,
172        /// `stop`, `length`, `toolUse`, `error`, `aborted`, or
173        /// (harness-v2) `deferred`. Left open for forward compat.
174        #[serde(rename = "stopReason", default)]
175        stop_reason: String,
176        /// The pi thinking level the agent loop requested for this
177        /// response (pi ≥ 0.99.0). Absent outside the agent loop and on
178        /// messages from older sessions.
179        #[serde(
180            rename = "thinkingLevel",
181            default,
182            skip_serializing_if = "Option::is_none"
183        )]
184        thinking_level: Option<String>,
185        #[serde(default)]
186        timestamp: f64,
187        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
188        extra: serde_json::Map<String, Value>,
189    },
190    ToolResult {
191        #[serde(rename = "toolCallId", default)]
192        tool_call_id: String,
193        #[serde(rename = "toolName", default)]
194        tool_name: String,
195        content: Value,
196        /// Nested LLM work performed by the tool; contributes to session
197        /// totals when present.
198        #[serde(default, skip_serializing_if = "Option::is_none")]
199        usage: Option<Usage>,
200        #[serde(rename = "isError", default)]
201        is_error: bool,
202        #[serde(default)]
203        timestamp: f64,
204        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
205        extra: serde_json::Map<String, Value>,
206    },
207    /// Created by the RPC `bash` command, never by LLM tool calls. On the
208    /// next prompt it is rewritten into a `user` message for the model.
209    BashExecution {
210        #[serde(default)]
211        command: String,
212        #[serde(default)]
213        output: String,
214        #[serde(rename = "exitCode", default, skip_serializing_if = "Option::is_none")]
215        exit_code: Option<i64>,
216        #[serde(default)]
217        cancelled: bool,
218        #[serde(default)]
219        truncated: bool,
220        #[serde(
221            rename = "fullOutputPath",
222            default,
223            skip_serializing_if = "Option::is_none"
224        )]
225        full_output_path: Option<String>,
226        #[serde(default)]
227        timestamp: f64,
228        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
229        extra: serde_json::Map<String, Value>,
230    },
231}
232
233/// One event from the `--mode json` / `--mode rpc` stdout stream,
234/// discriminated on `type`. Known lifecycle events are typed with their
235/// primary fields lifted; every variant keeps its full payload reachable
236/// (unmodeled fields land in `extra`), and unknown `type`s fall back to
237/// [`PiEvent::Unknown`] instead of erroring.
238#[derive(Debug, Clone, PartialEq)]
239pub enum PiEvent {
240    /// `agent_start`
241    AgentStart,
242    /// `agent_end` — carries the turn's messages.
243    AgentEnd { messages: Vec<PiMessage> },
244    /// `turn_start`
245    TurnStart,
246    /// `turn_end`
247    TurnEnd {
248        message: Box<PiMessage>,
249        tool_results: Vec<PiMessage>,
250    },
251    /// `message_start`
252    MessageStart { message: Box<PiMessage> },
253    /// `message_update` — streaming delta; in `--mode json` the
254    /// cumulative message snapshot is omitted and only the incremental
255    /// `assistantMessageEvent` (kept raw here) arrives.
256    MessageUpdate {
257        message: Option<Box<PiMessage>>,
258        usage: Option<Usage>,
259        assistant_message_event: Value,
260    },
261    /// `message_end`
262    MessageEnd { message: Box<PiMessage> },
263    /// `tool_execution_start`
264    ///
265    /// On all three tool events, `parent_tool_call_id` is set when another
266    /// tool made the call (pi ≥ 0.99.0: `ctx.executeTool()`, e.g. from a
267    /// `codemode` script). Such nested calls get a `<parent id>/<n>`
268    /// `tool_call_id` and never appear as tool calls or tool results in
269    /// the transcript.
270    ToolExecutionStart {
271        tool_call_id: String,
272        tool_name: String,
273        args: Value,
274        parent_tool_call_id: Option<String>,
275    },
276    /// `tool_execution_update`
277    ToolExecutionUpdate {
278        tool_call_id: String,
279        tool_name: String,
280        args: Value,
281        partial_result: Value,
282        parent_tool_call_id: Option<String>,
283    },
284    /// `tool_execution_end`
285    ToolExecutionEnd {
286        tool_call_id: String,
287        tool_name: String,
288        result: Value,
289        is_error: bool,
290        parent_tool_call_id: Option<String>,
291    },
292    /// Any other `type` (session events like `queue_update`,
293    /// `compaction_start`/`compaction_end`, `bash_execution_update`,
294    /// extension UI requests, and whatever future CLIs add). The full
295    /// object is preserved.
296    Unknown { event_type: String, payload: Value },
297}
298
299impl PiEvent {
300    /// The wire `type` string.
301    pub fn event_type(&self) -> &str {
302        match self {
303            PiEvent::AgentStart => "agent_start",
304            PiEvent::AgentEnd { .. } => "agent_end",
305            PiEvent::TurnStart => "turn_start",
306            PiEvent::TurnEnd { .. } => "turn_end",
307            PiEvent::MessageStart { .. } => "message_start",
308            PiEvent::MessageUpdate { .. } => "message_update",
309            PiEvent::MessageEnd { .. } => "message_end",
310            PiEvent::ToolExecutionStart { .. } => "tool_execution_start",
311            PiEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
312            PiEvent::ToolExecutionEnd { .. } => "tool_execution_end",
313            PiEvent::Unknown { event_type, .. } => event_type,
314        }
315    }
316
317    /// Parse one stdout JSON object into a typed event.
318    pub fn from_value(v: Value) -> Result<Self, serde_json::Error> {
319        let t = v
320            .get("type")
321            .and_then(Value::as_str)
322            .unwrap_or_default()
323            .to_string();
324        Ok(match t.as_str() {
325            "agent_start" => PiEvent::AgentStart,
326            "agent_end" => PiEvent::AgentEnd {
327                messages: serde_json::from_value(
328                    v.get("messages").cloned().unwrap_or(Value::Array(vec![])),
329                )?,
330            },
331            "turn_start" => PiEvent::TurnStart,
332            "turn_end" => PiEvent::TurnEnd {
333                message: serde_json::from_value(v.get("message").cloned().unwrap_or(Value::Null))?,
334                tool_results: serde_json::from_value(
335                    v.get("toolResults")
336                        .cloned()
337                        .unwrap_or(Value::Array(vec![])),
338                )?,
339            },
340            "message_start" => PiEvent::MessageStart {
341                message: serde_json::from_value(v.get("message").cloned().unwrap_or(Value::Null))?,
342            },
343            "message_update" => PiEvent::MessageUpdate {
344                message: match v.get("message") {
345                    Some(m) => Some(serde_json::from_value(m.clone())?),
346                    None => None,
347                },
348                usage: match v.get("usage") {
349                    Some(u) => Some(serde_json::from_value(u.clone())?),
350                    None => None,
351                },
352                assistant_message_event: v
353                    .get("assistantMessageEvent")
354                    .cloned()
355                    .unwrap_or(Value::Null),
356            },
357            "message_end" => PiEvent::MessageEnd {
358                message: serde_json::from_value(v.get("message").cloned().unwrap_or(Value::Null))?,
359            },
360            "tool_execution_start" => PiEvent::ToolExecutionStart {
361                tool_call_id: str_field(&v, "toolCallId"),
362                tool_name: str_field(&v, "toolName"),
363                args: v.get("args").cloned().unwrap_or(Value::Null),
364                parent_tool_call_id: opt_str_field(&v, "parentToolCallId"),
365            },
366            "tool_execution_update" => PiEvent::ToolExecutionUpdate {
367                tool_call_id: str_field(&v, "toolCallId"),
368                tool_name: str_field(&v, "toolName"),
369                args: v.get("args").cloned().unwrap_or(Value::Null),
370                partial_result: v.get("partialResult").cloned().unwrap_or(Value::Null),
371                parent_tool_call_id: opt_str_field(&v, "parentToolCallId"),
372            },
373            "tool_execution_end" => PiEvent::ToolExecutionEnd {
374                tool_call_id: str_field(&v, "toolCallId"),
375                tool_name: str_field(&v, "toolName"),
376                result: v.get("result").cloned().unwrap_or(Value::Null),
377                is_error: v
378                    .get("isError")
379                    .and_then(Value::as_bool)
380                    .unwrap_or_default(),
381                parent_tool_call_id: opt_str_field(&v, "parentToolCallId"),
382            },
383            _ => PiEvent::Unknown {
384                event_type: t,
385                payload: v,
386            },
387        })
388    }
389}
390
391fn str_field(v: &Value, k: &str) -> String {
392    v.get(k)
393        .and_then(Value::as_str)
394        .unwrap_or_default()
395        .to_string()
396}
397
398fn opt_str_field(v: &Value, k: &str) -> Option<String> {
399    v.get(k).and_then(Value::as_str).map(str::to_string)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn assistant_message_from_docs_parses() {
408        let m: PiMessage = serde_json::from_value(serde_json::json!({
409            "role": "assistant",
410            "content": [
411                {"type": "text", "text": "Hello! How can I help?"},
412                {"type": "thinking", "thinking": "User is greeting me..."},
413                {"type": "toolCall", "id": "call_123", "name": "bash", "arguments": {"command": "ls"}}
414            ],
415            "api": "anthropic-messages",
416            "provider": "anthropic",
417            "model": "claude-sonnet-4-20250514",
418            "usage": {
419                "input": 100, "output": 50, "cacheRead": 0, "cacheWrite": 0,
420                "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
421            },
422            "stopReason": "stop",
423            "timestamp": 1733234567890i64
424        }))
425        .unwrap();
426        let PiMessage::Assistant {
427            content,
428            stop_reason,
429            ..
430        } = m
431        else {
432            panic!("wrong role")
433        };
434        assert_eq!(content.len(), 3);
435        assert_eq!(stop_reason, "stop");
436        assert!(matches!(
437            content[2],
438            ContentBlock::ToolCall {
439                arguments_error: None,
440                ..
441            }
442        ));
443    }
444
445    #[test]
446    fn tool_call_arguments_error_round_trips() {
447        let raw = serde_json::json!({
448            "type": "toolCall",
449            "id": "call_7",
450            "name": "read",
451            "arguments": {},
452            "argumentsError": "Tool arguments are not valid JSON: {\"path\": \"a"
453        });
454        let block: ContentBlock = serde_json::from_value(raw.clone()).unwrap();
455        let ContentBlock::ToolCall {
456            ref arguments_error,
457            ..
458        } = block
459        else {
460            panic!("wrong block")
461        };
462        assert!(arguments_error
463            .as_deref()
464            .is_some_and(|e| e.contains("not valid JSON")));
465        assert_eq!(serde_json::to_value(&block).unwrap(), raw);
466    }
467
468    #[test]
469    fn system_message_from_docs_parses() {
470        let m: PiMessage = serde_json::from_value(serde_json::json!({
471            "role": "system",
472            "content": "",
473            "sections": {
474                "preamble": "You are an expert coding assistant...",
475                "tools": "<tools>\n- read: ...\n</tools>",
476                "cwd": "/project"
477            },
478            "toolsAdded": [{"name": "read", "description": "...", "parameters": {}}],
479            "timestamp": 1733234400000i64
480        }))
481        .unwrap();
482        let PiMessage::System {
483            sections,
484            tools_added,
485            tools_removed,
486            ..
487        } = m
488        else {
489            panic!("wrong role")
490        };
491        assert_eq!(sections.len(), 3);
492        assert_eq!(tools_added[0]["name"], "read");
493        assert!(tools_removed.is_empty());
494
495        let patch: PiMessage = serde_json::from_value(serde_json::json!({
496            "role": "system",
497            "content": "",
498            "sections": {"skills": null},
499            "toolsRemoved": [{"name": "write"}],
500            "timestamp": 1733234640000i64
501        }))
502        .unwrap();
503        let PiMessage::System {
504            sections,
505            tools_removed,
506            ..
507        } = patch
508        else {
509            panic!("wrong role")
510        };
511        assert!(sections["skills"].is_null());
512        assert_eq!(tools_removed[0]["name"], "write");
513    }
514
515    #[test]
516    fn bash_execution_message_round_trips() {
517        let v = serde_json::json!({
518            "role": "bashExecution",
519            "command": "ls -la",
520            "output": "total 48",
521            "exitCode": 0,
522            "cancelled": false,
523            "truncated": false,
524            "timestamp": 1733234567890i64
525        });
526        let m: PiMessage = serde_json::from_value(v).unwrap();
527        assert!(matches!(m, PiMessage::BashExecution { .. }));
528    }
529
530    #[test]
531    fn unknown_event_type_is_preserved_not_error() {
532        let e = PiEvent::from_value(serde_json::json!({
533            "type": "queue_update", "steering": [], "followUp": []
534        }))
535        .unwrap();
536        let PiEvent::Unknown {
537            event_type,
538            payload,
539        } = e
540        else {
541            panic!("expected Unknown")
542        };
543        assert_eq!(event_type, "queue_update");
544        assert!(payload.get("steering").is_some());
545    }
546
547    #[test]
548    fn tool_execution_events_lift_fields() {
549        let e = PiEvent::from_value(serde_json::json!({
550            "type": "tool_execution_end",
551            "toolCallId": "call_9", "toolName": "bash",
552            "result": {"ok": true}, "isError": false
553        }))
554        .unwrap();
555        let PiEvent::ToolExecutionEnd {
556            tool_call_id,
557            tool_name,
558            is_error,
559            parent_tool_call_id,
560            ..
561        } = e
562        else {
563            panic!("wrong variant")
564        };
565        assert_eq!(
566            (tool_call_id.as_str(), tool_name.as_str()),
567            ("call_9", "bash")
568        );
569        assert!(!is_error);
570        assert_eq!(parent_tool_call_id, None);
571    }
572
573    #[test]
574    fn nested_tool_execution_events_carry_parent_id() {
575        for (event_type, extra_key) in [
576            ("tool_execution_start", "args"),
577            ("tool_execution_update", "partialResult"),
578            ("tool_execution_end", "result"),
579        ] {
580            let e = PiEvent::from_value(serde_json::json!({
581                "type": event_type,
582                "toolCallId": "call_9/1", "toolName": "read",
583                extra_key: {}, "parentToolCallId": "call_9"
584            }))
585            .unwrap();
586            let parent = match e {
587                PiEvent::ToolExecutionStart {
588                    parent_tool_call_id,
589                    ..
590                }
591                | PiEvent::ToolExecutionUpdate {
592                    parent_tool_call_id,
593                    ..
594                }
595                | PiEvent::ToolExecutionEnd {
596                    parent_tool_call_id,
597                    ..
598                } => parent_tool_call_id,
599                other => panic!("wrong variant: {other:?}"),
600            };
601            assert_eq!(parent.as_deref(), Some("call_9"), "{event_type}");
602        }
603    }
604
605    #[test]
606    fn assistant_thinking_level_is_typed_and_optional() {
607        let with: PiMessage = serde_json::from_value(serde_json::json!({
608            "role": "assistant", "content": [], "stopReason": "stop",
609            "thinkingLevel": "off", "timestamp": 1790756884219i64
610        }))
611        .unwrap();
612        let PiMessage::Assistant {
613            thinking_level,
614            extra,
615            ..
616        } = &with
617        else {
618            panic!("wrong role")
619        };
620        assert_eq!(thinking_level.as_deref(), Some("off"));
621        assert!(!extra.contains_key("thinkingLevel"));
622        assert_eq!(serde_json::to_value(&with).unwrap()["thinkingLevel"], "off");
623
624        let without: PiMessage = serde_json::from_value(serde_json::json!({
625            "role": "assistant", "content": [], "stopReason": "stop"
626        }))
627        .unwrap();
628        let PiMessage::Assistant { thinking_level, .. } = &without else {
629            panic!("wrong role")
630        };
631        assert_eq!(*thinking_level, None);
632        assert!(serde_json::to_value(&without)
633            .unwrap()
634            .get("thinkingLevel")
635            .is_none());
636    }
637}