Skip to main content

deepstrike_core/scheduler/state_machine/
mod.rs

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