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