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