1use bamboo_agent_core::{AgentError, Message, Session};
8use serde::{Deserialize, Serialize};
9use tokio_util::sync::CancellationToken;
10
11use super::config::AgentLoopConfig;
12
13pub const LOOP_STATE_METADATA_KEY: &str = "runtime.counted_loop";
14pub const MAX_LOOP_ITERATIONS: u32 = 100;
15pub const MAX_LOOP_ROUNDS: u32 = 1_000;
16pub const MAX_LOOP_PROMPT_BYTES: usize = 16 * 1024;
17const MAX_STATE_BYTES: usize = MAX_LOOP_PROMPT_BYTES * 8;
18
19#[derive(Clone)]
23pub struct CountedLoopConfig {
24 pub max_iterations: u32,
25 pub max_total_rounds: u32,
26 stop: CancellationToken,
27 owner: Option<CountedLoopOwner>,
28}
29
30#[derive(Clone)]
31struct CountedLoopOwner {
32 run: crate::session_activation::SessionRunOwnership,
33 session_birth: chrono::DateTime<chrono::Utc>,
34}
35
36impl std::fmt::Debug for CountedLoopConfig {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("CountedLoopConfig")
39 .field("max_iterations", &self.max_iterations)
40 .field("max_total_rounds", &self.max_total_rounds)
41 .field("stopped", &self.stop.is_cancelled())
42 .finish_non_exhaustive()
43 }
44}
45
46impl CountedLoopConfig {
47 pub fn new(max_iterations: u32, max_total_rounds: u32) -> Self {
48 Self {
49 max_iterations,
50 max_total_rounds,
51 stop: CancellationToken::new(),
52 owner: None,
53 }
54 }
55
56 pub(crate) fn bind_owner(
59 &mut self,
60 run: crate::session_activation::SessionRunOwnership,
61 session: &Session,
62 ) {
63 self.owner = Some(CountedLoopOwner {
64 run,
65 session_birth: session.created_at,
66 });
67 }
68
69 pub(crate) fn ownership(&self) -> Option<crate::session_activation::SessionRunOwnership> {
70 self.owner.as_ref().map(|owner| owner.run.clone())
71 }
72
73 pub(crate) async fn owns_session(
74 &self,
75 router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
76 session: &Session,
77 ) -> bool {
78 let Some(owner) = self.owner.as_ref() else {
79 return false;
80 };
81 owner.session_birth == session.created_at
82 && router.is_some_and(|router| owner.run.matches_router(router))
83 && owner.run.is_current(&session.id).await
84 }
85
86 pub(crate) async fn has_current_owner(&self, session: &Session) -> bool {
87 match self.owner.as_ref() {
88 Some(owner) => {
89 owner.session_birth == session.created_at && owner.run.is_current(&session.id).await
90 }
91 None => false,
92 }
93 }
94
95 pub(crate) fn replace_stop(&mut self, stop: CancellationToken) {
98 self.stop = stop;
99 }
100
101 pub fn stop(&self) {
102 self.stop.cancel();
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum LoopStatus {
109 Running,
110 Finalizing,
111 Completed,
112 Stopped,
113 Cancelled,
114 BudgetExhausted,
115 Failed,
116 RecoveryUnsupported,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct LoopState {
125 pub schema_version: u32,
126 pub generation: String,
127 pub revision: u64,
128 pub objective_revision: u64,
129 pub owner_execution_id: String,
130 pub objective: String,
131 pub max_iterations: u32,
132 pub max_total_rounds: u32,
133 pub iterations_started: u32,
134 pub iterations_completed: u32,
135 pub rounds_started: u32,
138 pub status: LoopStatus,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub control: Option<super::loop_control::ManagedLoopState>,
141}
142
143fn invalid(reason: &str) -> AgentError {
144 AgentError::Budget(format!("counted loop: {reason}"))
145}
146
147fn validate_bounds(iterations: u32, rounds: u32, objective: &str) -> Result<(), AgentError> {
148 if !(1..=MAX_LOOP_ITERATIONS).contains(&iterations)
149 || !(iterations..=MAX_LOOP_ROUNDS).contains(&rounds)
150 || objective.trim().is_empty()
151 || objective.len() > MAX_LOOP_PROMPT_BYTES
152 {
153 return Err(invalid("invalid iteration, total-round, or prompt bound"));
154 }
155 Ok(())
156}
157
158impl LoopState {
159 fn validate(&self) -> Result<(), AgentError> {
160 validate_bounds(self.max_iterations, self.max_total_rounds, &self.objective)?;
161 let supported_schema = match (self.schema_version, self.control.as_ref()) {
162 (1, None) => self.objective_revision == 1,
163 (2, Some(control)) => {
164 control.validate(self)?;
165 self.objective_revision > 0 && self.objective_revision < u64::MAX
166 }
167 _ => false,
168 };
169 if !supported_schema
170 || self.revision == 0
171 || self.revision == u64::MAX
172 || uuid::Uuid::parse_str(&self.generation).is_err()
173 || self.owner_execution_id.is_empty()
174 || self.owner_execution_id.len() > 128
175 || self.iterations_started > self.max_iterations
176 || self.iterations_completed > self.iterations_started
177 || self.iterations_started - self.iterations_completed > 1
178 || self.rounds_started > self.max_total_rounds
179 || self.rounds_started < self.iterations_started
180 || (matches!(self.status, LoopStatus::Finalizing | LoopStatus::Completed)
181 && self.iterations_completed != self.max_iterations)
182 || (self.status == LoopStatus::Running
183 && self.iterations_completed == self.max_iterations)
184 {
185 return Err(invalid("malformed or unsupported persisted state"));
186 }
187 Ok(())
188 }
189
190 fn live(&self) -> bool {
191 matches!(self.status, LoopStatus::Running | LoopStatus::Finalizing)
192 }
193}
194
195pub fn read_loop_state(session: &Session) -> Result<Option<LoopState>, AgentError> {
197 let Some(raw) = session.metadata.get(LOOP_STATE_METADATA_KEY) else {
198 return Ok(None);
199 };
200 if raw.len() > MAX_STATE_BYTES {
201 return Err(invalid("persisted state exceeds its byte bound"));
202 }
203 let state: LoopState = serde_json::from_str(raw)
204 .map_err(|_| invalid("malformed or unsupported persisted state"))?;
205 state.validate()?;
206 Ok(Some(state))
207}
208
209async fn checkpoint(
210 session: &mut Session,
211 config: &AgentLoopConfig,
212 state: LoopState,
213) -> Result<(), AgentError> {
214 stage_state(session, state)?;
215 super::loop_control::scoped::persist_current_checkpoint(session, config).await
216}
217
218async fn checkpoint_state(
219 session: &mut Session,
220 persistence: Option<&dyn bamboo_domain::RuntimeSessionPersistence>,
221 state: LoopState,
222) -> Result<(), AgentError> {
223 stage_state(session, state)?;
224 persist_checkpoint(session, persistence).await
225}
226
227pub(crate) fn encode_next_state(state: &LoopState) -> Result<String, AgentError> {
230 let mut state = state.clone();
231 state.revision = state
232 .revision
233 .checked_add(1)
234 .ok_or_else(|| invalid("state revision exhausted"))?;
235 state.validate()?;
236 let encoded = serde_json::to_string(&state).map_err(|_| invalid("cannot serialize state"))?;
237 if encoded.len() > MAX_STATE_BYTES {
238 return Err(invalid("persisted state exceeds its byte bound"));
239 }
240 Ok(encoded)
241}
242
243pub(crate) fn stage_state(session: &mut Session, state: LoopState) -> Result<String, AgentError> {
244 let encoded = encode_next_state(&state)?;
245 session
246 .metadata
247 .insert(LOOP_STATE_METADATA_KEY.into(), encoded.clone());
248 Ok(encoded)
249}
250
251pub(crate) async fn persist_checkpoint(
252 session: &mut Session,
253 persistence: Option<&dyn bamboo_domain::RuntimeSessionPersistence>,
254) -> Result<(), AgentError> {
255 if let Some(persistence) = persistence {
256 persistence
257 .checkpoint_runtime_session(session)
258 .await
259 .map_err(|_| invalid("Session checkpoint failed; execution not admitted"))?;
260 }
261 Ok(())
262}
263
264pub(crate) fn reject_recovery(session: &Session) -> Result<(), AgentError> {
267 if read_loop_state(session)?.is_some_and(|state| {
268 state.live()
269 || state.status == LoopStatus::RecoveryUnsupported
270 || has_pending_repeat(session, &state)
271 }) {
272 return Err(invalid(
273 "recovery unsupported; the prior admission is not replayed",
274 ));
275 }
276 Ok(())
277}
278
279fn has_pending_repeat(session: &Session, state: &LoopState) -> bool {
282 if state.iterations_started == state.iterations_completed {
283 return false;
284 }
285 let counted = |message: &Message| {
286 message
287 .metadata
288 .as_ref()
289 .and_then(|meta| meta.get("runtime_kind"))
290 .and_then(serde_json::Value::as_str)
291 == Some("counted_loop_iteration")
292 };
293 let pending = session.messages.iter().rposition(|message| {
294 message.role == bamboo_agent_core::Role::User
295 && counted(message)
296 && message.metadata.as_ref().is_some_and(|meta| {
297 meta.get("loop_generation")
298 .and_then(serde_json::Value::as_str)
299 == Some(state.generation.as_str())
300 && meta
301 .get("objective_revision")
302 .and_then(serde_json::Value::as_u64)
303 == Some(state.objective_revision)
304 && meta.get("iteration").and_then(serde_json::Value::as_u64)
305 == Some(u64::from(state.iterations_started))
306 })
307 });
308 pending.is_some_and(|position| {
309 !session.messages[position + 1..].iter().any(|message| {
310 message.role == bamboo_agent_core::Role::User
311 && !counted(message)
312 && !bamboo_domain::session::is_system_resume_message(message)
313 })
314 })
315}
316
317pub(crate) async fn reject_ambiguous_reentry(
321 session: &Session,
322 persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
323) -> Result<(), AgentError> {
324 reject_recovery(session)?;
325 let Some(state) = read_loop_state(session)? else {
326 return Ok(());
327 };
328 if state.iterations_started == state.iterations_completed {
329 return Ok(());
330 }
331 let durable = persistence
332 .load_runtime_admission_snapshot(&session.id)
333 .await
334 .map_err(|_| invalid("cannot inspect durable state; recovery unsupported"))?;
335 let Some(durable) = durable else {
336 return Err(invalid(
337 "recovery unsupported; durable state missing; reload required",
338 ));
339 };
340 reject_recovery(&durable)?;
341 if durable.created_at != session.created_at
342 || read_loop_state(&durable)?.as_ref() != Some(&state)
343 {
344 return Err(invalid(
345 "recovery unsupported; durable generation or revision changed; reload required",
346 ));
347 }
348 Ok(())
349}
350
351fn interrupted_runtime_matches(
352 session_id: &str,
353 runtime: &bamboo_domain::AgentRuntimeState,
354 state: &LoopState,
355) -> bool {
356 match runtime.execution_id.as_deref() {
357 Some(id) => id == state.owner_execution_id,
358 None => runtime.round.last_round_id.as_ref().is_some_and(|round| {
361 *round
362 == crate::runtime::runner::round_prelude::build_round_id(
363 session_id,
364 &state.owner_execution_id,
365 runtime.round.current_round as usize,
366 )
367 }),
368 }
369}
370
371fn fail_interrupted_runtime(session: &mut Session, state: &LoopState) {
372 if let Some(runtime) = session.agent_runtime_state.as_mut() {
373 if interrupted_runtime_matches(&session.id, runtime, state)
374 && matches!(
375 runtime.status,
376 bamboo_domain::AgentStatusState::Initializing
377 | bamboo_domain::AgentStatusState::Running
378 | bamboo_domain::AgentStatusState::Finalizing
379 | bamboo_domain::AgentStatusState::Completed
380 )
381 {
382 runtime.status = bamboo_domain::AgentStatusState::Failed;
383 }
384 }
385}
386
387pub(crate) async fn admit_registered(
392 session: &mut Session,
393 ownership: Option<&crate::session_activation::SessionRunOwnership>,
394 router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
395 persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
396) -> Result<(), AgentError> {
397 let presented = read_loop_state(session)?;
398 let durable = persistence
400 .load_runtime_admission_snapshot(&session.id)
401 .await
402 .map_err(|_| invalid("cannot inspect durable state; execution not admitted"))?;
403 let stored = durable.as_ref().map(read_loop_state).transpose()?.flatten();
404 if presented != stored
405 || (stored.is_some()
406 && durable
407 .as_ref()
408 .is_some_and(|fresh| fresh.created_at != session.created_at))
409 {
410 return Err(invalid(
411 "recovery unsupported; durable generation or revision changed; reload required",
412 ));
413 }
414 let Some(mut state) = stored else {
415 return Ok(());
416 };
417 if state.status == LoopStatus::RecoveryUnsupported {
418 return reject_recovery(session);
419 }
420 if !state.live() {
421 reject_recovery(session)?;
422 if let Some(fresh) = durable.as_ref() {
426 reject_recovery(fresh)?;
427 }
428 return Ok(());
429 }
430 let Some(ownership) = ownership else {
431 return reject_recovery(session);
432 };
433 if !router.is_some_and(|router| ownership.matches_router(router))
434 || !ownership.is_current(&session.id).await
435 {
436 return Err(invalid(
437 "recovery unsupported; exact activation owner required",
438 ));
439 }
440 if durable
441 .as_ref()
442 .is_some_and(|fresh| persistence.root_actor_execution_required(fresh))
443 {
444 return Err(invalid("recovery unsupported; bound Actor writer required"));
445 }
446 let mut staged = durable.expect("stored state requires durable Session");
449 fail_interrupted_runtime(&mut staged, &state);
450 state.status = LoopStatus::RecoveryUnsupported;
451 checkpoint_state(&mut staged, Some(persistence), state.clone()).await?;
452 session.metadata.insert(
455 LOOP_STATE_METADATA_KEY.into(),
456 staged.metadata[LOOP_STATE_METADATA_KEY].clone(),
457 );
458 fail_interrupted_runtime(session, &state);
459 reject_recovery(session)
460}
461
462pub(crate) async fn preflight(
465 session: &mut Session,
466 config: &AgentLoopConfig,
467 prompt: &str,
468) -> Result<(), AgentError> {
469 reject_recovery(session)?;
470 if let Some(control) = config.loop_control.as_ref() {
471 if config.counted_loop.is_some()
472 || config.ticket_worker_plan.is_some()
473 || config.gold_config.as_ref().is_some_and(|gold| gold.enabled)
474 || config.persistence.is_none()
475 {
476 return Err(invalid(
477 "self control requires an exclusive durable non-Gold execution",
478 ));
479 }
480 control.validate_current_owner(session).await?;
481 if !config
482 .run_budget
483 .max_rounds
484 .is_some_and(|cap| cap <= control.max_total_rounds)
485 {
486 return Err(invalid(
487 "self control host round ceiling must already be enforced",
488 ));
489 }
490 }
491 if let Some(counted) = config.effective_counted_loop() {
492 validate_bounds(counted.max_iterations, counted.max_total_rounds, prompt)?;
493 validate_bounds(
494 counted.max_iterations,
495 effective_round_cap(counted, config),
496 prompt,
497 )?;
498 if config.gold_config.as_ref().is_some_and(|gold| gold.enabled) {
501 return Err(invalid(
502 "enabled Gold and counted repetition cannot be combined",
503 ));
504 }
505 if counted.stop.is_cancelled() {
506 return Err(invalid("the supplied generation was already stopped"));
507 }
508 if config.skip_initial_user_message
509 && !session.messages.last().is_some_and(|message| {
510 message.role == bamboo_agent_core::Role::User
511 && message.content == prompt
512 && !bamboo_domain::session::is_system_resume_message(message)
513 })
514 {
515 return Err(invalid(
516 "the initial prompt must be the current admitted User",
517 ));
518 }
519 }
520 Ok(())
521}
522
523fn effective_round_cap(counted: &CountedLoopConfig, config: &AgentLoopConfig) -> u32 {
524 config
525 .run_budget
526 .max_rounds
527 .map_or(counted.max_total_rounds, |cap| {
528 cap.min(counted.max_total_rounds)
529 })
530}
531
532pub(crate) async fn begin(
533 session: &mut Session,
534 config: &AgentLoopConfig,
535 execution_id: &str,
536 prompt: &str,
537) -> Result<Option<String>, AgentError> {
538 let Some(counted) = config.effective_counted_loop() else {
539 return Ok(None);
540 };
541 validate_bounds(
542 counted.max_iterations,
543 effective_round_cap(counted, config),
544 prompt,
545 )?;
546 let generation = uuid::Uuid::new_v4().to_string();
547 let state = LoopState {
548 schema_version: 1,
549 generation: generation.clone(),
550 revision: 1,
551 objective_revision: 1,
552 owner_execution_id: execution_id.into(),
553 objective: prompt.into(),
554 max_iterations: counted.max_iterations,
555 max_total_rounds: effective_round_cap(counted, config),
556 iterations_started: 0,
557 iterations_completed: 0,
558 rounds_started: 0,
559 status: LoopStatus::Running,
560 control: None,
561 };
562 if let Err(error) = checkpoint(session, config, state).await {
563 let failure = Err(error);
567 if let Err(checkpoint_error) = finish(session, config, Some(&generation), &failure).await {
568 tracing::warn!(%checkpoint_error, "counted-loop initial failure checkpoint failed");
569 }
570 return failure.map(|()| Some(generation));
571 }
572 Ok(Some(generation))
573}
574
575fn active_state(
576 session: &Session,
577 config: &AgentLoopConfig,
578) -> Result<Option<LoopState>, AgentError> {
579 if config.effective_counted_loop().is_none() {
580 return Ok(None);
581 }
582 Ok(Some(
583 read_loop_state(session)?.ok_or_else(|| invalid("missing live state"))?,
584 ))
585}
586
587pub(crate) async fn before_round(
590 session: &mut Session,
591 config: &AgentLoopConfig,
592 execution_id: &str,
593) -> Result<bool, AgentError> {
594 let Some(mut state) = active_state(session, config)? else {
595 return Ok(true);
596 };
597 if state.owner_execution_id != execution_id {
598 return Err(invalid("execution owner changed"));
599 }
600 if let Some(owner) = config
601 .effective_counted_loop()
602 .and_then(|counted| counted.owner.as_ref())
603 {
604 if owner.session_birth != session.created_at || !owner.run.is_current(&session.id).await {
605 return Err(invalid("execution ownership is no longer current"));
606 }
607 }
608 if !state.live() {
609 return Ok(false);
610 }
611 if config
612 .effective_counted_loop()
613 .expect("counted config")
614 .stop
615 .is_cancelled()
616 {
617 state.status = LoopStatus::Stopped;
618 checkpoint(session, config, state).await?;
619 return Ok(false);
620 }
621 if state.rounds_started == state.max_total_rounds {
622 state.status = LoopStatus::BudgetExhausted;
623 session.metadata.insert(
624 "runtime.completion_reason".into(),
625 "counted_loop_round_limit".into(),
626 );
627 checkpoint(session, config, state).await?;
628 return Ok(false);
629 }
630 let mut staged_repeat = None;
631 if state.status == LoopStatus::Running && state.iterations_started == state.iterations_completed
632 {
633 if let Some(control) = state.control.as_mut() {
634 if let Some(pending) = control.pending_objective.take() {
635 state.objective = pending.objective;
636 state.objective_revision = pending.objective_revision;
637 control.objective_origin = pending.origin;
638 }
639 control.applied_revision = control.control_revision;
640 control.iteration_objective_revision = state.objective_revision;
641 }
642 if state.iterations_started > 0 || state.control.is_some() {
643 let mut message = Message::user(state.objective.clone());
644 message.metadata = Some(serde_json::json!({
645 "runtime_kind": "counted_loop_iteration",
646 "loop_generation": state.generation,
647 "objective_revision": state.objective_revision,
648 "iteration": state.iterations_started + 1,
649 }));
650 let mut staged = session.clone();
651 staged.add_message(message);
652 staged_repeat = Some(staged);
653 }
654 state.iterations_started += 1;
655 }
656 state.rounds_started += 1;
657 if let Some(mut staged) = staged_repeat {
658 let admitted_state = stage_state(&mut staged, state)?;
662 if let Err(error) =
663 super::loop_control::scoped::persist_current_checkpoint(&mut staged, config).await
664 {
665 if bamboo_domain::scoped_loop_objective_origins(&staged)
666 .is_ok_and(|origins| !origins.is_empty())
667 || staged
668 .metadata
669 .contains_key(bamboo_domain::SCOPED_LOOP_UNCERTAIN_KEY)
670 {
671 *session = staged;
675 } else {
676 session
677 .metadata
678 .insert(LOOP_STATE_METADATA_KEY.into(), admitted_state);
679 }
680 return Err(error);
681 }
682 *session = staged;
683 } else {
684 checkpoint(session, config, state).await?;
685 }
686 Ok(true)
687}
688
689pub(crate) enum TerminalDecision {
690 Continue,
691 Stop,
692 Finalize,
693}
694
695pub(crate) async fn exhaust_budget(
696 session: &mut Session,
697 config: &AgentLoopConfig,
698) -> Result<(), AgentError> {
699 let Some(mut state) = active_state(session, config)? else {
700 if config.loop_control.is_some() {
701 session
702 .metadata
703 .insert("runtime.completion_reason".into(), "budget_exceeded".into());
704 }
705 return Ok(());
706 };
707 state.status = LoopStatus::BudgetExhausted;
708 session.metadata.insert(
709 "runtime.completion_reason".into(),
710 "counted_loop_budget_exhausted".into(),
711 );
712 checkpoint(session, config, state).await
713}
714
715pub(crate) async fn terminal(
716 session: &mut Session,
717 config: &AgentLoopConfig,
718) -> Result<TerminalDecision, AgentError> {
719 let Some(mut state) = active_state(session, config)? else {
720 return Ok(TerminalDecision::Finalize);
721 };
722 if state.status == LoopStatus::Finalizing {
723 return Ok(TerminalDecision::Finalize);
724 }
725 if state.status != LoopStatus::Running
726 || state.iterations_started != state.iterations_completed + 1
727 {
728 return Err(invalid("terminal without one admitted iteration"));
729 }
730 if state
731 .control
732 .as_ref()
733 .is_some_and(|control| control.iteration_objective_revision != state.objective_revision)
734 {
735 return Err(invalid("admitted objective revision changed"));
736 }
737 state.iterations_completed += 1;
738 let decision = if config
739 .effective_counted_loop()
740 .expect("counted config")
741 .stop
742 .is_cancelled()
743 {
744 state.status = LoopStatus::Stopped;
745 TerminalDecision::Stop
746 } else if state.iterations_completed == state.max_iterations
747 && state
748 .control
749 .as_ref()
750 .is_some_and(|c| c.pending_objective.is_some())
751 {
752 state.status = LoopStatus::BudgetExhausted;
753 session.metadata.insert(
754 "runtime.completion_reason".into(),
755 "loop_objective_unapplied_budget".into(),
756 );
757 TerminalDecision::Stop
758 } else if state.iterations_completed == state.max_iterations {
759 state.status = LoopStatus::Finalizing;
760 TerminalDecision::Finalize
761 } else {
762 TerminalDecision::Continue
763 };
764 checkpoint(session, config, state).await?;
765 Ok(decision)
766}
767
768pub(crate) async fn finish(
771 session: &mut Session,
772 config: &AgentLoopConfig,
773 generation: Option<&str>,
774 result: &Result<(), AgentError>,
775) -> Result<(), AgentError> {
776 let Some(generation) = generation else {
777 return Ok(());
778 };
779 let Some(mut state) = read_loop_state(session)? else {
780 return Err(invalid("missing terminal state"));
781 };
782 if state.generation != generation {
783 return Err(invalid("generation changed during execution"));
784 }
785 state.status = match result {
789 Err(error) if error.is_cancelled() => LoopStatus::Cancelled,
790 Err(_) => match state.status {
791 LoopStatus::Failed | LoopStatus::Cancelled | LoopStatus::RecoveryUnsupported => {
792 state.status
793 }
794 _ => LoopStatus::Failed,
795 },
796 Ok(()) if !state.live() => state.status,
797 Ok(())
798 if session.metadata.contains_key("runtime.suspend_reason")
799 || session.agent_runtime_state.as_ref().is_some_and(|runtime| {
800 matches!(runtime.status, bamboo_domain::AgentStatusState::Suspended)
801 }) =>
802 {
803 LoopStatus::RecoveryUnsupported
804 }
805 Ok(()) => {
806 if let Some(reason) = session.metadata.get("runtime.completion_reason") {
807 match reason.as_str() {
808 "counted_loop_round_limit"
809 | "counted_loop_budget_exhausted"
810 | "budget_exceeded"
811 | "max_rounds_reached"
812 | "stop_hook_continuation_cap" => LoopStatus::BudgetExhausted,
813 _ => LoopStatus::RecoveryUnsupported,
815 }
816 } else if state.iterations_completed == state.max_iterations {
817 LoopStatus::Completed
818 } else {
819 LoopStatus::Failed
820 }
821 }
822 };
823 checkpoint(session, config, state).await
827}