1use std::collections::{HashMap, VecDeque};
2
3use super::entropy::{EntropyTracker, EntropyWatchConfig};
4use super::milestone::MilestoneTracker;
5use super::policy::SchedulerBudget;
6use super::tcb::{TaskLifecycle, TaskTable, Tcb, WaitReason};
7use crate::AgentRunSpec;
8use crate::context::manager::ContextManager;
9use crate::context::renderer::RenderedContext;
10use crate::governance::pipeline::GovernancePipeline;
11use crate::governance::repeat_fuse::RepeatFuseConfig;
12use crate::signals::router::SignalRouter;
13use crate::types::result::SubAgentResult;
14pub use crate::runtime::kernel::KernelObservation;
17use crate::runtime::session::RollbackReason;
18use crate::types::message::{
19 Content, ContentPart, Message, ToolCall, ToolErrorKind, ToolResult, ToolSchema,
20};
21use crate::types::milestone::MilestoneCheckResult;
22use crate::types::result::{LoopResult, TerminationReason};
23use crate::types::task::RuntimeTask;
24
25fn compact_tool_args(args: &serde_json::Value) -> String {
30 if args.is_null() {
31 return String::new();
32 }
33 let s = args.to_string();
34 if s == "{}" {
35 return String::new();
36 }
37 const MAX: usize = 48;
38 if s.chars().count() <= MAX {
39 s
40 } else {
41 format!("{}…", s.chars().take(MAX).collect::<String>())
42 }
43}
44
45#[derive(Debug, Clone)]
53pub enum LoopPhase {
54 Reason,
55 Act { tool_calls: Vec<ToolCall> },
56}
57
58#[derive(Debug)]
60pub enum LoopEvent {
61 LLMResponse {
62 message: Message,
63 },
64 ToolResults {
65 results: Vec<ToolResult>,
66 },
67 MilestoneResult {
70 result: MilestoneCheckResult,
71 },
72 SubAgentCompleted {
74 result: SubAgentResult,
75 },
76 Complete,
77 Timeout,
78}
79
80#[derive(Debug, Clone)]
82pub enum LoopAction {
83 CallLLM {
88 context: RenderedContext,
89 tools: Vec<ToolSchema>,
90 },
91 ExecuteTools {
92 calls: Vec<ToolCall>,
93 },
94 RequestApproval {
97 requests: Vec<ApprovalRequest>,
98 },
99 SpawnWorkflow {
102 nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
103 budget: Option<crate::orchestration::workflow::WorkflowBudget>,
104 },
105 PreemptSubAgents {
107 agent_ids: Vec<String>,
108 reason: String,
109 },
110 PersistMemory {
111 memory: crate::mm::memory::MemoryRecord,
112 },
113 QueryMemory {
114 query: crate::mm::memory::MemoryQuery,
115 requested_k: usize,
116 },
117 SpoolLargeResult {
118 call_id: String,
119 tool: String,
120 output: String,
121 original_size: u32,
122 preview_size: u32,
123 },
124 ArchivePageOut {
125 turn: u32,
126 action: crate::runtime::kernel::KernelPressureAction,
127 summary: Option<String>,
128 archived: Vec<Message>,
129 tier: String,
130 },
131 Done {
132 result: LoopResult,
133 },
134 EvaluateMilestone {
139 phase_id: String,
140 criteria: Vec<String>,
141 verifier: Option<crate::types::milestone::MilestoneVerifier>,
142 required_evidence: Vec<String>,
143 },
144 AwaitingResume,
146}
147
148#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
149pub struct ApprovalRequest {
150 pub call_id: String,
151 pub tool: String,
152 pub arguments: serde_json::Value,
153 pub reason: String,
154}
155
156#[derive(Debug, Clone)]
157pub(super) struct PendingWorkflowSpawn {
158 pub nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
159 pub budget: Option<crate::orchestration::workflow::WorkflowBudget>,
160}
161
162#[derive(Debug, Clone)]
163pub(super) struct PendingPreempt {
164 pub agent_ids: Vec<String>,
165 pub reason: String,
166}
167
168#[derive(Debug, Clone)]
169pub(super) enum PendingHostEffect {
170 SpoolLargeResult {
171 call_id: String,
172 tool: String,
173 output: String,
174 original_size: u32,
175 preview_size: u32,
176 },
177 ArchivePageOut {
178 turn: u32,
179 action: crate::runtime::kernel::KernelPressureAction,
180 summary: Option<String>,
181 archived: Vec<Message>,
182 tier: String,
183 },
184}
185
186impl PendingHostEffect {
187 fn action(&self) -> LoopAction {
188 match self {
189 Self::SpoolLargeResult {
190 call_id,
191 tool,
192 output,
193 original_size,
194 preview_size,
195 } => LoopAction::SpoolLargeResult {
196 call_id: call_id.clone(),
197 tool: tool.clone(),
198 output: output.clone(),
199 original_size: *original_size,
200 preview_size: *preview_size,
201 },
202 Self::ArchivePageOut {
203 turn,
204 action,
205 summary,
206 archived,
207 tier,
208 } => LoopAction::ArchivePageOut {
209 turn: *turn,
210 action: *action,
211 summary: summary.clone(),
212 archived: archived.clone(),
213 tier: tier.clone(),
214 },
215 }
216 }
217}
218
219#[derive(Debug, Clone)]
221pub(super) enum SuspendState {
222 AskUser {
224 calls: Vec<ToolCall>,
225 gated_reasons: HashMap<String, String>,
226 },
227 SubAgentAwait { agent_ids: Vec<String> },
229}
230
231pub(super) enum GateToolOutcome {
232 Proceed,
233 Blocked(LoopAction),
234 ApprovalRequired(Vec<ApprovalRequest>),
235}
236
237#[derive(Debug, Clone, Default)]
240pub struct TurnCheckpoint {
241 pub history_len: usize,
242 pub signals_len: usize,
243 pub task_state: Option<crate::context::task_state::TaskState>,
244}
245
246#[doc(hidden)]
251pub struct LoopStateMachine {
252 pub phase: LoopPhase,
253 pub turn: u32,
254 pub ctx: ContextManager,
255 pub tools: Vec<ToolSchema>,
256 pub observations: Vec<KernelObservation>,
257 pub(super) policy: SchedulerBudget,
258 pub(super) scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig,
259 pub(super) total_tokens: u64,
260 pub(super) budget_grant: Option<crate::runtime::kernel::BudgetGrant>,
263 pub(super) local_rounds_completed: u32,
264 pub(super) pending_pace: Option<crate::types::result::PaceDecision>,
266 pub(super) pending_termination: Option<TerminationReason>,
269 pub(super) recovery_attempts: u8,
274 pub(crate) provider_recovery_attempt_limit: u8,
275 pub(super) output_recovery_attempts: u8,
281 pub(crate) output_recovery_attempt_limit: u8,
282 pub(crate) host_effect_retry_attempt_limit: u8,
283 pub(super) pending_stop_reason: Option<String>,
287 pub(super) session_history_baseline: usize,
290 pub(super) checkpoint: TurnCheckpoint,
291 pub(super) milestone: MilestoneTracker,
293 pub run_spec: Option<AgentRunSpec>,
294 pub(super) tasks: TaskTable,
299 pub(super) governance: Option<GovernancePipeline>,
303 pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
306 pub(super) memory_write_times: Vec<u64>,
309 pub(super) memory_policy: Option<crate::mm::memory::MemoryPolicy>,
312 pub(super) signal_router: SignalRouter,
315 pub(super) delivered_signals_len: usize,
319 pub(super) started_at_ms: Option<u64>,
322 pub(super) last_now_ms: Option<u64>,
324 pub(super) suspend_state: Option<SuspendState>,
326 pub(super) pending_denied_results: Vec<ToolResult>,
328 pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
332 pub(super) pending_workflow_spawn: Option<PendingWorkflowSpawn>,
335 pub(super) pending_preempt: Option<PendingPreempt>,
336 pub(super) pending_host_effects: VecDeque<PendingHostEffect>,
339 pub(super) active_host_effect: Option<PendingHostEffect>,
340 pub(super) active_host_effect_failures: u8,
341 pub(super) deferred_action: Option<Box<LoopAction>>,
342 pub(super) repeat_fuse: RepeatFuseConfig,
345 pub(super) repeat_sig: Option<String>,
349 pub(super) repeat_count: u32,
351 pub(super) criteria_gate_enabled: bool,
355 pub(super) criteria_gate_fired: bool,
357 pub(super) entropy: EntropyTracker,
361 pub(super) entropy_watch: EntropyWatchConfig,
364}
365
366mod cancellation;
367mod capability;
368mod eviction;
369mod gate;
370mod milestone_exec;
371mod process;
372mod signal;
373mod workflow;
374
375impl LoopStateMachine {
376 fn message_tokens(&self, message: &Message) -> u32 {
377 message
378 .token_count
379 .unwrap_or_else(|| self.ctx.engine.count_message(message))
380 }
381
382 pub fn new(policy: SchedulerBudget) -> Self {
383 let mut tasks = TaskTable::new();
384 tasks.insert(Tcb::root("root", policy.clone()));
388 Self {
389 phase: LoopPhase::Reason,
391 turn: 0,
392 ctx: ContextManager::new(policy.max_tokens),
393 tools: Vec::new(),
394 observations: Vec::new(),
395 policy,
396 scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig::default(),
397 total_tokens: 0,
398 budget_grant: None,
399 local_rounds_completed: 0,
400 pending_pace: None,
401 pending_termination: None,
402 recovery_attempts: 0,
403 provider_recovery_attempt_limit: 2,
404 output_recovery_attempts: 0,
405 output_recovery_attempt_limit: 3,
406 host_effect_retry_attempt_limit: 3,
407 pending_stop_reason: None,
408 session_history_baseline: 0,
409 checkpoint: TurnCheckpoint::default(),
410 milestone: MilestoneTracker::new(),
411 run_spec: None,
412 tasks,
413 governance: None,
414 resource_quota: None,
415 memory_write_times: Vec::new(),
416 memory_policy: None,
417 signal_router: SignalRouter::new(64),
418 delivered_signals_len: 0,
419 started_at_ms: None,
420 last_now_ms: None,
421 suspend_state: None,
422 pending_denied_results: Vec::new(),
423 workflow: None,
424 pending_workflow_spawn: None,
425 pending_preempt: None,
426 pending_host_effects: VecDeque::new(),
427 active_host_effect: None,
428 active_host_effect_failures: 0,
429 deferred_action: None,
430 repeat_fuse: RepeatFuseConfig::default(),
431 repeat_sig: None,
432 repeat_count: 0,
433 criteria_gate_enabled: true,
434 criteria_gate_fired: false,
435 entropy: EntropyTracker::default(),
436 entropy_watch: EntropyWatchConfig::default(),
437 }
438 }
439
440 pub fn set_criteria_gate(&mut self, enabled: bool) {
442 self.criteria_gate_enabled = enabled;
443 }
444
445 pub(crate) fn set_scheduler_policy(
446 &mut self,
447 policy: crate::scheduler::policy::SchedulerPolicyConfig,
448 ) {
449 self.scheduler_policy = policy;
450 if let Some(workflow) = self.workflow.as_mut() {
451 workflow.set_scheduler_policy(policy);
452 }
453 }
454
455 pub(crate) fn set_reliability_config(
456 &mut self,
457 config: &crate::runtime::kernel::KernelReliabilityConfig,
458 ) {
459 if let Some(limit) = config.provider_recovery_attempts {
460 self.provider_recovery_attempt_limit = limit;
461 }
462 if let Some(limit) = config.output_recovery_attempts {
463 self.output_recovery_attempt_limit = limit;
464 }
465 if let Some(limit) = config.host_effect_retry_attempts {
466 self.host_effect_retry_attempt_limit = limit;
467 }
468 if let Some(bytes) = config.spool_threshold_bytes {
469 self.ctx.config.spool_threshold_bytes = bytes;
470 }
471 if let Some(bytes) = config.spool_preview_bytes {
472 self.ctx.config.spool_preview_bytes = bytes;
473 }
474 }
475
476 pub(crate) fn externalize_pending_host_effect(
477 &mut self,
478 continuation: LoopAction,
479 ) -> LoopAction {
480 if self.active_host_effect.is_some() {
481 return continuation;
482 }
483 let Some(pending) = self.pending_host_effects.pop_front() else {
484 return continuation;
485 };
486 assert!(
487 self.deferred_action.is_none(),
488 "host effect continuation must be unique"
489 );
490 self.deferred_action = Some(Box::new(continuation));
491 self.active_host_effect = Some(pending);
492 self.active_host_effect_failures = 0;
493 self.active_host_effect
494 .as_ref()
495 .expect("host effect was just activated")
496 .action()
497 }
498
499 fn next_after_host_effect(&mut self) -> LoopAction {
500 if let Some(pending) = self.pending_host_effects.pop_front() {
501 self.active_host_effect = Some(pending);
502 self.active_host_effect_failures = 0;
503 self.active_host_effect
504 .as_ref()
505 .expect("host effect was just activated")
506 .action()
507 } else {
508 match self.deferred_action.take().map(|action| *action) {
509 Some(LoopAction::CallLLM { .. }) => self.emit_call_llm(),
512 Some(action) => action,
513 None => LoopAction::AwaitingResume,
514 }
515 }
516 }
517
518 pub(crate) fn resolve_large_result_spool(
519 &mut self,
520 spool_ref: Option<String>,
521 error: Option<String>,
522 ) -> LoopAction {
523 let pending = self
524 .active_host_effect
525 .as_ref()
526 .expect("spool result requires an active host effect");
527 let PendingHostEffect::SpoolLargeResult {
528 call_id,
529 tool,
530 original_size,
531 preview_size,
532 ..
533 } = pending
534 else {
535 panic!("spool result does not match active page-out effect");
536 };
537 if let Some(error) = error {
538 self.observations
539 .push(KernelObservation::LargeResultSpoolFailed {
540 turn: self.turn,
541 call_id: call_id.clone(),
542 tool: tool.clone(),
543 error,
544 });
545 self.active_host_effect_failures = self.active_host_effect_failures.saturating_add(1);
546 if self.active_host_effect_failures > self.host_effect_retry_attempt_limit {
547 self.active_host_effect = None;
548 self.pending_host_effects.clear();
549 self.deferred_action = None;
550 return self.terminate(TerminationReason::Error, None);
551 }
552 return pending.action();
553 }
554 let spool_ref = spool_ref.expect("successful spool result requires spool_ref");
555 let call_id = call_id.clone();
556 let tool = tool.clone();
557 let original_size = *original_size;
558 let preview_size = *preview_size;
559 self.ctx.mark_spooled(&call_id, spool_ref.clone());
560 self.observations
561 .push(KernelObservation::LargeResultSpooled {
562 turn: self.turn,
563 call_id,
564 tool,
565 original_size,
566 preview_size,
567 spool_ref: Some(spool_ref),
568 });
569 self.active_host_effect = None;
570 self.active_host_effect_failures = 0;
571 self.next_after_host_effect()
572 }
573
574 pub(crate) fn resolve_page_out_archive(
575 &mut self,
576 archive_ref: Option<String>,
577 error: Option<String>,
578 ) -> LoopAction {
579 let pending = self
580 .active_host_effect
581 .as_ref()
582 .expect("page-out result requires an active host effect");
583 let PendingHostEffect::ArchivePageOut {
584 turn,
585 action,
586 summary,
587 archived,
588 tier,
589 } = pending
590 else {
591 panic!("page-out result does not match active spool effect");
592 };
593 if let Some(error) = error {
594 self.observations
595 .push(KernelObservation::PageOutArchiveFailed {
596 turn: *turn,
597 action: *action,
598 tier: tier.clone(),
599 message_count: archived.len() as u32,
600 error,
601 });
602 self.active_host_effect_failures = self.active_host_effect_failures.saturating_add(1);
603 if self.active_host_effect_failures > self.host_effect_retry_attempt_limit {
604 self.active_host_effect = None;
605 self.pending_host_effects.clear();
606 self.deferred_action = None;
607 return self.terminate(TerminationReason::Error, None);
608 }
609 return pending.action();
610 }
611 self.observations.push(KernelObservation::PageOutArchived {
612 turn: *turn,
613 action: *action,
614 summary: summary.clone(),
615 tier: tier.clone(),
616 message_count: archived.len() as u32,
617 archive_ref,
618 });
619 self.active_host_effect = None;
620 self.active_host_effect_failures = 0;
621 self.next_after_host_effect()
622 }
623
624 pub fn set_repeat_fuse(&mut self, config: RepeatFuseConfig) {
626 self.repeat_fuse = config;
627 }
628
629 pub fn set_entropy_watch(&mut self, config: EntropyWatchConfig) {
632 self.entropy_watch = config;
633 }
634
635 pub fn entropy_watch_config(&self) -> EntropyWatchConfig {
636 self.entropy_watch
637 }
638
639 pub fn repeat_fuse_config(&self) -> RepeatFuseConfig {
641 self.repeat_fuse
642 }
643
644 pub fn lifecycle(&self) -> TaskLifecycle {
647 self.tasks
648 .get("root")
649 .map(|t| t.state)
650 .unwrap_or(TaskLifecycle::Ready)
651 }
652
653 pub fn wait_reason(&self) -> Option<WaitReason> {
655 self.tasks.get("root").and_then(|t| t.wait.clone())
656 }
657
658 pub fn is_terminal(&self) -> bool {
660 matches!(self.lifecycle(), TaskLifecycle::Done(_))
661 }
662
663 pub fn is_suspended(&self) -> bool {
665 matches!(self.lifecycle(), TaskLifecycle::Suspended)
666 }
667
668 fn set_lifecycle(&mut self, state: TaskLifecycle, wait: Option<WaitReason>) {
670 if let Some(root) = self.tasks.get_mut("root") {
671 root.state = state;
672 root.wait = wait;
673 } else {
674 let mut root = Tcb::root("root", self.policy.clone());
675 root.state = state;
676 root.wait = wait;
677 self.tasks.insert(root);
678 }
679 }
680
681 fn root_tcb(&self) -> Tcb {
685 let mut tcb = Tcb::root("root", self.policy.clone());
686 tcb.budget.turns = self.turn;
687 tcb.budget.total_tokens = self.total_tokens;
688 if let Some(tokens) = self.budget_grant.as_ref().and_then(|grant| grant.tokens) {
689 tcb.budget.limits.max_total_tokens = tcb.budget.limits.max_total_tokens.min(tokens);
690 }
691 tcb.budget.started_at_ms = self.started_at_ms;
692 tcb.state = self.lifecycle();
693 tcb
694 }
695
696 pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
698 self.policy.max_wall_ms = max_wall_ms;
699 }
700
701 pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
706 self.governance = Some(pipeline);
707 }
708
709 pub fn set_resource_quota(&mut self, quota: crate::governance::quota::ResourceQuota) {
712 self.resource_quota = Some(quota);
713 }
714
715 pub fn set_budget_grant(&mut self, grant: crate::runtime::kernel::BudgetGrant) {
716 self.budget_grant = Some(grant);
717 }
718
719 pub fn local_subagents_spawned(&self) -> u32 {
723 self.tasks.all().iter().filter(|t| t.proc.is_some()).count() as u32
724 }
725
726 pub fn local_budget_usage(&self) -> (u64, u32, u32) {
727 (
728 self.total_tokens,
729 self.local_subagents_spawned(),
730 self.local_rounds_completed,
731 )
732 }
733
734 pub fn budget_grant(&self) -> Option<&crate::runtime::kernel::BudgetGrant> {
735 self.budget_grant.as_ref()
736 }
737
738 pub fn set_memory_policy(&mut self, policy: crate::mm::memory::MemoryPolicy) {
742 self.memory_policy = Some(policy);
743 }
744
745 pub fn memory_policy(&self) -> Option<&crate::mm::memory::MemoryPolicy> {
747 self.memory_policy.as_ref()
748 }
749
750 pub fn set_observed_time(&mut self, now_ms: u64) {
752 if self.started_at_ms.is_none() {
753 self.started_at_ms = Some(now_ms);
754 }
755 self.last_now_ms = Some(now_ms);
756 if let Some(pipeline) = self.governance.as_mut() {
757 pipeline.set_time(now_ms);
758 }
759 }
760
761 pub fn set_pending_stop_reason(&mut self, stop_reason: Option<String>) {
764 self.pending_stop_reason = stop_reason;
765 }
766
767 pub fn preload_history(&mut self, messages: Vec<Message>) {
772 for msg in messages {
773 let tokens = self.message_tokens(&msg);
774 self.ctx.push_history(msg, tokens);
775 }
776 self.session_history_baseline = self.ctx.partitions.history.messages.len();
777 }
778
779 pub fn resume_after_preload(&mut self) -> LoopAction {
785 self.observations.clear();
786 let calls = crate::runtime::repair::pending_tool_calls_from_messages(
787 &self.ctx.partitions.history.messages,
788 );
789 if !calls.is_empty() {
790 self.phase = LoopPhase::Act {
791 tool_calls: calls.clone(),
792 };
793 self.set_lifecycle(TaskLifecycle::Running, None);
794 return LoopAction::ExecuteTools { calls };
795 }
796 self.phase = LoopPhase::Reason;
797 self.emit_call_llm()
798 }
799
800 pub fn drain_new_messages(&self) -> Vec<Message> {
806 let history = &self.ctx.partitions.history.messages;
807 let start = self.session_history_baseline.min(history.len());
808 history[start..].to_vec()
809 }
810
811 pub fn start(&mut self, task: RuntimeTask) -> LoopAction {
812 self.observations.clear();
813 self.ctx.init_task(task.goal.clone(), task.criteria.clone());
814
815 if self
819 .run_spec
820 .as_ref()
821 .and_then(|spec| spec.loop_round.as_ref())
822 .is_some()
823 && self.budget_grant.as_ref().and_then(|grant| grant.rounds) == Some(0)
824 {
825 self.observations.push(KernelObservation::BudgetExceeded {
826 turn: self.turn,
827 budget: "rounds".into(),
828 operation_id: String::new(),
829 reservation_id: self
830 .budget_grant
831 .as_ref()
832 .map(|grant| grant.reservation_id.clone()),
833 });
834 self.pending_pace = Some(crate::types::result::PaceDecision {
835 action: crate::types::result::PaceAction::Stop,
836 delay_ms: None,
837 reason: "round budget grant exhausted before start".into(),
838 coerced_from: None,
839 });
840 return self.terminate(TerminationReason::Completed, None);
841 }
842
843 let user_msg = "Proceed with the task described in [TASK STATE].".to_string();
844
845 let user_tokens = self.ctx.engine.count(&user_msg).max(1);
851 self.ctx.push_history(Message::user(user_msg), user_tokens);
852 self.phase = LoopPhase::Reason;
853 self.emit_call_llm()
855 }
856
857 pub fn feed(&mut self, event: LoopEvent) -> LoopAction {
858 self.observations.clear();
859 self.sweep_expired_leases();
860 self.ctx.sweep_expired_skill_leases(self.turn);
862
863 match event {
864 LoopEvent::LLMResponse { message } => {
865 let delivered = self
866 .delivered_signals_len
867 .min(self.ctx.partitions.signals.len());
868 self.ctx.partitions.signals.drain(..delivered);
869 self.delivered_signals_len = 0;
870 self.drain_queued_signals();
874 let signals_waiting_for_followup = !self.ctx.partitions.signals.is_empty();
875 self.recovery_attempts = 0;
877 let tokens = self.message_tokens(&message);
878 self.total_tokens += tokens as u64;
879
880 const OUTPUT_TRUNCATION_NUDGE: &str = "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.";
884 let truncated = matches!(
885 self.pending_stop_reason.take().as_deref(),
886 Some("max_tokens") | Some("length"),
887 );
888 if !truncated {
889 self.output_recovery_attempts = 0;
890 }
891
892 if let Some(reason) = self.pending_termination.take() {
893 return self.terminate(reason, Some(message));
894 }
895
896 if message.tool_calls.is_empty() {
897 if truncated
903 && self.output_recovery_attempts < self.output_recovery_attempt_limit
904 {
905 self.output_recovery_attempts += 1;
906 self.ctx.push_history(message, tokens);
907 self.ctx.push_signal(OUTPUT_TRUNCATION_NUDGE.to_string());
908 self.phase = LoopPhase::Reason;
909 return self.emit_call_llm();
910 }
911 if !self.milestone.is_complete() {
914 let phase_id = self.milestone.current_phase_id().unwrap_or("").to_string();
915 let criteria = self.milestone.current_criteria().to_vec();
916 let (verifier, required_evidence) = self
917 .milestone
918 .current_phase()
919 .map(|p| (p.verifier.clone(), p.required_evidence.clone()))
920 .unwrap_or_default();
921 self.ctx.push_history(message, tokens);
923 return LoopAction::EvaluateMilestone {
924 phase_id,
925 criteria,
926 verifier,
927 required_evidence,
928 };
929 }
930 if self.criteria_gate_enabled
936 && !self.criteria_gate_fired
937 && !self.ctx.partitions.task_state.criteria.is_empty()
938 {
939 self.criteria_gate_fired = true;
940 let criteria = self.ctx.partitions.task_state.criteria.clone();
941 self.ctx.push_history(message, tokens);
942 self.ctx.push_signal(format!(
943 "[CRITERIA CHECK] You are about to finish. Verify each acceptance \
944 criterion first: {}. If any is NOT met, continue working on it now. \
945 If all are met, give the final answer.",
946 criteria.join(" | ")
947 ));
948 self.observations
949 .push(KernelObservation::CriteriaGateFired {
950 turn: self.turn,
951 criteria,
952 });
953 self.phase = LoopPhase::Reason;
954 return self.emit_call_llm();
955 }
956 if signals_waiting_for_followup {
957 self.ctx.push_history(message, tokens);
958 self.phase = LoopPhase::Reason;
959 return self.emit_call_llm();
960 }
961 return self.terminate(TerminationReason::Completed, Some(message));
962 }
963
964 let calls = message.tool_calls.clone();
965 self.ctx.push_history(message, tokens);
966
967 if let Some(now_ms) = self.last_now_ms {
969 self.ctx.record_activity(now_ms);
970 }
971
972 if self
976 .run_spec
977 .as_ref()
978 .and_then(|r| r.loop_round.as_ref())
979 .is_some()
980 {
981 if let Some(pace_call) = calls.iter().find(|c| c.name.as_str() == "pace") {
982 let call = pace_call.clone();
983 return self.handle_pace_call(call);
984 }
985 }
986
987 let action_sigs: Vec<(String, String)> = calls
998 .iter()
999 .map(|c| (c.name.to_string(), compact_tool_args(&c.arguments)))
1000 .collect();
1001 self.ctx.note_tool_actions(&action_sigs);
1002
1003 if let Some(action) = self.check_repeat_fuse(&calls) {
1008 return action;
1009 }
1010
1011 match self.gate_tool_calls(&calls) {
1012 GateToolOutcome::Blocked(action) => return action,
1013 GateToolOutcome::ApprovalRequired(requests) => {
1014 return LoopAction::RequestApproval { requests };
1015 }
1016 GateToolOutcome::Proceed => {}
1017 }
1018 self.phase = LoopPhase::Act {
1019 tool_calls: calls.clone(),
1020 };
1021 self.set_lifecycle(TaskLifecycle::Running, None);
1022 LoopAction::ExecuteTools { calls }
1023 }
1024
1025 LoopEvent::ToolResults { mut results } => {
1026 if !self.pending_denied_results.is_empty() {
1027 results.append(&mut self.pending_denied_results);
1028 }
1029 if let Some(reason) = results
1030 .iter()
1031 .find_map(|result| self.rollback_reason_for_tool_result(result))
1032 {
1033 let note = Message::user(super::rollback::build_rollback_note(
1034 &reason,
1035 self.ctx.config.verbose_control_notes,
1036 ));
1037 self.rollback(reason);
1038 self.ctx
1039 .push_signal(note.content.as_text().unwrap_or_default().to_string());
1040 self.phase = LoopPhase::Reason;
1041 return self.emit_call_llm();
1042 }
1043 let errored_results = results.iter().filter(|r| r.is_error).count() as u32;
1049 let total_results = results.len() as u32;
1050 let tool_by_call_id: HashMap<String, String> = match &self.phase {
1051 LoopPhase::Act { tool_calls } => tool_calls
1052 .iter()
1053 .map(|call| (call.id.to_string(), call.name.to_string()))
1054 .collect(),
1055 LoopPhase::Reason => HashMap::new(),
1056 };
1057
1058 for r in &results {
1059 self.total_tokens += r.token_count.unwrap_or(0) as u64;
1060 let raw_output = match &r.output {
1063 Content::Text(s) => s.clone(),
1064 Content::Parts(parts) => serde_json::to_string(parts).unwrap_or_default(),
1065 };
1066 let (output, spooled) = match crate::mm::plan_spool(
1070 &raw_output,
1071 self.ctx.config.spool_threshold_bytes,
1072 self.ctx.config.spool_preview_bytes,
1073 ) {
1074 Some(decision) => {
1075 self.pending_host_effects.push_back(
1076 PendingHostEffect::SpoolLargeResult {
1077 call_id: r.call_id.to_string(),
1078 tool: tool_by_call_id
1079 .get(r.call_id.as_str())
1080 .cloned()
1081 .unwrap_or_default(),
1082 output: raw_output.clone(),
1083 original_size: decision.original_size,
1084 preview_size: decision.preview.len() as u32,
1085 },
1086 );
1087 (decision.preview, true)
1088 }
1089 None => (raw_output, false),
1090 };
1091 let parts = vec![ContentPart::ToolResult {
1092 call_id: r.call_id.clone(),
1093 output,
1094 is_error: r.is_error,
1095 }];
1096 let tool_msg = Message::tool(parts);
1097 let tokens = if spooled {
1099 self.ctx.engine.count_message(&tool_msg)
1100 } else {
1101 r.token_count
1102 .unwrap_or_else(|| self.ctx.engine.count_message(&tool_msg))
1103 };
1104 self.ctx.push_history(tool_msg, tokens);
1105 }
1106 self.turn += 1;
1107
1108 if let Some(term) = super::tcb::budget_verdict(&self.root_tcb(), self.last_now_ms) {
1112 let budget = match term {
1113 TerminationReason::MaxTurns => "max_turns",
1114 TerminationReason::Timeout => "wall_time",
1115 _ => "token_budget",
1116 };
1117 self.observations.push(KernelObservation::BudgetExceeded {
1118 turn: self.turn,
1119 budget: budget.to_string(),
1120 operation_id: String::new(),
1121 reservation_id: self
1122 .budget_grant
1123 .as_ref()
1124 .map(|grant| grant.reservation_id.clone()),
1125 });
1126 self.pending_termination = Some(term);
1127 self.phase = LoopPhase::Reason;
1128 return self.emit_call_llm();
1129 }
1130
1131 let idle_decay = self
1136 .last_now_ms
1137 .is_some_and(|now_ms| self.ctx.should_time_decay_compact(now_ms));
1138 if idle_decay {
1139 self.execute_eviction_op(&crate::mm::EvictionOp::TimeDecayMicro);
1140 }
1141
1142 self.ctx.recompute_handle_residency();
1144 if let Some((used, budget)) = self.ctx.enforce_knowledge_budget() {
1148 self.observations
1149 .push(KernelObservation::KnowledgeBudgetExceeded {
1150 turn: self.turn,
1151 used,
1152 budget,
1153 });
1154 }
1155 let (target_tokens, preserve_turns) = self.ctx.plan_compaction_params();
1159 let plan = crate::mm::plan_eviction(
1160 self.ctx.should_compress(),
1161 idle_decay,
1162 target_tokens,
1163 preserve_turns,
1164 );
1165 debug_assert!(!idle_decay || plan.has_time_decay());
1170 for op in &plan.ops {
1171 if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
1173 continue;
1174 }
1175 self.execute_eviction_op(op);
1176 }
1177
1178 if self.ctx.should_renew() {
1181 self.ctx.renew();
1182 self.signal_router.clear_dedup();
1186 self.observations.push(KernelObservation::Renewed {
1187 sprint: self.ctx.sprint,
1188 });
1189 self.emit_knowledge_sweep_observations();
1191 }
1192
1193 let repeat_streak = if self.repeat_fuse.enabled {
1197 self.repeat_count
1198 } else {
1199 0
1200 };
1201 let sample = self.entropy.sample(
1202 self.turn,
1203 self.ctx.rho(),
1204 repeat_streak,
1205 self.repeat_fuse.deny_after,
1206 errored_results,
1207 total_results,
1208 );
1209 self.observations.push(KernelObservation::EntropySample {
1210 turn: sample.turn,
1211 score: sample.score,
1212 score_version: super::entropy::ENTROPY_SCORE_VERSION,
1213 rho: sample.rho,
1214 repeat_pressure: sample.repeat_pressure,
1215 failure_rate: sample.failure_rate,
1216 rollbacks_in_window: sample.rollbacks_in_window,
1217 window_turns: sample.window_turns,
1218 });
1219 if self.entropy.should_alert(&self.entropy_watch, &sample) {
1225 self.observations.push(KernelObservation::EntropyAlert {
1226 turn: sample.turn,
1227 score: sample.score,
1228 threshold: self.entropy_watch.threshold,
1229 });
1230 if self.entropy_watch.notify_model {
1231 use crate::types::signal::{
1232 RuntimeSignal, SignalSource, SignalType, Urgency,
1233 };
1234 let signal = RuntimeSignal::new(
1235 SignalSource::Heartbeat,
1236 SignalType::Alert,
1237 Urgency::High,
1238 format!(
1239 "[entropy] session disorder {:.2} ≥ {:.2} (repeat {:.2} / failures {:.2} / pressure {:.2}). \
1240 Stop and reassess: state what is not working and try a different approach.",
1241 sample.score,
1242 self.entropy_watch.threshold,
1243 sample.repeat_pressure,
1244 sample.failure_rate,
1245 sample.rho,
1246 ),
1247 )
1248 .with_dedupe(format!("entropy_alert:{}", sample.turn));
1249 let _ = self.dispatch_signal(signal);
1250 }
1251 }
1252
1253 self.drain_queued_signals();
1256
1257 self.phase = LoopPhase::Reason;
1258 self.emit_call_llm()
1259 }
1260
1261 LoopEvent::MilestoneResult { result } => self.handle_milestone_result(result),
1262
1263 LoopEvent::SubAgentCompleted { result } => self.handle_sub_agent_completed(result),
1264
1265 LoopEvent::Complete => self.terminate(TerminationReason::Completed, None),
1266
1267 LoopEvent::Timeout => {
1268 let reason = RollbackReason::Timeout;
1269 let note = Message::user(super::rollback::build_rollback_note(
1270 &reason,
1271 self.ctx.config.verbose_control_notes,
1272 ));
1273 self.rollback(reason);
1274 self.ctx
1275 .push_signal(note.content.as_text().unwrap_or_default().to_string());
1276 self.phase = LoopPhase::Reason;
1277 self.emit_call_llm()
1278 }
1279 }
1280 }
1281
1282 pub fn take_observations(&mut self) -> Vec<KernelObservation> {
1284 std::mem::take(&mut self.observations)
1285 }
1286
1287 fn handle_pace_call(&mut self, call: ToolCall) -> LoopAction {
1295 use crate::types::result::{PaceAction, PaceDecision};
1296
1297 let spec = self
1298 .run_spec
1299 .as_ref()
1300 .and_then(|r| r.loop_round.as_ref())
1301 .cloned()
1302 .unwrap_or_default();
1303
1304 let next = call
1305 .arguments
1306 .get("next")
1307 .and_then(|v| v.as_str())
1308 .unwrap_or("");
1309 let reason = call
1310 .arguments
1311 .get("reason")
1312 .and_then(|v| v.as_str())
1313 .unwrap_or("")
1314 .to_string();
1315 let proposed_delay = call.arguments.get("delay_ms").and_then(|v| v.as_u64());
1316
1317 let mut action = match next {
1318 "continue" => PaceAction::Continue,
1319 "sleep" => PaceAction::Sleep,
1320 "stop" => PaceAction::Stop,
1321 other => {
1322 let rb = RollbackReason::GovernanceDenied {
1324 tool_name: "pace".to_string(),
1325 reason: format!("invalid pace next={other:?} (expected continue|sleep|stop)"),
1326 };
1327 let note = Message::user(super::rollback::build_rollback_note(
1328 &rb,
1329 self.ctx.config.verbose_control_notes,
1330 ));
1331 self.push_synthetic_tool_result(
1332 &call.id,
1333 "pace rejected: next must be continue|sleep|stop",
1334 );
1335 self.ctx
1336 .push_signal(note.content.as_text().unwrap_or_default().to_string());
1337 self.phase = LoopPhase::Reason;
1338 return self.emit_call_llm();
1339 }
1340 };
1341 let mut coerced_from: Option<String> = None;
1342
1343 if action != PaceAction::Stop {
1345 let granted_rounds = self.budget_grant.as_ref().and_then(|grant| grant.rounds);
1346 let max_rounds = if granted_rounds == Some(0) {
1347 Some(0)
1348 } else {
1349 spec.max_rounds
1350 };
1351 if let Some(max) = max_rounds {
1352 if self.local_rounds_completed.saturating_add(1) >= max {
1353 coerced_from = Some(format!("{} (max_rounds={max})", action.label()));
1354 action = PaceAction::Stop;
1355 }
1356 }
1357 }
1358
1359 if action == PaceAction::Stop
1362 && self.criteria_gate_enabled
1363 && !self.criteria_gate_fired
1364 && !self.ctx.partitions.task_state.criteria.is_empty()
1365 {
1366 self.criteria_gate_fired = true;
1367 let criteria = self.ctx.partitions.task_state.criteria.clone();
1368 self.push_synthetic_tool_result(
1369 &call.id,
1370 "pace(stop) noted — verify the acceptance criteria first, then pace again.",
1371 );
1372 self.ctx.push_signal(format!(
1373 "[CRITERIA CHECK] You proposed stopping the loop. Verify each acceptance \
1374 criterion first: {}. If any is NOT met, continue working (or pace(continue)). \
1375 If all are met, call pace(stop) again.",
1376 criteria.join(" | ")
1377 ));
1378 self.observations
1379 .push(KernelObservation::CriteriaGateFired {
1380 turn: self.turn,
1381 criteria,
1382 });
1383 self.phase = LoopPhase::Reason;
1384 return self.emit_call_llm();
1385 }
1386
1387 let delay_ms = if action == PaceAction::Sleep {
1389 let raw = proposed_delay.unwrap_or(spec.min_sleep_ms.unwrap_or(60_000));
1390 let mut clamped = raw;
1391 if let Some(min) = spec.min_sleep_ms {
1392 clamped = clamped.max(min);
1393 }
1394 if let Some(max) = spec.max_sleep_ms {
1395 clamped = clamped.min(max);
1396 }
1397 if clamped != raw && coerced_from.is_none() {
1398 coerced_from = Some(format!("sleep {raw}ms (clamped)"));
1399 }
1400 Some(clamped)
1401 } else {
1402 None
1403 };
1404
1405 self.local_rounds_completed = self.local_rounds_completed.saturating_add(1);
1406 let decision = PaceDecision {
1407 action,
1408 delay_ms,
1409 reason,
1410 coerced_from,
1411 };
1412 self.observations.push(KernelObservation::RoundPaced {
1413 turn: self.turn,
1414 round: self.local_rounds_completed,
1415 decision: decision.clone(),
1416 });
1417 self.push_synthetic_tool_result(
1418 &call.id,
1419 &format!(
1420 "pace acknowledged: {}{} — wrap up with a brief round report.",
1421 decision.action.label(),
1422 decision
1423 .delay_ms
1424 .map(|d| format!(" {d}ms"))
1425 .unwrap_or_default()
1426 ),
1427 );
1428 self.pending_pace = Some(decision);
1429 self.pending_termination = Some(TerminationReason::Completed);
1430 self.phase = LoopPhase::Reason;
1431 self.emit_call_llm()
1432 }
1433
1434 fn push_synthetic_tool_result(&mut self, call_id: &str, output: &str) {
1437 let msg = Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1438 call_id: call_id.into(),
1439 output: output.to_string(),
1440 is_error: false,
1441 }]);
1442 let tokens = self.message_tokens(&msg);
1443 self.ctx.push_history(msg, tokens);
1444 }
1445
1446 fn terminate(
1447 &mut self,
1448 termination: TerminationReason,
1449 final_message: Option<Message>,
1450 ) -> LoopAction {
1451 if let Some(ref msg) = final_message {
1454 let tokens = self.message_tokens(msg);
1455 self.ctx.push_history(msg.clone(), tokens);
1456 }
1457 let pace_decision = self.pending_pace.take().or_else(|| {
1462 let spec = self.run_spec.as_ref()?.loop_round.as_ref()?;
1463 if termination != TerminationReason::Completed {
1464 return Some(crate::types::result::PaceDecision {
1465 action: crate::types::result::PaceAction::Stop,
1466 delay_ms: None,
1467 reason: format!("round terminated: {}", termination.label()),
1468 coerced_from: None,
1469 });
1470 }
1471 match spec.default_action.as_deref() {
1472 Some("sleep") => Some(crate::types::result::PaceDecision {
1473 action: crate::types::result::PaceAction::Sleep,
1474 delay_ms: spec.min_sleep_ms.or(Some(60_000)),
1475 reason: "default_action: sleep (cron loop)".to_string(),
1476 coerced_from: None,
1477 }),
1478 _ => Some(crate::types::result::PaceDecision {
1479 action: crate::types::result::PaceAction::Stop,
1480 delay_ms: None,
1481 reason: "default_action: stop (no pace call this round)".to_string(),
1482 coerced_from: None,
1483 }),
1484 }
1485 });
1486 let result = LoopResult {
1487 termination,
1488 final_message,
1489 turns_used: self.turn,
1490 total_tokens_used: self.total_tokens,
1491 loop_continue: None,
1492 classify_branch: None,
1493 tournament_winner: None,
1494 pace_decision,
1495 };
1496 self.set_lifecycle(TaskLifecycle::Done(termination), None);
1497 LoopAction::Done { result }
1498 }
1499
1500 fn emit_call_llm(&mut self) -> LoopAction {
1505 self.set_lifecycle(TaskLifecycle::Running, None);
1508 self.checkpoint.history_len = self.ctx.partitions.history.messages.len();
1509 self.checkpoint.signals_len = self.ctx.partitions.signals.len();
1510 self.checkpoint.task_state = Some(self.ctx.partitions.task_state.clone());
1511 self.delivered_signals_len = self.ctx.partitions.signals.len();
1512 self.observations.push(KernelObservation::CheckpointTaken {
1513 turn: self.turn,
1514 history_len: self.checkpoint.history_len as u32,
1515 });
1516
1517 let context = self.ctx.render();
1518 if let Some(overflow) = context.budget_overflow.clone() {
1519 self.observations
1520 .push(KernelObservation::ContextBudgetExceeded {
1521 turn: self.turn,
1522 overflow_kind: overflow.kind,
1523 required_tokens: overflow.required_tokens,
1524 max_tokens: overflow.max_tokens,
1525 });
1526 if matches!(
1534 overflow.kind,
1535 crate::context::renderer::ContextBudgetOverflowKind::FixedContext
1536 ) {
1537 self.delivered_signals_len = 0;
1538 return self.terminate(TerminationReason::ContextOverflow, None);
1539 }
1540 }
1541 if self.pending_termination.is_some() {
1542 return LoopAction::CallLLM {
1543 context,
1544 tools: Vec::new(),
1545 };
1546 }
1547 let mut tools = self.tools.clone();
1548 tools.extend(self.ctx.meta_tool_schemas());
1549
1550 if let Some(ref spec) = self.run_spec {
1551 use crate::types::capability::CapabilityKind;
1552 tools.retain(|tool| {
1553 let kind = match tool.name.as_str() {
1554 "skill" => CapabilityKind::Skill,
1555 "memory" => CapabilityKind::Memory,
1556 "knowledge" => CapabilityKind::Knowledge,
1557 _ => CapabilityKind::Tool,
1558 };
1559 let desc = crate::types::capability::CapabilityDescriptor::marker(
1560 kind,
1561 tool.name.clone(),
1562 &tool.description,
1563 );
1564 spec.capability_filter.allows(&desc)
1565 });
1566 }
1567
1568 if let Some(allowed) = self.ctx.active_skill_tool_filter() {
1574 let stable = &self.ctx.stable_core_tools;
1575 tools.retain(|tool| {
1576 matches!(
1577 tool.name.as_str(),
1578 "skill" | "memory" | "knowledge" | "update_plan"
1579 ) || stable.contains(&tool.name)
1580 || allowed.contains(&tool.name)
1581 });
1582 }
1583
1584 if self
1589 .run_spec
1590 .as_ref()
1591 .and_then(|r| r.loop_round.as_ref())
1592 .is_some()
1593 {
1594 tools.push(pace_tool_schema());
1595 }
1596
1597 LoopAction::CallLLM { context, tools }
1598 }
1599
1600 pub fn rollback(&mut self, reason: RollbackReason) {
1601 self.ctx
1602 .partitions
1603 .history
1604 .messages
1605 .truncate(self.checkpoint.history_len);
1606 self.ctx
1607 .partitions
1608 .signals
1609 .truncate(self.checkpoint.signals_len);
1610 if let Some(ref state) = self.checkpoint.task_state {
1611 self.ctx.partitions.task_state = state.clone();
1612 }
1613 self.entropy.note_rollback();
1616 self.observations.push(KernelObservation::Rollbacked {
1617 turn: self.turn,
1618 checkpoint_history_len: self.checkpoint.history_len as u32,
1619 reason: Some(reason),
1620 });
1621 }
1622
1623 fn rollback_reason_for_tool_result(&self, result: &ToolResult) -> Option<RollbackReason> {
1624 let tool_name = self.tool_name_for_call(&result.call_id);
1625 let output = super::rollback::tool_result_output_text(result);
1626
1627 if result.is_fatal {
1628 return Some(RollbackReason::FatalToolError {
1629 tool_name,
1630 error: output,
1631 });
1632 }
1633
1634 match result.error_kind {
1635 Some(ToolErrorKind::Fatal) => Some(RollbackReason::FatalToolError {
1636 tool_name,
1637 error: output,
1638 }),
1639 Some(ToolErrorKind::GovernanceDenied) => Some(RollbackReason::GovernanceDenied {
1640 tool_name,
1641 reason: output,
1642 }),
1643 Some(ToolErrorKind::ProviderFailure) => {
1644 Some(RollbackReason::ProviderFailure { error: output })
1645 }
1646 Some(ToolErrorKind::Timeout) => Some(RollbackReason::Timeout),
1647 Some(ToolErrorKind::UserInterrupt) => Some(RollbackReason::UserInterrupt),
1648 Some(ToolErrorKind::Recoverable) | None => None,
1649 }
1650 }
1651
1652 fn tool_name_for_call(&self, call_id: &compact_str::CompactString) -> String {
1653 match &self.phase {
1654 LoopPhase::Act { tool_calls } => tool_calls
1655 .iter()
1656 .find(|call| call.id == *call_id)
1657 .map(|call| call.name.to_string())
1658 .unwrap_or_else(|| call_id.to_string()),
1659 _ => call_id.to_string(),
1660 }
1661 }
1662}
1663
1664#[cfg(test)]
1665#[path = "tests.rs"]
1666mod tests;
1667
1668fn pace_tool_schema() -> crate::types::message::ToolSchema {
1670 crate::types::message::ToolSchema {
1671 name: compact_str::CompactString::new("pace"),
1672 description: "End this round and decide what happens next: continue immediately, \
1673sleep then run another round, or stop the loop. Call this when the round's work is done."
1674 .to_string(),
1675 parameters: serde_json::json!({
1676 "type": "object",
1677 "properties": {
1678 "next": { "type": "string", "enum": ["continue", "sleep", "stop"] },
1679 "delay_ms": { "type": "integer", "minimum": 0 },
1680 "reason": { "type": "string" }
1681 },
1682 "required": ["next", "reason"]
1683 }),
1684 }
1685}