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