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::{DurableWaitSet, TaskLifecycle, TaskTable, Tcb, WaitSet};
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 ArchivePageOut {
134 turn: u32,
135 action: crate::runtime::kernel::KernelPressureAction,
136 summary: Option<String>,
137 archived: Vec<Message>,
138 tier: String,
139 },
140 Done {
141 result: LoopResult,
142 },
143 /// Kernel requests the SDK to evaluate the current milestone phase.
144 ///
145 /// The SDK should assess `criteria` against the agent's output using the
146 /// specified `verifier`, then feed back `LoopEvent::MilestoneResult { result }`.
147 EvaluateMilestone {
148 phase_id: String,
149 criteria: Vec<String>,
150 verifier: Option<crate::types::milestone::MilestoneVerifier>,
151 required_evidence: Vec<String>,
152 },
153 /// Kernel is suspended awaiting a non-approval internal continuation.
154 AwaitingResume,
155}
156
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct ApprovalRequest {
159 pub call_id: String,
160 pub tool: String,
161 pub arguments: serde_json::Value,
162 pub reason: String,
163}
164
165#[derive(Debug, Clone)]
166pub(super) struct PendingWorkflowSpawn {
167 pub nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
168 pub budget: Option<crate::orchestration::workflow::WorkflowBudget>,
169}
170
171#[derive(Debug, Clone)]
172pub(super) struct PendingPreempt {
173 pub agent_ids: Vec<String>,
174 pub reason: String,
175}
176
177#[derive(Debug, Clone)]
178pub(super) enum PendingHostEffect {
179 ArchivePageOut {
180 turn: u32,
181 action: crate::runtime::kernel::KernelPressureAction,
182 summary: Option<String>,
183 archived: Vec<Message>,
184 tier: String,
185 },
186}
187
188impl PendingHostEffect {
189 fn action(&self) -> LoopAction {
190 match self {
191 Self::ArchivePageOut {
192 turn,
193 action,
194 summary,
195 archived,
196 tier,
197 } => LoopAction::ArchivePageOut {
198 turn: *turn,
199 action: *action,
200 summary: summary.clone(),
201 archived: archived.clone(),
202 tier: tier.clone(),
203 },
204 }
205 }
206}
207
208/// Payload held while the loop is in `Suspended`.
209#[derive(Debug, Clone)]
210pub(super) enum SuspendState {
211 /// Governance AskUser — awaiting a correlated approval result.
212 AskUser {
213 calls: Vec<ToolCall>,
214 gated_reasons: HashMap<String, String>,
215 },
216 /// Sub-agent spawn — awaiting `SubAgentCompleted` for each listed agent id.
217 SubAgentAwait { agent_ids: Vec<String> },
218}
219
220pub(super) enum GateToolOutcome {
221 Proceed,
222 Blocked(LoopAction),
223 ApprovalRequired(Vec<ApprovalRequest>),
224}
225
226/// One P1 syscall the kernel adjudicated itself, and the answer the model reads for it.
227///
228/// A syscall call is never dispatched to a host, but it *is* a tool call the model made, so it
229/// still gets a tool result — the v0.2.42 rule that the model-facing surface stays a training-set
230/// convention. `is_error` is what distinguishes "the kernel did it" from "the kernel refused".
231#[derive(Debug, Clone)]
232pub struct AnsweredCall {
233 pub call_id: CompactString,
234 pub output: String,
235 pub is_error: bool,
236}
237
238/// What a provider turn does when the kernel's own adjudication left nothing for a host to run.
239///
240/// Spec adjudication §5k: a batch of pure control-plane calls (`skill`, `update_plan`) publishes no
241/// effect, so without this the operation would have nothing outstanding and stall.
242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
243pub enum IdleContinuation {
244 /// §5k · nothing is outstanding, so the kernel continues the turn the only way a turn
245 /// continues: it calls the provider again.
246 #[default]
247 CallProvider,
248 /// The adjudication already published kernel-owned work (a memory effect, a spawn round). The
249 /// turn resumes when that work resolves; issuing a provider call now would race it.
250 Await,
251}
252
253/// What the canonical driver adjudicated *before* the provider turn it is about to feed (§7.6).
254///
255/// Staged rather than passed as an argument so `feed(LLMResponse)` stays the single place that
256/// decides what a provider turn means. An absent value means there were no pre-turn answers and
257/// the provider is the idle continuation.
258#[derive(Debug, Clone, Default)]
259pub struct AdjudicatedTurn {
260 pub answered_calls: Vec<AnsweredCall>,
261 pub idle_continuation: IdleContinuation,
262}
263
264/// Outcome of the P1 fail-closed exposure gate: the calls that survived, or the committed action
265/// when every call in the batch was denied.
266pub(super) enum ExposureGateOutcome {
267 Proceed(Vec<ToolCall>),
268 Blocked(LoopAction),
269}
270
271/// Snapshot of context lengths captured just before each LLM call.
272/// Used internally to restore state on rollback.
273#[derive(Debug, Clone, Default)]
274pub struct TurnCheckpoint {
275 pub history_len: usize,
276 pub signals_len: usize,
277 pub task_state: Option<crate::context::task_state::TaskState>,
278}
279
280/// Pure state machine for the L* execution loop. No I/O — only state transitions.
281///
282/// Internal engine backing the canonical operation driver. Exposed for in-crate use and tests;
283/// external callers drive it through [`crate::runtime::kernel::wire::CanonicalKernel`].
284#[doc(hidden)]
285pub struct LoopStateMachine {
286 pub phase: LoopPhase,
287 pub turn: u32,
288 pub ctx: ContextManager,
289 pub tools: Vec<ToolSchema>,
290 pub observations: Vec<KernelObservation>,
291 pub(super) policy: SchedulerBudget,
292 pub(super) scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig,
293 pub(super) total_tokens: u64,
294 /// Reservation-backed hard limits for this operation. Shared accounting stays in the host;
295 /// the kernel tracks only this run's local usage.
296 pub(super) budget_grant: Option<crate::runtime::kernel::wire::BudgetGrant>,
297 pub(super) local_rounds_completed: u32,
298 /// spc_005-04: the `BudgetGrant` `evaluate_spawn_quota_inner` just reserved from the parent's
299 /// `child_budget_remaining`, awaiting attachment to the child `Tcb` the caller is about to
300 /// construct. Set only on a successful hierarchical-budget check; taken (and cleared) by the
301 /// spawn call site immediately after, so it never survives past the syscall that produced it.
302 pub(super) pending_budget_grant: Option<crate::scheduler::budget_grant::BudgetGrant>,
303 /// ③ the adjudicated `pace` decision awaiting attachment to this round's LoopResult.
304 pub(super) pending_pace: Option<crate::types::result::PaceDecision>,
305 /// When set, the next LLM call strips tools to force a text response,
306 /// then terminates with this reason once the response arrives.
307 pub(super) pending_termination: Option<TerminationReason>,
308 /// Reactive context-overflow recovery: consecutive compact-and-retry attempts since the last
309 /// successful provider turn. Bounds the recovery ladder (anti-spiral) and resets to 0 on any
310 /// `LLMResponse`, mirroring the per-turn `hasAttemptedReactiveCompact` reset the SDK runners
311 /// used to own. See `recover_from_provider_error`.
312 pub(super) recovery_attempts: u8,
313 pub(crate) provider_recovery_attempt_limit: u8,
314 /// Max-output-tokens recovery: consecutive continue-and-retry turns since the model last
315 /// finished a response WITHOUT hitting the output cap. When a turn is cut off at the cap
316 /// (provider `stop_reason` = max_tokens/length) the kernel keeps the partial, nudges the model
317 /// to resume mid-thought, and re-calls — bounded by `MAX_OUTPUT_RECOVERY` (mirrors query.ts's
318 /// MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). Resets to 0 on any non-truncated response.
319 pub(super) output_recovery_attempts: u8,
320 pub(crate) output_recovery_attempt_limit: u8,
321 /// Whether the in-flight response was cut off at the provider's output cap. Set just before
322 /// `feed(LLMResponse)` and taken (cleared) inside it.
323 ///
324 /// A **classified** fact, never vendor text (§22.8): the canonical provider outcome supplies a
325 /// typed stop reason and [`Self::set_output_truncated`] stages the derived boolean.
326 pub(super) pending_output_truncated: bool,
327 /// §7.6 · what the canonical driver adjudicated for the provider turn about to be fed. Consumed
328 /// (and cleared) inside `feed(LLMResponse)`; `None` until the canonical driver stages a turn.
329 pub(super) adjudicated_turn: Option<AdjudicatedTurn>,
330 /// Number of history messages present at session start (after preload_history).
331 /// drain_new_messages() returns the slice from this offset onward.
332 pub(super) session_history_baseline: usize,
333 pub(super) checkpoint: TurnCheckpoint,
334 /// Milestone contract tracker (extracted to reduce state machine bloat).
335 pub(super) milestone: MilestoneTracker,
336 pub run_spec: Option<AgentRunSpec>,
337 /// M1 収口: the single source of truth for schedulability *and* sub-agent lineage. Root is
338 /// task `"root"`; each sub-agent is a child task carrying its `ProcInfo`. The former
339 /// `ProcessTable` is now a derived view over this (`agent_process(es)` rebuild `AgentProcess`
340 /// rows on demand via `AgentProcess::from_tcb`).
341 pub(super) tasks: TaskTable,
342 /// Optional governance pipeline. When set, every tool call proposed by the
343 /// model is evaluated before `ExecuteTools` is emitted. `None` (default)
344 /// skips the gate entirely, preserving the pre-governance behavior.
345 pub(super) governance: Option<GovernancePipeline>,
346 /// P1 fail-closed dispatch: the toolset advertised to the model on the most recent `CallLLM`.
347 /// Refreshed at every emission (`call_llm_action`), consumed by `gate_exposed_tool_calls`.
348 ///
349 /// `None` means this process has not advertised a toolset yet. It remains fail-closed for task
350 /// tools, and canonical checkpoints preserve the distinction from an advertised empty set.
351 pub(super) exposed_tool_names: Option<HashSet<CompactString>>,
352 /// Optional resource quota evaluated at the syscall trap (M2). `None` (default) leaves spawn /
353 /// memory syscalls unconditionally allowed, preserving pre-M2 behavior.
354 pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
355 /// Timestamps of recent allowed `WriteMemory` syscalls, for the rolling-window rate limit.
356 /// Only populated when `resource_quota.memory_writes_per_window` is set.
357 pub(super) memory_write_times: Vec<u64>,
358 /// Kernel-owned signal routing: dedup set + attention policy + bounded queue.
359 /// Always initialized; `set_attention` rebuilds it with a new queue size.
360 pub(super) signal_router: SignalRouter,
361 /// Prefix of `ctx.partitions.signals` included in the currently pending provider request.
362 /// A correlated `ProviderResult` consumes exactly this prefix; signals arriving while the
363 /// provider is in flight remain for the next request.
364 pub(super) delivered_signals_len: usize,
365 /// Accepted envelope time of the operation's first timed transition.
366 /// Used by the wall-time budget axis in `SchedulerBudget::should_terminate`.
367 pub(super) started_at_ms: Option<u64>,
368 /// Most-recent accepted envelope time, forwarded to the budget check.
369 pub(super) last_now_ms: Option<u64>,
370 /// Tool batch awaiting `Resume` after an AskUser suspend.
371 pub(super) suspend_state: Option<SuspendState>,
372 /// Denied tool results to merge into the next `ToolResults` feed after resume.
373 pub(super) pending_denied_results: Vec<ToolResult>,
374 /// W0: an in-flight workflow DAG, when one is loaded. The kernel spawns its ready nodes as
375 /// gated batches (each through `evaluate_syscall(Syscall::Spawn)`) and advances on
376 /// completions. `None` (default) preserves the single-spawn `spawn_sub_agent` behavior.
377 pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
378 /// Whether the in-flight workflow **is** this operation's root (spec §6.1 invariant 7).
379 ///
380 /// Immutable for the lifetime of a run: it mirrors the canonical `RootKind`, which no committed
381 /// transition may change. `false` means a workflow nested inside an agent loop, whose completion resumes the parent
382 /// agent with another provider call. `true` makes that completion the operation's terminal
383 /// instead, which is what deletes the host-side `CompleteRun` race (§10.1 现状注记).
384 pub(super) root_workflow: bool,
385 /// Spec §10.4 / §15.3 · whether a spawned child waits for the host's launch acknowledgement
386 /// before it counts as `Running`.
387 ///
388 /// Child launch follows the single canonical arc: `PendingLaunch` when the kernel mints
389 /// identity, `Starting` when it publishes the effect, and `Running` only after `TasksSpawned`.
390 /// Workflow batch reserved by the kernel and awaiting the host's correlated
391 /// spawn result. This is intent, not an observed external fact.
392 pub(super) pending_workflow_spawn: Option<PendingWorkflowSpawn>,
393 pub(super) pending_preempt: Option<PendingPreempt>,
394 /// Ordered host-owned durability effects produced during a pure state-machine
395 /// transition. The normal continuation is held until every effect commits.
396 pub(super) pending_host_effects: VecDeque<PendingHostEffect>,
397 pub(super) active_host_effect: Option<PendingHostEffect>,
398 pub(super) deferred_action: Option<Box<LoopAction>>,
399 /// O6: repeat-fuse thresholds (the hard rungs above the 2c soft STOP). Default enabled with
400 /// generous thresholds; tune/disable via `SetRepeatFuse` / `ConfigureRun.repeat_fuse`.
401 pub(super) repeat_fuse: RepeatFuseConfig,
402 /// O6: the previous turn's action signature (non-meta `name(args)` joined — the same key the
403 /// 2c STOP uses). NOT part of the turn checkpoint: a fuse deny's rollback must not launder
404 /// the streak it just tripped on.
405 pub(super) repeat_sig: Option<String>,
406 /// O6: consecutive turns whose signature equalled `repeat_sig` (1 = first occurrence).
407 pub(super) repeat_count: u32,
408 /// O4: turn-end criteria gate (the Stop-hook analog). When the model finishes (no tool calls)
409 /// while explicit acceptance criteria stand, inject ONE bounded self-check turn before
410 /// accepting `Completed`. 2c guards "won't stop"; this guards "stops too early".
411 pub(super) criteria_gate_enabled: bool,
412 /// O4: whether the gate already fired this run (it fires at most once — no nag loops).
413 pub(super) criteria_gate_fired: bool,
414 /// Session-entropy sliding window + watch state (see `scheduler::entropy`). Like the
415 /// RepeatFuse streak, NOT part of the turn checkpoint — a rollback must not launder
416 /// the disorder it just evidenced.
417 pub(super) entropy: EntropyTracker,
418 /// Opt-in threshold watch over the per-turn entropy score. Default disabled; the
419 /// unconditional per-turn `EntropySample` observation does not depend on it.
420 pub(super) entropy_watch: EntropyWatchConfig,
421}
422
423mod cancellation;
424mod capability;
425mod eviction;
426mod gate;
427mod milestone_exec;
428mod process;
429mod signal;
430mod workflow;
431
432impl LoopStateMachine {
433 fn message_tokens(&self, message: &Message) -> u32 {
434 message
435 .token_count
436 .unwrap_or_else(|| self.ctx.engine.count_message(message))
437 }
438
439 pub fn new(policy: SchedulerBudget) -> Self {
440 let mut tasks = TaskTable::new();
441 // M1d: the root task carries the authoritative schedulability lifecycle. It starts
442 // `Ready`; `start()`/`resume_*` flip it to `Running`, suspends set `Suspended`, and
443 // `terminate()` sets `Done`. `phase` is now only the intra-turn step.
444 tasks.insert(Tcb::root("root", policy.clone()));
445 Self {
446 // Inert placeholder step; meaningful only while the root task is `Running`.
447 phase: LoopPhase::Reason,
448 turn: 0,
449 ctx: ContextManager::new(policy.max_tokens),
450 tools: Vec::new(),
451 observations: Vec::new(),
452 policy,
453 scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig::default(),
454 total_tokens: 0,
455 budget_grant: None,
456 pending_budget_grant: None,
457 local_rounds_completed: 0,
458 pending_pace: None,
459 pending_termination: None,
460 recovery_attempts: 0,
461 provider_recovery_attempt_limit: 2,
462 output_recovery_attempts: 0,
463 output_recovery_attempt_limit: 3,
464 pending_output_truncated: false,
465 adjudicated_turn: None,
466 session_history_baseline: 0,
467 checkpoint: TurnCheckpoint::default(),
468 milestone: MilestoneTracker::new(),
469 run_spec: None,
470 tasks,
471 governance: None,
472 exposed_tool_names: None,
473 resource_quota: None,
474 memory_write_times: Vec::new(),
475 signal_router: SignalRouter::new(64),
476 delivered_signals_len: 0,
477 started_at_ms: None,
478 last_now_ms: None,
479 suspend_state: None,
480 pending_denied_results: Vec::new(),
481 workflow: None,
482 root_workflow: false,
483 pending_workflow_spawn: None,
484 pending_preempt: None,
485 pending_host_effects: VecDeque::new(),
486 active_host_effect: None,
487 deferred_action: None,
488 repeat_fuse: RepeatFuseConfig::default(),
489 repeat_sig: None,
490 repeat_count: 0,
491 criteria_gate_enabled: true,
492 criteria_gate_fired: false,
493 entropy: EntropyTracker::default(),
494 entropy_watch: EntropyWatchConfig::default(),
495 }
496 }
497
498 /// O4: enable/disable the turn-end criteria gate (default enabled; no-op without criteria).
499 pub fn set_criteria_gate(&mut self, enabled: bool) {
500 self.criteria_gate_enabled = enabled;
501 }
502
503 /// Declare that the workflow this machine runs **is** the operation's root (spec §6.1.7).
504 ///
505 /// Set once, before the DAG is installed, and never unset — it mirrors the immutable
506 /// `RootKind`. Its only consumer is `finish_workflow`, which terminates instead of calling the
507 /// provider again. A nested (agent-authored) workflow leaves it `false`.
508 pub fn set_root_workflow(&mut self, is_root: bool) {
509 self.root_workflow = is_root;
510 }
511
512 /// Whether the in-flight workflow is this operation's root.
513 pub fn is_root_workflow(&self) -> bool {
514 self.root_workflow
515 }
516
517 /// The schedulability state of one task, for host projections and tests.
518 pub fn task_lifecycle(&self, task_id: &str) -> Option<TaskLifecycle> {
519 self.tasks.get(task_id).map(|task| task.state)
520 }
521
522 /// Fine-grained capabilities held by a task. Skill activation uses the derived syscall caller
523 /// as the parent set for the same attenuation rule that governs child-task delegation.
524 pub fn task_capabilities(&self, task_id: &str) -> &[crate::types::capability::Capability] {
525 self.tasks
526 .get(task_id)
527 .map(|task| task.capabilities.as_slice())
528 .unwrap_or(&[])
529 }
530
531 /// The root operation's capability set, used by host control-plane mutations.
532 pub fn root_capabilities(&self) -> &[crate::types::capability::Capability] {
533 self.task_capabilities("root")
534 }
535
536 /// §10.4 · the launch effect for these tasks has been published. Moves each of them from
537 /// `PendingLaunch` to `Starting`, and never downgrades a task that already advanced.
538 pub fn mark_tasks_starting(&mut self, task_ids: &[String]) {
539 for id in task_ids {
540 if let Some(task) = self.tasks.get_mut(id)
541 && task.state == TaskLifecycle::PendingLaunch
542 {
543 task.state = TaskLifecycle::Starting;
544 }
545 }
546 }
547
548 /// §7.6 · whether the named workflow node ran under quarantine (it read untrusted content).
549 /// A quarantined caller may not use a P1 syscall to widen its own authority, so the canonical
550 /// driver consults this before it admits a workflow / memory / capability request.
551 /// Errs closed only in the sense that an unknown id is *not* quarantined — an id the kernel
552 /// never issued is refused earlier, by causation derivation.
553 pub fn task_quarantined(&self, task_id: &str) -> bool {
554 self.workflow
555 .as_ref()
556 .is_some_and(|run| run.is_agent_quarantined(task_id))
557 }
558
559 /// Test instrument for the §7.6 quarantine refusal. The canonical wire `WorkflowNode` carries
560 /// no trust level yet (SPEC-ISSUE in the canonical driver), so a test cannot declare a
561 /// quarantined node through the contract the driver reads.
562 #[cfg(test)]
563 pub(crate) fn quarantine_task_for_test(&mut self, task_id: &str) -> bool {
564 self.workflow
565 .as_mut()
566 .is_some_and(|run| run.quarantine_agent(task_id))
567 }
568
569 pub(crate) fn set_scheduler_policy(
570 &mut self,
571 policy: crate::scheduler::policy::SchedulerPolicyConfig,
572 ) {
573 self.scheduler_policy = policy;
574 if let Some(workflow) = self.workflow.as_mut() {
575 workflow.set_scheduler_policy(policy);
576 }
577 }
578
579 /// Install the two semantic recovery ladders the kernel owns (§13.2 `ReplaceRecoveryPolicy`).
580 /// Both ladders are always stated here — a resolved policy has no "unset" — so a patch can
581 /// lower *and* raise within the validated ceiling.
582 pub fn set_recovery_limits(&mut self, provider_attempts: u8, output_attempts: u8) {
583 self.provider_recovery_attempt_limit = provider_attempts;
584 self.output_recovery_attempt_limit = output_attempts;
585 }
586
587 pub(crate) fn externalize_pending_host_effect(
588 &mut self,
589 continuation: LoopAction,
590 ) -> LoopAction {
591 if self.active_host_effect.is_some() {
592 return continuation;
593 }
594 let Some(pending) = self.pending_host_effects.pop_front() else {
595 return continuation;
596 };
597 assert!(
598 self.deferred_action.is_none(),
599 "host effect continuation must be unique"
600 );
601 self.deferred_action = Some(Box::new(continuation));
602 self.active_host_effect = Some(pending);
603 self.active_host_effect
604 .as_ref()
605 .expect("host effect was just activated")
606 .action()
607 }
608
609 fn next_after_host_effect(&mut self) -> LoopAction {
610 if let Some(pending) = self.pending_host_effects.pop_front() {
611 self.active_host_effect = Some(pending);
612 self.active_host_effect
613 .as_ref()
614 .expect("host effect was just activated")
615 .action()
616 } else {
617 match self.deferred_action.take().map(|action| *action) {
618 // Durability effects can change rendered context and conditional meta-tools
619 // (notably `read_result`). Never return the pre-commit frozen provider action.
620 Some(LoopAction::CallLLM { .. }) => self.emit_call_llm(),
621 Some(action) => action,
622 None => LoopAction::AwaitingResume,
623 }
624 }
625 }
626
627 /// Commit the archive the host performed and release the continuation it was holding.
628 pub(crate) fn commit_page_out_archive(&mut self, archive_ref: Option<String>) -> LoopAction {
629 let pending = self
630 .active_host_effect
631 .as_ref()
632 .expect("page-out result requires an active host effect");
633 let PendingHostEffect::ArchivePageOut {
634 turn,
635 action,
636 summary,
637 archived,
638 tier,
639 } = pending;
640 self.observations.push(KernelObservation::PageOutArchived {
641 turn: *turn,
642 action: *action,
643 summary: summary.clone(),
644 tier: tier.clone(),
645 message_count: archived.len() as u32,
646 archive_ref,
647 });
648 self.active_host_effect = None;
649 self.next_after_host_effect()
650 }
651
652 /// DEC-5 · the canonical decision for an archive the host could not perform: **abandon it**.
653 ///
654 /// The compaction it belongs to already happened in this kernel — the summary is in context and
655 /// the evicted bodies are gone either way — so the run stays live and degraded rather than
656 /// dying on a best-effort durability effect. The failure is a typed, replayable audit fact and
657 /// the kernel never re-emits the same archive; a host that wants another attempt asks again
658 /// with a new causation.
659 pub(crate) fn abandon_page_out_archive(&mut self, error: String) -> LoopAction {
660 self.push_page_out_archive_failure(error);
661 self.active_host_effect = None;
662 self.next_after_host_effect()
663 }
664
665 fn push_page_out_archive_failure(&mut self, error: String) {
666 let pending = self
667 .active_host_effect
668 .as_ref()
669 .expect("page-out failure requires an active host effect");
670 let PendingHostEffect::ArchivePageOut {
671 turn,
672 action,
673 archived,
674 tier,
675 ..
676 } = pending;
677 self.observations
678 .push(KernelObservation::PageOutArchiveFailed {
679 turn: *turn,
680 action: *action,
681 tier: tier.clone(),
682 message_count: archived.len() as u32,
683 error,
684 });
685 }
686
687 /// O6: tune or disable the repeat fuse (see [`RepeatFuseConfig`]).
688 pub fn set_repeat_fuse(&mut self, config: RepeatFuseConfig) {
689 self.repeat_fuse = config;
690 }
691
692 /// Configure the opt-in entropy threshold watch (see [`EntropyWatchConfig`]).
693 /// The per-turn `EntropySample` observation is unconditional and unaffected.
694 pub fn set_entropy_watch(&mut self, config: EntropyWatchConfig) {
695 self.entropy_watch = config;
696 }
697
698 pub fn entropy_watch_config(&self) -> EntropyWatchConfig {
699 self.entropy_watch
700 }
701
702 pub(crate) fn entropy_checkpoint_state(
703 &self,
704 ) -> crate::scheduler::entropy::EntropyTrackerRuntimeState {
705 self.entropy.checkpoint_state()
706 }
707
708 pub(crate) fn restore_entropy_checkpoint_state(
709 &mut self,
710 state: crate::scheduler::entropy::EntropyTrackerRuntimeState,
711 ) -> Result<(), String> {
712 self.entropy.restore_state(state, self.turn)
713 }
714
715 /// O6: the active repeat-fuse config (for read-modify-write from the ABI event).
716 pub fn repeat_fuse_config(&self) -> RepeatFuseConfig {
717 self.repeat_fuse
718 }
719
720 /// The authoritative schedulability lifecycle of the loop (root task state). Replaces the
721 /// removed `LoopPhase::{Idle,Suspended,Blocked,Terminal}` reads.
722 pub fn lifecycle(&self) -> TaskLifecycle {
723 self.tasks
724 .get("root")
725 .map(|t| t.state)
726 .unwrap_or(TaskLifecycle::Ready)
727 }
728
729 /// The canonical durable wait set while suspended, if any.
730 pub fn wait_set(&self) -> Option<DurableWaitSet> {
731 self.tasks
732 .get("root")
733 .and_then(|task| task.wait_set.clone())
734 }
735
736 /// Whether the loop has terminated.
737 pub fn is_terminal(&self) -> bool {
738 matches!(self.lifecycle(), TaskLifecycle::Done(_))
739 }
740
741 /// Whether the loop is suspended awaiting external resolution.
742 pub fn is_suspended(&self) -> bool {
743 matches!(self.lifecycle(), TaskLifecycle::Suspended)
744 }
745
746 /// §7.9 · the operation ended because a host executor failed on an effect the loop cannot do
747 /// without. Closes the loop so a later input finds a terminated kernel rather than one that
748 /// still believes it is running.
749 ///
750 /// Deliberately produces no `LoopResult`: the terminal belongs to the canonical driver, and a
751 /// second one minted here would give the same event two representations (§7.12).
752 pub fn close_for_host_effect_failure(&mut self) {
753 self.set_lifecycle(TaskLifecycle::Done(TerminationReason::Error), None);
754 }
755
756 /// Set the root task's lifecycle and canonical wait state.
757 fn set_lifecycle(&mut self, state: TaskLifecycle, wait: Option<WaitSet>) {
758 if self.tasks.get("root").is_none() {
759 self.tasks.insert(Tcb::root("root", self.policy.clone()));
760 }
761 if let Some(root) = self.tasks.get_mut("root") {
762 root.state = state;
763 }
764 if let Some(wait) = wait {
765 self.tasks.register_wait_set("root", wait);
766 } else {
767 self.tasks.clear_wait("root");
768 }
769 }
770
771 /// spc_009-05: convert the RunGroup admission grant (`tokens`/`subagents`/`rounds`, a coarse
772 /// whole-operation ceiling) into the [`crate::scheduler::budget_grant::ResourceBudget`] shape
773 /// spc_005's hierarchical checks understand. Axes with no source field (`cost_microunits`/
774 /// `wall_ms`/`concurrent_children`/`tool_calls`/`memory_writes`/`object_bytes`) stay `None` —
775 /// `ResourceBudget`'s own "unset means unbounded" convention, not an omission.
776 fn root_child_budget_seed(&self) -> Option<super::budget_grant::ResourceBudget> {
777 self.budget_grant
778 .as_ref()
779 .map(|grant| super::budget_grant::ResourceBudget {
780 tokens: grant.tokens.map(|t| t.get()),
781 child_tasks: grant.subagents,
782 turns: grant.rounds,
783 ..super::budget_grant::ResourceBudget::default()
784 })
785 }
786
787 /// Build a transient root [`Tcb`] mirroring the current scheduling facts (budget counters,
788 /// wall-clock anchors, lifecycle) so the pure scheduler applies the same budget verdict.
789 fn root_tcb(&self) -> Tcb {
790 let mut tcb = Tcb::root("root", self.policy.clone());
791 tcb.budget.turns = self.turn;
792 tcb.budget.total_tokens = self.total_tokens;
793 if let Some(tokens) = self
794 .budget_grant
795 .as_ref()
796 .and_then(|grant| grant.tokens)
797 .map(crate::runtime::kernel::wire::WireU64::get)
798 {
799 tcb.budget.limits.max_total_tokens = tcb.budget.limits.max_total_tokens.min(tokens);
800 }
801 tcb.budget.started_at_ms = self.started_at_ms;
802 tcb.state = self.lifecycle();
803 tcb
804 }
805
806 /// Adjust the wall-clock budget axis at runtime.
807 pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
808 self.policy.max_wall_ms = max_wall_ms;
809 }
810
811 /// The wall-clock budget axis as it currently stands — the value an `UpdateDeadline` command
812 /// last projected onto it. Read by the §12.1 checkpoint projection, because a deadline that a
813 /// restore forgot would silently un-bound the run.
814 pub fn wall_budget(&self) -> Option<u64> {
815 self.policy.max_wall_ms
816 }
817
818 // ----- §12.2 · restore -----
819 //
820 // Narrow, one-fact-each setters, not a "load this snapshot" door. Each one writes back exactly
821 // one value the §12.1 projection reads, so "what a checkpoint restores" is decided by the DTO
822 // and enforced by the restore's own digest re-check — never by whatever this struct happens to
823 // hold. Anything not reachable through these is, by construction, state the checkpoint declares
824 // rebuildable (the loop phase, the exposed toolset, the frozen-prefix marker).
825
826 /// Reinstall the counters the budget axes are evaluated against.
827 pub fn restore_budget_usage(&mut self, total_tokens: u64, rounds_completed: u32) {
828 self.total_tokens = total_tokens;
829 self.local_rounds_completed = rounds_completed;
830 }
831
832 /// Reinstall the anchor the wall-clock axis measures from.
833 pub fn restore_started_at_ms(&mut self, started_at_ms: Option<u64>) {
834 self.started_at_ms = started_at_ms;
835 }
836
837 /// The task table, for a restore to repopulate. `insert` is idempotent per task id, so a
838 /// restore that runs twice produces one table, not two.
839 pub fn task_table_mut(&mut self) -> &mut TaskTable {
840 &mut self.tasks
841 }
842
843 /// Reinstall the rolling memory-write window the syscall gate rate-limits against.
844 pub fn restore_memory_write_window(&mut self, window: Vec<u64>) {
845 self.memory_write_times = window;
846 }
847
848 /// The accepted time this operation started measuring wall-clock budget from, if any input has
849 /// carried a clock yet. The wall axis is a *duration* from here, so an absolute deadline is
850 /// projected onto it as `deadline − start`.
851 pub fn started_at_ms(&self) -> Option<u64> {
852 self.started_at_ms
853 }
854
855 /// Install a governance pipeline. Once set, all model-proposed tool calls
856 /// are evaluated before execution. Denied/rate-limited calls commit visible
857 /// error tool results; `AskUser` calls surface a `ToolGated` observation for
858 /// the SDK to enforce.
859 pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
860 self.governance = Some(pipeline);
861 }
862
863 /// Install resource quotas (M2). Once set, `Spawn` and `WriteMemory` syscalls are bounded by
864 /// the quota at the trap. Not setting it (the default) leaves them unconditionally allowed.
865 pub fn set_resource_quota(&mut self, quota: crate::governance::quota::ResourceQuota) {
866 self.resource_quota = Some(quota);
867 }
868
869 pub fn set_budget_grant(&mut self, grant: crate::runtime::kernel::wire::BudgetGrant) {
870 self.budget_grant = Some(grant);
871 // spc_009-05: seed root's own grantable pool from the RunGroup admission grant the Host
872 // just declared. `new()` already inserted root (eagerly, before any grant is known), so
873 // this is the first point a grant actually exists to seed from — `set_lifecycle`'s
874 // lazy-insert branch never fires for root and cannot be the hook. `None` stays `None`:
875 // this only activates the spc_005 hierarchical check when the Host declared a real
876 // admission grant, never unconditionally.
877 let seed = self.root_child_budget_seed();
878 if let Some(root) = self.tasks.get_mut("root") {
879 root.child_budget_remaining = seed;
880 }
881 }
882
883 /// spc_009-04: seed the operation root's own delegatable `Capability` set from
884 /// `InitialContext.requested_capabilities` — the Host input a `StartOperation` carries.
885 /// Mirrors `set_budget_grant`'s hook: root already exists (`new()` inserts it eagerly), so
886 /// this writes directly rather than waiting on `set_lifecycle`'s lazy-insert branch, which
887 /// never fires for root. An empty request leaves `capabilities` at its `Vec::new()` default —
888 /// no unconditional grant for operations that declared none.
889 pub fn set_requested_capabilities(
890 &mut self,
891 capabilities: Vec<crate::types::capability::Capability>,
892 ) {
893 if let Some(root) = self.tasks.get_mut("root") {
894 root.capabilities = capabilities;
895 }
896 }
897
898 /// L1: this vehicle's cumulative sub-agent spawns this run — every child task ever registered in
899 /// the `TaskTable` (running + completed), distinct from the *instantaneous* running count. Used
900 /// for the cumulative spawn quota and read back by the SDK to charge the group ledger at run end.
901 pub fn local_subagents_spawned(&self) -> u32 {
902 self.tasks.all().iter().filter(|t| t.proc.is_some()).count() as u32
903 }
904
905 pub fn local_budget_usage(&self) -> (u64, u32, u32) {
906 (
907 self.total_tokens,
908 self.local_subagents_spawned(),
909 self.local_rounds_completed,
910 )
911 }
912
913 pub fn budget_grant(&self) -> Option<&crate::runtime::kernel::wire::BudgetGrant> {
914 self.budget_grant.as_ref()
915 }
916
917 /// Timestamps of the recent allowed memory writes — the rolling window the syscall-gate rate
918 /// limit is evaluated against.
919 ///
920 /// Read by the §12.1 checkpoint projection: the window is a gate *input*, so a checkpoint that
921 /// dropped it would hand the restored run a fresh quota.
922 pub fn memory_write_window(&self) -> &[u64] {
923 &self.memory_write_times
924 }
925
926 /// §11.2 · ingest the **accepted envelope time** of the input being planned.
927 ///
928 /// The canonical driver calls this once per transition, before any semantic call, so that
929 /// every clock-dependent decision this step makes — signal TTL and deadline escalation, the
930 /// governance rate-limit window, the wall-time budget axis, idle time-decay — reads the one
931 /// host clock fact the journal already holds. The kernel itself never reads a system clock.
932 ///
933 /// Beyond [`Self::set_observed_time`] it anchors context *activity* at the first accepted
934 /// time. Without the anchor, `last_activity_ms` starts at 0 while the accepted clock is an
935 /// epoch value, so the very first turn would look idle for ~55 years and trip time-decay
936 /// compaction on an empty context.
937 pub fn observe_accepted_time(&mut self, now_ms: u64) {
938 let first = self.started_at_ms.is_none();
939 self.set_observed_time(now_ms);
940 if first {
941 self.ctx.record_activity(now_ms);
942 }
943 }
944
945 /// Feed the current wall-clock time (ms) to scheduler/governance budget axes.
946 pub fn set_observed_time(&mut self, now_ms: u64) {
947 if self.started_at_ms.is_none() {
948 self.started_at_ms = Some(now_ms);
949 }
950 self.last_now_ms = Some(now_ms);
951 if let Some(pipeline) = self.governance.as_mut() {
952 pipeline.set_time(now_ms);
953 }
954 }
955
956 /// The provider's typed stop reason says whether the response was cut off at the output cap;
957 /// core never parses vendor-specific text.
958 pub fn set_output_truncated(&mut self, truncated: bool) {
959 self.pending_output_truncated = truncated;
960 }
961
962 /// §7.6 · stage the canonical driver's adjudication of the provider turn about to be fed.
963 pub fn stage_adjudicated_turn(&mut self, adjudicated: AdjudicatedTurn) {
964 self.adjudicated_turn = Some(adjudicated);
965 }
966
967 /// The agent ids of the spawn batch the kernel published and is still waiting on. Empty when no
968 /// launch is outstanding. A batch-level launch failure is charged against exactly this set.
969 pub fn pending_spawn_agent_ids(&self) -> Vec<String> {
970 self.pending_workflow_spawn
971 .as_ref()
972 .map(|pending| {
973 pending
974 .nodes
975 .iter()
976 .map(|node| node.agent_id.clone())
977 .collect()
978 })
979 .unwrap_or_default()
980 }
981
982 /// The tool calls this turn dispatched and is still waiting on. Empty outside an `Act` phase.
983 pub fn dispatched_tool_calls(&self) -> Vec<ToolCall> {
984 match &self.phase {
985 LoopPhase::Act { tool_calls } => tool_calls.clone(),
986 LoopPhase::Reason => Vec::new(),
987 }
988 }
989
990 /// Pre-populate the history partition with messages from a prior session.
991 ///
992 /// Call **before** `start()` when resuming a conversation. Sets the baseline
993 /// so `drain_new_messages()` returns only the messages from the current run.
994 pub fn preload_history(&mut self, messages: Vec<Message>) {
995 for msg in messages {
996 let tokens = self.message_tokens(&msg);
997 self.ctx.push_history(msg, tokens);
998 }
999 self.session_history_baseline = self.ctx.partitions.history.messages.len();
1000 }
1001
1002 /// Continue from preloaded history without appending a new user turn.
1003 /// Use after `preload_history` when recovering a session that ended mid-run.
1004 ///
1005 /// If the last assistant turn has tool calls without matching tool results,
1006 /// resumes with `ExecuteTools` instead of calling the LLM again.
1007 ///
1008 /// "Unanswered" is read from history PLUS the results already synthesized this turn but not yet
1009 /// committed (`pending_denied_results`). Both matter because this is also the mid-turn
1010 /// continuation point: the kernel answers a `memory`/`knowledge` call by pushing hits into
1011 /// history and resuming here, while a denial from the same batch (fail-closed dispatch or a
1012 /// governance verdict) is still in flight and therefore invisible to a history-only scan.
1013 /// Re-dispatching such a call would execute a tool the kernel just refused AND give the model
1014 /// two results for one call_id. The filter keys on answered call_ids only, so a call that was
1015 /// never denied is still resumed — the wake-path behavior is untouched.
1016 pub fn resume_after_preload(&mut self) -> LoopAction {
1017 self.observations.clear();
1018 let mut calls = crate::runtime::repair::pending_tool_calls_from_messages(
1019 &self.ctx.partitions.history.messages,
1020 );
1021 if !self.pending_denied_results.is_empty() {
1022 let answered: HashSet<CompactString> = self
1023 .pending_denied_results
1024 .iter()
1025 .map(|result| result.call_id.clone())
1026 .collect();
1027 calls.retain(|call| !answered.contains(&call.id));
1028 }
1029 if !calls.is_empty() {
1030 self.phase = LoopPhase::Act {
1031 tool_calls: calls.clone(),
1032 };
1033 self.set_lifecycle(TaskLifecycle::Running, None);
1034 return LoopAction::ExecuteTools { calls };
1035 }
1036 self.phase = LoopPhase::Reason;
1037 self.emit_call_llm()
1038 }
1039
1040 /// Return all messages added to history during the current run
1041 /// (since the last `preload_history` call or since construction).
1042 ///
1043 /// Call after `LoopAction::Done` to get the complete turn transcript
1044 /// for persistence to a SessionStore.
1045 pub fn drain_new_messages(&self) -> Vec<Message> {
1046 let history = &self.ctx.partitions.history.messages;
1047 let start = self.session_history_baseline.min(history.len());
1048 history[start..].to_vec()
1049 }
1050
1051 pub fn start(&mut self, task: RuntimeTask) -> LoopAction {
1052 self.observations.clear();
1053 self.ctx.init_task(task.goal.clone(), task.criteria.clone());
1054
1055 // A loop vehicle with no admitted round capacity must not make even one provider call.
1056 // The host may have raced another member between reading its durable loop log and reserve;
1057 // the reservation is the authoritative admission decision.
1058 let zero_round_grant = self
1059 .run_spec
1060 .as_ref()
1061 .and_then(|spec| spec.loop_round.as_ref())
1062 .is_some()
1063 && self.budget_grant.as_ref().and_then(|grant| grant.rounds) == Some(0);
1064 // A zero-token grant is the same exhausted admission on the token axis, but it binds
1065 // every vehicle, loop or not. Both axes can race to zero on one reservation; report
1066 // each before terminating so no provider call is ever dispatched.
1067 let zero_token_grant = self
1068 .budget_grant
1069 .as_ref()
1070 .and_then(|grant| grant.tokens)
1071 .is_some_and(|tokens| tokens.get() == 0);
1072 if zero_round_grant || zero_token_grant {
1073 if zero_round_grant {
1074 self.observations.push(KernelObservation::BudgetExceeded {
1075 turn: self.turn,
1076 budget: "rounds".into(),
1077 operation_id: String::new(),
1078 reservation_id: self
1079 .budget_grant
1080 .as_ref()
1081 .map(|grant| grant.reservation_id.clone()),
1082 });
1083 self.pending_pace = Some(crate::types::result::PaceDecision {
1084 action: crate::types::result::PaceAction::Stop,
1085 delay_ms: None,
1086 reason: "round budget grant exhausted before start".into(),
1087 coerced_from: None,
1088 });
1089 }
1090 if zero_token_grant {
1091 self.observations.push(KernelObservation::BudgetExceeded {
1092 turn: self.turn,
1093 budget: "tokens".into(),
1094 operation_id: String::new(),
1095 reservation_id: self
1096 .budget_grant
1097 .as_ref()
1098 .map(|grant| grant.reservation_id.clone()),
1099 });
1100 }
1101 // Token exhaustion is the harder stop: prefer it when both axes are zero.
1102 return self.terminate(
1103 if zero_token_grant {
1104 TerminationReason::TokenBudget
1105 } else {
1106 TerminationReason::Completed
1107 },
1108 None,
1109 );
1110 }
1111
1112 let user_msg = "Proceed with the task described in [TASK STATE].".to_string();
1113
1114 // User message goes into history so it appears at the correct chronological
1115 // position: [prior turns...] → [current user message] — LLM reads left-to-right
1116 // and responds to the last message. working is reserved for runtime signals only.
1117 // Estimate tokens (1 token ≈ 4 chars) with a minimum of 1 so the renderer
1118 // does not skip this message (it skips zero-token entries).
1119 let user_tokens = self.ctx.engine.count(&user_msg).max(1);
1120 self.ctx.push_history(Message::user(user_msg), user_tokens);
1121 self.phase = LoopPhase::Reason;
1122 // Root task (seeded `Ready` in `new()`) becomes `Running`; `emit_call_llm` sets it.
1123 self.emit_call_llm()
1124 }
1125
1126 pub fn feed(&mut self, event: LoopEvent) -> LoopAction {
1127 self.observations.clear();
1128 self.sweep_expired_leases();
1129 // K3: skill leases expire on the same head-of-event cadence as capability leases.
1130 self.ctx.sweep_expired_skill_leases(self.turn);
1131
1132 match event {
1133 LoopEvent::LLMResponse { message } => {
1134 // §7.6 · taken unconditionally, so a staged adjudication can never survive into a
1135 // later turn — a turn with no tool calls at all simply has nothing to apply it to.
1136 let adjudicated = self.adjudicated_turn.take().unwrap_or_default();
1137 let delivered = self
1138 .delivered_signals_len
1139 .min(self.ctx.partitions.signals.len());
1140 self.ctx.partitions.signals.drain(..delivered);
1141 self.delivered_signals_len = 0;
1142 // Signals admitted while the provider was in flight were not in the completed
1143 // request. Promote queued items at this boundary and keep a no-tool response from
1144 // terminating before the model receives them in a follow-up request.
1145 self.drain_queued_signals();
1146 let signals_waiting_for_followup = !self.ctx.partitions.signals.is_empty();
1147 // A response arrived ⇒ the prompt fit ⇒ the overflow recovery ladder is reset.
1148 self.recovery_attempts = 0;
1149 let tokens = self.message_tokens(&message);
1150 self.total_tokens += tokens as u64;
1151
1152 // Max-output-tokens recovery (mirrors query.ts): a response cut off at the output
1153 // cap reports stop_reason = max_tokens (Anthropic) / length (OpenAI). A clean finish
1154 // resets the ladder.
1155 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.";
1156 let truncated = std::mem::take(&mut self.pending_output_truncated);
1157 if !truncated {
1158 self.output_recovery_attempts = 0;
1159 }
1160
1161 if let Some(reason) = self.pending_termination.take() {
1162 return self.terminate(reason, Some(message));
1163 }
1164
1165 if message.tool_calls.is_empty() {
1166 // The model was cut off at the output cap with no tool call. Keep the partial,
1167 // nudge it to resume mid-thought, and re-call — instead of mistaking the
1168 // truncation for a finished turn. Bounded by MAX_OUTPUT_RECOVERY; once exhausted
1169 // the partial stands and the turn terminates normally below. (A truncated
1170 // *tool-call* turn isn't handled here — it falls through to tool execution.)
1171 if truncated
1172 && self.output_recovery_attempts < self.output_recovery_attempt_limit
1173 {
1174 self.output_recovery_attempts += 1;
1175 self.ctx.push_history(message, tokens);
1176 self.ctx.push_signal(OUTPUT_TRUNCATION_NUDGE.to_string());
1177 self.phase = LoopPhase::Reason;
1178 return self.emit_call_llm();
1179 }
1180 // When a milestone contract is active and not yet complete,
1181 // request evaluation instead of terminating.
1182 if !self.milestone.is_complete() {
1183 let phase_id = self.milestone.current_phase_id().unwrap_or("").to_string();
1184 let criteria = self.milestone.current_criteria().to_vec();
1185 let (verifier, required_evidence) = self
1186 .milestone
1187 .current_phase()
1188 .map(|p| (p.verifier.clone(), p.required_evidence.clone()))
1189 .unwrap_or_default();
1190 // `tokens` was already computed for this message above.
1191 self.ctx.push_history(message, tokens);
1192 return LoopAction::EvaluateMilestone {
1193 phase_id,
1194 criteria,
1195 verifier,
1196 required_evidence,
1197 };
1198 }
1199 // O4 criteria gate (the Stop-hook analog): the model is finishing while explicit
1200 // acceptance criteria stand. Before accepting `Completed`, inject ONE bounded
1201 // self-check at the peak-attention slot — verify each criterion, continue if any
1202 // is unmet, else confirm. Fires at most once per run (no nag loop); runs with no
1203 // criteria are untouched. 2c guards "won't stop"; this guards "stops too early".
1204 if self.criteria_gate_enabled
1205 && !self.criteria_gate_fired
1206 && !self.ctx.partitions.task_state.criteria.is_empty()
1207 {
1208 self.criteria_gate_fired = true;
1209 let criteria = self.ctx.partitions.task_state.criteria.clone();
1210 self.ctx.push_history(message, tokens);
1211 self.ctx.push_signal(format!(
1212 "[CRITERIA CHECK] You are about to finish. Verify each acceptance \
1213 criterion first: {}. If any is NOT met, continue working on it now. \
1214 If all are met, give the final answer.",
1215 criteria.join(" | ")
1216 ));
1217 self.observations
1218 .push(KernelObservation::CriteriaGateFired {
1219 turn: self.turn,
1220 criteria,
1221 });
1222 self.phase = LoopPhase::Reason;
1223 return self.emit_call_llm();
1224 }
1225 if signals_waiting_for_followup {
1226 self.ctx.push_history(message, tokens);
1227 self.phase = LoopPhase::Reason;
1228 return self.emit_call_llm();
1229 }
1230 return self.terminate(TerminationReason::Completed, Some(message));
1231 }
1232
1233 let calls = message.tool_calls.clone();
1234 self.ctx.push_history(message, tokens);
1235
1236 // ━━ 记录活动时间(Layer 3时间衰减使用)
1237 if let Some(now_ms) = self.last_now_ms {
1238 self.ctx.record_activity(now_ms);
1239 }
1240
1241 // §7.6 · the P1 syscalls in this batch were already adjudicated by the canonical
1242 // driver. The assistant message keeps every call the model made — it must see the
1243 // turn it emitted — but a syscall is never dispatched: it is closed here with the
1244 // kernel's own answer, before the fuse/gate, which meter host work only.
1245 let calls = if adjudicated.answered_calls.is_empty() {
1246 calls
1247 } else {
1248 let answered: HashSet<CompactString> = adjudicated
1249 .answered_calls
1250 .iter()
1251 .map(|answer| answer.call_id.clone())
1252 .collect();
1253 for answer in &adjudicated.answered_calls {
1254 self.push_synthetic_tool_result(
1255 &answer.call_id,
1256 &answer.output,
1257 answer.is_error,
1258 );
1259 }
1260 calls
1261 .into_iter()
1262 .filter(|call| !answered.contains(&call.id))
1263 .collect()
1264 };
1265 if calls.is_empty() {
1266 // §5k · a syscall-only batch leaves nothing for a host to execute. Either the
1267 // adjudication already published kernel-owned work — and the turn resumes when
1268 // that resolves — or the turn continues the only way a turn can continue: the
1269 // kernel calls the provider again, itself, in this same committed step.
1270 self.phase = LoopPhase::Reason;
1271 return match adjudicated.idle_continuation {
1272 IdleContinuation::CallProvider => self.emit_call_llm(),
1273 IdleContinuation::Await => LoopAction::AwaitingResume,
1274 };
1275 }
1276
1277 // ③ pacing trap: a `pace` call is a kernel-adjudicated round-end proposal,
1278 // never an SDK tool. Handled before the fuse/gate — it is a control verb,
1279 // not task work.
1280 if self
1281 .run_spec
1282 .as_ref()
1283 .and_then(|r| r.loop_round.as_ref())
1284 .is_some()
1285 {
1286 if let Some(pace_call) = calls.iter().find(|c| c.name.as_str() == "pace") {
1287 let call = pace_call.clone();
1288 // The assistant message carrying `pace` is already committed to history
1289 // and the kernel adjudicates pace itself — sibling calls batched with it
1290 // are never executed, so close their transcript pairs here or they remain
1291 // orphaned tool_use blocks (wire-invalid on several vendors).
1292 for sibling in calls.iter().filter(|c| c.id != call.id) {
1293 self.push_synthetic_tool_result(
1294 &sibling.id,
1295 "not executed: superseded by pace — the round is ending",
1296 false,
1297 );
1298 }
1299 return self.handle_pace_call(call);
1300 }
1301 }
1302
1303 // 2b: record this turn's tool activity into the task-state recency log (meta-tools
1304 // filtered inside). The State-turn footer renders it as "just did: …" + a forward
1305 // nudge / STOP, so progress is kernel-derived and never depends on the model
1306 // remembering to call `update_plan`. Tool *names* live only on the request (results
1307 // carry call_id only), so this is the turn to capture them.
1308 //
1309 // Capture name AND a compact arg digest: the no-progress STOP keys on whether the
1310 // SAME call repeats, and a legit loop (same tool, DIFFERENT args — e.g. processing 20
1311 // items) is real progress, not a stall. Keying on the name alone false-positives those
1312 // loops; including args distinguishes "step(n=1), step(n=2)…" from a true repeat.
1313 let action_sigs: Vec<(String, String)> = calls
1314 .iter()
1315 .map(|c| (c.name.to_string(), compact_tool_args(&c.arguments)))
1316 .collect();
1317 self.ctx.note_tool_actions(&action_sigs);
1318
1319 // O6 RepeatFuse: the hard rungs above the 2c soft STOP. Runs BEFORE the governance
1320 // gate and independent of whether a policy is loaded — a batteries-included kernel
1321 // protection, not a policy feature. Deny commits a visible synthetic error result;
1322 // the terminate rung ends the run `NoProgress` after one final no-tools report turn.
1323 if let Some(action) = self.check_repeat_fuse(&calls) {
1324 return action;
1325 }
1326
1327 // P1 fail-closed dispatch: drop calls to tools this run never advertised, BEFORE the
1328 // governance gate (a tool that was never exposed is not a policy question) and AFTER
1329 // the fuse (a model retrying the same phantom tool must still burn the fuse, exactly
1330 // like a repeated host denial). Denials commit as visible error results; surviving
1331 // siblings execute this turn.
1332 let calls = match self.gate_exposed_tool_calls(calls) {
1333 ExposureGateOutcome::Blocked(action) => return action,
1334 ExposureGateOutcome::Proceed(calls) => calls,
1335 };
1336
1337 match self.gate_tool_calls(&calls) {
1338 GateToolOutcome::Blocked(action) => return action,
1339 GateToolOutcome::ApprovalRequired(requests) => {
1340 return LoopAction::RequestApproval { requests };
1341 }
1342 GateToolOutcome::Proceed => {}
1343 }
1344 self.phase = LoopPhase::Act {
1345 tool_calls: calls.clone(),
1346 };
1347 self.set_lifecycle(TaskLifecycle::Running, None);
1348 LoopAction::ExecuteTools { calls }
1349 }
1350
1351 LoopEvent::ToolResults { mut results } => {
1352 if !self.pending_denied_results.is_empty() {
1353 results.append(&mut self.pending_denied_results);
1354 }
1355 if let Some(reason) = results
1356 .iter()
1357 .find_map(|result| self.rollback_reason_for_tool_result(result))
1358 {
1359 let note = Message::user(super::rollback::build_rollback_note(
1360 &reason,
1361 self.ctx.config.verbose_control_notes,
1362 ));
1363 self.rollback(reason);
1364 self.ctx
1365 .push_signal(note.content.as_text().unwrap_or_default().to_string());
1366 self.phase = LoopPhase::Reason;
1367 return self.emit_call_llm();
1368 }
1369 // Tool errors are committed to history so the LLM can see them and self-correct
1370 // without losing turn state. UserInterrupt was handled by the rollback arm above.
1371
1372 // Entropy: this completed turn's failure tally. All model-visible tool failures,
1373 // including fatal and timeout results, accrue at this committed boundary.
1374 let errored_results = results.iter().filter(|r| r.is_error).count() as u32;
1375 let total_results = results.len() as u32;
1376 for r in &results {
1377 self.total_tokens += r.token_count.unwrap_or(0) as u64;
1378 // Preserve Content::Parts (structured / multimodal tool output).
1379 // Parts are serialised to JSON so the text can be restored faithfully.
1380 let output = match &r.output {
1381 Content::Text(s) => s.clone(),
1382 Content::Parts(parts) => serde_json::to_string(parts).unwrap_or_default(),
1383 };
1384 let parts = vec![ContentPart::ToolResult {
1385 call_id: r.call_id.clone(),
1386 output,
1387 is_error: r.is_error,
1388 durable_content: r.durable_content.clone(),
1389 }];
1390 let tool_msg = Message::tool(parts);
1391 let tokens = r
1392 .token_count
1393 .unwrap_or_else(|| self.ctx.engine.count_message(&tool_msg));
1394 self.ctx.push_history(tool_msg, tokens);
1395 }
1396 self.turn += 1;
1397 // The budget verdict (turn/token/wall) fires inside `emit_call_llm` at the end of
1398 // this arm — the single provider-call funnel — so the eviction checkpoint and
1399 // entropy sample below still run on the exhaustion turn before the final report.
1400
1401 // ━━ Eviction checkpoint (M3): one decision model (`plan_eviction`), one
1402 // execution funnel (`execute_eviction_op`). Layer 3 (idle/time-decay) must run
1403 // before the rho recommendation is read, since it mutates token usage — so the
1404 // plan is built in that interleaved order and the ops are executed in plan order.
1405 let idle_decay = self
1406 .last_now_ms
1407 .is_some_and(|now_ms| self.ctx.should_time_decay_compact(now_ms));
1408 if idle_decay {
1409 self.execute_eviction_op(&crate::mm::EvictionOp::TimeDecayMicro);
1410 }
1411
1412 // Layer 4 read-time projection: recompute handle residency on the post-time-decay rho.
1413 self.ctx.recompute_handle_residency();
1414 // K2: knowledge budget check — marks over-budget unpinned entries for the next
1415 // boundary sweep (marks are idempotent; drops only apply there) and stashes a
1416 // warn-once-per-generation notice, drained into an observation here.
1417 if let Some((used, budget)) = self.ctx.enforce_knowledge_budget() {
1418 self.observations
1419 .push(KernelObservation::KnowledgeBudgetExceeded {
1420 turn: self.turn,
1421 used,
1422 budget,
1423 });
1424 }
1425 // Layers 2/4/5: execute the pressure-driven ops from the plan (skip TimeDecayMicro
1426 // if already executed). The plan carries specific ops stamped with real config-derived
1427 // params (W1-1 収口 — no magic-number placeholders), not the umbrella `Pressure` wrapper.
1428 let (target_tokens, preserve_turns) = self.ctx.plan_compaction_params();
1429 let plan = crate::mm::plan_eviction(
1430 self.ctx.should_compress(),
1431 idle_decay,
1432 target_tokens,
1433 preserve_turns,
1434 );
1435 // `idle_decay` ⇒ the plan carries a `TimeDecayMicro` (so the skip-on-already-executed
1436 // below is meaningful). The converse does NOT hold: a pressure-driven `MicroCompact`
1437 // also emits `TimeDecayMicro` independent of `idle_decay` (W1 unified planner), so we
1438 // assert the implication, not equality.
1439 debug_assert!(!idle_decay || plan.has_time_decay());
1440 for op in &plan.ops {
1441 // Skip TimeDecayMicro if we already executed it (prevents double-execution).
1442 if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
1443 continue;
1444 }
1445 self.execute_eviction_op(op);
1446 }
1447
1448 // Renewal: when compression alone cannot recover enough headroom,
1449 // start a new sprint — carry forward system + memory + last N history turns.
1450 if self.ctx.should_renew() {
1451 self.ctx.renew();
1452 // A new sprint is a session boundary for signal identity: clear the dedup set so
1453 // it cannot grow unbounded across a long run, and so a signal seen in a prior
1454 // sprint may legitimately re-fire in the new one.
1455 self.signal_router.clear_dedup();
1456 self.observations.push(KernelObservation::Renewed {
1457 sprint: self.ctx.sprint,
1458 });
1459 // K1: renewal is a boundary — surface the knowledge sweep it just ran.
1460 self.emit_knowledge_sweep_observations();
1461 }
1462
1463 // Session-entropy sample (the heartbeat watch source): fold this completed
1464 // turn's outcomes into the sliding window and surface the measurement.
1465 // Unconditional, like `CheckpointTaken`; only the watch alert below is opt-in.
1466 let repeat_streak = if self.repeat_fuse.enabled {
1467 self.repeat_count
1468 } else {
1469 0
1470 };
1471 let sample = self.entropy.sample(
1472 self.turn,
1473 self.ctx.rho(),
1474 repeat_streak,
1475 self.repeat_fuse.deny_after,
1476 errored_results,
1477 total_results,
1478 );
1479 self.observations.push(KernelObservation::EntropySample {
1480 turn: sample.turn,
1481 score: sample.score,
1482 rho: sample.rho,
1483 repeat_pressure: sample.repeat_pressure,
1484 failure_rate: sample.failure_rate,
1485 rollbacks_in_window: sample.rollbacks_in_window,
1486 window_turns: sample.window_turns,
1487 });
1488 // Opt-in entropy watch: threshold + hysteresis + cooldown. The alert is an
1489 // observation (host-facing); with `notify_model` it is ALSO routed through
1490 // the kernel's own signal dispatch as a Heartbeat/Alert directive — High
1491 // urgency while running ⇒ a durable [SIGNAL] note on the turn we are about
1492 // to emit anyway, never an extra provider call.
1493 if self.entropy.should_alert(&self.entropy_watch, &sample) {
1494 self.observations.push(KernelObservation::EntropyAlert {
1495 turn: sample.turn,
1496 score: sample.score,
1497 threshold: self.entropy_watch.threshold,
1498 });
1499 if self.entropy_watch.notify_model {
1500 use crate::types::signal::{
1501 RuntimeSignal, SignalSource, SignalType, Urgency,
1502 };
1503 let signal = RuntimeSignal::new(
1504 SignalSource::Heartbeat,
1505 SignalType::Alert,
1506 Urgency::High,
1507 format!(
1508 "[entropy] session disorder {:.2} ≥ {:.2} (repeat {:.2} / failures {:.2} / pressure {:.2}). \
1509 Stop and reassess: state what is not working and try a different approach.",
1510 sample.score,
1511 self.entropy_watch.threshold,
1512 sample.repeat_pressure,
1513 sample.failure_rate,
1514 sample.rho,
1515 ),
1516 )
1517 .with_dedupe(format!("entropy_alert:{}", sample.turn));
1518 let _ = self.dispatch_signal(signal);
1519 }
1520 }
1521
1522 // Turn boundary: drain any kernel-queued signals into context so they
1523 // are seen on the next reasoning turn (ready queue → running).
1524 self.drain_queued_signals();
1525
1526 self.phase = LoopPhase::Reason;
1527 self.emit_call_llm()
1528 }
1529
1530 LoopEvent::MilestoneResult { result } => self.handle_milestone_result(result),
1531
1532 LoopEvent::SubAgentCompleted { result } => self.handle_sub_agent_completed(result),
1533
1534 LoopEvent::Complete => self.terminate(TerminationReason::Completed, None),
1535
1536 LoopEvent::Timeout => {
1537 // A timed-out tool batch commits per-call timeout error results — the trained
1538 // convention ("command timed out" as a visible error) — so the model sees which
1539 // call stalled and can verify or change approach. Only a Reason-phase timeout
1540 // (nothing model-visible pending) keeps the rollback + note path.
1541 if let LoopPhase::Act { tool_calls } = &self.phase {
1542 if !tool_calls.is_empty() {
1543 let results: Vec<ToolResult> = tool_calls
1544 .iter()
1545 .map(|call| ToolResult {
1546 call_id: call.id.clone(),
1547 output: Content::Text(format!(
1548 "Tool call `{}` timed out before completing. The operation \
1549 may or may not have taken effect — verify before assuming, \
1550 then retry with a smaller step or a faster approach.",
1551 call.name
1552 )),
1553 durable_content: None,
1554 is_error: true,
1555 is_fatal: false,
1556 error_kind: Some(ToolErrorKind::Timeout),
1557 token_count: None,
1558 })
1559 .collect();
1560 return self.feed(LoopEvent::ToolResults { results });
1561 }
1562 }
1563 let reason = RollbackReason::Timeout;
1564 let note = Message::user(super::rollback::build_rollback_note(
1565 &reason,
1566 self.ctx.config.verbose_control_notes,
1567 ));
1568 self.rollback(reason);
1569 self.ctx
1570 .push_signal(note.content.as_text().unwrap_or_default().to_string());
1571 self.phase = LoopPhase::Reason;
1572 self.emit_call_llm()
1573 }
1574 }
1575 }
1576
1577 /// Drain observations emitted during the last `start`/`feed` call.
1578 pub fn take_observations(&mut self) -> Vec<KernelObservation> {
1579 std::mem::take(&mut self.observations)
1580 }
1581
1582 /// ③ the pacing trap. The model PROPOSES `pace(next, delay_ms?, reason)`; the kernel
1583 /// ADJUDICATES: malformed → governance-style rollback note; sleep delay clamped into
1584 /// the spec's [min,max]; continue/sleep at the round cap coerced to stop("max_rounds");
1585 /// stop with standing acceptance criteria routes through the O4 criteria gate ONCE
1586 /// (one bounded self-check turn) before being honored. An allowed pace ends the round:
1587 /// the decision is stashed for LoopResult, a synthetic tool result closes the
1588 /// transcript pair, and the strip-tools final-report turn finishes the round.
1589 fn handle_pace_call(&mut self, call: ToolCall) -> LoopAction {
1590 use crate::types::result::{PaceAction, PaceDecision};
1591
1592 let spec = self
1593 .run_spec
1594 .as_ref()
1595 .and_then(|r| r.loop_round.as_ref())
1596 .cloned()
1597 .unwrap_or_default();
1598
1599 let next = call
1600 .arguments
1601 .get("next")
1602 .and_then(|v| v.as_str())
1603 .unwrap_or("");
1604 let reason = call
1605 .arguments
1606 .get("reason")
1607 .and_then(|v| v.as_str())
1608 .unwrap_or("")
1609 .to_string();
1610 let proposed_delay = call.arguments.get("delay_ms").and_then(|v| v.as_u64());
1611
1612 let mut action = match next {
1613 "continue" => PaceAction::Continue,
1614 "sleep" => PaceAction::Sleep,
1615 "stop" => PaceAction::Stop,
1616 other => {
1617 // Malformed proposal: governance-style directive note + fresh reason turn.
1618 let rejection_reason =
1619 format!("invalid pace next={other:?} (expected continue|sleep|stop)");
1620 let note = super::rollback::build_control_rejection_note(
1621 "pace",
1622 &rejection_reason,
1623 self.ctx.config.verbose_control_notes,
1624 );
1625 self.push_synthetic_tool_result(
1626 &call.id,
1627 "pace rejected: next must be continue|sleep|stop",
1628 false,
1629 );
1630 self.ctx.push_signal(note);
1631 self.phase = LoopPhase::Reason;
1632 return self.emit_call_llm();
1633 }
1634 };
1635 let mut coerced_from: Option<String> = None;
1636
1637 // Round-cap coercion: both the run spec and reservation grant bound local rounds.
1638 if action != PaceAction::Stop {
1639 let granted_rounds = self.budget_grant.as_ref().and_then(|grant| grant.rounds);
1640 let max_rounds = if granted_rounds == Some(0) {
1641 Some(0)
1642 } else {
1643 spec.max_rounds
1644 };
1645 if let Some(max) = max_rounds {
1646 if self.local_rounds_completed.saturating_add(1) >= max {
1647 coerced_from = Some(format!("{} (max_rounds={max})", action.label()));
1648 action = PaceAction::Stop;
1649 }
1650 }
1651 }
1652
1653 // O4 routing: a stop with standing criteria takes the existing criteria-gate
1654 // self-check turn first; the model re-decides with the checklist in view.
1655 if action == PaceAction::Stop
1656 && self.criteria_gate_enabled
1657 && !self.criteria_gate_fired
1658 && !self.ctx.partitions.task_state.criteria.is_empty()
1659 {
1660 self.criteria_gate_fired = true;
1661 let criteria = self.ctx.partitions.task_state.criteria.clone();
1662 self.push_synthetic_tool_result(
1663 &call.id,
1664 "pace(stop) noted — verify the acceptance criteria first, then pace again.",
1665 false,
1666 );
1667 self.ctx.push_signal(format!(
1668 "[CRITERIA CHECK] You proposed stopping the loop. Verify each acceptance \
1669 criterion first: {}. If any is NOT met, continue working (or pace(continue)). \
1670 If all are met, call pace(stop) again.",
1671 criteria.join(" | ")
1672 ));
1673 self.observations
1674 .push(KernelObservation::CriteriaGateFired {
1675 turn: self.turn,
1676 criteria,
1677 });
1678 self.phase = LoopPhase::Reason;
1679 return self.emit_call_llm();
1680 }
1681
1682 // Sleep clamp into [min, max].
1683 let delay_ms = if action == PaceAction::Sleep {
1684 let raw = proposed_delay.unwrap_or(spec.min_sleep_ms.unwrap_or(60_000));
1685 let mut clamped = raw;
1686 if let Some(min) = spec.min_sleep_ms {
1687 clamped = clamped.max(min);
1688 }
1689 if let Some(max) = spec.max_sleep_ms {
1690 clamped = clamped.min(max);
1691 }
1692 if clamped != raw && coerced_from.is_none() {
1693 coerced_from = Some(format!("sleep {raw}ms (clamped)"));
1694 }
1695 Some(clamped)
1696 } else {
1697 None
1698 };
1699
1700 self.local_rounds_completed = self.local_rounds_completed.saturating_add(1);
1701 let decision = PaceDecision {
1702 action,
1703 delay_ms,
1704 reason,
1705 coerced_from,
1706 };
1707 self.observations.push(KernelObservation::RoundPaced {
1708 turn: self.turn,
1709 round: self.local_rounds_completed,
1710 decision: decision.clone(),
1711 });
1712 self.push_synthetic_tool_result(
1713 &call.id,
1714 &format!(
1715 "pace acknowledged: {}{} — wrap up with a brief round report.",
1716 decision.action.label(),
1717 decision
1718 .delay_ms
1719 .map(|d| format!(" {d}ms"))
1720 .unwrap_or_default()
1721 ),
1722 false,
1723 );
1724 self.pending_pace = Some(decision);
1725 self.pending_termination = Some(TerminationReason::Completed);
1726 self.phase = LoopPhase::Reason;
1727 self.emit_call_llm()
1728 }
1729
1730 /// Close a kernel-handled tool call's transcript pair with a synthetic result so
1731 /// providers always see call → result.
1732 fn push_synthetic_tool_result(&mut self, call_id: &str, output: &str, is_error: bool) {
1733 let msg = Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1734 call_id: call_id.into(),
1735 output: output.to_string(),
1736 is_error,
1737 durable_content: None,
1738 }]);
1739 let tokens = self.message_tokens(&msg);
1740 self.ctx.push_history(msg, tokens);
1741 }
1742
1743 fn terminate(
1744 &mut self,
1745 termination: TerminationReason,
1746 final_message: Option<Message>,
1747 ) -> LoopAction {
1748 // Commit the final response into history so subsequent session restores
1749 // include the complete transcript: user → [tool turns] → final assistant.
1750 if let Some(ref msg) = final_message {
1751 let tokens = self.message_tokens(msg);
1752 self.ctx.push_history(msg.clone(), tokens);
1753 }
1754 // ③ attach the round's pacing decision. Stashed by the trap when the model
1755 // called `pace`; otherwise the spec's default_action ("stop" for goal loops,
1756 // "sleep" for cron loops) — but ONLY on a clean Completed. NoProgress /
1757 // ContextOverflow / Error rounds stop and surface (nothing nags the model).
1758 let pace_decision = self.pending_pace.take().or_else(|| {
1759 let spec = self.run_spec.as_ref()?.loop_round.as_ref()?;
1760 if termination != TerminationReason::Completed {
1761 return Some(crate::types::result::PaceDecision {
1762 action: crate::types::result::PaceAction::Stop,
1763 delay_ms: None,
1764 reason: format!("round terminated: {}", termination.label()),
1765 coerced_from: None,
1766 });
1767 }
1768 match spec.default_action.as_deref() {
1769 Some("sleep") => Some(crate::types::result::PaceDecision {
1770 action: crate::types::result::PaceAction::Sleep,
1771 delay_ms: spec.min_sleep_ms.or(Some(60_000)),
1772 reason: "default_action: sleep (cron loop)".to_string(),
1773 coerced_from: None,
1774 }),
1775 _ => Some(crate::types::result::PaceDecision {
1776 action: crate::types::result::PaceAction::Stop,
1777 delay_ms: None,
1778 reason: "default_action: stop (no pace call this round)".to_string(),
1779 coerced_from: None,
1780 }),
1781 }
1782 });
1783 let result = LoopResult {
1784 termination,
1785 final_message,
1786 turns_used: self.turn,
1787 total_tokens_used: self.total_tokens,
1788 loop_continue: None,
1789 classify_branch: None,
1790 tournament_winner: None,
1791 pace_decision,
1792 };
1793 self.set_lifecycle(TaskLifecycle::Done(termination), None);
1794 // spc_008-04: `SupervisionPolicy.child_failure` was structural-only (spc_002-07) until now
1795 // — the root's own terminal transition is the one place in production a task with children
1796 // (workflow nodes; see spc_002-03's finding that only root ever has children today) can
1797 // reliably be observed terminating. `Restart`/`Retry`/`Ignore` are deliberately treated as
1798 // `Isolate` for now (no policy distinguishes them yet) — a documented placeholder per
1799 // spc_008-04's own scope, not silently dropped.
1800 match self
1801 .tasks
1802 .get("root")
1803 .map(|root| root.supervision.child_failure)
1804 .unwrap_or_default()
1805 {
1806 crate::scheduler::tcb::ChildFailurePolicy::Propagate => {
1807 self.tasks.cancel_children("root");
1808 }
1809 crate::scheduler::tcb::ChildFailurePolicy::Isolate
1810 | crate::scheduler::tcb::ChildFailurePolicy::Restart
1811 | crate::scheduler::tcb::ChildFailurePolicy::Retry
1812 | crate::scheduler::tcb::ChildFailurePolicy::Ignore => {}
1813 }
1814 LoopAction::Done { result }
1815 }
1816
1817 /// Build the `CallLLM` action with a structured `RenderedContext`.
1818 /// Meta-tools (skill / memory / knowledge) are appended to the tool list
1819 /// when configured. When `pending_termination` is set, tools are stripped
1820 /// to force a plain-text response before the loop terminates.
1821 fn emit_call_llm(&mut self) -> LoopAction {
1822 // Calling the provider is definitionally "running" — the single funnel for entering the
1823 // Running lifecycle (covers start, resume, signal-driven turns, budget final-call).
1824 self.set_lifecycle(TaskLifecycle::Running, None);
1825
1826 // M1 収口 (completed): the budget verdict lives at the same single funnel. Every edge that
1827 // requests a provider call — tool-turn completion, milestone retry, signal-forced turns,
1828 // criteria gate, recovery ladders — passes the three axes here, so a loop that completes
1829 // no tool turns (and therefore never increments `turn`) is still bounded by the token and
1830 // wall axes. The final-report turn itself (`pending_termination` set) is exempt: it is the
1831 // one bounded call the verdict buys, so the check fires exactly once per exhaustion.
1832 if self.pending_termination.is_none() {
1833 if let Some(term) = super::tcb::budget_verdict(&self.root_tcb(), self.last_now_ms) {
1834 let budget = match term {
1835 TerminationReason::MaxTurns => "max_turns",
1836 TerminationReason::Timeout => "wall_time",
1837 _ => "token_budget",
1838 };
1839 self.observations.push(KernelObservation::BudgetExceeded {
1840 turn: self.turn,
1841 budget: budget.to_string(),
1842 operation_id: String::new(),
1843 reservation_id: self
1844 .budget_grant
1845 .as_ref()
1846 .map(|grant| grant.reservation_id.clone()),
1847 });
1848 self.pending_termination = Some(term);
1849 }
1850 }
1851 self.checkpoint.history_len = self.ctx.partitions.history.messages.len();
1852 self.checkpoint.signals_len = self.ctx.partitions.signals.len();
1853 self.checkpoint.task_state = Some(self.ctx.partitions.task_state.clone());
1854 self.delivered_signals_len = self.ctx.partitions.signals.len();
1855 self.observations.push(KernelObservation::CheckpointTaken {
1856 turn: self.turn,
1857 history_len: self.checkpoint.history_len as u32,
1858 });
1859
1860 let context = self.ctx.render();
1861 if let Some(overflow) = context.budget_overflow.clone() {
1862 self.observations
1863 .push(KernelObservation::ContextBudgetExceeded {
1864 turn: self.turn,
1865 overflow_kind: overflow.kind,
1866 required_tokens: overflow.required_tokens,
1867 max_tokens: overflow.max_tokens,
1868 });
1869 // P0-2 §C: only a `FixedContext` overflow (system + state_turn alone exceed the hard
1870 // window) is unrecoverable — compaction cannot touch that region, so terminate honestly.
1871 // A `ProtectedTail` overflow (a protected recent unit tips the budget after compaction
1872 // already ran) is NOT terminal: the observation records the over-budget tail, and the
1873 // context is still submitted. The provider decides; if it rejects with a 413, the
1874 // reactive recovery ladder (`recover_from_provider_error`) is the real backstop. Silently
1875 // terminating here would kill runs the provider could have accepted or recovered from.
1876 if matches!(
1877 overflow.kind,
1878 crate::context::renderer::ContextBudgetOverflowKind::FixedContext
1879 ) {
1880 self.delivered_signals_len = 0;
1881 return self.terminate(TerminationReason::ContextOverflow, None);
1882 }
1883 }
1884 let tools = if self.pending_termination.is_some() {
1885 Vec::new()
1886 } else {
1887 self.provider_tools()
1888 };
1889
1890 self.call_llm_action(context, tools)
1891 }
1892
1893 /// Rebuild the provider projection after a same-transition context mutation.
1894 ///
1895 /// External payload residency is committed after `ToolResults` has already produced its
1896 /// continuation. That mutation can change both rendered handle state and the conditional
1897 /// `read_result` meta-tool, but it must not cross a second scheduler boundary (budget verdict,
1898 /// checkpoint, or observation). Refresh only the projection and advertised-tool authority.
1899 pub(crate) fn refresh_call_llm_action(&mut self, action: &mut LoopAction) {
1900 if !matches!(action, LoopAction::CallLLM { .. }) {
1901 return;
1902 }
1903 let context = self.ctx.render();
1904 let tools = if self.pending_termination.is_some() {
1905 Vec::new()
1906 } else {
1907 self.provider_tools()
1908 };
1909 self.exposed_tool_names = Some(tools.iter().map(|tool| tool.name.clone()).collect());
1910 *action = LoopAction::CallLLM { context, tools };
1911 }
1912
1913 fn provider_tools(&self) -> Vec<ToolSchema> {
1914 let mut tools = self.tools.clone();
1915 tools.extend(self.ctx.meta_tool_schemas());
1916
1917 if let Some(ref spec) = self.run_spec {
1918 use crate::context::manager::is_exposure_exempt_meta_tool;
1919 use crate::types::capability::CapabilityKind;
1920 tools.retain(|tool| {
1921 let kind = match tool.name.as_str() {
1922 "skill" => CapabilityKind::Skill,
1923 "memory" => CapabilityKind::Memory,
1924 "knowledge" => CapabilityKind::Knowledge,
1925 _ => CapabilityKind::Tool,
1926 };
1927 // Kernel-owned meta surfaces are exempt from the ID axis: `allowedToolIds` lists
1928 // the run's *task* tools, and silently deleting the model's route back to kernel
1929 // state (load a skill, re-read an evicted result) is never what that means — the
1930 // same rationale the pace tool encodes below. The KIND axis still applies, so a
1931 // sub-agent isolation filter that admits only `Tool` still excludes
1932 // skill/memory/knowledge outright. See `EXPOSURE_EXEMPT_META_TOOLS`.
1933 if is_exposure_exempt_meta_tool(&tool.name) {
1934 return spec.capability_filter.allows_kind(kind);
1935 }
1936 let desc = crate::types::capability::CapabilityDescriptor::marker(
1937 kind,
1938 tool.name.clone(),
1939 &tool.description,
1940 );
1941 spec.capability_filter.allows(&desc)
1942 });
1943 }
1944
1945 // ─── Filter B: which of the ceiling-admitted tools are exposed *this* epoch ───
1946 //
1947 // The baseline is the pre-activation exposure policy under the ceiling:
1948 //
1949 // ```text
1950 // exposed = META ∪ ((baseline ∪ stableCore ∪ ⋃ activeSkills.allowed_tools) ∩ ceiling)
1951 // ```
1952 //
1953 // - `ceiling` = the run-level capability filter, already applied above as filter A, so the
1954 // `∩ ceiling` term needs no code here: a baseline entry outside the ceiling was simply
1955 // never in `tools` to begin with (D3 — silent intersection, no start_run error, the same
1956 // fold every id-list surface uses).
1957 // - `activeSkills.allowed_tools` covers only declared lists. An active skill that declares
1958 // nothing contributes ∅ and the surface stays at the baseline.
1959 // - Missing and empty baselines both mean meta-tools (+stable-core) only.
1960 //
1961 // Kernel-owned meta surfaces remain exempt so the model can still load a skill —
1962 // and still re-read an evicted result, which the truncation marker explicitly instructs it
1963 // to do. Byte-stable within an epoch either way: the set changes only at an
1964 // activation/deactivation boundary.
1965 let baseline = self
1966 .run_spec
1967 .as_ref()
1968 .and_then(|s| s.exposure_baseline.as_ref())
1969 .map(Vec::as_slice)
1970 .unwrap_or_default();
1971 let baseline: std::collections::HashSet<&CompactString> = baseline.iter().collect();
1972 let declared = self.ctx.active_skill_tool_filter().unwrap_or_default();
1973 let stable = &self.ctx.stable_core_tools;
1974 tools.retain(|tool| {
1975 crate::context::manager::is_exposure_exempt_meta_tool(&tool.name)
1976 || baseline.contains(&tool.name)
1977 || stable.contains(&tool.name)
1978 || declared.contains(&tool.name)
1979 });
1980
1981 // ③ pace meta-tool: exposed ONLY when this run is a round of a paced loop
1982 // (run_spec.loop_round present) — the same conditional-exposure pattern as
1983 // skill/memory/read_result. Pushed after every filter: pacing is kernel-owned
1984 // and must never be narrowed away by skills or capability filters.
1985 if self
1986 .run_spec
1987 .as_ref()
1988 .and_then(|r| r.loop_round.as_ref())
1989 .is_some()
1990 {
1991 tools.push(pace_tool_schema());
1992 }
1993
1994 tools
1995 }
1996
1997 /// The single exit for every provider call. Records the advertised toolset (P1 fail-closed
1998 /// dispatch arms against exactly what the model was shown this turn) and returns the action.
1999 fn call_llm_action(
2000 &mut self,
2001 context: crate::context::renderer::RenderedContext,
2002 tools: Vec<ToolSchema>,
2003 ) -> LoopAction {
2004 self.exposed_tool_names = Some(tools.iter().map(|tool| tool.name.clone()).collect());
2005 LoopAction::CallLLM { context, tools }
2006 }
2007
2008 /// Canonical checkpoint projection of the most recently advertised tool surface.
2009 pub(crate) fn advertised_tool_ids(&self) -> Option<Vec<String>> {
2010 self.exposed_tool_names.as_ref().map(|names| {
2011 let mut names: Vec<String> = names.iter().map(ToString::to_string).collect();
2012 names.sort();
2013 names
2014 })
2015 }
2016
2017 /// Restore the exact provider-advertised surface captured by a canonical checkpoint.
2018 pub(crate) fn restore_advertised_tool_ids(&mut self, names: Option<Vec<String>>) {
2019 self.exposed_tool_names = names.map(|names| names.into_iter().map(Into::into).collect());
2020 }
2021
2022 pub fn rollback(&mut self, reason: RollbackReason) {
2023 self.ctx
2024 .partitions
2025 .history
2026 .messages
2027 .truncate(self.checkpoint.history_len);
2028 self.ctx
2029 .partitions
2030 .signals
2031 .truncate(self.checkpoint.signals_len);
2032 if let Some(ref state) = self.checkpoint.task_state {
2033 self.ctx.partitions.task_state = state.clone();
2034 }
2035 // Rolled-back turns never reach the boundary sample point; accrue here so the
2036 // disorder they evidence lands in the next completed turn's entropy window.
2037 self.entropy.note_rollback();
2038 self.observations.push(KernelObservation::Rollbacked {
2039 turn: self.turn,
2040 checkpoint_history_len: self.checkpoint.history_len as u32,
2041 reason: Some(reason),
2042 });
2043 }
2044
2045 /// Which tool results still roll the turn back. Models are trained on "tool failed → an
2046 /// error tool result stays in history and the model adapts" — the convention every major
2047 /// harness produces — so fatal / timeout / provider-failure / denied results all COMMIT as
2048 /// visible errors (same evidence class as the governance-denial A/B: erasing the attempt
2049 /// makes the model re-try what it cannot see). The one survivor is `UserInterrupt`: the
2050 /// user's escape is a host-owned control event, not model feedback.
2051 fn rollback_reason_for_tool_result(&self, result: &ToolResult) -> Option<RollbackReason> {
2052 match result.error_kind {
2053 Some(ToolErrorKind::UserInterrupt) => Some(RollbackReason::UserInterrupt),
2054 _ => None,
2055 }
2056 }
2057}
2058
2059#[cfg(test)]
2060#[path = "tests.rs"]
2061mod tests;
2062
2063/// ③ the `pace` meta-tool schema — exposed only on loop-round runs.
2064fn pace_tool_schema() -> crate::types::message::ToolSchema {
2065 crate::types::message::ToolSchema {
2066 name: compact_str::CompactString::new("pace"),
2067 description: "End this round and decide what happens next: continue immediately, \
2068sleep then run another round, or stop the loop. Call this when the round's work is done."
2069 .to_string(),
2070 parameters: serde_json::json!({
2071 "type": "object",
2072 "properties": {
2073 "next": { "type": "string", "enum": ["continue", "sleep", "stop"] },
2074 "delay_ms": { "type": "integer", "minimum": 0 },
2075 "reason": { "type": "string" }
2076 },
2077 "required": ["next", "reason"]
2078 }),
2079 }
2080}