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) async fn owns_session(
70 &self,
71 router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
72 session: &Session,
73 ) -> bool {
74 let Some(owner) = self.owner.as_ref() else {
75 return false;
76 };
77 owner.session_birth == session.created_at
78 && router.is_some_and(|router| owner.run.matches_router(router))
79 && owner.run.is_current(&session.id).await
80 }
81
82 pub fn stop(&self) {
85 self.stop.cancel();
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum LoopStatus {
92 Running,
93 Finalizing,
94 Completed,
95 Stopped,
96 Cancelled,
97 BudgetExhausted,
98 Failed,
99 RecoveryUnsupported,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct LoopState {
108 pub schema_version: u32,
109 pub generation: String,
110 pub revision: u64,
111 pub objective_revision: u64,
112 pub owner_execution_id: String,
113 pub objective: String,
114 pub max_iterations: u32,
115 pub max_total_rounds: u32,
116 pub iterations_started: u32,
117 pub iterations_completed: u32,
118 pub rounds_started: u32,
121 pub status: LoopStatus,
122}
123
124fn invalid(reason: &str) -> AgentError {
125 AgentError::Budget(format!("counted loop: {reason}"))
126}
127
128fn validate_bounds(iterations: u32, rounds: u32, objective: &str) -> Result<(), AgentError> {
129 if !(1..=MAX_LOOP_ITERATIONS).contains(&iterations)
130 || !(iterations..=MAX_LOOP_ROUNDS).contains(&rounds)
131 || objective.trim().is_empty()
132 || objective.len() > MAX_LOOP_PROMPT_BYTES
133 {
134 return Err(invalid("invalid iteration, total-round, or prompt bound"));
135 }
136 Ok(())
137}
138
139impl LoopState {
140 fn validate(&self) -> Result<(), AgentError> {
141 validate_bounds(self.max_iterations, self.max_total_rounds, &self.objective)?;
142 if self.schema_version != 1
143 || self.revision == 0
144 || self.revision == u64::MAX
145 || self.objective_revision != 1
146 || uuid::Uuid::parse_str(&self.generation).is_err()
147 || self.owner_execution_id.is_empty()
148 || self.owner_execution_id.len() > 128
149 || self.iterations_started > self.max_iterations
150 || self.iterations_completed > self.iterations_started
151 || self.iterations_started - self.iterations_completed > 1
152 || self.rounds_started > self.max_total_rounds
153 || self.rounds_started < self.iterations_started
154 || (matches!(self.status, LoopStatus::Finalizing | LoopStatus::Completed)
155 && self.iterations_completed != self.max_iterations)
156 || (self.status == LoopStatus::Running
157 && self.iterations_completed == self.max_iterations)
158 {
159 return Err(invalid("malformed or unsupported persisted state"));
160 }
161 Ok(())
162 }
163
164 fn live(&self) -> bool {
165 matches!(self.status, LoopStatus::Running | LoopStatus::Finalizing)
166 }
167}
168
169pub fn read_loop_state(session: &Session) -> Result<Option<LoopState>, AgentError> {
171 let Some(raw) = session.metadata.get(LOOP_STATE_METADATA_KEY) else {
172 return Ok(None);
173 };
174 if raw.len() > MAX_STATE_BYTES {
175 return Err(invalid("persisted state exceeds its byte bound"));
176 }
177 let state: LoopState = serde_json::from_str(raw)
178 .map_err(|_| invalid("malformed or unsupported persisted state"))?;
179 state.validate()?;
180 Ok(Some(state))
181}
182
183async fn checkpoint(
184 session: &mut Session,
185 config: &AgentLoopConfig,
186 state: LoopState,
187) -> Result<(), AgentError> {
188 checkpoint_state(session, config.persistence.as_deref(), state).await
189}
190
191async fn checkpoint_state(
192 session: &mut Session,
193 persistence: Option<&dyn bamboo_domain::RuntimeSessionPersistence>,
194 state: LoopState,
195) -> Result<(), AgentError> {
196 stage_state(session, state)?;
197 persist_checkpoint(session, persistence).await
198}
199
200fn stage_state(session: &mut Session, mut state: LoopState) -> Result<String, AgentError> {
201 state.revision = state
202 .revision
203 .checked_add(1)
204 .ok_or_else(|| invalid("state revision exhausted"))?;
205 state.validate()?;
206 let encoded = serde_json::to_string(&state).map_err(|_| invalid("cannot serialize state"))?;
207 session
208 .metadata
209 .insert(LOOP_STATE_METADATA_KEY.into(), encoded.clone());
210 Ok(encoded)
211}
212
213async fn persist_checkpoint(
214 session: &mut Session,
215 persistence: Option<&dyn bamboo_domain::RuntimeSessionPersistence>,
216) -> Result<(), AgentError> {
217 if let Some(persistence) = persistence {
218 persistence
219 .checkpoint_runtime_session(session)
220 .await
221 .map_err(|_| invalid("Session checkpoint failed; execution not admitted"))?;
222 }
223 Ok(())
224}
225
226pub(crate) fn reject_recovery(session: &Session) -> Result<(), AgentError> {
229 if read_loop_state(session)?.is_some_and(|state| {
230 state.live()
231 || state.status == LoopStatus::RecoveryUnsupported
232 || has_pending_repeat(session, &state)
233 }) {
234 return Err(invalid(
235 "recovery unsupported; the prior admission is not replayed",
236 ));
237 }
238 Ok(())
239}
240
241fn has_pending_repeat(session: &Session, state: &LoopState) -> bool {
244 if state.iterations_started == state.iterations_completed {
245 return false;
246 }
247 let counted = |message: &Message| {
248 message
249 .metadata
250 .as_ref()
251 .and_then(|meta| meta.get("runtime_kind"))
252 .and_then(serde_json::Value::as_str)
253 == Some("counted_loop_iteration")
254 };
255 let pending = session.messages.iter().rposition(|message| {
256 message.role == bamboo_agent_core::Role::User
257 && counted(message)
258 && message.metadata.as_ref().is_some_and(|meta| {
259 meta.get("loop_generation")
260 .and_then(serde_json::Value::as_str)
261 == Some(state.generation.as_str())
262 && meta
263 .get("objective_revision")
264 .and_then(serde_json::Value::as_u64)
265 == Some(state.objective_revision)
266 && meta.get("iteration").and_then(serde_json::Value::as_u64)
267 == Some(u64::from(state.iterations_started))
268 })
269 });
270 pending.is_some_and(|position| {
271 !session.messages[position + 1..].iter().any(|message| {
272 message.role == bamboo_agent_core::Role::User
273 && !counted(message)
274 && !bamboo_domain::session::is_system_resume_message(message)
275 })
276 })
277}
278
279pub(crate) async fn reject_ambiguous_reentry(
283 session: &Session,
284 persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
285) -> Result<(), AgentError> {
286 reject_recovery(session)?;
287 let Some(state) = read_loop_state(session)? else {
288 return Ok(());
289 };
290 if state.iterations_started == state.iterations_completed {
291 return Ok(());
292 }
293 let durable = persistence
294 .load_runtime_admission_snapshot(&session.id)
295 .await
296 .map_err(|_| invalid("cannot inspect durable state; recovery unsupported"))?;
297 let Some(durable) = durable else {
298 return Err(invalid(
299 "recovery unsupported; durable state missing; reload required",
300 ));
301 };
302 reject_recovery(&durable)?;
303 if durable.created_at != session.created_at
304 || read_loop_state(&durable)?.as_ref() != Some(&state)
305 {
306 return Err(invalid(
307 "recovery unsupported; durable generation or revision changed; reload required",
308 ));
309 }
310 Ok(())
311}
312
313fn interrupted_runtime_matches(
314 session_id: &str,
315 runtime: &bamboo_domain::AgentRuntimeState,
316 state: &LoopState,
317) -> bool {
318 match runtime.execution_id.as_deref() {
319 Some(id) => id == state.owner_execution_id,
320 None => runtime.round.last_round_id.as_ref().is_some_and(|round| {
323 *round
324 == crate::runtime::runner::round_prelude::build_round_id(
325 session_id,
326 &state.owner_execution_id,
327 runtime.round.current_round as usize,
328 )
329 }),
330 }
331}
332
333fn fail_interrupted_runtime(session: &mut Session, state: &LoopState) {
334 if let Some(runtime) = session.agent_runtime_state.as_mut() {
335 if interrupted_runtime_matches(&session.id, runtime, state)
336 && matches!(
337 runtime.status,
338 bamboo_domain::AgentStatusState::Initializing
339 | bamboo_domain::AgentStatusState::Running
340 | bamboo_domain::AgentStatusState::Finalizing
341 | bamboo_domain::AgentStatusState::Completed
342 )
343 {
344 runtime.status = bamboo_domain::AgentStatusState::Failed;
345 }
346 }
347}
348
349pub(crate) async fn admit_registered(
354 session: &mut Session,
355 ownership: Option<&crate::session_activation::SessionRunOwnership>,
356 router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
357 persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
358) -> Result<(), AgentError> {
359 let presented = read_loop_state(session)?;
360 let durable = persistence
362 .load_runtime_admission_snapshot(&session.id)
363 .await
364 .map_err(|_| invalid("cannot inspect durable state; execution not admitted"))?;
365 let stored = durable.as_ref().map(read_loop_state).transpose()?.flatten();
366 if presented != stored
367 || (stored.is_some()
368 && durable
369 .as_ref()
370 .is_some_and(|fresh| fresh.created_at != session.created_at))
371 {
372 return Err(invalid(
373 "recovery unsupported; durable generation or revision changed; reload required",
374 ));
375 }
376 let Some(mut state) = stored else {
377 return Ok(());
378 };
379 if state.status == LoopStatus::RecoveryUnsupported {
380 return reject_recovery(session);
381 }
382 if !state.live() {
383 reject_recovery(session)?;
384 if let Some(fresh) = durable.as_ref() {
388 reject_recovery(fresh)?;
389 }
390 return Ok(());
391 }
392 let Some(ownership) = ownership else {
393 return reject_recovery(session);
394 };
395 if !router.is_some_and(|router| ownership.matches_router(router))
396 || !ownership.is_current(&session.id).await
397 {
398 return Err(invalid(
399 "recovery unsupported; exact activation owner required",
400 ));
401 }
402 if durable
403 .as_ref()
404 .is_some_and(|fresh| persistence.root_actor_execution_required(fresh))
405 {
406 return Err(invalid("recovery unsupported; bound Actor writer required"));
407 }
408 let mut staged = durable.expect("stored state requires durable Session");
411 fail_interrupted_runtime(&mut staged, &state);
412 state.status = LoopStatus::RecoveryUnsupported;
413 checkpoint_state(&mut staged, Some(persistence), state.clone()).await?;
414 session.metadata.insert(
417 LOOP_STATE_METADATA_KEY.into(),
418 staged.metadata[LOOP_STATE_METADATA_KEY].clone(),
419 );
420 fail_interrupted_runtime(session, &state);
421 reject_recovery(session)
422}
423
424pub(crate) async fn preflight(
427 session: &mut Session,
428 config: &AgentLoopConfig,
429 prompt: &str,
430) -> Result<(), AgentError> {
431 reject_recovery(session)?;
432 if let Some(counted) = config.counted_loop.as_ref() {
433 validate_bounds(counted.max_iterations, counted.max_total_rounds, prompt)?;
434 validate_bounds(
435 counted.max_iterations,
436 effective_round_cap(counted, config),
437 prompt,
438 )?;
439 if config.gold_config.as_ref().is_some_and(|gold| gold.enabled) {
442 return Err(invalid(
443 "enabled Gold and counted repetition cannot be combined",
444 ));
445 }
446 if counted.stop.is_cancelled() {
447 return Err(invalid("the supplied generation was already stopped"));
448 }
449 if config.skip_initial_user_message
450 && !session.messages.last().is_some_and(|message| {
451 message.role == bamboo_agent_core::Role::User
452 && message.content == prompt
453 && !bamboo_domain::session::is_system_resume_message(message)
454 })
455 {
456 return Err(invalid(
457 "the initial prompt must be the current admitted User",
458 ));
459 }
460 }
461 Ok(())
462}
463
464fn effective_round_cap(counted: &CountedLoopConfig, config: &AgentLoopConfig) -> u32 {
465 config
466 .run_budget
467 .max_rounds
468 .map_or(counted.max_total_rounds, |cap| {
469 cap.min(counted.max_total_rounds)
470 })
471}
472
473pub(crate) async fn begin(
474 session: &mut Session,
475 config: &AgentLoopConfig,
476 execution_id: &str,
477 prompt: &str,
478) -> Result<Option<String>, AgentError> {
479 let Some(counted) = config.counted_loop.as_ref() else {
480 return Ok(None);
481 };
482 validate_bounds(
483 counted.max_iterations,
484 effective_round_cap(counted, config),
485 prompt,
486 )?;
487 let generation = uuid::Uuid::new_v4().to_string();
488 let state = LoopState {
489 schema_version: 1,
490 generation: generation.clone(),
491 revision: 1,
492 objective_revision: 1,
493 owner_execution_id: execution_id.into(),
494 objective: prompt.into(),
495 max_iterations: counted.max_iterations,
496 max_total_rounds: effective_round_cap(counted, config),
497 iterations_started: 0,
498 iterations_completed: 0,
499 rounds_started: 0,
500 status: LoopStatus::Running,
501 };
502 if let Err(error) = checkpoint(session, config, state).await {
503 let failure = Err(error);
507 if let Err(checkpoint_error) = finish(session, config, Some(&generation), &failure).await {
508 tracing::warn!(%checkpoint_error, "counted-loop initial failure checkpoint failed");
509 }
510 return failure.map(|()| Some(generation));
511 }
512 Ok(Some(generation))
513}
514
515fn active_state(
516 session: &Session,
517 config: &AgentLoopConfig,
518) -> Result<Option<LoopState>, AgentError> {
519 if config.counted_loop.is_none() {
520 return Ok(None);
521 }
522 Ok(Some(
523 read_loop_state(session)?.ok_or_else(|| invalid("missing live state"))?,
524 ))
525}
526
527pub(crate) async fn before_round(
530 session: &mut Session,
531 config: &AgentLoopConfig,
532 execution_id: &str,
533) -> Result<bool, AgentError> {
534 let Some(mut state) = active_state(session, config)? else {
535 return Ok(true);
536 };
537 if state.owner_execution_id != execution_id {
538 return Err(invalid("execution owner changed"));
539 }
540 if let Some(owner) = config
541 .counted_loop
542 .as_ref()
543 .and_then(|counted| counted.owner.as_ref())
544 {
545 if owner.session_birth != session.created_at || !owner.run.is_current(&session.id).await {
546 return Err(invalid("execution ownership is no longer current"));
547 }
548 }
549 if !state.live() {
550 return Ok(false);
551 }
552 if config
553 .counted_loop
554 .as_ref()
555 .expect("counted config")
556 .stop
557 .is_cancelled()
558 {
559 state.status = LoopStatus::Stopped;
560 checkpoint(session, config, state).await?;
561 return Ok(false);
562 }
563 if state.rounds_started == state.max_total_rounds {
564 state.status = LoopStatus::BudgetExhausted;
565 session.metadata.insert(
566 "runtime.completion_reason".into(),
567 "counted_loop_round_limit".into(),
568 );
569 checkpoint(session, config, state).await?;
570 return Ok(false);
571 }
572 let mut staged_repeat = None;
573 if state.status == LoopStatus::Running && state.iterations_started == state.iterations_completed
574 {
575 if state.iterations_started > 0 {
576 let mut message = Message::user(state.objective.clone());
577 message.metadata = Some(serde_json::json!({
578 "runtime_kind": "counted_loop_iteration",
579 "loop_generation": state.generation,
580 "objective_revision": state.objective_revision,
581 "iteration": state.iterations_started + 1,
582 }));
583 let mut staged = session.clone();
584 staged.add_message(message);
585 staged_repeat = Some(staged);
586 }
587 state.iterations_started += 1;
588 }
589 state.rounds_started += 1;
590 if let Some(mut staged) = staged_repeat {
591 let admitted_state = stage_state(&mut staged, state)?;
595 if let Err(error) = persist_checkpoint(&mut staged, config.persistence.as_deref()).await {
596 session
597 .metadata
598 .insert(LOOP_STATE_METADATA_KEY.into(), admitted_state);
599 return Err(error);
600 }
601 *session = staged;
602 } else {
603 checkpoint(session, config, state).await?;
604 }
605 Ok(true)
606}
607
608pub(crate) enum TerminalDecision {
609 Continue,
610 Stop,
611 Finalize,
612}
613
614pub(crate) async fn exhaust_budget(
615 session: &mut Session,
616 config: &AgentLoopConfig,
617) -> Result<(), AgentError> {
618 let Some(mut state) = active_state(session, config)? else {
619 return Ok(());
620 };
621 state.status = LoopStatus::BudgetExhausted;
622 session.metadata.insert(
623 "runtime.completion_reason".into(),
624 "counted_loop_budget_exhausted".into(),
625 );
626 checkpoint(session, config, state).await
627}
628
629pub(crate) async fn terminal(
630 session: &mut Session,
631 config: &AgentLoopConfig,
632) -> Result<TerminalDecision, AgentError> {
633 let Some(mut state) = active_state(session, config)? else {
634 return Ok(TerminalDecision::Finalize);
635 };
636 if state.status == LoopStatus::Finalizing {
637 return Ok(TerminalDecision::Finalize);
638 }
639 if state.status != LoopStatus::Running
640 || state.iterations_started != state.iterations_completed + 1
641 {
642 return Err(invalid("terminal without one admitted iteration"));
643 }
644 state.iterations_completed += 1;
645 let decision = if config
646 .counted_loop
647 .as_ref()
648 .expect("counted config")
649 .stop
650 .is_cancelled()
651 {
652 state.status = LoopStatus::Stopped;
653 TerminalDecision::Stop
654 } else if state.iterations_completed == state.max_iterations {
655 state.status = LoopStatus::Finalizing;
656 TerminalDecision::Finalize
657 } else {
658 TerminalDecision::Continue
659 };
660 checkpoint(session, config, state).await?;
661 Ok(decision)
662}
663
664pub(crate) async fn finish(
667 session: &mut Session,
668 config: &AgentLoopConfig,
669 generation: Option<&str>,
670 result: &Result<(), AgentError>,
671) -> Result<(), AgentError> {
672 let Some(generation) = generation else {
673 return Ok(());
674 };
675 let Some(mut state) = read_loop_state(session)? else {
676 return Err(invalid("missing terminal state"));
677 };
678 if state.generation != generation {
679 return Err(invalid("generation changed during execution"));
680 }
681 state.status = match result {
685 Err(error) if error.is_cancelled() => LoopStatus::Cancelled,
686 Err(_) => match state.status {
687 LoopStatus::Failed | LoopStatus::Cancelled | LoopStatus::RecoveryUnsupported => {
688 state.status
689 }
690 _ => LoopStatus::Failed,
691 },
692 Ok(()) if !state.live() => state.status,
693 Ok(())
694 if session.metadata.contains_key("runtime.suspend_reason")
695 || session.agent_runtime_state.as_ref().is_some_and(|runtime| {
696 matches!(runtime.status, bamboo_domain::AgentStatusState::Suspended)
697 }) =>
698 {
699 LoopStatus::RecoveryUnsupported
700 }
701 Ok(()) => {
702 if let Some(reason) = session.metadata.get("runtime.completion_reason") {
703 match reason.as_str() {
704 "counted_loop_round_limit"
705 | "counted_loop_budget_exhausted"
706 | "budget_exceeded"
707 | "max_rounds_reached"
708 | "stop_hook_continuation_cap" => LoopStatus::BudgetExhausted,
709 _ => LoopStatus::RecoveryUnsupported,
711 }
712 } else if state.iterations_completed == state.max_iterations {
713 LoopStatus::Completed
714 } else {
715 LoopStatus::Failed
716 }
717 }
718 };
719 checkpoint(session, config, state).await
723}