synaps-core 0.3.0

Foundation types, config, session, auth, protocol — leaf crate for agent-runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! RPC protocol types for the `synaps-bridge` parent↔child IPC channel.
//!
//! # Overview
//!
//! The synaps-bridge RPC protocol enables a parent process to spawn a
//! long-lived "rpc child" process and communicate with it over a pair of
//! `stdio` pipes (child's `stdin` / `stdout`). This module defines every
//! message type exchanged over that channel.
//!
//! See also: `synaps-bridge.SPEC.md §4` (path:
//! `/home/jr/Projects/Maha-Media/synaps-bridge.SPEC.md`).
//!
//! # Framing
//!
//! * **Encoding:** UTF-8, line-delimited JSON (LDJSON / NDJSON).
//! * **One frame per line:** each JSON object is terminated by a single `\n`
//!   (`0x0A`). No `Content-Length` header or other envelope.
//! * **Max frame size:** 1 MiB (1 048 576 bytes). Frames that exceed this
//!   limit are considered malformed. The rpc child must emit an
//!   [`RpcEvent::Error`] with `id: None` and remain alive when it encounters
//!   an oversized inbound frame. Enforcement logic lives in Task 2.
//! * **Direction:** the parent writes [`RpcCommand`] frames to the child's
//!   `stdin`; the child writes [`RpcEvent`] frames to its `stdout`.
//!
//! # Version semantics
//!
//! The current protocol version is [`RPC_PROTOCOL_VERSION`] = `1`. The child
//! emits [`RpcEvent::Ready`] immediately after startup, advertising its
//! `protocol_version`. The parent must refuse to proceed if the version does
//! not match its own expectation.
//!
//! # Correlation
//!
//! Every [`RpcCommand`] variant **except** [`RpcCommand::Shutdown`] carries
//! an `id: String` field. The rpc child echoes the same `id` in the
//! corresponding [`RpcEvent::Response`] frame, allowing the parent to
//! correlate requests and responses. The `id` format is opaque to the child
//! (UUID, monotonic counter, or any other string).

use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// Protocol version
// ---------------------------------------------------------------------------

/// Wire-format protocol version.  Both sides must agree on this value;
/// the child advertises it in its [`RpcEvent::Ready`] frame.
pub const RPC_PROTOCOL_VERSION: u32 = 1;

// ---------------------------------------------------------------------------
// Auxiliary types
// ---------------------------------------------------------------------------

/// A file attachment included with a [`RpcCommand::Prompt`] message.
///
/// The rpc child reads the file at `path` from the local filesystem.
/// `name` and `mime` are optional hints; if absent the child falls back to
/// the basename of `path` and MIME auto-detection respectively.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcAttachment {
    /// Local filesystem path the rpc child can read.
    ///
    /// Convention (enforced when Task 10 adds binary attachment support):
    /// MUST be an absolute path; MUST NOT contain `..` segments. Path-traversal
    /// validation will reject relative or `..`-bearing paths at that point.
    pub path: String,
    /// Optional human-meaningful filename (defaults to basename of `path`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Optional MIME hint; rpc child re-detects if absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mime: Option<String>,
}

/// Token-usage summary for a completed agent turn.
///
/// Mirrors the shape of `runtime::types::SessionEvent::Usage` so that
/// consumers of the RPC protocol have identical fields without depending on
/// the internal runtime type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnUsage {
    /// Prompt tokens sent to the model.
    pub input_tokens: u64,
    /// Completion tokens returned by the model.
    pub output_tokens: u64,
    /// Tokens served from the prompt cache (not billed at full rate).
    #[serde(default)]
    pub cache_read_input_tokens: u64,
    /// Tokens written into the prompt cache during this turn.
    #[serde(default)]
    pub cache_creation_input_tokens: u64,
    /// 5-minute-TTL share of cache writes. Optional, omitted when unknown.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_creation_5m: Option<u64>,
    /// 1-hour-TTL share of cache writes. Optional, omitted when unknown.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_creation_1h: Option<u64>,
    /// The model identifier used for this turn, if known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
}

// ---------------------------------------------------------------------------
// Commands: parent → rpc child
// ---------------------------------------------------------------------------

/// Commands sent from the **parent** process to the **rpc child** over the
/// child's `stdin`.
///
/// All variants except [`RpcCommand::Shutdown`] carry an `id` field that the
/// child echoes back in the matching [`RpcEvent::Response`] frame.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum RpcCommand {
    /// Submit a new user prompt, optionally with file attachments.
    #[serde(rename = "prompt")]
    Prompt {
        /// Correlation id; echoed in the corresponding `Response` event.
        id: String,
        /// The user's message text.
        message: String,
        /// Zero or more file attachments.  Defaults to an empty list when
        /// the field is absent from the JSON frame.
        #[serde(default)]
        attachments: Vec<RpcAttachment>,
    },

    /// Send a follow-up message continuing the current conversation turn.
    #[serde(rename = "follow_up")]
    FollowUp {
        /// Correlation id.
        id: String,
        /// The follow-up message text.
        message: String,
    },

    /// Request in-context compaction of the conversation history.
    #[serde(rename = "compact")]
    Compact {
        /// Correlation id.
        id: String,
    },

    /// Start a fresh conversation session, discarding history.
    #[serde(rename = "new_session")]
    NewSession {
        /// Correlation id.
        id: String,
    },

    /// Retrieve the current conversation message history.
    #[serde(rename = "get_messages")]
    GetMessages {
        /// Correlation id.
        id: String,
    },

    /// Switch the active model for subsequent turns.
    #[serde(rename = "set_model")]
    SetModel {
        /// Correlation id.
        id: String,
        /// The model identifier to activate (e.g. `"claude-opus-4-5"`).
        model: String,
    },

    /// Enumerate models available to the current auth context.
    #[serde(rename = "get_available_models")]
    GetAvailableModels {
        /// Correlation id.
        id: String,
    },

    /// Abort the currently running prompt / agent turn.
    #[serde(rename = "abort")]
    Abort {
        /// Correlation id.
        id: String,
    },

    /// Retrieve aggregated token-usage statistics for the session.
    #[serde(rename = "get_session_stats")]
    GetSessionStats {
        /// Correlation id.
        id: String,
    },

    /// Retrieve the full runtime state snapshot of the rpc child.
    #[serde(rename = "get_state")]
    GetState {
        /// Correlation id.
        id: String,
    },

    /// Enumerate all tools currently registered in this rpc session's tool
    /// registry (built-ins + any MCP / extension tools loaded at boot).
    ///
    /// The `id` field follows the same optional-correlation convention used by
    /// other commands: when present it is echoed in the `Response` frame so the
    /// bridge can match the reply to its pending probe.  The bridge Phase 8
    /// router sends `{"type":"tools_list"}` without an `id`; both forms are
    /// accepted.
    #[serde(rename = "tools_list")]
    ToolsList {
        /// Optional correlation id; echoed in the corresponding `Response`.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        id: Option<String>,
    },

    /// Instruct the rpc child to exit cleanly.
    ///
    /// No `id` field — the child does not send a `Response` for shutdown.
    #[serde(rename = "shutdown")]
    Shutdown,
}

// ---------------------------------------------------------------------------
// Events: rpc child → parent
// ---------------------------------------------------------------------------

/// Events emitted by the **rpc child** to the **parent** over the child's
/// `stdout`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum RpcEvent {
    /// A streaming update from the assistant (text delta, thinking, tool
    /// call lifecycle, …).  One or more of these frames precede each
    /// [`RpcEvent::AgentEnd`].
    #[serde(rename = "message_update")]
    MessageUpdate {
        /// The granular assistant event payload.
        event: AssistantEvent,
    },

    /// A subagent has been spawned to handle a delegated task.
    #[serde(rename = "subagent_start")]
    SubagentStart {
        /// Opaque monotonic id for this subagent instance.
        subagent_id: u64,
        /// Human-readable agent name.
        agent_name: String,
        /// First few words of the task description.
        task_preview: String,
    },

    /// A running subagent has produced an intermediate status update.
    #[serde(rename = "subagent_update")]
    SubagentUpdate {
        /// Identifies the subagent (matches a prior [`RpcEvent::SubagentStart`]).
        subagent_id: u64,
        /// Human-readable agent name.
        agent_name: String,
        /// Free-form status string (e.g. `"running"`, `"tool_call"`).
        status: String,
    },

    /// A subagent has finished.
    #[serde(rename = "subagent_done")]
    SubagentDone {
        /// Identifies the subagent.
        subagent_id: u64,
        /// Human-readable agent name.
        agent_name: String,
        /// First few words of the result.
        result_preview: String,
        /// Wall-clock seconds the subagent ran for.
        duration_secs: f64,
    },

    /// The agent turn has completed.  Carries final token-usage data.
    #[serde(rename = "agent_end")]
    AgentEnd {
        /// Token usage for the completed turn.
        usage: TurnUsage,
    },

    /// A response to a specific [`RpcCommand`], correlated by `id`.
    ///
    /// The `body` is **flattened** into the enclosing JSON object — its keys
    /// appear at the top level alongside `"type"`, `"id"`, and `"command"`.
    #[serde(rename = "response")]
    Response {
        /// Echoed from the originating [`RpcCommand`]'s `id` field.
        id: String,
        /// The command name this is responding to (e.g. `"get_messages"`).
        command: String,
        /// Arbitrary response payload, flattened into the JSON frame.
        ///
        /// Type-erased to `serde_json::Value` for forward-compat with new
        /// `command` strings. Rust consumers wanting strong typing should
        /// inspect `command` and re-deserialise `body` into a per-command struct.
        #[serde(flatten)]
        body: serde_json::Value,
    },

    /// A protocol-level or runtime error.
    ///
    /// `id` is `None` for errors not attributable to a specific command
    /// (e.g. oversized frame, internal crash).
    #[serde(rename = "error")]
    Error {
        /// Correlation id of the command that caused the error, if any.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        id: Option<String>,
        /// Human-readable error description.
        message: String,
    },

    /// Emitted by the rpc child immediately after startup, before any
    /// commands are accepted.
    #[serde(rename = "ready")]
    Ready {
        /// Unique identifier for this session (UUID or similar).
        session_id: String,
        /// The model currently active.
        model: String,
        /// The protocol version implemented by this child.
        /// Must equal [`RPC_PROTOCOL_VERSION`] for the parent to proceed.
        protocol_version: u32,
    },
}

// ---------------------------------------------------------------------------
// Assistant streaming events
// ---------------------------------------------------------------------------

/// Granular events emitted by the assistant during a streaming turn.
///
/// Carried inside [`RpcEvent::MessageUpdate`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum AssistantEvent {
    /// An incremental text chunk from the assistant's response.
    #[serde(rename = "text_delta")]
    TextDelta {
        /// The text fragment.
        delta: String,
    },

    /// An incremental thinking/reasoning chunk (extended thinking mode).
    #[serde(rename = "thinking_delta")]
    ThinkingDelta {
        /// The thinking fragment.
        delta: String,
    },

    /// A tool call has started streaming.  Subsequent
    /// [`AssistantEvent::ToolcallInputDelta`] frames carry the JSON input.
    #[serde(rename = "toolcall_start")]
    ToolcallStart {
        /// Model-assigned opaque identifier for this tool call.
        tool_id: String,
        /// The tool being invoked.
        tool_name: String,
    },

    /// An incremental JSON fragment of a tool call's input.
    #[serde(rename = "toolcall_input_delta")]
    ToolcallInputDelta {
        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
        tool_id: String,
        /// Raw JSON fragment (not yet a complete object).
        delta: String,
    },

    /// The complete, finalised input for a tool call.
    #[serde(rename = "toolcall_input")]
    ToolcallInput {
        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
        tool_id: String,
        /// The fully parsed JSON input value.
        input: serde_json::Value,
    },

    /// The result returned by tool execution.
    #[serde(rename = "toolcall_result")]
    ToolcallResult {
        /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
        tool_id: String,
        /// The serialised tool result string.
        result: String,
    },
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::{from_str, json, to_string};

    // ── RpcCommand::ToolsList round-trip ────────────────────────────────────

    #[test]
    fn tools_list_no_id_serialises() {
        let cmd = RpcCommand::ToolsList { id: None };
        let json = to_string(&cmd).expect("serialise");
        let val: serde_json::Value = from_str(&json).unwrap();
        assert_eq!(val["type"], "tools_list");
        assert!(val.get("id").is_none(), "absent id must be omitted (skip_serializing_if)");
    }

    #[test]
    fn tools_list_with_id_serialises() {
        let cmd = RpcCommand::ToolsList { id: Some("req-42".to_string()) };
        let json = to_string(&cmd).expect("serialise");
        let val: serde_json::Value = from_str(&json).unwrap();
        assert_eq!(val["type"], "tools_list");
        assert_eq!(val["id"], "req-42");
    }

    #[test]
    fn tools_list_no_id_deserialises() {
        let line = r#"{"type":"tools_list"}"#;
        let cmd: RpcCommand = from_str(line).expect("deserialise");
        match cmd {
            RpcCommand::ToolsList { id } => assert!(id.is_none()),
            other => panic!("expected ToolsList, got {other:?}"),
        }
    }

    #[test]
    fn tools_list_with_id_deserialises() {
        let line = r#"{"type":"tools_list","id":"abc-123"}"#;
        let cmd: RpcCommand = from_str(line).expect("deserialise");
        match cmd {
            RpcCommand::ToolsList { id } => assert_eq!(id.as_deref(), Some("abc-123")),
            other => panic!("expected ToolsList, got {other:?}"),
        }
    }

    // ── Response body shape expected by the bridge ──────────────────────────

    /// The bridge validates: `response.ok === true && Array.isArray(response.tools)`.
    /// Verify the flattened RpcEvent::Response body produces exactly that shape.
    #[test]
    fn tools_list_response_body_shape_matches_bridge_expectation() {
        let tools_json = json!([
            {
                "name": "bash",
                "description": "Run a bash command",
                "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}}
            }
        ]);
        let ev = RpcEvent::Response {
            id: "req-1".to_string(),
            command: "tools_list".to_string(),
            body: json!({ "ok": true, "tools": tools_json }),
        };
        let serialised = to_string(&ev).expect("serialise");
        let val: serde_json::Value = from_str(&serialised).unwrap();

        assert_eq!(val["type"], "response");
        assert_eq!(val["id"], "req-1");
        assert_eq!(val["command"], "tools_list");
        // Bridge checks these two fields at the top level (flattened):
        assert_eq!(val["ok"], true, "bridge needs ok=true at top level");
        assert!(val["tools"].is_array(), "bridge needs tools array at top level");
        assert_eq!(val["tools"][0]["name"], "bash");
    }
}