Skip to main content

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