1use std::sync::{
5 atomic::{AtomicBool, Ordering},
6 Arc, OnceLock,
7};
8
9use bamboo_agent_core::{tools::ToolResult, AgentError, Message, Session};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tokio_util::sync::CancellationToken;
13
14pub use bamboo_tools::tools::loop_control::LoopControlRequest;
15
16use super::config::AgentLoopConfig;
17use super::counted_loop::{
18 self, CountedLoopConfig, LoopState, LoopStatus, MAX_LOOP_ITERATIONS, MAX_LOOP_PROMPT_BYTES,
19 MAX_LOOP_ROUNDS,
20};
21
22pub(crate) mod output_limits;
23pub(crate) mod scoped;
24pub(crate) mod suspension;
25pub use scoped::SupervisorLoopControlGrant;
26
27const MAX_OPERATIONS: usize = 64;
28
29#[derive(Clone)]
33pub struct LoopControlConfig {
34 pub max_iterations: u32,
35 pub max_total_rounds: u32,
36 stop: CancellationToken,
37 owner: CountedLoopConfig,
38 active: Arc<OnceLock<ManagedActivation>>,
39 execution: Arc<OnceLock<ControlExecution>>,
40 observed_running: Arc<AtomicBool>,
41 transport_retired: Arc<AtomicBool>,
42 supervisor_grant: Option<SupervisorLoopControlGrant>,
43 live_supervisor_binding: Arc<OnceLock<bamboo_domain::ScopedLoopControlBinding>>,
44}
45
46struct ManagedActivation {
47 generation: String,
48 counted: CountedLoopConfig,
49}
50
51struct ControlExecution {
52 id: String,
53 cancel: CancellationToken,
54}
55
56impl std::fmt::Debug for LoopControlConfig {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("LoopControlConfig")
59 .field("max_iterations", &self.max_iterations)
60 .field("max_total_rounds", &self.max_total_rounds)
61 .field("stopped", &self.stop.is_cancelled())
62 .field("activated", &self.active.get().is_some())
63 .finish_non_exhaustive()
64 }
65}
66
67impl LoopControlConfig {
68 pub fn new(max_iterations: u32, max_total_rounds: u32) -> Self {
69 Self {
70 max_iterations,
71 max_total_rounds,
72 stop: CancellationToken::new(),
73 owner: CountedLoopConfig::new(max_iterations, max_total_rounds),
74 active: Arc::new(OnceLock::new()),
75 execution: Arc::new(OnceLock::new()),
76 observed_running: Arc::new(AtomicBool::new(false)),
77 transport_retired: Arc::new(AtomicBool::new(false)),
78 supervisor_grant: None,
79 live_supervisor_binding: Arc::new(OnceLock::new()),
80 }
81 }
82
83 pub fn supervisor_control(
86 mut self,
87 grant: SupervisorLoopControlGrant,
88 ) -> Result<Self, AgentError> {
89 grant.validate(self.max_iterations, self.max_total_rounds)?;
90 self.supervisor_grant = Some(grant);
91 Ok(self)
92 }
93
94 pub fn supervisor_grant_id(&self) -> Option<uuid::Uuid> {
95 self.supervisor_grant
96 .as_ref()
97 .map(SupervisorLoopControlGrant::grant_id)
98 }
99
100 pub fn stop(&self) {
103 self.stop.cancel();
104 }
105
106 pub(crate) fn bind_owner(
107 &mut self,
108 ownership: crate::session_activation::SessionRunOwnership,
109 session: &Session,
110 ) {
111 self.owner = CountedLoopConfig::new(self.max_iterations, self.max_total_rounds);
112 self.owner.bind_owner(ownership, session);
113 self.active = Arc::new(OnceLock::new());
114 self.execution = Arc::new(OnceLock::new());
115 self.observed_running = Arc::new(AtomicBool::new(false));
116 self.transport_retired = Arc::new(AtomicBool::new(false));
117 self.live_supervisor_binding = Arc::new(OnceLock::new());
118 }
119
120 pub(crate) async fn validate_current_owner(&self, session: &Session) -> Result<(), AgentError> {
121 if !(1..=MAX_LOOP_ITERATIONS).contains(&self.max_iterations)
122 || !(self.max_iterations..=MAX_LOOP_ROUNDS).contains(&self.max_total_rounds)
123 || session.kind != bamboo_domain::SessionKind::Root
124 || session.parent_session_id.is_some()
125 || !session.authority_identity.is_ordinary()
126 || session.supervisor_incarnation_id().is_some()
127 || session.root_orchestration_only_enabled()
128 || !self.owner.has_current_owner(session).await
129 {
130 return Err(fatal(
131 "current ordinary Root owner and finite host ceilings required",
132 ));
133 }
134 Ok(())
135 }
136
137 pub(crate) async fn validate_admission(
138 &self,
139 session: &Session,
140 router: Option<&Arc<crate::session_activation::SessionActivationRouter>>,
141 ) -> Result<(), AgentError> {
142 if !(1..=MAX_LOOP_ITERATIONS).contains(&self.max_iterations)
143 || !(self.max_iterations..=MAX_LOOP_ROUNDS).contains(&self.max_total_rounds)
144 {
145 return Err(fatal("invalid host ceilings"));
146 }
147 if session.kind != bamboo_domain::SessionKind::Root
148 || session.parent_session_id.is_some()
149 || !session.authority_identity.is_ordinary()
150 || session.supervisor_incarnation_id().is_some()
151 || session.root_orchestration_only_enabled()
152 || !self.owner.owns_session(router, session).await
153 {
154 return Err(fatal("current ordinary Root activation owner required"));
155 }
156 Ok(())
157 }
158
159 pub(crate) fn bind_execution(
160 &self,
161 id: &str,
162 cancel: CancellationToken,
163 ) -> Result<(), AgentError> {
164 self.execution
165 .set(ControlExecution {
166 id: id.into(),
167 cancel,
168 })
169 .map_err(|_| fatal("execution capability already consumed"))
170 }
171
172 pub(crate) fn execution_cancel(&self) -> Result<&CancellationToken, AgentError> {
173 self.execution
174 .get()
175 .map(|execution| &execution.cancel)
176 .ok_or_else(|| fatal("execution identity absent"))
177 }
178
179 pub(crate) fn record_running_observed(&self) {
180 self.observed_running.store(true, Ordering::Release);
181 }
182
183 pub(crate) fn has_observed_running(&self) -> bool {
184 self.observed_running.load(Ordering::Acquire)
185 }
186
187 pub(crate) fn active_counted(&self) -> Option<&CountedLoopConfig> {
188 self.active.get().map(|active| &active.counted)
189 }
190
191 pub(crate) fn generation(&self) -> Option<&str> {
192 self.active.get().map(|active| active.generation.as_str())
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct LoopControlReceipt {
199 pub operation_id: String,
200 pub generation: String,
201 pub accepted_revision: u64,
202 pub applied_revision_at_acceptance: u64,
203 pub objective_revision: u64,
204 pub effective_iteration: Option<u32>,
205 pub applied: bool,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct StoredLoopOperation {
211 pub payload_sha256: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub origin: Option<bamboo_domain::ScopedLoopControlBinding>,
214 pub receipt: LoopControlReceipt,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct PendingLoopObjective {
220 pub objective: String,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub origin: Option<bamboo_domain::ScopedLoopControlBinding>,
223 pub objective_revision: u64,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct ManagedLoopState {
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub objective_origin: Option<bamboo_domain::ScopedLoopControlBinding>,
234 pub control_revision: u64,
235 pub applied_revision: u64,
236 pub iteration_objective_revision: u64,
237 pub stop_requested: bool,
238 pub pending_objective: Option<PendingLoopObjective>,
239 pub operations: Vec<StoredLoopOperation>,
240}
241
242impl ManagedLoopState {
243 pub(crate) fn validate(&self, state: &LoopState) -> Result<(), AgentError> {
244 if self.control_revision == 0
245 || self.control_revision == u64::MAX
246 || self.applied_revision > self.control_revision
247 || self.iteration_objective_revision > state.objective_revision
248 || (self.iteration_objective_revision == 0) != (state.iterations_started == 0)
249 || self.operations.is_empty()
250 || self.operations.len() > MAX_OPERATIONS
251 {
252 return Err(fatal("malformed management state"));
253 }
254 if let Some(pending) = &self.pending_objective {
255 if pending.objective.trim().is_empty()
256 || pending.objective.len() > MAX_LOOP_PROMPT_BYTES
257 || state.objective_revision.checked_add(1) != Some(pending.objective_revision)
258 || self.applied_revision >= self.control_revision
259 || self.stop_requested
260 {
261 return Err(fatal("malformed pending objective"));
262 }
263 } else if state.iterations_started > 0 && self.applied_revision != self.control_revision {
264 return Err(fatal("inconsistent applied revision"));
265 }
266 for origin in self
267 .objective_origin
268 .iter()
269 .chain(
270 self.pending_objective
271 .iter()
272 .filter_map(|pending| pending.origin.as_ref()),
273 )
274 .chain(
275 self.operations
276 .iter()
277 .filter_map(|operation| operation.origin.as_ref()),
278 )
279 {
280 origin
281 .validate()
282 .map_err(|_| fatal("malformed remote objective origin"))?;
283 if origin.execution_id != state.owner_execution_id
284 || state.max_iterations > origin.max_iterations
285 || state.max_total_rounds > origin.max_total_rounds
286 {
287 return Err(fatal("remote objective lifetime or bounds changed"));
288 }
289 }
290 let mut seen = std::collections::BTreeSet::new();
291 for operation in &self.operations {
292 let receipt = &operation.receipt;
293 if uuid::Uuid::parse_str(&receipt.operation_id).is_err()
294 || !seen.insert(&receipt.operation_id)
295 || receipt.generation != state.generation
296 || receipt.accepted_revision == 0
297 || receipt.accepted_revision > self.control_revision
298 || receipt.applied_revision_at_acceptance > receipt.accepted_revision
299 || operation.payload_sha256.len() != 64
300 || !operation
301 .payload_sha256
302 .bytes()
303 .all(|b| b.is_ascii_hexdigit())
304 {
305 return Err(fatal("malformed operation receipt"));
306 }
307 }
308 Ok(())
309 }
310}
311
312#[derive(Debug, Clone, Serialize)]
313pub struct LoopStatusSnapshot {
314 pub generation: Option<String>,
315 pub state_revision: u64,
316 pub control_revision: u64,
317 pub applied_revision: u64,
318 pub objective_revision: u64,
319 pub admitted_objective_revision: Option<u64>,
320 pub objective: Option<String>,
321 pub pending_objective: Option<String>,
322 pub status: Option<LoopStatus>,
323 pub stop_requested: bool,
324 pub iterations_started: u32,
325 pub iterations_completed: u32,
326 pub rounds_started: u32,
327 pub max_iterations: u32,
328 pub max_total_rounds: u32,
329 pub remaining_iterations: u32,
330 pub remaining_rounds: u32,
331}
332
333pub fn inspect_loop(session: &Session) -> Result<LoopStatusSnapshot, AgentError> {
336 let state = counted_loop::read_loop_state(session)?;
337 let control = state.as_ref().and_then(|state| state.control.as_ref());
338 Ok(LoopStatusSnapshot {
339 generation: state.as_ref().map(|s| s.generation.clone()),
340 state_revision: state.as_ref().map_or(0, |s| s.revision),
341 control_revision: control.map_or(0, |c| c.control_revision),
342 applied_revision: control.map_or(0, |c| c.applied_revision),
343 objective_revision: state.as_ref().map_or(0, |s| s.objective_revision),
344 admitted_objective_revision: control.and_then(|c| {
345 (c.iteration_objective_revision > 0).then_some(c.iteration_objective_revision)
346 }),
347 objective: state.as_ref().map(|s| s.objective.clone()),
348 pending_objective: control
349 .and_then(|c| c.pending_objective.as_ref())
350 .map(|p| p.objective.clone()),
351 status: state.as_ref().map(|s| s.status),
352 stop_requested: control.is_some_and(|c| c.stop_requested),
353 iterations_started: state.as_ref().map_or(0, |s| s.iterations_started),
354 iterations_completed: state.as_ref().map_or(0, |s| s.iterations_completed),
355 rounds_started: state.as_ref().map_or(0, |s| s.rounds_started),
356 max_iterations: state.as_ref().map_or(0, |s| s.max_iterations),
357 max_total_rounds: state.as_ref().map_or(0, |s| s.max_total_rounds),
358 remaining_iterations: state
359 .as_ref()
360 .map_or(0, |s| s.max_iterations.saturating_sub(s.iterations_started)),
361 remaining_rounds: state
362 .as_ref()
363 .map_or(0, |s| s.max_total_rounds.saturating_sub(s.rounds_started)),
364 })
365}
366
367fn fatal(reason: &str) -> AgentError {
368 AgentError::Budget(format!("loop_control: {reason}"))
369}
370
371fn rejected(code: &str) -> ToolResult {
372 ToolResult::text(
373 false,
374 serde_json::json!({"error":code,"applied":false}).to_string(),
375 )
376}
377
378fn receipt_result(receipt: &LoopControlReceipt) -> ToolResult {
379 ToolResult::text(
380 true,
381 serde_json::to_string(receipt).expect("typed receipt serializes"),
382 )
383}
384
385fn operation_id(command: &LoopControlRequest) -> Option<&str> {
386 match command {
387 LoopControlRequest::Status => None,
388 LoopControlRequest::Start { operation_id, .. }
389 | LoopControlRequest::Update { operation_id, .. }
390 | LoopControlRequest::Stop { operation_id, .. } => Some(operation_id),
391 }
392}
393
394fn fingerprint(command: &LoopControlRequest) -> String {
395 let bytes = serde_json::to_vec(command).expect("typed command serializes");
396 hex::encode(Sha256::digest(bytes))
397}
398
399fn control_revision(state: Option<&LoopState>) -> u64 {
400 state
401 .and_then(|s| s.control.as_ref())
402 .map_or(0, |c| c.control_revision)
403}
404
405#[allow(clippy::too_many_arguments)]
409pub(crate) async fn apply_current(
410 session: &mut Session,
411 config: &AgentLoopConfig,
412 command: &LoopControlRequest,
413 tool_call_id: &str,
414 round: usize,
415 approved_replay: bool,
416 plan_read_only: bool,
417 max_tool_output_tokens: u32,
418) -> Result<ToolResult, AgentError> {
419 let policy = config
420 .loop_control
421 .as_ref()
422 .ok_or_else(|| fatal("host capability absent"))?;
423 policy.validate_current_owner(session).await?;
424 let execution = policy
425 .execution
426 .get()
427 .ok_or_else(|| fatal("execution identity absent"))?;
428 if execution.cancel.is_cancelled() {
429 return Err(AgentError::Cancelled);
430 }
431 if config.persistence.is_none() {
432 return Err(fatal("durable checkpoint required"));
433 }
434 if config.ticket_worker_plan.is_some() || config.goal_loop_active() {
435 return Err(fatal("incompatible execution mode"));
436 }
437 if session.messages.iter().any(|message| {
438 message.role == bamboo_domain::Role::Tool
439 && message.tool_call_id.as_deref() == Some(tool_call_id)
440 }) {
441 return Err(fatal("tool result already paired"));
442 }
443 let current = counted_loop::read_loop_state(session)?;
444 let start_refusal = if policy.active.get().is_none() {
445 let refusal = suspension::start_refusal(session, config).await;
446 let authority = policy.validate_current_owner(session).await;
449 if execution.cancel.is_cancelled() {
450 return Err(AgentError::Cancelled);
451 }
452 authority?;
453 refusal
454 } else {
455 None
456 };
457 let result = if matches!(command, LoopControlRequest::Status) {
458 ToolResult::text(true, serde_json::json!({
459 "loop":inspect_loop(session)?,
460 "host_max_iterations":policy.max_iterations,
461 "host_max_total_rounds":policy.max_total_rounds.min(config.run_budget.max_rounds.unwrap_or(MAX_LOOP_ROUNDS)),
462 "activation_rounds_spent":round.saturating_add(1),
463 "host_remaining_rounds":policy.max_total_rounds.min(config.run_budget.max_rounds.unwrap_or(MAX_LOOP_ROUNDS)).saturating_sub(u32::try_from(round.saturating_add(1)).unwrap_or(u32::MAX)),
464 "can_start":policy.active.get().is_none() && !policy.stop.is_cancelled() && start_refusal.is_none(),
465 }).to_string())
466 } else if plan_read_only {
467 rejected("plan_read_only")
468 } else if approved_replay {
469 rejected("fresh_owner_command_required")
470 } else {
471 let hash = fingerprint(command);
472 let prior = current
473 .as_ref()
474 .and_then(|s| s.control.as_ref())
475 .and_then(|c| {
476 c.operations
477 .iter()
478 .find(|o| Some(o.receipt.operation_id.as_str()) == operation_id(command))
479 });
480 if let Some(prior) = prior {
481 if policy.generation() != Some(prior.receipt.generation.as_str())
484 || current
485 .as_ref()
486 .is_none_or(|s| s.owner_execution_id != execution.id)
487 {
488 rejected("generation_not_current_activation")
489 } else if prior.origin.is_none() && prior.payload_sha256 == hash {
490 receipt_result(&prior.receipt)
491 } else {
492 rejected("operation_payload_conflict")
493 }
494 } else if policy.stop.is_cancelled() && !matches!(command, LoopControlRequest::Stop { .. })
495 {
496 rejected("host_stop_requested")
497 } else if let Some(reason) =
498 start_refusal.filter(|_| matches!(command, LoopControlRequest::Start { .. }))
499 {
500 rejected(reason)
501 } else {
502 match accept_command(policy, config, current, command, &execution.id, round, hash) {
503 Ok(state) => {
504 let result = receipt_result(
505 &state
506 .control
507 .as_ref()
508 .expect("managed state")
509 .operations
510 .last()
511 .expect("accepted operation")
512 .receipt,
513 );
514 if !output_limits::fits(&result, max_tool_output_tokens) {
515 output_limits::budget_failure(max_tool_output_tokens)
518 } else if let Err(code) = install_command_effects(policy, &state, command) {
519 rejected(code)
520 } else {
521 return checkpoint_result(
522 session,
523 config,
524 policy,
525 tool_call_id,
526 result,
527 Some(state),
528 max_tool_output_tokens,
529 )
530 .await;
531 }
532 }
533 Err(code) => rejected(code),
534 }
535 }
536 };
537 checkpoint_result(
538 session,
539 config,
540 policy,
541 tool_call_id,
542 result,
543 None,
544 max_tool_output_tokens,
545 )
546 .await
547}
548
549#[allow(clippy::too_many_arguments)]
550fn accept_command(
551 policy: &LoopControlConfig,
552 config: &AgentLoopConfig,
553 current: Option<LoopState>,
554 command: &LoopControlRequest,
555 execution_id: &str,
556 round: usize,
557 hash: String,
558) -> Result<LoopState, &'static str> {
559 let spent = u32::try_from(round.saturating_add(1)).map_err(|_| "budget_exhausted")?;
560 accept_command_at_boundary(
561 policy,
562 config,
563 current,
564 command,
565 execution_id,
566 spent,
567 hash,
568 None,
569 )
570}
571
572#[allow(clippy::too_many_arguments)]
576fn accept_command_at_boundary(
577 policy: &LoopControlConfig,
578 config: &AgentLoopConfig,
579 current: Option<LoopState>,
580 command: &LoopControlRequest,
581 execution_id: &str,
582 activation_rounds_spent: u32,
583 hash: String,
584 origin: Option<&bamboo_domain::ScopedLoopControlBinding>,
585) -> Result<LoopState, &'static str> {
586 if let Some(origin) = origin {
587 origin
588 .authorize(command)
589 .map_err(|_| "remote_host_grant_refused")?;
590 if origin.execution_id != execution_id {
591 return Err("remote_execution_changed");
592 }
593 }
594 if !matches!(command, LoopControlRequest::Start { .. })
595 && current
596 .as_ref()
597 .and_then(|s| s.control.as_ref())
598 .is_some_and(|c| c.operations.len() >= MAX_OPERATIONS)
599 {
600 return Err("operation_limit_exhausted");
601 }
602 let mut state = match command {
603 LoopControlRequest::Start {
604 expected_generation,
605 expected_revision,
606 objective,
607 max_iterations,
608 max_total_rounds,
609 ..
610 } => {
611 if current.as_ref().map(|s| s.generation.as_str()) != expected_generation.as_deref()
612 || control_revision(current.as_ref()) != *expected_revision
613 {
614 return Err("stale_generation_or_revision");
615 }
616 if policy.active.get().is_some() {
617 return Err("generation_already_started_this_activation");
618 }
619 if current.as_ref().is_some_and(|s| {
620 matches!(
621 s.status,
622 LoopStatus::Running | LoopStatus::Finalizing | LoopStatus::RecoveryUnsupported
623 )
624 }) {
625 return Err("prior_generation_not_retired");
626 }
627 let spent = activation_rounds_spent;
628 let ceiling = policy
629 .max_total_rounds
630 .min(config.run_budget.max_rounds.unwrap_or(MAX_LOOP_ROUNDS));
631 if *max_iterations > policy.max_iterations
632 || *max_total_rounds > ceiling
633 || *max_total_rounds <= spent
634 || *max_iterations > max_total_rounds.saturating_sub(spent)
635 {
636 return Err("host_or_remaining_budget_exceeded");
637 }
638 let generation = uuid::Uuid::new_v4().to_string();
639 LoopState {
640 schema_version: 2,
641 generation,
642 revision: 1,
643 objective_revision: 1,
644 owner_execution_id: execution_id.into(),
645 objective: objective.clone(),
646 max_iterations: *max_iterations,
647 max_total_rounds: *max_total_rounds,
648 iterations_started: 0,
649 iterations_completed: 0,
650 rounds_started: spent,
651 status: LoopStatus::Running,
652 control: Some(ManagedLoopState {
653 objective_origin: origin.cloned(),
654 control_revision: 1,
655 applied_revision: 0,
656 iteration_objective_revision: 0,
657 stop_requested: false,
658 pending_objective: None,
659 operations: Vec::new(),
660 }),
661 }
662 }
663 LoopControlRequest::Update {
664 generation,
665 expected_revision,
666 objective,
667 max_iterations,
668 max_total_rounds,
669 ..
670 } => {
671 let mut state = current.ok_or("loop_absent")?;
672 validate_current(policy, &state, execution_id, generation, *expected_revision)?;
673 let control = state
674 .control
675 .as_mut()
676 .ok_or("managed_generation_required")?;
677 if control.stop_requested {
678 return Err("stop_is_terminal");
679 }
680 if objective.is_some() && control.pending_objective.is_some() {
681 return Err("objective_update_pending");
682 }
683 let iterations = max_iterations.unwrap_or(state.max_iterations);
684 let rounds = max_total_rounds.unwrap_or(state.max_total_rounds);
685 if iterations > state.max_iterations
686 || rounds > state.max_total_rounds
687 || iterations < state.iterations_started
688 || rounds < state.rounds_started
689 || iterations == 0
690 || rounds < iterations
691 {
692 return Err("bounds_may_only_tighten_without_reset");
693 }
694 let required_rounds =
698 1 + u32::from(state.iterations_started > state.iterations_completed);
699 if (control.pending_objective.is_some()
700 || objective
701 .as_ref()
702 .is_some_and(|text| *text != state.objective))
703 && (iterations <= state.iterations_started
704 || rounds.saturating_sub(state.rounds_started) < required_rounds)
705 {
706 return Err("no_budget_for_new_objective");
707 }
708 control.control_revision = control
709 .control_revision
710 .checked_add(1)
711 .filter(|r| *r < u64::MAX)
712 .ok_or("revision_exhausted")?;
713 state.max_iterations = iterations;
714 state.max_total_rounds = rounds;
715 if let Some(objective) = objective.as_ref().filter(|text| **text != state.objective) {
716 control.pending_objective = Some(PendingLoopObjective {
717 objective: objective.clone(),
718 origin: origin.cloned().or_else(|| control.objective_origin.clone()),
719 objective_revision: state
720 .objective_revision
721 .checked_add(1)
722 .filter(|r| *r < u64::MAX)
723 .ok_or("revision_exhausted")?,
724 });
725 } else if control.pending_objective.is_none() && state.iterations_started > 0 {
726 control.applied_revision = control.control_revision;
727 }
728 state
729 }
730 LoopControlRequest::Stop {
731 generation,
732 expected_revision,
733 ..
734 } => {
735 let mut state = current.ok_or("loop_absent")?;
736 validate_current(policy, &state, execution_id, generation, *expected_revision)?;
737 let control = state
738 .control
739 .as_mut()
740 .ok_or("managed_generation_required")?;
741 if !control.stop_requested {
742 control.control_revision = control
743 .control_revision
744 .checked_add(1)
745 .filter(|r| *r < u64::MAX)
746 .ok_or("revision_exhausted")?;
747 }
748 control.stop_requested = true;
749 control.pending_objective = None;
750 control.applied_revision = control.control_revision;
751 state
752 }
753 LoopControlRequest::Status => unreachable!("status is not a mutation"),
754 };
755 if origin.is_some_and(|origin| {
756 state.max_iterations > origin.max_iterations
757 || state.max_total_rounds > origin.max_total_rounds
758 }) {
759 return Err("effective_bounds_exceed_remote_host_grant");
760 }
761 let control = state.control.as_mut().expect("managed state");
762 if control.operations.len() >= MAX_OPERATIONS {
763 return Err("operation_limit_exhausted");
764 }
765 let pending = control.pending_objective.as_ref();
766 let applied = control.applied_revision == control.control_revision;
767 control.operations.push(StoredLoopOperation {
768 payload_sha256: hash,
769 origin: origin.cloned(),
770 receipt: LoopControlReceipt {
771 operation_id: operation_id(command).expect("mutation id").into(),
772 generation: state.generation.clone(),
773 accepted_revision: control.control_revision,
774 applied_revision_at_acceptance: control.applied_revision,
775 objective_revision: pending.map_or(state.objective_revision, |p| p.objective_revision),
776 effective_iteration: (!applied).then_some(state.iterations_started.saturating_add(1)),
777 applied,
778 },
779 });
780 counted_loop::encode_next_state(&state).map_err(|_| "state_checkpoint_bound_exceeded")?;
783 Ok(state)
784}
785
786fn install_command_effects(
787 policy: &LoopControlConfig,
788 state: &LoopState,
789 command: &LoopControlRequest,
790) -> Result<(), &'static str> {
791 if matches!(command, LoopControlRequest::Start { .. }) {
792 let mut counted = policy.owner.clone();
793 counted.max_iterations = state.max_iterations;
794 counted.max_total_rounds = state.max_total_rounds;
795 counted.replace_stop(policy.stop.child_token());
796 policy
797 .active
798 .set(ManagedActivation {
799 generation: state.generation.clone(),
800 counted,
801 })
802 .map_err(|_| "generation_already_started_this_activation")?;
803 } else if matches!(command, LoopControlRequest::Stop { .. }) {
804 policy
805 .active_counted()
806 .ok_or("managed_generation_required")?
807 .stop();
808 }
809 Ok(())
810}
811
812fn validate_current(
813 policy: &LoopControlConfig,
814 state: &LoopState,
815 execution_id: &str,
816 generation: &str,
817 expected_revision: u64,
818) -> Result<(), &'static str> {
819 if state.generation != generation || control_revision(Some(state)) != expected_revision {
820 return Err("stale_generation_or_revision");
821 }
822 if policy.generation() != Some(generation) || state.owner_execution_id != execution_id {
823 return Err("generation_not_current_activation");
824 }
825 if state.status != LoopStatus::Running {
826 return Err("generation_not_running");
827 }
828 Ok(())
829}
830
831async fn checkpoint_result(
832 session: &mut Session,
833 config: &AgentLoopConfig,
834 policy: &LoopControlConfig,
835 tool_call_id: &str,
836 result: ToolResult,
837 state: Option<LoopState>,
838 max_tool_output_tokens: u32,
839) -> Result<ToolResult, AgentError> {
840 let result = output_limits::bound_result(result, max_tool_output_tokens);
841 let mut staged = session.clone();
842 if let Some(state) = state {
843 counted_loop::stage_state(&mut staged, state)?;
844 }
845 staged.add_message(Message::tool_result_with_status(
846 tool_call_id,
847 result.result.clone(),
848 result.success,
849 ));
850 persist_staged(session, config, policy, staged).await?;
851 Ok(result)
852}
853
854pub(crate) async fn finish_checkpointed_result(
858 session: &mut Session,
859 config: &AgentLoopConfig,
860 tool_call_id: &str,
861 result: &ToolResult,
862 runtime_state: &mut bamboo_domain::AgentRuntimeState,
863 feedback_context: Option<String>,
864) -> Result<(), AgentError> {
865 let policy = config
866 .loop_control
867 .as_ref()
868 .ok_or_else(|| fatal("host capability absent"))?;
869 let authority = policy.validate_current_owner(session).await;
870 if policy.execution_cancel()?.is_cancelled() {
871 return Err(AgentError::Cancelled);
872 }
873 authority?;
874 validate_checkpointed_pair(session, tool_call_id, result)?;
875 if let Some(context) = feedback_context {
876 let expected_index = runtime_state.hook_contexts.len();
877 let mut next_runtime = runtime_state.clone();
878 next_runtime.hook_contexts.push(context.clone());
879 let mut staged = session.clone();
880 crate::runtime::runner::state_bridge::write_runtime_state(&mut staged, &next_runtime);
881 let checkpoint = persist_staged(session, config, policy, staged).await;
882 if let Some(saved_runtime) =
885 crate::runtime::runner::state_bridge::read_runtime_state(session)
886 {
887 *runtime_state = saved_runtime;
888 }
889 checkpoint?;
890 if runtime_state.hook_contexts.len() != expected_index + 1
891 || runtime_state.hook_contexts.get(expected_index) != Some(&context)
892 {
893 return Err(fatal("hook feedback checkpoint did not retain context"));
894 }
895 validate_checkpointed_pair(session, tool_call_id, result)?;
896 }
897 if policy.execution_cancel()?.is_cancelled() {
899 return Err(AgentError::Cancelled);
900 }
901 Ok(())
902}
903
904fn validate_checkpointed_pair(
905 session: &Session,
906 tool_call_id: &str,
907 result: &ToolResult,
908) -> Result<(), AgentError> {
909 let mut pairs = session.messages.iter().filter(|message| {
910 message.role == bamboo_domain::Role::Tool
911 && message.tool_call_id.as_deref() == Some(tool_call_id)
912 });
913 let pair = pairs
914 .next()
915 .ok_or_else(|| fatal("checkpointed tool result absent"))?;
916 if pairs.next().is_some()
917 || pair.content != result.result
918 || pair.tool_success != Some(result.success)
919 {
920 return Err(fatal("checkpointed tool result identity changed"));
921 }
922 Ok(())
923}
924
925async fn persist_staged(
926 session: &mut Session,
927 config: &AgentLoopConfig,
928 policy: &LoopControlConfig,
929 mut staged: Session,
930) -> Result<(), AgentError> {
931 let checkpoint = scoped::persist_current_checkpoint(&mut staged, config).await;
932 *session = staged;
936 if policy
937 .execution
938 .get()
939 .is_some_and(|e| e.cancel.is_cancelled())
940 {
941 if let Err(error) = &checkpoint {
942 tracing::warn!(%error, "loop control checkpoint failed during user cancellation");
943 }
944 return Err(AgentError::Cancelled);
945 }
946 checkpoint
947}
948
949#[cfg(test)]
950mod tests;
951
952#[cfg(test)]
953mod scoped_tests;