Skip to main content

wcode_harness/
protocol.rs

1//! The kernel's request/event interface — one protocol for every peer.
2//!
3//! The kernel already exposes one half of a protocol: [`AgentEvent`] is a
4//! typed, serializable stream of everything a session does (text deltas, tool
5//! lifecycle, turn boundaries). This module adds the inbound half, [`Request`],
6//! and a versioned, addressed envelope, [`Frame`], so that a client and a peer
7//! become the same kind of thing:
8//!
9//! - a **request** goes into a session's mailbox,
10//! - an **event** comes out of its stream.
11//!
12//! A TUI client, a script, and another session differ only in which address
13//! ([`Frame::session`]) they hold and how they render the stream — not in the
14//! protocol they speak. That is what lets the client seam and the future
15//! agent-to-agent seam share one implementation.
16//!
17//! Transport is deliberately absent: today the seam is an in-process
18//! `tokio::mpsc`; framing and sockets live above the kernel (a future
19//! `wcode-protocol` crate). This module is serde types only, so it adds no
20//! behavior — it is the shape the actor (S1) and the socket (S2) will speak.
21//!
22//! See `docs/interface-protocol-brainstorm.md` for the design and the staged
23//! path that lands these types.
24
25use serde::{Deserialize, Serialize};
26
27use crate::event::AgentEvent;
28
29/// Wire-protocol major version. Bumped only by a breaking change; additive
30/// changes leave it alone. [`Request`] already carries a [`Request::Unknown`]
31/// fallback so an older peer survives a newer request; the matching catch-all
32/// on the event side lands with the actor (S1), where an unknown event can be
33/// handled rather than silently dropped.
34pub const PROTOCOL_VERSION: u32 = 1;
35
36/// The well-known address of the human peer (brainstorm §5.2): the sender an
37/// in-process actor attributes an inbound message to until addressing lands
38/// (S4-2).
39pub const USER: &str = "user";
40
41/// Whether `request` is an A2A inbound message — the delivery verbs a peer sends
42/// (`Notify`/`Interrupt`/`Wake`), as opposed to a local command. The actor runs
43/// its `before_inbound` policy on exactly these.
44pub fn is_inbound(request: &Request) -> bool {
45    matches!(
46        request,
47        Request::Notify { .. } | Request::Interrupt { .. } | Request::Wake { .. }
48    )
49}
50
51/// A session's stable address.
52///
53/// Reuses the id already written in `SessionEntry::Header`, so a session is
54/// reachable by the same name it persists under. Transitively serialized as a
55/// bare string. Newtypes a plain `String` only to keep addressing legible at
56/// call sites — the conventions (the human is `"user"`, a peer is
57/// `"agent:<id>"`) are not encoded in the type yet.
58#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct SessionId(pub String);
61
62impl SessionId {
63    pub fn new(id: impl Into<String>) -> Self {
64        Self(id.into())
65    }
66
67    /// The well-known address of the human peer (see [`USER`]).
68    pub fn user() -> Self {
69        Self(USER.to_string())
70    }
71
72    /// A peer session's address: `"agent:<id>"` (§5.2).
73    pub fn agent(id: impl AsRef<str>) -> Self {
74        Self(format!("agent:{}", id.as_ref()))
75    }
76
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82impl From<String> for SessionId {
83    fn from(id: String) -> Self {
84        Self(id)
85    }
86}
87
88impl From<&str> for SessionId {
89    fn from(id: &str) -> Self {
90        Self(id.to_string())
91    }
92}
93
94impl std::fmt::Display for SessionId {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100/// A served session's address plus the little metadata a roster shows: today
101/// only its **effective model**. Carried by
102/// [`crate::event::AgentEvent::Sessions`], so a client can label each member by
103/// its own model instead of substituting the root's.
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct SessionInfo {
106    pub id: SessionId,
107    /// The session's effective model id, when the server knows one (a worker
108    /// registered with its model); `None` for a peer whose model is unknown.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub model: Option<String>,
111}
112
113/// Inbound intent: the requests a session's mailbox accepts.
114///
115/// Every variant maps one-to-one onto an existing [`crate::agent::Agent`]
116/// method, so the current session behavior is expressible without the actor
117/// inventing anything new:
118///
119/// | `Request` | today |
120/// |-----------|-------|
121/// | `Submit` | `Agent::run` |
122/// | `Notify` | `Agent::notify` (append, no turn) |
123/// | `Interrupt` | `Agent::steer` |
124/// | `Wake` | run — a turn even when idle |
125/// | `Cancel` | `Agent::cancel` |
126/// | `SetModel` | `Agent::set_model` |
127/// | `SetEffort` | `Agent::set_effort` |
128/// | `Compact` | `Agent::compact` |
129///
130/// The one exception is [`Request::ListSessions`]: a transport-level probe the
131/// server answers, not an `Agent` method.
132///
133/// Inbound payloads carry user **content**, not an `AgentMessage`: a remote peer
134/// must not be able to inject an `Assistant`/`ToolResult` message and impersonate
135/// the model. The session wraps content as a user message, exactly as
136/// `Agent::run` does. `GetHistory` is a read: it carries no payload and its
137/// answer comes back as [`AgentEvent::History`]
138/// (with the other replies — [`AgentEvent::Ack`],
139/// and [`AgentEvent::Error`] — alongside it).
140/// These reply variants are never *streamed*: they answer a specific request,
141/// which the transport correlates (an in-process oneshot today, the envelope's
142/// `reply_to` on the wire).
143#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum Request {
146    /// Start a turn: persist the text as a user message and run the loop until
147    /// it stops. The run's progress and result stream back as [`AgentEvent`]s.
148    Submit { text: String },
149
150    /// Append `content` to the conversation *without* starting a turn (recorded
151    /// now if idle, or at the next turn boundary if a run is in flight). The
152    /// "tell, don't ask" delivery mode — `Agent::notify`.
153    Notify { content: String },
154
155    /// Inject `content` at the next turn boundary of the in-flight run — a soft
156    /// interrupt that does not cancel the run. Formerly `Steer`; `"steer"`
157    /// still deserializes here.
158    #[serde(alias = "steer")]
159    Interrupt { content: String },
160
161    /// Wake the session: run `content` as a turn **even if it is idle** (mid-run
162    /// it rides the follow-up channel instead). Formerly `FollowUp`;
163    /// `"follow_up"` still deserializes here.
164    /// `"follow_up"` still deserializes here.
165    #[serde(alias = "follow_up")]
166    Wake { content: String },
167
168    /// Cancel the in-flight run (and any retry/backoff wait). Idempotent.
169    Cancel,
170
171    /// Swap the model for subsequent turns.
172    SetModel { model: String },
173
174    /// Set the reasoning effort, or clear it (`None` = send nothing).
175    SetEffort { effort: Option<String> },
176
177    /// Summarize the older prefix of the conversation now, optionally focused
178    /// by `instructions`.
179    Compact { instructions: Option<String> },
180
181    /// Toggle plan mode. The actor flips the agent's shared `PlanModeHandle`
182    /// (removing mutating tools + mutating `bash`) and recomposes the system
183    /// prompt. Infallible — replies [`AgentEvent::Ack`].
184    SetPlanMode { on: bool },
185
186    /// Ask a side question (`/btw`): answer `text` tool-free, from the current
187    /// context, WITHOUT recording it. A read — carries user content, names no
188    /// `AgentMessage` (see the enum doc above). Reply:
189    /// [`AgentEvent::SideAnswer`]. A `SideAsk` arriving mid-run is deferred to
190    /// the next turn boundary (like any other inbox request), not refused.
191    SideAsk { text: String },
192    /// Read back the conversation so far. Reply:
193    /// [`AgentEvent::History`].
194    GetHistory,
195
196    /// Ask the **transport** for the sessions a server serves — serve order,
197    /// root first. The one request that names no `Agent` method: an in-process
198    /// session has no roster, so the actor answers it with [`AgentEvent::Ack`]
199    /// (like [`Request::Unknown`]); a socket server intercepts it and replies
200    /// [`AgentEvent::Sessions`].
201    ListSessions,
202
203    /// Define a worker **on a served peer** (§8, S4). Like [`Request::ListSessions`]
204    /// it names no `Agent` method: a socket server intercepts it and asks an
205    /// injected handler to build a session, replying [`AgentEvent::Spawned`] with
206    /// its address (or a correlated [`AgentEvent::Error`]). Mirrors the CLI's
207    /// worker spec, so a peer can customize the worker's model, role, tool set,
208    /// `read_only`, `effort`, and provider. An in-process session has no
209    /// factory, so the actor answers
210    /// it with [`AgentEvent::Error`].
211    Define {
212        /// The worker's address; auto-assigned (`w1`, `w2`, …) when absent.
213        name: Option<String>,
214        /// Model id override; `None` inherits the server's model.
215        model: Option<String>,
216        /// Role text appended to the worker's system prompt.
217        role: Option<String>,
218        /// Tool allow-list; `None` = the full default set.
219        tools: Option<Vec<String>>,
220        /// Provider base URL override; `None` inherits the server's.
221        base_url: Option<String>,
222        /// Provider API key override; `None` inherits the server's.
223        api_key: Option<String>,
224        /// Enforce read-only for the defined worker (D1/D2); default false.
225        #[serde(default)]
226        read_only: bool,
227        /// Reasoning-effort override (D3); `None` inherits, synonyms clear.
228        #[serde(default)]
229        effort: Option<String>,
230    },
231
232    /// Forward-compatibility catch-all: an older peer must skip a request it
233    /// does not understand rather than fail the connection.
234    #[serde(other)]
235    Unknown,
236}
237
238/// Versioned, addressed envelope around a [`Request`] or an [`AgentEvent`].
239///
240/// Generic over the body so the same frame carries either direction: the
241/// request-to-a-session frame and the event-from-a-session frame have identical
242/// headers. The body is `#[serde(flatten)]`ed, so on the wire a frame is one
243/// flat JSON object — `{"v":1,"id":7,"session":"s1","type":"cancel"}`.
244#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
245pub struct Frame<P> {
246    /// Protocol major version ([`PROTOCOL_VERSION`]).
247    pub v: u32,
248    /// Sender-assigned id, monotonic per connection; answers echo it in
249    /// `reply_to`.
250    pub id: u64,
251    /// The request this frame answers, if any. Streaming events that are not
252    /// direct replies leave it absent.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub reply_to: Option<u64>,
255    /// The session this frame concerns — the target of a request, the origin of
256    /// an event. The whole addressing scheme.
257    pub session: SessionId,
258    /// The sender's address, when it is not the transport's own client (an
259    /// in-process handle leaves it unset; an A2A peer sets its `"agent:<id>"`).
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub sender: Option<SessionId>,
262    #[serde(flatten)]
263    pub body: P,
264}
265
266impl<P> Frame<P> {
267    /// A fresh top-level frame at the current version, not a reply.
268    pub fn new(id: u64, session: SessionId, body: P) -> Self {
269        Self {
270            v: PROTOCOL_VERSION,
271            id,
272            reply_to: None,
273            session,
274            sender: None,
275            body,
276        }
277    }
278}
279
280/// A client-to-session frame.
281pub type RequestFrame = Frame<Request>;
282/// A session-to-client frame.
283pub type EventFrame = Frame<AgentEvent>;
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::event::{TodoItem, TodoStatus};
289    use serde::de::DeserializeOwned;
290    use serde_json::json;
291
292    fn roundtrip<P>(p: P, expected_tag: &str)
293    where
294        P: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
295    {
296        let v = serde_json::to_value(&p).unwrap();
297        assert_eq!(v["type"], expected_tag);
298        let back: P = serde_json::from_value(v).unwrap();
299        assert_eq!(back, p);
300    }
301
302    #[test]
303    fn request_roundtrip_all_variants() {
304        roundtrip(Request::Submit { text: "hi".into() }, "submit");
305        roundtrip(Request::Cancel, "cancel");
306        roundtrip(Request::SetModel { model: "m1".into() }, "set_model");
307        roundtrip(
308            Request::SetEffort {
309                effort: Some("high".into()),
310            },
311            "set_effort",
312        );
313        roundtrip(Request::SetEffort { effort: None }, "set_effort");
314        roundtrip(
315            Request::Compact {
316                instructions: Some("focus on the parser".into()),
317            },
318            "compact",
319        );
320        roundtrip(Request::Compact { instructions: None }, "compact");
321        roundtrip(Request::SetPlanMode { on: true }, "set_plan_mode");
322        roundtrip(Request::Notify { content: "n".into() }, "notify");
323        roundtrip(Request::Interrupt { content: "i".into() }, "interrupt");
324        roundtrip(Request::Wake { content: "w".into() }, "wake");
325        roundtrip(Request::SideAsk { text: "why?".into() }, "side_ask");
326        roundtrip(Request::ListSessions, "list_sessions");
327        roundtrip(
328            Request::Define {
329                name: Some("w1".into()),
330                model: Some("m".into()),
331                role: Some("reviewer".into()),
332                tools: Some(vec!["read".into()]),
333                base_url: Some("http://w/v1".into()),
334                api_key: Some("k".into()),
335                read_only: true,
336                effort: Some("high".into()),
337            },
338            "define",
339        );
340        roundtrip(
341            Request::Define {
342                name: None,
343                model: None,
344                role: None,
345                tools: None,
346                base_url: None,
347                api_key: None,
348                read_only: false,
349                effort: None,
350            },
351            "define",
352        );
353    }
354
355    #[test]
356    fn legacy_verb_tags_deserialize_to_canonical_variants() {
357        let steer: Request = serde_json::from_str(r#"{"type":"steer","content":"s"}"#).unwrap();
358        assert_eq!(steer, Request::Interrupt { content: "s".into() });
359        let follow: Request = serde_json::from_str(r#"{"type":"follow_up","content":"f"}"#).unwrap();
360        assert_eq!(follow, Request::Wake { content: "f".into() });
361    }
362
363    #[test]
364    fn request_unknown_tag_is_caught_not_fatal() {
365        let back: Request = serde_json::from_str(r#"{"type":"from_the_future"}"#).unwrap();
366        assert_eq!(back, Request::Unknown);
367    }
368
369    #[test]
370    fn is_inbound_marks_only_the_delivery_verbs() {
371        assert!(is_inbound(&Request::Notify { content: "x".into() }));
372        assert!(is_inbound(&Request::Interrupt { content: "x".into() }));
373        assert!(is_inbound(&Request::Wake { content: "x".into() }));
374        assert!(!is_inbound(&Request::Submit { text: "x".into() }));
375        assert!(!is_inbound(&Request::GetHistory));
376        assert!(!is_inbound(&Request::SideAsk { text: "x".into() }));
377        assert!(!is_inbound(&Request::SetPlanMode { on: true }));
378        assert!(!is_inbound(&Request::Cancel));
379        assert!(!is_inbound(&Request::ListSessions));
380        assert!(!is_inbound(&Request::Define {
381            name: None,
382            model: None,
383            role: None,
384            tools: None,
385            base_url: None,
386            api_key: None,
387            read_only: false,
388            effort: None,
389        }));
390    }
391
392    #[test]
393    fn frame_flattens_body_and_omits_absent_reply_to() {
394        let f = Frame::new(7, SessionId::new("s1"), Request::Cancel);
395        let v = serde_json::to_value(&f).unwrap();
396        assert_eq!(
397            v,
398            json!({"v": PROTOCOL_VERSION, "id": 7, "session": "s1", "type": "cancel"})
399        );
400        let back: Frame<Request> = serde_json::from_value(v).unwrap();
401        assert_eq!(back, f);
402    }
403
404    #[test]
405    fn frame_carries_reply_to_and_an_event_body() {
406        let f = EventFrame {
407            v: PROTOCOL_VERSION,
408            id: 8,
409            reply_to: Some(7),
410            session: SessionId::from("s1"),
411            sender: None,
412            body: AgentEvent::AgentEnd,
413        };
414        let v = serde_json::to_value(&f).unwrap();
415        assert_eq!(
416            v,
417            json!({"v": PROTOCOL_VERSION, "id": 8, "reply_to": 7, "session": "s1", "type": "agent_end"})
418        );
419        // `AgentEvent` is not `PartialEq` (see event.rs); compare re-serialized
420        // JSON, matching that module's round-trip convention.
421        let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
422        assert_eq!(serde_json::to_value(&back).unwrap(), v);
423    }
424
425    #[test]
426    fn frame_carries_a_todo_event_body() {
427        let f = EventFrame {
428            v: PROTOCOL_VERSION,
429            id: 9,
430            reply_to: None,
431            session: SessionId::from("s1"),
432            sender: None,
433            body: AgentEvent::Todo {
434                todos: vec![TodoItem {
435                    content: "write tests".into(),
436                    status: TodoStatus::Pending,
437                }],
438            },
439        };
440        let v = serde_json::to_value(&f).unwrap();
441        assert_eq!(
442            v,
443            json!({
444                "v": PROTOCOL_VERSION, "id": 9, "session": "s1", "type": "todo",
445                "todos": [{"content": "write tests", "status": "pending"}],
446            })
447        );
448        let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
449        assert_eq!(serde_json::to_value(&back).unwrap(), v);
450    }
451
452    #[test]
453    fn session_id_serializes_transparently() {
454        assert_eq!(
455            serde_json::to_value(SessionId::new("abc")).unwrap(),
456            json!("abc")
457        );
458        assert_eq!(SessionId::new("abc").as_str(), "abc");
459        assert_eq!(SessionId::new("abc").to_string(), "abc");
460        assert_eq!(SessionId::from("x"), SessionId(String::from("x")));
461        assert_eq!(SessionId::user().as_str(), USER);
462        assert_eq!(SessionId::agent("abc").as_str(), "agent:abc");
463    }
464}