Skip to main content

deepstrike_core/scheduler/state_machine/
mod.rs

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