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