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 bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// Tier-1 discovery record an actor publishes into the file fabric so others can find it.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AgentRecord {
13    pub agent_id: String,
14    pub role: String,
15    #[serde(default)]
16    pub labels: Vec<String>,
17    /// `ws://127.0.0.1:<port>` reachable endpoint.
18    pub endpoint: String,
19    pub pid: u32,
20    #[serde(default)]
21    pub version: String,
22    pub started_at: DateTime<Utc>,
23    /// Lease: a reader treats the record as stale once `now > lease_expires_at`.
24    pub lease_expires_at: DateTime<Utc>,
25}
26
27/// A unit of work a parent assigns to an actor.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct RunSpec {
30    pub assignment: String,
31    /// Stable domain identity for the session being activated. Actor process,
32    /// mailbox, and pooled-worker ids are transport details and must never
33    /// replace these values in worker persistence or message routing.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub logical_session: Option<LogicalSessionIdentity>,
36    /// Stable Project identity inherited from the parent session. The typed
37    /// wire value rejects unsafe/invalid identifiers during deserialization.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub project_id: Option<ProjectId>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub reasoning_effort: Option<String>,
42    /// Effective permission policy captured by the host at this activation
43    /// boundary. Keeping it on `RunSpec` (rather than only provisioning) lets
44    /// warm, broker and remote workers observe policy revisions and bypass
45    /// changes on their next activation.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub permission_policy: Option<PermissionPolicyContext>,
48    /// Full prior conversation (serialized domain `Message`s, oldest first),
49    /// INCLUDING the assignment's user message when present. The actor's
50    /// durable state lives in the parent's store; each activation rehydrates
51    /// from here — this is what makes send_message/update/rerun carry context
52    /// across one-shot actor processes. Empty = first activation, no history.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub messages: Vec<serde_json::Value>,
55    /// Independently authoritative id of the host activation whose execution
56    /// this RunSpec starts. Initial and mid-run typed deliveries must match it;
57    /// a delivery's own run-id field is never accepted as self-authentication.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub activation_run_id: Option<String>,
60    /// Canonical logical-session deliveries that caused this idle actor
61    /// activation. The worker durably enqueues these before entering its first
62    /// provider boundary, then confirms admission over the child frame stream.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub initial_session_messages: Vec<SessionMessageDelivery>,
65    /// Secrets minted for this activation only. They are delivered in-memory
66    /// over the actor transport and must never be persisted by the worker.
67    #[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
68    pub secrets: RunSecrets,
69}
70
71/// Logical session ancestry carried across every actor placement.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct LogicalSessionIdentity {
74    pub session_id: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub parent_session_id: Option<String>,
77    pub root_session_id: String,
78}
79
80/// One canonical inbox claim forwarded to an active actor. The activation run
81/// id and claim generation make the worker's confirmation unambiguous even if
82/// a stale connection delivers a late frame after a successor has taken over.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct SessionMessageDelivery {
85    pub target_session_id: String,
86    pub envelope: SessionMessageEnvelope,
87    pub canonical_claim_generation: u64,
88    pub activation_run_id: String,
89    /// Durable host policy associated with the authorized claim prefix. The
90    /// worker mirrors it onto its local receipt before the safe-turn boundary.
91    #[serde(default)]
92    pub activation_policy: SessionActivationPolicy,
93}
94
95/// Worker proof that its local safe-turn path durably checkpointed and acked a
96/// forwarded envelope. The host still has to checkpoint the canonical logical
97/// transcript before it may ack the canonical claim.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct SessionMessageAdmissionConfirmation {
100    pub target_session_id: String,
101    pub envelope_id: String,
102    pub canonical_claim_generation: u64,
103    pub activation_run_id: String,
104}
105
106/// Per-activation secret envelope. A Bamboo-routed Codex token lives here so a
107/// warm worker never reuses a credential from an earlier run.
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109pub struct RunSecrets {
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub codex_provider_token: Option<SecretValue>,
112}
113
114impl RunSecrets {
115    pub fn is_empty(&self) -> bool {
116        self.codex_provider_token.is_none()
117    }
118}
119
120/// Serializable secret whose debug representation is always redacted.
121#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(transparent)]
123pub struct SecretValue(String);
124
125impl SecretValue {
126    pub fn new(value: impl Into<String>) -> Self {
127        Self(value.into())
128    }
129
130    pub fn expose(&self) -> &str {
131        &self.0
132    }
133}
134
135impl std::fmt::Debug for SecretValue {
136    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        formatter.write_str("SecretValue([REDACTED])")
138    }
139}
140
141/// Host-computed permission state for one actor activation. The policy payload
142/// is opaque here so `bamboo-subagent` remains a transport leaf.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct PermissionPolicyContext {
145    pub revision: u64,
146    pub bypass_permissions: bool,
147    pub session_id: String,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub workspace_path: Option<String>,
150    /// Session grants are deliberately not inherited across an actor boundary;
151    /// a future opt-in protocol can set this and carry explicit scoped grants.
152    #[serde(default)]
153    pub inherit_session_grants: bool,
154    pub policy: serde_json::Value,
155}
156
157/// Parent → child control/in-band frames.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(tag = "kind", rename_all = "snake_case")]
160pub enum ParentFrame {
161    Run(RunSpec),
162    Cancel,
163    Message {
164        text: String,
165    },
166    SessionMessage {
167        delivery: SessionMessageDelivery,
168    },
169    /// Reply to a [`ChildFrame::ApprovalRequest`] — the host's human/policy
170    /// decision on a gated tool the worker proxied back (Phase 2 child→parent
171    /// approval delegation). `id` correlates to the request. When
172    /// `approved == true` the worker records the grant locally and proceeds;
173    /// `false` denies the tool.
174    ApprovalReply {
175        id: String,
176        approved: bool,
177    },
178}
179
180/// Child → parent event/terminal frames.
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(tag = "kind", rename_all = "snake_case")]
183pub enum ChildFrame {
184    /// One agent event, serialized verbatim (the real `AgentEvent` lands here as JSON).
185    Event { event: serde_json::Value },
186    /// The worker hit a tool needing human approval (Phase 2 child→parent
187    /// approval delegation). Proxied to the host — which surfaces it to the
188    /// human via the parent session's pending-question / notification path. The
189    /// host answers with [`ParentFrame::ApprovalReply`] carrying the same `id`.
190    /// `body` carries `{tool_name, permission_type, resource, question}`.
191    ApprovalRequest { id: String, body: serde_json::Value },
192    /// Emitted only after the worker's local SessionInbox transcript + cursor
193    /// checkpoint and admitted receipt are durable.
194    SessionMessageAdmitted {
195        confirmation: SessionMessageAdmissionConfirmation,
196    },
197    Terminal {
198        status: TerminalStatus,
199        #[serde(default, skip_serializing_if = "Option::is_none")]
200        result: Option<String>,
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        error: Option<String>,
203        /// Full worker transcript (serialized domain `Message`s) shipped on
204        /// suspend so the host can persist it onto the child session and
205        /// rehydrate the worker on resume. Empty for non-suspend terminals.
206        #[serde(default, skip_serializing_if = "Vec::is_empty")]
207        transcript: Vec<serde_json::Value>,
208    },
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum TerminalStatus {
214    Completed,
215    Error,
216    Cancelled,
217    /// The worker's loop suspended (it spawned its own sub-agents and is waiting
218    /// on them). Non-terminal to the host: the completion coordinator resumes
219    /// the worker (re-dispatch) once its children finish.
220    Suspended,
221}
222
223impl ParentFrame {
224    pub fn to_text(&self) -> String {
225        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
226    }
227    pub fn from_text(s: &str) -> serde_json::Result<Self> {
228        serde_json::from_str(s)
229    }
230}
231
232impl ChildFrame {
233    pub fn to_text(&self) -> String {
234        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
235    }
236    pub fn from_text(s: &str) -> serde_json::Result<Self> {
237        serde_json::from_str(s)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn parent_frames_round_trip() {
247        for f in [
248            ParentFrame::Run(RunSpec {
249                assignment: "do x".into(),
250                logical_session: None,
251                project_id: None,
252                reasoning_effort: None,
253                permission_policy: None,
254                messages: Vec::new(),
255                activation_run_id: None,
256                initial_session_messages: Vec::new(),
257                secrets: Default::default(),
258            }),
259            ParentFrame::Cancel,
260            ParentFrame::Message { text: "hi".into() },
261        ] {
262            assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
263        }
264    }
265
266    #[test]
267    fn child_frames_round_trip() {
268        let e = ChildFrame::Event {
269            event: serde_json::json!({"type":"token","content":"hi"}),
270        };
271        assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
272        let t = ChildFrame::Terminal {
273            status: TerminalStatus::Completed,
274            result: Some("done".into()),
275            error: None,
276            transcript: Vec::new(),
277        };
278        assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
279
280        // Suspend terminal carries the worker transcript.
281        let s = ChildFrame::Terminal {
282            status: TerminalStatus::Suspended,
283            result: None,
284            error: None,
285            transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
286        };
287        assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
288
289        // Phase 2 approval request/reply round-trip over the per-child WS.
290        let areq = ChildFrame::ApprovalRequest {
291            id: "a1".into(),
292            body: serde_json::json!({
293                "tool_name": "Write",
294                "permission_type": "WriteFile",
295                "resource": "/tmp/x",
296                "question": "approve?",
297            }),
298        };
299        assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
300        let areply = ParentFrame::ApprovalReply {
301            id: "a1".into(),
302            approved: true,
303        };
304        assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
305    }
306
307    #[test]
308    fn run_frame_tag_is_stable() {
309        let f = ParentFrame::Run(RunSpec {
310            assignment: "a".into(),
311            logical_session: None,
312            project_id: None,
313            reasoning_effort: Some("high".into()),
314            permission_policy: None,
315            messages: Vec::new(),
316            activation_run_id: None,
317            initial_session_messages: Vec::new(),
318            secrets: Default::default(),
319        });
320        let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
321        assert_eq!(v["kind"], "run");
322        assert_eq!(v["assignment"], "a");
323        assert!(v.get("secrets").is_none());
324    }
325
326    #[test]
327    fn run_secret_round_trips_but_debug_output_is_redacted() {
328        let secret = SecretValue::new("bcx1_secret-570");
329        assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
330        assert!(!format!(
331            "{:?}",
332            RunSecrets {
333                codex_provider_token: Some(secret.clone()),
334            }
335        )
336        .contains("secret-570"));
337
338        let frame = ParentFrame::Run(RunSpec {
339            assignment: "a".into(),
340            logical_session: None,
341            project_id: None,
342            reasoning_effort: None,
343            permission_policy: None,
344            messages: Vec::new(),
345            activation_run_id: None,
346            initial_session_messages: Vec::new(),
347            secrets: RunSecrets {
348                codex_provider_token: Some(secret),
349            },
350        });
351        let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
352        assert_eq!(decoded, frame);
353    }
354
355    #[test]
356    fn permission_policy_context_round_trips_at_run_boundary() {
357        let context = PermissionPolicyContext {
358            revision: 9,
359            bypass_permissions: true,
360            session_id: "child-1".into(),
361            workspace_path: Some("/workspace/project".into()),
362            inherit_session_grants: false,
363            policy: serde_json::json!({"enabled":true,"durable_rules":[]}),
364        };
365        let frame = ParentFrame::Run(RunSpec {
366            assignment: "work".into(),
367            logical_session: None,
368            project_id: None,
369            reasoning_effort: None,
370            permission_policy: Some(context.clone()),
371            messages: Vec::new(),
372            activation_run_id: None,
373            initial_session_messages: Vec::new(),
374            secrets: Default::default(),
375        });
376        let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
377        assert_eq!(decoded, frame);
378        let ParentFrame::Run(run) = decoded else {
379            panic!("expected run frame");
380        };
381        assert_eq!(run.permission_policy, Some(context));
382    }
383
384    #[test]
385    fn run_frame_without_messages_parses_backward_compat() {
386        // An old-style frame (no `messages` field) must still parse.
387        let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
388        match parsed {
389            ParentFrame::Run(spec) => {
390                assert_eq!(spec.assignment, "x");
391                assert!(spec.messages.is_empty());
392            }
393            other => panic!("expected run frame, got {other:?}"),
394        }
395    }
396
397    #[test]
398    fn run_frame_round_trips_typed_project_identity() {
399        let frame = ParentFrame::Run(RunSpec {
400            assignment: "work".into(),
401            logical_session: None,
402            project_id: Some(ProjectId::parse("project-1").unwrap()),
403            reasoning_effort: None,
404            permission_policy: None,
405            messages: Vec::new(),
406            activation_run_id: None,
407            initial_session_messages: Vec::new(),
408            secrets: Default::default(),
409        });
410
411        let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
412        assert_eq!(decoded, frame);
413    }
414
415    #[test]
416    fn run_frame_rejects_unsafe_project_identity() {
417        let error =
418            ParentFrame::from_text(r#"{"kind":"run","assignment":"x","project_id":"../other"}"#)
419                .unwrap_err();
420
421        assert!(error.to_string().contains("invalid project id"));
422    }
423}