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