Skip to main content

deepstrike_core/scheduler/state_machine/
mod.rs

1use std::collections::HashMap;
2
3use super::entropy::{EntropyTracker, EntropyWatchConfig};
4use super::milestone::MilestoneTracker;
5use super::policy::SchedulerBudget;
6use super::tcb::{TaskLifecycle, TaskTable, Tcb, WaitReason};
7use crate::AgentRunSpec;
8use crate::context::manager::ContextManager;
9use crate::governance::pipeline::GovernancePipeline;
10use crate::governance::repeat_fuse::RepeatFuseConfig;
11use crate::signals::router::SignalRouter;
12use crate::types::result::SubAgentResult;
13use crate::context::renderer::RenderedContext;
14// `pub use` so external integration tests that glob `state_machine::*` resolve the observation
15// type here — exactly as they did for the former `pub enum LoopObservation` this replaced.
16pub use crate::runtime::kernel::KernelObservation;
17use crate::runtime::session::RollbackReason;
18use crate::types::message::{
19    Content, ContentPart, Message, ToolCall, ToolErrorKind, ToolResult, ToolSchema,
20};
21use crate::types::milestone::MilestoneCheckResult;
22use crate::types::result::{LoopResult, TerminationReason};
23use crate::types::task::RuntimeTask;
24
25/// Compact digest of a tool call's arguments for the recency log (2b). Kept short and CJK-safe — it
26/// only needs to make `same-tool / different-args` calls distinguishable (so a legit loop isn't
27/// flagged as a no-progress repeat) and to read sensibly in the "just did: …" footer. Empty for
28/// no-arg / `{}` calls. Lives in the volatile State turn, so length here never churns the cache.
29fn compact_tool_args(args: &serde_json::Value) -> String {
30    if args.is_null() {
31        return String::new();
32    }
33    let s = args.to_string();
34    if s == "{}" {
35        return String::new();
36    }
37    const MAX: usize = 48;
38    if s.chars().count() <= MAX {
39        s
40    } else {
41        format!("{}…", s.chars().take(MAX).collect::<String>())
42    }
43}
44
45/// The *turn step* of the L* execution loop (M1d).
46///
47/// Schedulability (`Ready/Running/Blocked/Suspended/Done`) is no longer carried here — it lives
48/// on the root task's [`TaskLifecycle`] in the kernel's `TaskTable`, queried via
49/// [`LoopStateMachine::lifecycle`]. `LoopPhase` is now orthogonal: it only records *which step of a
50/// running turn* the loop is in. When the task is `Ready/Suspended/Done`, the phase value is
51/// inert (left at its last step) and ignored.
52#[derive(Debug, Clone)]
53pub enum LoopPhase {
54    Reason,
55    Act { tool_calls: Vec<ToolCall> },
56}
57
58/// Events fed into the state machine from the SDK layer.
59#[derive(Debug)]
60pub enum LoopEvent {
61    LLMResponse {
62        message: Message,
63    },
64    ToolResults {
65        results: Vec<ToolResult>,
66    },
67    /// Result of evaluating the current milestone phase's criteria.
68    /// Feed this back after handling `LoopAction::EvaluateMilestone`.
69    MilestoneResult {
70        result: MilestoneCheckResult,
71    },
72    /// Sub-agent run completed — result is injected into the loop as context.
73    SubAgentCompleted {
74        result: SubAgentResult,
75    },
76    Timeout,
77}
78
79/// Actions the state machine outputs — SDK layer executes the I/O.
80#[derive(Debug)]
81pub enum LoopAction {
82    /// Structured context ready for a provider call.
83    /// `context.system_text` → provider system param.
84    /// `context.turns`       → provider messages array (strictly alternating).
85    /// `tools`               → tool schemas (skill / memory / knowledge / user tools).
86    CallLLM {
87        context: RenderedContext,
88        tools: Vec<ToolSchema>,
89    },
90    ExecuteTools {
91        calls: Vec<ToolCall>,
92    },
93    Done {
94        result: LoopResult,
95    },
96    /// Kernel requests the SDK to evaluate the current milestone phase.
97    ///
98    /// The SDK should assess `criteria` against the agent's output using the
99    /// specified `verifier`, then feed back `LoopEvent::MilestoneResult { result }`.
100    EvaluateMilestone {
101        phase_id: String,
102        criteria: Vec<String>,
103        verifier: Option<crate::types::milestone::MilestoneVerifier>,
104        required_evidence: Vec<String>,
105    },
106    /// Kernel is suspended — SDK must resolve (e.g. human approval) and feed `Resume`.
107    AwaitingResume,
108}
109
110/// Payload held while the loop is in `Suspended`.
111#[derive(Debug, Clone)]
112pub(super) enum SuspendState {
113    /// Governance AskUser — awaiting `Resume { approved_calls, denied_calls }`.
114    AskUser {
115        calls: Vec<ToolCall>,
116        gated_reasons: HashMap<String, String>,
117    },
118    /// Sub-agent spawn — awaiting `SubAgentCompleted` for each listed agent id.
119    SubAgentAwait {
120        agent_ids: Vec<String>,
121    },
122}
123
124pub(super) enum GateToolOutcome {
125    Proceed,
126    Blocked(LoopAction),
127    Suspended,
128}
129
130/// Snapshot of context lengths captured just before each LLM call.
131/// Used internally to restore state on rollback.
132#[derive(Debug, Clone, Default)]
133pub struct TurnCheckpoint {
134    pub history_len: usize,
135    pub signals_len: usize,
136    pub task_state: Option<crate::context::task_state::TaskState>,
137}
138
139/// Pure state machine for the L* execution loop. No I/O — only state transitions.
140///
141/// Internal engine backing [`crate::runtime::KernelRuntime`]. Exposed for in-crate
142/// use and tests; external callers should drive the kernel through `KernelRuntime`.
143#[doc(hidden)]
144pub struct LoopStateMachine {
145    pub phase: LoopPhase,
146    pub turn: u32,
147    pub ctx: ContextManager,
148    pub tools: Vec<ToolSchema>,
149    pub observations: Vec<KernelObservation>,
150    pub(super) policy: SchedulerBudget,
151    pub(super) total_tokens: u64,
152    /// L1 (RunGroup): cumulative tokens spent by *other* members of this run's governance domain,
153    /// seeded at boot via `seed_group_budget`. The run-level token cap is enforced against
154    /// `group_tokens_base + total_tokens` so the budget spans the whole group, not one vehicle.
155    /// 0 (default) ⇒ no group (N=1) ⇒ pre-L1 per-kernel behavior (byte-identical).
156    pub(super) group_tokens_base: u64,
157    /// ③ loop-agent: rounds completed across the loop BEFORE this run (seeded like the
158    /// token/spawn bases); this round's completion makes it `group_rounds_base + 1`.
159    pub(super) group_rounds_base: u32,
160    /// ③ the adjudicated `pace` decision awaiting attachment to this round's LoopResult.
161    pub(super) pending_pace: Option<crate::types::result::PaceDecision>,
162    /// L1 (RunGroup): sub-agents spawned by *other* members of this run's governance domain, seeded
163    /// at boot. `max_total_subagents` is enforced against `group_spawns_base + local spawns`. 0 ⇒ N=1.
164    pub(super) group_spawns_base: u32,
165    /// When set, the next LLM call strips tools to force a text response,
166    /// then terminates with this reason once the response arrives.
167    pub(super) pending_termination: Option<TerminationReason>,
168    /// Reactive context-overflow recovery: consecutive compact-and-retry attempts since the last
169    /// successful provider turn. Bounds the recovery ladder (anti-spiral) and resets to 0 on any
170    /// `LLMResponse`, mirroring the per-turn `hasAttemptedReactiveCompact` reset the SDK runners
171    /// used to own. See `recover_from_provider_error`.
172    pub(super) recovery_attempts: u8,
173    /// Max-output-tokens recovery: consecutive continue-and-retry turns since the model last
174    /// finished a response WITHOUT hitting the output cap. When a turn is cut off at the cap
175    /// (provider `stop_reason` = max_tokens/length) the kernel keeps the partial, nudges the model
176    /// to resume mid-thought, and re-calls — bounded by `MAX_OUTPUT_RECOVERY` (mirrors query.ts's
177    /// MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). Resets to 0 on any non-truncated response.
178    pub(super) output_recovery_attempts: u8,
179    /// Transient carrier for the provider `stop_reason` of the in-flight response, set by the
180    /// kernel ABI just before `feed(LLMResponse)` and taken (cleared) inside it. `None` when the
181    /// SDK/provider doesn't report one (every non-Anthropic provider today ⇒ no-op).
182    pub(super) pending_stop_reason: Option<String>,
183    /// Number of history messages present at session start (after preload_history).
184    /// drain_new_messages() returns the slice from this offset onward.
185    pub(super) session_history_baseline: usize,
186    pub(super) checkpoint: TurnCheckpoint,
187    /// Milestone contract tracker (extracted to reduce state machine bloat).
188    pub(super) milestone: MilestoneTracker,
189    pub run_spec: Option<AgentRunSpec>,
190    /// M1 収口: the single source of truth for schedulability *and* sub-agent lineage. Root is
191    /// task `"root"`; each sub-agent is a child task carrying its `ProcInfo`. The former
192    /// `ProcessTable` is now a derived view over this (`agent_process(es)` rebuild `AgentProcess`
193    /// rows on demand via `AgentProcess::from_tcb`).
194    pub(super) tasks: TaskTable,
195    /// Optional governance pipeline. When set, every tool call proposed by the
196    /// model is evaluated before `ExecuteTools` is emitted. `None` (default)
197    /// skips the gate entirely, preserving the pre-governance behavior.
198    pub(super) governance: Option<GovernancePipeline>,
199    /// Optional resource quota evaluated at the syscall trap (M2). `None` (default) leaves spawn /
200    /// memory syscalls unconditionally allowed, preserving pre-M2 behavior.
201    pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
202    /// Timestamps of recent allowed `WriteMemory` syscalls, for the rolling-window rate limit.
203    /// Only populated when `resource_quota.memory_writes_per_window` is set.
204    pub(super) memory_write_times: Vec<u64>,
205    /// Optional long-term memory policy (`set_memory_policy`). `None` (default) preserves
206    /// pre-policy behavior: default-rule validation + verbatim retrieval `top_k`.
207    pub(super) memory_policy: Option<crate::mm::memory::MemoryPolicy>,
208    /// Kernel-owned signal routing: dedup set + attention policy + bounded queue.
209    /// Always initialized; `set_attention` rebuilds it with a new queue size.
210    pub(super) signal_router: SignalRouter,
211    /// Wall-clock timestamp of the first `ProviderResult.now_ms` received.
212    /// Used by the wall-time budget axis in `SchedulerBudget::should_terminate`.
213    pub(super) started_at_ms: Option<u64>,
214    /// Most-recent `now_ms` value from `ProviderResult`, forwarded to the budget check.
215    pub(super) last_now_ms: Option<u64>,
216    /// Tool batch awaiting `Resume` after an AskUser suspend.
217    pub(super) suspend_state: Option<SuspendState>,
218    /// Denied tool results to merge into the next `ToolResults` feed after resume.
219    pub(super) pending_denied_results: Vec<ToolResult>,
220    /// W0: an in-flight workflow DAG, when one is loaded. The kernel spawns its ready nodes as
221    /// gated batches (each through `evaluate_syscall(Syscall::Spawn)`) and advances on
222    /// completions. `None` (default) preserves the single-spawn `spawn_sub_agent` behavior.
223    pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
224    /// O6: repeat-fuse thresholds (the hard rungs above the 2c soft STOP). Default enabled with
225    /// generous thresholds; tune/disable via `SetRepeatFuse` / `ConfigureRun.repeat_fuse`.
226    pub(super) repeat_fuse: RepeatFuseConfig,
227    /// O6: the previous turn's action signature (non-meta `name(args)` joined — the same key the
228    /// 2c STOP uses). NOT part of the turn checkpoint: a fuse deny's rollback must not launder
229    /// the streak it just tripped on.
230    pub(super) repeat_sig: Option<String>,
231    /// O6: consecutive turns whose signature equalled `repeat_sig` (1 = first occurrence).
232    pub(super) repeat_count: u32,
233    /// O4: turn-end criteria gate (the Stop-hook analog). When the model finishes (no tool calls)
234    /// while explicit acceptance criteria stand, inject ONE bounded self-check turn before
235    /// accepting `Completed`. 2c guards "won't stop"; this guards "stops too early".
236    pub(super) criteria_gate_enabled: bool,
237    /// O4: whether the gate already fired this run (it fires at most once — no nag loops).
238    pub(super) criteria_gate_fired: bool,
239    /// Session-entropy sliding window + watch state (see `scheduler::entropy`). Like the
240    /// RepeatFuse streak, NOT part of the turn checkpoint — a rollback must not launder
241    /// the disorder it just evidenced.
242    pub(super) entropy: EntropyTracker,
243    /// Opt-in threshold watch over the per-turn entropy score. Default disabled; the
244    /// unconditional per-turn `EntropySample` observation does not depend on it.
245    pub(super) entropy_watch: EntropyWatchConfig,
246}
247
248mod signal;
249mod capability;
250mod gate;
251mod eviction;
252mod process;
253mod workflow;
254mod milestone_exec;
255
256impl LoopStateMachine {
257    fn message_tokens(&self, message: &Message) -> u32 {
258        message
259            .token_count
260            .unwrap_or_else(|| self.ctx.engine.count_message(message))
261    }
262
263    pub fn new(policy: SchedulerBudget) -> Self {
264        let mut tasks = TaskTable::new();
265        // M1d: the root task carries the authoritative schedulability lifecycle. It starts
266        // `Ready`; `start()`/`resume_*` flip it to `Running`, suspends set `Suspended`, and
267        // `terminate()` sets `Done`. `phase` is now only the intra-turn step.
268        tasks.insert(Tcb::root("root", policy.clone()));
269        Self {
270            // Inert placeholder step; meaningful only while the root task is `Running`.
271            phase: LoopPhase::Reason,
272            turn: 0,
273            ctx: ContextManager::new(policy.max_tokens),
274            tools: Vec::new(),
275            observations: Vec::new(),
276            policy,
277            total_tokens: 0,
278            group_tokens_base: 0,
279            group_rounds_base: 0,
280            pending_pace: None,
281            group_spawns_base: 0,
282            pending_termination: None,
283            recovery_attempts: 0,
284            output_recovery_attempts: 0,
285            pending_stop_reason: None,
286            session_history_baseline: 0,
287            checkpoint: TurnCheckpoint::default(),
288            milestone: MilestoneTracker::new(),
289            run_spec: None,
290            tasks,
291            governance: None,
292            resource_quota: None,
293            memory_write_times: Vec::new(),
294            memory_policy: None,
295            signal_router: SignalRouter::new(64),
296            started_at_ms: None,
297            last_now_ms: None,
298            suspend_state: None,
299            pending_denied_results: Vec::new(),
300            workflow: None,
301            repeat_fuse: RepeatFuseConfig::default(),
302            repeat_sig: None,
303            repeat_count: 0,
304            criteria_gate_enabled: true,
305            criteria_gate_fired: false,
306            entropy: EntropyTracker::default(),
307            entropy_watch: EntropyWatchConfig::default(),
308        }
309    }
310
311    /// O4: enable/disable the turn-end criteria gate (default enabled; no-op without criteria).
312    pub fn set_criteria_gate(&mut self, enabled: bool) {
313        self.criteria_gate_enabled = enabled;
314    }
315
316    /// O6: tune or disable the repeat fuse (see [`RepeatFuseConfig`]).
317    pub fn set_repeat_fuse(&mut self, config: RepeatFuseConfig) {
318        self.repeat_fuse = config;
319    }
320
321    /// Configure the opt-in entropy threshold watch (see [`EntropyWatchConfig`]).
322    /// The per-turn `EntropySample` observation is unconditional and unaffected.
323    pub fn set_entropy_watch(&mut self, config: EntropyWatchConfig) {
324        self.entropy_watch = config;
325    }
326
327    pub fn entropy_watch_config(&self) -> EntropyWatchConfig {
328        self.entropy_watch
329    }
330
331    /// O6: the active repeat-fuse config (for read-modify-write from the ABI event).
332    pub fn repeat_fuse_config(&self) -> RepeatFuseConfig {
333        self.repeat_fuse
334    }
335
336    /// The authoritative schedulability lifecycle of the loop (root task state). Replaces the
337    /// removed `LoopPhase::{Idle,Suspended,Blocked,Terminal}` reads.
338    pub fn lifecycle(&self) -> TaskLifecycle {
339        self.tasks.get("root").map(|t| t.state).unwrap_or(TaskLifecycle::Ready)
340    }
341
342    /// The wait reason while suspended/blocked, if any.
343    pub fn wait_reason(&self) -> Option<WaitReason> {
344        self.tasks.get("root").and_then(|t| t.wait.clone())
345    }
346
347    /// Whether the loop has terminated.
348    pub fn is_terminal(&self) -> bool {
349        matches!(self.lifecycle(), TaskLifecycle::Done(_))
350    }
351
352    /// Whether the loop is suspended awaiting external resolution.
353    pub fn is_suspended(&self) -> bool {
354        matches!(self.lifecycle(), TaskLifecycle::Suspended)
355    }
356
357    /// Set the root task's lifecycle (and wait reason). Single mutation point for schedulability.
358    fn set_lifecycle(&mut self, state: TaskLifecycle, wait: Option<WaitReason>) {
359        if let Some(root) = self.tasks.get_mut("root") {
360            root.state = state;
361            root.wait = wait;
362        } else {
363            let mut root = Tcb::root("root", self.policy.clone());
364            root.state = state;
365            root.wait = wait;
366            self.tasks.insert(root);
367        }
368    }
369
370    /// Build a transient root [`Tcb`] mirroring the current scheduling facts (budget counters,
371    /// wall-clock anchors, lifecycle). M1b uses this to run the pure `schedule()` spine in
372    /// parallel with the legacy budget path; later milestones promote it to the live task row.
373    fn root_tcb(&self) -> Tcb {
374        let mut tcb = Tcb::root("root", self.policy.clone());
375        tcb.budget.turns = self.turn;
376        // L1: the token-budget axis is evaluated against the whole governance domain's cumulative
377        // spend (this vehicle's `total_tokens` plus other members' `group_tokens_base`).
378        tcb.budget.total_tokens = self.total_tokens.saturating_add(self.group_tokens_base);
379        tcb.budget.started_at_ms = self.started_at_ms;
380        tcb.state = self.lifecycle();
381        tcb
382    }
383
384    /// Adjust the wall-clock budget axis at runtime.
385    pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
386        self.policy.max_wall_ms = max_wall_ms;
387    }
388
389    /// Install a governance pipeline. Once set, all model-proposed tool calls
390    /// are evaluated before execution. Denied/rate-limited calls roll the turn
391    /// back (reusing the `GovernanceDenied` path); `AskUser` calls surface a
392    /// `ToolGated` observation for the SDK to enforce.
393    pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
394        self.governance = Some(pipeline);
395    }
396
397    /// Install resource quotas (M2). Once set, `Spawn` and `WriteMemory` syscalls are bounded by
398    /// the quota at the trap. Not setting it (the default) leaves them unconditionally allowed.
399    pub fn set_resource_quota(&mut self, quota: crate::governance::quota::ResourceQuota) {
400        self.resource_quota = Some(quota);
401    }
402
403    /// L1 (RunGroup): seed the cumulative tokens already spent by other members of this run's
404    /// governance domain. The run-level token cap is then enforced against the group total. Seeding
405    /// 0 (the default) preserves pre-L1 per-vehicle behavior.
406    pub fn seed_group_budget(&mut self, tokens_spent: u64) {
407        self.group_tokens_base = tokens_spent;
408    }
409
410    /// L1 (RunGroup): seed the sub-agents already spawned by other members of this run's governance
411    /// domain. `max_total_subagents` is then enforced against the group total. 0 ⇒ pre-L1 behavior.
412    /// ③ seed the loop's completed-round count (parallel to the token/spawn bases) so
413    /// the pacing trap can coerce continue/sleep to stop at `max_rounds`.
414    pub fn seed_group_rounds(&mut self, rounds_completed: u32) {
415        self.group_rounds_base = rounds_completed;
416    }
417
418    pub fn seed_group_spawns(&mut self, subagents_spawned: u32) {
419        self.group_spawns_base = subagents_spawned;
420    }
421
422    /// L1: this vehicle's cumulative sub-agent spawns this run — every child task ever registered in
423    /// the `TaskTable` (running + completed), distinct from the *instantaneous* running count. Used
424    /// for the cumulative spawn quota and read back by the SDK to charge the group ledger at run end.
425    pub fn local_subagents_spawned(&self) -> u32 {
426        self.tasks.all().iter().filter(|t| t.proc.is_some()).count() as u32
427    }
428
429    /// Install the long-term memory policy (`set_memory_policy`). Once set it gates `write_memory`
430    /// validation and bounds `query_memory` retrieval breadth. Not setting it (the default)
431    /// preserves pre-policy behavior.
432    pub fn set_memory_policy(&mut self, policy: crate::mm::memory::MemoryPolicy) {
433        self.memory_policy = Some(policy);
434    }
435
436    /// The installed memory policy, if any. `None` means default-rule validation + verbatim top_k.
437    pub fn memory_policy(&self) -> Option<&crate::mm::memory::MemoryPolicy> {
438        self.memory_policy.as_ref()
439    }
440
441    /// Feed the current wall-clock time (ms) to scheduler/governance budget axes.
442    pub fn set_observed_time(&mut self, now_ms: u64) {
443        if self.started_at_ms.is_none() {
444            self.started_at_ms = Some(now_ms);
445        }
446        self.last_now_ms = Some(now_ms);
447        if let Some(pipeline) = self.governance.as_mut() {
448            pipeline.set_time(now_ms);
449        }
450    }
451
452    /// Stash the in-flight response's provider `stop_reason` so `feed(LLMResponse)` can detect an
453    /// output-cap truncation. Set by the kernel ABI right before feeding the result; `None` clears it.
454    pub fn set_pending_stop_reason(&mut self, stop_reason: Option<String>) {
455        self.pending_stop_reason = stop_reason;
456    }
457
458    /// Pre-populate the history partition with messages from a prior session.
459    ///
460    /// Call **before** `start()` when resuming a conversation. Sets the baseline
461    /// so `drain_new_messages()` returns only the messages from the current run.
462    pub fn preload_history(&mut self, messages: Vec<Message>) {
463        for msg in messages {
464            let tokens = self.message_tokens(&msg);
465            self.ctx.push_history(msg, tokens);
466        }
467        self.session_history_baseline = self.ctx.partitions.history.messages.len();
468    }
469
470    /// Continue from preloaded history without appending a new user turn.
471    /// Use after `preload_history` when recovering a session that ended mid-run.
472    ///
473    /// If the last assistant turn has tool calls without matching tool results,
474    /// resumes with `ExecuteTools` instead of calling the LLM again.
475    pub fn resume_after_preload(&mut self) -> LoopAction {
476        self.observations.clear();
477        let calls = crate::runtime::repair::pending_tool_calls_from_messages(
478            &self.ctx.partitions.history.messages,
479        );
480        if !calls.is_empty() {
481            self.phase = LoopPhase::Act {
482                tool_calls: calls.clone(),
483            };
484            self.set_lifecycle(TaskLifecycle::Running, None);
485            return LoopAction::ExecuteTools { calls };
486        }
487        self.phase = LoopPhase::Reason;
488        self.emit_call_llm()
489    }
490
491    /// Return all messages added to history during the current run
492    /// (since the last `preload_history` call or since construction).
493    ///
494    /// Call after `LoopAction::Done` to get the complete turn transcript
495    /// for persistence to a SessionStore.
496    pub fn drain_new_messages(&self) -> Vec<Message> {
497        let history = &self.ctx.partitions.history.messages;
498        let start = self.session_history_baseline.min(history.len());
499        history[start..].to_vec()
500    }
501
502    pub fn start(&mut self, task: RuntimeTask) -> LoopAction {
503        self.observations.clear();
504        self.ctx.init_task(task.goal.clone(), task.criteria.clone());
505
506        let user_msg = "Proceed with the task described in [TASK STATE].".to_string();
507
508        // User message goes into history so it appears at the correct chronological
509        // position: [prior turns...] → [current user message] — LLM reads left-to-right
510        // and responds to the last message. working is reserved for runtime signals only.
511        // Estimate tokens (1 token ≈ 4 chars) with a minimum of 1 so the renderer
512        // does not skip this message (it skips zero-token entries).
513        let user_tokens = self.ctx.engine.count(&user_msg).max(1);
514        self.ctx.push_history(Message::user(user_msg), user_tokens);
515        self.phase = LoopPhase::Reason;
516        // Root task (seeded `Ready` in `new()`) becomes `Running`; `emit_call_llm` sets it.
517        self.emit_call_llm()
518    }
519
520    pub fn feed(&mut self, event: LoopEvent) -> LoopAction {
521        self.observations.clear();
522        self.sweep_expired_leases();
523        // K3: skill leases expire on the same head-of-event cadence as capability leases.
524        self.ctx.sweep_expired_skill_leases(self.turn);
525
526        match event {
527
528            LoopEvent::LLMResponse { message } => {
529                // A response arrived ⇒ the prompt fit ⇒ the overflow recovery ladder is reset.
530                self.recovery_attempts = 0;
531                let tokens = self.message_tokens(&message);
532                self.total_tokens += tokens as u64;
533
534                // Max-output-tokens recovery (mirrors query.ts): a response cut off at the output
535                // cap reports stop_reason = max_tokens (Anthropic) / length (OpenAI). A clean finish
536                // resets the ladder.
537                const MAX_OUTPUT_RECOVERY: u8 = 3;
538                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.";
539                let truncated = matches!(
540                    self.pending_stop_reason.take().as_deref(),
541                    Some("max_tokens") | Some("length"),
542                );
543                if !truncated {
544                    self.output_recovery_attempts = 0;
545                }
546
547                if let Some(reason) = self.pending_termination.take() {
548                    return self.terminate(reason, Some(message));
549                }
550
551                if message.tool_calls.is_empty() {
552                    // The model was cut off at the output cap with no tool call. Keep the partial,
553                    // nudge it to resume mid-thought, and re-call — instead of mistaking the
554                    // truncation for a finished turn. Bounded by MAX_OUTPUT_RECOVERY; once exhausted
555                    // the partial stands and the turn terminates normally below. (A truncated
556                    // *tool-call* turn isn't handled here — it falls through to tool execution.)
557                    if truncated && self.output_recovery_attempts < MAX_OUTPUT_RECOVERY {
558                        self.output_recovery_attempts += 1;
559                        self.ctx.push_history(message, tokens);
560                        self.ctx.push_signal(OUTPUT_TRUNCATION_NUDGE.to_string());
561                        self.phase = LoopPhase::Reason;
562                        return self.emit_call_llm();
563                    }
564                    // When a milestone contract is active and not yet complete,
565                    // request evaluation instead of terminating.
566                    if !self.milestone.is_complete() {
567                        let phase_id = self.milestone.current_phase_id().unwrap_or("").to_string();
568                        let criteria = self.milestone.current_criteria().to_vec();
569                        let (verifier, required_evidence) = self
570                            .milestone
571                            .current_phase()
572                            .map(|p| (p.verifier.clone(), p.required_evidence.clone()))
573                            .unwrap_or_default();
574                        // `tokens` was already computed for this message above.
575                        self.ctx.push_history(message, tokens);
576                        return LoopAction::EvaluateMilestone {
577                            phase_id,
578                            criteria,
579                            verifier,
580                            required_evidence,
581                        };
582                    }
583                    // O4 criteria gate (the Stop-hook analog): the model is finishing while explicit
584                    // acceptance criteria stand. Before accepting `Completed`, inject ONE bounded
585                    // self-check at the peak-attention slot — verify each criterion, continue if any
586                    // is unmet, else confirm. Fires at most once per run (no nag loop); runs with no
587                    // criteria are untouched. 2c guards "won't stop"; this guards "stops too early".
588                    if self.criteria_gate_enabled
589                        && !self.criteria_gate_fired
590                        && !self.ctx.partitions.task_state.criteria.is_empty()
591                    {
592                        self.criteria_gate_fired = true;
593                        let criteria = self.ctx.partitions.task_state.criteria.clone();
594                        self.ctx.push_history(message, tokens);
595                        self.ctx.push_signal(format!(
596                            "[CRITERIA CHECK] You are about to finish. Verify each acceptance \
597                             criterion first: {}. If any is NOT met, continue working on it now. \
598                             If all are met, give the final answer.",
599                            criteria.join(" | ")
600                        ));
601                        self.observations.push(KernelObservation::CriteriaGateFired {
602                            turn: self.turn,
603                            criteria,
604                        });
605                        self.phase = LoopPhase::Reason;
606                        return self.emit_call_llm();
607                    }
608                    return self.terminate(TerminationReason::Completed, Some(message));
609                }
610
611                let calls = message.tool_calls.clone();
612                self.ctx.push_history(message, tokens);
613
614                // ━━ 记录活动时间(Layer 3时间衰减使用)
615                if let Some(now_ms) = self.last_now_ms {
616                    self.ctx.record_activity(now_ms);
617                }
618
619                // ③ pacing trap: a `pace` call is a kernel-adjudicated round-end proposal,
620                // never an SDK tool. Handled before the fuse/gate — it is a control verb,
621                // not task work.
622                if self.run_spec.as_ref().and_then(|r| r.loop_round.as_ref()).is_some() {
623                    if let Some(pace_call) = calls.iter().find(|c| c.name.as_str() == "pace") {
624                        let call = pace_call.clone();
625                        return self.handle_pace_call(call);
626                    }
627                }
628
629                // 2b: record this turn's tool activity into the task-state recency log (meta-tools
630                // filtered inside). The State-turn footer renders it as "just did: …" + a forward
631                // nudge / STOP, so progress is kernel-derived and never depends on the model
632                // remembering to call `update_plan`. Tool *names* live only on the request (results
633                // carry call_id only), so this is the turn to capture them.
634                //
635                // Capture name AND a compact arg digest: the no-progress STOP keys on whether the
636                // SAME call repeats, and a legit loop (same tool, DIFFERENT args — e.g. processing 20
637                // items) is real progress, not a stall. Keying on the name alone false-positives those
638                // loops; including args distinguishes "step(n=1), step(n=2)…" from a true repeat.
639                let action_sigs: Vec<(String, String)> = calls
640                    .iter()
641                    .map(|c| (c.name.to_string(), compact_tool_args(&c.arguments)))
642                    .collect();
643                self.ctx.note_tool_actions(&action_sigs);
644
645                // O6 RepeatFuse: the hard rungs above the 2c soft STOP. Runs BEFORE the governance
646                // gate and independent of whether a policy is loaded — a batteries-included kernel
647                // protection, not a policy feature. Deny rolls the turn back with a directive note;
648                // the terminate rung ends the run `NoProgress` after one final no-tools report turn.
649                if let Some(action) = self.check_repeat_fuse(&calls) {
650                    return action;
651                }
652
653                match self.gate_tool_calls(&calls) {
654                    GateToolOutcome::Blocked(action) => return action,
655                    GateToolOutcome::Suspended => return LoopAction::AwaitingResume,
656                    GateToolOutcome::Proceed => {}
657                }
658                self.phase = LoopPhase::Act {
659                    tool_calls: calls.clone(),
660                };
661                self.set_lifecycle(TaskLifecycle::Running, None);
662                LoopAction::ExecuteTools { calls }
663            }
664
665            LoopEvent::ToolResults { mut results } => {
666                if !self.pending_denied_results.is_empty() {
667                    results.append(&mut self.pending_denied_results);
668                }
669                if let Some(reason) = results
670                    .iter()
671                    .find_map(|result| self.rollback_reason_for_tool_result(result))
672                {
673                    let note = Message::user(super::rollback::build_rollback_note(
674                        &reason,
675                        self.ctx.config.verbose_control_notes,
676                    ));
677                    self.rollback(reason);
678                    self.ctx.push_signal(note.content.as_text().unwrap_or_default().to_string());
679                    self.phase = LoopPhase::Reason;
680                    return self.emit_call_llm();
681                }
682                // Non-fatal errors are committed to history so the LLM can
683                // see them and self-correct without losing turn state.
684
685                // Entropy: this completed turn's failure tally (fatal errors never get
686                // here — they rolled back above and accrued via `note_rollback`).
687                let errored_results = results.iter().filter(|r| r.is_error).count() as u32;
688                let total_results = results.len() as u32;
689
690                for r in &results {
691                    self.total_tokens += r.token_count.unwrap_or(0) as u64;
692                    // Preserve Content::Parts (structured / multimodal tool output).
693                    // Parts are serialised to JSON so the text can be restored faithfully.
694                    let raw_output = match &r.output {
695                        Content::Text(s) => s.clone(),
696                        Content::Parts(parts) => serde_json::to_string(parts).unwrap_or_default(),
697                    };
698                    // Layer 1 spool: oversized results keep only a preview in context; the kernel
699                    // emits `LargeResultSpooled` so the SDK persists the full output it still holds.
700                    let (output, spooled) = match crate::mm::plan_spool(
701                        &raw_output,
702                        self.ctx.config.spool_threshold_bytes,
703                        self.ctx.config.spool_preview_bytes,
704                    ) {
705                        Some(decision) => {
706                            self.observations.push(KernelObservation::LargeResultSpooled {
707                                turn: self.turn,
708                                call_id: r.call_id.to_string(),
709                                // ToolResult carries no tool name; the SDK maps call_id -> tool.
710                                tool: String::new(),
711                                original_size: decision.original_size,
712                                preview_size: decision.preview.len() as u32,
713                                spool_ref: None,
714                            });
715                            (decision.preview, true)
716                        }
717                        None => (raw_output, false),
718                    };
719                    let parts = vec![ContentPart::ToolResult {
720                        call_id: r.call_id.clone(),
721                        output,
722                        is_error: r.is_error,
723                    }];
724                    let tool_msg = Message::tool(parts);
725                    // When spooled, `r.token_count` reflects the full output — recount the preview.
726                    let tokens = if spooled {
727                        self.ctx.engine.count_message(&tool_msg)
728                    } else {
729                        r.token_count
730                            .unwrap_or_else(|| self.ctx.engine.count_message(&tool_msg))
731                    };
732                    self.ctx.push_history(tool_msg, tokens);
733                    // Layer 1: a spooled result's handle is marked SpooledOut (its full output now
734                    // lives on disk via the SDK); the SDK maps call_id -> the persisted ref.
735                    if spooled {
736                        self.ctx.mark_spooled(&r.call_id, r.call_id.to_string());
737                    }
738                }
739                self.turn += 1;
740
741                // M1 收口: the pure `schedule()` is now the single budget decision point.
742                // It evaluates the same three axes (turn/token/wall) via `BudgetLedger`, which
743                // delegates to `SchedulerBudget::should_terminate` internally — one source of truth.
744                if let Some(term) = super::tcb::budget_verdict(&self.root_tcb(), self.last_now_ms) {
745                    let budget = match term {
746                        TerminationReason::MaxTurns => "max_turns",
747                        TerminationReason::Timeout => "wall_time",
748                        _ => "token_budget",
749                    };
750                    self.observations.push(KernelObservation::BudgetExceeded {
751                        turn: self.turn,
752                        budget: budget.to_string(),
753                    });
754                    self.pending_termination = Some(term);
755                    self.phase = LoopPhase::Reason;
756                    return self.emit_call_llm();
757                }
758
759                // ━━ Eviction checkpoint (M3): one decision model (`plan_eviction`), one
760                // execution funnel (`execute_eviction_op`). Layer 3 (idle/time-decay) must run
761                // before the rho recommendation is read, since it mutates token usage — so the
762                // plan is built in that interleaved order and the ops are executed in plan order.
763                let idle_decay = self
764                    .last_now_ms
765                    .is_some_and(|now_ms| self.ctx.should_time_decay_compact(now_ms));
766                if idle_decay {
767                    self.execute_eviction_op(&crate::mm::EvictionOp::TimeDecayMicro);
768                }
769
770                // Layer 4 read-time projection: recompute handle residency on the post-time-decay rho.
771                self.ctx.recompute_handle_residency();
772                // K2: knowledge budget check — marks over-budget unpinned entries for the next
773                // boundary sweep (marks are idempotent; drops only apply there) and stashes a
774                // warn-once-per-generation notice, drained into an observation here.
775                if let Some((used, budget)) = self.ctx.enforce_knowledge_budget() {
776                    self.observations.push(KernelObservation::KnowledgeBudgetExceeded {
777                        turn: self.turn,
778                        used,
779                        budget,
780                    });
781                }
782                // Layers 2/4/5: execute the pressure-driven ops from the plan (skip TimeDecayMicro
783                // if already executed). The plan carries specific ops stamped with real config-derived
784                // params (W1-1 収口 — no magic-number placeholders), not the umbrella `Pressure` wrapper.
785                let (target_tokens, preserve_turns) = self.ctx.plan_compaction_params();
786                let plan =
787                    crate::mm::plan_eviction(self.ctx.should_compress(), idle_decay, target_tokens, preserve_turns);
788                // `idle_decay` ⇒ the plan carries a `TimeDecayMicro` (so the skip-on-already-executed
789                // below is meaningful). The converse does NOT hold: a pressure-driven `MicroCompact`
790                // also emits `TimeDecayMicro` independent of `idle_decay` (W1 unified planner), so we
791                // assert the implication, not equality.
792                debug_assert!(!idle_decay || plan.has_time_decay());
793                for op in &plan.ops {
794                    // Skip TimeDecayMicro if we already executed it (prevents double-execution).
795                    if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
796                        continue;
797                    }
798                    self.execute_eviction_op(op);
799                }
800
801                // Renewal: when compression alone cannot recover enough headroom,
802                // start a new sprint — carry forward system + memory + last N history turns.
803                if self.ctx.should_renew() {
804                    self.ctx.renew();
805                    // A new sprint is a session boundary for signal identity: clear the dedup set so
806                    // it cannot grow unbounded across a long run, and so a signal seen in a prior
807                    // sprint may legitimately re-fire in the new one.
808                    self.signal_router.clear_dedup();
809                    self.observations.push(KernelObservation::Renewed {
810                        sprint: self.ctx.sprint,
811                    });
812                    // K1: renewal is a boundary — surface the knowledge sweep it just ran.
813                    self.emit_knowledge_sweep_observations();
814                }
815
816                // Session-entropy sample (the heartbeat watch source): fold this completed
817                // turn's outcomes into the sliding window and surface the measurement.
818                // Unconditional, like `CheckpointTaken`; only the watch alert below is opt-in.
819                let repeat_streak = if self.repeat_fuse.enabled { self.repeat_count } else { 0 };
820                let sample = self.entropy.sample(
821                    self.turn,
822                    self.ctx.rho(),
823                    repeat_streak,
824                    self.repeat_fuse.deny_after,
825                    errored_results,
826                    total_results,
827                );
828                self.observations.push(KernelObservation::EntropySample {
829                    turn: sample.turn,
830                    score: sample.score,
831                    score_version: super::entropy::ENTROPY_SCORE_VERSION,
832                    rho: sample.rho,
833                    repeat_pressure: sample.repeat_pressure,
834                    failure_rate: sample.failure_rate,
835                    rollbacks_in_window: sample.rollbacks_in_window,
836                    window_turns: sample.window_turns,
837                });
838                // Opt-in entropy watch: threshold + hysteresis + cooldown. The alert is an
839                // observation (host-facing); with `notify_model` it is ALSO routed through
840                // the kernel's own signal dispatch as a Heartbeat/Alert directive — High
841                // urgency while running ⇒ a durable [SIGNAL] note on the turn we are about
842                // to emit anyway, never an extra provider call.
843                if self.entropy.should_alert(&self.entropy_watch, &sample) {
844                    self.observations.push(KernelObservation::EntropyAlert {
845                        turn: sample.turn,
846                        score: sample.score,
847                        threshold: self.entropy_watch.threshold,
848                    });
849                    if self.entropy_watch.notify_model {
850                        use crate::types::signal::{RuntimeSignal, SignalSource, SignalType, Urgency};
851                        let signal = RuntimeSignal::new(
852                            SignalSource::Heartbeat,
853                            SignalType::Alert,
854                            Urgency::High,
855                            format!(
856                                "[entropy] session disorder {:.2} ≥ {:.2} (repeat {:.2} / failures {:.2} / pressure {:.2}). \
857                                 Stop and reassess: state what is not working and try a different approach.",
858                                sample.score,
859                                self.entropy_watch.threshold,
860                                sample.repeat_pressure,
861                                sample.failure_rate,
862                                sample.rho,
863                            ),
864                        )
865                        .with_dedupe(format!("entropy_alert:{}", sample.turn));
866                        let _ = self.dispatch_signal(signal);
867                    }
868                }
869
870                // Turn boundary: drain any kernel-queued signals into context so they
871                // are seen on the next reasoning turn (ready queue → running).
872                self.drain_queued_signals();
873
874                self.phase = LoopPhase::Reason;
875                self.emit_call_llm()
876            }
877
878            LoopEvent::MilestoneResult { result } => self.handle_milestone_result(result),
879
880            LoopEvent::SubAgentCompleted { result } => self.handle_sub_agent_completed(result),
881
882            LoopEvent::Timeout => {
883                let reason = RollbackReason::Timeout;
884                let note = Message::user(super::rollback::build_rollback_note(
885                    &reason,
886                    self.ctx.config.verbose_control_notes,
887                ));
888                self.rollback(reason);
889                self.ctx.push_signal(note.content.as_text().unwrap_or_default().to_string());
890                self.phase = LoopPhase::Reason;
891                self.emit_call_llm()
892            }
893        }
894    }
895
896
897    /// Drain observations emitted during the last `start`/`feed` call.
898    pub fn take_observations(&mut self) -> Vec<KernelObservation> {
899        std::mem::take(&mut self.observations)
900    }
901
902    /// ③ the pacing trap. The model PROPOSES `pace(next, delay_ms?, reason)`; the kernel
903    /// ADJUDICATES: malformed → governance-style rollback note; sleep delay clamped into
904    /// the spec's [min,max]; continue/sleep at the round cap coerced to stop("max_rounds");
905    /// stop with standing acceptance criteria routes through the O4 criteria gate ONCE
906    /// (one bounded self-check turn) before being honored. An allowed pace ends the round:
907    /// the decision is stashed for LoopResult, a synthetic tool result closes the
908    /// transcript pair, and the strip-tools final-report turn finishes the round.
909    fn handle_pace_call(&mut self, call: ToolCall) -> LoopAction {
910        use crate::types::result::{PaceAction, PaceDecision};
911
912        let spec = self
913            .run_spec
914            .as_ref()
915            .and_then(|r| r.loop_round.as_ref())
916            .cloned()
917            .unwrap_or_default();
918
919        let next = call.arguments.get("next").and_then(|v| v.as_str()).unwrap_or("");
920        let reason = call
921            .arguments
922            .get("reason")
923            .and_then(|v| v.as_str())
924            .unwrap_or("")
925            .to_string();
926        let proposed_delay = call.arguments.get("delay_ms").and_then(|v| v.as_u64());
927
928        let mut action = match next {
929            "continue" => PaceAction::Continue,
930            "sleep" => PaceAction::Sleep,
931            "stop" => PaceAction::Stop,
932            other => {
933                // Malformed proposal: governance-style directive note + fresh reason turn.
934                let rb = RollbackReason::GovernanceDenied {
935                    tool_name: "pace".to_string(),
936                    reason: format!(
937                        "invalid pace next={other:?} (expected continue|sleep|stop)"
938                    ),
939                };
940                let note = Message::user(super::rollback::build_rollback_note(
941                    &rb,
942                    self.ctx.config.verbose_control_notes,
943                ));
944                self.push_synthetic_tool_result(
945                    &call.id,
946                    "pace rejected: next must be continue|sleep|stop",
947                );
948                self.ctx
949                    .push_signal(note.content.as_text().unwrap_or_default().to_string());
950                self.phase = LoopPhase::Reason;
951                return self.emit_call_llm();
952            }
953        };
954        let mut coerced_from: Option<String> = None;
955
956        // Round-cap coercion: this round's completion is group_rounds_base + 1.
957        if action != PaceAction::Stop {
958            if let Some(max) = spec.max_rounds {
959                if self.group_rounds_base.saturating_add(1) >= max {
960                    coerced_from = Some(format!("{} (max_rounds={max})", action.label()));
961                    action = PaceAction::Stop;
962                }
963            }
964        }
965
966        // O4 routing: a stop with standing criteria takes the existing criteria-gate
967        // self-check turn first; the model re-decides with the checklist in view.
968        if action == PaceAction::Stop
969            && self.criteria_gate_enabled
970            && !self.criteria_gate_fired
971            && !self.ctx.partitions.task_state.criteria.is_empty()
972        {
973            self.criteria_gate_fired = true;
974            let criteria = self.ctx.partitions.task_state.criteria.clone();
975            self.push_synthetic_tool_result(
976                &call.id,
977                "pace(stop) noted — verify the acceptance criteria first, then pace again.",
978            );
979            self.ctx.push_signal(format!(
980                "[CRITERIA CHECK] You proposed stopping the loop. Verify each acceptance \
981                 criterion first: {}. If any is NOT met, continue working (or pace(continue)). \
982                 If all are met, call pace(stop) again.",
983                criteria.join(" | ")
984            ));
985            self.observations.push(KernelObservation::CriteriaGateFired {
986                turn: self.turn,
987                criteria,
988            });
989            self.phase = LoopPhase::Reason;
990            return self.emit_call_llm();
991        }
992
993        // Sleep clamp into [min, max].
994        let delay_ms = if action == PaceAction::Sleep {
995            let raw = proposed_delay.unwrap_or(spec.min_sleep_ms.unwrap_or(60_000));
996            let mut clamped = raw;
997            if let Some(min) = spec.min_sleep_ms {
998                clamped = clamped.max(min);
999            }
1000            if let Some(max) = spec.max_sleep_ms {
1001                clamped = clamped.min(max);
1002            }
1003            if clamped != raw && coerced_from.is_none() {
1004                coerced_from = Some(format!("sleep {raw}ms (clamped)"));
1005            }
1006            Some(clamped)
1007        } else {
1008            None
1009        };
1010
1011        let decision = PaceDecision { action, delay_ms, reason, coerced_from };
1012        self.observations.push(KernelObservation::RoundPaced {
1013            turn: self.turn,
1014            round: self.group_rounds_base.saturating_add(1),
1015            decision: decision.clone(),
1016        });
1017        self.push_synthetic_tool_result(
1018            &call.id,
1019            &format!(
1020                "pace acknowledged: {}{} — wrap up with a brief round report.",
1021                decision.action.label(),
1022                decision
1023                    .delay_ms
1024                    .map(|d| format!(" {d}ms"))
1025                    .unwrap_or_default()
1026            ),
1027        );
1028        self.pending_pace = Some(decision);
1029        self.pending_termination = Some(TerminationReason::Completed);
1030        self.phase = LoopPhase::Reason;
1031        self.emit_call_llm()
1032    }
1033
1034    /// Close a kernel-handled tool call's transcript pair with a synthetic result so
1035    /// providers always see call → result.
1036    fn push_synthetic_tool_result(&mut self, call_id: &str, output: &str) {
1037        let msg = Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1038            call_id: call_id.into(),
1039            output: output.to_string(),
1040            is_error: false,
1041        }]);
1042        let tokens = self.message_tokens(&msg);
1043        self.ctx.push_history(msg, tokens);
1044    }
1045
1046    fn terminate(
1047        &mut self,
1048        termination: TerminationReason,
1049        final_message: Option<Message>,
1050    ) -> LoopAction {
1051        // Commit the final response into history so subsequent session restores
1052        // include the complete transcript: user → [tool turns] → final assistant.
1053        if let Some(ref msg) = final_message {
1054            let tokens = self.message_tokens(msg);
1055            self.ctx.push_history(msg.clone(), tokens);
1056        }
1057        // ③ attach the round's pacing decision. Stashed by the trap when the model
1058        // called `pace`; otherwise the spec's default_action ("stop" for goal loops,
1059        // "sleep" for cron loops) — but ONLY on a clean Completed. NoProgress /
1060        // ContextOverflow / Error rounds stop and surface (nothing nags the model).
1061        let pace_decision = self.pending_pace.take().or_else(|| {
1062            let spec = self.run_spec.as_ref()?.loop_round.as_ref()?;
1063            if termination != TerminationReason::Completed {
1064                return Some(crate::types::result::PaceDecision {
1065                    action: crate::types::result::PaceAction::Stop,
1066                    delay_ms: None,
1067                    reason: format!("round terminated: {}", termination.label()),
1068                    coerced_from: None,
1069                });
1070            }
1071            match spec.default_action.as_deref() {
1072                Some("sleep") => Some(crate::types::result::PaceDecision {
1073                    action: crate::types::result::PaceAction::Sleep,
1074                    delay_ms: spec.min_sleep_ms.or(Some(60_000)),
1075                    reason: "default_action: sleep (cron loop)".to_string(),
1076                    coerced_from: None,
1077                }),
1078                _ => Some(crate::types::result::PaceDecision {
1079                    action: crate::types::result::PaceAction::Stop,
1080                    delay_ms: None,
1081                    reason: "default_action: stop (no pace call this round)".to_string(),
1082                    coerced_from: None,
1083                }),
1084            }
1085        });
1086        let result = LoopResult {
1087            termination,
1088            final_message,
1089            turns_used: self.turn,
1090            total_tokens_used: self.total_tokens,
1091            loop_continue: None,
1092            classify_branch: None,
1093            tournament_winner: None,
1094            pace_decision,
1095        };
1096        self.set_lifecycle(TaskLifecycle::Done(termination), None);
1097        LoopAction::Done { result }
1098    }
1099
1100    /// Build the `CallLLM` action with a structured `RenderedContext`.
1101    /// Meta-tools (skill / memory / knowledge) are appended to the tool list
1102    /// when configured. When `pending_termination` is set, tools are stripped
1103    /// to force a plain-text response before the loop terminates.
1104    fn emit_call_llm(&mut self) -> LoopAction {
1105        // Calling the provider is definitionally "running" — the single funnel for entering the
1106        // Running lifecycle (covers start, resume, signal-driven turns, budget final-call).
1107        self.set_lifecycle(TaskLifecycle::Running, None);
1108        self.checkpoint.history_len = self.ctx.partitions.history.messages.len();
1109        self.checkpoint.signals_len = self.ctx.partitions.signals.len();
1110        self.checkpoint.task_state = Some(self.ctx.partitions.task_state.clone());
1111        self.observations.push(KernelObservation::CheckpointTaken {
1112            turn: self.turn,
1113            history_len: self.checkpoint.history_len as u32,
1114        });
1115
1116        let context = self.ctx.render();
1117        if self.pending_termination.is_some() {
1118            return LoopAction::CallLLM {
1119                context,
1120                tools: Vec::new(),
1121            };
1122        }
1123        let mut tools = self.tools.clone();
1124        tools.extend(self.ctx.meta_tool_schemas());
1125
1126        if let Some(ref spec) = self.run_spec {
1127            use crate::types::capability::CapabilityKind;
1128            tools.retain(|tool| {
1129                let kind = match tool.name.as_str() {
1130                    "skill" => CapabilityKind::Skill,
1131                    "memory" => CapabilityKind::Memory,
1132                    "knowledge" => CapabilityKind::Knowledge,
1133                    _ => CapabilityKind::Tool,
1134                };
1135                let desc = crate::types::capability::CapabilityDescriptor::marker(
1136                    kind,
1137                    tool.name.clone(),
1138                    &tool.description,
1139                );
1140                spec.capability_filter.allows(&desc)
1141            });
1142        }
1143
1144        // P1-B epoch skill gating (applied *after* the run-level filter ③, so A is the outer bound
1145        // and B narrows within it — D6). When skills are active and declare tools, expose only
1146        // `meta-tools ∪ stable-core ∪ ⋃(active skills' allowed_tools)`. `None` ⇒ no active/declared
1147        // skill ⇒ no narrowing (D3, errs-open). Meta-tools are always exempt (D5) so the model can
1148        // still load more skills. Byte-stable within an epoch: the set only changes on activation.
1149        if let Some(allowed) = self.ctx.active_skill_tool_filter() {
1150            let stable = &self.ctx.stable_core_tools;
1151            tools.retain(|tool| {
1152                matches!(tool.name.as_str(), "skill" | "memory" | "knowledge" | "update_plan")
1153                    || stable.contains(&tool.name)
1154                    || allowed.contains(&tool.name)
1155            });
1156        }
1157
1158        // ③ pace meta-tool: exposed ONLY when this run is a round of a paced loop
1159        // (run_spec.loop_round present) — the same conditional-exposure pattern as
1160        // skill/memory/read_result. Pushed after every filter: pacing is kernel-owned
1161        // and must never be narrowed away by skills or capability filters.
1162        if self.run_spec.as_ref().and_then(|r| r.loop_round.as_ref()).is_some() {
1163            tools.push(pace_tool_schema());
1164        }
1165
1166        LoopAction::CallLLM { context, tools }
1167    }
1168
1169    pub fn rollback(&mut self, reason: RollbackReason) {
1170        self.ctx.partitions.history.messages.truncate(self.checkpoint.history_len);
1171        self.ctx.partitions.signals.truncate(self.checkpoint.signals_len);
1172        if let Some(ref state) = self.checkpoint.task_state {
1173            self.ctx.partitions.task_state = state.clone();
1174        }
1175        // Rolled-back turns never reach the boundary sample point; accrue here so the
1176        // disorder they evidence lands in the next completed turn's entropy window.
1177        self.entropy.note_rollback();
1178        self.observations.push(KernelObservation::Rollbacked {
1179            turn: self.turn,
1180            checkpoint_history_len: self.checkpoint.history_len as u32,
1181            reason: Some(reason),
1182        });
1183    }
1184
1185    fn rollback_reason_for_tool_result(&self, result: &ToolResult) -> Option<RollbackReason> {
1186        let tool_name = self.tool_name_for_call(&result.call_id);
1187        let output = super::rollback::tool_result_output_text(result);
1188
1189        if result.is_fatal {
1190            return Some(RollbackReason::FatalToolError {
1191                tool_name,
1192                error: output,
1193            });
1194        }
1195
1196        match result.error_kind {
1197            Some(ToolErrorKind::Fatal) => Some(RollbackReason::FatalToolError {
1198                tool_name,
1199                error: output,
1200            }),
1201            Some(ToolErrorKind::GovernanceDenied) => Some(RollbackReason::GovernanceDenied {
1202                tool_name,
1203                reason: output,
1204            }),
1205            Some(ToolErrorKind::ProviderFailure) => {
1206                Some(RollbackReason::ProviderFailure { error: output })
1207            }
1208            Some(ToolErrorKind::Timeout) => Some(RollbackReason::Timeout),
1209            Some(ToolErrorKind::UserInterrupt) => Some(RollbackReason::UserInterrupt),
1210            Some(ToolErrorKind::Recoverable) | None => None,
1211        }
1212    }
1213
1214    fn tool_name_for_call(&self, call_id: &compact_str::CompactString) -> String {
1215        match &self.phase {
1216            LoopPhase::Act { tool_calls } => tool_calls
1217                .iter()
1218                .find(|call| call.id == *call_id)
1219                .map(|call| call.name.to_string())
1220                .unwrap_or_else(|| call_id.to_string()),
1221            _ => call_id.to_string(),
1222        }
1223    }
1224}
1225
1226#[cfg(test)]
1227#[path = "tests.rs"]
1228mod tests;
1229
1230/// ③ the `pace` meta-tool schema — exposed only on loop-round runs.
1231fn pace_tool_schema() -> crate::types::message::ToolSchema {
1232    crate::types::message::ToolSchema {
1233        name: compact_str::CompactString::new("pace"),
1234        description: "End this round and decide what happens next: continue immediately, \
1235sleep then run another round, or stop the loop. Call this when the round's work is done."
1236            .to_string(),
1237        parameters: serde_json::json!({
1238            "type": "object",
1239            "properties": {
1240                "next": { "type": "string", "enum": ["continue", "sleep", "stop"] },
1241                "delay_ms": { "type": "integer", "minimum": 0 },
1242                "reason": { "type": "string" }
1243            },
1244            "required": ["next", "reason"]
1245        }),
1246    }
1247}