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/// Canonical runtime event payload carried by `RpcEvent::Event`.
76/// Server mode internalizes the same formatted event into its owning conversation
77/// rather than exposing a raw transport frame.
78///
79/// All string fields are UTF-8; `formatted` is the full XML-tagged payload
80/// already safe for injection into the agent context (prompt-injection
81/// stripped by `format_event_for_agent`).
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct EventPayload {
84    /// UUID of the originating event.
85    pub id: String,
86    /// Source type string (e.g. `"discord"`, `"uptime-kuma"`, `"cli"`).
87    pub source: String,
88    /// Severity label: `"low"` | `"medium"` | `"high"` | `"critical"`.
89    pub severity: String,
90    /// Content type label (e.g. `"message"`, `"alert"`, `"subagent_completion"`).
91    pub content_type: String,
92    /// Raw event text (before XML wrapping).
93    pub text: String,
94    /// ISO-8601 UTC timestamp string.
95    pub timestamp: String,
96    /// Full `format_event_for_agent` output — XML-wrapped, injection-safe.
97    pub formatted: String,
98}
99
100/// Token-usage summary for a completed agent turn.
101///
102/// Mirrors the shape of `runtime::types::SessionEvent::Usage` so that
103/// consumers of the RPC protocol have identical fields without depending on
104/// the internal runtime type.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct TurnUsage {
107    /// Prompt tokens sent to the model.
108    pub input_tokens: u64,
109    /// Completion tokens returned by the model.
110    pub output_tokens: u64,
111    /// Tokens served from the prompt cache (not billed at full rate).
112    #[serde(default)]
113    pub cache_read_input_tokens: u64,
114    /// Tokens written into the prompt cache during this turn.
115    #[serde(default)]
116    pub cache_creation_input_tokens: u64,
117    /// 5-minute-TTL share of cache writes. Optional, omitted when unknown.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub cache_creation_5m: Option<u64>,
120    /// 1-hour-TTL share of cache writes. Optional, omitted when unknown.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub cache_creation_1h: Option<u64>,
123    /// The model identifier used for this turn, if known.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub model: Option<String>,
126}
127
128// ---------------------------------------------------------------------------
129// Commands: parent → rpc child
130// ---------------------------------------------------------------------------
131
132/// Commands sent from the **parent** process to the **rpc child** over the
133/// child's `stdin`.
134///
135/// All variants except [`RpcCommand::Shutdown`] carry an `id` field that the
136/// child echoes back in the matching [`RpcEvent::Response`] frame.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(tag = "type")]
139pub enum RpcCommand {
140    /// Submit a new user prompt, optionally with file attachments.
141    #[serde(rename = "prompt")]
142    Prompt {
143        /// Correlation id; echoed in the corresponding `Response` event.
144        id: String,
145        /// The user's message text.
146        message: String,
147        /// Zero or more file attachments.  Defaults to an empty list when
148        /// the field is absent from the JSON frame.
149        #[serde(default)]
150        attachments: Vec<RpcAttachment>,
151    },
152
153    /// Send a follow-up message continuing the current conversation turn.
154    #[serde(rename = "follow_up")]
155    FollowUp {
156        /// Correlation id.
157        id: String,
158        /// The follow-up message text.
159        message: String,
160    },
161
162    /// Request in-context compaction of the conversation history.
163    #[serde(rename = "compact")]
164    Compact {
165        /// Correlation id.
166        id: String,
167    },
168
169    /// Start a fresh conversation session, discarding history.
170    #[serde(rename = "new_session")]
171    NewSession {
172        /// Correlation id.
173        id: String,
174    },
175
176    /// Retrieve the current conversation message history.
177    #[serde(rename = "get_messages")]
178    GetMessages {
179        /// Correlation id.
180        id: String,
181    },
182
183    /// Switch the active model for subsequent turns.
184    #[serde(rename = "set_model")]
185    SetModel {
186        /// Correlation id.
187        id: String,
188        /// The model identifier to activate (e.g. `"claude-opus-4-5"`).
189        model: String,
190    },
191
192    /// Enumerate models available to the current auth context.
193    #[serde(rename = "get_available_models")]
194    GetAvailableModels {
195        /// Correlation id.
196        id: String,
197    },
198
199    /// Abort the currently running prompt / agent turn.
200    #[serde(rename = "abort")]
201    Abort {
202        /// Correlation id.
203        id: String,
204    },
205
206    /// Retrieve aggregated token-usage statistics for the session.
207    #[serde(rename = "get_session_stats")]
208    GetSessionStats {
209        /// Correlation id.
210        id: String,
211    },
212
213    /// Retrieve the full runtime state snapshot of the rpc child.
214    #[serde(rename = "get_state")]
215    GetState {
216        /// Correlation id.
217        id: String,
218    },
219
220    /// Enumerate all tools currently registered in this rpc session's tool
221    /// registry (built-ins + any MCP / extension tools loaded at boot).
222    ///
223    /// The `id` field follows the same optional-correlation convention used by
224    /// other commands: when present it is echoed in the `Response` frame so the
225    /// bridge can match the reply to its pending probe.  The bridge Phase 8
226    /// router sends `{"type":"tools_list"}` without an `id`; both forms are
227    /// accepted.
228    #[serde(rename = "tools_list")]
229    ToolsList {
230        /// Optional correlation id; echoed in the corresponding `Response`.
231        #[serde(default, skip_serializing_if = "Option::is_none")]
232        id: Option<String>,
233    },
234
235    /// Instruct the rpc child to exit cleanly.
236    ///
237    /// No `id` field — the child does not send a `Response` for shutdown.
238    #[serde(rename = "shutdown")]
239    Shutdown,
240}
241
242// ---------------------------------------------------------------------------
243// Events: rpc child → parent
244// ---------------------------------------------------------------------------
245
246/// Events emitted by the **rpc child** to the **parent** over the child's
247/// `stdout`.
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249#[serde(tag = "type")]
250pub enum RpcEvent {
251    /// A runtime event forwarded to the parent without triggering a new model
252    /// turn.  The parent may inject this into the next authorised
253    /// [`RpcCommand::FollowUp`] / [`RpcCommand::Prompt`].  This variant is
254    /// **additive** — old protocol consumers that don't recognise `"event"`
255    /// will deserialise it as an unknown type and can ignore it safely.
256    #[serde(rename = "event")]
257    Event {
258        /// Canonical structured payload from the runtime event queue.
259        payload: Box<EventPayload>,
260    },
261
262    /// A streaming update from the assistant (text delta, thinking, tool
263    /// call lifecycle, …).  One or more of these frames precede each
264    /// [`RpcEvent::AgentEnd`].
265    #[serde(rename = "message_update")]
266    MessageUpdate {
267        /// The granular assistant event payload.
268        event: AssistantEvent,
269    },
270
271    /// A subagent has been spawned to handle a delegated task.
272    #[serde(rename = "subagent_start")]
273    SubagentStart {
274        /// Opaque monotonic id for this subagent instance.
275        subagent_id: u64,
276        /// Human-readable agent name.
277        agent_name: String,
278        /// First few words of the task description.
279        task_preview: String,
280    },
281
282    /// A running subagent has produced an intermediate status update.
283    #[serde(rename = "subagent_update")]
284    SubagentUpdate {
285        /// Identifies the subagent (matches a prior [`RpcEvent::SubagentStart`]).
286        subagent_id: u64,
287        /// Human-readable agent name.
288        agent_name: String,
289        /// Free-form status string (e.g. `"running"`, `"tool_call"`).
290        status: String,
291    },
292
293    /// A subagent has finished.
294    #[serde(rename = "subagent_done")]
295    SubagentDone {
296        /// Identifies the subagent.
297        subagent_id: u64,
298        /// Human-readable agent name.
299        agent_name: String,
300        /// First few words of the result.
301        result_preview: String,
302        /// Wall-clock seconds the subagent ran for.
303        duration_secs: f64,
304    },
305
306    /// The agent turn has completed.  Carries final token-usage data.
307    #[serde(rename = "agent_end")]
308    AgentEnd {
309        /// Token usage for the completed turn.
310        usage: TurnUsage,
311    },
312
313    /// A response to a specific [`RpcCommand`], correlated by `id`.
314    ///
315    /// The `body` is **flattened** into the enclosing JSON object — its keys
316    /// appear at the top level alongside `"type"`, `"id"`, and `"command"`.
317    #[serde(rename = "response")]
318    Response {
319        /// Echoed from the originating [`RpcCommand`]'s `id` field.
320        id: String,
321        /// The command name this is responding to (e.g. `"get_messages"`).
322        command: String,
323        /// Arbitrary response payload, flattened into the JSON frame.
324        ///
325        /// Type-erased to `serde_json::Value` for forward-compat with new
326        /// `command` strings. Rust consumers wanting strong typing should
327        /// inspect `command` and re-deserialise `body` into a per-command struct.
328        #[serde(flatten)]
329        body: serde_json::Value,
330    },
331
332    /// A protocol-level or runtime error.
333    ///
334    /// `id` is `None` for errors not attributable to a specific command
335    /// (e.g. oversized frame, internal crash).
336    #[serde(rename = "error")]
337    Error {
338        /// Correlation id of the command that caused the error, if any.
339        #[serde(default, skip_serializing_if = "Option::is_none")]
340        id: Option<String>,
341        /// Human-readable error description.
342        message: String,
343    },
344
345    /// Emitted by the rpc child immediately after startup, before any
346    /// commands are accepted.
347    #[serde(rename = "ready")]
348    Ready {
349        /// Unique identifier for this session (UUID or similar).
350        session_id: String,
351        /// The model currently active.
352        model: String,
353        /// The protocol version implemented by this child.
354        /// Must equal [`RPC_PROTOCOL_VERSION`] for the parent to proceed.
355        protocol_version: u32,
356    },
357}
358
359// ---------------------------------------------------------------------------
360// Assistant streaming events
361// ---------------------------------------------------------------------------
362
363/// Granular events emitted by the assistant during a streaming turn.
364///
365/// Carried inside [`RpcEvent::MessageUpdate`].
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
367#[serde(tag = "type")]
368pub enum AssistantEvent {
369    /// An incremental text chunk from the assistant's response.
370    #[serde(rename = "text_delta")]
371    TextDelta {
372        /// The text fragment.
373        delta: String,
374    },
375
376    /// An incremental thinking/reasoning chunk (extended thinking mode).
377    #[serde(rename = "thinking_delta")]
378    ThinkingDelta {
379        /// The thinking fragment.
380        delta: String,
381    },
382
383    /// A tool call has started streaming.  Subsequent
384    /// [`AssistantEvent::ToolcallInputDelta`] frames carry the JSON input.
385    #[serde(rename = "toolcall_start")]
386    ToolcallStart {
387        /// Model-assigned opaque identifier for this tool call.
388        tool_id: String,
389        /// The tool being invoked.
390        tool_name: String,
391    },
392
393    /// An incremental JSON fragment of a tool call's input.
394    #[serde(rename = "toolcall_input_delta")]
395    ToolcallInputDelta {
396        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
397        tool_id: String,
398        /// Raw JSON fragment (not yet a complete object).
399        delta: String,
400    },
401
402    /// The complete, finalised input for a tool call.
403    #[serde(rename = "toolcall_input")]
404    ToolcallInput {
405        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
406        tool_id: String,
407        /// The fully parsed JSON input value.
408        input: serde_json::Value,
409    },
410
411    /// The result returned by tool execution.
412    #[serde(rename = "toolcall_result")]
413    ToolcallResult {
414        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
415        tool_id: String,
416        /// The serialised tool result string.
417        result: String,
418    },
419}
420
421// ---------------------------------------------------------------------------
422// Tests
423// ---------------------------------------------------------------------------
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use serde_json::{from_str, json, to_string};
429
430    // ── RpcCommand::ToolsList round-trip ────────────────────────────────────
431
432    #[test]
433    fn tools_list_no_id_serialises() {
434        let cmd = RpcCommand::ToolsList { id: None };
435        let json = to_string(&cmd).expect("serialise");
436        let val: serde_json::Value = from_str(&json).unwrap();
437        assert_eq!(val["type"], "tools_list");
438        assert!(val.get("id").is_none(), "absent id must be omitted (skip_serializing_if)");
439    }
440
441    #[test]
442    fn tools_list_with_id_serialises() {
443        let cmd = RpcCommand::ToolsList { id: Some("req-42".to_string()) };
444        let json = to_string(&cmd).expect("serialise");
445        let val: serde_json::Value = from_str(&json).unwrap();
446        assert_eq!(val["type"], "tools_list");
447        assert_eq!(val["id"], "req-42");
448    }
449
450    #[test]
451    fn tools_list_no_id_deserialises() {
452        let line = r#"{"type":"tools_list"}"#;
453        let cmd: RpcCommand = from_str(line).expect("deserialise");
454        match cmd {
455            RpcCommand::ToolsList { id } => assert!(id.is_none()),
456            other => panic!("expected ToolsList, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn tools_list_with_id_deserialises() {
462        let line = r#"{"type":"tools_list","id":"abc-123"}"#;
463        let cmd: RpcCommand = from_str(line).expect("deserialise");
464        match cmd {
465            RpcCommand::ToolsList { id } => assert_eq!(id.as_deref(), Some("abc-123")),
466            other => panic!("expected ToolsList, got {other:?}"),
467        }
468    }
469
470    // ── Response body shape expected by the bridge ──────────────────────────
471
472    /// The bridge validates: `response.ok === true && Array.isArray(response.tools)`.
473    /// Verify the flattened RpcEvent::Response body produces exactly that shape.
474    #[test]
475    fn tools_list_response_body_shape_matches_bridge_expectation() {
476        let tools_json = json!([
477            {
478                "name": "bash",
479                "description": "Run a bash command",
480                "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}}
481            }
482        ]);
483        let ev = RpcEvent::Response {
484            id: "req-1".to_string(),
485            command: "tools_list".to_string(),
486            body: json!({ "ok": true, "tools": tools_json }),
487        };
488        let serialised = to_string(&ev).expect("serialise");
489        let val: serde_json::Value = from_str(&serialised).unwrap();
490
491        assert_eq!(val["type"], "response");
492        assert_eq!(val["id"], "req-1");
493        assert_eq!(val["command"], "tools_list");
494        // Bridge checks these two fields at the top level (flattened):
495        assert_eq!(val["ok"], true, "bridge needs ok=true at top level");
496        assert!(val["tools"].is_array(), "bridge needs tools array at top level");
497        assert_eq!(val["tools"][0]["name"], "bash");
498    }
499}