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