bamboo_engine/ticket_worker_plan/
remote.rs1use super::*;
4use bamboo_subagent::executor::HostBridge;
5use bamboo_tickets::WorkContextPacket;
6use serde::{Deserialize, Serialize};
7
8pub const TICKET_BOOTSTRAP_POSTURE_PENDING: &str = "ticket_bootstrap_posture_pending";
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct PlanProjection {
13 pub assignment_id: String,
14 pub generation: u64,
15 pub contract_revision: u64,
16 pub packet_hash: String,
17 pub binding: bamboo_tickets::ScopeBinding,
18 pub task_list: Option<TaskList>,
19 pub plan_revision: u64,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub question: Option<bamboo_tickets::PendingRequest>,
22}
23
24pub struct RemoteWorkerPlan {
25 host: HostBridge,
26 packet: WorkContextPacket,
27 session_id: String,
28 run_id: String,
29 initial: PlanProjection,
30}
31
32impl RemoteWorkerPlan {
33 pub async fn from_run(
34 run: &bamboo_subagent::proto::RunSpec,
35 host: Option<HostBridge>,
36 ) -> Result<Option<Self>> {
37 let mut packets = run
38 .messages
39 .iter()
40 .filter(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
41 .filter_map(|m| m.get("metadata")?.get(TICKET_PLAN_PACKET_KEY));
42 let Some(raw) = packets.next() else {
43 return Ok(None);
44 };
45 if packets.next().is_some() {
46 return Err(Error::ScopeDenied("multiple LocalPlan packets".into()));
47 }
48 let packet: WorkContextPacket = serde_json::from_value(raw.clone())?;
49 let identity = run
50 .logical_session
51 .as_ref()
52 .filter(|i| i.creation.is_some())
53 .ok_or_else(|| {
54 Error::ScopeDenied("native LocalPlan needs exact Child creation".into())
55 })?;
56 if identity.parent_session_id.as_deref() != Some(&packet.binding.supervisor_session_id) {
57 return Err(Error::ScopeDenied(
58 "native LocalPlan Supervisor differs".into(),
59 ));
60 }
61 let run_id = run
62 .activation_run_id
63 .clone()
64 .filter(|id| !id.is_empty())
65 .ok_or_else(|| Error::ScopeDenied("native LocalPlan needs Host Run".into()))?;
66 let host = host.ok_or_else(|| {
67 Error::AuthorityUnavailable("native LocalPlan HostBridge missing".into())
68 })?;
69 Self::from_host_packet(host, packet, identity.session_id.clone(), run_id)
70 .await
71 .map(Some)
72 }
73
74 pub async fn from_host_packet(
75 host: HostBridge,
76 packet: WorkContextPacket,
77 session_id: String,
78 run_id: String,
79 ) -> Result<Self> {
80 if session_id.is_empty() || run_id.is_empty() {
81 return Err(Error::ScopeDenied(
82 "invalid native LocalPlan binding".into(),
83 ));
84 }
85 bamboo_tickets::validate_native_tool_ceiling(
86 packet.contract.allowed_tools.iter().map(String::as_str),
87 )?;
88 let request = serde_json::json!({(TICKET_PLAN_ACTION): {"read":true}});
92 let mut attempts = 0;
93 let data = loop {
94 match host
95 .subagent_call(request.clone(), "ticket-plan-bootstrap")
96 .await
97 {
98 Ok(data) => break data,
99 Err(error) if error == TICKET_BOOTSTRAP_POSTURE_PENDING && attempts < 16 => {
100 attempts += 1;
101 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
102 }
103 Err(error) => return Err(Error::AuthorityUnavailable(error)),
104 }
105 };
106 let initial: PlanProjection = serde_json::from_value(data)?;
107 if initial.assignment_id != packet.assignment_id
108 || initial.generation != packet.generation
109 || initial.contract_revision != packet.contract_revision
110 || initial.binding != packet.binding
111 || initial.packet_hash != packet_fingerprint(&packet)?
112 {
113 return Err(Error::ScopeDenied(
114 "LocalPlan packet differs from Host capability".into(),
115 ));
116 }
117 if initial
118 .task_list
119 .as_ref()
120 .is_some_and(|plan| plan.session_id != session_id)
121 {
122 return Err(Error::ScopeDenied("Host returned another plan".into()));
123 }
124 Ok(Self {
125 host,
126 packet,
127 session_id,
128 run_id,
129 initial,
130 })
131 }
132
133 fn validate(&self, session: &Session) -> Result<()> {
134 if session.kind != SessionKind::Child
135 || session.id != self.session_id
136 || session.parent_session_id.as_deref()
137 != Some(&self.packet.binding.supervisor_session_id)
138 || session
139 .agent_runtime_state
140 .as_ref()
141 .is_none_or(|s| s.run_id != self.run_id)
142 {
143 return Err(Error::ScopeDenied(
144 "native LocalPlan identity changed".into(),
145 ));
146 }
147 Ok(())
148 }
149}
150
151#[async_trait::async_trait]
152impl WorkerLocalPlan for RemoteWorkerPlan {
153 fn run_id(&self) -> &str {
154 &self.run_id
155 }
156 fn bind_session(&self, session: &mut Session) -> Result<()> {
157 self.validate(session)?;
158 session.metadata.insert(
159 TICKET_LOCAL_PLAN_KEY.into(),
160 self.packet.assignment_id.clone(),
161 );
162 session.task_list = self.initial.task_list.clone();
163 session.set_task_list_version_meta(self.initial.plan_revision.to_string());
164 Ok(())
165 }
166 async fn apply_task(
167 &self,
168 session: &mut Session,
169 call_id: &str,
170 args: &serde_json::Value,
171 ) -> Result<TaskList> {
172 self.validate(session)?;
173 if session.metadata.get(TICKET_LOCAL_PLAN_KEY) != Some(&self.packet.assignment_id) {
174 return Err(Error::ScopeDenied(
175 "native LocalPlan capability missing".into(),
176 ));
177 }
178 let data = self
179 .host
180 .subagent_call(
181 serde_json::json!({(TICKET_PLAN_ACTION): {"task":args}}),
182 call_id,
183 )
184 .await
185 .map_err(Error::AuthorityUnavailable)?;
186 let projection: PlanProjection = serde_json::from_value(data)?;
187 if projection.assignment_id != self.packet.assignment_id
188 || projection.generation != self.packet.generation
189 || projection.contract_revision != self.packet.contract_revision
190 || projection.binding != self.packet.binding
191 || projection.packet_hash != packet_fingerprint(&self.packet)?
192 {
193 return Err(Error::ScopeDenied(
194 "Host LocalPlan capability changed".into(),
195 ));
196 }
197 let plan = projection
198 .task_list
199 .filter(|p| p.session_id == self.session_id)
200 .ok_or_else(|| Error::ScopeDenied("Host omitted the exact plan".into()))?;
201 if projection.plan_revision
202 < session
203 .task_list_version_meta()
204 .and_then(|v| v.parse().ok())
205 .unwrap_or(0)
206 {
207 return Err(Error::RevisionConflict);
208 }
209 session.task_list = Some(plan.clone());
210 session.set_task_list_version_meta(projection.plan_revision.to_string());
211 if let Some(q) = projection.question {
212 if q.kind != bamboo_tickets::RequestKind::Question
213 || q.work_id != self.packet.contract_ref
214 || q.assignment_id.as_deref() != Some(self.packet.assignment_id.as_str())
215 || q.generation != self.packet.generation
216 || q.contract_revision != self.packet.contract_revision
217 || q.worker_packet_hash.as_deref() != Some(projection.packet_hash.as_str())
218 {
219 return Err(Error::ScopeDenied("Host question binding changed".into()));
220 }
221 session
222 .metadata
223 .insert(TICKET_QUESTION_YIELD_KEY.into(), serde_json::to_string(&q)?);
224 }
225 Ok(plan)
226 }
227}
228
229pub fn apply_host_plan_request(
232 service: &TicketService,
233 caller: &Session,
234 run_id: &str,
235 args: &serde_json::Value,
236 call_id: &str,
237) -> Result<PlanProjection> {
238 let body = args
239 .as_object()
240 .filter(|o| o.len() == 1)
241 .and_then(|o| o.get(TICKET_PLAN_ACTION))
242 .and_then(|v| v.as_object())
243 .filter(|o| o.len() == 1)
244 .ok_or_else(|| Error::InvalidTransition("invalid private Task request".into()))?;
245 let assignment = caller
246 .metadata
247 .get(TICKET_LOCAL_PLAN_KEY)
248 .ok_or_else(|| Error::ScopeDenied("Child has no LocalPlan capability".into()))?;
249 let plan = TicketWorkerPlan::from_receipt_identity(Arc::new(service.clone()), assignment)?;
250 if plan.run_id() != run_id || plan.session_id != caller.id {
251 return Err(Error::ScopeDenied(
252 "Host Run differs from admission receipt".into(),
253 ));
254 }
255 let mut projection = caller.clone();
256 projection
257 .agent_runtime_state
258 .get_or_insert_with(Default::default)
259 .run_id = run_id.into();
260 plan.project_session(&mut projection)?;
261 let saved_question = service.published()?.1.assignments[assignment]
262 .awaiting_request
263 .clone();
264 let packet_hash = if let Some(request) = saved_question {
265 service.published()?.1.requests[&request]
266 .worker_packet_hash
267 .clone()
268 .ok_or_else(|| Error::AuthorityUnavailable("question packet hash missing".into()))?
269 } else {
270 packet_fingerprint(&service.child_context_packet(&plan.authority, assignment, 65536)?)?
271 };
272 if body.get("read") != Some(&serde_json::Value::Bool(true)) {
273 let task = body
274 .get("task")
275 .ok_or_else(|| Error::InvalidTransition("Task payload missing".into()))?;
276 let prompt = question_prompt(task)?;
277 let mut candidate = TaskTool::task_list_from_args_with_existing(
281 task,
282 &caller.id,
283 projection.task_list.as_ref(),
284 None,
285 )
286 .map_err(|e| Error::InvalidTransition(e.to_string()))?;
287 let snapshot = service.published()?.1;
288 let a = &snapshot.assignments[assignment];
289 candidate.title = format!(
290 "Local plan: {}",
291 snapshot.tickets[&a.work_id].contract.title
292 );
293 let candidate = PlanProjection {
294 assignment_id: assignment.clone(),
295 generation: a.generation,
296 contract_revision: a.contract_revision,
297 packet_hash: packet_hash.clone(),
298 binding: snapshot.binding,
299 task_list: Some(candidate),
300 plan_revision: a.plan.plan_revision.saturating_add(1),
301 question: None,
302 };
303 let question_budget = prompt
304 .as_ref()
305 .map_or(0, |p| p.len().saturating_mul(2).saturating_add(2048));
306 if serde_json::to_vec(&serde_json::json!({"result":candidate}))?
307 .len()
308 .saturating_add(question_budget)
309 > 16 * 1024 - 256
310 {
311 return Err(Error::InvalidTransition(
312 "native LocalPlan exceeds HostBridge reply budget".into(),
313 ));
314 }
315 plan.apply_task(&mut projection, call_id, task)?;
316 } else {
317 service.authorize_tool(&plan.authority, "Task")?;
318 }
319 let plan_revision = projection
320 .task_list_version_meta()
321 .and_then(|v| v.parse().ok())
322 .unwrap_or(0);
323 let snapshot = service.published()?.1;
324 let a = &snapshot.assignments[assignment];
325 Ok(PlanProjection {
326 assignment_id: assignment.clone(),
327 generation: a.generation,
328 contract_revision: a.contract_revision,
329 packet_hash,
330 binding: snapshot.binding,
331 task_list: projection.task_list,
332 plan_revision,
333 question: a
334 .awaiting_request
335 .as_ref()
336 .and_then(|id| snapshot.requests.get(id))
337 .cloned(),
338 })
339}
340
341fn packet_fingerprint(packet: &WorkContextPacket) -> Result<String> {
342 Ok(bamboo_tickets::content_hash(
343 &bamboo_tickets::canonical_bytes(packet)?,
344 ))
345}