1use async_trait::async_trait;
8use bamboo_agent_core::{
9 Tool, ToolClass, ToolCtx, ToolError, ToolMutability, ToolOutcome, ToolResult,
10};
11use serde_json::json;
12
13pub const LOOP_CONTROL_TOOL_NAME: &str = "loop_control";
14use bamboo_domain::session::loop_control_request::{
15 MAX_ITERATIONS, MAX_OBJECTIVE_BYTES, MAX_TOTAL_ROUNDS,
16};
17pub use bamboo_domain::{parse_loop_control_request, LoopControlRequest};
18
19pub struct LoopControlTool;
21
22impl LoopControlTool {
23 pub fn new() -> Self {
24 Self
25 }
26}
27
28impl Default for LoopControlTool {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34#[async_trait]
35impl Tool for LoopControlTool {
36 fn name(&self) -> &str {
37 LOOP_CONTROL_TOOL_NAME
38 }
39
40 fn description(&self) -> &str {
41 "Read or control this session's bounded loop only when the host explicitly enabled ExecuteRequest.loop_control(config). \
42 status requires only action. start requires operation_id (UUID), expected_revision, objective, max_iterations and max_total_rounds; expected_generation is optional. \
43 update requires operation_id, generation (UUID), expected_revision and at least one of objective/max_iterations/max_total_rounds. stop requires operation_id, generation and expected_revision. \
44 Mutations use generation/control-revision compare-and-swap and an idempotent operation_id; read status before constructing a command. Receipts are bounded to 64 operations in the current generation; retired generations are stale. \
45 Host ceilings, consumed budgets and user cancellation always take priority: a command cannot grant authority, overlap execution, restore a stopped run or increase the host's finite limits. \
46 Updates may only tighten current limits without dropping below consumption. A new objective applies no earlier than the next iteration admission; only one pending objective is allowed. A pending objective needs one future iteration and at least two remaining rounds during an active iteration (its terminal answer, then the new objective), or one before the first iteration. max_total_rounds is the absolute parent-round cap for this entire activation, including rounds already consumed before start, and grants no new rounds. Start is only for work that can complete without human questions, new approvals, asynchronous child work or background Bash; incompatible waits are refused without approval or a waiter. Unexpected detached tool execution aborts the generation with stop unconfirmed; a previously observed Running in this activation also refuses Start. Interactive work requires a later ordinary activation outside this generation. This tool alone validates a command; only a durable owner receipt confirms application. No pause, resume, cadence or other-session target is supported."
47 }
48
49 fn classify(&self, args: &serde_json::Value) -> ToolClass {
50 match parse_loop_control_request(&args.to_string()) {
51 Ok(LoopControlRequest::Status) => ToolClass {
52 mutability: ToolMutability::ReadOnly,
53 parallel_safe: false,
54 promotable: false,
55 },
56 _ => ToolClass::MUTATING_SERIAL,
57 }
58 }
59
60 fn parameters_schema(&self) -> serde_json::Value {
61 json!({
62 "type": "object",
63 "properties": {
64 "action": {
65 "type": "string",
66 "enum": ["status", "start", "update", "stop"],
67 "description": "status: no other fields. start: operation_id, expected_revision, objective, max_iterations, max_total_rounds; optional expected_generation. update: operation_id, generation, expected_revision, and at least one patch field. stop: operation_id, generation, expected_revision."
68 },
69 "operation_id": {
70 "type": "string",
71 "format": "uuid",
72 "description": "Required for start/update/stop. Reuse only for an identical command in the current generation; the owner checks idempotency before CAS. At most 64 operation receipts; retired generations are stale."
73 },
74 "expected_generation": {
75 "type": ["string", "null"],
76 "format": "uuid",
77 "description": "start only: expected prior generation UUID, or absent/null if none. The owner verifies current generation and revision."
78 },
79 "generation": {
80 "type": "string",
81 "format": "uuid",
82 "description": "Required for update/stop: exact current generation from status. Retired generations are stale; stop cannot be undone by start."
83 },
84 "expected_revision": {
85 "type": "integer",
86 "minimum": 0,
87 "description": "Required for start/update/stop: exact current control revision from status, independent of ordinary checkpoint state revisions. Stale commands are refused."
88 },
89 "objective": {
90 "type": "string",
91 "minLength": 1,
92 "maxLength": MAX_OBJECTIVE_BYTES,
93 "description": "Required for start; optional update patch. Non-whitespace text, at most 16384 UTF-8 bytes. An edit applies no earlier than the next iteration admission; at most one objective edit may be pending."
94 },
95 "max_iterations": {
96 "type": "integer",
97 "minimum": 1,
98 "maximum": MAX_ITERATIONS,
99 "description": "Required for start; optional update patch. Total iteration limit within the immutable host ceiling. Updates may only tighten the current limit and cannot drop below consumed iterations."
100 },
101 "max_total_rounds": {
102 "type": "integer",
103 "minimum": 1,
104 "maximum": MAX_TOTAL_ROUNDS,
105 "description": "Required for start; optional update patch. Absolute parent-round cap for the entire current activation, including rounds consumed before start; never an additional allowance. Within immutable host ceilings; updates may only tighten and cannot drop below consumption."
106 }
107 },
108 "required": ["action"],
109 "additionalProperties": false
110 })
111 }
112
113 async fn invoke(
114 &self,
115 args: serde_json::Value,
116 ctx: ToolCtx,
117 ) -> Result<ToolOutcome, ToolError> {
118 let request =
119 parse_loop_control_request(&args.to_string()).map_err(ToolError::InvalidArguments)?;
120 let session_id = ctx
121 .session_id()
122 .filter(|value| !value.trim().is_empty())
123 .ok_or_else(|| {
124 ToolError::Execution("loop_control requires the current session context".into())
125 })?;
126 if ctx.plan_read_only && !matches!(request, LoopControlRequest::Status) {
127 return Err(ToolError::Execution(
128 "Plan mode: loop_control mutation blocked".into(),
129 ));
130 }
131 Ok(ToolOutcome::Completed(ToolResult {
132 success: true,
133 result: json!({
134 "action": request.action(),
135 "session_id": session_id,
136 "status": "validated",
137 "applied": false,
138 "requires_owner_apply": true,
139 "message": "loop_control command validated only; the current session runner must apply it and return a durable receipt before ToolComplete."
140 })
141 .to_string(),
142 display_preference: Some("json".into()),
143 images: Vec::new(),
144 }))
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::sync::Arc;
152
153 const OPERATION_ID: &str = "A4B85B5B-BA44-488D-9900-20DD7E016E88";
154 const GENERATION: &str = "b23bc011-5424-4b5e-8fc9-3c861b89874b";
155
156 fn start() -> serde_json::Value {
157 json!({
158 "action": "start",
159 "operation_id": OPERATION_ID,
160 "expected_revision": 0,
161 "objective": " Verify the bounded loop ",
162 "max_iterations": 2,
163 "max_total_rounds": 4
164 })
165 }
166
167 fn update() -> serde_json::Value {
168 json!({
169 "action": "update",
170 "operation_id": OPERATION_ID,
171 "generation": GENERATION,
172 "expected_revision": 1,
173 "objective": "Use the updated objective"
174 })
175 }
176
177 fn stop() -> serde_json::Value {
178 json!({
179 "action": "stop",
180 "operation_id": OPERATION_ID,
181 "generation": GENERATION,
182 "expected_revision": 2
183 })
184 }
185
186 fn context() -> ToolCtx {
187 let mut context = ToolCtx::none("loop-control-test");
188 context.session_id = Some(Arc::from("current-session"));
189 context
190 }
191
192 #[test]
193 fn parses_all_actions_and_preserves_fingerprint_inputs() {
194 assert_eq!(
195 parse_loop_control_request(r#"{"action":"status"}"#).unwrap(),
196 LoopControlRequest::Status
197 );
198 let parsed = parse_loop_control_request(&start().to_string()).unwrap();
199 assert!(matches!(
200 &parsed,
201 LoopControlRequest::Start {
202 operation_id,
203 expected_generation: None,
204 objective,
205 ..
206 } if operation_id == OPERATION_ID && objective == " Verify the bounded loop "
207 ));
208 assert_eq!(serde_json::to_value(&parsed).unwrap()["action"], "start");
209 assert_eq!(
210 serde_json::from_str::<LoopControlRequest>(&serde_json::to_string(&parsed).unwrap())
211 .unwrap(),
212 parsed
213 );
214 assert!(matches!(
215 parse_loop_control_request(&update().to_string()).unwrap(),
216 LoopControlRequest::Update {
217 max_iterations: None,
218 max_total_rounds: None,
219 ..
220 }
221 ));
222 assert!(matches!(
223 parse_loop_control_request(&stop().to_string()).unwrap(),
224 LoopControlRequest::Stop { .. }
225 ));
226 }
227
228 #[test]
229 fn rejects_unknown_fields_for_every_action_including_direct_deserialization() {
230 for mut args in [json!({"action": "status"}), start(), update(), stop()] {
231 args["session_id"] = json!("another-session");
232 assert!(parse_loop_control_request(&args.to_string()).is_err());
233 assert!(serde_json::from_value::<LoopControlRequest>(args).is_err());
234 }
235 let mut args = start();
236 args["generation"] = json!(GENERATION);
237 assert!(parse_loop_control_request(&args.to_string()).is_err());
238 let mut args = stop();
239 args["objective"] = json!("not a stop field");
240 assert!(parse_loop_control_request(&args.to_string()).is_err());
241 }
242
243 #[test]
244 fn rejects_missing_fields_unknown_actions_and_wrong_types() {
245 for field in [
246 "operation_id",
247 "expected_revision",
248 "objective",
249 "max_iterations",
250 "max_total_rounds",
251 ] {
252 let mut args = start();
253 args.as_object_mut().unwrap().remove(field);
254 assert!(
255 parse_loop_control_request(&args.to_string()).is_err(),
256 "{field}"
257 );
258 }
259 for args in [
260 json!({}),
261 json!({"action": "Status"}),
262 json!({"action": "pause"}),
263 json!({"action": "resume"}),
264 json!({"action": 1}),
265 json!(null),
266 json!([]),
267 ] {
268 assert!(parse_loop_control_request(&args.to_string()).is_err());
269 }
270 for value in [json!(-1), json!(1.5), json!("1"), json!(null)] {
271 let mut args = start();
272 args["expected_revision"] = value;
273 assert!(parse_loop_control_request(&args.to_string()).is_err());
274 }
275 }
276
277 #[test]
278 fn rejects_duplicate_fields_and_trailing_input() {
279 for arguments in [
280 r#"{"action":"status","action":"status"}"#,
281 r#"{"action":"status"} {}"#,
282 r#"{"action":"stop","operation_id":"A4B85B5B-BA44-488D-9900-20DD7E016E88","generation":"b23bc011-5424-4b5e-8fc9-3c861b89874b","expected_revision":1,"expected_revision":2}"#,
283 ] {
284 assert!(parse_loop_control_request(arguments).is_err());
285 }
286 }
287
288 #[test]
289 fn validates_uuid_fields_without_normalizing_them() {
290 for (mut args, field) in [
291 (start(), "operation_id"),
292 (start(), "expected_generation"),
293 (update(), "generation"),
294 (stop(), "generation"),
295 ] {
296 args[field] = json!("not-a-uuid");
297 assert!(
298 parse_loop_control_request(&args.to_string()).is_err(),
299 "{field}"
300 );
301 }
302 let mut args = start();
303 args["expected_generation"] = json!(GENERATION);
304 let parsed = parse_loop_control_request(&args.to_string()).unwrap();
305 assert_eq!(
306 serde_json::to_value(parsed).unwrap()["expected_generation"],
307 GENERATION
308 );
309 }
310
311 #[test]
312 fn enforces_objective_utf8_byte_limit_and_non_whitespace() {
313 for mut args in [start(), update()] {
314 for objective in [
315 "".into(),
316 " \n\t".into(),
317 "界".repeat(MAX_OBJECTIVE_BYTES / 3 + 1),
318 ] {
319 args["objective"] = json!(objective);
320 assert!(parse_loop_control_request(&args.to_string()).is_err());
321 }
322 args["objective"] = json!("x".repeat(MAX_OBJECTIVE_BYTES));
323 assert!(parse_loop_control_request(&args.to_string()).is_ok());
324 }
325 }
326
327 #[test]
328 fn enforces_positive_iteration_and_round_bounds_for_start_and_update() {
329 for mut args in [start(), update()] {
330 for (field, ceiling) in [
331 ("max_iterations", MAX_ITERATIONS),
332 ("max_total_rounds", MAX_TOTAL_ROUNDS),
333 ] {
334 for value in [json!(0), json!(ceiling + 1), json!(-1), json!(1.5)] {
335 args[field] = value;
336 assert!(
337 parse_loop_control_request(&args.to_string()).is_err(),
338 "{field}"
339 );
340 }
341 args[field] = json!(ceiling);
342 assert!(parse_loop_control_request(&args.to_string()).is_ok());
343 }
344 }
345 }
346
347 #[test]
348 fn update_requires_a_non_null_patch() {
349 let mut args = update();
350 args.as_object_mut().unwrap().remove("objective");
351 assert!(parse_loop_control_request(&args.to_string()).is_err());
352 args["objective"] = json!(null);
353 args["max_iterations"] = json!(null);
354 args["max_total_rounds"] = json!(null);
355 assert!(parse_loop_control_request(&args.to_string()).is_err());
356 args["max_total_rounds"] = json!(8);
357 assert!(parse_loop_control_request(&args.to_string()).is_ok());
358 }
359
360 #[test]
361 fn status_is_readonly_but_all_calls_are_serial_and_nonpromotable() {
362 let tool = LoopControlTool::new();
363 assert_eq!(
364 tool.classify(&json!({"action": "status"})),
365 ToolClass {
366 mutability: ToolMutability::ReadOnly,
367 parallel_safe: false,
368 promotable: false,
369 }
370 );
371 for args in [
372 start(),
373 update(),
374 stop(),
375 json!({"action": "status", "session_id": "foreign"}),
376 json!({"action": "update"}),
377 json!({"action": "unknown"}),
378 json!(null),
379 ] {
380 assert_eq!(tool.classify(&args), ToolClass::MUTATING_SERIAL);
381 }
382 }
383
384 #[test]
385 fn schema_is_flat_bounded_and_requires_action() {
386 let schema = LoopControlTool::new().parameters_schema();
387 assert_eq!(schema["additionalProperties"], false);
388 assert_eq!(schema["required"], json!(["action"]));
389 assert_eq!(
390 schema["properties"]["max_iterations"]["maximum"],
391 MAX_ITERATIONS
392 );
393 assert_eq!(
394 schema["properties"]["max_total_rounds"]["maximum"],
395 MAX_TOTAL_ROUNDS
396 );
397 assert!(schema.get("oneOf").is_none());
398 assert!(schema["properties"].get("session_id").is_none());
399 assert!(schema["properties"].get("target_session_id").is_none());
400 }
401
402 #[tokio::test]
403 async fn invocation_is_validation_only_for_every_action() {
404 let tool = LoopControlTool::new();
405 for args in [json!({"action": "status"}), start(), update(), stop()] {
406 let ToolOutcome::Completed(result) = tool.invoke(args, context()).await.unwrap() else {
407 panic!("expected a validation-only completion")
408 };
409 assert!(result.success);
410 let receipt: serde_json::Value = serde_json::from_str(&result.result).unwrap();
411 assert_eq!(receipt["status"], "validated");
412 assert_eq!(receipt["applied"], false);
413 assert_eq!(receipt["requires_owner_apply"], true);
414 assert_eq!(receipt["session_id"], "current-session");
415 assert!(receipt.get("generation").is_none());
416 assert!(receipt.get("revision").is_none());
417 assert!(receipt.get("objective").is_none());
418 assert!(receipt["message"]
419 .as_str()
420 .unwrap()
421 .contains("validated only"));
422 }
423 }
424
425 #[tokio::test]
426 async fn invocation_requires_current_session_and_rejects_invalid_arguments() {
427 let tool = LoopControlTool::new();
428 assert!(matches!(
429 tool.invoke(json!({"action": "status"}), ToolCtx::none("missing"))
430 .await,
431 Err(ToolError::Execution(_))
432 ));
433 let mut empty_context = context();
434 empty_context.session_id = Some(Arc::from(" "));
435 assert!(matches!(
436 tool.invoke(start(), empty_context).await,
437 Err(ToolError::Execution(_))
438 ));
439 assert!(matches!(
440 tool.invoke(json!({"action": "start"}), context()).await,
441 Err(ToolError::InvalidArguments(_))
442 ));
443 }
444
445 #[tokio::test]
446 async fn plan_posture_allows_status_and_blocks_all_mutations_even_with_bypass() {
447 let tool = LoopControlTool::new();
448 let mut context = context();
449 context.plan_read_only = true;
450 context.bypass_permissions = true;
451 context.auto_approve_permissions = true;
452 assert!(tool
453 .invoke(json!({"action": "status"}), context.clone())
454 .await
455 .is_ok());
456 for args in [start(), update(), stop()] {
457 assert!(matches!(
458 tool.invoke(args, context.clone()).await,
459 Err(ToolError::Execution(message)) if message.contains("Plan mode")
460 ));
461 }
462 }
463}