Skip to main content

bamboo_subagent/
proto.rs

1//! Wire protocol: discovery record + parent/child WebSocket frames.
2//!
3//! The session/event payloads are kept opaque (`serde_json::Value`) so this crate stays a leaf;
4//! the real `AgentEvent` serializes into [`ChildFrame::Event`] verbatim (design §6, zero mapping).
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Tier-1 discovery record an actor publishes into the file fabric so others can find it.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct AgentRecord {
12    pub agent_id: String,
13    pub role: String,
14    #[serde(default)]
15    pub labels: Vec<String>,
16    /// `ws://127.0.0.1:<port>` reachable endpoint.
17    pub endpoint: String,
18    pub pid: u32,
19    #[serde(default)]
20    pub version: String,
21    pub started_at: DateTime<Utc>,
22    /// Lease: a reader treats the record as stale once `now > lease_expires_at`.
23    pub lease_expires_at: DateTime<Utc>,
24}
25
26/// A unit of work a parent assigns to an actor.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct RunSpec {
29    pub assignment: String,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub reasoning_effort: Option<String>,
32    /// Full prior conversation (serialized domain `Message`s, oldest first),
33    /// INCLUDING the assignment's user message when present. The actor's
34    /// durable state lives in the parent's store; each activation rehydrates
35    /// from here — this is what makes send_message/update/rerun carry context
36    /// across one-shot actor processes. Empty = first activation, no history.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub messages: Vec<serde_json::Value>,
39}
40
41/// Parent → child control/in-band frames.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum ParentFrame {
45    Run(RunSpec),
46    Cancel,
47    Message {
48        text: String,
49    },
50    /// Reply to a [`ChildFrame::ApprovalRequest`] — the host's human/policy
51    /// decision on a gated tool the worker proxied back (Phase 2 child→parent
52    /// approval delegation). `id` correlates to the request. When
53    /// `approved == true` the worker records the grant locally and proceeds;
54    /// `false` denies the tool.
55    ApprovalReply {
56        id: String,
57        approved: bool,
58    },
59}
60
61/// Child → parent event/terminal frames.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64pub enum ChildFrame {
65    /// One agent event, serialized verbatim (the real `AgentEvent` lands here as JSON).
66    Event { event: serde_json::Value },
67    /// The worker hit a tool needing human approval (Phase 2 child→parent
68    /// approval delegation). Proxied to the host — which surfaces it to the
69    /// human via the parent session's pending-question / notification path. The
70    /// host answers with [`ParentFrame::ApprovalReply`] carrying the same `id`.
71    /// `body` carries `{tool_name, permission_type, resource, question}`.
72    ApprovalRequest { id: String, body: serde_json::Value },
73    Terminal {
74        status: TerminalStatus,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        result: Option<String>,
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        error: Option<String>,
79        /// Full worker transcript (serialized domain `Message`s) shipped on
80        /// suspend so the host can persist it onto the child session and
81        /// rehydrate the worker on resume. Empty for non-suspend terminals.
82        #[serde(default, skip_serializing_if = "Vec::is_empty")]
83        transcript: Vec<serde_json::Value>,
84    },
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum TerminalStatus {
90    Completed,
91    Error,
92    Cancelled,
93    /// The worker's loop suspended (it spawned its own sub-agents and is waiting
94    /// on them). Non-terminal to the host: the completion coordinator resumes
95    /// the worker (re-dispatch) once its children finish.
96    Suspended,
97}
98
99impl ParentFrame {
100    pub fn to_text(&self) -> String {
101        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
102    }
103    pub fn from_text(s: &str) -> serde_json::Result<Self> {
104        serde_json::from_str(s)
105    }
106}
107
108impl ChildFrame {
109    pub fn to_text(&self) -> String {
110        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
111    }
112    pub fn from_text(s: &str) -> serde_json::Result<Self> {
113        serde_json::from_str(s)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn parent_frames_round_trip() {
123        for f in [
124            ParentFrame::Run(RunSpec {
125                assignment: "do x".into(),
126                reasoning_effort: None,
127                messages: Vec::new(),
128            }),
129            ParentFrame::Cancel,
130            ParentFrame::Message { text: "hi".into() },
131        ] {
132            assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
133        }
134    }
135
136    #[test]
137    fn child_frames_round_trip() {
138        let e = ChildFrame::Event {
139            event: serde_json::json!({"type":"token","content":"hi"}),
140        };
141        assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
142        let t = ChildFrame::Terminal {
143            status: TerminalStatus::Completed,
144            result: Some("done".into()),
145            error: None,
146            transcript: Vec::new(),
147        };
148        assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
149
150        // Suspend terminal carries the worker transcript.
151        let s = ChildFrame::Terminal {
152            status: TerminalStatus::Suspended,
153            result: None,
154            error: None,
155            transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
156        };
157        assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
158
159        // Phase 2 approval request/reply round-trip over the per-child WS.
160        let areq = ChildFrame::ApprovalRequest {
161            id: "a1".into(),
162            body: serde_json::json!({
163                "tool_name": "Write",
164                "permission_type": "WriteFile",
165                "resource": "/tmp/x",
166                "question": "approve?",
167            }),
168        };
169        assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
170        let areply = ParentFrame::ApprovalReply {
171            id: "a1".into(),
172            approved: true,
173        };
174        assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
175    }
176
177    #[test]
178    fn run_frame_tag_is_stable() {
179        let f = ParentFrame::Run(RunSpec {
180            assignment: "a".into(),
181            reasoning_effort: Some("high".into()),
182            messages: Vec::new(),
183        });
184        let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
185        assert_eq!(v["kind"], "run");
186        assert_eq!(v["assignment"], "a");
187    }
188
189    #[test]
190    fn run_frame_without_messages_parses_backward_compat() {
191        // An old-style frame (no `messages` field) must still parse.
192        let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
193        match parsed {
194            ParentFrame::Run(spec) => {
195                assert_eq!(spec.assignment, "x");
196                assert!(spec.messages.is_empty());
197            }
198            other => panic!("expected run frame, got {other:?}"),
199        }
200    }
201}