Skip to main content

bamboo_engine/runtime/
loop_control.rs

1//! Host-authorized, live self-control over the existing counted runner (#1991).
2//! No target lookup, timer, restart manager or execution authority lives here.
3
4use 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/// Explicit host ceilings for a model-managed loop in one existing activation.
30/// Opting in does not start a loop. Clones share only the host's sticky stop;
31/// consuming a real lease creates fresh per-execution activation slots.
32#[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    /// Separately opt this one activation into remote Supervisor management.
84    /// Attachment and the self-control opt-in alone never install this grant.
85    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    /// Revoke this host capability and stop any loop using its child token.
101    /// A model stop never cancels this parent or another host request.
102    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/// Schema-two management data inside the existing LoopState checkpoint.
227/// State revision advances on every checkpoint; control revision only on a
228/// newly accepted command. Receipts are compact and bounded, never a backlog.
229#[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
333/// Inspect only an already-authorized Session value. This grants no target read,
334/// writer, activation or recovery capability.
335pub 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/// Called only for an exact admitted loop_control identity, after the executor's
406/// permission decision, on the existing runner's serial apply path. The tool
407/// itself can validate a command but cannot invoke this authority boundary.
408#[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        // The child/status observation may yield. It grants no stale owner or
447        // cancelled execution permission to install a generation afterwards.
448        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            // Idempotency precedes CAS only for this live activation's generation.
482            // A retired receipt never makes an old command current again.
483            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                        // No command, OnceLock or stop side effect is installed
516                        // unless its entire immutable acknowledgement fits.
517                        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/// One proposal algorithm for the self tool and the target's serial Inbox
573/// boundary. The caller supplies rounds already charged; remote admission
574/// before before_round must not charge a fictional tool/provider round.
575#[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            // A tool-containing round cannot also finish its active
695            // iteration. Keep its later terminal round plus the pending
696            // objective's admission; before the first iteration only one.
697            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    // A valid text-byte bound can expand under JSON escaping. Check the exact
781    // persisted representation before any OnceLock or cancellation side effect.
782    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
854/// Keep the first-checkpoint pair immutable. Native post-tool feedback uses
855/// the existing volatile hook-context plane; the append-safe checkpoint cannot
856/// rewrite a durable ToolResult and no history-rewrite authority is granted.
857pub(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        // Preserve the same conservative context in the loop's local state as
883        // in its staged Session even if the checkpoint outcome was uncertain.
884        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    // Includes the no-feedback path: cancellation during AfterToolUse wins.
898    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    // An uncertain write reserves this operation/consumption conservatively.
933    // The original error aborts the runner; its one known generation is Failed
934    // (or Cancelled), never replayed or silently started a second time.
935    *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;