Skip to main content

deepstrike_core/runtime/
kernel.rs

1//! Stable host/kernel ABI types.
2//!
3//! This module is the narrow contract SDKs should bind to over time. It wraps
4//! the existing loop state machine without changing behavior, giving FFI layers
5//! a versioned input/action/observation vocabulary before the larger runner
6//! refactor lands.
7
8use serde::{Deserialize, Serialize};
9
10use crate::context::pressure::PressureAction;
11use crate::context::renderer::RenderedContext;
12use crate::context::task_state::TaskUpdate;
13use crate::context::token_engine::ContextTokenEngine;
14use crate::runtime::session::RollbackReason;
15use crate::scheduler::policy::SchedulerBudget;
16use crate::scheduler::state_machine::{LoopAction, LoopEvent, LoopStateMachine};
17use crate::types::agent::AgentRunSpec;
18use crate::types::capability::{CapabilityCommand, CapabilityDescriptor, CapabilityKind};
19use crate::types::message::{Message, ToolCall, ToolResult, ToolSchema};
20use crate::types::milestone::{MilestoneCheckResult, MilestoneContract};
21use crate::types::result::{LoopResult, SubAgentResult};
22use crate::types::signal::RuntimeSignal;
23use crate::types::skill::SkillMetadata;
24use crate::types::task::RuntimeTask;
25
26pub const KERNEL_ABI_VERSION: u32 = 1;
27
28/// Serializable permission action for the governance ABI.
29/// Mirrors [`crate::governance::permission::PermissionAction`] without coupling
30/// the wire format to the internal type.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum PolicyAction {
34    Allow,
35    Deny,
36    AskUser,
37}
38
39impl From<PolicyAction> for crate::governance::permission::PermissionAction {
40    fn from(action: PolicyAction) -> Self {
41        match action {
42            PolicyAction::Allow => Self::Allow,
43            PolicyAction::Deny => Self::Deny,
44            PolicyAction::AskUser => Self::AskUser,
45        }
46    }
47}
48
49/// One permission rule for the governance ABI: glob `tool_pattern` → action.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PolicyRule {
52    pub tool_pattern: String,
53    pub action: PolicyAction,
54}
55
56/// Per-tool rate limit for the governance ABI.
57/// Maps to [`crate::governance::rate_limit::RateLimit`].
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RateLimitSpec {
60    pub tool: String,
61    pub max_calls: u32,
62    pub window_ms: u64,
63}
64
65/// Parameter constraint for the governance ABI.
66/// Maps to [`crate::governance::constraint::ConstraintRule`] (structural rules only;
67/// pattern/predicate matching stays in the SDK execution layer).
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum ConstraintSpec {
71    /// Parameter must be present and non-null.
72    Required { tool: String, path: String },
73    /// Parameter value must be one of `values`.
74    Enum {
75        tool: String,
76        path: String,
77        values: Vec<String>,
78    },
79    /// Numeric parameter must fall within `[min, max]`.
80    Range {
81        tool: String,
82        path: String,
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        min: Option<f64>,
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        max: Option<f64>,
87    },
88}
89
90fn default_signal_queue_size() -> u32 {
91    64
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct KernelInput {
96    pub version: u32,
97    pub event: KernelInputEvent,
98}
99
100impl KernelInput {
101    pub fn new(event: KernelInputEvent) -> Self {
102        Self {
103            version: KERNEL_ABI_VERSION,
104            event,
105        }
106    }
107}
108
109/// K2: the governance sub-bundle of [`RunConfig`] — the same five fields as the `LoadGovernancePolicy`
110/// event, grouped so a run's whole governance posture travels as one value.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct GovernanceConfig {
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub default_action: Option<PolicyAction>,
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub rules: Vec<PolicyRule>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub vetoed_tools: Vec<String>,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub rate_limits: Vec<RateLimitSpec>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub constraints: Vec<ConstraintSpec>,
123}
124
125/// K2: a bundle of run-setup configuration carried by the [`KernelInputEvent::ConfigureRun`] event.
126/// Each field maps 1:1 to a granular `Set*` / `Load*` event; `None`/absent leaves that aspect untouched.
127/// This is the host-side analogue of the SDK's `applyKernelPolicies` — one event for the whole setup.
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129pub struct RunConfig {
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub tools: Option<Vec<ToolSchema>>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub available_skills: Option<Vec<SkillMetadata>>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub stable_core_tools: Option<Vec<String>>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub memory_enabled: Option<bool>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub knowledge_enabled: Option<bool>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub plan_tool_enabled: Option<bool>,
142    /// Present (any value) ⇒ reset the token engine to the char-approx estimator (see `SetTokenizer`).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub tokenizer: Option<String>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub governance: Option<GovernanceConfig>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub attention_max_queue_size: Option<u32>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub scheduler_max_wall_ms: Option<u64>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub resource_quota: Option<crate::governance::quota::ResourceQuota>,
153    /// L1 (RunGroup): cumulative tokens already spent by *other* members of this run's governance
154    /// domain, seeded at boot so the run-level token cap (`max_total_tokens`) is enforced across the
155    /// whole group, not per-vehicle. `None`/0 ⇒ no group (N=1) ⇒ pre-L1 per-kernel behavior.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub group_tokens_base: Option<u64>,
158    /// L1 (RunGroup): sub-agents already spawned by *other* members of this run's governance domain,
159    /// seeded at boot so `ResourceQuota::max_total_subagents` is enforced across the whole group.
160    /// `None`/0 ⇒ no group (N=1) ⇒ pre-L1 per-vehicle behavior.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub group_spawns_base: Option<u32>,
163    /// ③ loop-agent: rounds completed across the loop before this run (seeds the
164    /// pacing trap's max_rounds coercion). Additive.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub group_rounds_base: Option<u32>,
167    /// O6: repeat-fuse thresholds (see `SetRepeatFuse`). Absent ⇒ kernel defaults
168    /// (enabled, deny_after=5, terminate_after=8).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub repeat_fuse: Option<crate::governance::repeat_fuse::RepeatFuseConfig>,
171    /// O4: enable/disable the turn-end criteria gate. Absent ⇒ enabled (kernel default).
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub criteria_gate: Option<bool>,
174    /// K2: max share of `max_tokens` the knowledge partition may occupy (see
175    /// `ContextConfig::knowledge_budget_ratio`). Absent ⇒ kernel default (0.25); `0.0` disables.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub knowledge_budget_ratio: Option<f64>,
178    /// Entropy watch: opt-in threshold alerting over the per-turn session-entropy score
179    /// (see `SetEntropyWatch`). Absent ⇒ kernel default (disabled; sampling itself is
180    /// unconditional and unaffected).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub entropy_watch: Option<crate::scheduler::entropy::EntropyWatchConfig>,
183}
184
185/// Build a [`GovernancePipeline`](crate::governance::pipeline::GovernancePipeline) from the ABI policy
186/// fields. Shared by the `LoadGovernancePolicy` event and the `ConfigureRun` bundle so the two can never
187/// drift in how they interpret rules / vetoes / rate-limits / constraints.
188pub(crate) fn build_governance_pipeline(
189    default_action: Option<PolicyAction>,
190    rules: Vec<PolicyRule>,
191    vetoed_tools: Vec<String>,
192    rate_limits: Vec<RateLimitSpec>,
193    constraints: Vec<ConstraintSpec>,
194) -> crate::governance::pipeline::GovernancePipeline {
195    use crate::governance::constraint::{ConstraintRule, ParamConstraint};
196    use crate::governance::permission::PermissionRule;
197    use crate::governance::rate_limit::RateLimit;
198    let default = default_action.unwrap_or(PolicyAction::Allow).into();
199    let mut pipeline = crate::governance::pipeline::GovernancePipeline::new(default);
200    for rule in rules {
201        pipeline.permission.add_rule(PermissionRule {
202            tool_pattern: rule.tool_pattern.into(),
203            action: rule.action.into(),
204        });
205    }
206    for tool in vetoed_tools {
207        pipeline.veto.block_tool(tool);
208    }
209    for rl in rate_limits {
210        pipeline.rate_limiter.set_limit(
211            rl.tool,
212            RateLimit {
213                max_calls: rl.max_calls,
214                window_ms: rl.window_ms,
215            },
216        );
217    }
218    for c in constraints {
219        let (tool_name, param_path, rule) = match c {
220            ConstraintSpec::Required { tool, path } => (tool, path, ConstraintRule::Required),
221            ConstraintSpec::Enum { tool, path, values } => (tool, path, ConstraintRule::Enum(values)),
222            ConstraintSpec::Range { tool, path, min, max } => {
223                (tool, path, ConstraintRule::Range { min, max })
224            }
225        };
226        pipeline.constraints.add(ParamConstraint {
227            tool_name,
228            param_path,
229            rule,
230        });
231    }
232    pipeline
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "kind", rename_all = "snake_case")]
237pub enum KernelInputEvent {
238    SetTools {
239        tools: Vec<ToolSchema>,
240    },
241    SetAvailableSkills {
242        skills: Vec<SkillMetadata>,
243    },
244    /// P1-B tool gating: the model loaded a skill (`name`). The SDK emits this when it resolves a
245    /// `skill` tool call. The kernel records it in the active-skill set and resolves the skill's
246    /// `allowed_tools` from the catalog to narrow the toolset on subsequent turns.
247    SkillActivated {
248        name: String,
249        /// K3: auto-deactivate after this many turns (`None` = permanent, the default). On expiry
250        /// the toolset re-widens and the skill's knowledge pin is boundary-swept — same path as
251        /// an explicit `SkillDeactivated`.
252        #[serde(default, skip_serializing_if = "Option::is_none")]
253        lease_turns: Option<u32>,
254    },
255    /// K3: host-driven skill deactivation (there is deliberately NO model-facing unload — it
256    /// invites thrash). The toolset re-widens at the next provider call (an epoch event, same
257    /// cache cost class as activation); the `skill:<name>` knowledge pin drops at the next
258    /// boundary sweep. Errs-open: not-active is a no-op.
259    SkillDeactivated {
260        name: String,
261    },
262    /// P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Set once by
263    /// the SDK; empty/absent ⇒ skills narrow to exactly their declared tools + meta-tools.
264    SetStableCoreTools {
265        tool_ids: Vec<String>,
266    },
267    SetMemoryEnabled {
268        enabled: bool,
269    },
270    SetKnowledgeEnabled {
271        enabled: bool,
272    },
273    SetPlanToolEnabled {
274        enabled: bool,
275    },
276    SetTokenizer {
277        name: String,
278    },
279    AddSystemMessage {
280        content: String,
281        tokens: u32,
282    },
283    AddKnowledgeMessage {
284        content: String,
285        tokens: u32,
286        /// K1: entry identity. `Some` ⇒ upsert semantics (same key replaces at the next
287        /// boundary); `None` ⇒ legacy unkeyed append. Additive — old logs replay unchanged.
288        #[serde(default, skip_serializing_if = "Option::is_none")]
289        key: Option<String>,
290        /// K1: host-pinned entries are exempt from the K2 budget sweep.
291        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
292        pinned: bool,
293    },
294    /// K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
295    /// Errs-open: unknown key is a no-op.
296    RemoveKnowledge {
297        key: String,
298    },
299    AddHistoryMessage {
300        message: Message,
301        tokens: Option<u32>,
302    },
303    PreloadHistory {
304        messages: Vec<Message>,
305    },
306    MountCapability {
307        capability: CapabilityDescriptor,
308    },
309    UnmountCapability {
310        capability_kind: CapabilityKind,
311        id: String,
312    },
313    LoadMilestoneContract {
314        contract: MilestoneContract,
315    },
316    /// Install a governance policy. Once loaded, every model-proposed tool call
317    /// is evaluated in-kernel before execution. Omitting this event leaves the
318    /// gate disabled (pre-governance behavior).
319    LoadGovernancePolicy {
320        #[serde(default)]
321        default_action: Option<PolicyAction>,
322        #[serde(default, skip_serializing_if = "Vec::is_empty")]
323        rules: Vec<PolicyRule>,
324        #[serde(default, skip_serializing_if = "Vec::is_empty")]
325        vetoed_tools: Vec<String>,
326        // COMPAT(gov-abi-additive): rate_limits/constraints are additive fields with
327        // serde(default) so older SDKs that omit them still deserialize. Safe to keep.
328        #[serde(default, skip_serializing_if = "Vec::is_empty")]
329        rate_limits: Vec<RateLimitSpec>,
330        #[serde(default, skip_serializing_if = "Vec::is_empty")]
331        constraints: Vec<ConstraintSpec>,
332    },
333    /// Override the default in-kernel signal router queue size (default 64).
334    /// The router is always active; this only adjusts capacity.
335    SetAttentionPolicy {
336        #[serde(default = "default_signal_queue_size")]
337        max_queue_size: u32,
338    },
339    ForceCompact,
340    UpdateTask {
341        update: TaskUpdate,
342    },
343    StartRun {
344        task: RuntimeTask,
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        run_spec: Option<AgentRunSpec>,
347    },
348    /// K2: apply a bundle of run-setup configuration in a single event — the consolidation of the
349    /// ~10 discrete `Set*` / `Load*` config events the SDK used to fire one-by-one before `StartRun`.
350    /// Every field is optional; an absent field leaves that aspect untouched. The granular events
351    /// remain for runtime mutation (a skill mount changing tools, a mid-run budget change). ABI-additive.
352    ConfigureRun {
353        config: RunConfig,
354    },
355    CapabilityCommand {
356        command: CapabilityCommand,
357    },
358    Resume {
359        // COMPAT(sched-resume-generic): old SDKs send `{kind:"resume"}` with no
360        // fields — serde(default) deserialises to empty vecs. Change to required
361        // once all SDKs supply approved/denied explicitly.
362        #[serde(default, skip_serializing_if = "Vec::is_empty")]
363        approved_calls: Vec<String>,
364        #[serde(default, skip_serializing_if = "Vec::is_empty")]
365        denied_calls: Vec<String>,
366    },
367    /// K2: set the knowledge-budget ratio at runtime (granular sibling of
368    /// `RunConfig::knowledge_budget_ratio`). `0.0` disables the cap.
369    SetKnowledgeBudget {
370        ratio: f64,
371    },
372    /// O4: enable/disable the turn-end criteria gate (default enabled; no-op for runs without
373    /// criteria). Additive ABI.
374    SetCriteriaGate {
375        enabled: bool,
376    },
377    /// O6: tune or disable the repeat fuse (defaults: enabled, deny_after=5, terminate_after=8).
378    /// Each field is optional — an absent field keeps the current value. Additive ABI.
379    SetRepeatFuse {
380        #[serde(default, skip_serializing_if = "Option::is_none")]
381        enabled: Option<bool>,
382        #[serde(default, skip_serializing_if = "Option::is_none")]
383        deny_after: Option<u32>,
384        #[serde(default, skip_serializing_if = "Option::is_none")]
385        terminate_after: Option<u32>,
386    },
387    /// Entropy watch: tune the opt-in threshold alerting over the per-turn session-entropy
388    /// score (defaults: disabled, threshold=0.65, hysteresis=0.1, cooldown_turns=4,
389    /// notify_model=false). Each field is optional — an absent field keeps the current
390    /// value. Sampling itself is unconditional and unaffected. Additive ABI.
391    SetEntropyWatch {
392        #[serde(default, skip_serializing_if = "Option::is_none")]
393        enabled: Option<bool>,
394        #[serde(default, skip_serializing_if = "Option::is_none")]
395        threshold: Option<f64>,
396        #[serde(default, skip_serializing_if = "Option::is_none")]
397        hysteresis: Option<f64>,
398        #[serde(default, skip_serializing_if = "Option::is_none")]
399        cooldown_turns: Option<u32>,
400        #[serde(default, skip_serializing_if = "Option::is_none")]
401        notify_model: Option<bool>,
402    },
403    /// Adjust the wall-clock budget at runtime (e.g. to extend or set a deadline
404    /// after a run has already started). Additive: omit to keep the value from
405    /// `SchedulerBudget` passed at construction.
406    SetSchedulerBudget {
407        #[serde(default, skip_serializing_if = "Option::is_none")]
408        max_wall_ms: Option<u64>,
409    },
410    /// M2 资源配额: install a declarative [`crate::governance::quota::ResourceQuota`] at the
411    /// single syscall trap. Like governance/attention/scheduler config, quotas flow in through
412    /// the versioned JSON event ABI (replayable, session-loggable) rather than a side-channel
413    /// setter — sending it is opt-in, and omitting it preserves the pre-M2 unconditional `Allow`
414    /// for spawn / memory-write syscalls.
415    SetResourceQuota {
416        quota: crate::governance::quota::ResourceQuota,
417    },
418    ProviderResult {
419        message: Message,
420        #[serde(default, skip_serializing_if = "Option::is_none")]
421        observed_input_tokens: Option<u32>,
422        #[serde(default, skip_serializing_if = "Option::is_none")]
423        observed_output_tokens: Option<u32>,
424        // COMPAT(gov-clock): now_ms is optional so SDKs that don't drive the in-kernel
425        // governance gate need not supply a clock. When absent, the rate limiter runs
426        // on a 0 clock (effectively unlimited). Can become required once all SDKs feed time.
427        #[serde(default, skip_serializing_if = "Option::is_none")]
428        now_ms: Option<u64>,
429        /// Provider stop_reason for this response — `max_tokens` (Anthropic) / `length` (OpenAI)
430        /// signal an output-cap truncation, which drives the kernel's max-output-tokens recovery.
431        /// Additive: omitted by providers/SDKs that don't report it (no-op recovery).
432        #[serde(default, skip_serializing_if = "Option::is_none")]
433        stop_reason: Option<String>,
434    },
435    ToolResults {
436        results: Vec<ToolResult>,
437    },
438    /// Reactive recovery entry point: the SDK's provider stream failed. The kernel classifies the
439    /// error (context-overflow vs other) and runs the bounded compact-and-retry recovery ladder,
440    /// returning `CallProvider` to retry with a freshly compacted context or `Done` to terminate.
441    /// The runners forward the raw provider error text and dispatch the result, instead of each
442    /// owning the classify + compact + retry + give-up policy. Additive ABI: a brand-new variant,
443    /// byte-identical on the wire for SDKs that never send it.
444    ProviderError {
445        message: String,
446    },
447    Signal {
448        signal: RuntimeSignal,
449    },
450    MilestoneResult {
451        result: MilestoneCheckResult,
452    },
453    /// Spawn a sub-agent: registers/updates the kernel process table.
454    SpawnSubAgent {
455        spec: AgentRunSpec,
456        parent_session_id: String,
457    },
458    /// W0-ABI: load a workflow DAG and spawn its first gated batch. The kernel drives the DAG;
459    /// each node spawn passes the syscall trap and is reported via `workflow_batch_spawned`.
460    /// Completions feed back through `SubAgentCompleted` (reused); finish emits
461    /// `workflow_completed`.
462    LoadWorkflow {
463        spec: crate::orchestration::workflow::WorkflowSpec,
464        parent_session_id: String,
465        /// W0-ABI resume: node agent-ids already completed (recovered from the log). Empty = fresh.
466        #[serde(default, skip_serializing_if = "Vec::is_empty")]
467        resumed_completed: Vec<String>,
468        /// R3-1 resume: the runtime `submit_workflow_nodes` batches (in order) recovered from the log,
469        /// re-applied before completions so dynamically-appended nodes are reconstructed. Additive:
470        /// empty for a fresh run or a resume without dynamic submissions.
471        #[serde(default, skip_serializing_if = "Vec::is_empty")]
472        resumed_submissions: Vec<Vec<crate::orchestration::workflow::WorkflowNode>>,
473        /// R3-1: base graph index recorded for each submission batch (parallel to
474        /// `resumed_submissions`); absent/short = legacy order-only replay.
475        #[serde(default)]
476        resumed_submission_bases: Vec<u32>,
477        /// W-1: recovered completions WITH their result-borne control signals (classify branch /
478        /// loop stop), so a resumed classifier re-prunes and a semantic loop stop is honored.
479        /// Additive: SDKs that only send `resumed_completed` (bare ids) get signal-less replay.
480        /// When both fields name the same agent id, this one wins.
481        #[serde(default, skip_serializing_if = "Vec::is_empty")]
482        resumed_results: Vec<crate::orchestration::workflow::ResumedCompletion>,
483    },
484    /// Feed a completed sub-agent result back into the parent loop.
485    SubAgentCompleted {
486        result: SubAgentResult,
487    },
488    /// R3-1: append nodes to the in-flight workflow DAG at runtime (dynamic fan-out /
489    /// loop-until-done). Sent by the SDK while the submitting node is still running — the appended
490    /// nodes spawn on the next gated drive. No-op if no workflow is active. Additive ABI: a brand-new
491    /// event variant, so existing SDKs that never send it are byte-identical on the wire.
492    SubmitWorkflowNodes {
493        #[serde(default, skip_serializing_if = "Vec::is_empty")]
494        nodes: Vec<crate::orchestration::workflow::WorkflowNode>,
495        /// G1: the agent id of the node that requested this submission. When it names a quarantined
496        /// node, the kernel coerces every submitted node to quarantined (no privilege escalation
497        /// across the trust boundary). Additive: omitted by older SDKs → `None` → no coercion.
498        #[serde(default, skip_serializing_if = "Option::is_none")]
499        submitter_agent_id: Option<String>,
500    },
501    /// M5/G1: an agent authors a whole `WorkflowSpec` (the article's "model writes its own harness").
502    /// The agent-reachable analogue of the host-only `LoadWorkflow`: **bootstraps** the DAG when no
503    /// workflow is active, else **flattens** the spec's nodes onto the running DAG (bootstrap-or-flatten,
504    /// one kernel / one quota — never a workflow stack). Gated by `Syscall::LoadWorkflow`. Additive ABI:
505    /// a brand-new variant, byte-identical on the wire for SDKs that never send it.
506    SubmitWorkflow {
507        spec: crate::orchestration::workflow::WorkflowSpec,
508        /// Used only on bootstrap (no workflow active) to seed child session ids; ignored on flatten.
509        #[serde(default)]
510        parent_session_id: String,
511        /// G1: the authoring node's agent id (flatten case) — a quarantined author's nodes are coerced
512        /// quarantined. Additive: omitted (top-level bootstrap) → `None` → the run's own trust applies.
513        #[serde(default, skip_serializing_if = "Option::is_none")]
514        submitter_agent_id: Option<String>,
515    },
516    /// Feed long-term memory entries into the knowledge partition (page-in).
517    /// SDK performs retrieval I/O; kernel only applies the result.
518    PageIn {
519        #[serde(default, skip_serializing_if = "Vec::is_empty")]
520        entries: Vec<crate::mm::PageInEntry>,
521    },
522    /// Configure long-term memory management policy (Phase 7). Opt-in: installing the policy makes
523    /// `validation_enabled`, `retrieval_top_k`, and the optional size/name overrides authoritative.
524    SetMemoryPolicy {
525        #[serde(default)]
526        memory_path: String,
527        #[serde(default = "default_stale_days")]
528        stale_warning_days: u32,
529        #[serde(default = "default_top_k")]
530        retrieval_top_k: usize,
531        #[serde(default = "default_validation_enabled")]
532        validation_enabled: bool,
533        /// Override the validation content-size limit (bytes). Omit to keep the kernel default.
534        #[serde(default, skip_serializing_if = "Option::is_none")]
535        max_content_bytes: Option<u32>,
536        /// Override the validation name-length limit. Omit to keep the kernel default.
537        #[serde(default, skip_serializing_if = "Option::is_none")]
538        max_name_length: Option<usize>,
539    },
540    /// Write a long-term memory entry (SDK background agent calls this).
541    WriteMemory {
542        memory: crate::mm::memory::MemoryWriteRequest,
543    },
544    /// Query long-term memory for context (kernel calls this; SDK responds asynchronously).
545    QueryMemory {
546        query: crate::mm::memory::MemoryQuery,
547    },
548    Timeout,
549}
550
551fn default_stale_days() -> u32 { 2 }
552fn default_top_k() -> usize { 5 }
553fn default_validation_enabled() -> bool { true }
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct KernelStep {
557    pub version: u32,
558    pub actions: Vec<KernelAction>,
559    pub observations: Vec<KernelObservation>,
560}
561
562impl KernelStep {
563    fn empty(observations: Vec<KernelObservation>) -> Self {
564        Self {
565            version: KERNEL_ABI_VERSION,
566            actions: Vec::new(),
567            observations,
568        }
569    }
570
571    fn single(action: LoopAction, observations: Vec<KernelObservation>) -> Self {
572        Self {
573            version: KERNEL_ABI_VERSION,
574            actions: vec![action.into()],
575            observations,
576        }
577    }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581#[serde(tag = "kind", rename_all = "snake_case")]
582pub enum KernelAction {
583    CallProvider {
584        context: RenderedContext,
585        tools: Vec<ToolSchema>,
586    },
587    ExecuteTool {
588        calls: Vec<ToolCall>,
589    },
590    EvaluateMilestone {
591        phase_id: String,
592        criteria: Vec<String>,
593        #[serde(default, skip_serializing_if = "Option::is_none")]
594        verifier: Option<crate::types::milestone::MilestoneVerifier>,
595        #[serde(default, skip_serializing_if = "Vec::is_empty")]
596        required_evidence: Vec<String>,
597    },
598    Done {
599        result: LoopResult,
600    },
601}
602
603impl From<LoopAction> for KernelAction {
604    fn from(action: LoopAction) -> Self {
605        match action {
606            LoopAction::AwaitingResume => {
607                panic!("AwaitingResume must not be converted to KernelAction")
608            }
609            LoopAction::CallLLM { context, tools } => Self::CallProvider { context, tools },
610            LoopAction::ExecuteTools { calls } => Self::ExecuteTool { calls },
611            LoopAction::EvaluateMilestone {
612                phase_id,
613                criteria,
614                verifier,
615                required_evidence,
616            } => Self::EvaluateMilestone {
617                phase_id,
618                criteria,
619                verifier,
620                required_evidence,
621            },
622            LoopAction::Done { result } => Self::Done { result },
623        }
624    }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628#[serde(tag = "kind", rename_all = "snake_case")]
629pub enum KernelObservation {
630    /// One compaction = one observation. `archived` non-empty ⇒ content left working
631    /// context (what the retired separate PageOut observation used to duplicate);
632    /// `tier_hint` then names the recommended long-term tier for the archived batch.
633    Compressed {
634        #[serde(default)]
635        turn: u32,
636        action: KernelPressureAction,
637        rho_after: f64,
638        summary: Option<String>,
639        archived: Vec<Message>,
640        #[serde(default, skip_serializing_if = "Option::is_none")]
641        tier_hint: Option<String>,
642        /// W1-1 cache-awareness: the message index at which this compression invalidated the
643        /// prompt cache prefix (if any). `None` = prefix-safe. SDK/telemetry can use this to
644        /// quantify "tokens saved vs cache rebuild cost". Additive ABI field with default.
645        #[serde(default, skip_serializing_if = "Option::is_none")]
646        invalidates_prefix_at: Option<usize>,
647    },
648    Renewed {
649        sprint: u32,
650    },
651    /// K1: a boundary sweep of the knowledge partition applied deferred upserts and/or dropped
652    /// marked entries. `removed_keys` lists keyed removals (unkeyed drops count only in
653    /// `tokens_freed`); an upsert-only sweep has empty `removed_keys`.
654    KnowledgeSwept {
655        turn: u32,
656        #[serde(default, skip_serializing_if = "Vec::is_empty")]
657        removed_keys: Vec<String>,
658        tokens_freed: u32,
659    },
660    /// K2: the knowledge partition exceeds its configured budget share. Fired at most once per
661    /// cache generation; the over-budget unpinned entries are already marked for the next
662    /// boundary sweep. Pinned/skill weight that cannot be evicted keeps the warning standing.
663    KnowledgeBudgetExceeded {
664        turn: u32,
665        used: u32,
666        budget: u32,
667    },
668    Rollbacked {
669        turn: u32,
670        checkpoint_history_len: u32,
671        #[serde(default, skip_serializing_if = "Option::is_none")]
672        reason: Option<RollbackReason>,
673    },
674    CapabilityChanged {
675        turn: u32,
676        #[serde(default, skip_serializing_if = "Vec::is_empty")]
677        added: Vec<String>,
678        #[serde(default, skip_serializing_if = "Vec::is_empty")]
679        removed: Vec<String>,
680        #[serde(default, skip_serializing_if = "Option::is_none")]
681        change_kind: Option<String>,
682        #[serde(default, skip_serializing_if = "Option::is_none")]
683        capability_id: Option<String>,
684        #[serde(default, skip_serializing_if = "Option::is_none")]
685        version: Option<String>,
686        #[serde(default, skip_serializing_if = "Option::is_none")]
687        mounted_by: Option<String>,
688        #[serde(default, skip_serializing_if = "Option::is_none")]
689        mount_reason: Option<String>,
690    },
691    MilestoneAdvanced {
692        turn: u32,
693        phase_id: String,
694        capabilities_unlocked: Vec<String>,
695    },
696    MilestoneBlocked {
697        turn: u32,
698        phase_id: String,
699        reason: String,
700    },
701    /// Checkpoint taken at the start of a turn transaction (before LLM call).
702    CheckpointTaken {
703        turn: u32,
704        history_len: u32,
705    },
706    /// O6: the repeat fuse tripped — the same turn signature (non-meta tool name AND args) was
707    /// re-issued `count`x consecutively. `action` = `"deny"` (turn rolled back, directive note fed
708    /// back) or `"terminate"` (run ends `no_progress` after one final report turn). Additive ABI.
709    RepeatFuseTripped {
710        turn: u32,
711        signature: String,
712        count: u32,
713        action: String,
714    },
715    /// O4: the turn-end criteria gate fired — the model tried to finish while acceptance criteria
716    /// stand; the kernel injected one self-check turn before accepting `Completed`. Additive ABI.
717    CriteriaGateFired {
718        turn: u32,
719        criteria: Vec<String>,
720    },
721    /// Session-entropy sample at a completed turn boundary (the heartbeat watch source).
722    /// One per completed turn, unconditional — like `CheckpointTaken`. The component
723    /// vector is the contract; `score` is a versioned default fold (`score_version`).
724    /// See `scheduler::entropy`. Additive ABI.
725    EntropySample {
726        turn: u32,
727        score: f64,
728        score_version: u32,
729        rho: f64,
730        repeat_pressure: f64,
731        failure_rate: f64,
732        rollbacks_in_window: u32,
733        window_turns: u32,
734    },
735    /// The opt-in entropy watch tripped: `score` crossed `threshold` while armed and
736    /// cooled down (`EntropyWatchConfig`). Correlate components via the same-turn
737    /// `EntropySample`. Additive ABI.
738    EntropyAlert {
739        turn: u32,
740        score: f64,
741        threshold: f64,
742    },
743    /// Kernel process table changed for a spawned sub-agent.
744    AgentProcessChanged {
745        turn: u32,
746        agent_id: String,
747        parent_session_id: String,
748        role: String,
749        isolation: String,
750        context_inheritance: String,
751        state: String,
752        #[serde(default, skip_serializing_if = "Vec::is_empty")]
753        permitted_capability_ids: Vec<String>,
754        #[serde(default, skip_serializing_if = "Option::is_none")]
755        result_termination: Option<String>,
756    },
757    /// W0-ABI: a workflow batch was spawned — each node's spawn descriptor (agent id + goal +
758    /// role/isolation/inheritance) so the SDK can run the kernel-generated nodes.
759    WorkflowBatchSpawned {
760        turn: u32,
761        nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
762        /// G4 budget-as-signal: the workflow's remaining headroom under the active quota at spawn
763        /// time, so a coordinator node can scale its next submission. Additive: omitted when no
764        /// resource quota is installed (nothing to report).
765        #[serde(default, skip_serializing_if = "Option::is_none")]
766        budget: Option<crate::orchestration::workflow::WorkflowBudget>,
767    },
768    /// W0-ABI: a workflow finished (all nodes terminal, or stalled by a gated dependency).
769    WorkflowCompleted {
770        turn: u32,
771        #[serde(default, skip_serializing_if = "Vec::is_empty")]
772        completed: Vec<String>,
773        #[serde(default, skip_serializing_if = "Vec::is_empty")]
774        failed: Vec<String>,
775    },
776    /// #2-B: a high-urgency `InterruptNow` signal preempted in-flight work. The kernel has already
777    /// marked these agents `Done(UserAbort)` and reclaimed the root to reason about the interrupt; the
778    /// SDK must ABORT the listed in-flight child runs and discard their results (do NOT feed their
779    /// `SubAgentCompleted`). Additive variant (`agent_preempted`) — byte-identical for SDKs that never
780    /// receive it.
781    AgentPreempted {
782        turn: u32,
783        #[serde(default, skip_serializing_if = "Vec::is_empty")]
784        agent_ids: Vec<String>,
785        reason: String,
786    },
787    /// ③ loop-agent pacing: the kernel adjudicated a `pace` proposal for this round.
788    RoundPaced {
789        turn: u32,
790        round: u32,
791        decision: crate::types::result::PaceDecision,
792    },
793    /// R3-1: a runtime node submission was appended to the in-flight DAG at `base`
794    /// (the graph length before the append). The SDK records `base` on the
795    /// `workflow_nodes_submitted` session event so resume can re-apply the batch at
796    /// the exact original indices (gap-filling any interleaved runtime children).
797    WorkflowNodesSubmitted {
798        turn: u32,
799        base: u32,
800        count: u32,
801        /// W-N3: the submitting node's agent id (`None` = host/bootstrap). Persisted so resume can
802        /// DROP batches whose submitter re-runs (it will re-submit) instead of duplicating them.
803        #[serde(default, skip_serializing_if = "Option::is_none")]
804        submitter: Option<String>,
805    },
806    /// A tool call needs user approval (governance `AskUser`). Not blocked by the
807    /// kernel — the SDK must obtain approval before executing the named call.
808    ToolGated {
809        turn: u32,
810        call_id: String,
811        tool: String,
812        reason: String,
813    },
814    /// An inbound signal was routed by the in-kernel attention policy.
815    SignalDisposed {
816        turn: u32,
817        signal_id: String,
818        disposition: String,
819        queue_depth: u32,
820    },
821    /// A budget axis (turns / tokens / wall-time) was exhausted.
822    BudgetExceeded { turn: u32, budget: String },
823    /// Loop entered `Suspended` state (awaiting human approval or sub-agent).
824    Suspended {
825        turn: u32,
826        reason: String,
827        #[serde(default, skip_serializing_if = "Vec::is_empty")]
828        pending_calls: Vec<String>,
829    },
830    /// Loop resumed from `Suspended` state.
831    Resumed {
832        turn: u32,
833        #[serde(default, skip_serializing_if = "Vec::is_empty")]
834        approved: Vec<String>,
835        #[serde(default, skip_serializing_if = "Vec::is_empty")]
836        denied: Vec<String>,
837    },
838    /// Memory entry written successfully (Phase 7).
839    MemoryWritten {
840        turn: u32,
841        memory_id: String,
842        memory_kind: String,
843        size_bytes: u32,
844    },
845    /// Memory validation failed (Phase 7).
846    MemoryValidationFailed {
847        turn: u32,
848        memory_id: String,
849        error: String,
850    },
851    /// Memory query request (Phase 7).
852    MemoryQueried {
853        turn: u32,
854        query_context: String,
855        requested_k: usize,
856        requires_async_response: bool,
857    },
858    /// Large tool result spooled (Layer 1).
859    LargeResultSpooled {
860        turn: u32,
861        call_id: String,
862        tool: String,
863        original_size: u32,
864        preview_size: u32,
865        spool_ref: Option<String>,
866    },
867}
868
869#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
870#[serde(rename_all = "snake_case")]
871pub enum KernelPressureAction {
872    None,
873    SnipCompact,
874    MicroCompact,
875    ContextCollapse,
876    AutoCompact,
877}
878
879impl From<PressureAction> for KernelPressureAction {
880    fn from(action: PressureAction) -> Self {
881        match action {
882            PressureAction::None => Self::None,
883            PressureAction::SnipCompact => Self::SnipCompact,
884            PressureAction::MicroCompact => Self::MicroCompact,
885            PressureAction::ContextCollapse => Self::ContextCollapse,
886            PressureAction::AutoCompact => Self::AutoCompact,
887        }
888    }
889}
890
891/// Pure kernel runtime wrapper. SDKs should migrate toward feeding
892/// `KernelInput` values here instead of directly driving `LoopStateMachine`.
893pub struct KernelRuntime {
894    sm: LoopStateMachine,
895}
896
897impl KernelRuntime {
898    pub fn new(policy: SchedulerBudget) -> Self {
899        Self {
900            sm: LoopStateMachine::new(policy),
901        }
902    }
903
904    pub fn state_machine(&self) -> &LoopStateMachine {
905        &self.sm
906    }
907
908    pub fn state_machine_mut(&mut self) -> &mut LoopStateMachine {
909        &mut self.sm
910    }
911
912    pub fn is_terminal(&self) -> bool {
913        self.sm.is_terminal()
914    }
915
916    /// L1 (RunGroup): this vehicle's cumulative sub-agent spawns this run, read back by the SDK at run
917    /// end to charge the group ledger (so the next member's cumulative spawn cap is seeded correctly).
918    pub fn local_subagents_spawned(&self) -> u32 {
919        self.sm.local_subagents_spawned()
920    }
921
922    pub fn step(&mut self, input: KernelInput) -> KernelStep {
923        // The ABI version stamped by `KernelInput::new` is checked, not ceremonial: a payload
924        // built against a different kernel ABI is rejected instead of being silently
925        // reinterpreted under this version's semantics.
926        if input.version != KERNEL_ABI_VERSION {
927            self.sm.observations.push(KernelObservation::ToolGated {
928                turn: self.sm.turn,
929                call_id: String::new(),
930                tool: "kernel_abi".to_string(),
931                reason: format!(
932                    "kernel ABI version mismatch: input v{}, kernel v{}",
933                    input.version, KERNEL_ABI_VERSION
934                ),
935            });
936            return KernelStep::empty(self.sm.take_observations());
937        }
938        let action = match input.event {
939            KernelInputEvent::SetTools { tools } => {
940                self.sm.tools = tools;
941                return KernelStep::empty(self.sm.take_observations());
942            }
943            KernelInputEvent::SetAvailableSkills { skills } => {
944                self.sm.ctx.set_available_skills(skills);
945                return KernelStep::empty(self.sm.take_observations());
946            }
947            KernelInputEvent::SkillActivated { name, lease_turns } => {
948                // B1: record the activation (B2 reads it in emit_call_llm to narrow tools).
949                // The returned `changed` flag is the epoch boundary for D's cache re-anchor.
950                // K3: a lease converts to an absolute expiry turn here (the manager is turn-blind).
951                let expires_at_turn = lease_turns.map(|n| self.sm.turn.saturating_add(n));
952                self.sm.ctx.activate_skill_leased(name, expires_at_turn);
953                return KernelStep::empty(self.sm.take_observations());
954            }
955            KernelInputEvent::SkillDeactivated { name } => {
956                self.sm.ctx.deactivate_skill(&name);
957                return KernelStep::empty(self.sm.take_observations());
958            }
959            KernelInputEvent::SetStableCoreTools { tool_ids } => {
960                self.sm.ctx.set_stable_core_tools(tool_ids.into_iter().map(Into::into));
961                return KernelStep::empty(self.sm.take_observations());
962            }
963            KernelInputEvent::SetMemoryEnabled { enabled } => {
964                self.sm.ctx.set_memory_enabled(enabled);
965                return KernelStep::empty(self.sm.take_observations());
966            }
967            KernelInputEvent::SetKnowledgeEnabled { enabled } => {
968                self.sm.ctx.set_knowledge_enabled(enabled);
969                return KernelStep::empty(self.sm.take_observations());
970            }
971            KernelInputEvent::SetPlanToolEnabled { enabled } => {
972                self.sm.ctx.set_plan_tool_enabled(enabled);
973                return KernelStep::empty(self.sm.take_observations());
974            }
975            KernelInputEvent::SetTokenizer { .. } => {
976                // Local BPE tokenisers are no longer used — accuracy comes from
977                // observed_input_tokens reported by the provider API (P0-1 Step 2).
978                // char_approx is always used for pre-flight truncation estimates.
979                self.sm.ctx.engine = ContextTokenEngine::char_approx();
980                return KernelStep::empty(self.sm.take_observations());
981            }
982            KernelInputEvent::AddSystemMessage { content, tokens } => {
983                self.sm
984                    .ctx
985                    .partitions
986                    .system
987                    .push(Message::system(content), tokens.max(1));
988                return KernelStep::empty(self.sm.take_observations());
989            }
990            KernelInputEvent::AddKnowledgeMessage { content, tokens, key, pinned } => {
991                // P1-B2 cache contract: the knowledge partition renders into the cached system[1]
992                // block. Appending here is the right home for *stable* reference material (skill
993                // defs, durable artifacts) — it's append-only, so the existing prefix stays
994                // byte-stable, and a fresh append costs only a one-time system[1] re-cache. Do NOT
995                // route *per-turn* retrievals (a memory/knowledge lookup that changes every turn)
996                // through here: each would rewrite the cached block and invalidate it plus the
997                // history cache every turn. Volatile per-turn context belongs on the signal/tail
998                // path (`push_signal` → state_turn), which is uncached *and* high-attention (P1-F).
999                //
1000                // K1: a `key` gives the entry identity — a same-key push stages a boundary-deferred
1001                // upsert instead of appending a duplicate (the cache contract above is why the swap
1002                // waits for the next compaction/renewal boundary).
1003                self.sm.ctx.push_knowledge_entry(
1004                    key.map(compact_str::CompactString::from),
1005                    Message::system(content),
1006                    tokens.max(1),
1007                    pinned,
1008                );
1009                return KernelStep::empty(self.sm.take_observations());
1010            }
1011            KernelInputEvent::RemoveKnowledge { key } => {
1012                self.sm.ctx.remove_knowledge(&key);
1013                return KernelStep::empty(self.sm.take_observations());
1014            }
1015            KernelInputEvent::AddHistoryMessage { message, tokens } => {
1016                let tokens = tokens.unwrap_or_else(|| self.sm.ctx.engine.count_message(&message));
1017                self.sm.ctx.push_history(message, tokens.max(1));
1018                return KernelStep::empty(self.sm.take_observations());
1019            }
1020            KernelInputEvent::PreloadHistory { messages } => {
1021                self.sm.preload_history(messages);
1022                return KernelStep::empty(self.sm.take_observations());
1023            }
1024            KernelInputEvent::MountCapability { capability } => {
1025                self.sm.mount_capability(capability, None, None);
1026                return KernelStep::empty(self.sm.take_observations());
1027            }
1028            KernelInputEvent::UnmountCapability {
1029                capability_kind,
1030                id,
1031            } => {
1032                self.sm.unmount_capability(capability_kind, &id);
1033                return KernelStep::empty(self.sm.take_observations());
1034            }
1035            KernelInputEvent::LoadMilestoneContract { contract } => {
1036                self.sm.load_milestone_contract(contract);
1037                return KernelStep::empty(self.sm.take_observations());
1038            }
1039            KernelInputEvent::LoadGovernancePolicy {
1040                default_action,
1041                rules,
1042                vetoed_tools,
1043                rate_limits,
1044                constraints,
1045            } => {
1046                self.sm.set_governance(build_governance_pipeline(
1047                    default_action,
1048                    rules,
1049                    vetoed_tools,
1050                    rate_limits,
1051                    constraints,
1052                ));
1053                return KernelStep::empty(self.sm.take_observations());
1054            }
1055            KernelInputEvent::ConfigureRun { config } => {
1056                // K2: apply a bundle of run-setup config in one event (tools / governance / attention /
1057                // quota / scheduler / toggles), replacing the ~10 separate `Set*` / `Load*` events the
1058                // SDK used to fire one-by-one. Each field is optional; an absent field is left untouched.
1059                // The individual events remain for runtime mutation (skill mount, mid-run budget change).
1060                // Each branch delegates to exactly the method its granular event uses, so the two paths
1061                // can never diverge.
1062                let RunConfig {
1063                    tools,
1064                    available_skills,
1065                    stable_core_tools,
1066                    memory_enabled,
1067                    knowledge_enabled,
1068                    plan_tool_enabled,
1069                    tokenizer,
1070                    governance,
1071                    attention_max_queue_size,
1072                    scheduler_max_wall_ms,
1073                    resource_quota,
1074                    group_tokens_base,
1075                    group_spawns_base,
1076                    group_rounds_base,
1077                    repeat_fuse,
1078                    criteria_gate,
1079                    knowledge_budget_ratio,
1080                    entropy_watch,
1081                } = config;
1082                if let Some(tools) = tools {
1083                    self.sm.tools = tools;
1084                }
1085                if let Some(skills) = available_skills {
1086                    self.sm.ctx.set_available_skills(skills);
1087                }
1088                if let Some(ids) = stable_core_tools {
1089                    self.sm.ctx.set_stable_core_tools(ids.into_iter().map(Into::into));
1090                }
1091                if let Some(enabled) = memory_enabled {
1092                    self.sm.ctx.set_memory_enabled(enabled);
1093                }
1094                if let Some(enabled) = knowledge_enabled {
1095                    self.sm.ctx.set_knowledge_enabled(enabled);
1096                }
1097                if let Some(enabled) = plan_tool_enabled {
1098                    self.sm.ctx.set_plan_tool_enabled(enabled);
1099                }
1100                if tokenizer.is_some() {
1101                    self.sm.ctx.engine = ContextTokenEngine::char_approx();
1102                }
1103                if let Some(g) = governance {
1104                    self.sm.set_governance(build_governance_pipeline(
1105                        g.default_action,
1106                        g.rules,
1107                        g.vetoed_tools,
1108                        g.rate_limits,
1109                        g.constraints,
1110                    ));
1111                }
1112                if let Some(max_queue) = attention_max_queue_size {
1113                    self.sm.set_attention(max_queue as usize);
1114                }
1115                if let Some(ms) = scheduler_max_wall_ms {
1116                    self.sm.set_wall_budget(Some(ms));
1117                }
1118                if let Some(quota) = resource_quota {
1119                    self.sm.set_resource_quota(quota);
1120                }
1121                if let Some(base) = group_tokens_base {
1122                    self.sm.seed_group_budget(base);
1123                }
1124                if let Some(base) = group_spawns_base {
1125                    self.sm.seed_group_spawns(base);
1126                }
1127                if let Some(base) = group_rounds_base {
1128                    self.sm.seed_group_rounds(base);
1129                }
1130                if let Some(fuse) = repeat_fuse {
1131                    self.sm.set_repeat_fuse(fuse);
1132                }
1133                if let Some(enabled) = criteria_gate {
1134                    self.sm.set_criteria_gate(enabled);
1135                }
1136                if let Some(ratio) = knowledge_budget_ratio {
1137                    self.sm.ctx.config.knowledge_budget_ratio = ratio;
1138                }
1139                if let Some(watch) = entropy_watch {
1140                    self.sm.set_entropy_watch(watch);
1141                }
1142                return KernelStep::empty(self.sm.take_observations());
1143            }
1144            KernelInputEvent::SetAttentionPolicy { max_queue_size } => {
1145                self.sm.set_attention(max_queue_size as usize);
1146                return KernelStep::empty(self.sm.take_observations());
1147            }
1148            KernelInputEvent::PageIn { entries } => {
1149                self.sm.apply_page_in(&entries);
1150                return KernelStep::empty(self.sm.take_observations());
1151            }
1152            KernelInputEvent::ForceCompact => {
1153                self.sm.force_compact();
1154                return KernelStep::empty(self.sm.take_observations());
1155            }
1156            KernelInputEvent::UpdateTask { update } => {
1157                self.sm.ctx.update_task(update);
1158                return KernelStep::empty(self.sm.take_observations());
1159            }
1160            KernelInputEvent::StartRun { task, run_spec } => {
1161                self.sm.run_spec = run_spec;
1162                self.sm.start(task)
1163            }
1164            KernelInputEvent::CapabilityCommand { command } => {
1165                self.sm.execute_capability_command(command);
1166                return KernelStep::empty(self.sm.take_observations());
1167            }
1168            KernelInputEvent::Resume { approved_calls, denied_calls } => {
1169                self.sm.resume_from_suspend(approved_calls, denied_calls)
1170            }
1171            KernelInputEvent::SetSchedulerBudget { max_wall_ms } => {
1172                self.sm.set_wall_budget(max_wall_ms);
1173                return KernelStep::empty(self.sm.take_observations());
1174            }
1175            KernelInputEvent::SetResourceQuota { quota } => {
1176                self.sm.set_resource_quota(quota);
1177                return KernelStep::empty(self.sm.take_observations());
1178            }
1179            KernelInputEvent::SetCriteriaGate { enabled } => {
1180                self.sm.set_criteria_gate(enabled);
1181                return KernelStep::empty(self.sm.take_observations());
1182            }
1183            KernelInputEvent::SetKnowledgeBudget { ratio } => {
1184                self.sm.ctx.config.knowledge_budget_ratio = ratio;
1185                return KernelStep::empty(self.sm.take_observations());
1186            }
1187            KernelInputEvent::SetRepeatFuse { enabled, deny_after, terminate_after } => {
1188                let mut cfg = self.sm.repeat_fuse_config();
1189                if let Some(e) = enabled { cfg.enabled = e; }
1190                if let Some(d) = deny_after { cfg.deny_after = d; }
1191                if let Some(t) = terminate_after { cfg.terminate_after = t; }
1192                self.sm.set_repeat_fuse(cfg);
1193                return KernelStep::empty(self.sm.take_observations());
1194            }
1195            KernelInputEvent::SetEntropyWatch { enabled, threshold, hysteresis, cooldown_turns, notify_model } => {
1196                let mut cfg = self.sm.entropy_watch_config();
1197                if let Some(e) = enabled { cfg.enabled = e; }
1198                if let Some(t) = threshold { cfg.threshold = t; }
1199                if let Some(h) = hysteresis { cfg.hysteresis = h; }
1200                if let Some(c) = cooldown_turns { cfg.cooldown_turns = c; }
1201                if let Some(n) = notify_model { cfg.notify_model = n; }
1202                self.sm.set_entropy_watch(cfg);
1203                return KernelStep::empty(self.sm.take_observations());
1204            }
1205            KernelInputEvent::ProviderResult {
1206                message,
1207                observed_input_tokens,
1208                observed_output_tokens: _,
1209                now_ms,
1210                stop_reason,
1211            } => {
1212                if let Some(tokens) = observed_input_tokens {
1213                    self.sm.ctx.set_observed_prompt_tokens(tokens);
1214                }
1215                // Feed the clock before the governance gate fires inside `feed`, so the
1216                // rate limiter sees a real timestamp (no-op when no policy is loaded).
1217                if let Some(ms) = now_ms {
1218                    self.sm.set_observed_time(ms);
1219                }
1220                // Stash stop_reason so `feed` can detect an output-cap truncation and drive recovery.
1221                self.sm.set_pending_stop_reason(stop_reason);
1222                self.sm.feed(LoopEvent::LLMResponse { message })
1223            }
1224            KernelInputEvent::ToolResults { results } => {
1225                self.sm.feed(LoopEvent::ToolResults { results })
1226            }
1227            KernelInputEvent::ProviderError { message } => {
1228                // Reactive recovery is a kernel decision: classify + bounded compact-and-retry,
1229                // returning the next action (retry or honest terminal) through the common tail.
1230                self.sm.recover_from_provider_error(&message)
1231            }
1232            KernelInputEvent::Signal { signal } => match self.sm.signal_event(signal) {
1233                Some(action) => action,
1234                // Non-actionable disposition (queued / observed / ignored / dropped):
1235                // no provider call this step, just the SignalDisposed observation.
1236                None => return KernelStep::empty(self.sm.take_observations()),
1237            },
1238            KernelInputEvent::MilestoneResult { result } => {
1239                self.sm.feed(LoopEvent::MilestoneResult { result })
1240            }
1241            KernelInputEvent::SpawnSubAgent {
1242                spec,
1243                parent_session_id,
1244            } => {
1245                self.sm.spawn_sub_agent(spec, &parent_session_id)
1246            }
1247            KernelInputEvent::LoadWorkflow {
1248                spec,
1249                parent_session_id,
1250                resumed_completed,
1251                resumed_submissions,
1252                resumed_submission_bases,
1253                resumed_results,
1254            } => {
1255                // K1: self-bootstrap the run if the host never fired `StartRun` (stateless
1256                // `runWorkflow` caller). Parity with the agent-reachable `SubmitWorkflow`, which already
1257                // bootstraps. Idempotent no-op once the root task has left `Ready`.
1258                self.sm.ensure_started_for_workflow(&spec);
1259                if resumed_completed.is_empty()
1260                    && resumed_results.is_empty()
1261                    && resumed_submissions.is_empty()
1262                {
1263                    self.sm.load_workflow(spec, &parent_session_id)
1264                } else {
1265                    // W-1: merge legacy bare ids with signal-carrying records (records win).
1266                    use crate::orchestration::workflow::ResumedCompletion;
1267                    let mut completed: Vec<ResumedCompletion> = resumed_completed
1268                        .iter()
1269                        .filter(|id| resumed_results.iter().all(|r| &r.agent_id != *id))
1270                        .map(ResumedCompletion::bare)
1271                        .collect();
1272                    completed.extend(resumed_results);
1273                    self.sm.load_workflow_resumed(
1274                        spec,
1275                        &parent_session_id,
1276                        &resumed_submissions,
1277                        &resumed_submission_bases,
1278                        &completed,
1279                    )
1280                }
1281            }
1282            KernelInputEvent::SubAgentCompleted { result } => {
1283                self.sm.feed(LoopEvent::SubAgentCompleted { result })
1284            }
1285            KernelInputEvent::SubmitWorkflowNodes {
1286                nodes,
1287                submitter_agent_id,
1288            } => {
1289                self.sm
1290                    .submit_workflow_nodes(nodes, submitter_agent_id.as_deref())
1291            }
1292            KernelInputEvent::SubmitWorkflow {
1293                spec,
1294                parent_session_id,
1295                submitter_agent_id,
1296            } => {
1297                self.sm.submit_workflow(
1298                    spec,
1299                    &parent_session_id,
1300                    submitter_agent_id.as_deref(),
1301                )
1302            }
1303            KernelInputEvent::SetMemoryPolicy {
1304                memory_path,
1305                stale_warning_days,
1306                retrieval_top_k,
1307                validation_enabled,
1308                max_content_bytes,
1309                max_name_length,
1310            } => {
1311                // Phase 7: install the memory policy. The kernel enforces validation_enabled +
1312                // retrieval_top_k + size/name overrides at the WriteMemory/QueryMemory traps;
1313                // memory_path / stale_warning_days are carried for the SDK's recall I/O.
1314                self.sm.set_memory_policy(crate::mm::memory::MemoryPolicy {
1315                    memory_path,
1316                    stale_warning_days,
1317                    retrieval_top_k,
1318                    validation_enabled,
1319                    max_content_bytes,
1320                    max_name_length,
1321                });
1322                return KernelStep::empty(self.sm.take_observations());
1323            }
1324            KernelInputEvent::WriteMemory { memory } => {
1325                // Phase 7: Validate memory write request.
1326                // Kernel validates; SDK performs I/O.
1327                use crate::mm::memory::validate_memory_write;
1328                let turn = self.sm.turn;
1329                // M2: route the write through the syscall trap so the resource quota (write-rate
1330                // limit) applies. A rate-limited / denied write surfaces as a validation failure
1331                // (the write does not happen) and short-circuits before validation.
1332                let disposition = self
1333                    .sm
1334                    .gate_syscall(&crate::syscall::Syscall::WriteMemory(memory.clone()));
1335                if !disposition.is_allowed() {
1336                    let error = match disposition {
1337                        crate::syscall::Disposition::RateLimited { retry_after_ms } => {
1338                            format!("memory write rate limited; retry after {retry_after_ms}ms")
1339                        }
1340                        crate::syscall::Disposition::Deny { reason, .. } => {
1341                            format!("memory write denied: {reason}")
1342                        }
1343                        _ => "memory write not permitted".to_string(),
1344                    };
1345                    self.sm.observations.push(
1346                        KernelObservation::MemoryValidationFailed {
1347                            turn,
1348                            memory_id: memory.metadata.name.clone(),
1349                            error,
1350                        },
1351                    );
1352                    return KernelStep::empty(self.sm.take_observations());
1353                }
1354                // Validate honoring any installed memory policy: a policy with validation disabled
1355                // admits the write outright; a policy with size/name overrides validates against
1356                // those; no policy uses the default rules (pre-policy behavior).
1357                let validation_result = match self.sm.memory_policy() {
1358                    Some(p) if !p.validation_enabled => Ok(()),
1359                    Some(p) => p.validation().validate(&memory),
1360                    None => validate_memory_write(&memory),
1361                };
1362                match validation_result {
1363                    Ok(()) => {
1364                        // Emit observation for SDK to perform I/O
1365                        self.sm.observations.push(KernelObservation::MemoryWritten {
1366                            turn,
1367                            memory_id: memory.metadata.name.clone(),
1368                            // Kind is an optional caller-supplied label; the kernel does not
1369                            // guess taxonomy from metadata (P13: heuristic classifier deleted).
1370                            memory_kind: memory
1371                                .metadata
1372                                .kind
1373                                .map(|k| k.label())
1374                                .unwrap_or("unclassified")
1375                                .to_string(),
1376                            size_bytes: memory.content.len() as u32,
1377                        });
1378                    }
1379                    Err(err) => {
1380                        // Emit validation error observation
1381                        use crate::mm::memory::MemoryValidationError;
1382                        let error_msg = match err {
1383                            MemoryValidationError::MissingRequiredField { field } => format!("Missing required field: {}", field),
1384                            MemoryValidationError::ContentTooLarge { size, limit } => format!("Content too large: {} bytes (limit: {})", size, limit),
1385                            MemoryValidationError::ForbiddenPattern { pattern, reason } => format!("Forbidden pattern '{}': {}", pattern, reason),
1386                            MemoryValidationError::InvalidKind { kind } => format!("Invalid kind: {}", kind),
1387                            MemoryValidationError::NameTooLong { length, limit } => format!("Name too long: {} chars (limit: {})", length, limit),
1388                        };
1389                        self.sm.observations.push(KernelObservation::MemoryValidationFailed {
1390                            turn,
1391                            memory_id: memory.metadata.name.clone(),
1392                            error: error_msg,
1393                        });
1394                    }
1395                }
1396                return KernelStep::empty(self.sm.take_observations());
1397            }
1398            KernelInputEvent::QueryMemory { query } => {
1399                // Phase 7: Query memory for context.
1400                // Kernel emits observation; SDK responds asynchronously.
1401                let turn = self.sm.turn;
1402                // An installed policy caps retrieval breadth: requested_k = min(query.top_k, policy).
1403                let requested_k = match self.sm.memory_policy() {
1404                    Some(p) => p.clamp_top_k(query.top_k),
1405                    None => query.top_k,
1406                };
1407                self.sm.observations.push(KernelObservation::MemoryQueried {
1408                    turn,
1409                    query_context: query.current_context.clone(),
1410                    requested_k,
1411                    requires_async_response: true,
1412                });
1413                return KernelStep::empty(self.sm.take_observations());
1414            }
1415            KernelInputEvent::Timeout => self.sm.feed(LoopEvent::Timeout),
1416        };
1417        if matches!(action, LoopAction::AwaitingResume) {
1418            return KernelStep::empty(self.sm.take_observations());
1419        }
1420        KernelStep::single(action, self.sm.take_observations())
1421    }
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427
1428    #[test]
1429    fn start_run_returns_versioned_provider_action() {
1430        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1431        let step = runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1432            task: RuntimeTask::new("ship it"),
1433            run_spec: None,
1434        }));
1435
1436        assert_eq!(step.version, KERNEL_ABI_VERSION);
1437        assert!(matches!(
1438            step.actions.as_slice(),
1439            [KernelAction::CallProvider { .. }]
1440        ));
1441    }
1442
1443    #[test]
1444    fn provider_text_response_returns_done() {
1445        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1446        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1447            task: RuntimeTask::new("ship it"),
1448            run_spec: None,
1449        }));
1450        let step = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1451            message: Message::assistant("done"),
1452            observed_input_tokens: None,
1453            observed_output_tokens: None,
1454            stop_reason: None,
1455            now_ms: None,
1456        }));
1457
1458        assert!(matches!(
1459            step.actions.as_slice(),
1460            [KernelAction::Done { .. }]
1461        ));
1462    }
1463
1464    #[test]
1465    fn config_inputs_mutate_runtime_without_actions() {
1466        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1467        let step = runtime.step(KernelInput::new(KernelInputEvent::SetTools {
1468            tools: vec![ToolSchema {
1469                name: "echo".into(),
1470                description: "Echo input".to_string(),
1471                parameters: serde_json::json!({"type": "object"}),
1472            }],
1473        }));
1474
1475        assert!(step.actions.is_empty());
1476        assert_eq!(runtime.state_machine().tools.len(), 1);
1477    }
1478
1479    #[test]
1480    fn skill_activated_input_records_active_skill() {
1481        // P1-B B1: the SkillActivated event (serde `skill_activated`) records the active skill and,
1482        // via the catalog's declared tools, yields a narrowing filter — without itself acting.
1483        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1484        let mut debug = SkillMetadata::new("debug", "Debug helper");
1485        debug.allowed_tools = vec!["read".into(), "grep".into()];
1486        runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1487            skills: vec![debug],
1488        }));
1489
1490        let step = runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1491            name: "debug".to_string(),
1492            lease_turns: None,
1493        }));
1494
1495        assert!(step.actions.is_empty(), "activation is config, not an action");
1496        assert!(runtime.state_machine().ctx.active_skills.contains_key("debug"));
1497        let filter = runtime.state_machine().ctx.active_skill_tool_filter().unwrap();
1498        assert_eq!(filter.len(), 2);
1499    }
1500
1501    #[test]
1502    fn skill_deactivated_rewidens_toolset_and_unpins_knowledge() {
1503        // K3: deactivation removes the skill from the active set (filter back to None ⇒ no
1504        // narrowing) and marks its `skill:<name>` knowledge pin for the next boundary sweep.
1505        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1506        let mut debug = SkillMetadata::new("debug", "Debug helper");
1507        debug.allowed_tools = vec!["read".into(), "grep".into()];
1508        runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1509            skills: vec![debug],
1510        }));
1511        runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1512            name: "debug".to_string(),
1513            lease_turns: None,
1514        }));
1515        runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1516            content: "debug skill content".to_string(),
1517            tokens: 5,
1518            key: Some("skill:debug".to_string()),
1519            pinned: false,
1520        }));
1521        assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_some());
1522
1523        runtime.step(KernelInput::new(KernelInputEvent::SkillDeactivated {
1524            name: "debug".to_string(),
1525        }));
1526        let sm = runtime.state_machine();
1527        assert!(!sm.ctx.active_skills.contains_key("debug"));
1528        assert!(sm.ctx.active_skill_tool_filter().is_none(), "toolset re-widens");
1529        assert!(
1530            sm.ctx.partitions.knowledge.entries[0].evict_at_boundary,
1531            "knowledge pin marked for the boundary sweep"
1532        );
1533    }
1534
1535    #[test]
1536    fn skill_lease_expires_after_turns_and_reactivation_renarrows() {
1537        // K3: `lease_turns: 1` expires once the turn counter passes activation+1 — the head-of-
1538        // event sweep deactivates it exactly like an explicit SkillDeactivated. A later
1539        // re-activation re-narrows.
1540        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1541        let mut debug = SkillMetadata::new("debug", "Debug helper");
1542        debug.allowed_tools = vec!["read".into(), "grep".into()];
1543        runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1544            skills: vec![debug],
1545        }));
1546        runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1547            name: "debug".to_string(),
1548            lease_turns: Some(1),
1549        }));
1550        assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_some());
1551
1552        // One full tool round advances the turn; the sweep runs at the HEAD of the next loop
1553        // event, so the following provider turn is what actually expires the lease.
1554        run_with_tool_call(&mut runtime, "read");
1555        runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1556            results: vec![ToolResult {
1557                call_id: "call-1".into(),
1558                output: crate::types::message::Content::Text("ok".into()),
1559                is_error: false,
1560                is_fatal: false,
1561                error_kind: None,
1562                token_count: None,
1563            }],
1564        }));
1565        runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1566            message: Message::assistant("done"),
1567            observed_input_tokens: None,
1568            observed_output_tokens: None,
1569            stop_reason: None,
1570            now_ms: None,
1571        }));
1572        assert!(
1573            !runtime.state_machine().ctx.active_skills.contains_key("debug"),
1574            "lease expired after the turn advanced"
1575        );
1576        assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_none());
1577
1578        // Re-activation works and re-narrows.
1579        runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1580            name: "debug".to_string(),
1581            lease_turns: None,
1582        }));
1583        assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_some());
1584    }
1585
1586    #[test]
1587    fn update_task_input_mutates_task_state() {
1588        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1589        let step = runtime.step(KernelInput::new(KernelInputEvent::UpdateTask {
1590            update: TaskUpdate {
1591                progress: Some("tools executed".to_string()),
1592                ..Default::default()
1593            },
1594        }));
1595
1596        assert!(step.actions.is_empty());
1597        assert_eq!(
1598            runtime.state_machine().ctx.partitions.task_state.progress,
1599            "tools executed"
1600        );
1601    }
1602
1603    #[test]
1604    fn add_knowledge_message_enters_knowledge_partition() {
1605        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1606        let step = runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1607            content: "skill: debug".to_string(),
1608            tokens: 10,
1609            key: None,
1610            pinned: false,
1611        }));
1612
1613        assert!(step.actions.is_empty());
1614        assert_eq!(
1615            runtime.state_machine().ctx.partitions.knowledge.len(),
1616            1
1617        );
1618    }
1619
1620    #[test]
1621    fn knowledge_budget_exceeded_observed_in_live_loop() {
1622        // K2: the per-turn budget check runs in the LLMResponse path; an over-budget knowledge
1623        // partition (40 tokens > 100 × 0.25 default) surfaces as a KnowledgeBudgetExceeded
1624        // observation. Raising the ratio via SetKnowledgeBudget silences it.
1625        let mut runtime = KernelRuntime::new(SchedulerBudget {
1626            max_tokens: 100,
1627            ..SchedulerBudget::default()
1628        });
1629        runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1630            content: "reference".to_string(),
1631            tokens: 40,
1632            key: None,
1633            pinned: false,
1634        }));
1635        run_with_tool_call(&mut runtime, "search");
1636        // The check runs at the turn boundary (ToolResults handling), not on the proposal.
1637        let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1638            results: vec![ToolResult {
1639                call_id: "call-1".into(),
1640                output: crate::types::message::Content::Text("ok".into()),
1641                is_error: false,
1642                is_fatal: false,
1643                error_kind: None,
1644                token_count: None,
1645            }],
1646        }));
1647        assert!(step.observations.iter().any(|o| matches!(
1648            o,
1649            KernelObservation::KnowledgeBudgetExceeded { used: 40, budget: 25, .. }
1650        )));
1651
1652        // Runtime knob: a generous ratio ⇒ under budget ⇒ no warning on the next turn.
1653        let mut runtime2 = KernelRuntime::new(SchedulerBudget {
1654            max_tokens: 100,
1655            ..SchedulerBudget::default()
1656        });
1657        runtime2.step(KernelInput::new(KernelInputEvent::SetKnowledgeBudget { ratio: 0.9 }));
1658        runtime2.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1659            content: "reference".to_string(),
1660            tokens: 40,
1661            key: None,
1662            pinned: false,
1663        }));
1664        run_with_tool_call(&mut runtime2, "search");
1665        let step2 = runtime2.step(KernelInput::new(KernelInputEvent::ToolResults {
1666            results: vec![ToolResult {
1667                call_id: "call-1".into(),
1668                output: crate::types::message::Content::Text("ok".into()),
1669                is_error: false,
1670                is_fatal: false,
1671                error_kind: None,
1672                token_count: None,
1673            }],
1674        }));
1675        assert!(!step2.observations.iter().any(|o| matches!(
1676            o,
1677            KernelObservation::KnowledgeBudgetExceeded { .. }
1678        )));
1679    }
1680
1681    #[test]
1682    fn keyed_add_knowledge_dedupes_and_remove_marks() {
1683        // K1 event-level: a same-key AddKnowledgeMessage stages an upsert (one entry, original
1684        // bytes rendered mid-generation); RemoveKnowledge marks for the boundary sweep. Both
1685        // decoded from the serde wire shape SDKs feed.
1686        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1687        for content in ["v1", "v2"] {
1688            let ev: KernelInputEvent = serde_json::from_value(serde_json::json!({
1689                "kind": "add_knowledge_message",
1690                "content": content,
1691                "tokens": 5,
1692                "key": "skill:debug",
1693            }))
1694            .unwrap();
1695            runtime.step(KernelInput::new(ev));
1696        }
1697        let knowledge = &runtime.state_machine().ctx.partitions.knowledge;
1698        assert_eq!(knowledge.len(), 1);
1699        assert_eq!(
1700            knowledge.messages().next().and_then(|m| m.content.as_text()),
1701            Some("v1"),
1702            "upsert deferred to boundary — original bytes still rendered"
1703        );
1704
1705        let ev: KernelInputEvent = serde_json::from_value(serde_json::json!({
1706            "kind": "remove_knowledge",
1707            "key": "skill:debug",
1708        }))
1709        .unwrap();
1710        runtime.step(KernelInput::new(ev));
1711        assert!(runtime.state_machine().ctx.partitions.knowledge.entries[0].evict_at_boundary);
1712    }
1713
1714    #[test]
1715    fn capability_mount_emits_observation() {
1716        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1717        let step = runtime.step(KernelInput::new(KernelInputEvent::MountCapability {
1718            capability: CapabilityDescriptor::marker(
1719                CapabilityKind::McpServer,
1720                "docs",
1721                "Documentation server",
1722            ),
1723        }));
1724
1725        assert!(step.actions.is_empty());
1726        assert!(matches!(
1727            step.observations.as_slice(),
1728            [KernelObservation::CapabilityChanged { .. }]
1729        ));
1730    }
1731
1732    #[test]
1733    fn spawn_sub_agent_input_registers_process() {
1734        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1735
1736        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1737        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1738            task: RuntimeTask::new("parent task"),
1739            run_spec: None,
1740        }));
1741        runtime.state_machine_mut().take_observations();
1742
1743        let spec = AgentRunSpec::new(
1744            AgentIdentity::sub_agent("worker", "worker-session"),
1745            AgentRole::Implement,
1746            "do work",
1747        );
1748        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1749            spec,
1750            parent_session_id: "parent-session".to_string(),
1751        }));
1752
1753        assert!(step.actions.is_empty());
1754        assert!(step.observations.iter().any(|o| matches!(
1755            o,
1756            KernelObservation::AgentProcessChanged {
1757                agent_id,
1758                parent_session_id,
1759                state,
1760                ..
1761            } if agent_id == "worker" && parent_session_id == "parent-session" && state == "running"
1762        )));
1763        assert_eq!(
1764            runtime
1765                .state_machine()
1766                .agent_process("worker")
1767                .expect("process")
1768                .parent_session_id
1769                .as_str(),
1770            "parent-session"
1771        );
1772        assert!(step.observations.iter().any(|o| matches!(
1773            o,
1774            KernelObservation::Suspended { reason, .. } if reason == "sub_agent_await"
1775        )));
1776        assert!(runtime.state_machine().is_suspended());
1777        assert!(matches!(
1778            runtime.state_machine().wait_reason(),
1779            Some(crate::scheduler::tcb::WaitReason::SubAgentJoin(_))
1780        ));
1781    }
1782
1783    #[test]
1784    fn set_resource_quota_input_denies_spawn_over_quota() {
1785        use crate::governance::quota::ResourceQuota;
1786        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1787
1788        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1789        // Quota flows in through the same versioned JSON event ABI as governance/scheduler config.
1790        let step = runtime.step(KernelInput::new(KernelInputEvent::SetResourceQuota {
1791            quota: ResourceQuota { max_spawn_depth: Some(0), ..ResourceQuota::default() },
1792        }));
1793        assert!(step.actions.is_empty(), "config input yields no actions");
1794
1795        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1796            task: RuntimeTask::new("parent task"),
1797            run_spec: None,
1798        }));
1799        runtime.state_machine_mut().take_observations();
1800
1801        let spec = AgentRunSpec::new(
1802            AgentIdentity::sub_agent("worker", "worker-session"),
1803            AgentRole::Implement,
1804            "do work",
1805        );
1806        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1807            spec,
1808            parent_session_id: "parent-session".to_string(),
1809        }));
1810
1811        // Denied spawn rolls the turn back to another reasoning pass — no process registered,
1812        // not suspended on a sub-agent join.
1813        assert!(matches!(
1814            step.actions.as_slice(),
1815            [KernelAction::CallProvider { .. }]
1816        ));
1817        assert!(!step.observations.iter().any(|o| matches!(
1818            o,
1819            KernelObservation::AgentProcessChanged { agent_id, .. } if agent_id == "worker"
1820        )));
1821        assert!(runtime.state_machine().agent_process("worker").is_none());
1822        assert!(!runtime.state_machine().is_suspended());
1823    }
1824
1825    #[test]
1826    fn group_budget_base_enforces_shared_token_cap() {
1827        use crate::types::message::{Content, Message, ToolCall, ToolResult};
1828
1829        // Drive one tool-calling turn under a 100-token run cap, with `group_base` already spent by
1830        // other members of the governance domain. The token-budget axis is checked after the tool
1831        // results, against `group_base + local`.
1832        fn run_one_turn(group_base: Option<u64>) -> KernelStep {
1833            let mut runtime = KernelRuntime::new(SchedulerBudget {
1834                max_total_tokens: 100,
1835                ..SchedulerBudget::default()
1836            });
1837            runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
1838                config: RunConfig { group_tokens_base: group_base, ..RunConfig::default() },
1839            }));
1840            runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1841                task: RuntimeTask::new("task"),
1842                run_spec: None,
1843            }));
1844            let mut msg = Message::assistant("");
1845            msg.token_count = Some(10); // this vehicle's local spend this turn
1846            msg.tool_calls.push(ToolCall {
1847                id: "c1".into(),
1848                name: "echo".into(),
1849                arguments: serde_json::json!({}),
1850            });
1851            runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1852                message: msg,
1853                observed_input_tokens: None,
1854                observed_output_tokens: None,
1855                stop_reason: None,
1856                now_ms: None,
1857            }));
1858            runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1859                results: vec![ToolResult {
1860                    call_id: "c1".into(),
1861                    output: Content::Text("ok".into()),
1862                    is_error: false,
1863                    is_fatal: false,
1864                    error_kind: None,
1865                    token_count: None,
1866                }],
1867            }))
1868        }
1869
1870        let exceeded = |step: &KernelStep| {
1871            step.observations.iter().any(|o| {
1872                matches!(o, KernelObservation::BudgetExceeded { budget, .. } if budget == "token_budget")
1873            })
1874        };
1875
1876        // Group already spent 95; this vehicle's 10 pushes the domain to 105 > 100 → shared cap fires.
1877        assert!(
1878            exceeded(&run_one_turn(Some(95))),
1879            "group token budget must span the whole domain"
1880        );
1881        // N=1 / no group (base 0): local 10 is far under the cap → pre-L1 behavior unchanged.
1882        assert!(
1883            !exceeded(&run_one_turn(None)),
1884            "no group seed ⇒ per-vehicle budget, well under cap"
1885        );
1886    }
1887
1888    #[test]
1889    fn group_spawns_base_enforces_cumulative_spawn_cap() {
1890        use crate::governance::quota::ResourceQuota;
1891        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1892
1893        // Cumulative cap of 2 sub-agents across the domain. Other members already spawned 2 (seeded),
1894        // so this vehicle's very first spawn is denied — the cap spans the whole group.
1895        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1896        runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
1897            config: RunConfig {
1898                resource_quota: Some(ResourceQuota {
1899                    max_total_subagents: Some(2),
1900                    ..ResourceQuota::default()
1901                }),
1902                group_spawns_base: Some(2),
1903                ..RunConfig::default()
1904            },
1905        }));
1906        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1907            task: RuntimeTask::new("task"),
1908            run_spec: None,
1909        }));
1910        runtime.state_machine_mut().take_observations();
1911
1912        let spec = AgentRunSpec::new(
1913            AgentIdentity::sub_agent("worker", "worker-session"),
1914            AgentRole::Implement,
1915            "do work",
1916        );
1917        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1918            spec,
1919            parent_session_id: "parent-session".to_string(),
1920        }));
1921
1922        // Denied: domain already at the cumulative cap → rolled back, no process registered.
1923        assert!(matches!(
1924            step.actions.as_slice(),
1925            [KernelAction::CallProvider { .. }]
1926        ));
1927        assert!(runtime.state_machine().agent_process("worker").is_none());
1928        assert_eq!(runtime.local_subagents_spawned(), 0);
1929    }
1930
1931    #[test]
1932    fn default_runtime_leaves_spawn_unquota_ed() {
1933        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1934
1935        // No SetResourceQuota event => pre-M2 behavior: spawn is unconditionally admitted.
1936        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1937        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1938            task: RuntimeTask::new("parent task"),
1939            run_spec: None,
1940        }));
1941        runtime.state_machine_mut().take_observations();
1942
1943        let spec = AgentRunSpec::new(
1944            AgentIdentity::sub_agent("worker", "worker-session"),
1945            AgentRole::Implement,
1946            "do work",
1947        );
1948        runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1949            spec,
1950            parent_session_id: "parent-session".to_string(),
1951        }));
1952        assert!(runtime.state_machine().agent_process("worker").is_some());
1953        assert!(runtime.state_machine().is_suspended());
1954    }
1955
1956    /// Wire-format lock for `agent_process_changed` multi-word enum values. The kernel stringifies
1957    /// `isolation`/`context_inheritance` as debug-lowercase (`readonly`/`systemonly`), which is NOT
1958    /// the same as serde snake_case (`read_only`/`system_only`) — and no golden fixture covers these
1959    /// variants. This pins the current bytes so the observation refactor cannot silently change them.
1960    #[test]
1961    fn agent_process_changed_locks_multiword_wire_form() {
1962        use crate::types::agent::{AgentIdentity, AgentIsolation, AgentRole, AgentRunSpec};
1963
1964        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1965        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1966            task: RuntimeTask::new("parent task"),
1967            run_spec: None,
1968        }));
1969        runtime.state_machine_mut().take_observations();
1970
1971        // Verify role → SystemOnly inheritance; explicit ReadOnly isolation. Both are multi-word.
1972        let spec = AgentRunSpec::new(
1973            AgentIdentity::sub_agent("worker", "worker-session"),
1974            AgentRole::Verify,
1975            "do work",
1976        )
1977        .with_isolation(AgentIsolation::ReadOnly);
1978        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1979            spec,
1980            parent_session_id: "parent-session".to_string(),
1981        }));
1982
1983        let obs = step
1984            .observations
1985            .iter()
1986            .find(|o| matches!(o, KernelObservation::AgentProcessChanged { .. }))
1987            .expect("agent_process_changed observation");
1988        let json = serde_json::to_value(obs).unwrap();
1989        assert_eq!(json["isolation"], "readonly", "isolation must stay debug-lowercase");
1990        assert_eq!(
1991            json["context_inheritance"], "systemonly",
1992            "context_inheritance must stay debug-lowercase"
1993        );
1994        assert_eq!(json["role"], "verify");
1995        assert_eq!(json["state"], "running");
1996    }
1997
1998    // ── M-memory-policy: set_memory_policy is enforced at the WriteMemory / QueryMemory traps ──
1999
2000    fn write_memory(runtime: &mut KernelRuntime, name: &str, content: &str) -> KernelStep {
2001        use crate::mm::memory::{MemoryMetadata, MemoryWriteRequest};
2002        runtime.step(KernelInput::new(KernelInputEvent::WriteMemory {
2003            memory: MemoryWriteRequest {
2004                metadata: MemoryMetadata {
2005                    name: name.to_string(),
2006                    description: "desc".to_string(),
2007                    ..Default::default()
2008                },
2009                content: content.to_string(),
2010            },
2011        }))
2012    }
2013
2014    #[test]
2015    fn memory_policy_validation_disabled_admits_forbidden_write() {
2016        // "代码模式:" is a forbidden pattern under default validation; disabling validation admits it.
2017        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2018        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
2019            memory_path: String::new(),
2020            stale_warning_days: 2,
2021            retrieval_top_k: 5,
2022            validation_enabled: false,
2023            max_content_bytes: None,
2024            max_name_length: None,
2025        }));
2026        let step = write_memory(&mut runtime, "note", "代码模式: foo");
2027        assert!(step
2028            .observations
2029            .iter()
2030            .any(|o| matches!(o, KernelObservation::MemoryWritten { .. })));
2031        assert!(!step
2032            .observations
2033            .iter()
2034            .any(|o| matches!(o, KernelObservation::MemoryValidationFailed { .. })));
2035    }
2036
2037    #[test]
2038    fn default_runtime_accepts_content_hosts_have_not_forbidden() {
2039        // P13: no baked-in forbidden patterns — with no policy installed, a structurally
2040        // valid write passes; content judgment belongs to hosts/models, not the kernel.
2041        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2042        let step = write_memory(&mut runtime, "note", "代码模式: foo");
2043        assert!(step
2044            .observations
2045            .iter()
2046            .any(|o| matches!(o, KernelObservation::MemoryWritten { .. })));
2047    }
2048
2049    #[test]
2050    fn memory_policy_size_override_rejects_oversized_write() {
2051        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2052        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
2053            memory_path: String::new(),
2054            stale_warning_days: 2,
2055            retrieval_top_k: 5,
2056            validation_enabled: true,
2057            max_content_bytes: Some(8),
2058            max_name_length: None,
2059        }));
2060        let step = write_memory(&mut runtime, "note", "this content is well over eight bytes");
2061        let failed = step.observations.iter().find_map(|o| match o {
2062            KernelObservation::MemoryValidationFailed { error, .. } => Some(error.clone()),
2063            _ => None,
2064        });
2065        assert!(failed.is_some_and(|e| e.contains("too large")));
2066    }
2067
2068    #[test]
2069    fn memory_policy_clamps_retrieval_top_k() {
2070        use crate::mm::memory::MemoryQuery;
2071        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2072        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
2073            memory_path: String::new(),
2074            stale_warning_days: 2,
2075            retrieval_top_k: 3,
2076            validation_enabled: true,
2077            max_content_bytes: None,
2078            max_name_length: None,
2079        }));
2080        let step = runtime.step(KernelInput::new(KernelInputEvent::QueryMemory {
2081            query: MemoryQuery { top_k: 50, ..Default::default() },
2082        }));
2083        let requested = step.observations.iter().find_map(|o| match o {
2084            KernelObservation::MemoryQueried { requested_k, .. } => Some(*requested_k),
2085            _ => None,
2086        });
2087        assert_eq!(requested, Some(3));
2088    }
2089
2090    #[test]
2091    fn default_runtime_uses_requested_top_k_verbatim() {
2092        use crate::mm::memory::MemoryQuery;
2093        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2094        let step = runtime.step(KernelInput::new(KernelInputEvent::QueryMemory {
2095            query: MemoryQuery { top_k: 50, ..Default::default() },
2096        }));
2097        let requested = step.observations.iter().find_map(|o| match o {
2098            KernelObservation::MemoryQueried { requested_k, .. } => Some(*requested_k),
2099            _ => None,
2100        });
2101        assert_eq!(requested, Some(50));
2102    }
2103
2104    #[test]
2105    fn provider_result_now_ms_drives_wall_time_budget() {
2106        let mut runtime = KernelRuntime::new(SchedulerBudget {
2107            max_wall_ms: Some(10),
2108            ..SchedulerBudget::default()
2109        });
2110        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2111            task: RuntimeTask::new("ship it"),
2112            run_spec: None,
2113        }));
2114        let mut msg = Message::assistant("");
2115        msg.tool_calls.push(ToolCall {
2116            id: "call-1".into(),
2117            name: "echo".into(),
2118            arguments: serde_json::json!({}),
2119        });
2120        runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2121            message: msg,
2122            observed_input_tokens: None,
2123            observed_output_tokens: None,
2124            stop_reason: None,
2125            now_ms: Some(100),
2126        }));
2127        let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
2128            results: vec![ToolResult {
2129                call_id: "call-1".into(),
2130                output: crate::types::message::Content::Text("ok".into()),
2131                is_error: false,
2132                is_fatal: false,
2133                error_kind: None,
2134                token_count: None,
2135            }],
2136        }));
2137
2138        assert!(matches!(
2139            step.actions.as_slice(),
2140            [KernelAction::CallProvider { tools, .. }] if tools.is_empty()
2141        ));
2142    }
2143
2144    // ─── Governance gate ───────────────────────────────────────────────────
2145
2146    fn assistant_calling(tool: &str) -> Message {
2147        let mut msg = Message::assistant("");
2148        msg.tool_calls.push(ToolCall {
2149            id: "call-1".into(),
2150            name: tool.into(),
2151            arguments: serde_json::json!({}),
2152        });
2153        msg
2154    }
2155
2156    /// Feed a tool-calling response and return the resulting step.
2157    fn run_with_tool_call(runtime: &mut KernelRuntime, tool: &str) -> KernelStep {
2158        run_with_tool_call_named(runtime, tool, "call-1")
2159    }
2160
2161    fn run_with_tool_call_named(
2162        runtime: &mut KernelRuntime,
2163        tool: &str,
2164        _call_id: &str,
2165    ) -> KernelStep {
2166        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2167            task: RuntimeTask::new("do the thing"),
2168            run_spec: None,
2169        }));
2170        runtime.state_machine_mut().take_observations();
2171        runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2172            message: assistant_calling(tool),
2173            observed_input_tokens: None,
2174            observed_output_tokens: None,
2175            stop_reason: None,
2176            now_ms: None,
2177        }))
2178    }
2179
2180    #[test]
2181    fn governance_deny_blocks_tool_and_reprompts() {
2182        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2183        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2184            default_action: Some(PolicyAction::Allow),
2185            rules: vec![PolicyRule {
2186                tool_pattern: "danger.*".to_string(),
2187                action: PolicyAction::Deny,
2188            }],
2189            vetoed_tools: vec![],
2190            rate_limits: vec![],
2191            constraints: vec![],
2192        }));
2193
2194        let step = run_with_tool_call(&mut runtime, "danger.delete");
2195
2196        // Denied call must NOT reach ExecuteTool; the turn rolls back and re-prompts.
2197        assert!(
2198            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2199            "denied tool should roll back and re-call provider, got {:?}",
2200            step.actions
2201        );
2202        assert!(
2203            step.observations
2204                .iter()
2205                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2206            "expected a Rollbacked observation for the denied turn",
2207        );
2208    }
2209
2210    #[test]
2211    fn configure_run_bundle_applies_governance_equivalently_to_load_governance_policy() {
2212        // K2: the consolidated `ConfigureRun` bundle must apply governance identically to the granular
2213        // `LoadGovernancePolicy` event — a deny rule blocks the matching tool and re-prompts.
2214        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2215        runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
2216            config: RunConfig {
2217                tools: Some(vec![]),
2218                governance: Some(GovernanceConfig {
2219                    default_action: Some(PolicyAction::Allow),
2220                    rules: vec![PolicyRule {
2221                        tool_pattern: "danger.*".to_string(),
2222                        action: PolicyAction::Deny,
2223                    }],
2224                    ..GovernanceConfig::default()
2225                }),
2226                attention_max_queue_size: Some(32),
2227                ..RunConfig::default()
2228            },
2229        }));
2230
2231        let step = run_with_tool_call(&mut runtime, "danger.delete");
2232
2233        assert!(
2234            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2235            "bundle-configured deny should roll back and re-call provider, got {:?}",
2236            step.actions
2237        );
2238        assert!(
2239            step.observations
2240                .iter()
2241                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2242            "expected a Rollbacked observation for the bundle-denied turn",
2243        );
2244    }
2245
2246    #[test]
2247    fn configure_run_entropy_watch_flows_through_the_abi() {
2248        // The `entropy_watch` bundle field arms the watch; a completed tool round then
2249        // surfaces BOTH the unconditional sample and (threshold 0 + all-error results) the alert.
2250        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2251        runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
2252            config: RunConfig {
2253                entropy_watch: Some(crate::scheduler::entropy::EntropyWatchConfig {
2254                    enabled: true,
2255                    threshold: 0.1,
2256                    hysteresis: 0.05,
2257                    cooldown_turns: 0,
2258                    notify_model: false,
2259                }),
2260                ..RunConfig::default()
2261            },
2262        }));
2263        run_with_tool_call(&mut runtime, "step");
2264        let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
2265            results: vec![ToolResult {
2266                call_id: "call-1".into(),
2267                output: crate::types::message::Content::Text("boom".into()),
2268                is_error: true,
2269                is_fatal: false,
2270                error_kind: None,
2271                token_count: None,
2272            }],
2273        }));
2274        assert!(
2275            step.observations
2276                .iter()
2277                .any(|o| matches!(o, KernelObservation::EntropySample { failure_rate, .. } if *failure_rate > 0.9)),
2278            "completed boundary must carry the entropy sample: {:?}",
2279            step.observations
2280        );
2281        assert!(
2282            step.observations
2283                .iter()
2284                .any(|o| matches!(o, KernelObservation::EntropyAlert { threshold, .. } if (*threshold - 0.1).abs() < 1e-9)),
2285            "armed watch + errored turn must alert: {:?}",
2286            step.observations
2287        );
2288    }
2289
2290    #[test]
2291    fn set_entropy_watch_event_parses_from_json_and_applies_partially() {
2292        // Additive ABI: the granular event round-trips from JSON with absent fields
2293        // keeping current values (mirrors SetRepeatFuse).
2294        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2295        let input: KernelInput = serde_json::from_str(
2296            r#"{"version":1,"event":{"kind":"set_entropy_watch","enabled":true,"threshold":0.4}}"#,
2297        )
2298        .expect("granular event must deserialize");
2299        runtime.step(input);
2300        let cfg = runtime.state_machine().entropy_watch_config();
2301        assert!(cfg.enabled);
2302        assert!((cfg.threshold - 0.4).abs() < 1e-9);
2303        assert!((cfg.hysteresis - 0.1).abs() < 1e-9, "absent field keeps the default");
2304        assert_eq!(cfg.cooldown_turns, 4, "absent field keeps the default");
2305    }
2306
2307    #[test]
2308    fn configure_run_round_trips_over_the_abi() {
2309        // The bundle must survive the versioned JSON ABI (replayable / session-loggable) like every
2310        // other host event.
2311        let event = KernelInputEvent::ConfigureRun {
2312            config: RunConfig {
2313                resource_quota: Some(crate::governance::quota::ResourceQuota {
2314                    max_concurrent_subagents: Some(2),
2315                    ..Default::default()
2316                }),
2317                scheduler_max_wall_ms: Some(60_000),
2318                plan_tool_enabled: Some(true),
2319                ..RunConfig::default()
2320            },
2321        };
2322        let json = serde_json::to_string(&event).expect("serialize");
2323        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2324        assert!(matches!(parsed, KernelInputEvent::ConfigureRun { .. }));
2325    }
2326
2327    #[test]
2328    fn governance_ask_user_suspends_until_resume() {
2329        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2330        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2331            default_action: Some(PolicyAction::Allow),
2332            rules: vec![PolicyRule {
2333                tool_pattern: "sensitive.*".to_string(),
2334                action: PolicyAction::AskUser,
2335            }],
2336            vetoed_tools: vec![],
2337            rate_limits: vec![],
2338            constraints: vec![],
2339        }));
2340
2341        let step = run_with_tool_call(&mut runtime, "sensitive.read");
2342
2343        assert!(
2344            step.actions.is_empty(),
2345            "AskUser should suspend without ExecuteTool, got {:?}",
2346            step.actions
2347        );
2348        assert!(
2349            step.observations.iter().any(|o| matches!(
2350                o,
2351                KernelObservation::ToolGated { tool, .. } if tool == "sensitive.read"
2352            )),
2353            "expected a ToolGated observation for the AskUser call",
2354        );
2355        assert!(
2356            step.observations.iter().any(|o| matches!(
2357                o,
2358                KernelObservation::Suspended { reason, .. } if reason == "ask_user"
2359            )),
2360            "expected a Suspended observation",
2361        );
2362
2363        let resumed = runtime.step(KernelInput::new(KernelInputEvent::Resume {
2364            approved_calls: vec!["call-1".to_string()],
2365            denied_calls: vec![],
2366        }));
2367        assert!(
2368            matches!(resumed.actions.as_slice(), [KernelAction::ExecuteTool { .. }]),
2369            "resume with approval should emit ExecuteTool, got {:?}",
2370            resumed.actions
2371        );
2372        assert!(
2373            resumed.observations.iter().any(|o| matches!(
2374                o,
2375                KernelObservation::Resumed { approved, denied, .. }
2376                if approved == &["call-1"] && denied.is_empty()
2377            )),
2378        );
2379    }
2380
2381    #[test]
2382    fn governance_ask_user_resume_all_denied_feeds_tool_results() {
2383        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2384        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2385            default_action: Some(PolicyAction::Allow),
2386            rules: vec![PolicyRule {
2387                tool_pattern: "sensitive.*".to_string(),
2388                action: PolicyAction::AskUser,
2389            }],
2390            vetoed_tools: vec![],
2391            rate_limits: vec![],
2392            constraints: vec![],
2393        }));
2394        run_with_tool_call(&mut runtime, "sensitive.read");
2395        runtime.state_machine_mut().take_observations();
2396
2397        let step = runtime.step(KernelInput::new(KernelInputEvent::Resume {
2398            approved_calls: vec![],
2399            denied_calls: vec!["call-1".to_string()],
2400        }));
2401        assert!(
2402            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2403            "all denied should re-prompt provider, got {:?}",
2404            step.actions
2405        );
2406    }
2407
2408    #[test]
2409    fn no_governance_policy_executes_all_tools() {
2410        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2411        let step = run_with_tool_call(&mut runtime, "danger.delete");
2412
2413        // Without a policy the gate is a no-op — behavior is unchanged.
2414        assert!(matches!(
2415            step.actions.as_slice(),
2416            [KernelAction::ExecuteTool { .. }]
2417        ));
2418        assert!(
2419            !step
2420                .observations
2421                .iter()
2422                .any(|o| matches!(o, KernelObservation::ToolGated { .. })),
2423        );
2424    }
2425
2426    fn tool_ok(call_id: &str) -> ToolResult {
2427        ToolResult {
2428            call_id: call_id.into(),
2429            output: crate::types::message::Content::Text("ok".to_string()),
2430            is_error: false,
2431            is_fatal: false,
2432            error_kind: None,
2433            token_count: None,
2434        }
2435    }
2436
2437    #[test]
2438    fn governance_rate_limit_blocks_second_call() {
2439        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2440        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2441            default_action: Some(PolicyAction::Allow),
2442            rules: vec![],
2443            vetoed_tools: vec![],
2444            rate_limits: vec![RateLimitSpec {
2445                tool: "fetch".to_string(),
2446                max_calls: 1,
2447                window_ms: 60_000,
2448            }],
2449            constraints: vec![],
2450        }));
2451        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2452            task: RuntimeTask::new("fetch twice"),
2453            run_spec: None,
2454        }));
2455        runtime.state_machine_mut().take_observations();
2456
2457        // First call within the window — allowed.
2458        let s1 = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2459            message: assistant_calling("fetch"),
2460            observed_input_tokens: None,
2461            observed_output_tokens: None,
2462            stop_reason: None,
2463            now_ms: Some(1_000),
2464        }));
2465        assert!(
2466            matches!(s1.actions.as_slice(), [KernelAction::ExecuteTool { .. }]),
2467            "first call should execute, got {:?}",
2468            s1.actions
2469        );
2470
2471        // Close the turn so the kernel re-prompts the provider.
2472        runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
2473            results: vec![tool_ok("call-1")],
2474        }));
2475        runtime.state_machine_mut().take_observations();
2476
2477        // Second call to the same tool within the window — rate limited → rollback.
2478        let s2 = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2479            message: assistant_calling("fetch"),
2480            observed_input_tokens: None,
2481            observed_output_tokens: None,
2482            stop_reason: None,
2483            now_ms: Some(1_001),
2484        }));
2485        assert!(
2486            matches!(s2.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2487            "rate-limited call should roll back and re-call provider, got {:?}",
2488            s2.actions
2489        );
2490        assert!(
2491            s2.observations
2492                .iter()
2493                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2494            "expected a Rollbacked observation for the rate-limited turn",
2495        );
2496    }
2497
2498    #[test]
2499    fn governance_constraint_required_param_denies() {
2500        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2501        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2502            default_action: Some(PolicyAction::Allow),
2503            rules: vec![],
2504            vetoed_tools: vec![],
2505            rate_limits: vec![],
2506            constraints: vec![ConstraintSpec::Required {
2507                tool: "write".to_string(),
2508                path: "path".to_string(),
2509            }],
2510        }));
2511
2512        // assistant_calling emits empty args `{}` → required "path" is missing → deny.
2513        let step = run_with_tool_call(&mut runtime, "write");
2514        assert!(
2515            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2516            "missing required param should roll back, got {:?}",
2517            step.actions
2518        );
2519        assert!(
2520            step.observations
2521                .iter()
2522                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2523            "expected a Rollbacked observation for the constraint violation",
2524        );
2525    }
2526
2527    // ─── In-kernel signal routing (attention policy) ────────────────────────
2528
2529    fn signal(urgency: crate::types::signal::Urgency, summary: &str) -> crate::types::signal::RuntimeSignal {
2530        use crate::types::signal::{RuntimeSignal, SignalSource, SignalType};
2531        RuntimeSignal::new(SignalSource::Gateway, SignalType::Alert, urgency, summary)
2532    }
2533
2534    fn started_runtime_with_attention(max_queue: u32) -> KernelRuntime {
2535        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2536        runtime.step(KernelInput::new(KernelInputEvent::SetAttentionPolicy {
2537            max_queue_size: max_queue,
2538        }));
2539        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2540            task: RuntimeTask::new("watch for signals"),
2541            run_spec: None,
2542        }));
2543        runtime.state_machine_mut().take_observations();
2544        runtime
2545    }
2546
2547    #[test]
2548    fn attention_policy_critical_signal_interrupts() {
2549        use crate::types::signal::Urgency;
2550        let mut runtime = started_runtime_with_attention(8);
2551        let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
2552            signal: signal(Urgency::Critical, "fire"),
2553        }));
2554        assert!(
2555            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2556            "critical signal should drive a provider call, got {:?}",
2557            step.actions
2558        );
2559        assert!(step.observations.iter().any(|o| matches!(
2560            o,
2561            KernelObservation::SignalDisposed { disposition, .. } if disposition == "interrupt_now"
2562        )));
2563    }
2564
2565    #[test]
2566    fn attention_policy_normal_signal_queues_without_action() {
2567        use crate::types::signal::Urgency;
2568        let mut runtime = started_runtime_with_attention(8);
2569        let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
2570            signal: signal(Urgency::Normal, "job"),
2571        }));
2572        assert!(
2573            step.actions.is_empty(),
2574            "normal signal should queue without a provider call, got {:?}",
2575            step.actions
2576        );
2577        assert!(step.observations.iter().any(|o| matches!(
2578            o,
2579            KernelObservation::SignalDisposed { disposition, queue_depth, .. }
2580            if disposition == "queue" && *queue_depth == 1
2581        )));
2582    }
2583
2584    #[test]
2585    fn attention_policy_full_queue_drops() {
2586        use crate::types::signal::Urgency;
2587        let mut runtime = started_runtime_with_attention(1);
2588        runtime.step(KernelInput::new(KernelInputEvent::Signal {
2589            signal: signal(Urgency::Normal, "first"),
2590        }));
2591        let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
2592            signal: signal(Urgency::Normal, "second"),
2593        }));
2594        assert!(step.observations.iter().any(|o| matches!(
2595            o,
2596            KernelObservation::SignalDisposed { disposition, .. } if disposition == "dropped"
2597        )));
2598    }
2599
2600    #[test]
2601    fn page_in_populates_knowledge_partition() {
2602        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2603        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryEnabled {
2604            enabled: true,
2605        }));
2606        let before = runtime.state_machine().ctx.partitions.knowledge.len();
2607        runtime.step(KernelInput::new(KernelInputEvent::PageIn {
2608            entries: vec![crate::mm::PageInEntry {
2609                content: "[memory] prior fix".to_string(),
2610                tokens: Some(10),
2611                source: Some("memory".to_string()),
2612                key: None,
2613                pinned: false,
2614            }],
2615        }));
2616        let after = runtime.state_machine().ctx.partitions.knowledge.len();
2617        assert!(after > before, "page-in should add knowledge messages");
2618    }
2619
2620    #[test]
2621    fn memory_tool_does_not_emit_page_in_requested() {
2622        // The automatic PageInRequested producer for live memory/knowledge tool calls was retired:
2623        // a memory-tool result now flows to `history` via the normal tool-result path only, so it
2624        // decays with the compression pyramid instead of living forever in `knowledge`.
2625        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2626        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryEnabled {
2627            enabled: true,
2628        }));
2629        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2630            task: RuntimeTask::new("test"),
2631            run_spec: None,
2632        }));
2633        runtime.state_machine_mut().take_observations();
2634
2635        let _step = run_with_tool_call(&mut runtime, "memory");
2636        // (the PageInRequested observation itself was deleted with its retired producer)
2637    }
2638
2639    #[test]
2640    fn load_workflow_input_drives_dag_to_completion() {
2641        use crate::orchestration::workflow::fanout_synthesize;
2642        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2643
2644        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2645        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2646            task: RuntimeTask::new("parent task"),
2647            run_spec: None,
2648        }));
2649        runtime.state_machine_mut().take_observations();
2650
2651        // Exercise the full serde round-trip of LoadWorkflow + WorkflowSpec over the ABI.
2652        let spec =
2653            fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2654        let event = KernelInputEvent::LoadWorkflow {
2655            spec,
2656            parent_session_id: "sess".to_string(),
2657            resumed_completed: Vec::new(),
2658            resumed_submissions: Vec::new(),
2659            resumed_submission_bases: Vec::new(),
2660            resumed_results: Vec::new(),
2661        };
2662        let json = serde_json::to_string(&event).expect("serialize");
2663        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2664
2665        let step = runtime.step(KernelInput::new(parsed));
2666        // First batch carries both workers' goals so the SDK can run them.
2667        let batch = step
2668            .observations
2669            .iter()
2670            .find_map(|o| match o {
2671                KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2672                _ => None,
2673            })
2674            .expect("workflow_batch_spawned");
2675        assert_eq!(batch.len(), 2);
2676        let goals: Vec<&str> = batch.iter().map(|n| n.goal.as_str()).collect();
2677        assert!(goals.contains(&"w0") && goals.contains(&"w1"));
2678        assert_eq!(batch[0].agent_id, "wf-node0");
2679        assert_eq!(batch[0].isolation, "read_only"); // fanout workers are Explore → read_only
2680
2681        let complete = |runtime: &mut KernelRuntime, id: &str| {
2682            runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2683                result: SubAgentResult {
2684                    agent_id: compact_str::CompactString::new(id),
2685                    result: LoopResult {
2686                        termination: TerminationReason::Completed,
2687                        final_message: None,
2688                        turns_used: 1,
2689                        total_tokens_used: 1,
2690                        loop_continue: None,
2691                        classify_branch: None,
2692                        tournament_winner: None,
2693                        pace_decision: None,
2694                    },
2695                },
2696            }))
2697        };
2698
2699        complete(&mut runtime, "wf-node0");
2700        // After both workers, synth becomes the next batch.
2701        let step = complete(&mut runtime, "wf-node1");
2702        assert!(step.observations.iter().any(|o| matches!(
2703            o,
2704            KernelObservation::WorkflowBatchSpawned { nodes, .. }
2705                if nodes.len() == 1 && nodes[0].agent_id == "wf-node2"
2706        )));
2707
2708        // Synth completes → workflow finishes.
2709        let step = complete(&mut runtime, "wf-node2");
2710        assert!(step.observations.iter().any(|o| matches!(
2711            o,
2712            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 3
2713        )));
2714    }
2715
2716    #[test]
2717    fn load_workflow_self_bootstraps_with_no_prior_start_run() {
2718        // K1: a stateless `runWorkflow` caller fires `LoadWorkflow` with NO preceding `StartRun`. The
2719        // host path now self-bootstraps the run (parity with the agent-reachable `SubmitWorkflow`), so
2720        // the DAG installs and drives to completion exactly as the started-path test above.
2721        use crate::orchestration::workflow::fanout_synthesize;
2722        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2723
2724        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2725        // NOTE: deliberately no StartRun here — that is the whole point of K1.
2726
2727        let spec =
2728            fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2729        let step = runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2730            spec,
2731            parent_session_id: "sess".to_string(),
2732            resumed_completed: Vec::new(),
2733            resumed_submissions: Vec::new(),
2734            resumed_submission_bases: Vec::new(),
2735            resumed_results: Vec::new(),
2736        }));
2737        let batch = step
2738            .observations
2739            .iter()
2740            .find_map(|o| match o {
2741                KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2742                _ => None,
2743            })
2744            .expect("workflow_batch_spawned even without a prior StartRun");
2745        assert_eq!(batch.len(), 2);
2746
2747        let complete = |runtime: &mut KernelRuntime, id: &str| {
2748            runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2749                result: SubAgentResult {
2750                    agent_id: compact_str::CompactString::new(id),
2751                    result: LoopResult {
2752                        termination: TerminationReason::Completed,
2753                        final_message: None,
2754                        turns_used: 1,
2755                        total_tokens_used: 1,
2756                        loop_continue: None,
2757                        classify_branch: None,
2758                        tournament_winner: None,
2759                        pace_decision: None,
2760                    },
2761                },
2762            }))
2763        };
2764        complete(&mut runtime, "wf-node0");
2765        complete(&mut runtime, "wf-node1");
2766        let step = complete(&mut runtime, "wf-node2");
2767        assert!(step.observations.iter().any(|o| matches!(
2768            o,
2769            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 3
2770        )));
2771    }
2772
2773    #[test]
2774    fn submit_workflow_nodes_input_appends_a_node_over_the_abi() {
2775        // R3-1: exercise the full serde round-trip of SubmitWorkflowNodes + WorkflowNode over the
2776        // ABI, and confirm the appended node spawns as a workflow batch mid-run.
2777        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2778        use crate::types::agent::AgentRole;
2779        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2780
2781        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2782        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2783            task: RuntimeTask::new("parent task"),
2784            run_spec: None,
2785        }));
2786        runtime.state_machine_mut().take_observations();
2787
2788        // A single-node workflow: wf-node0 spawns first.
2789        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
2790            RuntimeTask::new("root"),
2791            AgentRole::Implement,
2792        )]);
2793        runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2794            spec,
2795            parent_session_id: "sess".to_string(),
2796            resumed_completed: Vec::new(),
2797            resumed_submissions: Vec::new(),
2798            resumed_submission_bases: Vec::new(),
2799            resumed_results: Vec::new(),
2800        }));
2801        runtime.state_machine_mut().take_observations();
2802
2803        // Submit a node over the ABI while wf-node0 runs (full serde round-trip).
2804        let event = KernelInputEvent::SubmitWorkflowNodes {
2805            nodes: vec![WorkflowNode::new(RuntimeTask::new("more"), AgentRole::Implement)],
2806            submitter_agent_id: None,
2807        };
2808        let json = serde_json::to_string(&event).expect("serialize");
2809        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2810        let step = runtime.step(KernelInput::new(parsed));
2811        // The appended node spawns as wf-node1 in a workflow batch.
2812        assert!(step.observations.iter().any(|o| matches!(
2813            o,
2814            KernelObservation::WorkflowBatchSpawned { nodes, .. }
2815                if nodes.len() == 1 && nodes[0].agent_id == "wf-node1" && nodes[0].goal == "more"
2816        )));
2817
2818        let complete = |runtime: &mut KernelRuntime, id: &str| {
2819            runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2820                result: SubAgentResult {
2821                    agent_id: compact_str::CompactString::new(id),
2822                    result: LoopResult {
2823                        termination: TerminationReason::Completed,
2824                        final_message: None,
2825                        turns_used: 1,
2826                        total_tokens_used: 1,
2827                        loop_continue: None,
2828                        classify_branch: None,
2829                        tournament_winner: None,
2830                        pace_decision: None,
2831                    },
2832                },
2833            }))
2834        };
2835        complete(&mut runtime, "wf-node0");
2836        // The workflow finishes only after the submitted node also completes (2 nodes total).
2837        let step = complete(&mut runtime, "wf-node1");
2838        assert!(step.observations.iter().any(|o| matches!(
2839            o,
2840            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 2
2841        )));
2842    }
2843
2844    #[test]
2845    fn submit_workflow_input_bootstraps_a_dag_over_the_abi() {
2846        // M5/G1: a top-level agent authors a whole spec over the ABI (full serde round-trip of
2847        // SubmitWorkflow + WorkflowSpec) with no workflow active → the kernel bootstraps and drives it.
2848        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2849        use crate::types::agent::AgentRole;
2850        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2851
2852        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2853        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2854            task: RuntimeTask::new("parent task"),
2855            run_spec: None,
2856        }));
2857        runtime.state_machine_mut().take_observations();
2858
2859        // No LoadWorkflow first — the agent itself authors the spec.
2860        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
2861            RuntimeTask::new("authored root"),
2862            AgentRole::Implement,
2863        )]);
2864        let event = KernelInputEvent::SubmitWorkflow {
2865            spec,
2866            parent_session_id: "sess".to_string(),
2867            submitter_agent_id: None,
2868        };
2869        let json = serde_json::to_string(&event).expect("serialize");
2870        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2871        let step = runtime.step(KernelInput::new(parsed));
2872        // The authored node bootstraps as wf-node0 in a workflow batch.
2873        assert!(step.observations.iter().any(|o| matches!(
2874            o,
2875            KernelObservation::WorkflowBatchSpawned { nodes, .. }
2876                if nodes.len() == 1 && nodes[0].agent_id == "wf-node0" && nodes[0].goal == "authored root"
2877        )));
2878
2879        let step = runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2880            result: SubAgentResult {
2881                agent_id: compact_str::CompactString::new("wf-node0"),
2882                result: LoopResult {
2883                    termination: TerminationReason::Completed,
2884                    final_message: None,
2885                    turns_used: 1,
2886                    total_tokens_used: 1,
2887                    loop_continue: None,
2888                    classify_branch: None,
2889                    tournament_winner: None,
2890                    pace_decision: None,
2891                },
2892            },
2893        }));
2894        assert!(step.observations.iter().any(|o| matches!(
2895            o,
2896            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 1
2897        )));
2898    }
2899
2900    #[test]
2901    fn load_workflow_resumes_from_completed_nodes() {
2902        use crate::orchestration::workflow::fanout_synthesize;
2903
2904        let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2905        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2906            task: RuntimeTask::new("parent task"),
2907            run_spec: None,
2908        }));
2909        runtime.state_machine_mut().take_observations();
2910
2911        // Resume a 2-worker fanout where worker 0 already completed before the interruption.
2912        let spec =
2913            fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2914        let step = runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2915            spec,
2916            parent_session_id: "sess".to_string(),
2917            resumed_completed: vec!["wf-node0".to_string()],
2918            resumed_submissions: Vec::new(),
2919            resumed_submission_bases: Vec::new(),
2920            resumed_results: Vec::new(),
2921        }));
2922
2923        // Only the remaining worker is re-spawned (node 0 is not re-run).
2924        let batch = step
2925            .observations
2926            .iter()
2927            .find_map(|o| match o {
2928                KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2929                _ => None,
2930            })
2931            .expect("workflow_batch_spawned");
2932        assert_eq!(batch.len(), 1);
2933        assert_eq!(batch[0].agent_id, "wf-node1");
2934    }
2935}