Skip to main content

deepstrike_core/scheduler/state_machine/
mod.rs

1use std::collections::HashMap;
2
3use super::milestone::MilestoneTracker;
4use super::policy::LoopPolicy;
5use super::tcb::{ScheduleDecision, TaskState, TaskTable, Tcb, WaitReason};
6use crate::AgentRunSpec;
7use crate::context::manager::ContextManager;
8use crate::governance::pipeline::GovernancePipeline;
9use crate::signals::router::SignalRouter;
10use crate::types::result::SubAgentResult;
11use crate::context::renderer::RenderedContext;
12// `pub use` so external integration tests that glob `state_machine::*` resolve the observation
13// type here — exactly as they did for the former `pub enum LoopObservation` this replaced.
14pub use crate::runtime::kernel::KernelObservation;
15use crate::runtime::session::RollbackReason;
16use crate::types::message::{
17    Content, ContentPart, Message, ToolCall, ToolErrorKind, ToolResult, ToolSchema,
18};
19use crate::types::milestone::MilestoneCheckResult;
20use crate::types::result::{LoopResult, TerminationReason};
21use crate::types::signal::RuntimeSignal;
22use crate::types::task::RuntimeTask;
23
24/// Compact digest of a tool call's arguments for the recency log (2b). Kept short and CJK-safe — it
25/// only needs to make `same-tool / different-args` calls distinguishable (so a legit loop isn't
26/// flagged as a no-progress repeat) and to read sensibly in the "just did: …" footer. Empty for
27/// no-arg / `{}` calls. Lives in the volatile State turn, so length here never churns the cache.
28fn compact_tool_args(args: &serde_json::Value) -> String {
29    if args.is_null() {
30        return String::new();
31    }
32    let s = args.to_string();
33    if s == "{}" {
34        return String::new();
35    }
36    const MAX: usize = 48;
37    if s.chars().count() <= MAX {
38        s
39    } else {
40        format!("{}…", s.chars().take(MAX).collect::<String>())
41    }
42}
43
44/// The *turn step* of the L* execution loop (M1d).
45///
46/// Schedulability (`Ready/Running/Blocked/Suspended/Done`) is no longer carried here — it lives
47/// on the root task's [`TaskState`] in the kernel's `TaskTable`, queried via
48/// [`LoopStateMachine::lifecycle`]. `LoopPhase` is now orthogonal: it only records *which step of a
49/// running turn* the loop is in. When the task is `Ready/Suspended/Done`, the phase value is
50/// inert (left at its last step) and ignored.
51#[derive(Debug, Clone)]
52pub enum LoopPhase {
53    Reason,
54    Act { tool_calls: Vec<ToolCall> },
55    Observe { results: Vec<ToolResult> },
56    Delta { pressure: f64 },
57}
58
59/// Why the loop entered `Suspended` state.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SuspendReason {
62    /// Governance `AskUser` — waiting for SDK to resolve human approval.
63    AskUser,
64    /// Sub-agent spawned — waiting for sub-agent to complete.
65    SubAgentAwait,
66    /// Externally requested suspension.
67    External,
68}
69
70/// What the loop is blocked waiting for.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum BlockReason {
73    /// Awaiting a tool's continuation (tool suspend pattern).
74    ToolSuspend,
75    /// Awaiting milestone evaluation result.
76    MilestoneAwait,
77}
78
79/// Events fed into the state machine from the SDK layer.
80#[derive(Debug)]
81pub enum LoopEvent {
82    Start {
83        task: RuntimeTask,
84    },
85    LLMResponse {
86        message: Message,
87    },
88    ToolResults {
89        results: Vec<ToolResult>,
90    },
91    /// Inbound signal from SignalRouter — Critical/High urgency may interrupt.
92    Signal {
93        signal: RuntimeSignal,
94    },
95    /// Result of evaluating the current milestone phase's criteria.
96    /// Feed this back after handling `LoopAction::EvaluateMilestone`.
97    MilestoneResult {
98        result: MilestoneCheckResult,
99    },
100    /// Sub-agent run completed — result is injected into the loop as context.
101    SubAgentCompleted {
102        result: SubAgentResult,
103    },
104    Timeout,
105}
106
107/// Actions the state machine outputs — SDK layer executes the I/O.
108#[derive(Debug)]
109pub enum LoopAction {
110    /// Structured context ready for a provider call.
111    /// `context.system_text` → provider system param.
112    /// `context.turns`       → provider messages array (strictly alternating).
113    /// `tools`               → tool schemas (skill / memory / knowledge / user tools).
114    CallLLM {
115        context: RenderedContext,
116        tools: Vec<ToolSchema>,
117    },
118    ExecuteTools {
119        calls: Vec<ToolCall>,
120    },
121    Done {
122        result: LoopResult,
123    },
124    /// Kernel requests the SDK to evaluate the current milestone phase.
125    ///
126    /// The SDK should assess `criteria` against the agent's output using the
127    /// specified `verifier`, then feed back `LoopEvent::MilestoneResult { result }`.
128    EvaluateMilestone {
129        phase_id: String,
130        criteria: Vec<String>,
131        verifier: Option<crate::types::milestone::MilestoneVerifier>,
132        required_evidence: Vec<String>,
133    },
134    /// Kernel is suspended — SDK must resolve (e.g. human approval) and feed `Resume`.
135    AwaitingResume,
136}
137
138/// Payload held while the loop is in `Suspended`.
139#[derive(Debug, Clone)]
140pub(super) enum SuspendState {
141    /// Governance AskUser — awaiting `Resume { approved_calls, denied_calls }`.
142    AskUser {
143        calls: Vec<ToolCall>,
144        gated_reasons: HashMap<String, String>,
145    },
146    /// Sub-agent spawn — awaiting `SubAgentCompleted` for each listed agent id.
147    SubAgentAwait {
148        agent_ids: Vec<String>,
149    },
150}
151
152pub(super) enum GateToolOutcome {
153    Proceed,
154    Blocked(LoopAction),
155    Suspended,
156}
157
158/// Snapshot of context lengths captured just before each LLM call.
159/// Used internally to restore state on rollback.
160#[derive(Debug, Clone, Default)]
161pub struct TurnCheckpoint {
162    pub history_len: usize,
163    pub signals_len: usize,
164    pub task_state: Option<crate::context::task_state::TaskState>,
165}
166
167/// Pure state machine for the L* execution loop. No I/O — only state transitions.
168///
169/// Internal engine backing [`crate::runtime::KernelRuntime`]. Exposed for in-crate
170/// use and tests; external callers should drive the kernel through `KernelRuntime`.
171#[doc(hidden)]
172pub struct LoopStateMachine {
173    pub phase: LoopPhase,
174    pub turn: u32,
175    pub ctx: ContextManager,
176    pub tools: Vec<ToolSchema>,
177    pub observations: Vec<KernelObservation>,
178    pub(super) policy: LoopPolicy,
179    pub(super) total_tokens: u64,
180    /// L1 (RunGroup): cumulative tokens spent by *other* members of this run's governance domain,
181    /// seeded at boot via `seed_group_budget`. The run-level token cap is enforced against
182    /// `group_tokens_base + total_tokens` so the budget spans the whole group, not one vehicle.
183    /// 0 (default) ⇒ no group (N=1) ⇒ pre-L1 per-kernel behavior (byte-identical).
184    pub(super) group_tokens_base: u64,
185    /// L1 (RunGroup): sub-agents spawned by *other* members of this run's governance domain, seeded
186    /// at boot. `max_total_subagents` is enforced against `group_spawns_base + local spawns`. 0 ⇒ N=1.
187    pub(super) group_spawns_base: u32,
188    /// When set, the next LLM call strips tools to force a text response,
189    /// then terminates with this reason once the response arrives.
190    pub(super) pending_termination: Option<TerminationReason>,
191    /// Reactive context-overflow recovery: consecutive compact-and-retry attempts since the last
192    /// successful provider turn. Bounds the recovery ladder (anti-spiral) and resets to 0 on any
193    /// `LLMResponse`, mirroring the per-turn `hasAttemptedReactiveCompact` reset the SDK runners
194    /// used to own. See `recover_from_provider_error`.
195    pub(super) recovery_attempts: u8,
196    /// Max-output-tokens recovery: consecutive continue-and-retry turns since the model last
197    /// finished a response WITHOUT hitting the output cap. When a turn is cut off at the cap
198    /// (provider `stop_reason` = max_tokens/length) the kernel keeps the partial, nudges the model
199    /// to resume mid-thought, and re-calls — bounded by `MAX_OUTPUT_RECOVERY` (mirrors query.ts's
200    /// MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). Resets to 0 on any non-truncated response.
201    pub(super) output_recovery_attempts: u8,
202    /// Transient carrier for the provider `stop_reason` of the in-flight response, set by the
203    /// kernel ABI just before `feed(LLMResponse)` and taken (cleared) inside it. `None` when the
204    /// SDK/provider doesn't report one (every non-Anthropic provider today ⇒ no-op).
205    pub(super) pending_stop_reason: Option<String>,
206    /// Number of history messages present at session start (after preload_history).
207    /// drain_new_messages() returns the slice from this offset onward.
208    pub(super) session_history_baseline: usize,
209    pub(super) checkpoint: TurnCheckpoint,
210    /// Milestone contract tracker (extracted to reduce state machine bloat).
211    pub(super) milestone: MilestoneTracker,
212    pub run_spec: Option<AgentRunSpec>,
213    /// M1 収口: the single source of truth for schedulability *and* sub-agent lineage. Root is
214    /// task `"root"`; each sub-agent is a child task carrying its `ProcInfo`. The former
215    /// `ProcessTable` is now a derived view over this (`agent_process(es)` rebuild `AgentProcess`
216    /// rows on demand via `AgentProcess::from_tcb`).
217    pub(super) tasks: TaskTable,
218    /// Optional governance pipeline. When set, every tool call proposed by the
219    /// model is evaluated before `ExecuteTools` is emitted. `None` (default)
220    /// skips the gate entirely, preserving the pre-governance behavior.
221    pub(super) governance: Option<GovernancePipeline>,
222    /// Optional resource quota evaluated at the syscall trap (M2). `None` (default) leaves spawn /
223    /// memory syscalls unconditionally allowed, preserving pre-M2 behavior.
224    pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
225    /// Timestamps of recent allowed `WriteMemory` syscalls, for the rolling-window rate limit.
226    /// Only populated when `resource_quota.memory_writes_per_window` is set.
227    pub(super) memory_write_times: Vec<u64>,
228    /// Optional long-term memory policy (`set_memory_policy`). `None` (default) preserves
229    /// pre-policy behavior: default-rule validation + verbatim retrieval `top_k`.
230    pub(super) memory_policy: Option<crate::mm::memory::MemoryPolicy>,
231    /// Optional in-kernel signal router. When set, inbound signals are routed
232    /// through dedup + attention policy + queue here (the kernel owns disposition).
233    /// `None` (default) keeps the legacy hardcoded urgency handling in `feed`.
234    pub(super) signal_router: Option<SignalRouter>,
235    /// Wall-clock timestamp of the first `ProviderResult.now_ms` received.
236    /// Used by the wall-time budget axis in `SchedulerBudget::should_terminate`.
237    pub(super) started_at_ms: Option<u64>,
238    /// Most-recent `now_ms` value from `ProviderResult`, forwarded to the budget check.
239    pub(super) last_now_ms: Option<u64>,
240    /// Tool batch awaiting `Resume` after an AskUser suspend.
241    pub(super) suspend_state: Option<SuspendState>,
242    /// Denied tool results to merge into the next `ToolResults` feed after resume.
243    pub(super) pending_denied_results: Vec<ToolResult>,
244    /// W0: an in-flight workflow DAG, when one is loaded. The kernel spawns its ready nodes as
245    /// gated batches (each through `evaluate_syscall(Syscall::Spawn)`) and advances on
246    /// completions. `None` (default) preserves the single-spawn `spawn_sub_agent` behavior.
247    pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
248}
249
250mod signal;
251mod capability;
252mod gate;
253mod eviction;
254mod process;
255mod workflow;
256mod milestone_exec;
257
258impl LoopStateMachine {
259    fn message_tokens(&self, message: &Message) -> u32 {
260        message
261            .token_count
262            .unwrap_or_else(|| self.ctx.engine.count_message(message))
263    }
264
265    pub fn new(policy: LoopPolicy) -> Self {
266        let mut tasks = TaskTable::new();
267        // M1d: the root task carries the authoritative schedulability lifecycle. It starts
268        // `Ready`; `start()`/`resume_*` flip it to `Running`, suspends set `Suspended`, and
269        // `terminate()` sets `Done`. `phase` is now only the intra-turn step.
270        tasks.insert(Tcb::root("root", policy.clone()));
271        Self {
272            // Inert placeholder step; meaningful only while the root task is `Running`.
273            phase: LoopPhase::Reason,
274            turn: 0,
275            ctx: ContextManager::new(policy.max_tokens),
276            tools: Vec::new(),
277            observations: Vec::new(),
278            policy,
279            total_tokens: 0,
280            group_tokens_base: 0,
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: Some(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        }
302    }
303
304    /// The authoritative schedulability lifecycle of the loop (root task state). Replaces the
305    /// removed `LoopPhase::{Idle,Suspended,Blocked,Terminal}` reads.
306    pub fn lifecycle(&self) -> TaskState {
307        self.tasks.get("root").map(|t| t.state).unwrap_or(TaskState::Ready)
308    }
309
310    /// The wait reason while suspended/blocked, if any.
311    pub fn wait_reason(&self) -> Option<WaitReason> {
312        self.tasks.get("root").and_then(|t| t.wait.clone())
313    }
314
315    /// Whether the loop has terminated.
316    pub fn is_terminal(&self) -> bool {
317        matches!(self.lifecycle(), TaskState::Done(_))
318    }
319
320    /// Whether the loop is suspended awaiting external resolution.
321    pub fn is_suspended(&self) -> bool {
322        matches!(self.lifecycle(), TaskState::Suspended)
323    }
324
325    /// Set the root task's lifecycle (and wait reason). Single mutation point for schedulability.
326    fn set_lifecycle(&mut self, state: TaskState, wait: Option<WaitReason>) {
327        if let Some(root) = self.tasks.get_mut("root") {
328            root.state = state;
329            root.wait = wait;
330        } else {
331            let mut root = Tcb::root("root", self.policy.clone());
332            root.state = state;
333            root.wait = wait;
334            self.tasks.insert(root);
335        }
336    }
337
338    /// Build a transient root [`Tcb`] mirroring the current scheduling facts (budget counters,
339    /// wall-clock anchors, lifecycle). M1b uses this to run the pure `schedule()` spine in
340    /// parallel with the legacy budget path; later milestones promote it to the live task row.
341    fn root_tcb(&self) -> Tcb {
342        let mut tcb = Tcb::root("root", self.policy.clone());
343        tcb.budget.turns = self.turn;
344        // L1: the token-budget axis is evaluated against the whole governance domain's cumulative
345        // spend (this vehicle's `total_tokens` plus other members' `group_tokens_base`).
346        tcb.budget.total_tokens = self.total_tokens.saturating_add(self.group_tokens_base);
347        tcb.budget.started_at_ms = self.started_at_ms;
348        tcb.state = self.lifecycle();
349        tcb
350    }
351
352    /// Adjust the wall-clock budget axis at runtime.
353    pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
354        self.policy.max_wall_ms = max_wall_ms;
355    }
356
357    /// Install a governance pipeline. Once set, all model-proposed tool calls
358    /// are evaluated before execution. Denied/rate-limited calls roll the turn
359    /// back (reusing the `GovernanceDenied` path); `AskUser` calls surface a
360    /// `ToolGated` observation for the SDK to enforce.
361    pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
362        self.governance = Some(pipeline);
363    }
364
365    /// Install resource quotas (M2). Once set, `Spawn` and `WriteMemory` syscalls are bounded by
366    /// the quota at the trap. Not setting it (the default) leaves them unconditionally allowed.
367    pub fn set_resource_quota(&mut self, quota: crate::governance::quota::ResourceQuota) {
368        self.resource_quota = Some(quota);
369    }
370
371    /// L1 (RunGroup): seed the cumulative tokens already spent by other members of this run's
372    /// governance domain. The run-level token cap is then enforced against the group total. Seeding
373    /// 0 (the default) preserves pre-L1 per-vehicle behavior.
374    pub fn seed_group_budget(&mut self, tokens_spent: u64) {
375        self.group_tokens_base = tokens_spent;
376    }
377
378    /// L1 (RunGroup): seed the sub-agents already spawned by other members of this run's governance
379    /// domain. `max_total_subagents` is then enforced against the group total. 0 ⇒ pre-L1 behavior.
380    pub fn seed_group_spawns(&mut self, subagents_spawned: u32) {
381        self.group_spawns_base = subagents_spawned;
382    }
383
384    /// L1: this vehicle's cumulative sub-agent spawns this run — every child task ever registered in
385    /// the `TaskTable` (running + completed), distinct from the *instantaneous* running count. Used
386    /// for the cumulative spawn quota and read back by the SDK to charge the group ledger at run end.
387    pub fn local_subagents_spawned(&self) -> u32 {
388        self.tasks.all().iter().filter(|t| t.proc.is_some()).count() as u32
389    }
390
391    /// Install the long-term memory policy (`set_memory_policy`). Once set it gates `write_memory`
392    /// validation and bounds `query_memory` retrieval breadth. Not setting it (the default)
393    /// preserves pre-policy behavior.
394    pub fn set_memory_policy(&mut self, policy: crate::mm::memory::MemoryPolicy) {
395        self.memory_policy = Some(policy);
396    }
397
398    /// The installed memory policy, if any. `None` means default-rule validation + verbatim top_k.
399    pub fn memory_policy(&self) -> Option<&crate::mm::memory::MemoryPolicy> {
400        self.memory_policy.as_ref()
401    }
402
403    /// Feed the current wall-clock time (ms) to scheduler/governance budget axes.
404    pub fn set_observed_time(&mut self, now_ms: u64) {
405        if self.started_at_ms.is_none() {
406            self.started_at_ms = Some(now_ms);
407        }
408        self.last_now_ms = Some(now_ms);
409        if let Some(pipeline) = self.governance.as_mut() {
410            pipeline.set_time(now_ms);
411        }
412    }
413
414    /// Stash the in-flight response's provider `stop_reason` so `feed(LLMResponse)` can detect an
415    /// output-cap truncation. Set by the kernel ABI right before feeding the result; `None` clears it.
416    pub fn set_pending_stop_reason(&mut self, stop_reason: Option<String>) {
417        self.pending_stop_reason = stop_reason;
418    }
419
420    /// Pre-populate the history partition with messages from a prior session.
421    ///
422    /// Call **before** `start()` when resuming a conversation. Sets the baseline
423    /// so `drain_new_messages()` returns only the messages from the current run.
424    pub fn preload_history(&mut self, messages: Vec<Message>) {
425        for msg in messages {
426            let tokens = self.message_tokens(&msg);
427            self.ctx.push_history(msg, tokens);
428        }
429        self.session_history_baseline = self.ctx.partitions.history.messages.len();
430    }
431
432    /// Continue from preloaded history without appending a new user turn.
433    /// Use after `preload_history` when recovering a session that ended mid-run.
434    ///
435    /// If the last assistant turn has tool calls without matching tool results,
436    /// resumes with `ExecuteTools` instead of calling the LLM again.
437    pub fn resume_after_preload(&mut self) -> LoopAction {
438        self.observations.clear();
439        let calls = crate::runtime::repair::pending_tool_calls_from_messages(
440            &self.ctx.partitions.history.messages,
441        );
442        if !calls.is_empty() {
443            self.emit_page_in_requested(&calls);
444            self.phase = LoopPhase::Act {
445                tool_calls: calls.clone(),
446            };
447            self.set_lifecycle(TaskState::Running, None);
448            return LoopAction::ExecuteTools { calls };
449        }
450        self.phase = LoopPhase::Reason;
451        self.emit_call_llm()
452    }
453
454    /// Return all messages added to history during the current run
455    /// (since the last `preload_history` call or since construction).
456    ///
457    /// Call after `LoopAction::Done` to get the complete turn transcript
458    /// for persistence to a SessionStore.
459    pub fn drain_new_messages(&self) -> Vec<Message> {
460        let history = &self.ctx.partitions.history.messages;
461        let start = self.session_history_baseline.min(history.len());
462        history[start..].to_vec()
463    }
464
465    pub fn start(&mut self, task: RuntimeTask) -> LoopAction {
466        self.observations.clear();
467        self.ctx.init_task(task.goal.clone(), task.criteria.clone());
468
469        let user_msg = "Proceed with the task described in [TASK STATE].".to_string();
470
471        // User message goes into history so it appears at the correct chronological
472        // position: [prior turns...] → [current user message] — LLM reads left-to-right
473        // and responds to the last message. working is reserved for runtime signals only.
474        // Estimate tokens (1 token ≈ 4 chars) with a minimum of 1 so the renderer
475        // does not skip this message (it skips zero-token entries).
476        let user_tokens = self.ctx.engine.count(&user_msg).max(1);
477        self.ctx.push_history(Message::user(user_msg), user_tokens);
478        self.phase = LoopPhase::Reason;
479        // Root task (seeded `Ready` in `new()`) becomes `Running`; `emit_call_llm` sets it.
480        self.emit_call_llm()
481    }
482
483    pub fn feed(&mut self, event: LoopEvent) -> LoopAction {
484        self.observations.clear();
485        self.sweep_expired_leases();
486
487        match event {
488            LoopEvent::Start { task } => self.start(task),
489
490            LoopEvent::LLMResponse { message } => {
491                // A response arrived ⇒ the prompt fit ⇒ the overflow recovery ladder is reset.
492                self.recovery_attempts = 0;
493                let tokens = self.message_tokens(&message);
494                self.total_tokens += tokens as u64;
495
496                // Max-output-tokens recovery (mirrors query.ts): a response cut off at the output
497                // cap reports stop_reason = max_tokens (Anthropic) / length (OpenAI). A clean finish
498                // resets the ladder.
499                const MAX_OUTPUT_RECOVERY: u8 = 3;
500                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.";
501                let truncated = matches!(
502                    self.pending_stop_reason.take().as_deref(),
503                    Some("max_tokens") | Some("length"),
504                );
505                if !truncated {
506                    self.output_recovery_attempts = 0;
507                }
508
509                if let Some(reason) = self.pending_termination.take() {
510                    return self.terminate(reason, Some(message));
511                }
512
513                if message.tool_calls.is_empty() {
514                    // The model was cut off at the output cap with no tool call. Keep the partial,
515                    // nudge it to resume mid-thought, and re-call — instead of mistaking the
516                    // truncation for a finished turn. Bounded by MAX_OUTPUT_RECOVERY; once exhausted
517                    // the partial stands and the turn terminates normally below. (A truncated
518                    // *tool-call* turn isn't handled here — it falls through to tool execution.)
519                    if truncated && self.output_recovery_attempts < MAX_OUTPUT_RECOVERY {
520                        self.output_recovery_attempts += 1;
521                        self.ctx.push_history(message, tokens);
522                        self.ctx.push_signal(OUTPUT_TRUNCATION_NUDGE.to_string());
523                        self.phase = LoopPhase::Reason;
524                        return self.emit_call_llm();
525                    }
526                    // When a milestone contract is active and not yet complete,
527                    // request evaluation instead of terminating.
528                    if !self.milestone.is_complete() {
529                        let phase_id = self.milestone.current_phase_id().unwrap_or("").to_string();
530                        let criteria = self.milestone.current_criteria().to_vec();
531                        let (verifier, required_evidence) = self
532                            .milestone
533                            .contract
534                            .as_ref()
535                            .and_then(|c| c.phases.get(self.milestone.current_phase))
536                            .map(|p| (p.verifier.clone(), p.required_evidence.clone()))
537                            .unwrap_or_default();
538                        // `tokens` was already computed for this message above.
539                        self.ctx.push_history(message, tokens);
540                        return LoopAction::EvaluateMilestone {
541                            phase_id,
542                            criteria,
543                            verifier,
544                            required_evidence,
545                        };
546                    }
547                    return self.terminate(TerminationReason::Completed, Some(message));
548                }
549
550                let calls = message.tool_calls.clone();
551                self.ctx.push_history(message, tokens);
552
553                // ━━ 记录活动时间(Layer 3时间衰减使用)
554                if let Some(now_ms) = self.last_now_ms {
555                    self.ctx.record_activity(now_ms);
556                }
557
558                // 2b: record this turn's tool activity into the task-state recency log (meta-tools
559                // filtered inside). The State-turn footer renders it as "just did: …" + a forward
560                // nudge / STOP, so progress is kernel-derived and never depends on the model
561                // remembering to call `update_plan`. Tool *names* live only on the request (results
562                // carry call_id only), so this is the turn to capture them.
563                //
564                // Capture name AND a compact arg digest: the no-progress STOP keys on whether the
565                // SAME call repeats, and a legit loop (same tool, DIFFERENT args — e.g. processing 20
566                // items) is real progress, not a stall. Keying on the name alone false-positives those
567                // loops; including args distinguishes "step(n=1), step(n=2)…" from a true repeat.
568                let action_sigs: Vec<(String, String)> = calls
569                    .iter()
570                    .map(|c| (c.name.to_string(), compact_tool_args(&c.arguments)))
571                    .collect();
572                self.ctx.note_tool_actions(&action_sigs);
573
574                match self.gate_tool_calls(&calls) {
575                    GateToolOutcome::Blocked(action) => return action,
576                    GateToolOutcome::Suspended => return LoopAction::AwaitingResume,
577                    GateToolOutcome::Proceed => {}
578                }
579                self.emit_page_in_requested(&calls);
580                self.phase = LoopPhase::Act {
581                    tool_calls: calls.clone(),
582                };
583                self.set_lifecycle(TaskState::Running, None);
584                LoopAction::ExecuteTools { calls }
585            }
586
587            LoopEvent::ToolResults { mut results } => {
588                if !self.pending_denied_results.is_empty() {
589                    results.append(&mut self.pending_denied_results);
590                }
591                if let Some(reason) = results
592                    .iter()
593                    .find_map(|result| self.rollback_reason_for_tool_result(result))
594                {
595                    let note = Message::user(super::rollback::build_rollback_note(
596                        &reason,
597                        self.ctx.config.verbose_control_notes,
598                    ));
599                    self.rollback(reason);
600                    self.ctx.push_signal(note.content.as_text().unwrap_or_default().to_string());
601                    self.phase = LoopPhase::Reason;
602                    return self.emit_call_llm();
603                }
604                // Non-fatal errors are committed to history so the LLM can
605                // see them and self-correct without losing turn state.
606
607                for r in &results {
608                    self.total_tokens += r.token_count.unwrap_or(0) as u64;
609                    // Preserve Content::Parts (structured / multimodal tool output).
610                    // Parts are serialised to JSON so the text can be restored faithfully.
611                    let raw_output = match &r.output {
612                        Content::Text(s) => s.clone(),
613                        Content::Parts(parts) => serde_json::to_string(parts).unwrap_or_default(),
614                    };
615                    // Layer 1 spool: oversized results keep only a preview in context; the kernel
616                    // emits `LargeResultSpooled` so the SDK persists the full output it still holds.
617                    let (output, spooled) = match crate::mm::plan_spool(
618                        &raw_output,
619                        self.ctx.config.spool_threshold_bytes,
620                        self.ctx.config.spool_preview_bytes,
621                    ) {
622                        Some(decision) => {
623                            self.observations.push(KernelObservation::LargeResultSpooled {
624                                turn: self.turn,
625                                call_id: r.call_id.to_string(),
626                                // ToolResult carries no tool name; the SDK maps call_id -> tool.
627                                tool: String::new(),
628                                original_size: decision.original_size,
629                                preview_size: decision.preview.len() as u32,
630                                spool_ref: None,
631                            });
632                            (decision.preview, true)
633                        }
634                        None => (raw_output, false),
635                    };
636                    let parts = vec![ContentPart::ToolResult {
637                        call_id: r.call_id.clone(),
638                        output,
639                        is_error: r.is_error,
640                    }];
641                    let tool_msg = Message::tool(parts);
642                    // When spooled, `r.token_count` reflects the full output — recount the preview.
643                    let tokens = if spooled {
644                        self.ctx.engine.count_message(&tool_msg)
645                    } else {
646                        r.token_count
647                            .unwrap_or_else(|| self.ctx.engine.count_message(&tool_msg))
648                    };
649                    self.ctx.push_history(tool_msg, tokens);
650                    // Layer 1: a spooled result's handle is marked SpooledOut (its full output now
651                    // lives on disk via the SDK); the SDK maps call_id -> the persisted ref.
652                    if spooled {
653                        self.ctx.mark_spooled(&r.call_id, r.call_id.to_string());
654                    }
655                }
656                self.turn += 1;
657
658                // M1 收口: the pure `schedule()` is now the single budget decision point.
659                // It evaluates the same three axes (turn/token/wall) via `BudgetLedger`, which
660                // delegates to `SchedulerBudget::should_terminate` internally — one source of truth.
661                if let ScheduleDecision::Terminate { reason: term, .. } =
662                    super::tcb::schedule(&self.root_tcb(), self.last_now_ms)
663                {
664                    let budget = match term {
665                        TerminationReason::MaxTurns => "max_turns",
666                        TerminationReason::Timeout => "wall_time",
667                        _ => "token_budget",
668                    };
669                    self.observations.push(KernelObservation::BudgetExceeded {
670                        turn: self.turn,
671                        budget: budget.to_string(),
672                    });
673                    self.pending_termination = Some(term);
674                    self.phase = LoopPhase::Reason;
675                    return self.emit_call_llm();
676                }
677
678                // ━━ Eviction checkpoint (M3): one decision model (`plan_eviction`), one
679                // execution funnel (`execute_eviction_op`). Layer 3 (idle/time-decay) must run
680                // before the rho recommendation is read, since it mutates token usage — so the
681                // plan is built in that interleaved order and the ops are executed in plan order.
682                let idle_decay = self
683                    .last_now_ms
684                    .is_some_and(|now_ms| self.ctx.should_time_decay_compact(now_ms));
685                if idle_decay {
686                    self.execute_eviction_op(&crate::mm::EvictionOp::TimeDecayMicro);
687                }
688
689                // Layer 4 read-time projection: recompute handle residency on the post-time-decay rho.
690                self.ctx.recompute_handle_residency();
691                self.phase = LoopPhase::Delta {
692                    pressure: self.ctx.rho(),
693                };
694
695                // Layers 2/4/5: execute the pressure-driven ops from the plan (skip TimeDecayMicro
696                // if already executed). The plan carries specific ops stamped with real config-derived
697                // params (W1-1 収口 — no magic-number placeholders), not the umbrella `Pressure` wrapper.
698                let (target_tokens, preserve_turns) = self.ctx.plan_compaction_params();
699                let plan =
700                    crate::mm::plan_eviction(self.ctx.should_compress(), idle_decay, target_tokens, preserve_turns);
701                // `idle_decay` ⇒ the plan carries a `TimeDecayMicro` (so the skip-on-already-executed
702                // below is meaningful). The converse does NOT hold: a pressure-driven `MicroCompact`
703                // also emits `TimeDecayMicro` independent of `idle_decay` (W1 unified planner), so we
704                // assert the implication, not equality.
705                debug_assert!(!idle_decay || plan.has_time_decay());
706                for op in &plan.ops {
707                    // Skip TimeDecayMicro if we already executed it (prevents double-execution).
708                    if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
709                        continue;
710                    }
711                    self.execute_eviction_op(op);
712                }
713
714                // Renewal: when compression alone cannot recover enough headroom,
715                // start a new sprint — carry forward system + memory + last N history turns.
716                if self.ctx.should_renew() {
717                    self.ctx.renew();
718                    // A new sprint is a session boundary for signal identity: clear the dedup set so
719                    // it cannot grow unbounded across a long run, and so a signal seen in a prior
720                    // sprint may legitimately re-fire in the new one.
721                    if let Some(router) = self.signal_router.as_mut() {
722                        router.clear_dedup();
723                    }
724                    self.observations.push(KernelObservation::Renewed {
725                        sprint: self.ctx.sprint,
726                    });
727                }
728
729                // Turn boundary: drain any kernel-queued signals into context so they
730                // are seen on the next reasoning turn (ready queue → running).
731                self.drain_queued_signals();
732
733                self.phase = LoopPhase::Reason;
734                self.emit_call_llm()
735            }
736
737            LoopEvent::Signal { signal } => {
738                // `feed` always returns an action; non-actionable dispositions
739                // (queue/observe/ignore) fall back to a plain provider call here.
740                // The kernel-routed path (`dispatch_signal`) is driven via the ABI.
741                self.dispatch_signal(signal)
742                    .unwrap_or_else(|| self.emit_call_llm())
743            }
744
745            LoopEvent::MilestoneResult { result } => self.handle_milestone_result(result),
746
747            LoopEvent::SubAgentCompleted { result } => self.handle_sub_agent_completed(result),
748
749            LoopEvent::Timeout => {
750                let reason = RollbackReason::Timeout;
751                let note = Message::user(super::rollback::build_rollback_note(
752                    &reason,
753                    self.ctx.config.verbose_control_notes,
754                ));
755                self.rollback(reason);
756                self.ctx.push_signal(note.content.as_text().unwrap_or_default().to_string());
757                self.phase = LoopPhase::Reason;
758                self.emit_call_llm()
759            }
760        }
761    }
762
763
764    /// Drain observations emitted during the last `start`/`feed` call.
765    pub fn take_observations(&mut self) -> Vec<KernelObservation> {
766        std::mem::take(&mut self.observations)
767    }
768
769    /// W2-2: Create a snapshot of the current kernel state for crash recovery or migration.
770    pub fn snapshot(&self) -> crate::runtime::snapshot::KernelSnapshot {
771        use crate::runtime::snapshot::{ContextSnapshot, KernelSnapshot};
772        let context = ContextSnapshot::from_context(&self.ctx);
773        KernelSnapshot::from_state(
774            self.turn,
775            self.total_tokens,
776            &self.tasks,
777            &context,
778            self.run_spec.as_ref(),
779        )
780    }
781
782    /// W2-2: Restore kernel state from a snapshot. Returns a new LoopStateMachine rebuilt from the snapshot.
783    /// Note: This is a foundational restore - some state (governance, milestone, signal router dedup) is
784    /// recreated from policy/config rather than serialized, following the principle that strategy is data.
785    pub fn restore(snap: &crate::runtime::snapshot::KernelSnapshot) -> Self {
786        use crate::signals::router::SignalRouter;
787
788        // Reconstruct policy from the max_tokens in snapshot
789        let policy = crate::scheduler::policy::LoopPolicy {
790            max_tokens: snap.context.max_tokens,
791            ..Default::default()
792        };
793
794        // Rebuild TaskTable from snapshot TCBs
795        let mut tasks = TaskTable::new();
796        for tcb_snap in &snap.tasks {
797            if let Some(tcb) = snap.restore_tcb(tcb_snap) {
798                tasks.insert(tcb);
799            }
800        }
801
802        // Rebuild context partitions from snapshot
803        let mut ctx = ContextManager::new(snap.context.max_tokens);
804        ctx.sprint = snap.context.sprint;
805
806        // Restore messages
807        for msg in &snap.context.system_messages {
808            let tokens = ctx.engine.count_message(msg);
809            ctx.partitions.system.push(msg.clone(), tokens);
810        }
811        for msg in &snap.context.knowledge_messages {
812            let tokens = ctx.engine.count_message(msg);
813            ctx.partitions.knowledge.push(msg.clone(), tokens);
814        }
815        for msg in &snap.context.history_messages {
816            let tokens = ctx.engine.count_message(msg);
817            ctx.partitions.history.push(msg.clone(), tokens);
818        }
819
820        // Restore task state
821        if let Some(goal) = &snap.context.task_goal {
822            ctx.partitions.task_state.goal = goal.clone();
823        }
824        if let Some(plan_json) = &snap.context.task_plan {
825            if let Ok(plan_steps) = serde_json::from_str::<Vec<crate::context::task_state::PlanStep>>(plan_json) {
826                ctx.partitions.task_state.plan = plan_steps;
827            }
828        }
829        if let Some(progress) = &snap.context.task_progress {
830            ctx.partitions.task_state.progress = progress.clone();
831        }
832        ctx.partitions.task_state.directives = snap.context.task_directives.clone();
833
834        // Restore signals
835        ctx.partitions.signals = snap.context.signals.clone();
836
837        Self {
838            phase: LoopPhase::Reason,
839            turn: snap.turn,
840            ctx,
841            tools: Vec::new(),  // Tools are rebuilt from capabilities on next LLM call
842            observations: Vec::new(),
843            policy,
844            total_tokens: snap.total_tokens,
845            // Re-seeded from the replayed `ConfigureRun` (strategy is data, not serialized state).
846            group_tokens_base: 0,
847            group_spawns_base: 0,
848            pending_termination: None,
849            recovery_attempts: 0,
850            output_recovery_attempts: 0,
851            pending_stop_reason: None,
852            session_history_baseline: 0,
853            checkpoint: TurnCheckpoint::default(),
854            milestone: crate::scheduler::milestone::MilestoneTracker::new(),
855            run_spec: snap.run_spec(),
856            tasks,
857            governance: None,  // Governance is policy data, recreated from config
858            resource_quota: None,
859            memory_write_times: Vec::new(),
860            memory_policy: None,
861            signal_router: Some(SignalRouter::new(64)),  // Dedup cleared on restore
862            started_at_ms: None,
863            last_now_ms: None,
864            suspend_state: None,
865            pending_denied_results: Vec::new(),
866            workflow: None,
867        }
868    }
869
870    fn terminate(
871        &mut self,
872        termination: TerminationReason,
873        final_message: Option<Message>,
874    ) -> LoopAction {
875        // Commit the final response into history so subsequent session restores
876        // include the complete transcript: user → [tool turns] → final assistant.
877        if let Some(ref msg) = final_message {
878            let tokens = self.message_tokens(msg);
879            self.ctx.push_history(msg.clone(), tokens);
880        }
881        let result = LoopResult {
882            termination,
883            final_message,
884            turns_used: self.turn,
885            total_tokens_used: self.total_tokens,
886            loop_continue: None,
887            classify_branch: None,
888            tournament_winner: None,
889        };
890        self.set_lifecycle(TaskState::Done(termination), None);
891        LoopAction::Done { result }
892    }
893
894    /// Build the `CallLLM` action with a structured `RenderedContext`.
895    /// Meta-tools (skill / memory / knowledge) are appended to the tool list
896    /// when configured. When `pending_termination` is set, tools are stripped
897    /// to force a plain-text response before the loop terminates.
898    fn emit_call_llm(&mut self) -> LoopAction {
899        // Calling the provider is definitionally "running" — the single funnel for entering the
900        // Running lifecycle (covers start, resume, signal-driven turns, budget final-call).
901        self.set_lifecycle(TaskState::Running, None);
902        self.checkpoint.history_len = self.ctx.partitions.history.messages.len();
903        self.checkpoint.signals_len = self.ctx.partitions.signals.len();
904        self.checkpoint.task_state = Some(self.ctx.partitions.task_state.clone());
905        self.observations.push(KernelObservation::CheckpointTaken {
906            turn: self.turn,
907            history_len: self.checkpoint.history_len as u32,
908        });
909
910        let context = self.ctx.render();
911        if self.pending_termination.is_some() {
912            return LoopAction::CallLLM {
913                context,
914                tools: Vec::new(),
915            };
916        }
917        let mut tools = self.tools.clone();
918        tools.extend(self.ctx.meta_tool_schemas());
919
920        if let Some(ref spec) = self.run_spec {
921            use crate::types::capability::CapabilityKind;
922            tools.retain(|tool| {
923                let kind = match tool.name.as_str() {
924                    "skill" => CapabilityKind::Skill,
925                    "memory" => CapabilityKind::Memory,
926                    "knowledge" => CapabilityKind::Knowledge,
927                    _ => CapabilityKind::Tool,
928                };
929                let desc = crate::types::capability::CapabilityDescriptor::marker(
930                    kind,
931                    tool.name.clone(),
932                    &tool.description,
933                );
934                spec.capability_filter.allows(&desc)
935            });
936        }
937
938        // P1-B epoch skill gating (applied *after* the run-level filter ③, so A is the outer bound
939        // and B narrows within it — D6). When skills are active and declare tools, expose only
940        // `meta-tools ∪ stable-core ∪ ⋃(active skills' allowed_tools)`. `None` ⇒ no active/declared
941        // skill ⇒ no narrowing (D3, errs-open). Meta-tools are always exempt (D5) so the model can
942        // still load more skills. Byte-stable within an epoch: the set only changes on activation.
943        if let Some(allowed) = self.ctx.active_skill_tool_filter() {
944            let stable = &self.ctx.stable_core_tools;
945            tools.retain(|tool| {
946                matches!(tool.name.as_str(), "skill" | "memory" | "knowledge" | "update_plan")
947                    || stable.contains(&tool.name)
948                    || allowed.contains(&tool.name)
949            });
950        }
951
952        LoopAction::CallLLM { context, tools }
953    }
954
955    pub fn rollback(&mut self, reason: RollbackReason) {
956        self.ctx.partitions.history.messages.truncate(self.checkpoint.history_len);
957        self.ctx.partitions.signals.truncate(self.checkpoint.signals_len);
958        if let Some(ref state) = self.checkpoint.task_state {
959            self.ctx.partitions.task_state = state.clone();
960        }
961        self.observations.push(KernelObservation::Rollbacked {
962            turn: self.turn,
963            checkpoint_history_len: self.checkpoint.history_len as u32,
964            reason: Some(reason),
965        });
966    }
967
968    fn rollback_reason_for_tool_result(&self, result: &ToolResult) -> Option<RollbackReason> {
969        let tool_name = self.tool_name_for_call(&result.call_id);
970        let output = super::rollback::tool_result_output_text(result);
971
972        if result.is_fatal {
973            return Some(RollbackReason::FatalToolError {
974                tool_name,
975                error: output,
976            });
977        }
978
979        match result.error_kind {
980            Some(ToolErrorKind::Fatal) => Some(RollbackReason::FatalToolError {
981                tool_name,
982                error: output,
983            }),
984            Some(ToolErrorKind::GovernanceDenied) => Some(RollbackReason::GovernanceDenied {
985                tool_name,
986                reason: output,
987            }),
988            Some(ToolErrorKind::ProviderFailure) => {
989                Some(RollbackReason::ProviderFailure { error: output })
990            }
991            Some(ToolErrorKind::Timeout) => Some(RollbackReason::Timeout),
992            Some(ToolErrorKind::UserInterrupt) => Some(RollbackReason::UserInterrupt),
993            Some(ToolErrorKind::Recoverable) | None => None,
994        }
995    }
996
997    fn tool_name_for_call(&self, call_id: &compact_str::CompactString) -> String {
998        match &self.phase {
999            LoopPhase::Act { tool_calls } => tool_calls
1000                .iter()
1001                .find(|call| call.id == *call_id)
1002                .map(|call| call.name.to_string())
1003                .unwrap_or_else(|| call_id.to_string()),
1004            _ => call_id.to_string(),
1005        }
1006    }
1007}
1008
1009#[cfg(test)]
1010#[path = "tests.rs"]
1011mod tests;