Skip to main content

deepstrike_core/scheduler/state_machine/
mod.rs

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;
14// `pub use` so external integration tests that glob `state_machine::*` resolve the observation
15// type here — exactly as they did for the former `pub enum LoopObservation` this replaced.
16pub 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
25/// Compact digest of a tool call's arguments for the recency log (2b). Kept short and CJK-safe — it
26/// only needs to make `same-tool / different-args` calls distinguishable (so a legit loop isn't
27/// flagged as a no-progress repeat) and to read sensibly in the "just did: …" footer. Empty for
28/// no-arg / `{}` calls. Lives in the volatile State turn, so length here never churns the cache.
29fn 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/// The *turn step* of the L* execution loop (M1d).
46///
47/// Schedulability (`Ready/Running/Blocked/Suspended/Done`) is no longer carried here — it lives
48/// on the root task's [`TaskLifecycle`] in the kernel's `TaskTable`, queried via
49/// [`LoopStateMachine::lifecycle`]. `LoopPhase` is now orthogonal: it only records *which step of a
50/// running turn* the loop is in. When the task is `Ready/Suspended/Done`, the phase value is
51/// inert (left at its last step) and ignored.
52#[derive(Debug, Clone)]
53pub enum LoopPhase {
54    Reason,
55    Act { tool_calls: Vec<ToolCall> },
56}
57
58/// Events fed into the state machine from the SDK layer.
59#[derive(Debug)]
60pub enum LoopEvent {
61    LLMResponse {
62        message: Message,
63    },
64    ToolResults {
65        results: Vec<ToolResult>,
66    },
67    /// Result of evaluating the current milestone phase's criteria.
68    /// Feed this back after handling `LoopAction::EvaluateMilestone`.
69    MilestoneResult {
70        result: MilestoneCheckResult,
71    },
72    /// Sub-agent run completed — result is injected into the loop as context.
73    SubAgentCompleted {
74        result: SubAgentResult,
75    },
76    Complete,
77    Timeout,
78}
79
80/// Actions the state machine outputs — SDK layer executes the I/O.
81#[derive(Debug, Clone)]
82pub enum LoopAction {
83    /// Structured context ready for a provider call.
84    /// `context.system_text` → provider system param.
85    /// `context.turns`       → provider messages array (strictly alternating).
86    /// `tools`               → tool schemas (skill / memory / knowledge / user tools).
87    CallLLM {
88        context: RenderedContext,
89        tools: Vec<ToolSchema>,
90    },
91    ExecuteTools {
92        calls: Vec<ToolCall>,
93    },
94    /// Host-owned approval effect. The kernel remains suspended until the host
95    /// returns the correlated result through the ABI.
96    RequestApproval {
97        requests: Vec<ApprovalRequest>,
98    },
99    /// Host-owned workflow orchestration effect. The kernel has reserved the
100    /// batch but records no spawn fact until the correlated result arrives.
101    SpawnWorkflow {
102        nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
103        budget: Option<crate::orchestration::workflow::WorkflowBudget>,
104    },
105    /// Host-owned cancellation of in-flight child agents.
106    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    /// Kernel requests the SDK to evaluate the current milestone phase.
135    ///
136    /// The SDK should assess `criteria` against the agent's output using the
137    /// specified `verifier`, then feed back `LoopEvent::MilestoneResult { result }`.
138    EvaluateMilestone {
139        phase_id: String,
140        criteria: Vec<String>,
141        verifier: Option<crate::types::milestone::MilestoneVerifier>,
142        required_evidence: Vec<String>,
143    },
144    /// Kernel is suspended awaiting a non-approval internal continuation.
145    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/// Payload held while the loop is in `Suspended`.
220#[derive(Debug, Clone)]
221pub(super) enum SuspendState {
222    /// Governance AskUser — awaiting a correlated approval result.
223    AskUser {
224        calls: Vec<ToolCall>,
225        gated_reasons: HashMap<String, String>,
226    },
227    /// Sub-agent spawn — awaiting `SubAgentCompleted` for each listed agent id.
228    SubAgentAwait { agent_ids: Vec<String> },
229}
230
231pub(super) enum GateToolOutcome {
232    Proceed,
233    Blocked(LoopAction),
234    ApprovalRequired(Vec<ApprovalRequest>),
235}
236
237/// Snapshot of context lengths captured just before each LLM call.
238/// Used internally to restore state on rollback.
239#[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/// Pure state machine for the L* execution loop. No I/O — only state transitions.
247///
248/// Internal engine backing [`crate::runtime::KernelRuntime`]. Exposed for in-crate
249/// use and tests; external callers should drive the kernel through `KernelRuntime`.
250#[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    /// Reservation-backed hard limits for this operation. Shared accounting stays in the host;
261    /// the kernel tracks only this run's local usage.
262    pub(super) budget_grant: Option<crate::runtime::kernel::BudgetGrant>,
263    pub(super) local_rounds_completed: u32,
264    /// ③ the adjudicated `pace` decision awaiting attachment to this round's LoopResult.
265    pub(super) pending_pace: Option<crate::types::result::PaceDecision>,
266    /// When set, the next LLM call strips tools to force a text response,
267    /// then terminates with this reason once the response arrives.
268    pub(super) pending_termination: Option<TerminationReason>,
269    /// Reactive context-overflow recovery: consecutive compact-and-retry attempts since the last
270    /// successful provider turn. Bounds the recovery ladder (anti-spiral) and resets to 0 on any
271    /// `LLMResponse`, mirroring the per-turn `hasAttemptedReactiveCompact` reset the SDK runners
272    /// used to own. See `recover_from_provider_error`.
273    pub(super) recovery_attempts: u8,
274    pub(crate) provider_recovery_attempt_limit: u8,
275    /// Max-output-tokens recovery: consecutive continue-and-retry turns since the model last
276    /// finished a response WITHOUT hitting the output cap. When a turn is cut off at the cap
277    /// (provider `stop_reason` = max_tokens/length) the kernel keeps the partial, nudges the model
278    /// to resume mid-thought, and re-calls — bounded by `MAX_OUTPUT_RECOVERY` (mirrors query.ts's
279    /// MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). Resets to 0 on any non-truncated response.
280    pub(super) output_recovery_attempts: u8,
281    pub(crate) output_recovery_attempt_limit: u8,
282    pub(crate) host_effect_retry_attempt_limit: u8,
283    /// Transient carrier for the provider `stop_reason` of the in-flight response, set by the
284    /// kernel ABI just before `feed(LLMResponse)` and taken (cleared) inside it. `None` when the
285    /// SDK/provider doesn't report one (every non-Anthropic provider today ⇒ no-op).
286    pub(super) pending_stop_reason: Option<String>,
287    /// Number of history messages present at session start (after preload_history).
288    /// drain_new_messages() returns the slice from this offset onward.
289    pub(super) session_history_baseline: usize,
290    pub(super) checkpoint: TurnCheckpoint,
291    /// Milestone contract tracker (extracted to reduce state machine bloat).
292    pub(super) milestone: MilestoneTracker,
293    pub run_spec: Option<AgentRunSpec>,
294    /// M1 収口: the single source of truth for schedulability *and* sub-agent lineage. Root is
295    /// task `"root"`; each sub-agent is a child task carrying its `ProcInfo`. The former
296    /// `ProcessTable` is now a derived view over this (`agent_process(es)` rebuild `AgentProcess`
297    /// rows on demand via `AgentProcess::from_tcb`).
298    pub(super) tasks: TaskTable,
299    /// Optional governance pipeline. When set, every tool call proposed by the
300    /// model is evaluated before `ExecuteTools` is emitted. `None` (default)
301    /// skips the gate entirely, preserving the pre-governance behavior.
302    pub(super) governance: Option<GovernancePipeline>,
303    /// Optional resource quota evaluated at the syscall trap (M2). `None` (default) leaves spawn /
304    /// memory syscalls unconditionally allowed, preserving pre-M2 behavior.
305    pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
306    /// Timestamps of recent allowed `WriteMemory` syscalls, for the rolling-window rate limit.
307    /// Only populated when `resource_quota.memory_writes_per_window` is set.
308    pub(super) memory_write_times: Vec<u64>,
309    /// Optional long-term memory policy (`set_memory_policy`). `None` (default) preserves
310    /// pre-policy behavior: default-rule validation + verbatim retrieval `top_k`.
311    pub(super) memory_policy: Option<crate::mm::memory::MemoryPolicy>,
312    /// Kernel-owned signal routing: dedup set + attention policy + bounded queue.
313    /// Always initialized; `set_attention` rebuilds it with a new queue size.
314    pub(super) signal_router: SignalRouter,
315    /// Prefix of `ctx.partitions.signals` included in the currently pending provider request.
316    /// A correlated `ProviderResult` consumes exactly this prefix; signals arriving while the
317    /// provider is in flight remain for the next request.
318    pub(super) delivered_signals_len: usize,
319    /// Wall-clock timestamp of the first `ProviderResult.now_ms` received.
320    /// Used by the wall-time budget axis in `SchedulerBudget::should_terminate`.
321    pub(super) started_at_ms: Option<u64>,
322    /// Most-recent `now_ms` value from `ProviderResult`, forwarded to the budget check.
323    pub(super) last_now_ms: Option<u64>,
324    /// Tool batch awaiting `Resume` after an AskUser suspend.
325    pub(super) suspend_state: Option<SuspendState>,
326    /// Denied tool results to merge into the next `ToolResults` feed after resume.
327    pub(super) pending_denied_results: Vec<ToolResult>,
328    /// W0: an in-flight workflow DAG, when one is loaded. The kernel spawns its ready nodes as
329    /// gated batches (each through `evaluate_syscall(Syscall::Spawn)`) and advances on
330    /// completions. `None` (default) preserves the single-spawn `spawn_sub_agent` behavior.
331    pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
332    /// Workflow batch reserved by the kernel and awaiting the host's correlated
333    /// spawn result. This is intent, not an observed external fact.
334    pub(super) pending_workflow_spawn: Option<PendingWorkflowSpawn>,
335    pub(super) pending_preempt: Option<PendingPreempt>,
336    /// Ordered host-owned durability effects produced during a pure state-machine
337    /// transition. The normal continuation is held until every effect commits.
338    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    /// O6: repeat-fuse thresholds (the hard rungs above the 2c soft STOP). Default enabled with
343    /// generous thresholds; tune/disable via `SetRepeatFuse` / `ConfigureRun.repeat_fuse`.
344    pub(super) repeat_fuse: RepeatFuseConfig,
345    /// O6: the previous turn's action signature (non-meta `name(args)` joined — the same key the
346    /// 2c STOP uses). NOT part of the turn checkpoint: a fuse deny's rollback must not launder
347    /// the streak it just tripped on.
348    pub(super) repeat_sig: Option<String>,
349    /// O6: consecutive turns whose signature equalled `repeat_sig` (1 = first occurrence).
350    pub(super) repeat_count: u32,
351    /// O4: turn-end criteria gate (the Stop-hook analog). When the model finishes (no tool calls)
352    /// while explicit acceptance criteria stand, inject ONE bounded self-check turn before
353    /// accepting `Completed`. 2c guards "won't stop"; this guards "stops too early".
354    pub(super) criteria_gate_enabled: bool,
355    /// O4: whether the gate already fired this run (it fires at most once — no nag loops).
356    pub(super) criteria_gate_fired: bool,
357    /// Session-entropy sliding window + watch state (see `scheduler::entropy`). Like the
358    /// RepeatFuse streak, NOT part of the turn checkpoint — a rollback must not launder
359    /// the disorder it just evidenced.
360    pub(super) entropy: EntropyTracker,
361    /// Opt-in threshold watch over the per-turn entropy score. Default disabled; the
362    /// unconditional per-turn `EntropySample` observation does not depend on it.
363    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        // M1d: the root task carries the authoritative schedulability lifecycle. It starts
385        // `Ready`; `start()`/`resume_*` flip it to `Running`, suspends set `Suspended`, and
386        // `terminate()` sets `Done`. `phase` is now only the intra-turn step.
387        tasks.insert(Tcb::root("root", policy.clone()));
388        Self {
389            // Inert placeholder step; meaningful only while the root task is `Running`.
390            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    /// O4: enable/disable the turn-end criteria gate (default enabled; no-op without criteria).
441    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                // Durability effects can change rendered context and conditional meta-tools
510                // (notably `read_result`). Never return the pre-commit frozen provider action.
511                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    /// O6: tune or disable the repeat fuse (see [`RepeatFuseConfig`]).
625    pub fn set_repeat_fuse(&mut self, config: RepeatFuseConfig) {
626        self.repeat_fuse = config;
627    }
628
629    /// Configure the opt-in entropy threshold watch (see [`EntropyWatchConfig`]).
630    /// The per-turn `EntropySample` observation is unconditional and unaffected.
631    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    /// O6: the active repeat-fuse config (for read-modify-write from the ABI event).
640    pub fn repeat_fuse_config(&self) -> RepeatFuseConfig {
641        self.repeat_fuse
642    }
643
644    /// The authoritative schedulability lifecycle of the loop (root task state). Replaces the
645    /// removed `LoopPhase::{Idle,Suspended,Blocked,Terminal}` reads.
646    pub fn lifecycle(&self) -> TaskLifecycle {
647        self.tasks
648            .get("root")
649            .map(|t| t.state)
650            .unwrap_or(TaskLifecycle::Ready)
651    }
652
653    /// The wait reason while suspended/blocked, if any.
654    pub fn wait_reason(&self) -> Option<WaitReason> {
655        self.tasks.get("root").and_then(|t| t.wait.clone())
656    }
657
658    /// Whether the loop has terminated.
659    pub fn is_terminal(&self) -> bool {
660        matches!(self.lifecycle(), TaskLifecycle::Done(_))
661    }
662
663    /// Whether the loop is suspended awaiting external resolution.
664    pub fn is_suspended(&self) -> bool {
665        matches!(self.lifecycle(), TaskLifecycle::Suspended)
666    }
667
668    /// Set the root task's lifecycle (and wait reason). Single mutation point for schedulability.
669    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    /// Build a transient root [`Tcb`] mirroring the current scheduling facts (budget counters,
682    /// wall-clock anchors, lifecycle). M1b uses this to run the pure `schedule()` spine in
683    /// parallel with the legacy budget path; later milestones promote it to the live task row.
684    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    /// Adjust the wall-clock budget axis at runtime.
697    pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
698        self.policy.max_wall_ms = max_wall_ms;
699    }
700
701    /// Install a governance pipeline. Once set, all model-proposed tool calls
702    /// are evaluated before execution. Denied/rate-limited calls roll the turn
703    /// back (reusing the `GovernanceDenied` path); `AskUser` calls surface a
704    /// `ToolGated` observation for the SDK to enforce.
705    pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
706        self.governance = Some(pipeline);
707    }
708
709    /// Install resource quotas (M2). Once set, `Spawn` and `WriteMemory` syscalls are bounded by
710    /// the quota at the trap. Not setting it (the default) leaves them unconditionally allowed.
711    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    /// L1: this vehicle's cumulative sub-agent spawns this run — every child task ever registered in
720    /// the `TaskTable` (running + completed), distinct from the *instantaneous* running count. Used
721    /// for the cumulative spawn quota and read back by the SDK to charge the group ledger at run end.
722    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    /// Install the long-term memory policy (`set_memory_policy`). Once set it gates `write_memory`
739    /// validation and bounds `query_memory` retrieval breadth. Not setting it (the default)
740    /// preserves pre-policy behavior.
741    pub fn set_memory_policy(&mut self, policy: crate::mm::memory::MemoryPolicy) {
742        self.memory_policy = Some(policy);
743    }
744
745    /// The installed memory policy, if any. `None` means default-rule validation + verbatim top_k.
746    pub fn memory_policy(&self) -> Option<&crate::mm::memory::MemoryPolicy> {
747        self.memory_policy.as_ref()
748    }
749
750    /// Feed the current wall-clock time (ms) to scheduler/governance budget axes.
751    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    /// Stash the in-flight response's provider `stop_reason` so `feed(LLMResponse)` can detect an
762    /// output-cap truncation. Set by the kernel ABI right before feeding the result; `None` clears it.
763    pub fn set_pending_stop_reason(&mut self, stop_reason: Option<String>) {
764        self.pending_stop_reason = stop_reason;
765    }
766
767    /// Pre-populate the history partition with messages from a prior session.
768    ///
769    /// Call **before** `start()` when resuming a conversation. Sets the baseline
770    /// so `drain_new_messages()` returns only the messages from the current run.
771    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    /// Continue from preloaded history without appending a new user turn.
780    /// Use after `preload_history` when recovering a session that ended mid-run.
781    ///
782    /// If the last assistant turn has tool calls without matching tool results,
783    /// resumes with `ExecuteTools` instead of calling the LLM again.
784    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    /// Return all messages added to history during the current run
801    /// (since the last `preload_history` call or since construction).
802    ///
803    /// Call after `LoopAction::Done` to get the complete turn transcript
804    /// for persistence to a SessionStore.
805    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        // A loop vehicle with no admitted round capacity must not make even one provider call.
816        // The host may have raced another member between reading its durable loop log and reserve;
817        // the reservation is the authoritative admission decision.
818        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        // User message goes into history so it appears at the correct chronological
846        // position: [prior turns...] → [current user message] — LLM reads left-to-right
847        // and responds to the last message. working is reserved for runtime signals only.
848        // Estimate tokens (1 token ≈ 4 chars) with a minimum of 1 so the renderer
849        // does not skip this message (it skips zero-token entries).
850        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        // Root task (seeded `Ready` in `new()`) becomes `Running`; `emit_call_llm` sets it.
854        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        // K3: skill leases expire on the same head-of-event cadence as capability leases.
861        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                // Signals admitted while the provider was in flight were not in the completed
871                // request. Promote queued items at this boundary and keep a no-tool response from
872                // terminating before the model receives them in a follow-up request.
873                self.drain_queued_signals();
874                let signals_waiting_for_followup = !self.ctx.partitions.signals.is_empty();
875                // A response arrived ⇒ the prompt fit ⇒ the overflow recovery ladder is reset.
876                self.recovery_attempts = 0;
877                let tokens = self.message_tokens(&message);
878                self.total_tokens += tokens as u64;
879
880                // Max-output-tokens recovery (mirrors query.ts): a response cut off at the output
881                // cap reports stop_reason = max_tokens (Anthropic) / length (OpenAI). A clean finish
882                // resets the ladder.
883                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                    // The model was cut off at the output cap with no tool call. Keep the partial,
898                    // nudge it to resume mid-thought, and re-call — instead of mistaking the
899                    // truncation for a finished turn. Bounded by MAX_OUTPUT_RECOVERY; once exhausted
900                    // the partial stands and the turn terminates normally below. (A truncated
901                    // *tool-call* turn isn't handled here — it falls through to tool execution.)
902                    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                    // When a milestone contract is active and not yet complete,
912                    // request evaluation instead of terminating.
913                    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                        // `tokens` was already computed for this message above.
922                        self.ctx.push_history(message, tokens);
923                        return LoopAction::EvaluateMilestone {
924                            phase_id,
925                            criteria,
926                            verifier,
927                            required_evidence,
928                        };
929                    }
930                    // O4 criteria gate (the Stop-hook analog): the model is finishing while explicit
931                    // acceptance criteria stand. Before accepting `Completed`, inject ONE bounded
932                    // self-check at the peak-attention slot — verify each criterion, continue if any
933                    // is unmet, else confirm. Fires at most once per run (no nag loop); runs with no
934                    // criteria are untouched. 2c guards "won't stop"; this guards "stops too early".
935                    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                // ━━ 记录活动时间(Layer 3时间衰减使用)
968                if let Some(now_ms) = self.last_now_ms {
969                    self.ctx.record_activity(now_ms);
970                }
971
972                // ③ pacing trap: a `pace` call is a kernel-adjudicated round-end proposal,
973                // never an SDK tool. Handled before the fuse/gate — it is a control verb,
974                // not task work.
975                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                // 2b: record this turn's tool activity into the task-state recency log (meta-tools
988                // filtered inside). The State-turn footer renders it as "just did: …" + a forward
989                // nudge / STOP, so progress is kernel-derived and never depends on the model
990                // remembering to call `update_plan`. Tool *names* live only on the request (results
991                // carry call_id only), so this is the turn to capture them.
992                //
993                // Capture name AND a compact arg digest: the no-progress STOP keys on whether the
994                // SAME call repeats, and a legit loop (same tool, DIFFERENT args — e.g. processing 20
995                // items) is real progress, not a stall. Keying on the name alone false-positives those
996                // loops; including args distinguishes "step(n=1), step(n=2)…" from a true repeat.
997                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                // O6 RepeatFuse: the hard rungs above the 2c soft STOP. Runs BEFORE the governance
1004                // gate and independent of whether a policy is loaded — a batteries-included kernel
1005                // protection, not a policy feature. Deny rolls the turn back with a directive note;
1006                // the terminate rung ends the run `NoProgress` after one final no-tools report turn.
1007                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                // Non-fatal errors are committed to history so the LLM can
1044                // see them and self-correct without losing turn state.
1045
1046                // Entropy: this completed turn's failure tally (fatal errors never get
1047                // here — they rolled back above and accrued via `note_rollback`).
1048                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                    // Preserve Content::Parts (structured / multimodal tool output).
1061                    // Parts are serialised to JSON so the text can be restored faithfully.
1062                    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                    // Layer 1 spool: oversized results keep only a preview in context. The full
1067                    // output becomes a host effect and no success fact is recorded until its
1068                    // correlated result commits.
1069                    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                    // When spooled, `r.token_count` reflects the full output — recount the preview.
1098                    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                // M1 收口: the pure `schedule()` is now the single budget decision point.
1109                // It evaluates the same three axes (turn/token/wall) via `BudgetLedger`, which
1110                // delegates to `SchedulerBudget::should_terminate` internally — one source of truth.
1111                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                // ━━ Eviction checkpoint (M3): one decision model (`plan_eviction`), one
1132                // execution funnel (`execute_eviction_op`). Layer 3 (idle/time-decay) must run
1133                // before the rho recommendation is read, since it mutates token usage — so the
1134                // plan is built in that interleaved order and the ops are executed in plan order.
1135                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                // Layer 4 read-time projection: recompute handle residency on the post-time-decay rho.
1143                self.ctx.recompute_handle_residency();
1144                // K2: knowledge budget check — marks over-budget unpinned entries for the next
1145                // boundary sweep (marks are idempotent; drops only apply there) and stashes a
1146                // warn-once-per-generation notice, drained into an observation here.
1147                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                // Layers 2/4/5: execute the pressure-driven ops from the plan (skip TimeDecayMicro
1156                // if already executed). The plan carries specific ops stamped with real config-derived
1157                // params (W1-1 収口 — no magic-number placeholders), not the umbrella `Pressure` wrapper.
1158                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                // `idle_decay` ⇒ the plan carries a `TimeDecayMicro` (so the skip-on-already-executed
1166                // below is meaningful). The converse does NOT hold: a pressure-driven `MicroCompact`
1167                // also emits `TimeDecayMicro` independent of `idle_decay` (W1 unified planner), so we
1168                // assert the implication, not equality.
1169                debug_assert!(!idle_decay || plan.has_time_decay());
1170                for op in &plan.ops {
1171                    // Skip TimeDecayMicro if we already executed it (prevents double-execution).
1172                    if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
1173                        continue;
1174                    }
1175                    self.execute_eviction_op(op);
1176                }
1177
1178                // Renewal: when compression alone cannot recover enough headroom,
1179                // start a new sprint — carry forward system + memory + last N history turns.
1180                if self.ctx.should_renew() {
1181                    self.ctx.renew();
1182                    // A new sprint is a session boundary for signal identity: clear the dedup set so
1183                    // it cannot grow unbounded across a long run, and so a signal seen in a prior
1184                    // sprint may legitimately re-fire in the new one.
1185                    self.signal_router.clear_dedup();
1186                    self.observations.push(KernelObservation::Renewed {
1187                        sprint: self.ctx.sprint,
1188                    });
1189                    // K1: renewal is a boundary — surface the knowledge sweep it just ran.
1190                    self.emit_knowledge_sweep_observations();
1191                }
1192
1193                // Session-entropy sample (the heartbeat watch source): fold this completed
1194                // turn's outcomes into the sliding window and surface the measurement.
1195                // Unconditional, like `CheckpointTaken`; only the watch alert below is opt-in.
1196                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                // Opt-in entropy watch: threshold + hysteresis + cooldown. The alert is an
1220                // observation (host-facing); with `notify_model` it is ALSO routed through
1221                // the kernel's own signal dispatch as a Heartbeat/Alert directive — High
1222                // urgency while running ⇒ a durable [SIGNAL] note on the turn we are about
1223                // to emit anyway, never an extra provider call.
1224                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                // Turn boundary: drain any kernel-queued signals into context so they
1254                // are seen on the next reasoning turn (ready queue → running).
1255                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    /// Drain observations emitted during the last `start`/`feed` call.
1283    pub fn take_observations(&mut self) -> Vec<KernelObservation> {
1284        std::mem::take(&mut self.observations)
1285    }
1286
1287    /// ③ the pacing trap. The model PROPOSES `pace(next, delay_ms?, reason)`; the kernel
1288    /// ADJUDICATES: malformed → governance-style rollback note; sleep delay clamped into
1289    /// the spec's [min,max]; continue/sleep at the round cap coerced to stop("max_rounds");
1290    /// stop with standing acceptance criteria routes through the O4 criteria gate ONCE
1291    /// (one bounded self-check turn) before being honored. An allowed pace ends the round:
1292    /// the decision is stashed for LoopResult, a synthetic tool result closes the
1293    /// transcript pair, and the strip-tools final-report turn finishes the round.
1294    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                // Malformed proposal: governance-style directive note + fresh reason turn.
1323                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        // Round-cap coercion: both the run spec and reservation grant bound local rounds.
1344        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        // O4 routing: a stop with standing criteria takes the existing criteria-gate
1360        // self-check turn first; the model re-decides with the checklist in view.
1361        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        // Sleep clamp into [min, max].
1388        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    /// Close a kernel-handled tool call's transcript pair with a synthetic result so
1435    /// providers always see call → result.
1436    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        // Commit the final response into history so subsequent session restores
1452        // include the complete transcript: user → [tool turns] → final assistant.
1453        if let Some(ref msg) = final_message {
1454            let tokens = self.message_tokens(msg);
1455            self.ctx.push_history(msg.clone(), tokens);
1456        }
1457        // ③ attach the round's pacing decision. Stashed by the trap when the model
1458        // called `pace`; otherwise the spec's default_action ("stop" for goal loops,
1459        // "sleep" for cron loops) — but ONLY on a clean Completed. NoProgress /
1460        // ContextOverflow / Error rounds stop and surface (nothing nags the model).
1461        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    /// Build the `CallLLM` action with a structured `RenderedContext`.
1501    /// Meta-tools (skill / memory / knowledge) are appended to the tool list
1502    /// when configured. When `pending_termination` is set, tools are stripped
1503    /// to force a plain-text response before the loop terminates.
1504    fn emit_call_llm(&mut self) -> LoopAction {
1505        // Calling the provider is definitionally "running" — the single funnel for entering the
1506        // Running lifecycle (covers start, resume, signal-driven turns, budget final-call).
1507        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            // P0-2 §C: only a `FixedContext` overflow (system + state_turn alone exceed the hard
1527            // window) is unrecoverable — compaction cannot touch that region, so terminate honestly.
1528            // A `ProtectedTail` overflow (a protected recent unit tips the budget after compaction
1529            // already ran) is NOT terminal: the observation records the over-budget tail, and the
1530            // context is still submitted. The provider decides; if it rejects with a 413, the
1531            // reactive recovery ladder (`recover_from_provider_error`) is the real backstop. Silently
1532            // terminating here would kill runs the provider could have accepted or recovered from.
1533            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        // P1-B epoch skill gating (applied *after* the run-level filter ③, so A is the outer bound
1569        // and B narrows within it — D6). When skills are active and declare tools, expose only
1570        // `meta-tools ∪ stable-core ∪ ⋃(active skills' allowed_tools)`. `None` ⇒ no active/declared
1571        // skill ⇒ no narrowing (D3, errs-open). Meta-tools are always exempt (D5) so the model can
1572        // still load more skills. Byte-stable within an epoch: the set only changes on activation.
1573        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        // ③ pace meta-tool: exposed ONLY when this run is a round of a paced loop
1585        // (run_spec.loop_round present) — the same conditional-exposure pattern as
1586        // skill/memory/read_result. Pushed after every filter: pacing is kernel-owned
1587        // and must never be narrowed away by skills or capability filters.
1588        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        // Rolled-back turns never reach the boundary sample point; accrue here so the
1614        // disorder they evidence lands in the next completed turn's entropy window.
1615        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
1668/// ③ the `pace` meta-tool schema — exposed only on loop-round runs.
1669fn 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}