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.
29///
30/// The 2c STOP and the O6 fuse compare these digests for EQUALITY, so identity must cover the
31/// FULL arguments even though the display truncates: serde_json orders keys alphabetically, and
32/// a long leading value (an `edit` call's `file_path`) otherwise swallows the whole window —
33/// collapsing distinct same-file edits into one signature. A truncated digest therefore carries
34/// a hash of the complete canonical JSON as its suffix.
35fn compact_tool_args(args: &serde_json::Value) -> String {
36    if args.is_null() {
37        return String::new();
38    }
39    let s = args.to_string();
40    if s == "{}" {
41        return String::new();
42    }
43    const MAX: usize = 48;
44    if s.chars().count() <= MAX {
45        s
46    } else {
47        // FNV-1a 64 folded to 32 bits: deterministic across processes/replays (no SipHash
48        // random keys), 8 hex chars of noise in a footer line that already truncates.
49        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
50        for b in s.as_bytes() {
51            h ^= u64::from(*b);
52            h = h.wrapping_mul(0x0000_0100_0000_01b3);
53        }
54        let fold = (h ^ (h >> 32)) as u32;
55        format!("{}…#{fold:08x}", s.chars().take(MAX).collect::<String>())
56    }
57}
58
59/// The *turn step* of the L* execution loop (M1d).
60///
61/// Schedulability (`Ready/Running/Blocked/Suspended/Done`) is no longer carried here — it lives
62/// on the root task's [`TaskLifecycle`] in the kernel's `TaskTable`, queried via
63/// [`LoopStateMachine::lifecycle`]. `LoopPhase` is now orthogonal: it only records *which step of a
64/// running turn* the loop is in. When the task is `Ready/Suspended/Done`, the phase value is
65/// inert (left at its last step) and ignored.
66#[derive(Debug, Clone)]
67pub enum LoopPhase {
68    Reason,
69    Act { tool_calls: Vec<ToolCall> },
70}
71
72/// Events fed into the state machine from the SDK layer.
73#[derive(Debug)]
74pub enum LoopEvent {
75    LLMResponse {
76        message: Message,
77    },
78    ToolResults {
79        results: Vec<ToolResult>,
80    },
81    /// Result of evaluating the current milestone phase's criteria.
82    /// Feed this back after handling `LoopAction::EvaluateMilestone`.
83    MilestoneResult {
84        result: MilestoneCheckResult,
85    },
86    /// Sub-agent run completed — result is injected into the loop as context.
87    SubAgentCompleted {
88        result: SubAgentResult,
89    },
90    Complete,
91    Timeout,
92}
93
94/// Actions the state machine outputs — SDK layer executes the I/O.
95#[derive(Debug, Clone)]
96pub enum LoopAction {
97    /// Structured context ready for a provider call.
98    /// `context.system_text` → provider system param.
99    /// `context.turns`       → provider messages array (strictly alternating).
100    /// `tools`               → tool schemas (skill / memory / knowledge / user tools).
101    CallLLM {
102        context: RenderedContext,
103        tools: Vec<ToolSchema>,
104    },
105    ExecuteTools {
106        calls: Vec<ToolCall>,
107    },
108    /// Host-owned approval effect. The kernel remains suspended until the host
109    /// returns the correlated result through the ABI.
110    RequestApproval {
111        requests: Vec<ApprovalRequest>,
112    },
113    /// Host-owned workflow orchestration effect. The kernel has reserved the
114    /// batch but records no spawn fact until the correlated result arrives.
115    SpawnWorkflow {
116        nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
117        budget: Option<crate::orchestration::workflow::WorkflowBudget>,
118    },
119    /// Host-owned cancellation of in-flight child agents.
120    PreemptSubAgents {
121        agent_ids: Vec<String>,
122        reason: String,
123    },
124    PersistMemory {
125        memory: crate::mm::memory::MemoryRecord,
126    },
127    QueryMemory {
128        query: crate::mm::memory::MemoryQuery,
129        requested_k: usize,
130    },
131    SpoolLargeResult {
132        call_id: String,
133        tool: String,
134        output: String,
135        original_size: u32,
136        preview_size: u32,
137    },
138    ArchivePageOut {
139        turn: u32,
140        action: crate::runtime::kernel::KernelPressureAction,
141        summary: Option<String>,
142        archived: Vec<Message>,
143        tier: String,
144    },
145    Done {
146        result: LoopResult,
147    },
148    /// Kernel requests the SDK to evaluate the current milestone phase.
149    ///
150    /// The SDK should assess `criteria` against the agent's output using the
151    /// specified `verifier`, then feed back `LoopEvent::MilestoneResult { result }`.
152    EvaluateMilestone {
153        phase_id: String,
154        criteria: Vec<String>,
155        verifier: Option<crate::types::milestone::MilestoneVerifier>,
156        required_evidence: Vec<String>,
157    },
158    /// Kernel is suspended awaiting a non-approval internal continuation.
159    AwaitingResume,
160}
161
162#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
163pub struct ApprovalRequest {
164    pub call_id: String,
165    pub tool: String,
166    pub arguments: serde_json::Value,
167    pub reason: String,
168}
169
170#[derive(Debug, Clone)]
171pub(super) struct PendingWorkflowSpawn {
172    pub nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
173    pub budget: Option<crate::orchestration::workflow::WorkflowBudget>,
174}
175
176#[derive(Debug, Clone)]
177pub(super) struct PendingPreempt {
178    pub agent_ids: Vec<String>,
179    pub reason: String,
180}
181
182#[derive(Debug, Clone)]
183pub(super) enum PendingHostEffect {
184    SpoolLargeResult {
185        call_id: String,
186        tool: String,
187        output: String,
188        original_size: u32,
189        preview_size: u32,
190    },
191    ArchivePageOut {
192        turn: u32,
193        action: crate::runtime::kernel::KernelPressureAction,
194        summary: Option<String>,
195        archived: Vec<Message>,
196        tier: String,
197    },
198}
199
200impl PendingHostEffect {
201    fn action(&self) -> LoopAction {
202        match self {
203            Self::SpoolLargeResult {
204                call_id,
205                tool,
206                output,
207                original_size,
208                preview_size,
209            } => LoopAction::SpoolLargeResult {
210                call_id: call_id.clone(),
211                tool: tool.clone(),
212                output: output.clone(),
213                original_size: *original_size,
214                preview_size: *preview_size,
215            },
216            Self::ArchivePageOut {
217                turn,
218                action,
219                summary,
220                archived,
221                tier,
222            } => LoopAction::ArchivePageOut {
223                turn: *turn,
224                action: *action,
225                summary: summary.clone(),
226                archived: archived.clone(),
227                tier: tier.clone(),
228            },
229        }
230    }
231}
232
233/// Payload held while the loop is in `Suspended`.
234#[derive(Debug, Clone)]
235pub(super) enum SuspendState {
236    /// Governance AskUser — awaiting a correlated approval result.
237    AskUser {
238        calls: Vec<ToolCall>,
239        gated_reasons: HashMap<String, String>,
240    },
241    /// Sub-agent spawn — awaiting `SubAgentCompleted` for each listed agent id.
242    SubAgentAwait { agent_ids: Vec<String> },
243}
244
245pub(super) enum GateToolOutcome {
246    Proceed,
247    Blocked(LoopAction),
248    ApprovalRequired(Vec<ApprovalRequest>),
249}
250
251/// Snapshot of context lengths captured just before each LLM call.
252/// Used internally to restore state on rollback.
253#[derive(Debug, Clone, Default)]
254pub struct TurnCheckpoint {
255    pub history_len: usize,
256    pub signals_len: usize,
257    pub task_state: Option<crate::context::task_state::TaskState>,
258}
259
260/// Pure state machine for the L* execution loop. No I/O — only state transitions.
261///
262/// Internal engine backing [`crate::runtime::KernelRuntime`]. Exposed for in-crate
263/// use and tests; external callers should drive the kernel through `KernelRuntime`.
264#[doc(hidden)]
265pub struct LoopStateMachine {
266    pub phase: LoopPhase,
267    pub turn: u32,
268    pub ctx: ContextManager,
269    pub tools: Vec<ToolSchema>,
270    pub observations: Vec<KernelObservation>,
271    pub(super) policy: SchedulerBudget,
272    pub(super) scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig,
273    pub(super) total_tokens: u64,
274    /// Reservation-backed hard limits for this operation. Shared accounting stays in the host;
275    /// the kernel tracks only this run's local usage.
276    pub(super) budget_grant: Option<crate::runtime::kernel::BudgetGrant>,
277    pub(super) local_rounds_completed: u32,
278    /// ③ the adjudicated `pace` decision awaiting attachment to this round's LoopResult.
279    pub(super) pending_pace: Option<crate::types::result::PaceDecision>,
280    /// When set, the next LLM call strips tools to force a text response,
281    /// then terminates with this reason once the response arrives.
282    pub(super) pending_termination: Option<TerminationReason>,
283    /// Reactive context-overflow recovery: consecutive compact-and-retry attempts since the last
284    /// successful provider turn. Bounds the recovery ladder (anti-spiral) and resets to 0 on any
285    /// `LLMResponse`, mirroring the per-turn `hasAttemptedReactiveCompact` reset the SDK runners
286    /// used to own. See `recover_from_provider_error`.
287    pub(super) recovery_attempts: u8,
288    pub(crate) provider_recovery_attempt_limit: u8,
289    /// Max-output-tokens recovery: consecutive continue-and-retry turns since the model last
290    /// finished a response WITHOUT hitting the output cap. When a turn is cut off at the cap
291    /// (provider `stop_reason` = max_tokens/length) the kernel keeps the partial, nudges the model
292    /// to resume mid-thought, and re-calls — bounded by `MAX_OUTPUT_RECOVERY` (mirrors query.ts's
293    /// MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). Resets to 0 on any non-truncated response.
294    pub(super) output_recovery_attempts: u8,
295    pub(crate) output_recovery_attempt_limit: u8,
296    pub(crate) host_effect_retry_attempt_limit: u8,
297    /// Transient carrier for the provider `stop_reason` of the in-flight response, set by the
298    /// kernel ABI just before `feed(LLMResponse)` and taken (cleared) inside it. `None` when the
299    /// SDK/provider doesn't report one (every non-Anthropic provider today ⇒ no-op).
300    pub(super) pending_stop_reason: Option<String>,
301    /// Number of history messages present at session start (after preload_history).
302    /// drain_new_messages() returns the slice from this offset onward.
303    pub(super) session_history_baseline: usize,
304    pub(super) checkpoint: TurnCheckpoint,
305    /// Milestone contract tracker (extracted to reduce state machine bloat).
306    pub(super) milestone: MilestoneTracker,
307    pub run_spec: Option<AgentRunSpec>,
308    /// M1 収口: the single source of truth for schedulability *and* sub-agent lineage. Root is
309    /// task `"root"`; each sub-agent is a child task carrying its `ProcInfo`. The former
310    /// `ProcessTable` is now a derived view over this (`agent_process(es)` rebuild `AgentProcess`
311    /// rows on demand via `AgentProcess::from_tcb`).
312    pub(super) tasks: TaskTable,
313    /// Optional governance pipeline. When set, every tool call proposed by the
314    /// model is evaluated before `ExecuteTools` is emitted. `None` (default)
315    /// skips the gate entirely, preserving the pre-governance behavior.
316    pub(super) governance: Option<GovernancePipeline>,
317    /// Optional resource quota evaluated at the syscall trap (M2). `None` (default) leaves spawn /
318    /// memory syscalls unconditionally allowed, preserving pre-M2 behavior.
319    pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
320    /// Timestamps of recent allowed `WriteMemory` syscalls, for the rolling-window rate limit.
321    /// Only populated when `resource_quota.memory_writes_per_window` is set.
322    pub(super) memory_write_times: Vec<u64>,
323    /// Optional long-term memory policy (`set_memory_policy`). `None` (default) preserves
324    /// pre-policy behavior: default-rule validation + verbatim retrieval `top_k`.
325    pub(super) memory_policy: Option<crate::mm::memory::MemoryPolicy>,
326    /// Kernel-owned signal routing: dedup set + attention policy + bounded queue.
327    /// Always initialized; `set_attention` rebuilds it with a new queue size.
328    pub(super) signal_router: SignalRouter,
329    /// Prefix of `ctx.partitions.signals` included in the currently pending provider request.
330    /// A correlated `ProviderResult` consumes exactly this prefix; signals arriving while the
331    /// provider is in flight remain for the next request.
332    pub(super) delivered_signals_len: usize,
333    /// Wall-clock timestamp of the first `ProviderResult.now_ms` received.
334    /// Used by the wall-time budget axis in `SchedulerBudget::should_terminate`.
335    pub(super) started_at_ms: Option<u64>,
336    /// Most-recent `now_ms` value from `ProviderResult`, forwarded to the budget check.
337    pub(super) last_now_ms: Option<u64>,
338    /// Tool batch awaiting `Resume` after an AskUser suspend.
339    pub(super) suspend_state: Option<SuspendState>,
340    /// Denied tool results to merge into the next `ToolResults` feed after resume.
341    pub(super) pending_denied_results: Vec<ToolResult>,
342    /// W0: an in-flight workflow DAG, when one is loaded. The kernel spawns its ready nodes as
343    /// gated batches (each through `evaluate_syscall(Syscall::Spawn)`) and advances on
344    /// completions. `None` (default) preserves the single-spawn `spawn_sub_agent` behavior.
345    pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
346    /// Workflow batch reserved by the kernel and awaiting the host's correlated
347    /// spawn result. This is intent, not an observed external fact.
348    pub(super) pending_workflow_spawn: Option<PendingWorkflowSpawn>,
349    pub(super) pending_preempt: Option<PendingPreempt>,
350    /// Ordered host-owned durability effects produced during a pure state-machine
351    /// transition. The normal continuation is held until every effect commits.
352    pub(super) pending_host_effects: VecDeque<PendingHostEffect>,
353    pub(super) active_host_effect: Option<PendingHostEffect>,
354    pub(super) active_host_effect_failures: u8,
355    pub(super) deferred_action: Option<Box<LoopAction>>,
356    /// O6: repeat-fuse thresholds (the hard rungs above the 2c soft STOP). Default enabled with
357    /// generous thresholds; tune/disable via `SetRepeatFuse` / `ConfigureRun.repeat_fuse`.
358    pub(super) repeat_fuse: RepeatFuseConfig,
359    /// O6: the previous turn's action signature (non-meta `name(args)` joined — the same key the
360    /// 2c STOP uses). NOT part of the turn checkpoint: a fuse deny's rollback must not launder
361    /// the streak it just tripped on.
362    pub(super) repeat_sig: Option<String>,
363    /// O6: consecutive turns whose signature equalled `repeat_sig` (1 = first occurrence).
364    pub(super) repeat_count: u32,
365    /// O4: turn-end criteria gate (the Stop-hook analog). When the model finishes (no tool calls)
366    /// while explicit acceptance criteria stand, inject ONE bounded self-check turn before
367    /// accepting `Completed`. 2c guards "won't stop"; this guards "stops too early".
368    pub(super) criteria_gate_enabled: bool,
369    /// O4: whether the gate already fired this run (it fires at most once — no nag loops).
370    pub(super) criteria_gate_fired: bool,
371    /// Session-entropy sliding window + watch state (see `scheduler::entropy`). Like the
372    /// RepeatFuse streak, NOT part of the turn checkpoint — a rollback must not launder
373    /// the disorder it just evidenced.
374    pub(super) entropy: EntropyTracker,
375    /// Opt-in threshold watch over the per-turn entropy score. Default disabled; the
376    /// unconditional per-turn `EntropySample` observation does not depend on it.
377    pub(super) entropy_watch: EntropyWatchConfig,
378}
379
380mod cancellation;
381mod capability;
382mod eviction;
383mod gate;
384mod milestone_exec;
385mod process;
386mod signal;
387mod workflow;
388
389impl LoopStateMachine {
390    fn message_tokens(&self, message: &Message) -> u32 {
391        message
392            .token_count
393            .unwrap_or_else(|| self.ctx.engine.count_message(message))
394    }
395
396    pub fn new(policy: SchedulerBudget) -> Self {
397        let mut tasks = TaskTable::new();
398        // M1d: the root task carries the authoritative schedulability lifecycle. It starts
399        // `Ready`; `start()`/`resume_*` flip it to `Running`, suspends set `Suspended`, and
400        // `terminate()` sets `Done`. `phase` is now only the intra-turn step.
401        tasks.insert(Tcb::root("root", policy.clone()));
402        Self {
403            // Inert placeholder step; meaningful only while the root task is `Running`.
404            phase: LoopPhase::Reason,
405            turn: 0,
406            ctx: ContextManager::new(policy.max_tokens),
407            tools: Vec::new(),
408            observations: Vec::new(),
409            policy,
410            scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig::default(),
411            total_tokens: 0,
412            budget_grant: None,
413            local_rounds_completed: 0,
414            pending_pace: None,
415            pending_termination: None,
416            recovery_attempts: 0,
417            provider_recovery_attempt_limit: 2,
418            output_recovery_attempts: 0,
419            output_recovery_attempt_limit: 3,
420            host_effect_retry_attempt_limit: 3,
421            pending_stop_reason: None,
422            session_history_baseline: 0,
423            checkpoint: TurnCheckpoint::default(),
424            milestone: MilestoneTracker::new(),
425            run_spec: None,
426            tasks,
427            governance: None,
428            resource_quota: None,
429            memory_write_times: Vec::new(),
430            memory_policy: None,
431            signal_router: SignalRouter::new(64),
432            delivered_signals_len: 0,
433            started_at_ms: None,
434            last_now_ms: None,
435            suspend_state: None,
436            pending_denied_results: Vec::new(),
437            workflow: None,
438            pending_workflow_spawn: None,
439            pending_preempt: None,
440            pending_host_effects: VecDeque::new(),
441            active_host_effect: None,
442            active_host_effect_failures: 0,
443            deferred_action: None,
444            repeat_fuse: RepeatFuseConfig::default(),
445            repeat_sig: None,
446            repeat_count: 0,
447            criteria_gate_enabled: true,
448            criteria_gate_fired: false,
449            entropy: EntropyTracker::default(),
450            entropy_watch: EntropyWatchConfig::default(),
451        }
452    }
453
454    /// O4: enable/disable the turn-end criteria gate (default enabled; no-op without criteria).
455    pub fn set_criteria_gate(&mut self, enabled: bool) {
456        self.criteria_gate_enabled = enabled;
457    }
458
459    pub(crate) fn set_scheduler_policy(
460        &mut self,
461        policy: crate::scheduler::policy::SchedulerPolicyConfig,
462    ) {
463        self.scheduler_policy = policy;
464        if let Some(workflow) = self.workflow.as_mut() {
465            workflow.set_scheduler_policy(policy);
466        }
467    }
468
469    pub(crate) fn set_reliability_config(
470        &mut self,
471        config: &crate::runtime::kernel::KernelReliabilityConfig,
472    ) {
473        if let Some(limit) = config.provider_recovery_attempts {
474            self.provider_recovery_attempt_limit = limit;
475        }
476        if let Some(limit) = config.output_recovery_attempts {
477            self.output_recovery_attempt_limit = limit;
478        }
479        if let Some(limit) = config.host_effect_retry_attempts {
480            self.host_effect_retry_attempt_limit = limit;
481        }
482        if let Some(bytes) = config.spool_threshold_bytes {
483            self.ctx.config.spool_threshold_bytes = bytes;
484        }
485        if let Some(bytes) = config.spool_preview_bytes {
486            self.ctx.config.spool_preview_bytes = bytes;
487        }
488    }
489
490    pub(crate) fn externalize_pending_host_effect(
491        &mut self,
492        continuation: LoopAction,
493    ) -> LoopAction {
494        if self.active_host_effect.is_some() {
495            return continuation;
496        }
497        let Some(pending) = self.pending_host_effects.pop_front() else {
498            return continuation;
499        };
500        assert!(
501            self.deferred_action.is_none(),
502            "host effect continuation must be unique"
503        );
504        self.deferred_action = Some(Box::new(continuation));
505        self.active_host_effect = Some(pending);
506        self.active_host_effect_failures = 0;
507        self.active_host_effect
508            .as_ref()
509            .expect("host effect was just activated")
510            .action()
511    }
512
513    fn next_after_host_effect(&mut self) -> LoopAction {
514        if let Some(pending) = self.pending_host_effects.pop_front() {
515            self.active_host_effect = Some(pending);
516            self.active_host_effect_failures = 0;
517            self.active_host_effect
518                .as_ref()
519                .expect("host effect was just activated")
520                .action()
521        } else {
522            match self.deferred_action.take().map(|action| *action) {
523                // Durability effects can change rendered context and conditional meta-tools
524                // (notably `read_result`). Never return the pre-commit frozen provider action.
525                Some(LoopAction::CallLLM { .. }) => self.emit_call_llm(),
526                Some(action) => action,
527                None => LoopAction::AwaitingResume,
528            }
529        }
530    }
531
532    pub(crate) fn resolve_large_result_spool(
533        &mut self,
534        spool_ref: Option<String>,
535        error: Option<String>,
536    ) -> LoopAction {
537        let pending = self
538            .active_host_effect
539            .as_ref()
540            .expect("spool result requires an active host effect");
541        let PendingHostEffect::SpoolLargeResult {
542            call_id,
543            tool,
544            original_size,
545            preview_size,
546            ..
547        } = pending
548        else {
549            panic!("spool result does not match active page-out effect");
550        };
551        if let Some(error) = error {
552            self.observations
553                .push(KernelObservation::LargeResultSpoolFailed {
554                    turn: self.turn,
555                    call_id: call_id.clone(),
556                    tool: tool.clone(),
557                    error,
558                });
559            self.active_host_effect_failures = self.active_host_effect_failures.saturating_add(1);
560            if self.active_host_effect_failures > self.host_effect_retry_attempt_limit {
561                self.active_host_effect = None;
562                self.pending_host_effects.clear();
563                self.deferred_action = None;
564                return self.terminate(TerminationReason::Error, None);
565            }
566            return pending.action();
567        }
568        let spool_ref = spool_ref.expect("successful spool result requires spool_ref");
569        let call_id = call_id.clone();
570        let tool = tool.clone();
571        let original_size = *original_size;
572        let preview_size = *preview_size;
573        self.ctx.mark_spooled(&call_id, spool_ref.clone());
574        self.observations
575            .push(KernelObservation::LargeResultSpooled {
576                turn: self.turn,
577                call_id,
578                tool,
579                original_size,
580                preview_size,
581                spool_ref: Some(spool_ref),
582            });
583        self.active_host_effect = None;
584        self.active_host_effect_failures = 0;
585        self.next_after_host_effect()
586    }
587
588    pub(crate) fn resolve_page_out_archive(
589        &mut self,
590        archive_ref: Option<String>,
591        error: Option<String>,
592    ) -> LoopAction {
593        let pending = self
594            .active_host_effect
595            .as_ref()
596            .expect("page-out result requires an active host effect");
597        let PendingHostEffect::ArchivePageOut {
598            turn,
599            action,
600            summary,
601            archived,
602            tier,
603        } = pending
604        else {
605            panic!("page-out result does not match active spool effect");
606        };
607        if let Some(error) = error {
608            self.observations
609                .push(KernelObservation::PageOutArchiveFailed {
610                    turn: *turn,
611                    action: *action,
612                    tier: tier.clone(),
613                    message_count: archived.len() as u32,
614                    error,
615                });
616            self.active_host_effect_failures = self.active_host_effect_failures.saturating_add(1);
617            if self.active_host_effect_failures > self.host_effect_retry_attempt_limit {
618                self.active_host_effect = None;
619                self.pending_host_effects.clear();
620                self.deferred_action = None;
621                return self.terminate(TerminationReason::Error, None);
622            }
623            return pending.action();
624        }
625        self.observations.push(KernelObservation::PageOutArchived {
626            turn: *turn,
627            action: *action,
628            summary: summary.clone(),
629            tier: tier.clone(),
630            message_count: archived.len() as u32,
631            archive_ref,
632        });
633        self.active_host_effect = None;
634        self.active_host_effect_failures = 0;
635        self.next_after_host_effect()
636    }
637
638    /// O6: tune or disable the repeat fuse (see [`RepeatFuseConfig`]).
639    pub fn set_repeat_fuse(&mut self, config: RepeatFuseConfig) {
640        self.repeat_fuse = config;
641    }
642
643    /// Configure the opt-in entropy threshold watch (see [`EntropyWatchConfig`]).
644    /// The per-turn `EntropySample` observation is unconditional and unaffected.
645    pub fn set_entropy_watch(&mut self, config: EntropyWatchConfig) {
646        self.entropy_watch = config;
647    }
648
649    pub fn entropy_watch_config(&self) -> EntropyWatchConfig {
650        self.entropy_watch
651    }
652
653    /// O6: the active repeat-fuse config (for read-modify-write from the ABI event).
654    pub fn repeat_fuse_config(&self) -> RepeatFuseConfig {
655        self.repeat_fuse
656    }
657
658    /// The authoritative schedulability lifecycle of the loop (root task state). Replaces the
659    /// removed `LoopPhase::{Idle,Suspended,Blocked,Terminal}` reads.
660    pub fn lifecycle(&self) -> TaskLifecycle {
661        self.tasks
662            .get("root")
663            .map(|t| t.state)
664            .unwrap_or(TaskLifecycle::Ready)
665    }
666
667    /// The wait reason while suspended/blocked, if any.
668    pub fn wait_reason(&self) -> Option<WaitReason> {
669        self.tasks.get("root").and_then(|t| t.wait.clone())
670    }
671
672    /// Whether the loop has terminated.
673    pub fn is_terminal(&self) -> bool {
674        matches!(self.lifecycle(), TaskLifecycle::Done(_))
675    }
676
677    /// Whether the loop is suspended awaiting external resolution.
678    pub fn is_suspended(&self) -> bool {
679        matches!(self.lifecycle(), TaskLifecycle::Suspended)
680    }
681
682    /// Set the root task's lifecycle (and wait reason). Single mutation point for schedulability.
683    fn set_lifecycle(&mut self, state: TaskLifecycle, wait: Option<WaitReason>) {
684        if let Some(root) = self.tasks.get_mut("root") {
685            root.state = state;
686            root.wait = wait;
687        } else {
688            let mut root = Tcb::root("root", self.policy.clone());
689            root.state = state;
690            root.wait = wait;
691            self.tasks.insert(root);
692        }
693    }
694
695    /// Build a transient root [`Tcb`] mirroring the current scheduling facts (budget counters,
696    /// wall-clock anchors, lifecycle). M1b uses this to run the pure `schedule()` spine in
697    /// parallel with the legacy budget path; later milestones promote it to the live task row.
698    fn root_tcb(&self) -> Tcb {
699        let mut tcb = Tcb::root("root", self.policy.clone());
700        tcb.budget.turns = self.turn;
701        tcb.budget.total_tokens = self.total_tokens;
702        if let Some(tokens) = self.budget_grant.as_ref().and_then(|grant| grant.tokens) {
703            tcb.budget.limits.max_total_tokens = tcb.budget.limits.max_total_tokens.min(tokens);
704        }
705        tcb.budget.started_at_ms = self.started_at_ms;
706        tcb.state = self.lifecycle();
707        tcb
708    }
709
710    /// Adjust the wall-clock budget axis at runtime.
711    pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
712        self.policy.max_wall_ms = max_wall_ms;
713    }
714
715    /// Install a governance pipeline. Once set, all model-proposed tool calls
716    /// are evaluated before execution. Denied/rate-limited calls commit visible
717    /// error tool results; `AskUser` calls surface a `ToolGated` observation for
718    /// the SDK to enforce.
719    pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
720        self.governance = Some(pipeline);
721    }
722
723    /// Install resource quotas (M2). Once set, `Spawn` and `WriteMemory` syscalls are bounded by
724    /// the quota at the trap. Not setting it (the default) leaves them unconditionally allowed.
725    pub fn set_resource_quota(&mut self, quota: crate::governance::quota::ResourceQuota) {
726        self.resource_quota = Some(quota);
727    }
728
729    pub fn set_budget_grant(&mut self, grant: crate::runtime::kernel::BudgetGrant) {
730        self.budget_grant = Some(grant);
731    }
732
733    /// L1: this vehicle's cumulative sub-agent spawns this run — every child task ever registered in
734    /// the `TaskTable` (running + completed), distinct from the *instantaneous* running count. Used
735    /// for the cumulative spawn quota and read back by the SDK to charge the group ledger at run end.
736    pub fn local_subagents_spawned(&self) -> u32 {
737        self.tasks.all().iter().filter(|t| t.proc.is_some()).count() as u32
738    }
739
740    pub fn local_budget_usage(&self) -> (u64, u32, u32) {
741        (
742            self.total_tokens,
743            self.local_subagents_spawned(),
744            self.local_rounds_completed,
745        )
746    }
747
748    pub fn budget_grant(&self) -> Option<&crate::runtime::kernel::BudgetGrant> {
749        self.budget_grant.as_ref()
750    }
751
752    /// Install the long-term memory policy (`set_memory_policy`). Once set it gates `write_memory`
753    /// validation and bounds `query_memory` retrieval breadth. Not setting it (the default)
754    /// preserves pre-policy behavior.
755    pub fn set_memory_policy(&mut self, policy: crate::mm::memory::MemoryPolicy) {
756        self.memory_policy = Some(policy);
757    }
758
759    /// The installed memory policy, if any. `None` means default-rule validation + verbatim top_k.
760    pub fn memory_policy(&self) -> Option<&crate::mm::memory::MemoryPolicy> {
761        self.memory_policy.as_ref()
762    }
763
764    /// Feed the current wall-clock time (ms) to scheduler/governance budget axes.
765    pub fn set_observed_time(&mut self, now_ms: u64) {
766        if self.started_at_ms.is_none() {
767            self.started_at_ms = Some(now_ms);
768        }
769        self.last_now_ms = Some(now_ms);
770        if let Some(pipeline) = self.governance.as_mut() {
771            pipeline.set_time(now_ms);
772        }
773    }
774
775    /// Stash the in-flight response's provider `stop_reason` so `feed(LLMResponse)` can detect an
776    /// output-cap truncation. Set by the kernel ABI right before feeding the result; `None` clears it.
777    pub fn set_pending_stop_reason(&mut self, stop_reason: Option<String>) {
778        self.pending_stop_reason = stop_reason;
779    }
780
781    /// Pre-populate the history partition with messages from a prior session.
782    ///
783    /// Call **before** `start()` when resuming a conversation. Sets the baseline
784    /// so `drain_new_messages()` returns only the messages from the current run.
785    pub fn preload_history(&mut self, messages: Vec<Message>) {
786        for msg in messages {
787            let tokens = self.message_tokens(&msg);
788            self.ctx.push_history(msg, tokens);
789        }
790        self.session_history_baseline = self.ctx.partitions.history.messages.len();
791    }
792
793    /// Continue from preloaded history without appending a new user turn.
794    /// Use after `preload_history` when recovering a session that ended mid-run.
795    ///
796    /// If the last assistant turn has tool calls without matching tool results,
797    /// resumes with `ExecuteTools` instead of calling the LLM again.
798    pub fn resume_after_preload(&mut self) -> LoopAction {
799        self.observations.clear();
800        let calls = crate::runtime::repair::pending_tool_calls_from_messages(
801            &self.ctx.partitions.history.messages,
802        );
803        if !calls.is_empty() {
804            self.phase = LoopPhase::Act {
805                tool_calls: calls.clone(),
806            };
807            self.set_lifecycle(TaskLifecycle::Running, None);
808            return LoopAction::ExecuteTools { calls };
809        }
810        self.phase = LoopPhase::Reason;
811        self.emit_call_llm()
812    }
813
814    /// Return all messages added to history during the current run
815    /// (since the last `preload_history` call or since construction).
816    ///
817    /// Call after `LoopAction::Done` to get the complete turn transcript
818    /// for persistence to a SessionStore.
819    pub fn drain_new_messages(&self) -> Vec<Message> {
820        let history = &self.ctx.partitions.history.messages;
821        let start = self.session_history_baseline.min(history.len());
822        history[start..].to_vec()
823    }
824
825    pub fn start(&mut self, task: RuntimeTask) -> LoopAction {
826        self.observations.clear();
827        self.ctx.init_task(task.goal.clone(), task.criteria.clone());
828
829        // A loop vehicle with no admitted round capacity must not make even one provider call.
830        // The host may have raced another member between reading its durable loop log and reserve;
831        // the reservation is the authoritative admission decision.
832        if self
833            .run_spec
834            .as_ref()
835            .and_then(|spec| spec.loop_round.as_ref())
836            .is_some()
837            && self.budget_grant.as_ref().and_then(|grant| grant.rounds) == Some(0)
838        {
839            self.observations.push(KernelObservation::BudgetExceeded {
840                turn: self.turn,
841                budget: "rounds".into(),
842                operation_id: String::new(),
843                reservation_id: self
844                    .budget_grant
845                    .as_ref()
846                    .map(|grant| grant.reservation_id.clone()),
847            });
848            self.pending_pace = Some(crate::types::result::PaceDecision {
849                action: crate::types::result::PaceAction::Stop,
850                delay_ms: None,
851                reason: "round budget grant exhausted before start".into(),
852                coerced_from: None,
853            });
854            return self.terminate(TerminationReason::Completed, None);
855        }
856
857        let user_msg = "Proceed with the task described in [TASK STATE].".to_string();
858
859        // User message goes into history so it appears at the correct chronological
860        // position: [prior turns...] → [current user message] — LLM reads left-to-right
861        // and responds to the last message. working is reserved for runtime signals only.
862        // Estimate tokens (1 token ≈ 4 chars) with a minimum of 1 so the renderer
863        // does not skip this message (it skips zero-token entries).
864        let user_tokens = self.ctx.engine.count(&user_msg).max(1);
865        self.ctx.push_history(Message::user(user_msg), user_tokens);
866        self.phase = LoopPhase::Reason;
867        // Root task (seeded `Ready` in `new()`) becomes `Running`; `emit_call_llm` sets it.
868        self.emit_call_llm()
869    }
870
871    pub fn feed(&mut self, event: LoopEvent) -> LoopAction {
872        self.observations.clear();
873        self.sweep_expired_leases();
874        // K3: skill leases expire on the same head-of-event cadence as capability leases.
875        self.ctx.sweep_expired_skill_leases(self.turn);
876
877        match event {
878            LoopEvent::LLMResponse { message } => {
879                let delivered = self
880                    .delivered_signals_len
881                    .min(self.ctx.partitions.signals.len());
882                self.ctx.partitions.signals.drain(..delivered);
883                self.delivered_signals_len = 0;
884                // Signals admitted while the provider was in flight were not in the completed
885                // request. Promote queued items at this boundary and keep a no-tool response from
886                // terminating before the model receives them in a follow-up request.
887                self.drain_queued_signals();
888                let signals_waiting_for_followup = !self.ctx.partitions.signals.is_empty();
889                // A response arrived ⇒ the prompt fit ⇒ the overflow recovery ladder is reset.
890                self.recovery_attempts = 0;
891                let tokens = self.message_tokens(&message);
892                self.total_tokens += tokens as u64;
893
894                // Max-output-tokens recovery (mirrors query.ts): a response cut off at the output
895                // cap reports stop_reason = max_tokens (Anthropic) / length (OpenAI). A clean finish
896                // resets the ladder.
897                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.";
898                let truncated = matches!(
899                    self.pending_stop_reason.take().as_deref(),
900                    Some("max_tokens") | Some("length"),
901                );
902                if !truncated {
903                    self.output_recovery_attempts = 0;
904                }
905
906                if let Some(reason) = self.pending_termination.take() {
907                    return self.terminate(reason, Some(message));
908                }
909
910                if message.tool_calls.is_empty() {
911                    // The model was cut off at the output cap with no tool call. Keep the partial,
912                    // nudge it to resume mid-thought, and re-call — instead of mistaking the
913                    // truncation for a finished turn. Bounded by MAX_OUTPUT_RECOVERY; once exhausted
914                    // the partial stands and the turn terminates normally below. (A truncated
915                    // *tool-call* turn isn't handled here — it falls through to tool execution.)
916                    if truncated
917                        && self.output_recovery_attempts < self.output_recovery_attempt_limit
918                    {
919                        self.output_recovery_attempts += 1;
920                        self.ctx.push_history(message, tokens);
921                        self.ctx.push_signal(OUTPUT_TRUNCATION_NUDGE.to_string());
922                        self.phase = LoopPhase::Reason;
923                        return self.emit_call_llm();
924                    }
925                    // When a milestone contract is active and not yet complete,
926                    // request evaluation instead of terminating.
927                    if !self.milestone.is_complete() {
928                        let phase_id = self.milestone.current_phase_id().unwrap_or("").to_string();
929                        let criteria = self.milestone.current_criteria().to_vec();
930                        let (verifier, required_evidence) = self
931                            .milestone
932                            .current_phase()
933                            .map(|p| (p.verifier.clone(), p.required_evidence.clone()))
934                            .unwrap_or_default();
935                        // `tokens` was already computed for this message above.
936                        self.ctx.push_history(message, tokens);
937                        return LoopAction::EvaluateMilestone {
938                            phase_id,
939                            criteria,
940                            verifier,
941                            required_evidence,
942                        };
943                    }
944                    // O4 criteria gate (the Stop-hook analog): the model is finishing while explicit
945                    // acceptance criteria stand. Before accepting `Completed`, inject ONE bounded
946                    // self-check at the peak-attention slot — verify each criterion, continue if any
947                    // is unmet, else confirm. Fires at most once per run (no nag loop); runs with no
948                    // criteria are untouched. 2c guards "won't stop"; this guards "stops too early".
949                    if self.criteria_gate_enabled
950                        && !self.criteria_gate_fired
951                        && !self.ctx.partitions.task_state.criteria.is_empty()
952                    {
953                        self.criteria_gate_fired = true;
954                        let criteria = self.ctx.partitions.task_state.criteria.clone();
955                        self.ctx.push_history(message, tokens);
956                        self.ctx.push_signal(format!(
957                            "[CRITERIA CHECK] You are about to finish. Verify each acceptance \
958                             criterion first: {}. If any is NOT met, continue working on it now. \
959                             If all are met, give the final answer.",
960                            criteria.join(" | ")
961                        ));
962                        self.observations
963                            .push(KernelObservation::CriteriaGateFired {
964                                turn: self.turn,
965                                criteria,
966                            });
967                        self.phase = LoopPhase::Reason;
968                        return self.emit_call_llm();
969                    }
970                    if signals_waiting_for_followup {
971                        self.ctx.push_history(message, tokens);
972                        self.phase = LoopPhase::Reason;
973                        return self.emit_call_llm();
974                    }
975                    return self.terminate(TerminationReason::Completed, Some(message));
976                }
977
978                let calls = message.tool_calls.clone();
979                self.ctx.push_history(message, tokens);
980
981                // ━━ 记录活动时间(Layer 3时间衰减使用)
982                if let Some(now_ms) = self.last_now_ms {
983                    self.ctx.record_activity(now_ms);
984                }
985
986                // ③ pacing trap: a `pace` call is a kernel-adjudicated round-end proposal,
987                // never an SDK tool. Handled before the fuse/gate — it is a control verb,
988                // not task work.
989                if self
990                    .run_spec
991                    .as_ref()
992                    .and_then(|r| r.loop_round.as_ref())
993                    .is_some()
994                {
995                    if let Some(pace_call) = calls.iter().find(|c| c.name.as_str() == "pace") {
996                        let call = pace_call.clone();
997                        // The assistant message carrying `pace` is already committed to history
998                        // and the kernel adjudicates pace itself — sibling calls batched with it
999                        // are never executed, so close their transcript pairs here or they remain
1000                        // orphaned tool_use blocks (wire-invalid on several vendors).
1001                        for sibling in calls.iter().filter(|c| c.id != call.id) {
1002                            self.push_synthetic_tool_result(
1003                                &sibling.id,
1004                                "not executed: superseded by pace — the round is ending",
1005                            );
1006                        }
1007                        return self.handle_pace_call(call);
1008                    }
1009                }
1010
1011                // 2b: record this turn's tool activity into the task-state recency log (meta-tools
1012                // filtered inside). The State-turn footer renders it as "just did: …" + a forward
1013                // nudge / STOP, so progress is kernel-derived and never depends on the model
1014                // remembering to call `update_plan`. Tool *names* live only on the request (results
1015                // carry call_id only), so this is the turn to capture them.
1016                //
1017                // Capture name AND a compact arg digest: the no-progress STOP keys on whether the
1018                // SAME call repeats, and a legit loop (same tool, DIFFERENT args — e.g. processing 20
1019                // items) is real progress, not a stall. Keying on the name alone false-positives those
1020                // loops; including args distinguishes "step(n=1), step(n=2)…" from a true repeat.
1021                let action_sigs: Vec<(String, String)> = calls
1022                    .iter()
1023                    .map(|c| (c.name.to_string(), compact_tool_args(&c.arguments)))
1024                    .collect();
1025                self.ctx.note_tool_actions(&action_sigs);
1026
1027                // O6 RepeatFuse: the hard rungs above the 2c soft STOP. Runs BEFORE the governance
1028                // gate and independent of whether a policy is loaded — a batteries-included kernel
1029                // protection, not a policy feature. Deny commits a visible synthetic error result;
1030                // the terminate rung ends the run `NoProgress` after one final no-tools report turn.
1031                if let Some(action) = self.check_repeat_fuse(&calls) {
1032                    return action;
1033                }
1034
1035                match self.gate_tool_calls(&calls) {
1036                    GateToolOutcome::Blocked(action) => return action,
1037                    GateToolOutcome::ApprovalRequired(requests) => {
1038                        return LoopAction::RequestApproval { requests };
1039                    }
1040                    GateToolOutcome::Proceed => {}
1041                }
1042                self.phase = LoopPhase::Act {
1043                    tool_calls: calls.clone(),
1044                };
1045                self.set_lifecycle(TaskLifecycle::Running, None);
1046                LoopAction::ExecuteTools { calls }
1047            }
1048
1049            LoopEvent::ToolResults { mut results } => {
1050                if !self.pending_denied_results.is_empty() {
1051                    results.append(&mut self.pending_denied_results);
1052                }
1053                if let Some(reason) = results
1054                    .iter()
1055                    .find_map(|result| self.rollback_reason_for_tool_result(result))
1056                {
1057                    let note = Message::user(super::rollback::build_rollback_note(
1058                        &reason,
1059                        self.ctx.config.verbose_control_notes,
1060                    ));
1061                    self.rollback(reason);
1062                    self.ctx
1063                        .push_signal(note.content.as_text().unwrap_or_default().to_string());
1064                    self.phase = LoopPhase::Reason;
1065                    return self.emit_call_llm();
1066                }
1067                // Tool errors are committed to history so the LLM can see them and self-correct
1068                // without losing turn state. UserInterrupt was handled by the rollback arm above.
1069
1070                // Entropy: this completed turn's failure tally. All model-visible tool failures,
1071                // including fatal and timeout results, accrue at this committed boundary.
1072                let errored_results = results.iter().filter(|r| r.is_error).count() as u32;
1073                let total_results = results.len() as u32;
1074                let tool_by_call_id: HashMap<String, String> = match &self.phase {
1075                    LoopPhase::Act { tool_calls } => tool_calls
1076                        .iter()
1077                        .map(|call| (call.id.to_string(), call.name.to_string()))
1078                        .collect(),
1079                    LoopPhase::Reason => HashMap::new(),
1080                };
1081
1082                for r in &results {
1083                    self.total_tokens += r.token_count.unwrap_or(0) as u64;
1084                    // Preserve Content::Parts (structured / multimodal tool output).
1085                    // Parts are serialised to JSON so the text can be restored faithfully.
1086                    let raw_output = match &r.output {
1087                        Content::Text(s) => s.clone(),
1088                        Content::Parts(parts) => serde_json::to_string(parts).unwrap_or_default(),
1089                    };
1090                    // Layer 1 spool: oversized results keep only a preview in context. The full
1091                    // output becomes a host effect and no success fact is recorded until its
1092                    // correlated result commits.
1093                    let (output, spooled) = match crate::mm::plan_spool(
1094                        &raw_output,
1095                        r.call_id.as_str(),
1096                        self.ctx.config.spool_threshold_bytes,
1097                        self.ctx.config.spool_preview_bytes,
1098                    ) {
1099                        Some(decision) => {
1100                            self.pending_host_effects.push_back(
1101                                PendingHostEffect::SpoolLargeResult {
1102                                    call_id: r.call_id.to_string(),
1103                                    tool: tool_by_call_id
1104                                        .get(r.call_id.as_str())
1105                                        .cloned()
1106                                        .unwrap_or_default(),
1107                                    output: raw_output.clone(),
1108                                    original_size: decision.original_size,
1109                                    preview_size: decision.preview.len() as u32,
1110                                },
1111                            );
1112                            (decision.preview, true)
1113                        }
1114                        None => (raw_output, false),
1115                    };
1116                    let parts = vec![ContentPart::ToolResult {
1117                        call_id: r.call_id.clone(),
1118                        output,
1119                        is_error: r.is_error,
1120                    }];
1121                    let tool_msg = Message::tool(parts);
1122                    // When spooled, `r.token_count` reflects the full output — recount the preview.
1123                    let tokens = if spooled {
1124                        self.ctx.engine.count_message(&tool_msg)
1125                    } else {
1126                        r.token_count
1127                            .unwrap_or_else(|| self.ctx.engine.count_message(&tool_msg))
1128                    };
1129                    self.ctx.push_history(tool_msg, tokens);
1130                }
1131                self.turn += 1;
1132                // The budget verdict (turn/token/wall) fires inside `emit_call_llm` at the end of
1133                // this arm — the single provider-call funnel — so the eviction checkpoint and
1134                // entropy sample below still run on the exhaustion turn before the final report.
1135
1136                // ━━ Eviction checkpoint (M3): one decision model (`plan_eviction`), one
1137                // execution funnel (`execute_eviction_op`). Layer 3 (idle/time-decay) must run
1138                // before the rho recommendation is read, since it mutates token usage — so the
1139                // plan is built in that interleaved order and the ops are executed in plan order.
1140                let idle_decay = self
1141                    .last_now_ms
1142                    .is_some_and(|now_ms| self.ctx.should_time_decay_compact(now_ms));
1143                if idle_decay {
1144                    self.execute_eviction_op(&crate::mm::EvictionOp::TimeDecayMicro);
1145                }
1146
1147                // Layer 4 read-time projection: recompute handle residency on the post-time-decay rho.
1148                self.ctx.recompute_handle_residency();
1149                // K2: knowledge budget check — marks over-budget unpinned entries for the next
1150                // boundary sweep (marks are idempotent; drops only apply there) and stashes a
1151                // warn-once-per-generation notice, drained into an observation here.
1152                if let Some((used, budget)) = self.ctx.enforce_knowledge_budget() {
1153                    self.observations
1154                        .push(KernelObservation::KnowledgeBudgetExceeded {
1155                            turn: self.turn,
1156                            used,
1157                            budget,
1158                        });
1159                }
1160                // Layers 2/4/5: execute the pressure-driven ops from the plan (skip TimeDecayMicro
1161                // if already executed). The plan carries specific ops stamped with real config-derived
1162                // params (W1-1 収口 — no magic-number placeholders), not the umbrella `Pressure` wrapper.
1163                let (target_tokens, preserve_turns) = self.ctx.plan_compaction_params();
1164                let plan = crate::mm::plan_eviction(
1165                    self.ctx.should_compress(),
1166                    idle_decay,
1167                    target_tokens,
1168                    preserve_turns,
1169                );
1170                // `idle_decay` ⇒ the plan carries a `TimeDecayMicro` (so the skip-on-already-executed
1171                // below is meaningful). The converse does NOT hold: a pressure-driven `MicroCompact`
1172                // also emits `TimeDecayMicro` independent of `idle_decay` (W1 unified planner), so we
1173                // assert the implication, not equality.
1174                debug_assert!(!idle_decay || plan.has_time_decay());
1175                for op in &plan.ops {
1176                    // Skip TimeDecayMicro if we already executed it (prevents double-execution).
1177                    if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
1178                        continue;
1179                    }
1180                    self.execute_eviction_op(op);
1181                }
1182
1183                // Renewal: when compression alone cannot recover enough headroom,
1184                // start a new sprint — carry forward system + memory + last N history turns.
1185                if self.ctx.should_renew() {
1186                    self.ctx.renew();
1187                    // A new sprint is a session boundary for signal identity: clear the dedup set so
1188                    // it cannot grow unbounded across a long run, and so a signal seen in a prior
1189                    // sprint may legitimately re-fire in the new one.
1190                    self.signal_router.clear_dedup();
1191                    self.observations.push(KernelObservation::Renewed {
1192                        sprint: self.ctx.sprint,
1193                    });
1194                    // K1: renewal is a boundary — surface the knowledge sweep it just ran.
1195                    self.emit_knowledge_sweep_observations();
1196                }
1197
1198                // Session-entropy sample (the heartbeat watch source): fold this completed
1199                // turn's outcomes into the sliding window and surface the measurement.
1200                // Unconditional, like `CheckpointTaken`; only the watch alert below is opt-in.
1201                let repeat_streak = if self.repeat_fuse.enabled {
1202                    self.repeat_count
1203                } else {
1204                    0
1205                };
1206                let sample = self.entropy.sample(
1207                    self.turn,
1208                    self.ctx.rho(),
1209                    repeat_streak,
1210                    self.repeat_fuse.deny_after,
1211                    errored_results,
1212                    total_results,
1213                );
1214                self.observations.push(KernelObservation::EntropySample {
1215                    turn: sample.turn,
1216                    score: sample.score,
1217                    score_version: super::entropy::ENTROPY_SCORE_VERSION,
1218                    rho: sample.rho,
1219                    repeat_pressure: sample.repeat_pressure,
1220                    failure_rate: sample.failure_rate,
1221                    rollbacks_in_window: sample.rollbacks_in_window,
1222                    window_turns: sample.window_turns,
1223                });
1224                // Opt-in entropy watch: threshold + hysteresis + cooldown. The alert is an
1225                // observation (host-facing); with `notify_model` it is ALSO routed through
1226                // the kernel's own signal dispatch as a Heartbeat/Alert directive — High
1227                // urgency while running ⇒ a durable [SIGNAL] note on the turn we are about
1228                // to emit anyway, never an extra provider call.
1229                if self.entropy.should_alert(&self.entropy_watch, &sample) {
1230                    self.observations.push(KernelObservation::EntropyAlert {
1231                        turn: sample.turn,
1232                        score: sample.score,
1233                        threshold: self.entropy_watch.threshold,
1234                    });
1235                    if self.entropy_watch.notify_model {
1236                        use crate::types::signal::{
1237                            RuntimeSignal, SignalSource, SignalType, Urgency,
1238                        };
1239                        let signal = RuntimeSignal::new(
1240                            SignalSource::Heartbeat,
1241                            SignalType::Alert,
1242                            Urgency::High,
1243                            format!(
1244                                "[entropy] session disorder {:.2} ≥ {:.2} (repeat {:.2} / failures {:.2} / pressure {:.2}). \
1245                                 Stop and reassess: state what is not working and try a different approach.",
1246                                sample.score,
1247                                self.entropy_watch.threshold,
1248                                sample.repeat_pressure,
1249                                sample.failure_rate,
1250                                sample.rho,
1251                            ),
1252                        )
1253                        .with_dedupe(format!("entropy_alert:{}", sample.turn));
1254                        let _ = self.dispatch_signal(signal);
1255                    }
1256                }
1257
1258                // Turn boundary: drain any kernel-queued signals into context so they
1259                // are seen on the next reasoning turn (ready queue → running).
1260                self.drain_queued_signals();
1261
1262                self.phase = LoopPhase::Reason;
1263                self.emit_call_llm()
1264            }
1265
1266            LoopEvent::MilestoneResult { result } => self.handle_milestone_result(result),
1267
1268            LoopEvent::SubAgentCompleted { result } => self.handle_sub_agent_completed(result),
1269
1270            LoopEvent::Complete => self.terminate(TerminationReason::Completed, None),
1271
1272            LoopEvent::Timeout => {
1273                // A timed-out tool batch commits per-call timeout error results — the trained
1274                // convention ("command timed out" as a visible error) — so the model sees which
1275                // call stalled and can verify or change approach. Only a Reason-phase timeout
1276                // (nothing model-visible pending) keeps the rollback + note path.
1277                if let LoopPhase::Act { tool_calls } = &self.phase {
1278                    if !tool_calls.is_empty() {
1279                        let results: Vec<ToolResult> = tool_calls
1280                            .iter()
1281                            .map(|call| ToolResult {
1282                                call_id: call.id.clone(),
1283                                output: Content::Text(format!(
1284                                    "Tool call `{}` timed out before completing. The operation \
1285                                     may or may not have taken effect — verify before assuming, \
1286                                     then retry with a smaller step or a faster approach.",
1287                                    call.name
1288                                )),
1289                                is_error: true,
1290                                is_fatal: false,
1291                                error_kind: Some(ToolErrorKind::Timeout),
1292                                token_count: None,
1293                            })
1294                            .collect();
1295                        return self.feed(LoopEvent::ToolResults { results });
1296                    }
1297                }
1298                let reason = RollbackReason::Timeout;
1299                let note = Message::user(super::rollback::build_rollback_note(
1300                    &reason,
1301                    self.ctx.config.verbose_control_notes,
1302                ));
1303                self.rollback(reason);
1304                self.ctx
1305                    .push_signal(note.content.as_text().unwrap_or_default().to_string());
1306                self.phase = LoopPhase::Reason;
1307                self.emit_call_llm()
1308            }
1309        }
1310    }
1311
1312    /// Drain observations emitted during the last `start`/`feed` call.
1313    pub fn take_observations(&mut self) -> Vec<KernelObservation> {
1314        std::mem::take(&mut self.observations)
1315    }
1316
1317    /// ③ the pacing trap. The model PROPOSES `pace(next, delay_ms?, reason)`; the kernel
1318    /// ADJUDICATES: malformed → governance-style rollback note; sleep delay clamped into
1319    /// the spec's [min,max]; continue/sleep at the round cap coerced to stop("max_rounds");
1320    /// stop with standing acceptance criteria routes through the O4 criteria gate ONCE
1321    /// (one bounded self-check turn) before being honored. An allowed pace ends the round:
1322    /// the decision is stashed for LoopResult, a synthetic tool result closes the
1323    /// transcript pair, and the strip-tools final-report turn finishes the round.
1324    fn handle_pace_call(&mut self, call: ToolCall) -> LoopAction {
1325        use crate::types::result::{PaceAction, PaceDecision};
1326
1327        let spec = self
1328            .run_spec
1329            .as_ref()
1330            .and_then(|r| r.loop_round.as_ref())
1331            .cloned()
1332            .unwrap_or_default();
1333
1334        let next = call
1335            .arguments
1336            .get("next")
1337            .and_then(|v| v.as_str())
1338            .unwrap_or("");
1339        let reason = call
1340            .arguments
1341            .get("reason")
1342            .and_then(|v| v.as_str())
1343            .unwrap_or("")
1344            .to_string();
1345        let proposed_delay = call.arguments.get("delay_ms").and_then(|v| v.as_u64());
1346
1347        let mut action = match next {
1348            "continue" => PaceAction::Continue,
1349            "sleep" => PaceAction::Sleep,
1350            "stop" => PaceAction::Stop,
1351            other => {
1352                // Malformed proposal: governance-style directive note + fresh reason turn.
1353                let rejection_reason =
1354                    format!("invalid pace next={other:?} (expected continue|sleep|stop)");
1355                let note = super::rollback::build_control_rejection_note(
1356                    "pace",
1357                    &rejection_reason,
1358                    self.ctx.config.verbose_control_notes,
1359                );
1360                self.push_synthetic_tool_result(
1361                    &call.id,
1362                    "pace rejected: next must be continue|sleep|stop",
1363                );
1364                self.ctx.push_signal(note);
1365                self.phase = LoopPhase::Reason;
1366                return self.emit_call_llm();
1367            }
1368        };
1369        let mut coerced_from: Option<String> = None;
1370
1371        // Round-cap coercion: both the run spec and reservation grant bound local rounds.
1372        if action != PaceAction::Stop {
1373            let granted_rounds = self.budget_grant.as_ref().and_then(|grant| grant.rounds);
1374            let max_rounds = if granted_rounds == Some(0) {
1375                Some(0)
1376            } else {
1377                spec.max_rounds
1378            };
1379            if let Some(max) = max_rounds {
1380                if self.local_rounds_completed.saturating_add(1) >= max {
1381                    coerced_from = Some(format!("{} (max_rounds={max})", action.label()));
1382                    action = PaceAction::Stop;
1383                }
1384            }
1385        }
1386
1387        // O4 routing: a stop with standing criteria takes the existing criteria-gate
1388        // self-check turn first; the model re-decides with the checklist in view.
1389        if action == PaceAction::Stop
1390            && self.criteria_gate_enabled
1391            && !self.criteria_gate_fired
1392            && !self.ctx.partitions.task_state.criteria.is_empty()
1393        {
1394            self.criteria_gate_fired = true;
1395            let criteria = self.ctx.partitions.task_state.criteria.clone();
1396            self.push_synthetic_tool_result(
1397                &call.id,
1398                "pace(stop) noted — verify the acceptance criteria first, then pace again.",
1399            );
1400            self.ctx.push_signal(format!(
1401                "[CRITERIA CHECK] You proposed stopping the loop. Verify each acceptance \
1402                 criterion first: {}. If any is NOT met, continue working (or pace(continue)). \
1403                 If all are met, call pace(stop) again.",
1404                criteria.join(" | ")
1405            ));
1406            self.observations
1407                .push(KernelObservation::CriteriaGateFired {
1408                    turn: self.turn,
1409                    criteria,
1410                });
1411            self.phase = LoopPhase::Reason;
1412            return self.emit_call_llm();
1413        }
1414
1415        // Sleep clamp into [min, max].
1416        let delay_ms = if action == PaceAction::Sleep {
1417            let raw = proposed_delay.unwrap_or(spec.min_sleep_ms.unwrap_or(60_000));
1418            let mut clamped = raw;
1419            if let Some(min) = spec.min_sleep_ms {
1420                clamped = clamped.max(min);
1421            }
1422            if let Some(max) = spec.max_sleep_ms {
1423                clamped = clamped.min(max);
1424            }
1425            if clamped != raw && coerced_from.is_none() {
1426                coerced_from = Some(format!("sleep {raw}ms (clamped)"));
1427            }
1428            Some(clamped)
1429        } else {
1430            None
1431        };
1432
1433        self.local_rounds_completed = self.local_rounds_completed.saturating_add(1);
1434        let decision = PaceDecision {
1435            action,
1436            delay_ms,
1437            reason,
1438            coerced_from,
1439        };
1440        self.observations.push(KernelObservation::RoundPaced {
1441            turn: self.turn,
1442            round: self.local_rounds_completed,
1443            decision: decision.clone(),
1444        });
1445        self.push_synthetic_tool_result(
1446            &call.id,
1447            &format!(
1448                "pace acknowledged: {}{} — wrap up with a brief round report.",
1449                decision.action.label(),
1450                decision
1451                    .delay_ms
1452                    .map(|d| format!(" {d}ms"))
1453                    .unwrap_or_default()
1454            ),
1455        );
1456        self.pending_pace = Some(decision);
1457        self.pending_termination = Some(TerminationReason::Completed);
1458        self.phase = LoopPhase::Reason;
1459        self.emit_call_llm()
1460    }
1461
1462    /// Close a kernel-handled tool call's transcript pair with a synthetic result so
1463    /// providers always see call → result.
1464    fn push_synthetic_tool_result(&mut self, call_id: &str, output: &str) {
1465        let msg = Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1466            call_id: call_id.into(),
1467            output: output.to_string(),
1468            is_error: false,
1469        }]);
1470        let tokens = self.message_tokens(&msg);
1471        self.ctx.push_history(msg, tokens);
1472    }
1473
1474    fn terminate(
1475        &mut self,
1476        termination: TerminationReason,
1477        final_message: Option<Message>,
1478    ) -> LoopAction {
1479        // Commit the final response into history so subsequent session restores
1480        // include the complete transcript: user → [tool turns] → final assistant.
1481        if let Some(ref msg) = final_message {
1482            let tokens = self.message_tokens(msg);
1483            self.ctx.push_history(msg.clone(), tokens);
1484        }
1485        // ③ attach the round's pacing decision. Stashed by the trap when the model
1486        // called `pace`; otherwise the spec's default_action ("stop" for goal loops,
1487        // "sleep" for cron loops) — but ONLY on a clean Completed. NoProgress /
1488        // ContextOverflow / Error rounds stop and surface (nothing nags the model).
1489        let pace_decision = self.pending_pace.take().or_else(|| {
1490            let spec = self.run_spec.as_ref()?.loop_round.as_ref()?;
1491            if termination != TerminationReason::Completed {
1492                return Some(crate::types::result::PaceDecision {
1493                    action: crate::types::result::PaceAction::Stop,
1494                    delay_ms: None,
1495                    reason: format!("round terminated: {}", termination.label()),
1496                    coerced_from: None,
1497                });
1498            }
1499            match spec.default_action.as_deref() {
1500                Some("sleep") => Some(crate::types::result::PaceDecision {
1501                    action: crate::types::result::PaceAction::Sleep,
1502                    delay_ms: spec.min_sleep_ms.or(Some(60_000)),
1503                    reason: "default_action: sleep (cron loop)".to_string(),
1504                    coerced_from: None,
1505                }),
1506                _ => Some(crate::types::result::PaceDecision {
1507                    action: crate::types::result::PaceAction::Stop,
1508                    delay_ms: None,
1509                    reason: "default_action: stop (no pace call this round)".to_string(),
1510                    coerced_from: None,
1511                }),
1512            }
1513        });
1514        let result = LoopResult {
1515            termination,
1516            final_message,
1517            turns_used: self.turn,
1518            total_tokens_used: self.total_tokens,
1519            loop_continue: None,
1520            classify_branch: None,
1521            tournament_winner: None,
1522            pace_decision,
1523        };
1524        self.set_lifecycle(TaskLifecycle::Done(termination), None);
1525        LoopAction::Done { result }
1526    }
1527
1528    /// Build the `CallLLM` action with a structured `RenderedContext`.
1529    /// Meta-tools (skill / memory / knowledge) are appended to the tool list
1530    /// when configured. When `pending_termination` is set, tools are stripped
1531    /// to force a plain-text response before the loop terminates.
1532    fn emit_call_llm(&mut self) -> LoopAction {
1533        // Calling the provider is definitionally "running" — the single funnel for entering the
1534        // Running lifecycle (covers start, resume, signal-driven turns, budget final-call).
1535        self.set_lifecycle(TaskLifecycle::Running, None);
1536
1537        // M1 収口 (completed): the budget verdict lives at the same single funnel. Every edge that
1538        // requests a provider call — tool-turn completion, milestone retry, signal-forced turns,
1539        // criteria gate, recovery ladders — passes the three axes here, so a loop that completes
1540        // no tool turns (and therefore never increments `turn`) is still bounded by the token and
1541        // wall axes. The final-report turn itself (`pending_termination` set) is exempt: it is the
1542        // one bounded call the verdict buys, so the check fires exactly once per exhaustion.
1543        if self.pending_termination.is_none() {
1544            if let Some(term) = super::tcb::budget_verdict(&self.root_tcb(), self.last_now_ms) {
1545                let budget = match term {
1546                    TerminationReason::MaxTurns => "max_turns",
1547                    TerminationReason::Timeout => "wall_time",
1548                    _ => "token_budget",
1549                };
1550                self.observations.push(KernelObservation::BudgetExceeded {
1551                    turn: self.turn,
1552                    budget: budget.to_string(),
1553                    operation_id: String::new(),
1554                    reservation_id: self
1555                        .budget_grant
1556                        .as_ref()
1557                        .map(|grant| grant.reservation_id.clone()),
1558                });
1559                self.pending_termination = Some(term);
1560            }
1561        }
1562        self.checkpoint.history_len = self.ctx.partitions.history.messages.len();
1563        self.checkpoint.signals_len = self.ctx.partitions.signals.len();
1564        self.checkpoint.task_state = Some(self.ctx.partitions.task_state.clone());
1565        self.delivered_signals_len = self.ctx.partitions.signals.len();
1566        self.observations.push(KernelObservation::CheckpointTaken {
1567            turn: self.turn,
1568            history_len: self.checkpoint.history_len as u32,
1569        });
1570
1571        let context = self.ctx.render();
1572        if let Some(overflow) = context.budget_overflow.clone() {
1573            self.observations
1574                .push(KernelObservation::ContextBudgetExceeded {
1575                    turn: self.turn,
1576                    overflow_kind: overflow.kind,
1577                    required_tokens: overflow.required_tokens,
1578                    max_tokens: overflow.max_tokens,
1579                });
1580            // P0-2 §C: only a `FixedContext` overflow (system + state_turn alone exceed the hard
1581            // window) is unrecoverable — compaction cannot touch that region, so terminate honestly.
1582            // A `ProtectedTail` overflow (a protected recent unit tips the budget after compaction
1583            // already ran) is NOT terminal: the observation records the over-budget tail, and the
1584            // context is still submitted. The provider decides; if it rejects with a 413, the
1585            // reactive recovery ladder (`recover_from_provider_error`) is the real backstop. Silently
1586            // terminating here would kill runs the provider could have accepted or recovered from.
1587            if matches!(
1588                overflow.kind,
1589                crate::context::renderer::ContextBudgetOverflowKind::FixedContext
1590            ) {
1591                self.delivered_signals_len = 0;
1592                return self.terminate(TerminationReason::ContextOverflow, None);
1593            }
1594        }
1595        if self.pending_termination.is_some() {
1596            return LoopAction::CallLLM {
1597                context,
1598                tools: Vec::new(),
1599            };
1600        }
1601        let mut tools = self.tools.clone();
1602        tools.extend(self.ctx.meta_tool_schemas());
1603
1604        if let Some(ref spec) = self.run_spec {
1605            use crate::types::capability::CapabilityKind;
1606            tools.retain(|tool| {
1607                let kind = match tool.name.as_str() {
1608                    "skill" => CapabilityKind::Skill,
1609                    "memory" => CapabilityKind::Memory,
1610                    "knowledge" => CapabilityKind::Knowledge,
1611                    _ => CapabilityKind::Tool,
1612                };
1613                let desc = crate::types::capability::CapabilityDescriptor::marker(
1614                    kind,
1615                    tool.name.clone(),
1616                    &tool.description,
1617                );
1618                spec.capability_filter.allows(&desc)
1619            });
1620        }
1621
1622        // P1-B epoch skill gating (applied *after* the run-level filter ③, so A is the outer bound
1623        // and B narrows within it — D6). When skills are active and declare tools, expose only
1624        // `meta-tools ∪ stable-core ∪ ⋃(active skills' allowed_tools)`. `None` ⇒ no active/declared
1625        // skill ⇒ no narrowing (D3, errs-open). Meta-tools are always exempt (D5) so the model can
1626        // still load more skills. Byte-stable within an epoch: the set only changes on activation.
1627        if let Some(allowed) = self.ctx.active_skill_tool_filter() {
1628            let stable = &self.ctx.stable_core_tools;
1629            tools.retain(|tool| {
1630                matches!(
1631                    tool.name.as_str(),
1632                    "skill" | "memory" | "knowledge" | "update_plan"
1633                ) || stable.contains(&tool.name)
1634                    || allowed.contains(&tool.name)
1635            });
1636        }
1637
1638        // ③ pace meta-tool: exposed ONLY when this run is a round of a paced loop
1639        // (run_spec.loop_round present) — the same conditional-exposure pattern as
1640        // skill/memory/read_result. Pushed after every filter: pacing is kernel-owned
1641        // and must never be narrowed away by skills or capability filters.
1642        if self
1643            .run_spec
1644            .as_ref()
1645            .and_then(|r| r.loop_round.as_ref())
1646            .is_some()
1647        {
1648            tools.push(pace_tool_schema());
1649        }
1650
1651        LoopAction::CallLLM { context, tools }
1652    }
1653
1654    pub fn rollback(&mut self, reason: RollbackReason) {
1655        self.ctx
1656            .partitions
1657            .history
1658            .messages
1659            .truncate(self.checkpoint.history_len);
1660        self.ctx
1661            .partitions
1662            .signals
1663            .truncate(self.checkpoint.signals_len);
1664        if let Some(ref state) = self.checkpoint.task_state {
1665            self.ctx.partitions.task_state = state.clone();
1666        }
1667        // Rolled-back turns never reach the boundary sample point; accrue here so the
1668        // disorder they evidence lands in the next completed turn's entropy window.
1669        self.entropy.note_rollback();
1670        self.observations.push(KernelObservation::Rollbacked {
1671            turn: self.turn,
1672            checkpoint_history_len: self.checkpoint.history_len as u32,
1673            reason: Some(reason),
1674        });
1675    }
1676
1677    /// Which tool results still roll the turn back. Models are trained on "tool failed → an
1678    /// error tool result stays in history and the model adapts" — the convention every major
1679    /// harness produces — so fatal / timeout / provider-failure / denied results all COMMIT as
1680    /// visible errors (same evidence class as the governance-denial A/B: erasing the attempt
1681    /// makes the model re-try what it cannot see). The one survivor is `UserInterrupt`: the
1682    /// user's escape is a host-owned control event, not model feedback.
1683    fn rollback_reason_for_tool_result(&self, result: &ToolResult) -> Option<RollbackReason> {
1684        match result.error_kind {
1685            Some(ToolErrorKind::UserInterrupt) => Some(RollbackReason::UserInterrupt),
1686            _ => None,
1687        }
1688    }
1689
1690}
1691
1692#[cfg(test)]
1693#[path = "tests.rs"]
1694mod tests;
1695
1696/// ③ the `pace` meta-tool schema — exposed only on loop-round runs.
1697fn pace_tool_schema() -> crate::types::message::ToolSchema {
1698    crate::types::message::ToolSchema {
1699        name: compact_str::CompactString::new("pace"),
1700        description: "End this round and decide what happens next: continue immediately, \
1701sleep then run another round, or stop the loop. Call this when the round's work is done."
1702            .to_string(),
1703        parameters: serde_json::json!({
1704            "type": "object",
1705            "properties": {
1706                "next": { "type": "string", "enum": ["continue", "sleep", "stop"] },
1707                "delay_ms": { "type": "integer", "minimum": 0 },
1708                "reason": { "type": "string" }
1709            },
1710            "required": ["next", "reason"]
1711        }),
1712    }
1713}