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