Skip to main content

phi_agent/bridge/
messages.rs

1//! NDJSON protocol message types shared between phi-agent and language SDKs.
2//!
3//! This module has **zero** dependency on `agent-base` — it is a pure serde
4//! contract. Message fields are self-documenting by their names.
5#![allow(missing_docs)]
6//! contract.  SDK authors can use this file as the authoritative reference for
7//! the wire format without pulling in the entire Rust crate.
8//!
9//! # Protocol overview
10//!
11//! - **Transport**: stdio, one JSON object per line (NDJSON).
12//! - **Schema rule**: new fields may be added at any time (receivers MUST ignore
13//!   unknown fields).  Removing or re-typing a field is a MAJOR version change.
14//!
15//! # Message flow
16//!
17//! ```text
18//! SDK → phi serve         SDK ← phi serve
19//! ─────────────────       ─────────────────
20//! register_tool           hello (on connect)
21//! create_session          session_created
22//! run                     event
23//! tool_result             tool_call
24//! list_tools              tools_listed
25//! cancel                  done
26//!                         error
27//! ```
28
29use agent_base::ToolMetadata as AgentToolMetadata;
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33pub const PROTOCOL_VERSION: u32 = 1;
34
35// ── Tool metadata (bridge-facing, mirrors agent_base::ToolMetadata) ────
36
37/// Stable wire-format representation of a registered tool's metadata.
38/// Mirrors `agent_base::ToolMetadata` without depending on agent-base so
39/// SDK authors can read this file as a pure serde contract.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ToolMetadata {
42    pub name: String,
43    pub description: String,
44    pub origin: String,
45    pub version: String,
46    pub requirements: Vec<String>,
47}
48
49impl From<AgentToolMetadata> for ToolMetadata {
50    fn from(m: AgentToolMetadata) -> Self {
51        Self {
52            name: m.name,
53            description: m.description,
54            origin: m.origin,
55            version: m.version,
56            requirements: m.requirements,
57        }
58    }
59}
60
61// ── Incoming (SDK → phi serve) ────────────────────────────────────────
62
63#[derive(Debug, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum IncomingMessage {
66    RegisterTool {
67        name: String,
68        description: String,
69        parameters: Value,
70    },
71    CreateSession {
72        #[serde(default)]
73        session_id: Option<String>,
74    },
75    Run {
76        #[serde(default)]
77        session_id: String,
78        query: String,
79        #[serde(default)]
80        config: Option<RunConfig>,
81    },
82    ToolResult {
83        call_id: String,
84        summary: String,
85        #[serde(default)]
86        raw: Option<Value>,
87        #[serde(default)]
88        control_flow: Option<String>,
89    },
90    Cancel {
91        #[serde(default)]
92        session_id: String,
93    },
94    ListTools {},
95}
96
97#[derive(Debug, Deserialize, Default)]
98pub struct RunConfig {
99    #[serde(default)]
100    pub model: Option<String>,
101    #[serde(default)]
102    pub api_key: Option<String>,
103    #[serde(default)]
104    pub base_url: Option<String>,
105    pub enable_thinking: Option<bool>,
106    pub thinking_budget: Option<u64>,
107    pub thinking_effort: Option<String>,
108    pub max_tool_calls_per_turn: Option<usize>,
109    pub max_consecutive_failures: Option<usize>,
110    pub max_turns: Option<u32>,
111}
112
113// ── Outgoing (phi serve → SDK) ────────────────────────────────────────
114
115#[derive(Debug, Serialize)]
116#[serde(tag = "type", rename_all = "snake_case")]
117pub enum OutgoingMessage {
118    Hello {
119        protocol_version: u32,
120        server_name: String,
121        server_version: String,
122    },
123    SessionCreated {
124        session_id: Option<String>,
125        internal_id: u64,
126    },
127    Event {
128        seq: u64,
129        #[serde(flatten)]
130        event: Value,
131    },
132    ToolCall {
133        seq: u64,
134        call_id: String,
135        name: String,
136        args: Value,
137    },
138    ToolRegistered {
139        name: String,
140        ok: bool,
141    },
142    Done {
143        seq: u64,
144        outcome: String,
145        #[serde(skip_serializing_if = "Option::is_none")]
146        error: Option<String>,
147        #[serde(skip_serializing_if = "Option::is_none")]
148        turns: Option<u32>,
149    },
150    Error {
151        code: String,
152        message: String,
153        #[serde(skip_serializing_if = "Option::is_none")]
154        detail: Option<Value>,
155    },
156    ToolsListed {
157        tools: Vec<ToolMetadata>,
158    },
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use agent_base::ToolMetadata as AgentToolMetadata;
165
166    // ── IncomingMessage deserialization ──
167
168    #[test]
169    fn test_deserialize_all_incoming_variants() {
170        // register_tool
171        let json = r#"{"type":"register_tool","name":"shell","description":"run shell","parameters":{}}"#;
172        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
173        assert!(matches!(msg, IncomingMessage::RegisterTool { .. }));
174
175        // create_session with session_id
176        let json = r#"{"type":"create_session","session_id":"ext-1"}"#;
177        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
178        assert!(matches!(msg, IncomingMessage::CreateSession { .. }));
179
180        // create_session without session_id
181        let json = r#"{"type":"create_session"}"#;
182        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
183        assert!(matches!(msg, IncomingMessage::CreateSession { session_id: None }));
184
185        // run
186        let json = r#"{"type":"run","session_id":"abc","query":"hello"}"#;
187        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
188        assert!(matches!(msg, IncomingMessage::Run { .. }));
189
190        // tool_result
191        let json = r#"{"type":"tool_result","call_id":"c1","summary":"done"}"#;
192        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
193        assert!(matches!(msg, IncomingMessage::ToolResult { .. }));
194
195        // cancel
196        let json = r#"{"type":"cancel","session_id":"abc"}"#;
197        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
198        assert!(matches!(msg, IncomingMessage::Cancel { .. }));
199
200        // list_tools
201        let json = r#"{"type":"list_tools"}"#;
202        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
203        assert!(matches!(msg, IncomingMessage::ListTools {}));
204    }
205
206    #[test]
207    fn test_run_config_all_fields_populated() {
208        let json = r#"{
209            "type":"run",
210            "session_id":"s1",
211            "query":"test",
212            "config":{
213                "model":"gpt-4",
214                "api_key":"sk-xxx",
215                "base_url":"https://example.com/v1",
216                "enable_thinking":true,
217                "thinking_budget":32000,
218                "thinking_effort":"high",
219                "max_tool_calls_per_turn":10,
220                "max_consecutive_failures":3,
221                "max_turns":5
222            }
223        }"#;
224        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
225        if let IncomingMessage::Run { config: Some(cfg), .. } = &msg {
226            assert_eq!(cfg.model.as_deref(), Some("gpt-4"));
227            assert_eq!(cfg.api_key.as_deref(), Some("sk-xxx"));
228            assert_eq!(cfg.base_url.as_deref(), Some("https://example.com/v1"));
229            assert_eq!(cfg.enable_thinking, Some(true));
230            assert_eq!(cfg.thinking_budget, Some(32000));
231            assert_eq!(cfg.thinking_effort.as_deref(), Some("high"));
232            assert_eq!(cfg.max_tool_calls_per_turn, Some(10));
233            assert_eq!(cfg.max_consecutive_failures, Some(3));
234            assert_eq!(cfg.max_turns, Some(5));
235        } else {
236            panic!("expected Run with config");
237        }
238    }
239
240    #[test]
241    fn test_run_config_default_all_none() {
242        let cfg: RunConfig = serde_json::from_str("{}").unwrap();
243        assert!(cfg.model.is_none());
244        assert!(cfg.api_key.is_none());
245        assert!(cfg.base_url.is_none());
246        assert!(cfg.enable_thinking.is_none());
247        assert!(cfg.thinking_budget.is_none());
248        assert!(cfg.thinking_effort.is_none());
249        assert!(cfg.max_tool_calls_per_turn.is_none());
250        assert!(cfg.max_consecutive_failures.is_none());
251        assert!(cfg.max_turns.is_none());
252    }
253
254    #[test]
255    fn test_run_without_config_uses_none() {
256        let json = r#"{"type":"run","session_id":"s1","query":"test"}"#;
257        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
258        if let IncomingMessage::Run { config, .. } = &msg {
259            assert!(config.is_none());
260        } else {
261            panic!("expected Run");
262        }
263    }
264
265    #[test]
266    fn test_unknown_fields_ignored() {
267        let json = r#"{"type":"list_tools","extra_field":"should-be-ignored","nested":{"a":1}}"#;
268        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
269        assert!(matches!(msg, IncomingMessage::ListTools {}));
270    }
271
272    #[test]
273    fn test_missing_type_field_errors() {
274        let json = r#"{"session_id":"abc","query":"hello"}"#;
275        let result = serde_json::from_str::<IncomingMessage>(json);
276        assert!(result.is_err());
277    }
278
279    #[test]
280    fn test_deserialize_tool_result_with_all_fields() {
281        let json =
282            r#"{"type":"tool_result","call_id":"c1","summary":"done","raw":{"output":"hello"},"control_flow":"break"}"#;
283        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
284        if let IncomingMessage::ToolResult { call_id, summary, raw, control_flow } = &msg {
285            assert_eq!(call_id, "c1");
286            assert_eq!(summary, "done");
287            assert_eq!(raw.as_ref().and_then(|v| v.get("output")).and_then(|v| v.as_str()), Some("hello"));
288            assert_eq!(control_flow.as_deref(), Some("break"));
289        } else {
290            panic!("expected ToolResult");
291        }
292    }
293
294    // ── OutgoingMessage serialization ──
295
296    #[test]
297    fn test_serialize_all_outgoing_variants() {
298        // Hello
299        let msg =
300            OutgoingMessage::Hello { protocol_version: 1, server_name: "phi".into(), server_version: "0.2.6".into() };
301        let json = serde_json::to_value(&msg).unwrap();
302        assert_eq!(json["type"], "hello");
303        assert_eq!(json["protocol_version"], 1);
304
305        // SessionCreated
306        let msg = OutgoingMessage::SessionCreated { session_id: Some("ext-1".into()), internal_id: 42 };
307        let json = serde_json::to_value(&msg).unwrap();
308        assert_eq!(json["type"], "session_created");
309        assert_eq!(json["internal_id"], 42);
310
311        // Event (flattened — inner "type" overrides the enum tag)
312        let msg = OutgoingMessage::Event { seq: 1, event: serde_json::json!({"type":"text_delta","text":"hi"}) };
313        let json = serde_json::to_value(&msg).unwrap();
314        assert_eq!(json["seq"], 1);
315        assert_eq!(json["text"], "hi"); // flattened field present
316
317        // ToolCall
318        let msg = OutgoingMessage::ToolCall {
319            seq: 2,
320            call_id: "c1".into(),
321            name: "shell".into(),
322            args: serde_json::json!({"cmd":"ls"}),
323        };
324        let json = serde_json::to_value(&msg).unwrap();
325        assert_eq!(json["type"], "tool_call");
326        assert_eq!(json["call_id"], "c1");
327
328        // ToolRegistered
329        let msg = OutgoingMessage::ToolRegistered { name: "shell".into(), ok: true };
330        let json = serde_json::to_value(&msg).unwrap();
331        assert_eq!(json["type"], "tool_registered");
332        assert!(json["ok"].as_bool().unwrap());
333
334        // Done
335        let msg = OutgoingMessage::Done { seq: 3, outcome: "completed".into(), error: None, turns: Some(1) };
336        let json = serde_json::to_value(&msg).unwrap();
337        assert_eq!(json["type"], "done");
338        assert_eq!(json["outcome"], "completed");
339        assert!(json.get("error").is_none());
340
341        // Done with error (error should appear)
342        let msg = OutgoingMessage::Done {
343            seq: 4,
344            outcome: "failed".into(),
345            error: Some("something went wrong".into()),
346            turns: None,
347        };
348        let json = serde_json::to_value(&msg).unwrap();
349        assert_eq!(json["error"], "something went wrong");
350        assert!(json.get("turns").is_none());
351
352        // Error
353        let msg = OutgoingMessage::Error { code: "E001".into(), message: "bad request".into(), detail: None };
354        let json = serde_json::to_value(&msg).unwrap();
355        assert_eq!(json["type"], "error");
356        assert!(json.get("detail").is_none());
357
358        // ToolsListed
359        let msg = OutgoingMessage::ToolsListed { tools: vec![] };
360        let json = serde_json::to_value(&msg).unwrap();
361        assert_eq!(json["type"], "tools_listed");
362    }
363
364    // ── ToolMetadata ──
365
366    #[test]
367    fn test_tool_metadata_from_agent_tool_metadata() {
368        let am = AgentToolMetadata {
369            name: "shell".into(),
370            description: "Run shell commands".into(),
371            origin: "phi-tools".into(),
372            version: "1.0.0".into(),
373            requirements: vec!["bash".into()],
374        };
375        let tm = ToolMetadata::from(am);
376        assert_eq!(tm.name, "shell");
377        assert_eq!(tm.description, "Run shell commands");
378        assert_eq!(tm.origin, "phi-tools");
379        assert_eq!(tm.version, "1.0.0");
380        assert_eq!(tm.requirements, vec!["bash"]);
381    }
382
383    #[test]
384    fn test_tool_metadata_round_trip() {
385        let tm = ToolMetadata {
386            name: "shell".into(),
387            description: "desc".into(),
388            origin: "phi".into(),
389            version: "1.0".into(),
390            requirements: vec!["bash".into(), "zsh".into()],
391        };
392        let json = serde_json::to_string(&tm).unwrap();
393        let back: ToolMetadata = serde_json::from_str(&json).unwrap();
394        assert_eq!(back.name, tm.name);
395        assert_eq!(back.description, tm.description);
396        assert_eq!(back.origin, tm.origin);
397        assert_eq!(back.version, tm.version);
398        assert_eq!(back.requirements, tm.requirements);
399    }
400}
401
402#[cfg(test)]
403mod proptests {
404    use super::*;
405
406    proptest::proptest! {
407        #[test]
408        fn incoming_message_deser_never_panics(json in ".*") {
409            let _ = serde_json::from_str::<IncomingMessage>(&json);
410        }
411
412        #[test]
413        fn incoming_message_unknown_type_returns_err(type_name in "[a-z_]{1,20}") {
414            let known = ["register_tool", "create_session", "run", "tool_result", "cancel", "list_tools"];
415            let json = format!(r#"{{"type":"{}","name":"x","description":"d","parameters":{{}},"session_id":"s","query":"q","call_id":"c","summary":"s"}}"#, type_name);
416            let result = serde_json::from_str::<IncomingMessage>(&json);
417            if known.contains(&type_name.as_str()) {
418                proptest::prop_assert!(result.is_ok(), "known type '{}' should deserialize", type_name);
419            } else {
420                proptest::prop_assert!(result.is_err(), "unknown type '{}' should fail", type_name);
421            }
422        }
423    }
424}