Skip to main content

agent_core/core/
rpc_protocol.rs

1//! RPC protocol types for the `synaps-bridge` parent↔child IPC channel.
2//!
3//! # Overview
4//!
5//! The synaps-bridge RPC protocol enables a parent process to spawn a
6//! long-lived "rpc child" process and communicate with it over a pair of
7//! `stdio` pipes (child's `stdin` / `stdout`). This module defines every
8//! message type exchanged over that channel.
9//!
10//! See also: `synaps-bridge.SPEC.md §4` (path:
11//! `/home/jr/Projects/Maha-Media/synaps-bridge.SPEC.md`).
12//!
13//! # Framing
14//!
15//! * **Encoding:** UTF-8, line-delimited JSON (LDJSON / NDJSON).
16//! * **One frame per line:** each JSON object is terminated by a single `\n`
17//!   (`0x0A`). No `Content-Length` header or other envelope.
18//! * **Max frame size:** 1 MiB (1 048 576 bytes). Frames that exceed this
19//!   limit are considered malformed. The rpc child must emit an
20//!   [`RpcEvent::Error`] with `id: None` and remain alive when it encounters
21//!   an oversized inbound frame. Enforcement logic lives in Task 2.
22//! * **Direction:** the parent writes [`RpcCommand`] frames to the child's
23//!   `stdin`; the child writes [`RpcEvent`] frames to its `stdout`.
24//!
25//! # Version semantics
26//!
27//! The current protocol version is [`RPC_PROTOCOL_VERSION`] = `1`. The child
28//! emits [`RpcEvent::Ready`] immediately after startup, advertising its
29//! `protocol_version`. The parent must refuse to proceed if the version does
30//! not match its own expectation.
31//!
32//! # Correlation
33//!
34//! Every [`RpcCommand`] variant **except** [`RpcCommand::Shutdown`] carries
35//! an `id: String` field. The rpc child echoes the same `id` in the
36//! corresponding [`RpcEvent::Response`] frame, allowing the parent to
37//! correlate requests and responses. The `id` format is opaque to the child
38//! (UUID, monotonic counter, or any other string).
39
40use serde::{Deserialize, Serialize};
41
42// ---------------------------------------------------------------------------
43// Protocol version
44// ---------------------------------------------------------------------------
45
46/// Wire-format protocol version.  Both sides must agree on this value;
47/// the child advertises it in its [`RpcEvent::Ready`] frame.
48pub const RPC_PROTOCOL_VERSION: u32 = 1;
49
50// ---------------------------------------------------------------------------
51// Auxiliary types
52// ---------------------------------------------------------------------------
53
54/// A file attachment included with a [`RpcCommand::Prompt`] message.
55///
56/// The rpc child reads the file at `path` from the local filesystem.
57/// `name` and `mime` are optional hints; if absent the child falls back to
58/// the basename of `path` and MIME auto-detection respectively.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct RpcAttachment {
61    /// Local filesystem path the rpc child can read.
62    ///
63    /// Convention (enforced when Task 10 adds binary attachment support):
64    /// MUST be an absolute path; MUST NOT contain `..` segments. Path-traversal
65    /// validation will reject relative or `..`-bearing paths at that point.
66    pub path: String,
67    /// Optional human-meaningful filename (defaults to basename of `path`).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub name: Option<String>,
70    /// Optional MIME hint; rpc child re-detects if absent.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub mime: Option<String>,
73}
74
75/// Token-usage summary for a completed agent turn.
76///
77/// Mirrors the shape of `runtime::types::SessionEvent::Usage` so that
78/// consumers of the RPC protocol have identical fields without depending on
79/// the internal runtime type.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct TurnUsage {
82    /// Prompt tokens sent to the model.
83    pub input_tokens: u64,
84    /// Completion tokens returned by the model.
85    pub output_tokens: u64,
86    /// Tokens served from the prompt cache (not billed at full rate).
87    #[serde(default)]
88    pub cache_read_input_tokens: u64,
89    /// Tokens written into the prompt cache during this turn.
90    #[serde(default)]
91    pub cache_creation_input_tokens: u64,
92    /// 5-minute-TTL share of cache writes. Optional, omitted when unknown.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub cache_creation_5m: Option<u64>,
95    /// 1-hour-TTL share of cache writes. Optional, omitted when unknown.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub cache_creation_1h: Option<u64>,
98    /// The model identifier used for this turn, if known.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub model: Option<String>,
101}
102
103// ---------------------------------------------------------------------------
104// Commands: parent → rpc child
105// ---------------------------------------------------------------------------
106
107/// Commands sent from the **parent** process to the **rpc child** over the
108/// child's `stdin`.
109///
110/// All variants except [`RpcCommand::Shutdown`] carry an `id` field that the
111/// child echoes back in the matching [`RpcEvent::Response`] frame.
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113#[serde(tag = "type")]
114pub enum RpcCommand {
115    /// Submit a new user prompt, optionally with file attachments.
116    #[serde(rename = "prompt")]
117    Prompt {
118        /// Correlation id; echoed in the corresponding `Response` event.
119        id: String,
120        /// The user's message text.
121        message: String,
122        /// Zero or more file attachments.  Defaults to an empty list when
123        /// the field is absent from the JSON frame.
124        #[serde(default)]
125        attachments: Vec<RpcAttachment>,
126    },
127
128    /// Send a follow-up message continuing the current conversation turn.
129    #[serde(rename = "follow_up")]
130    FollowUp {
131        /// Correlation id.
132        id: String,
133        /// The follow-up message text.
134        message: String,
135    },
136
137    /// Request in-context compaction of the conversation history.
138    #[serde(rename = "compact")]
139    Compact {
140        /// Correlation id.
141        id: String,
142    },
143
144    /// Start a fresh conversation session, discarding history.
145    #[serde(rename = "new_session")]
146    NewSession {
147        /// Correlation id.
148        id: String,
149    },
150
151    /// Retrieve the current conversation message history.
152    #[serde(rename = "get_messages")]
153    GetMessages {
154        /// Correlation id.
155        id: String,
156    },
157
158    /// Switch the active model for subsequent turns.
159    #[serde(rename = "set_model")]
160    SetModel {
161        /// Correlation id.
162        id: String,
163        /// The model identifier to activate (e.g. `"claude-opus-4-5"`).
164        model: String,
165    },
166
167    /// Enumerate models available to the current auth context.
168    #[serde(rename = "get_available_models")]
169    GetAvailableModels {
170        /// Correlation id.
171        id: String,
172    },
173
174    /// Abort the currently running prompt / agent turn.
175    #[serde(rename = "abort")]
176    Abort {
177        /// Correlation id.
178        id: String,
179    },
180
181    /// Retrieve aggregated token-usage statistics for the session.
182    #[serde(rename = "get_session_stats")]
183    GetSessionStats {
184        /// Correlation id.
185        id: String,
186    },
187
188    /// Retrieve the full runtime state snapshot of the rpc child.
189    #[serde(rename = "get_state")]
190    GetState {
191        /// Correlation id.
192        id: String,
193    },
194
195    /// Enumerate all tools currently registered in this rpc session's tool
196    /// registry (built-ins + any MCP / extension tools loaded at boot).
197    ///
198    /// The `id` field follows the same optional-correlation convention used by
199    /// other commands: when present it is echoed in the `Response` frame so the
200    /// bridge can match the reply to its pending probe.  The bridge Phase 8
201    /// router sends `{"type":"tools_list"}` without an `id`; both forms are
202    /// accepted.
203    #[serde(rename = "tools_list")]
204    ToolsList {
205        /// Optional correlation id; echoed in the corresponding `Response`.
206        #[serde(default, skip_serializing_if = "Option::is_none")]
207        id: Option<String>,
208    },
209
210    /// Instruct the rpc child to exit cleanly.
211    ///
212    /// No `id` field — the child does not send a `Response` for shutdown.
213    #[serde(rename = "shutdown")]
214    Shutdown,
215}
216
217// ---------------------------------------------------------------------------
218// Events: rpc child → parent
219// ---------------------------------------------------------------------------
220
221/// Events emitted by the **rpc child** to the **parent** over the child's
222/// `stdout`.
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
224#[serde(tag = "type")]
225pub enum RpcEvent {
226    /// A streaming update from the assistant (text delta, thinking, tool
227    /// call lifecycle, …).  One or more of these frames precede each
228    /// [`RpcEvent::AgentEnd`].
229    #[serde(rename = "message_update")]
230    MessageUpdate {
231        /// The granular assistant event payload.
232        event: AssistantEvent,
233    },
234
235    /// A subagent has been spawned to handle a delegated task.
236    #[serde(rename = "subagent_start")]
237    SubagentStart {
238        /// Opaque monotonic id for this subagent instance.
239        subagent_id: u64,
240        /// Human-readable agent name.
241        agent_name: String,
242        /// First few words of the task description.
243        task_preview: String,
244    },
245
246    /// A running subagent has produced an intermediate status update.
247    #[serde(rename = "subagent_update")]
248    SubagentUpdate {
249        /// Identifies the subagent (matches a prior [`RpcEvent::SubagentStart`]).
250        subagent_id: u64,
251        /// Human-readable agent name.
252        agent_name: String,
253        /// Free-form status string (e.g. `"running"`, `"tool_call"`).
254        status: String,
255    },
256
257    /// A subagent has finished.
258    #[serde(rename = "subagent_done")]
259    SubagentDone {
260        /// Identifies the subagent.
261        subagent_id: u64,
262        /// Human-readable agent name.
263        agent_name: String,
264        /// First few words of the result.
265        result_preview: String,
266        /// Wall-clock seconds the subagent ran for.
267        duration_secs: f64,
268    },
269
270    /// The agent turn has completed.  Carries final token-usage data.
271    #[serde(rename = "agent_end")]
272    AgentEnd {
273        /// Token usage for the completed turn.
274        usage: TurnUsage,
275    },
276
277    /// A response to a specific [`RpcCommand`], correlated by `id`.
278    ///
279    /// The `body` is **flattened** into the enclosing JSON object — its keys
280    /// appear at the top level alongside `"type"`, `"id"`, and `"command"`.
281    #[serde(rename = "response")]
282    Response {
283        /// Echoed from the originating [`RpcCommand`]'s `id` field.
284        id: String,
285        /// The command name this is responding to (e.g. `"get_messages"`).
286        command: String,
287        /// Arbitrary response payload, flattened into the JSON frame.
288        ///
289        /// Type-erased to `serde_json::Value` for forward-compat with new
290        /// `command` strings. Rust consumers wanting strong typing should
291        /// inspect `command` and re-deserialise `body` into a per-command struct.
292        #[serde(flatten)]
293        body: serde_json::Value,
294    },
295
296    /// A protocol-level or runtime error.
297    ///
298    /// `id` is `None` for errors not attributable to a specific command
299    /// (e.g. oversized frame, internal crash).
300    #[serde(rename = "error")]
301    Error {
302        /// Correlation id of the command that caused the error, if any.
303        #[serde(default, skip_serializing_if = "Option::is_none")]
304        id: Option<String>,
305        /// Human-readable error description.
306        message: String,
307    },
308
309    /// Emitted by the rpc child immediately after startup, before any
310    /// commands are accepted.
311    #[serde(rename = "ready")]
312    Ready {
313        /// Unique identifier for this session (UUID or similar).
314        session_id: String,
315        /// The model currently active.
316        model: String,
317        /// The protocol version implemented by this child.
318        /// Must equal [`RPC_PROTOCOL_VERSION`] for the parent to proceed.
319        protocol_version: u32,
320    },
321}
322
323// ---------------------------------------------------------------------------
324// Assistant streaming events
325// ---------------------------------------------------------------------------
326
327/// Granular events emitted by the assistant during a streaming turn.
328///
329/// Carried inside [`RpcEvent::MessageUpdate`].
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
331#[serde(tag = "type")]
332pub enum AssistantEvent {
333    /// An incremental text chunk from the assistant's response.
334    #[serde(rename = "text_delta")]
335    TextDelta {
336        /// The text fragment.
337        delta: String,
338    },
339
340    /// An incremental thinking/reasoning chunk (extended thinking mode).
341    #[serde(rename = "thinking_delta")]
342    ThinkingDelta {
343        /// The thinking fragment.
344        delta: String,
345    },
346
347    /// A tool call has started streaming.  Subsequent
348    /// [`AssistantEvent::ToolcallInputDelta`] frames carry the JSON input.
349    #[serde(rename = "toolcall_start")]
350    ToolcallStart {
351        /// Model-assigned opaque identifier for this tool call.
352        tool_id: String,
353        /// The tool being invoked.
354        tool_name: String,
355    },
356
357    /// An incremental JSON fragment of a tool call's input.
358    #[serde(rename = "toolcall_input_delta")]
359    ToolcallInputDelta {
360        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
361        tool_id: String,
362        /// Raw JSON fragment (not yet a complete object).
363        delta: String,
364    },
365
366    /// The complete, finalised input for a tool call.
367    #[serde(rename = "toolcall_input")]
368    ToolcallInput {
369        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
370        tool_id: String,
371        /// The fully parsed JSON input value.
372        input: serde_json::Value,
373    },
374
375    /// The result returned by tool execution.
376    #[serde(rename = "toolcall_result")]
377    ToolcallResult {
378        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
379        tool_id: String,
380        /// The serialised tool result string.
381        result: String,
382    },
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use serde_json::{from_str, json, to_string};
393
394    // ── RpcCommand::ToolsList round-trip ────────────────────────────────────
395
396    #[test]
397    fn tools_list_no_id_serialises() {
398        let cmd = RpcCommand::ToolsList { id: None };
399        let json = to_string(&cmd).expect("serialise");
400        let val: serde_json::Value = from_str(&json).unwrap();
401        assert_eq!(val["type"], "tools_list");
402        assert!(val.get("id").is_none(), "absent id must be omitted (skip_serializing_if)");
403    }
404
405    #[test]
406    fn tools_list_with_id_serialises() {
407        let cmd = RpcCommand::ToolsList { id: Some("req-42".to_string()) };
408        let json = to_string(&cmd).expect("serialise");
409        let val: serde_json::Value = from_str(&json).unwrap();
410        assert_eq!(val["type"], "tools_list");
411        assert_eq!(val["id"], "req-42");
412    }
413
414    #[test]
415    fn tools_list_no_id_deserialises() {
416        let line = r#"{"type":"tools_list"}"#;
417        let cmd: RpcCommand = from_str(line).expect("deserialise");
418        match cmd {
419            RpcCommand::ToolsList { id } => assert!(id.is_none()),
420            other => panic!("expected ToolsList, got {other:?}"),
421        }
422    }
423
424    #[test]
425    fn tools_list_with_id_deserialises() {
426        let line = r#"{"type":"tools_list","id":"abc-123"}"#;
427        let cmd: RpcCommand = from_str(line).expect("deserialise");
428        match cmd {
429            RpcCommand::ToolsList { id } => assert_eq!(id.as_deref(), Some("abc-123")),
430            other => panic!("expected ToolsList, got {other:?}"),
431        }
432    }
433
434    // ── Response body shape expected by the bridge ──────────────────────────
435
436    /// The bridge validates: `response.ok === true && Array.isArray(response.tools)`.
437    /// Verify the flattened RpcEvent::Response body produces exactly that shape.
438    #[test]
439    fn tools_list_response_body_shape_matches_bridge_expectation() {
440        let tools_json = json!([
441            {
442                "name": "bash",
443                "description": "Run a bash command",
444                "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}}
445            }
446        ]);
447        let ev = RpcEvent::Response {
448            id: "req-1".to_string(),
449            command: "tools_list".to_string(),
450            body: json!({ "ok": true, "tools": tools_json }),
451        };
452        let serialised = to_string(&ev).expect("serialise");
453        let val: serde_json::Value = from_str(&serialised).unwrap();
454
455        assert_eq!(val["type"], "response");
456        assert_eq!(val["id"], "req-1");
457        assert_eq!(val["command"], "tools_list");
458        // Bridge checks these two fields at the top level (flattened):
459        assert_eq!(val["ok"], true, "bridge needs ok=true at top level");
460        assert!(val["tools"].is_array(), "bridge needs tools array at top level");
461        assert_eq!(val["tools"][0]["name"], "bash");
462    }
463}