Skip to main content

deepstrike_core/scheduler/state_machine/
mod.rs

1use std::collections::HashMap;
2
3use super::milestone::MilestoneTracker;
4use super::policy::LoopPolicy;
5use super::tcb::{ScheduleDecision, TaskState, TaskTable, Tcb, WaitReason};
6use crate::AgentRunSpec;
7use crate::context::manager::ContextManager;
8use crate::governance::pipeline::GovernancePipeline;
9use crate::governance::repeat_fuse::RepeatFuseConfig;
10use crate::signals::router::SignalRouter;
11use crate::types::result::SubAgentResult;
12use crate::context::renderer::RenderedContext;
13// `pub use` so external integration tests that glob `state_machine::*` resolve the observation
14// type here — exactly as they did for the former `pub enum LoopObservation` this replaced.
15pub use crate::runtime::kernel::KernelObservation;
16use crate::runtime::session::RollbackReason;
17use crate::types::message::{
18    Content, ContentPart, Message, ToolCall, ToolErrorKind, ToolResult, ToolSchema,
19};
20use crate::types::milestone::MilestoneCheckResult;
21use crate::types::result::{LoopResult, TerminationReason};
22use crate::types::signal::RuntimeSignal;
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.
29fn compact_tool_args(args: &serde_json::Value) -> String {
30    if args.is_null() {
31        return String::new();
32    }
33    let s = args.to_string();
34    if s == "{}" {
35        return String::new();
36    }
37    const MAX: usize = 48;
38    if s.chars().count() <= MAX {
39        s
40    } else {
41        format!("{}…", s.chars().take(MAX).collect::<String>())
42    }
43}
44
45/// The *turn step* of the L* execution loop (M1d).
46///
47/// Schedulability (`Ready/Running/Blocked/Suspended/Done`) is no longer carried here — it lives
48/// on the root task's [`TaskState`] in the kernel's `TaskTable`, queried via
49/// [`LoopStateMachine::lifecycle`]. `LoopPhase` is now orthogonal: it only records *which step of a
50/// running turn* the loop is in. When the task is `Ready/Suspended/Done`, the phase value is
51/// inert (left at its last step) and ignored.
52#[derive(Debug, Clone)]
53pub enum LoopPhase {
54    Reason,
55    Act { tool_calls: Vec<ToolCall> },
56    Observe { results: Vec<ToolResult> },
57    Delta { pressure: f64 },
58}
59
60/// Why the loop entered `Suspended` state.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SuspendReason {
63    /// Governance `AskUser` — waiting for SDK to resolve human approval.
64    AskUser,
65    /// Sub-agent spawned — waiting for sub-agent to complete.
66    SubAgentAwait,
67    /// Externally requested suspension.
68    External,
69}
70
71/// What the loop is blocked waiting for.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum BlockReason {
74    /// Awaiting a tool's continuation (tool suspend pattern).
75    ToolSuspend,
76    /// Awaiting milestone evaluation result.
77    MilestoneAwait,
78}
79
80/// Events fed into the state machine from the SDK layer.
81#[derive(Debug)]
82pub enum LoopEvent {
83    Start {
84        task: RuntimeTask,
85    },
86    LLMResponse {
87        message: Message,
88    },
89    ToolResults {
90        results: Vec<ToolResult>,
91    },
92    /// Inbound signal from SignalRouter — Critical/High urgency may interrupt.
93    Signal {
94        signal: RuntimeSignal,
95    },
96    /// Result of evaluating the current milestone phase's criteria.
97    /// Feed this back after handling `LoopAction::EvaluateMilestone`.
98    MilestoneResult {
99        result: MilestoneCheckResult,
100    },
101    /// Sub-agent run completed — result is injected into the loop as context.
102    SubAgentCompleted {
103        result: SubAgentResult,
104    },
105    Timeout,
106}
107
108/// Actions the state machine outputs — SDK layer executes the I/O.
109#[derive(Debug)]
110pub enum LoopAction {
111    /// Structured context ready for a provider call.
112    /// `context.system_text` → provider system param.
113    /// `context.turns`       → provider messages array (strictly alternating).
114    /// `tools`               → tool schemas (skill / memory / knowledge / user tools).
115    CallLLM {
116        context: RenderedContext,
117        tools: Vec<ToolSchema>,
118    },
119    ExecuteTools {
120        calls: Vec<ToolCall>,
121    },
122    Done {
123        result: LoopResult,
124    },
125    /// Kernel requests the SDK to evaluate the current milestone phase.
126    ///
127    /// The SDK should assess `criteria` against the agent's output using the
128    /// specified `verifier`, then feed back `LoopEvent::MilestoneResult { result }`.
129    EvaluateMilestone {
130        phase_id: String,
131        criteria: Vec<String>,
132        verifier: Option<crate::types::milestone::MilestoneVerifier>,
133        required_evidence: Vec<String>,
134    },
135    /// Kernel is suspended — SDK must resolve (e.g. human approval) and feed `Resume`.
136    AwaitingResume,
137}
138
139/// Payload held while the loop is in `Suspended`.
140#[derive(Debug, Clone)]
141pub(super) enum SuspendState {
142    /// Governance AskUser — awaiting `Resume { approved_calls, denied_calls }`.
143    AskUser {
144        calls: Vec<ToolCall>,
145        gated_reasons: HashMap<String, String>,
146    },
147    /// Sub-agent spawn — awaiting `SubAgentCompleted` for each listed agent id.
148    SubAgentAwait {
149        agent_ids: Vec<String>,
150    },
151}
152
153pub(super) enum GateToolOutcome {
154    Proceed,
155    Blocked(LoopAction),
156    Suspended,
157}
158
159/// Snapshot of context lengths captured just before each LLM call.
160/// Used internally to restore state on rollback.
161#[derive(Debug, Clone, Default)]
162pub struct TurnCheckpoint {
163    pub history_len: usize,
164    pub signals_len: usize,
165    pub task_state: Option<crate::context::task_state::TaskState>,
166}
167
168/// Pure state machine for the L* execution loop. No I/O — only state transitions.
169///
170/// Internal engine backing [`crate::runtime::KernelRuntime`]. Exposed for in-crate
171/// use and tests; external callers should drive the kernel through `KernelRuntime`.
172#[doc(hidden)]
173pub struct LoopStateMachine {
174    pub phase: LoopPhase,
175    pub turn: u32,
176    pub ctx: ContextManager,
177    pub tools: Vec<ToolSchema>,
178    pub observations: Vec<KernelObservation>,
179    pub(super) policy: LoopPolicy,
180    pub(super) total_tokens: u64,
181    /// L1 (RunGroup): cumulative tokens spent by *other* members of this run's governance domain,
182    /// seeded at boot via `seed_group_budget`. The run-level token cap is enforced against
183    /// `group_tokens_base + total_tokens` so the budget spans the whole group, not one vehicle.
184    /// 0 (default) ⇒ no group (N=1) ⇒ pre-L1 per-kernel behavior (byte-identical).
185    pub(super) group_tokens_base: u64,
186    /// L1 (RunGroup): sub-agents spawned by *other* members of this run's governance domain, seeded
187    /// at boot. `max_total_subagents` is enforced against `group_spawns_base + local spawns`. 0 ⇒ N=1.
188    pub(super) group_spawns_base: u32,
189    /// When set, the next LLM call strips tools to force a text response,
190    /// then terminates with this reason once the response arrives.
191    pub(super) pending_termination: Option<TerminationReason>,
192    /// Reactive context-overflow recovery: consecutive compact-and-retry attempts since the last
193    /// successful provider turn. Bounds the recovery ladder (anti-spiral) and resets to 0 on any
194    /// `LLMResponse`, mirroring the per-turn `hasAttemptedReactiveCompact` reset the SDK runners
195    /// used to own. See `recover_from_provider_error`.
196    pub(super) recovery_attempts: u8,
197    /// Max-output-tokens recovery: consecutive continue-and-retry turns since the model last
198    /// finished a response WITHOUT hitting the output cap. When a turn is cut off at the cap
199    /// (provider `stop_reason` = max_tokens/length) the kernel keeps the partial, nudges the model
200    /// to resume mid-thought, and re-calls — bounded by `MAX_OUTPUT_RECOVERY` (mirrors query.ts's
201    /// MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). Resets to 0 on any non-truncated response.
202    pub(super) output_recovery_attempts: u8,
203    /// Transient carrier for the provider `stop_reason` of the in-flight response, set by the
204    /// kernel ABI just before `feed(LLMResponse)` and taken (cleared) inside it. `None` when the
205    /// SDK/provider doesn't report one (every non-Anthropic provider today ⇒ no-op).
206    pub(super) pending_stop_reason: Option<String>,
207    /// Number of history messages present at session start (after preload_history).
208    /// drain_new_messages() returns the slice from this offset onward.
209    pub(super) session_history_baseline: usize,
210    pub(super) checkpoint: TurnCheckpoint,
211    /// Milestone contract tracker (extracted to reduce state machine bloat).
212    pub(super) milestone: MilestoneTracker,
213    pub run_spec: Option<AgentRunSpec>,
214    /// M1 収口: the single source of truth for schedulability *and* sub-agent lineage. Root is
215    /// task `"root"`; each sub-agent is a child task carrying its `ProcInfo`. The former
216    /// `ProcessTable` is now a derived view over this (`agent_process(es)` rebuild `AgentProcess`
217    /// rows on demand via `AgentProcess::from_tcb`).
218    pub(super) tasks: TaskTable,
219    /// Optional governance pipeline. When set, every tool call proposed by the
220    /// model is evaluated before `ExecuteTools` is emitted. `None` (default)
221    /// skips the gate entirely, preserving the pre-governance behavior.
222    pub(super) governance: Option<GovernancePipeline>,
223    /// Optional resource quota evaluated at the syscall trap (M2). `None` (default) leaves spawn /
224    /// memory syscalls unconditionally allowed, preserving pre-M2 behavior.
225    pub(super) resource_quota: Option<crate::governance::quota::ResourceQuota>,
226    /// Timestamps of recent allowed `WriteMemory` syscalls, for the rolling-window rate limit.
227    /// Only populated when `resource_quota.memory_writes_per_window` is set.
228    pub(super) memory_write_times: Vec<u64>,
229    /// Optional long-term memory policy (`set_memory_policy`). `None` (default) preserves
230    /// pre-policy behavior: default-rule validation + verbatim retrieval `top_k`.
231    pub(super) memory_policy: Option<crate::mm::memory::MemoryPolicy>,
232    /// Optional in-kernel signal router. When set, inbound signals are routed
233    /// through dedup + attention policy + queue here (the kernel owns disposition).
234    /// `None` (default) keeps the legacy hardcoded urgency handling in `feed`.
235    pub(super) signal_router: Option<SignalRouter>,
236    /// Wall-clock timestamp of the first `ProviderResult.now_ms` received.
237    /// Used by the wall-time budget axis in `SchedulerBudget::should_terminate`.
238    pub(super) started_at_ms: Option<u64>,
239    /// Most-recent `now_ms` value from `ProviderResult`, forwarded to the budget check.
240    pub(super) last_now_ms: Option<u64>,
241    /// Tool batch awaiting `Resume` after an AskUser suspend.
242    pub(super) suspend_state: Option<SuspendState>,
243    /// Denied tool results to merge into the next `ToolResults` feed after resume.
244    pub(super) pending_denied_results: Vec<ToolResult>,
245    /// W0: an in-flight workflow DAG, when one is loaded. The kernel spawns its ready nodes as
246    /// gated batches (each through `evaluate_syscall(Syscall::Spawn)`) and advances on
247    /// completions. `None` (default) preserves the single-spawn `spawn_sub_agent` behavior.
248    pub(super) workflow: Option<crate::orchestration::workflow::WorkflowRun>,
249    /// O6: repeat-fuse thresholds (the hard rungs above the 2c soft STOP). Default enabled with
250    /// generous thresholds; tune/disable via `SetRepeatFuse` / `ConfigureRun.repeat_fuse`.
251    pub(super) repeat_fuse: RepeatFuseConfig,
252    /// O6: the previous turn's action signature (non-meta `name(args)` joined — the same key the
253    /// 2c STOP uses). NOT part of the turn checkpoint: a fuse deny's rollback must not launder
254    /// the streak it just tripped on.
255    pub(super) repeat_sig: Option<String>,
256    /// O6: consecutive turns whose signature equalled `repeat_sig` (1 = first occurrence).
257    pub(super) repeat_count: u32,
258    /// O4: turn-end criteria gate (the Stop-hook analog). When the model finishes (no tool calls)
259    /// while explicit acceptance criteria stand, inject ONE bounded self-check turn before
260    /// accepting `Completed`. 2c guards "won't stop"; this guards "stops too early".
261    pub(super) criteria_gate_enabled: bool,
262    /// O4: whether the gate already fired this run (it fires at most once — no nag loops).
263    pub(super) criteria_gate_fired: bool,
264}
265
266mod signal;
267mod capability;
268mod gate;
269mod eviction;
270mod process;
271mod workflow;
272mod milestone_exec;
273
274impl LoopStateMachine {
275    fn message_tokens(&self, message: &Message) -> u32 {
276        message
277            .token_count
278            .unwrap_or_else(|| self.ctx.engine.count_message(message))
279    }
280
281    pub fn new(policy: LoopPolicy) -> Self {
282        let mut tasks = TaskTable::new();
283        // M1d: the root task carries the authoritative schedulability lifecycle. It starts
284        // `Ready`; `start()`/`resume_*` flip it to `Running`, suspends set `Suspended`, and
285        // `terminate()` sets `Done`. `phase` is now only the intra-turn step.
286        tasks.insert(Tcb::root("root", policy.clone()));
287        Self {
288            // Inert placeholder step; meaningful only while the root task is `Running`.
289            phase: LoopPhase::Reason,
290            turn: 0,
291            ctx: ContextManager::new(policy.max_tokens),
292            tools: Vec::new(),
293            observations: Vec::new(),
294            policy,
295            total_tokens: 0,
296            group_tokens_base: 0,
297            group_spawns_base: 0,
298            pending_termination: None,
299            recovery_attempts: 0,
300            output_recovery_attempts: 0,
301            pending_stop_reason: None,
302            session_history_baseline: 0,
303            checkpoint: TurnCheckpoint::default(),
304            milestone: MilestoneTracker::new(),
305            run_spec: None,
306            tasks,
307            governance: None,
308            resource_quota: None,
309            memory_write_times: Vec::new(),
310            memory_policy: None,
311            signal_router: Some(SignalRouter::new(64)),
312            started_at_ms: None,
313            last_now_ms: None,
314            suspend_state: None,
315            pending_denied_results: Vec::new(),
316            workflow: None,
317            repeat_fuse: RepeatFuseConfig::default(),
318            repeat_sig: None,
319            repeat_count: 0,
320            criteria_gate_enabled: true,
321            criteria_gate_fired: false,
322        }
323    }
324
325    /// O4: enable/disable the turn-end criteria gate (default enabled; no-op without criteria).
326    pub fn set_criteria_gate(&mut self, enabled: bool) {
327        self.criteria_gate_enabled = enabled;
328    }
329
330    /// O6: tune or disable the repeat fuse (see [`RepeatFuseConfig`]).
331    pub fn set_repeat_fuse(&mut self, config: RepeatFuseConfig) {
332        self.repeat_fuse = config;
333    }
334
335    /// O6: the active repeat-fuse config (for read-modify-write from the ABI event).
336    pub fn repeat_fuse_config(&self) -> RepeatFuseConfig {
337        self.repeat_fuse
338    }
339
340    /// The authoritative schedulability lifecycle of the loop (root task state). Replaces the
341    /// removed `LoopPhase::{Idle,Suspended,Blocked,Terminal}` reads.
342    pub fn lifecycle(&self) -> TaskState {
343        self.tasks.get("root").map(|t| t.state).unwrap_or(TaskState::Ready)
344    }
345
346    /// The wait reason while suspended/blocked, if any.
347    pub fn wait_reason(&self) -> Option<WaitReason> {
348        self.tasks.get("root").and_then(|t| t.wait.clone())
349    }
350
351    /// Whether the loop has terminated.
352    pub fn is_terminal(&self) -> bool {
353        matches!(self.lifecycle(), TaskState::Done(_))
354    }
355
356    /// Whether the loop is suspended awaiting external resolution.
357    pub fn is_suspended(&self) -> bool {
358        matches!(self.lifecycle(), TaskState::Suspended)
359    }
360
361    /// Set the root task's lifecycle (and wait reason). Single mutation point for schedulability.
362    fn set_lifecycle(&mut self, state: TaskState, wait: Option<WaitReason>) {
363        if let Some(root) = self.tasks.get_mut("root") {
364            root.state = state;
365            root.wait = wait;
366        } else {
367            let mut root = Tcb::root("root", self.policy.clone());
368            root.state = state;
369            root.wait = wait;
370            self.tasks.insert(root);
371        }
372    }
373
374    /// Build a transient root [`Tcb`] mirroring the current scheduling facts (budget counters,
375    /// wall-clock anchors, lifecycle). M1b uses this to run the pure `schedule()` spine in
376    /// parallel with the legacy budget path; later milestones promote it to the live task row.
377    fn root_tcb(&self) -> Tcb {
378        let mut tcb = Tcb::root("root", self.policy.clone());
379        tcb.budget.turns = self.turn;
380        // L1: the token-budget axis is evaluated against the whole governance domain's cumulative
381        // spend (this vehicle's `total_tokens` plus other members' `group_tokens_base`).
382        tcb.budget.total_tokens = self.total_tokens.saturating_add(self.group_tokens_base);
383        tcb.budget.started_at_ms = self.started_at_ms;
384        tcb.state = self.lifecycle();
385        tcb
386    }
387
388    /// Adjust the wall-clock budget axis at runtime.
389    pub fn set_wall_budget(&mut self, max_wall_ms: Option<u64>) {
390        self.policy.max_wall_ms = max_wall_ms;
391    }
392
393    /// Install a governance pipeline. Once set, all model-proposed tool calls
394    /// are evaluated before execution. Denied/rate-limited calls roll the turn
395    /// back (reusing the `GovernanceDenied` path); `AskUser` calls surface a
396    /// `ToolGated` observation for the SDK to enforce.
397    pub fn set_governance(&mut self, pipeline: GovernancePipeline) {
398        self.governance = Some(pipeline);
399    }
400
401    /// Install resource quotas (M2). Once set, `Spawn` and `WriteMemory` syscalls are bounded by
402    /// the quota at the trap. Not setting it (the default) leaves them unconditionally allowed.
403    pub fn set_resource_quota(&mut self, quota: crate::governance::quota::ResourceQuota) {
404        self.resource_quota = Some(quota);
405    }
406
407    /// L1 (RunGroup): seed the cumulative tokens already spent by other members of this run's
408    /// governance domain. The run-level token cap is then enforced against the group total. Seeding
409    /// 0 (the default) preserves pre-L1 per-vehicle behavior.
410    pub fn seed_group_budget(&mut self, tokens_spent: u64) {
411        self.group_tokens_base = tokens_spent;
412    }
413
414    /// L1 (RunGroup): seed the sub-agents already spawned by other members of this run's governance
415    /// domain. `max_total_subagents` is then enforced against the group total. 0 ⇒ pre-L1 behavior.
416    pub fn seed_group_spawns(&mut self, subagents_spawned: u32) {
417        self.group_spawns_base = subagents_spawned;
418    }
419
420    /// L1: this vehicle's cumulative sub-agent spawns this run — every child task ever registered in
421    /// the `TaskTable` (running + completed), distinct from the *instantaneous* running count. Used
422    /// for the cumulative spawn quota and read back by the SDK to charge the group ledger at run end.
423    pub fn local_subagents_spawned(&self) -> u32 {
424        self.tasks.all().iter().filter(|t| t.proc.is_some()).count() as u32
425    }
426
427    /// Install the long-term memory policy (`set_memory_policy`). Once set it gates `write_memory`
428    /// validation and bounds `query_memory` retrieval breadth. Not setting it (the default)
429    /// preserves pre-policy behavior.
430    pub fn set_memory_policy(&mut self, policy: crate::mm::memory::MemoryPolicy) {
431        self.memory_policy = Some(policy);
432    }
433
434    /// The installed memory policy, if any. `None` means default-rule validation + verbatim top_k.
435    pub fn memory_policy(&self) -> Option<&crate::mm::memory::MemoryPolicy> {
436        self.memory_policy.as_ref()
437    }
438
439    /// Feed the current wall-clock time (ms) to scheduler/governance budget axes.
440    pub fn set_observed_time(&mut self, now_ms: u64) {
441        if self.started_at_ms.is_none() {
442            self.started_at_ms = Some(now_ms);
443        }
444        self.last_now_ms = Some(now_ms);
445        if let Some(pipeline) = self.governance.as_mut() {
446            pipeline.set_time(now_ms);
447        }
448    }
449
450    /// Stash the in-flight response's provider `stop_reason` so `feed(LLMResponse)` can detect an
451    /// output-cap truncation. Set by the kernel ABI right before feeding the result; `None` clears it.
452    pub fn set_pending_stop_reason(&mut self, stop_reason: Option<String>) {
453        self.pending_stop_reason = stop_reason;
454    }
455
456    /// Pre-populate the history partition with messages from a prior session.
457    ///
458    /// Call **before** `start()` when resuming a conversation. Sets the baseline
459    /// so `drain_new_messages()` returns only the messages from the current run.
460    pub fn preload_history(&mut self, messages: Vec<Message>) {
461        for msg in messages {
462            let tokens = self.message_tokens(&msg);
463            self.ctx.push_history(msg, tokens);
464        }
465        self.session_history_baseline = self.ctx.partitions.history.messages.len();
466    }
467
468    /// Continue from preloaded history without appending a new user turn.
469    /// Use after `preload_history` when recovering a session that ended mid-run.
470    ///
471    /// If the last assistant turn has tool calls without matching tool results,
472    /// resumes with `ExecuteTools` instead of calling the LLM again.
473    pub fn resume_after_preload(&mut self) -> LoopAction {
474        self.observations.clear();
475        let calls = crate::runtime::repair::pending_tool_calls_from_messages(
476            &self.ctx.partitions.history.messages,
477        );
478        if !calls.is_empty() {
479            self.phase = LoopPhase::Act {
480                tool_calls: calls.clone(),
481            };
482            self.set_lifecycle(TaskState::Running, None);
483            return LoopAction::ExecuteTools { calls };
484        }
485        self.phase = LoopPhase::Reason;
486        self.emit_call_llm()
487    }
488
489    /// Return all messages added to history during the current run
490    /// (since the last `preload_history` call or since construction).
491    ///
492    /// Call after `LoopAction::Done` to get the complete turn transcript
493    /// for persistence to a SessionStore.
494    pub fn drain_new_messages(&self) -> Vec<Message> {
495        let history = &self.ctx.partitions.history.messages;
496        let start = self.session_history_baseline.min(history.len());
497        history[start..].to_vec()
498    }
499
500    pub fn start(&mut self, task: RuntimeTask) -> LoopAction {
501        self.observations.clear();
502        self.ctx.init_task(task.goal.clone(), task.criteria.clone());
503
504        let user_msg = "Proceed with the task described in [TASK STATE].".to_string();
505
506        // User message goes into history so it appears at the correct chronological
507        // position: [prior turns...] → [current user message] — LLM reads left-to-right
508        // and responds to the last message. working is reserved for runtime signals only.
509        // Estimate tokens (1 token ≈ 4 chars) with a minimum of 1 so the renderer
510        // does not skip this message (it skips zero-token entries).
511        let user_tokens = self.ctx.engine.count(&user_msg).max(1);
512        self.ctx.push_history(Message::user(user_msg), user_tokens);
513        self.phase = LoopPhase::Reason;
514        // Root task (seeded `Ready` in `new()`) becomes `Running`; `emit_call_llm` sets it.
515        self.emit_call_llm()
516    }
517
518    pub fn feed(&mut self, event: LoopEvent) -> LoopAction {
519        self.observations.clear();
520        self.sweep_expired_leases();
521        // K3: skill leases expire on the same head-of-event cadence as capability leases.
522        self.ctx.sweep_expired_skill_leases(self.turn);
523
524        match event {
525            LoopEvent::Start { task } => self.start(task),
526
527            LoopEvent::LLMResponse { message } => {
528                // A response arrived ⇒ the prompt fit ⇒ the overflow recovery ladder is reset.
529                self.recovery_attempts = 0;
530                let tokens = self.message_tokens(&message);
531                self.total_tokens += tokens as u64;
532
533                // Max-output-tokens recovery (mirrors query.ts): a response cut off at the output
534                // cap reports stop_reason = max_tokens (Anthropic) / length (OpenAI). A clean finish
535                // resets the ladder.
536                const MAX_OUTPUT_RECOVERY: u8 = 3;
537                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.";
538                let truncated = matches!(
539                    self.pending_stop_reason.take().as_deref(),
540                    Some("max_tokens") | Some("length"),
541                );
542                if !truncated {
543                    self.output_recovery_attempts = 0;
544                }
545
546                if let Some(reason) = self.pending_termination.take() {
547                    return self.terminate(reason, Some(message));
548                }
549
550                if message.tool_calls.is_empty() {
551                    // The model was cut off at the output cap with no tool call. Keep the partial,
552                    // nudge it to resume mid-thought, and re-call — instead of mistaking the
553                    // truncation for a finished turn. Bounded by MAX_OUTPUT_RECOVERY; once exhausted
554                    // the partial stands and the turn terminates normally below. (A truncated
555                    // *tool-call* turn isn't handled here — it falls through to tool execution.)
556                    if truncated && self.output_recovery_attempts < MAX_OUTPUT_RECOVERY {
557                        self.output_recovery_attempts += 1;
558                        self.ctx.push_history(message, tokens);
559                        self.ctx.push_signal(OUTPUT_TRUNCATION_NUDGE.to_string());
560                        self.phase = LoopPhase::Reason;
561                        return self.emit_call_llm();
562                    }
563                    // When a milestone contract is active and not yet complete,
564                    // request evaluation instead of terminating.
565                    if !self.milestone.is_complete() {
566                        let phase_id = self.milestone.current_phase_id().unwrap_or("").to_string();
567                        let criteria = self.milestone.current_criteria().to_vec();
568                        let (verifier, required_evidence) = self
569                            .milestone
570                            .contract
571                            .as_ref()
572                            .and_then(|c| c.phases.get(self.milestone.current_phase))
573                            .map(|p| (p.verifier.clone(), p.required_evidence.clone()))
574                            .unwrap_or_default();
575                        // `tokens` was already computed for this message above.
576                        self.ctx.push_history(message, tokens);
577                        return LoopAction::EvaluateMilestone {
578                            phase_id,
579                            criteria,
580                            verifier,
581                            required_evidence,
582                        };
583                    }
584                    // O4 criteria gate (the Stop-hook analog): the model is finishing while explicit
585                    // acceptance criteria stand. Before accepting `Completed`, inject ONE bounded
586                    // self-check at the peak-attention slot — verify each criterion, continue if any
587                    // is unmet, else confirm. Fires at most once per run (no nag loop); runs with no
588                    // criteria are untouched. 2c guards "won't stop"; this guards "stops too early".
589                    if self.criteria_gate_enabled
590                        && !self.criteria_gate_fired
591                        && !self.ctx.partitions.task_state.criteria.is_empty()
592                    {
593                        self.criteria_gate_fired = true;
594                        let criteria = self.ctx.partitions.task_state.criteria.clone();
595                        self.ctx.push_history(message, tokens);
596                        self.ctx.push_signal(format!(
597                            "[CRITERIA CHECK] You are about to finish. Verify each acceptance \
598                             criterion first: {}. If any is NOT met, continue working on it now. \
599                             If all are met, give the final answer.",
600                            criteria.join(" | ")
601                        ));
602                        self.observations.push(KernelObservation::CriteriaGateFired {
603                            turn: self.turn,
604                            criteria,
605                        });
606                        self.phase = LoopPhase::Reason;
607                        return self.emit_call_llm();
608                    }
609                    return self.terminate(TerminationReason::Completed, Some(message));
610                }
611
612                let calls = message.tool_calls.clone();
613                self.ctx.push_history(message, tokens);
614
615                // ━━ 记录活动时间(Layer 3时间衰减使用)
616                if let Some(now_ms) = self.last_now_ms {
617                    self.ctx.record_activity(now_ms);
618                }
619
620                // 2b: record this turn's tool activity into the task-state recency log (meta-tools
621                // filtered inside). The State-turn footer renders it as "just did: …" + a forward
622                // nudge / STOP, so progress is kernel-derived and never depends on the model
623                // remembering to call `update_plan`. Tool *names* live only on the request (results
624                // carry call_id only), so this is the turn to capture them.
625                //
626                // Capture name AND a compact arg digest: the no-progress STOP keys on whether the
627                // SAME call repeats, and a legit loop (same tool, DIFFERENT args — e.g. processing 20
628                // items) is real progress, not a stall. Keying on the name alone false-positives those
629                // loops; including args distinguishes "step(n=1), step(n=2)…" from a true repeat.
630                let action_sigs: Vec<(String, String)> = calls
631                    .iter()
632                    .map(|c| (c.name.to_string(), compact_tool_args(&c.arguments)))
633                    .collect();
634                self.ctx.note_tool_actions(&action_sigs);
635
636                // O6 RepeatFuse: the hard rungs above the 2c soft STOP. Runs BEFORE the governance
637                // gate and independent of whether a policy is loaded — a batteries-included kernel
638                // protection, not a policy feature. Deny rolls the turn back with a directive note;
639                // the terminate rung ends the run `NoProgress` after one final no-tools report turn.
640                if let Some(action) = self.check_repeat_fuse(&calls) {
641                    return action;
642                }
643
644                match self.gate_tool_calls(&calls) {
645                    GateToolOutcome::Blocked(action) => return action,
646                    GateToolOutcome::Suspended => return LoopAction::AwaitingResume,
647                    GateToolOutcome::Proceed => {}
648                }
649                self.phase = LoopPhase::Act {
650                    tool_calls: calls.clone(),
651                };
652                self.set_lifecycle(TaskState::Running, None);
653                LoopAction::ExecuteTools { calls }
654            }
655
656            LoopEvent::ToolResults { mut results } => {
657                if !self.pending_denied_results.is_empty() {
658                    results.append(&mut self.pending_denied_results);
659                }
660                if let Some(reason) = results
661                    .iter()
662                    .find_map(|result| self.rollback_reason_for_tool_result(result))
663                {
664                    let note = Message::user(super::rollback::build_rollback_note(
665                        &reason,
666                        self.ctx.config.verbose_control_notes,
667                    ));
668                    self.rollback(reason);
669                    self.ctx.push_signal(note.content.as_text().unwrap_or_default().to_string());
670                    self.phase = LoopPhase::Reason;
671                    return self.emit_call_llm();
672                }
673                // Non-fatal errors are committed to history so the LLM can
674                // see them and self-correct without losing turn state.
675
676                for r in &results {
677                    self.total_tokens += r.token_count.unwrap_or(0) as u64;
678                    // Preserve Content::Parts (structured / multimodal tool output).
679                    // Parts are serialised to JSON so the text can be restored faithfully.
680                    let raw_output = match &r.output {
681                        Content::Text(s) => s.clone(),
682                        Content::Parts(parts) => serde_json::to_string(parts).unwrap_or_default(),
683                    };
684                    // Layer 1 spool: oversized results keep only a preview in context; the kernel
685                    // emits `LargeResultSpooled` so the SDK persists the full output it still holds.
686                    let (output, spooled) = match crate::mm::plan_spool(
687                        &raw_output,
688                        self.ctx.config.spool_threshold_bytes,
689                        self.ctx.config.spool_preview_bytes,
690                    ) {
691                        Some(decision) => {
692                            self.observations.push(KernelObservation::LargeResultSpooled {
693                                turn: self.turn,
694                                call_id: r.call_id.to_string(),
695                                // ToolResult carries no tool name; the SDK maps call_id -> tool.
696                                tool: String::new(),
697                                original_size: decision.original_size,
698                                preview_size: decision.preview.len() as u32,
699                                spool_ref: None,
700                            });
701                            (decision.preview, true)
702                        }
703                        None => (raw_output, false),
704                    };
705                    let parts = vec![ContentPart::ToolResult {
706                        call_id: r.call_id.clone(),
707                        output,
708                        is_error: r.is_error,
709                    }];
710                    let tool_msg = Message::tool(parts);
711                    // When spooled, `r.token_count` reflects the full output — recount the preview.
712                    let tokens = if spooled {
713                        self.ctx.engine.count_message(&tool_msg)
714                    } else {
715                        r.token_count
716                            .unwrap_or_else(|| self.ctx.engine.count_message(&tool_msg))
717                    };
718                    self.ctx.push_history(tool_msg, tokens);
719                    // Layer 1: a spooled result's handle is marked SpooledOut (its full output now
720                    // lives on disk via the SDK); the SDK maps call_id -> the persisted ref.
721                    if spooled {
722                        self.ctx.mark_spooled(&r.call_id, r.call_id.to_string());
723                    }
724                }
725                self.turn += 1;
726
727                // M1 收口: the pure `schedule()` is now the single budget decision point.
728                // It evaluates the same three axes (turn/token/wall) via `BudgetLedger`, which
729                // delegates to `SchedulerBudget::should_terminate` internally — one source of truth.
730                if let ScheduleDecision::Terminate { reason: term, .. } =
731                    super::tcb::schedule(&self.root_tcb(), self.last_now_ms)
732                {
733                    let budget = match term {
734                        TerminationReason::MaxTurns => "max_turns",
735                        TerminationReason::Timeout => "wall_time",
736                        _ => "token_budget",
737                    };
738                    self.observations.push(KernelObservation::BudgetExceeded {
739                        turn: self.turn,
740                        budget: budget.to_string(),
741                    });
742                    self.pending_termination = Some(term);
743                    self.phase = LoopPhase::Reason;
744                    return self.emit_call_llm();
745                }
746
747                // ━━ Eviction checkpoint (M3): one decision model (`plan_eviction`), one
748                // execution funnel (`execute_eviction_op`). Layer 3 (idle/time-decay) must run
749                // before the rho recommendation is read, since it mutates token usage — so the
750                // plan is built in that interleaved order and the ops are executed in plan order.
751                let idle_decay = self
752                    .last_now_ms
753                    .is_some_and(|now_ms| self.ctx.should_time_decay_compact(now_ms));
754                if idle_decay {
755                    self.execute_eviction_op(&crate::mm::EvictionOp::TimeDecayMicro);
756                }
757
758                // Layer 4 read-time projection: recompute handle residency on the post-time-decay rho.
759                self.ctx.recompute_handle_residency();
760                // K2: knowledge budget check — marks over-budget unpinned entries for the next
761                // boundary sweep (marks are idempotent; drops only apply there) and stashes a
762                // warn-once-per-generation notice, drained into an observation here.
763                if let Some((used, budget)) = self.ctx.enforce_knowledge_budget() {
764                    self.observations.push(KernelObservation::KnowledgeBudgetExceeded {
765                        turn: self.turn,
766                        used,
767                        budget,
768                    });
769                }
770                self.phase = LoopPhase::Delta {
771                    pressure: self.ctx.rho(),
772                };
773
774                // Layers 2/4/5: execute the pressure-driven ops from the plan (skip TimeDecayMicro
775                // if already executed). The plan carries specific ops stamped with real config-derived
776                // params (W1-1 収口 — no magic-number placeholders), not the umbrella `Pressure` wrapper.
777                let (target_tokens, preserve_turns) = self.ctx.plan_compaction_params();
778                let plan =
779                    crate::mm::plan_eviction(self.ctx.should_compress(), idle_decay, target_tokens, preserve_turns);
780                // `idle_decay` ⇒ the plan carries a `TimeDecayMicro` (so the skip-on-already-executed
781                // below is meaningful). The converse does NOT hold: a pressure-driven `MicroCompact`
782                // also emits `TimeDecayMicro` independent of `idle_decay` (W1 unified planner), so we
783                // assert the implication, not equality.
784                debug_assert!(!idle_decay || plan.has_time_decay());
785                for op in &plan.ops {
786                    // Skip TimeDecayMicro if we already executed it (prevents double-execution).
787                    if matches!(op, crate::mm::EvictionOp::TimeDecayMicro) && idle_decay {
788                        continue;
789                    }
790                    self.execute_eviction_op(op);
791                }
792
793                // Renewal: when compression alone cannot recover enough headroom,
794                // start a new sprint — carry forward system + memory + last N history turns.
795                if self.ctx.should_renew() {
796                    self.ctx.renew();
797                    // A new sprint is a session boundary for signal identity: clear the dedup set so
798                    // it cannot grow unbounded across a long run, and so a signal seen in a prior
799                    // sprint may legitimately re-fire in the new one.
800                    if let Some(router) = self.signal_router.as_mut() {
801                        router.clear_dedup();
802                    }
803                    self.observations.push(KernelObservation::Renewed {
804                        sprint: self.ctx.sprint,
805                    });
806                    // K1: renewal is a boundary — surface the knowledge sweep it just ran.
807                    self.emit_knowledge_sweep_observations();
808                }
809
810                // Turn boundary: drain any kernel-queued signals into context so they
811                // are seen on the next reasoning turn (ready queue → running).
812                self.drain_queued_signals();
813
814                self.phase = LoopPhase::Reason;
815                self.emit_call_llm()
816            }
817
818            LoopEvent::Signal { signal } => {
819                // `feed` always returns an action; non-actionable dispositions
820                // (queue/observe/ignore) fall back to a plain provider call here.
821                // The kernel-routed path (`dispatch_signal`) is driven via the ABI.
822                self.dispatch_signal(signal)
823                    .unwrap_or_else(|| self.emit_call_llm())
824            }
825
826            LoopEvent::MilestoneResult { result } => self.handle_milestone_result(result),
827
828            LoopEvent::SubAgentCompleted { result } => self.handle_sub_agent_completed(result),
829
830            LoopEvent::Timeout => {
831                let reason = RollbackReason::Timeout;
832                let note = Message::user(super::rollback::build_rollback_note(
833                    &reason,
834                    self.ctx.config.verbose_control_notes,
835                ));
836                self.rollback(reason);
837                self.ctx.push_signal(note.content.as_text().unwrap_or_default().to_string());
838                self.phase = LoopPhase::Reason;
839                self.emit_call_llm()
840            }
841        }
842    }
843
844
845    /// Drain observations emitted during the last `start`/`feed` call.
846    pub fn take_observations(&mut self) -> Vec<KernelObservation> {
847        std::mem::take(&mut self.observations)
848    }
849
850    /// W2-2: Create a snapshot of the current kernel state for crash recovery or migration.
851    pub fn snapshot(&self) -> crate::runtime::snapshot::KernelSnapshot {
852        use crate::runtime::snapshot::{ContextSnapshot, KernelSnapshot};
853        let context = ContextSnapshot::from_context(&self.ctx);
854        KernelSnapshot::from_state(
855            self.turn,
856            self.total_tokens,
857            &self.tasks,
858            &context,
859            self.run_spec.as_ref(),
860        )
861    }
862
863    /// W2-2: Restore kernel state from a snapshot. Returns a new LoopStateMachine rebuilt from the snapshot.
864    /// Note: This is a foundational restore - some state (governance, milestone, signal router dedup) is
865    /// recreated from policy/config rather than serialized, following the principle that strategy is data.
866    pub fn restore(snap: &crate::runtime::snapshot::KernelSnapshot) -> Self {
867        use crate::signals::router::SignalRouter;
868
869        // Reconstruct policy from the max_tokens in snapshot
870        let policy = crate::scheduler::policy::LoopPolicy {
871            max_tokens: snap.context.max_tokens,
872            ..Default::default()
873        };
874
875        // Rebuild TaskTable from snapshot TCBs
876        let mut tasks = TaskTable::new();
877        for tcb_snap in &snap.tasks {
878            if let Some(tcb) = snap.restore_tcb(tcb_snap) {
879                tasks.insert(tcb);
880            }
881        }
882
883        // Rebuild context partitions from snapshot
884        let mut ctx = ContextManager::new(snap.context.max_tokens);
885        ctx.sprint = snap.context.sprint;
886
887        // Restore messages
888        for msg in &snap.context.system_messages {
889            let tokens = ctx.engine.count_message(msg);
890            ctx.partitions.system.push(msg.clone(), tokens);
891        }
892        // K1: restore entry identity (key/pinned) from the index-parallel meta vec; pre-K1
893        // snapshots have no meta ⇒ every entry restores unkeyed/unpinned (graceful).
894        for (i, msg) in snap.context.knowledge_messages.iter().enumerate() {
895            let tokens = ctx.engine.count_message(msg);
896            let meta = snap.context.knowledge_entries_meta.get(i);
897            ctx.partitions.knowledge.push_entry(
898                meta.and_then(|m| m.key.as_deref()).map(compact_str::CompactString::new),
899                msg.clone(),
900                tokens,
901                meta.map(|m| m.pinned).unwrap_or(false),
902            );
903        }
904        for msg in &snap.context.history_messages {
905            let tokens = ctx.engine.count_message(msg);
906            ctx.partitions.history.push(msg.clone(), tokens);
907        }
908
909        // Restore task state
910        if let Some(goal) = &snap.context.task_goal {
911            ctx.partitions.task_state.goal = goal.clone();
912        }
913        if let Some(plan_json) = &snap.context.task_plan {
914            if let Ok(plan_steps) = serde_json::from_str::<Vec<crate::context::task_state::PlanStep>>(plan_json) {
915                ctx.partitions.task_state.plan = plan_steps;
916            }
917        }
918        if let Some(progress) = &snap.context.task_progress {
919            ctx.partitions.task_state.progress = progress.clone();
920        }
921        ctx.partitions.task_state.directives = snap.context.task_directives.clone();
922
923        // Restore signals
924        ctx.partitions.signals = snap.context.signals.clone();
925
926        Self {
927            phase: LoopPhase::Reason,
928            turn: snap.turn,
929            ctx,
930            tools: Vec::new(),  // Tools are rebuilt from capabilities on next LLM call
931            observations: Vec::new(),
932            policy,
933            total_tokens: snap.total_tokens,
934            // Re-seeded from the replayed `ConfigureRun` (strategy is data, not serialized state).
935            group_tokens_base: 0,
936            group_spawns_base: 0,
937            pending_termination: None,
938            recovery_attempts: 0,
939            output_recovery_attempts: 0,
940            pending_stop_reason: None,
941            session_history_baseline: 0,
942            checkpoint: TurnCheckpoint::default(),
943            milestone: crate::scheduler::milestone::MilestoneTracker::new(),
944            run_spec: snap.run_spec(),
945            tasks,
946            governance: None,  // Governance is policy data, recreated from config
947            resource_quota: None,
948            memory_write_times: Vec::new(),
949            memory_policy: None,
950            signal_router: Some(SignalRouter::new(64)),  // Dedup cleared on restore
951            started_at_ms: None,
952            last_now_ms: None,
953            suspend_state: None,
954            pending_denied_results: Vec::new(),
955            workflow: None,
956            // Re-seeded from the replayed `ConfigureRun` / `SetRepeatFuse` (config, not state);
957            // the streak itself intentionally restarts on restore (stale across a suspend).
958            repeat_fuse: RepeatFuseConfig::default(),
959            repeat_sig: None,
960            repeat_count: 0,
961            criteria_gate_enabled: true,
962            criteria_gate_fired: false,
963        }
964    }
965
966    fn terminate(
967        &mut self,
968        termination: TerminationReason,
969        final_message: Option<Message>,
970    ) -> LoopAction {
971        // Commit the final response into history so subsequent session restores
972        // include the complete transcript: user → [tool turns] → final assistant.
973        if let Some(ref msg) = final_message {
974            let tokens = self.message_tokens(msg);
975            self.ctx.push_history(msg.clone(), tokens);
976        }
977        let result = LoopResult {
978            termination,
979            final_message,
980            turns_used: self.turn,
981            total_tokens_used: self.total_tokens,
982            loop_continue: None,
983            classify_branch: None,
984            tournament_winner: None,
985        };
986        self.set_lifecycle(TaskState::Done(termination), None);
987        LoopAction::Done { result }
988    }
989
990    /// Build the `CallLLM` action with a structured `RenderedContext`.
991    /// Meta-tools (skill / memory / knowledge) are appended to the tool list
992    /// when configured. When `pending_termination` is set, tools are stripped
993    /// to force a plain-text response before the loop terminates.
994    fn emit_call_llm(&mut self) -> LoopAction {
995        // Calling the provider is definitionally "running" — the single funnel for entering the
996        // Running lifecycle (covers start, resume, signal-driven turns, budget final-call).
997        self.set_lifecycle(TaskState::Running, None);
998        self.checkpoint.history_len = self.ctx.partitions.history.messages.len();
999        self.checkpoint.signals_len = self.ctx.partitions.signals.len();
1000        self.checkpoint.task_state = Some(self.ctx.partitions.task_state.clone());
1001        self.observations.push(KernelObservation::CheckpointTaken {
1002            turn: self.turn,
1003            history_len: self.checkpoint.history_len as u32,
1004        });
1005
1006        let context = self.ctx.render();
1007        if self.pending_termination.is_some() {
1008            return LoopAction::CallLLM {
1009                context,
1010                tools: Vec::new(),
1011            };
1012        }
1013        let mut tools = self.tools.clone();
1014        tools.extend(self.ctx.meta_tool_schemas());
1015
1016        if let Some(ref spec) = self.run_spec {
1017            use crate::types::capability::CapabilityKind;
1018            tools.retain(|tool| {
1019                let kind = match tool.name.as_str() {
1020                    "skill" => CapabilityKind::Skill,
1021                    "memory" => CapabilityKind::Memory,
1022                    "knowledge" => CapabilityKind::Knowledge,
1023                    _ => CapabilityKind::Tool,
1024                };
1025                let desc = crate::types::capability::CapabilityDescriptor::marker(
1026                    kind,
1027                    tool.name.clone(),
1028                    &tool.description,
1029                );
1030                spec.capability_filter.allows(&desc)
1031            });
1032        }
1033
1034        // P1-B epoch skill gating (applied *after* the run-level filter ③, so A is the outer bound
1035        // and B narrows within it — D6). When skills are active and declare tools, expose only
1036        // `meta-tools ∪ stable-core ∪ ⋃(active skills' allowed_tools)`. `None` ⇒ no active/declared
1037        // skill ⇒ no narrowing (D3, errs-open). Meta-tools are always exempt (D5) so the model can
1038        // still load more skills. Byte-stable within an epoch: the set only changes on activation.
1039        if let Some(allowed) = self.ctx.active_skill_tool_filter() {
1040            let stable = &self.ctx.stable_core_tools;
1041            tools.retain(|tool| {
1042                matches!(tool.name.as_str(), "skill" | "memory" | "knowledge" | "update_plan")
1043                    || stable.contains(&tool.name)
1044                    || allowed.contains(&tool.name)
1045            });
1046        }
1047
1048        LoopAction::CallLLM { context, tools }
1049    }
1050
1051    pub fn rollback(&mut self, reason: RollbackReason) {
1052        self.ctx.partitions.history.messages.truncate(self.checkpoint.history_len);
1053        self.ctx.partitions.signals.truncate(self.checkpoint.signals_len);
1054        if let Some(ref state) = self.checkpoint.task_state {
1055            self.ctx.partitions.task_state = state.clone();
1056        }
1057        self.observations.push(KernelObservation::Rollbacked {
1058            turn: self.turn,
1059            checkpoint_history_len: self.checkpoint.history_len as u32,
1060            reason: Some(reason),
1061        });
1062    }
1063
1064    fn rollback_reason_for_tool_result(&self, result: &ToolResult) -> Option<RollbackReason> {
1065        let tool_name = self.tool_name_for_call(&result.call_id);
1066        let output = super::rollback::tool_result_output_text(result);
1067
1068        if result.is_fatal {
1069            return Some(RollbackReason::FatalToolError {
1070                tool_name,
1071                error: output,
1072            });
1073        }
1074
1075        match result.error_kind {
1076            Some(ToolErrorKind::Fatal) => Some(RollbackReason::FatalToolError {
1077                tool_name,
1078                error: output,
1079            }),
1080            Some(ToolErrorKind::GovernanceDenied) => Some(RollbackReason::GovernanceDenied {
1081                tool_name,
1082                reason: output,
1083            }),
1084            Some(ToolErrorKind::ProviderFailure) => {
1085                Some(RollbackReason::ProviderFailure { error: output })
1086            }
1087            Some(ToolErrorKind::Timeout) => Some(RollbackReason::Timeout),
1088            Some(ToolErrorKind::UserInterrupt) => Some(RollbackReason::UserInterrupt),
1089            Some(ToolErrorKind::Recoverable) | None => None,
1090        }
1091    }
1092
1093    fn tool_name_for_call(&self, call_id: &compact_str::CompactString) -> String {
1094        match &self.phase {
1095            LoopPhase::Act { tool_calls } => tool_calls
1096                .iter()
1097                .find(|call| call.id == *call_id)
1098                .map(|call| call.name.to_string())
1099                .unwrap_or_else(|| call_id.to_string()),
1100            _ => call_id.to_string(),
1101        }
1102    }
1103}
1104
1105#[cfg(test)]
1106#[path = "tests.rs"]
1107mod tests;