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