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