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