Skip to main content

synaps_cli/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    /// The model identifier used for this turn, if known.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub model: Option<String>,
95}
96
97// ---------------------------------------------------------------------------
98// Commands: parent → rpc child
99// ---------------------------------------------------------------------------
100
101/// Commands sent from the **parent** process to the **rpc child** over the
102/// child's `stdin`.
103///
104/// All variants except [`RpcCommand::Shutdown`] carry an `id` field that the
105/// child echoes back in the matching [`RpcEvent::Response`] frame.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107#[serde(tag = "type")]
108pub enum RpcCommand {
109    /// Submit a new user prompt, optionally with file attachments.
110    #[serde(rename = "prompt")]
111    Prompt {
112        /// Correlation id; echoed in the corresponding `Response` event.
113        id: String,
114        /// The user's message text.
115        message: String,
116        /// Zero or more file attachments.  Defaults to an empty list when
117        /// the field is absent from the JSON frame.
118        #[serde(default)]
119        attachments: Vec<RpcAttachment>,
120    },
121
122    /// Send a follow-up message continuing the current conversation turn.
123    #[serde(rename = "follow_up")]
124    FollowUp {
125        /// Correlation id.
126        id: String,
127        /// The follow-up message text.
128        message: String,
129    },
130
131    /// Request in-context compaction of the conversation history.
132    #[serde(rename = "compact")]
133    Compact {
134        /// Correlation id.
135        id: String,
136    },
137
138    /// Start a fresh conversation session, discarding history.
139    #[serde(rename = "new_session")]
140    NewSession {
141        /// Correlation id.
142        id: String,
143    },
144
145    /// Retrieve the current conversation message history.
146    #[serde(rename = "get_messages")]
147    GetMessages {
148        /// Correlation id.
149        id: String,
150    },
151
152    /// Switch the active model for subsequent turns.
153    #[serde(rename = "set_model")]
154    SetModel {
155        /// Correlation id.
156        id: String,
157        /// The model identifier to activate (e.g. `"claude-opus-4-5"`).
158        model: String,
159    },
160
161    /// Enumerate models available to the current auth context.
162    #[serde(rename = "get_available_models")]
163    GetAvailableModels {
164        /// Correlation id.
165        id: String,
166    },
167
168    /// Abort the currently running prompt / agent turn.
169    #[serde(rename = "abort")]
170    Abort {
171        /// Correlation id.
172        id: String,
173    },
174
175    /// Retrieve aggregated token-usage statistics for the session.
176    #[serde(rename = "get_session_stats")]
177    GetSessionStats {
178        /// Correlation id.
179        id: String,
180    },
181
182    /// Retrieve the full runtime state snapshot of the rpc child.
183    #[serde(rename = "get_state")]
184    GetState {
185        /// Correlation id.
186        id: String,
187    },
188
189    /// Instruct the rpc child to exit cleanly.
190    ///
191    /// No `id` field — the child does not send a `Response` for shutdown.
192    #[serde(rename = "shutdown")]
193    Shutdown,
194}
195
196// ---------------------------------------------------------------------------
197// Events: rpc child → parent
198// ---------------------------------------------------------------------------
199
200/// Events emitted by the **rpc child** to the **parent** over the child's
201/// `stdout`.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203#[serde(tag = "type")]
204pub enum RpcEvent {
205    /// A streaming update from the assistant (text delta, thinking, tool
206    /// call lifecycle, …).  One or more of these frames precede each
207    /// [`RpcEvent::AgentEnd`].
208    #[serde(rename = "message_update")]
209    MessageUpdate {
210        /// The granular assistant event payload.
211        event: AssistantEvent,
212    },
213
214    /// A subagent has been spawned to handle a delegated task.
215    #[serde(rename = "subagent_start")]
216    SubagentStart {
217        /// Opaque monotonic id for this subagent instance.
218        subagent_id: u64,
219        /// Human-readable agent name.
220        agent_name: String,
221        /// First few words of the task description.
222        task_preview: String,
223    },
224
225    /// A running subagent has produced an intermediate status update.
226    #[serde(rename = "subagent_update")]
227    SubagentUpdate {
228        /// Identifies the subagent (matches a prior [`RpcEvent::SubagentStart`]).
229        subagent_id: u64,
230        /// Human-readable agent name.
231        agent_name: String,
232        /// Free-form status string (e.g. `"running"`, `"tool_call"`).
233        status: String,
234    },
235
236    /// A subagent has finished.
237    #[serde(rename = "subagent_done")]
238    SubagentDone {
239        /// Identifies the subagent.
240        subagent_id: u64,
241        /// Human-readable agent name.
242        agent_name: String,
243        /// First few words of the result.
244        result_preview: String,
245        /// Wall-clock seconds the subagent ran for.
246        duration_secs: f64,
247    },
248
249    /// The agent turn has completed.  Carries final token-usage data.
250    #[serde(rename = "agent_end")]
251    AgentEnd {
252        /// Token usage for the completed turn.
253        usage: TurnUsage,
254    },
255
256    /// A response to a specific [`RpcCommand`], correlated by `id`.
257    ///
258    /// The `body` is **flattened** into the enclosing JSON object — its keys
259    /// appear at the top level alongside `"type"`, `"id"`, and `"command"`.
260    #[serde(rename = "response")]
261    Response {
262        /// Echoed from the originating [`RpcCommand`]'s `id` field.
263        id: String,
264        /// The command name this is responding to (e.g. `"get_messages"`).
265        command: String,
266        /// Arbitrary response payload, flattened into the JSON frame.
267        ///
268        /// Type-erased to `serde_json::Value` for forward-compat with new
269        /// `command` strings. Rust consumers wanting strong typing should
270        /// inspect `command` and re-deserialise `body` into a per-command struct.
271        #[serde(flatten)]
272        body: serde_json::Value,
273    },
274
275    /// A protocol-level or runtime error.
276    ///
277    /// `id` is `None` for errors not attributable to a specific command
278    /// (e.g. oversized frame, internal crash).
279    #[serde(rename = "error")]
280    Error {
281        /// Correlation id of the command that caused the error, if any.
282        #[serde(default, skip_serializing_if = "Option::is_none")]
283        id: Option<String>,
284        /// Human-readable error description.
285        message: String,
286    },
287
288    /// Emitted by the rpc child immediately after startup, before any
289    /// commands are accepted.
290    #[serde(rename = "ready")]
291    Ready {
292        /// Unique identifier for this session (UUID or similar).
293        session_id: String,
294        /// The model currently active.
295        model: String,
296        /// The protocol version implemented by this child.
297        /// Must equal [`RPC_PROTOCOL_VERSION`] for the parent to proceed.
298        protocol_version: u32,
299    },
300}
301
302// ---------------------------------------------------------------------------
303// Assistant streaming events
304// ---------------------------------------------------------------------------
305
306/// Granular events emitted by the assistant during a streaming turn.
307///
308/// Carried inside [`RpcEvent::MessageUpdate`].
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
310#[serde(tag = "type")]
311pub enum AssistantEvent {
312    /// An incremental text chunk from the assistant's response.
313    #[serde(rename = "text_delta")]
314    TextDelta {
315        /// The text fragment.
316        delta: String,
317    },
318
319    /// An incremental thinking/reasoning chunk (extended thinking mode).
320    #[serde(rename = "thinking_delta")]
321    ThinkingDelta {
322        /// The thinking fragment.
323        delta: String,
324    },
325
326    /// A tool call has started streaming.  Subsequent
327    /// [`AssistantEvent::ToolcallInputDelta`] frames carry the JSON input.
328    #[serde(rename = "toolcall_start")]
329    ToolcallStart {
330        /// Model-assigned opaque identifier for this tool call.
331        tool_id: String,
332        /// The tool being invoked.
333        tool_name: String,
334    },
335
336    /// An incremental JSON fragment of a tool call's input.
337    #[serde(rename = "toolcall_input_delta")]
338    ToolcallInputDelta {
339        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
340        tool_id: String,
341        /// Raw JSON fragment (not yet a complete object).
342        delta: String,
343    },
344
345    /// The complete, finalised input for a tool call.
346    #[serde(rename = "toolcall_input")]
347    ToolcallInput {
348        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
349        tool_id: String,
350        /// The fully parsed JSON input value.
351        input: serde_json::Value,
352    },
353
354    /// The result returned by tool execution.
355    #[serde(rename = "toolcall_result")]
356    ToolcallResult {
357        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
358        tool_id: String,
359        /// The serialised tool result string.
360        result: String,
361    },
362}