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#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(tag = "kind", rename_all = "snake_case")]
111pub enum KernelInputEvent {
112    SetTools {
113        tools: Vec<ToolSchema>,
114    },
115    SetAvailableSkills {
116        skills: Vec<SkillMetadata>,
117    },
118    /// P1-B tool gating: the model loaded a skill (`name`). The SDK emits this when it resolves a
119    /// `skill` tool call. The kernel records it in the active-skill set and resolves the skill's
120    /// `allowed_tools` from the catalog to narrow the toolset on subsequent turns.
121    SkillActivated {
122        name: String,
123    },
124    /// P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Set once by
125    /// the SDK; empty/absent ⇒ skills narrow to exactly their declared tools + meta-tools.
126    SetStableCoreTools {
127        tool_ids: Vec<String>,
128    },
129    SetMemoryEnabled {
130        enabled: bool,
131    },
132    SetKnowledgeEnabled {
133        enabled: bool,
134    },
135    SetPlanToolEnabled {
136        enabled: bool,
137    },
138    SetTokenizer {
139        name: String,
140    },
141    AddSystemMessage {
142        content: String,
143        tokens: u32,
144    },
145    AddKnowledgeMessage {
146        content: String,
147        tokens: u32,
148    },
149    AddHistoryMessage {
150        message: Message,
151        tokens: Option<u32>,
152    },
153    PreloadHistory {
154        messages: Vec<Message>,
155    },
156    MountCapability {
157        capability: CapabilityDescriptor,
158    },
159    UnmountCapability {
160        capability_kind: CapabilityKind,
161        id: String,
162    },
163    LoadMilestoneContract {
164        contract: MilestoneContract,
165    },
166    /// Install a governance policy. Once loaded, every model-proposed tool call
167    /// is evaluated in-kernel before execution. Omitting this event leaves the
168    /// gate disabled (pre-governance behavior).
169    LoadGovernancePolicy {
170        #[serde(default)]
171        default_action: Option<PolicyAction>,
172        #[serde(default, skip_serializing_if = "Vec::is_empty")]
173        rules: Vec<PolicyRule>,
174        #[serde(default, skip_serializing_if = "Vec::is_empty")]
175        vetoed_tools: Vec<String>,
176        // COMPAT(gov-abi-additive): rate_limits/constraints are additive fields with
177        // serde(default) so older SDKs that omit them still deserialize. Safe to keep.
178        #[serde(default, skip_serializing_if = "Vec::is_empty")]
179        rate_limits: Vec<RateLimitSpec>,
180        #[serde(default, skip_serializing_if = "Vec::is_empty")]
181        constraints: Vec<ConstraintSpec>,
182    },
183    /// Override the default in-kernel signal router queue size (default 64).
184    /// The router is always active; this only adjusts capacity.
185    SetAttentionPolicy {
186        #[serde(default = "default_signal_queue_size")]
187        max_queue_size: u32,
188    },
189    ForceCompact,
190    UpdateTask {
191        update: TaskUpdate,
192    },
193    StartRun {
194        task: RuntimeTask,
195        #[serde(default, skip_serializing_if = "Option::is_none")]
196        run_spec: Option<AgentRunSpec>,
197    },
198    CapabilityCommand {
199        command: CapabilityCommand,
200    },
201    Resume {
202        // COMPAT(sched-resume-generic): old SDKs send `{kind:"resume"}` with no
203        // fields — serde(default) deserialises to empty vecs. Change to required
204        // once all SDKs supply approved/denied explicitly.
205        #[serde(default, skip_serializing_if = "Vec::is_empty")]
206        approved_calls: Vec<String>,
207        #[serde(default, skip_serializing_if = "Vec::is_empty")]
208        denied_calls: Vec<String>,
209    },
210    /// Adjust the wall-clock budget at runtime (e.g. to extend or set a deadline
211    /// after a run has already started). Additive: omit to keep the value from
212    /// `LoopPolicy` passed at construction.
213    SetSchedulerBudget {
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        max_wall_ms: Option<u64>,
216    },
217    /// M2 资源配额: install a declarative [`crate::governance::quota::ResourceQuota`] at the
218    /// single syscall trap. Like governance/attention/scheduler config, quotas flow in through
219    /// the versioned JSON event ABI (replayable, session-loggable) rather than a side-channel
220    /// setter — sending it is opt-in, and omitting it preserves the pre-M2 unconditional `Allow`
221    /// for spawn / memory-write syscalls.
222    SetResourceQuota {
223        quota: crate::governance::quota::ResourceQuota,
224    },
225    ProviderResult {
226        message: Message,
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        observed_input_tokens: Option<u32>,
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        observed_output_tokens: Option<u32>,
231        // COMPAT(gov-clock): now_ms is optional so SDKs that don't drive the in-kernel
232        // governance gate need not supply a clock. When absent, the rate limiter runs
233        // on a 0 clock (effectively unlimited). Can become required once all SDKs feed time.
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        now_ms: Option<u64>,
236    },
237    ToolResults {
238        results: Vec<ToolResult>,
239    },
240    Signal {
241        signal: RuntimeSignal,
242    },
243    MilestoneResult {
244        result: MilestoneCheckResult,
245    },
246    /// Spawn a sub-agent: registers/updates the kernel process table.
247    SpawnSubAgent {
248        spec: AgentRunSpec,
249        parent_session_id: String,
250    },
251    /// W0-ABI: load a workflow DAG and spawn its first gated batch. The kernel drives the DAG;
252    /// each node spawn passes the syscall trap and is reported via `workflow_batch_spawned`.
253    /// Completions feed back through `SubAgentCompleted` (reused); finish emits
254    /// `workflow_completed`.
255    LoadWorkflow {
256        spec: crate::orchestration::workflow::WorkflowSpec,
257        parent_session_id: String,
258        /// W0-ABI resume: node agent-ids already completed (recovered from the log). Empty = fresh.
259        #[serde(default, skip_serializing_if = "Vec::is_empty")]
260        resumed_completed: Vec<String>,
261        /// R3-1 resume: the runtime `submit_workflow_nodes` batches (in order) recovered from the log,
262        /// re-applied before completions so dynamically-appended nodes are reconstructed. Additive:
263        /// empty for a fresh run or a resume without dynamic submissions.
264        #[serde(default, skip_serializing_if = "Vec::is_empty")]
265        resumed_submissions: Vec<Vec<crate::orchestration::workflow::WorkflowNode>>,
266    },
267    /// Feed a completed sub-agent result back into the parent loop.
268    SubAgentCompleted {
269        result: SubAgentResult,
270    },
271    /// R3-1: append nodes to the in-flight workflow DAG at runtime (dynamic fan-out /
272    /// loop-until-done). Sent by the SDK while the submitting node is still running — the appended
273    /// nodes spawn on the next gated drive. No-op if no workflow is active. Additive ABI: a brand-new
274    /// event variant, so existing SDKs that never send it are byte-identical on the wire.
275    SubmitWorkflowNodes {
276        #[serde(default, skip_serializing_if = "Vec::is_empty")]
277        nodes: Vec<crate::orchestration::workflow::WorkflowNode>,
278        /// G1: the agent id of the node that requested this submission. When it names a quarantined
279        /// node, the kernel coerces every submitted node to quarantined (no privilege escalation
280        /// across the trust boundary). Additive: omitted by older SDKs → `None` → no coercion.
281        #[serde(default, skip_serializing_if = "Option::is_none")]
282        submitter_agent_id: Option<String>,
283    },
284    /// M5/G1: an agent authors a whole `WorkflowSpec` (the article's "model writes its own harness").
285    /// The agent-reachable analogue of the host-only `LoadWorkflow`: **bootstraps** the DAG when no
286    /// workflow is active, else **flattens** the spec's nodes onto the running DAG (bootstrap-or-flatten,
287    /// one kernel / one quota — never a workflow stack). Gated by `Syscall::LoadWorkflow`. Additive ABI:
288    /// a brand-new variant, byte-identical on the wire for SDKs that never send it.
289    SubmitWorkflow {
290        spec: crate::orchestration::workflow::WorkflowSpec,
291        /// Used only on bootstrap (no workflow active) to seed child session ids; ignored on flatten.
292        #[serde(default)]
293        parent_session_id: String,
294        /// G1: the authoring node's agent id (flatten case) — a quarantined author's nodes are coerced
295        /// quarantined. Additive: omitted (top-level bootstrap) → `None` → the run's own trust applies.
296        #[serde(default, skip_serializing_if = "Option::is_none")]
297        submitter_agent_id: Option<String>,
298    },
299    /// Feed long-term memory entries into the knowledge partition (page-in).
300    /// SDK performs retrieval I/O; kernel only applies the result.
301    PageIn {
302        #[serde(default, skip_serializing_if = "Vec::is_empty")]
303        entries: Vec<crate::mm::PageInEntry>,
304    },
305    /// Configure long-term memory management policy (Phase 7). Opt-in: installing the policy makes
306    /// `validation_enabled`, `retrieval_top_k`, and the optional size/name overrides authoritative.
307    SetMemoryPolicy {
308        #[serde(default)]
309        memory_path: String,
310        #[serde(default = "default_stale_days")]
311        stale_warning_days: u32,
312        #[serde(default = "default_top_k")]
313        retrieval_top_k: usize,
314        #[serde(default = "default_validation_enabled")]
315        validation_enabled: bool,
316        /// Override the validation content-size limit (bytes). Omit to keep the kernel default.
317        #[serde(default, skip_serializing_if = "Option::is_none")]
318        max_content_bytes: Option<u32>,
319        /// Override the validation name-length limit. Omit to keep the kernel default.
320        #[serde(default, skip_serializing_if = "Option::is_none")]
321        max_name_length: Option<usize>,
322    },
323    /// Write a long-term memory entry (SDK background agent calls this).
324    WriteMemory {
325        memory: crate::mm::memory::MemoryWriteRequest,
326    },
327    /// Query long-term memory for context (kernel calls this; SDK responds asynchronously).
328    QueryMemory {
329        query: crate::mm::memory::MemoryQuery,
330    },
331    /// Feed memory selection results back after `QueryMemory` (SDK → kernel acknowledgment).
332    MemoryRetrievalResult {
333        retrieval: crate::mm::memory::MemoryRetrieval,
334    },
335    Timeout,
336}
337
338fn default_stale_days() -> u32 { 2 }
339fn default_top_k() -> usize { 5 }
340fn default_validation_enabled() -> bool { true }
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct KernelStep {
344    pub version: u32,
345    pub actions: Vec<KernelAction>,
346    pub observations: Vec<KernelObservation>,
347}
348
349impl KernelStep {
350    fn empty(observations: Vec<KernelObservation>) -> Self {
351        Self {
352            version: KERNEL_ABI_VERSION,
353            actions: Vec::new(),
354            observations,
355        }
356    }
357
358    fn single(action: LoopAction, observations: Vec<KernelObservation>) -> Self {
359        Self {
360            version: KERNEL_ABI_VERSION,
361            actions: vec![action.into()],
362            observations,
363        }
364    }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(tag = "kind", rename_all = "snake_case")]
369pub enum KernelAction {
370    CallProvider {
371        context: RenderedContext,
372        tools: Vec<ToolSchema>,
373    },
374    ExecuteTool {
375        calls: Vec<ToolCall>,
376    },
377    EvaluateMilestone {
378        phase_id: String,
379        criteria: Vec<String>,
380        #[serde(default, skip_serializing_if = "Option::is_none")]
381        verifier: Option<crate::types::milestone::MilestoneVerifier>,
382        #[serde(default, skip_serializing_if = "Vec::is_empty")]
383        required_evidence: Vec<String>,
384    },
385    Done {
386        result: LoopResult,
387    },
388}
389
390impl From<LoopAction> for KernelAction {
391    fn from(action: LoopAction) -> Self {
392        match action {
393            LoopAction::AwaitingResume => {
394                panic!("AwaitingResume must not be converted to KernelAction")
395            }
396            LoopAction::CallLLM { context, tools } => Self::CallProvider { context, tools },
397            LoopAction::ExecuteTools { calls } => Self::ExecuteTool { calls },
398            LoopAction::EvaluateMilestone {
399                phase_id,
400                criteria,
401                verifier,
402                required_evidence,
403            } => Self::EvaluateMilestone {
404                phase_id,
405                criteria,
406                verifier,
407                required_evidence,
408            },
409            LoopAction::Done { result } => Self::Done { result },
410        }
411    }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(tag = "kind", rename_all = "snake_case")]
416pub enum KernelObservation {
417    Compressed {
418        action: KernelPressureAction,
419        rho_after: f64,
420        summary: Option<String>,
421        archived: Vec<Message>,
422        /// W1-1 cache-awareness: the message index at which this compression invalidated the
423        /// prompt cache prefix (if any). `None` = prefix-safe. SDK/telemetry can use this to
424        /// quantify "tokens saved vs cache rebuild cost". Additive ABI field with default.
425        #[serde(default, skip_serializing_if = "Option::is_none")]
426        invalidates_prefix_at: Option<usize>,
427    },
428    Renewed {
429        sprint: u32,
430    },
431    Rollbacked {
432        turn: u32,
433        checkpoint_history_len: u32,
434        #[serde(default, skip_serializing_if = "Option::is_none")]
435        reason: Option<RollbackReason>,
436    },
437    CapabilityChanged {
438        turn: u32,
439        #[serde(default, skip_serializing_if = "Vec::is_empty")]
440        added: Vec<String>,
441        #[serde(default, skip_serializing_if = "Vec::is_empty")]
442        removed: Vec<String>,
443        #[serde(default, skip_serializing_if = "Option::is_none")]
444        change_kind: Option<String>,
445        #[serde(default, skip_serializing_if = "Option::is_none")]
446        capability_id: Option<String>,
447        #[serde(default, skip_serializing_if = "Option::is_none")]
448        version: Option<String>,
449        #[serde(default, skip_serializing_if = "Option::is_none")]
450        mounted_by: Option<String>,
451        #[serde(default, skip_serializing_if = "Option::is_none")]
452        mount_reason: Option<String>,
453    },
454    MilestoneAdvanced {
455        turn: u32,
456        phase_id: String,
457        capabilities_unlocked: Vec<String>,
458    },
459    MilestoneBlocked {
460        turn: u32,
461        phase_id: String,
462        reason: String,
463    },
464    /// Evidence collected by the verifier during milestone evaluation.
465    MilestoneEvidence {
466        turn: u32,
467        phase_id: String,
468        #[serde(default, skip_serializing_if = "Vec::is_empty")]
469        evidence: Vec<String>,
470    },
471    /// Checkpoint taken at the start of a turn transaction (before LLM call).
472    CheckpointTaken {
473        turn: u32,
474        history_len: u32,
475    },
476    /// Kernel process table changed for a spawned sub-agent.
477    AgentProcessChanged {
478        turn: u32,
479        agent_id: String,
480        parent_session_id: String,
481        role: String,
482        isolation: String,
483        context_inheritance: String,
484        state: String,
485        #[serde(default, skip_serializing_if = "Vec::is_empty")]
486        permitted_capability_ids: Vec<String>,
487        #[serde(default, skip_serializing_if = "Option::is_none")]
488        result_termination: Option<String>,
489    },
490    /// W0-ABI: a workflow batch was spawned — each node's spawn descriptor (agent id + goal +
491    /// role/isolation/inheritance) so the SDK can run the kernel-generated nodes.
492    WorkflowBatchSpawned {
493        turn: u32,
494        nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
495        /// G4 budget-as-signal: the workflow's remaining headroom under the active quota at spawn
496        /// time, so a coordinator node can scale its next submission. Additive: omitted when no
497        /// resource quota is installed (nothing to report).
498        #[serde(default, skip_serializing_if = "Option::is_none")]
499        budget: Option<crate::orchestration::workflow::WorkflowBudget>,
500    },
501    /// W0-ABI: a workflow finished (all nodes terminal, or stalled by a gated dependency).
502    WorkflowCompleted {
503        turn: u32,
504        #[serde(default, skip_serializing_if = "Vec::is_empty")]
505        completed: Vec<String>,
506        #[serde(default, skip_serializing_if = "Vec::is_empty")]
507        failed: Vec<String>,
508    },
509    /// #2-B: a high-urgency `InterruptNow` signal preempted in-flight work. The kernel has already
510    /// marked these agents `Done(UserAbort)` and reclaimed the root to reason about the interrupt; the
511    /// SDK must ABORT the listed in-flight child runs and discard their results (do NOT feed their
512    /// `SubAgentCompleted`). Additive variant (`agent_preempted`) — byte-identical for SDKs that never
513    /// receive it.
514    AgentPreempted {
515        turn: u32,
516        #[serde(default, skip_serializing_if = "Vec::is_empty")]
517        agent_ids: Vec<String>,
518        reason: String,
519    },
520    /// A tool call needs user approval (governance `AskUser`). Not blocked by the
521    /// kernel — the SDK must obtain approval before executing the named call.
522    ToolGated {
523        turn: u32,
524        call_id: String,
525        tool: String,
526        reason: String,
527    },
528    /// An inbound signal was routed by the in-kernel attention policy.
529    SignalDisposed {
530        turn: u32,
531        signal_id: String,
532        disposition: String,
533        queue_depth: u32,
534    },
535    /// A budget axis (turns / tokens / wall-time) was exhausted.
536    BudgetExceeded { turn: u32, budget: String },
537    /// Loop entered `Suspended` state (awaiting human approval or sub-agent).
538    Suspended {
539        turn: u32,
540        reason: String,
541        #[serde(default, skip_serializing_if = "Vec::is_empty")]
542        pending_calls: Vec<String>,
543    },
544    /// Loop resumed from `Suspended` state.
545    Resumed {
546        turn: u32,
547        #[serde(default, skip_serializing_if = "Vec::is_empty")]
548        approved: Vec<String>,
549        #[serde(default, skip_serializing_if = "Vec::is_empty")]
550        denied: Vec<String>,
551    },
552    /// Working memory archived for long-term storage (page-out decision).
553    PageOut {
554        turn: u32,
555        action: KernelPressureAction,
556        rho_after: f64,
557        #[serde(default, skip_serializing_if = "Option::is_none")]
558        summary: Option<String>,
559        #[serde(default, skip_serializing_if = "Vec::is_empty")]
560        archived: Vec<Message>,
561        tier_hint: String,
562    },
563    /// Kernel requests SDK to fetch long-term memory for a meta-tool call.
564    PageInRequested {
565        turn: u32,
566        call_id: String,
567        tool: String,
568        query: String,
569        top_k: u32,
570    },
571    /// Memory entry written successfully (Phase 7).
572    MemoryWritten {
573        turn: u32,
574        memory_id: String,
575        memory_kind: String,
576        size_bytes: u32,
577    },
578    /// Memory validation failed (Phase 7).
579    MemoryValidationFailed {
580        turn: u32,
581        memory_id: String,
582        error: String,
583    },
584    /// Memory query request (Phase 7).
585    MemoryQueried {
586        turn: u32,
587        query_context: String,
588        requested_k: usize,
589        requires_async_response: bool,
590    },
591    /// Large tool result spooled (Layer 1).
592    LargeResultSpooled {
593        turn: u32,
594        call_id: String,
595        tool: String,
596        original_size: u32,
597        preview_size: u32,
598        spool_ref: Option<String>,
599    },
600}
601
602/// Transaction-boundary observations emitted by the kernel.
603///
604/// A turn transaction lifecycle looks like:
605///   `CheckpointTaken` (before LLM call) → … → `Rollbacked` (if fatal) or
606///   implicit commit (clean `ToolCompleted` + turn increment).
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(tag = "kind", rename_all = "snake_case")]
609pub enum TransactionObservation {
610    CheckpointTaken { turn: u32, history_len: u32 },
611    Rollbacked {
612        turn: u32,
613        checkpoint_history_len: u32,
614        #[serde(default, skip_serializing_if = "Option::is_none")]
615        reason: Option<crate::runtime::session::RollbackReason>,
616    },
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "snake_case")]
621pub enum KernelPressureAction {
622    None,
623    SnipCompact,
624    MicroCompact,
625    ContextCollapse,
626    AutoCompact,
627}
628
629impl From<PressureAction> for KernelPressureAction {
630    fn from(action: PressureAction) -> Self {
631        match action {
632            PressureAction::None => Self::None,
633            PressureAction::SnipCompact => Self::SnipCompact,
634            PressureAction::MicroCompact => Self::MicroCompact,
635            PressureAction::ContextCollapse => Self::ContextCollapse,
636            PressureAction::AutoCompact => Self::AutoCompact,
637        }
638    }
639}
640
641/// Pure kernel runtime wrapper. SDKs should migrate toward feeding
642/// `KernelInput` values here instead of directly driving `LoopStateMachine`.
643pub struct KernelRuntime {
644    sm: LoopStateMachine,
645}
646
647impl KernelRuntime {
648    pub fn new(policy: LoopPolicy) -> Self {
649        Self {
650            sm: LoopStateMachine::new(policy),
651        }
652    }
653
654    pub fn state_machine(&self) -> &LoopStateMachine {
655        &self.sm
656    }
657
658    pub fn state_machine_mut(&mut self) -> &mut LoopStateMachine {
659        &mut self.sm
660    }
661
662    pub fn is_terminal(&self) -> bool {
663        self.sm.is_terminal()
664    }
665
666    pub fn step(&mut self, input: KernelInput) -> KernelStep {
667        let action = match input.event {
668            KernelInputEvent::SetTools { tools } => {
669                self.sm.tools = tools;
670                return KernelStep::empty(self.sm.take_observations());
671            }
672            KernelInputEvent::SetAvailableSkills { skills } => {
673                self.sm.ctx.set_available_skills(skills);
674                return KernelStep::empty(self.sm.take_observations());
675            }
676            KernelInputEvent::SkillActivated { name } => {
677                // B1: record the activation (B2 reads it in emit_call_llm to narrow tools).
678                // The returned `changed` flag is the epoch boundary for D's cache re-anchor.
679                self.sm.ctx.activate_skill(name);
680                return KernelStep::empty(self.sm.take_observations());
681            }
682            KernelInputEvent::SetStableCoreTools { tool_ids } => {
683                self.sm.ctx.set_stable_core_tools(tool_ids.into_iter().map(Into::into));
684                return KernelStep::empty(self.sm.take_observations());
685            }
686            KernelInputEvent::SetMemoryEnabled { enabled } => {
687                self.sm.ctx.set_memory_enabled(enabled);
688                return KernelStep::empty(self.sm.take_observations());
689            }
690            KernelInputEvent::SetKnowledgeEnabled { enabled } => {
691                self.sm.ctx.set_knowledge_enabled(enabled);
692                return KernelStep::empty(self.sm.take_observations());
693            }
694            KernelInputEvent::SetPlanToolEnabled { enabled } => {
695                self.sm.ctx.set_plan_tool_enabled(enabled);
696                return KernelStep::empty(self.sm.take_observations());
697            }
698            KernelInputEvent::SetTokenizer { .. } => {
699                // Local BPE tokenisers are no longer used — accuracy comes from
700                // observed_input_tokens reported by the provider API (P0-1 Step 2).
701                // char_approx is always used for pre-flight truncation estimates.
702                self.sm.ctx.engine = ContextTokenEngine::char_approx();
703                return KernelStep::empty(self.sm.take_observations());
704            }
705            KernelInputEvent::AddSystemMessage { content, tokens } => {
706                self.sm
707                    .ctx
708                    .partitions
709                    .system
710                    .push(Message::system(content), tokens.max(1));
711                return KernelStep::empty(self.sm.take_observations());
712            }
713            KernelInputEvent::AddKnowledgeMessage { content, tokens } => {
714                // P1-B2 cache contract: the knowledge partition renders into the cached system[1]
715                // block. Appending here is the right home for *stable* reference material (skill
716                // defs, durable artifacts) — it's append-only, so the existing prefix stays
717                // byte-stable, and a fresh append costs only a one-time system[1] re-cache. Do NOT
718                // route *per-turn* retrievals (a memory/knowledge lookup that changes every turn)
719                // through here: each would rewrite the cached block and invalidate it plus the
720                // history cache every turn. Volatile per-turn context belongs on the signal/tail
721                // path (`push_signal` → state_turn), which is uncached *and* high-attention (P1-F).
722                self.sm.ctx.partitions.knowledge.push(Message::system(content), tokens.max(1));
723                return KernelStep::empty(self.sm.take_observations());
724            }
725            KernelInputEvent::AddHistoryMessage { message, tokens } => {
726                let tokens = tokens.unwrap_or_else(|| self.sm.ctx.engine.count_message(&message));
727                self.sm.ctx.push_history(message, tokens.max(1));
728                return KernelStep::empty(self.sm.take_observations());
729            }
730            KernelInputEvent::PreloadHistory { messages } => {
731                self.sm.preload_history(messages);
732                return KernelStep::empty(self.sm.take_observations());
733            }
734            KernelInputEvent::MountCapability { capability } => {
735                self.sm.mount_capability(capability, None, None);
736                return KernelStep::empty(self.sm.take_observations());
737            }
738            KernelInputEvent::UnmountCapability {
739                capability_kind,
740                id,
741            } => {
742                self.sm.unmount_capability(capability_kind, &id);
743                return KernelStep::empty(self.sm.take_observations());
744            }
745            KernelInputEvent::LoadMilestoneContract { contract } => {
746                self.sm.load_milestone_contract(contract);
747                return KernelStep::empty(self.sm.take_observations());
748            }
749            KernelInputEvent::LoadGovernancePolicy {
750                default_action,
751                rules,
752                vetoed_tools,
753                rate_limits,
754                constraints,
755            } => {
756                use crate::governance::constraint::{ConstraintRule, ParamConstraint};
757                use crate::governance::permission::PermissionRule;
758                use crate::governance::rate_limit::RateLimit;
759                let default = default_action.unwrap_or(PolicyAction::Allow).into();
760                let mut pipeline = crate::governance::pipeline::GovernancePipeline::new(default);
761                for rule in rules {
762                    pipeline.permission.add_rule(PermissionRule {
763                        tool_pattern: rule.tool_pattern.into(),
764                        action: rule.action.into(),
765                    });
766                }
767                for tool in vetoed_tools {
768                    pipeline.veto.block_tool(tool);
769                }
770                for rl in rate_limits {
771                    pipeline.rate_limiter.set_limit(
772                        rl.tool,
773                        RateLimit {
774                            max_calls: rl.max_calls,
775                            window_ms: rl.window_ms,
776                        },
777                    );
778                }
779                for c in constraints {
780                    let (tool_name, param_path, rule) = match c {
781                        ConstraintSpec::Required { tool, path } => {
782                            (tool, path, ConstraintRule::Required)
783                        }
784                        ConstraintSpec::Enum { tool, path, values } => {
785                            (tool, path, ConstraintRule::Enum(values))
786                        }
787                        ConstraintSpec::Range {
788                            tool,
789                            path,
790                            min,
791                            max,
792                        } => (tool, path, ConstraintRule::Range { min, max }),
793                    };
794                    pipeline.constraints.add(ParamConstraint {
795                        tool_name,
796                        param_path,
797                        rule,
798                    });
799                }
800                self.sm.set_governance(pipeline);
801                return KernelStep::empty(self.sm.take_observations());
802            }
803            KernelInputEvent::SetAttentionPolicy { max_queue_size } => {
804                self.sm.set_attention(max_queue_size as usize);
805                return KernelStep::empty(self.sm.take_observations());
806            }
807            KernelInputEvent::PageIn { entries } => {
808                self.sm.apply_page_in(&entries);
809                return KernelStep::empty(self.sm.take_observations());
810            }
811            KernelInputEvent::ForceCompact => {
812                self.sm.force_compact();
813                return KernelStep::empty(self.sm.take_observations());
814            }
815            KernelInputEvent::UpdateTask { update } => {
816                self.sm.ctx.update_task(update);
817                return KernelStep::empty(self.sm.take_observations());
818            }
819            KernelInputEvent::StartRun { task, run_spec } => {
820                self.sm.run_spec = run_spec;
821                self.sm.start(task)
822            }
823            KernelInputEvent::CapabilityCommand { command } => {
824                self.sm.execute_capability_command(command);
825                return KernelStep::empty(self.sm.take_observations());
826            }
827            KernelInputEvent::Resume { approved_calls, denied_calls } => {
828                let action = self.sm.resume_from_suspend(approved_calls, denied_calls);
829                if matches!(action, LoopAction::AwaitingResume) {
830                    return KernelStep::empty(self.sm.take_observations());
831                }
832                return KernelStep::single(action, self.sm.take_observations());
833            }
834            KernelInputEvent::SetSchedulerBudget { max_wall_ms } => {
835                self.sm.set_wall_budget(max_wall_ms);
836                return KernelStep::empty(self.sm.take_observations());
837            }
838            KernelInputEvent::SetResourceQuota { quota } => {
839                self.sm.set_resource_quota(quota);
840                return KernelStep::empty(self.sm.take_observations());
841            }
842            KernelInputEvent::ProviderResult {
843                message,
844                observed_input_tokens,
845                observed_output_tokens: _,
846                now_ms,
847            } => {
848                if let Some(tokens) = observed_input_tokens {
849                    self.sm.ctx.set_observed_prompt_tokens(tokens);
850                }
851                // Feed the clock before the governance gate fires inside `feed`, so the
852                // rate limiter sees a real timestamp (no-op when no policy is loaded).
853                if let Some(ms) = now_ms {
854                    self.sm.set_observed_time(ms);
855                }
856                self.sm.feed(LoopEvent::LLMResponse { message })
857            }
858            KernelInputEvent::ToolResults { results } => {
859                self.sm.feed(LoopEvent::ToolResults { results })
860            }
861            KernelInputEvent::Signal { signal } => match self.sm.signal_event(signal) {
862                Some(action) => action,
863                // Non-actionable disposition (queued / observed / ignored / dropped):
864                // no provider call this step, just the SignalDisposed observation.
865                None => return KernelStep::empty(self.sm.take_observations()),
866            },
867            KernelInputEvent::MilestoneResult { result } => {
868                self.sm.feed(LoopEvent::MilestoneResult { result })
869            }
870            KernelInputEvent::SpawnSubAgent {
871                spec,
872                parent_session_id,
873            } => {
874                let action = self.sm.spawn_sub_agent(spec, &parent_session_id);
875                if matches!(action, LoopAction::AwaitingResume) {
876                    return KernelStep::empty(self.sm.take_observations());
877                }
878                return KernelStep::single(action, self.sm.take_observations());
879            }
880            KernelInputEvent::LoadWorkflow {
881                spec,
882                parent_session_id,
883                resumed_completed,
884                resumed_submissions,
885            } => {
886                let action = if resumed_completed.is_empty() && resumed_submissions.is_empty() {
887                    self.sm.load_workflow(spec, &parent_session_id)
888                } else {
889                    self.sm.load_workflow_resumed(
890                        spec,
891                        &parent_session_id,
892                        &resumed_submissions,
893                        &resumed_completed,
894                    )
895                };
896                if matches!(action, LoopAction::AwaitingResume) {
897                    return KernelStep::empty(self.sm.take_observations());
898                }
899                return KernelStep::single(action, self.sm.take_observations());
900            }
901            KernelInputEvent::SubAgentCompleted { result } => {
902                self.sm.feed(LoopEvent::SubAgentCompleted { result })
903            }
904            KernelInputEvent::SubmitWorkflowNodes {
905                nodes,
906                submitter_agent_id,
907            } => {
908                let action = self
909                    .sm
910                    .submit_workflow_nodes(nodes, submitter_agent_id.as_deref());
911                if matches!(action, LoopAction::AwaitingResume) {
912                    return KernelStep::empty(self.sm.take_observations());
913                }
914                return KernelStep::single(action, self.sm.take_observations());
915            }
916            KernelInputEvent::SubmitWorkflow {
917                spec,
918                parent_session_id,
919                submitter_agent_id,
920            } => {
921                let action = self.sm.submit_workflow(
922                    spec,
923                    &parent_session_id,
924                    submitter_agent_id.as_deref(),
925                );
926                if matches!(action, LoopAction::AwaitingResume) {
927                    return KernelStep::empty(self.sm.take_observations());
928                }
929                return KernelStep::single(action, self.sm.take_observations());
930            }
931            KernelInputEvent::SetMemoryPolicy {
932                memory_path,
933                stale_warning_days,
934                retrieval_top_k,
935                validation_enabled,
936                max_content_bytes,
937                max_name_length,
938            } => {
939                // Phase 7: install the memory policy. The kernel enforces validation_enabled +
940                // retrieval_top_k + size/name overrides at the WriteMemory/QueryMemory traps;
941                // memory_path / stale_warning_days are carried for the SDK's recall I/O.
942                self.sm.set_memory_policy(crate::mm::memory::MemoryPolicy {
943                    memory_path,
944                    stale_warning_days,
945                    retrieval_top_k,
946                    validation_enabled,
947                    max_content_bytes,
948                    max_name_length,
949                });
950                return KernelStep::empty(self.sm.take_observations());
951            }
952            KernelInputEvent::WriteMemory { memory } => {
953                // Phase 7: Validate memory write request.
954                // Kernel validates; SDK performs I/O.
955                use crate::mm::memory::validate_memory_write;
956                let turn = self.sm.turn;
957                // M2: route the write through the syscall trap so the resource quota (write-rate
958                // limit) applies. A rate-limited / denied write surfaces as a validation failure
959                // (the write does not happen) and short-circuits before validation.
960                let disposition = self
961                    .sm
962                    .gate_syscall(&crate::syscall::Syscall::WriteMemory(memory.clone()));
963                if !disposition.is_allowed() {
964                    let error = match disposition {
965                        crate::syscall::Disposition::RateLimited { retry_after_ms } => {
966                            format!("memory write rate limited; retry after {retry_after_ms}ms")
967                        }
968                        crate::syscall::Disposition::Deny { reason, .. } => {
969                            format!("memory write denied: {reason}")
970                        }
971                        _ => "memory write not permitted".to_string(),
972                    };
973                    self.sm.observations.push(
974                        KernelObservation::MemoryValidationFailed {
975                            turn,
976                            memory_id: memory.metadata.name.clone(),
977                            error,
978                        },
979                    );
980                    return KernelStep::empty(self.sm.take_observations());
981                }
982                // Validate honoring any installed memory policy: a policy with validation disabled
983                // admits the write outright; a policy with size/name overrides validates against
984                // those; no policy uses the default rules (pre-policy behavior).
985                let validation_result = match self.sm.memory_policy() {
986                    Some(p) if !p.validation_enabled => Ok(()),
987                    Some(p) => p.validation().validate(&memory),
988                    None => validate_memory_write(&memory),
989                };
990                match validation_result {
991                    Ok(()) => {
992                        // Emit observation for SDK to perform I/O
993                        self.sm.observations.push(KernelObservation::MemoryWritten {
994                            turn,
995                            memory_id: memory.metadata.name.clone(),
996                            memory_kind: memory.metadata.kind.map(|k| k.label()).unwrap_or_else(|| {
997                                crate::mm::memory::MemoryKind::infer_from_metadata(&memory.metadata).label()
998                            }).to_string(),
999                            size_bytes: memory.content.len() as u32,
1000                        });
1001                    }
1002                    Err(err) => {
1003                        // Emit validation error observation
1004                        use crate::mm::memory::MemoryValidationError;
1005                        let error_msg = match err {
1006                            MemoryValidationError::MissingRequiredField { field } => format!("Missing required field: {}", field),
1007                            MemoryValidationError::ContentTooLarge { size, limit } => format!("Content too large: {} bytes (limit: {})", size, limit),
1008                            MemoryValidationError::ForbiddenPattern { pattern, reason } => format!("Forbidden pattern '{}': {}", pattern, reason),
1009                            MemoryValidationError::InvalidKind { kind } => format!("Invalid kind: {}", kind),
1010                            MemoryValidationError::NameTooLong { length, limit } => format!("Name too long: {} chars (limit: {})", length, limit),
1011                        };
1012                        self.sm.observations.push(KernelObservation::MemoryValidationFailed {
1013                            turn,
1014                            memory_id: memory.metadata.name.clone(),
1015                            error: error_msg,
1016                        });
1017                    }
1018                }
1019                return KernelStep::empty(self.sm.take_observations());
1020            }
1021            KernelInputEvent::QueryMemory { query } => {
1022                // Phase 7: Query memory for context.
1023                // Kernel emits observation; SDK responds asynchronously.
1024                let turn = self.sm.turn;
1025                // An installed policy caps retrieval breadth: requested_k = min(query.top_k, policy).
1026                let requested_k = match self.sm.memory_policy() {
1027                    Some(p) => p.clamp_top_k(query.top_k),
1028                    None => query.top_k,
1029                };
1030                self.sm.observations.push(KernelObservation::MemoryQueried {
1031                    turn,
1032                    query_context: query.current_context.clone(),
1033                    requested_k,
1034                    requires_async_response: true,
1035                });
1036                return KernelStep::empty(self.sm.take_observations());
1037            }
1038            KernelInputEvent::MemoryRetrievalResult { .. } => {
1039                // SDK acknowledgment after async retrieval; no further kernel action.
1040                return KernelStep::empty(self.sm.take_observations());
1041            }
1042            KernelInputEvent::Timeout => self.sm.feed(LoopEvent::Timeout),
1043        };
1044        if matches!(action, LoopAction::AwaitingResume) {
1045            return KernelStep::empty(self.sm.take_observations());
1046        }
1047        KernelStep::single(action, self.sm.take_observations())
1048    }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054
1055    #[test]
1056    fn start_run_returns_versioned_provider_action() {
1057        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1058        let step = runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1059            task: RuntimeTask::new("ship it"),
1060            run_spec: None,
1061        }));
1062
1063        assert_eq!(step.version, KERNEL_ABI_VERSION);
1064        assert!(matches!(
1065            step.actions.as_slice(),
1066            [KernelAction::CallProvider { .. }]
1067        ));
1068    }
1069
1070    #[test]
1071    fn provider_text_response_returns_done() {
1072        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1073        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1074            task: RuntimeTask::new("ship it"),
1075            run_spec: None,
1076        }));
1077        let step = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1078            message: Message::assistant("done"),
1079            observed_input_tokens: None,
1080            observed_output_tokens: None,
1081            now_ms: None,
1082        }));
1083
1084        assert!(matches!(
1085            step.actions.as_slice(),
1086            [KernelAction::Done { .. }]
1087        ));
1088    }
1089
1090    #[test]
1091    fn config_inputs_mutate_runtime_without_actions() {
1092        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1093        let step = runtime.step(KernelInput::new(KernelInputEvent::SetTools {
1094            tools: vec![ToolSchema {
1095                name: "echo".into(),
1096                description: "Echo input".to_string(),
1097                parameters: serde_json::json!({"type": "object"}),
1098            }],
1099        }));
1100
1101        assert!(step.actions.is_empty());
1102        assert_eq!(runtime.state_machine().tools.len(), 1);
1103    }
1104
1105    #[test]
1106    fn skill_activated_input_records_active_skill() {
1107        // P1-B B1: the SkillActivated event (serde `skill_activated`) records the active skill and,
1108        // via the catalog's declared tools, yields a narrowing filter — without itself acting.
1109        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1110        let mut debug = SkillMetadata::new("debug", "Debug helper");
1111        debug.allowed_tools = vec!["read".into(), "grep".into()];
1112        runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1113            skills: vec![debug],
1114        }));
1115
1116        let step = runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1117            name: "debug".to_string(),
1118        }));
1119
1120        assert!(step.actions.is_empty(), "activation is config, not an action");
1121        assert!(runtime.state_machine().ctx.active_skills.contains("debug"));
1122        let filter = runtime.state_machine().ctx.active_skill_tool_filter().unwrap();
1123        assert_eq!(filter.len(), 2);
1124    }
1125
1126    #[test]
1127    fn update_task_input_mutates_task_state() {
1128        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1129        let step = runtime.step(KernelInput::new(KernelInputEvent::UpdateTask {
1130            update: TaskUpdate {
1131                progress: Some("tools executed".to_string()),
1132                ..Default::default()
1133            },
1134        }));
1135
1136        assert!(step.actions.is_empty());
1137        assert_eq!(
1138            runtime.state_machine().ctx.partitions.task_state.progress,
1139            "tools executed"
1140        );
1141    }
1142
1143    #[test]
1144    fn add_knowledge_message_enters_knowledge_partition() {
1145        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1146        let step = runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1147            content: "skill: debug".to_string(),
1148            tokens: 10,
1149        }));
1150
1151        assert!(step.actions.is_empty());
1152        assert_eq!(
1153            runtime.state_machine().ctx.partitions.knowledge.messages.len(),
1154            1
1155        );
1156    }
1157
1158    #[test]
1159    fn capability_mount_emits_observation() {
1160        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1161        let step = runtime.step(KernelInput::new(KernelInputEvent::MountCapability {
1162            capability: CapabilityDescriptor::marker(
1163                CapabilityKind::McpServer,
1164                "docs",
1165                "Documentation server",
1166            ),
1167        }));
1168
1169        assert!(step.actions.is_empty());
1170        assert!(matches!(
1171            step.observations.as_slice(),
1172            [KernelObservation::CapabilityChanged { .. }]
1173        ));
1174    }
1175
1176    #[test]
1177    fn spawn_sub_agent_input_registers_process() {
1178        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1179
1180        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1181        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1182            task: RuntimeTask::new("parent task"),
1183            run_spec: None,
1184        }));
1185        runtime.state_machine_mut().take_observations();
1186
1187        let spec = AgentRunSpec::new(
1188            AgentIdentity::sub_agent("worker", "worker-session"),
1189            AgentRole::Implement,
1190            "do work",
1191        );
1192        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1193            spec,
1194            parent_session_id: "parent-session".to_string(),
1195        }));
1196
1197        assert!(step.actions.is_empty());
1198        assert!(step.observations.iter().any(|o| matches!(
1199            o,
1200            KernelObservation::AgentProcessChanged {
1201                agent_id,
1202                parent_session_id,
1203                state,
1204                ..
1205            } if agent_id == "worker" && parent_session_id == "parent-session" && state == "running"
1206        )));
1207        assert_eq!(
1208            runtime
1209                .state_machine()
1210                .agent_process("worker")
1211                .expect("process")
1212                .parent_session_id
1213                .as_str(),
1214            "parent-session"
1215        );
1216        assert!(step.observations.iter().any(|o| matches!(
1217            o,
1218            KernelObservation::Suspended { reason, .. } if reason == "sub_agent_await"
1219        )));
1220        assert!(runtime.state_machine().is_suspended());
1221        assert!(matches!(
1222            runtime.state_machine().wait_reason(),
1223            Some(crate::scheduler::tcb::WaitReason::SubAgentJoin(_))
1224        ));
1225    }
1226
1227    #[test]
1228    fn set_resource_quota_input_denies_spawn_over_quota() {
1229        use crate::governance::quota::ResourceQuota;
1230        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1231
1232        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1233        // Quota flows in through the same versioned JSON event ABI as governance/scheduler config.
1234        let step = runtime.step(KernelInput::new(KernelInputEvent::SetResourceQuota {
1235            quota: ResourceQuota { max_spawn_depth: Some(0), ..ResourceQuota::default() },
1236        }));
1237        assert!(step.actions.is_empty(), "config input yields no actions");
1238
1239        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1240            task: RuntimeTask::new("parent task"),
1241            run_spec: None,
1242        }));
1243        runtime.state_machine_mut().take_observations();
1244
1245        let spec = AgentRunSpec::new(
1246            AgentIdentity::sub_agent("worker", "worker-session"),
1247            AgentRole::Implement,
1248            "do work",
1249        );
1250        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1251            spec,
1252            parent_session_id: "parent-session".to_string(),
1253        }));
1254
1255        // Denied spawn rolls the turn back to another reasoning pass — no process registered,
1256        // not suspended on a sub-agent join.
1257        assert!(matches!(
1258            step.actions.as_slice(),
1259            [KernelAction::CallProvider { .. }]
1260        ));
1261        assert!(!step.observations.iter().any(|o| matches!(
1262            o,
1263            KernelObservation::AgentProcessChanged { agent_id, .. } if agent_id == "worker"
1264        )));
1265        assert!(runtime.state_machine().agent_process("worker").is_none());
1266        assert!(!runtime.state_machine().is_suspended());
1267    }
1268
1269    #[test]
1270    fn default_runtime_leaves_spawn_unquota_ed() {
1271        use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1272
1273        // No SetResourceQuota event => pre-M2 behavior: spawn is unconditionally admitted.
1274        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1275        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1276            task: RuntimeTask::new("parent task"),
1277            run_spec: None,
1278        }));
1279        runtime.state_machine_mut().take_observations();
1280
1281        let spec = AgentRunSpec::new(
1282            AgentIdentity::sub_agent("worker", "worker-session"),
1283            AgentRole::Implement,
1284            "do work",
1285        );
1286        runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1287            spec,
1288            parent_session_id: "parent-session".to_string(),
1289        }));
1290        assert!(runtime.state_machine().agent_process("worker").is_some());
1291        assert!(runtime.state_machine().is_suspended());
1292    }
1293
1294    /// Wire-format lock for `agent_process_changed` multi-word enum values. The kernel stringifies
1295    /// `isolation`/`context_inheritance` as debug-lowercase (`readonly`/`systemonly`), which is NOT
1296    /// the same as serde snake_case (`read_only`/`system_only`) — and no golden fixture covers these
1297    /// variants. This pins the current bytes so the observation refactor cannot silently change them.
1298    #[test]
1299    fn agent_process_changed_locks_multiword_wire_form() {
1300        use crate::types::agent::{AgentIdentity, AgentIsolation, AgentRole, AgentRunSpec};
1301
1302        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1303        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1304            task: RuntimeTask::new("parent task"),
1305            run_spec: None,
1306        }));
1307        runtime.state_machine_mut().take_observations();
1308
1309        // Verify role → SystemOnly inheritance; explicit ReadOnly isolation. Both are multi-word.
1310        let spec = AgentRunSpec::new(
1311            AgentIdentity::sub_agent("worker", "worker-session"),
1312            AgentRole::Verify,
1313            "do work",
1314        )
1315        .with_isolation(AgentIsolation::ReadOnly);
1316        let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1317            spec,
1318            parent_session_id: "parent-session".to_string(),
1319        }));
1320
1321        let obs = step
1322            .observations
1323            .iter()
1324            .find(|o| matches!(o, KernelObservation::AgentProcessChanged { .. }))
1325            .expect("agent_process_changed observation");
1326        let json = serde_json::to_value(obs).unwrap();
1327        assert_eq!(json["isolation"], "readonly", "isolation must stay debug-lowercase");
1328        assert_eq!(
1329            json["context_inheritance"], "systemonly",
1330            "context_inheritance must stay debug-lowercase"
1331        );
1332        assert_eq!(json["role"], "verify");
1333        assert_eq!(json["state"], "running");
1334    }
1335
1336    // ── M-memory-policy: set_memory_policy is enforced at the WriteMemory / QueryMemory traps ──
1337
1338    fn write_memory(runtime: &mut KernelRuntime, name: &str, content: &str) -> KernelStep {
1339        use crate::mm::memory::{MemoryMetadata, MemoryWriteRequest};
1340        runtime.step(KernelInput::new(KernelInputEvent::WriteMemory {
1341            memory: MemoryWriteRequest {
1342                metadata: MemoryMetadata {
1343                    name: name.to_string(),
1344                    description: "desc".to_string(),
1345                    ..Default::default()
1346                },
1347                content: content.to_string(),
1348            },
1349        }))
1350    }
1351
1352    #[test]
1353    fn memory_policy_validation_disabled_admits_forbidden_write() {
1354        // "代码模式:" is a forbidden pattern under default validation; disabling validation admits it.
1355        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1356        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
1357            memory_path: String::new(),
1358            stale_warning_days: 2,
1359            retrieval_top_k: 5,
1360            validation_enabled: false,
1361            max_content_bytes: None,
1362            max_name_length: None,
1363        }));
1364        let step = write_memory(&mut runtime, "note", "代码模式: foo");
1365        assert!(step
1366            .observations
1367            .iter()
1368            .any(|o| matches!(o, KernelObservation::MemoryWritten { .. })));
1369        assert!(!step
1370            .observations
1371            .iter()
1372            .any(|o| matches!(o, KernelObservation::MemoryValidationFailed { .. })));
1373    }
1374
1375    #[test]
1376    fn default_runtime_validates_forbidden_write() {
1377        // No policy installed => default validation rejects the forbidden pattern (pre-policy behavior).
1378        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1379        let step = write_memory(&mut runtime, "note", "代码模式: foo");
1380        assert!(step
1381            .observations
1382            .iter()
1383            .any(|o| matches!(o, KernelObservation::MemoryValidationFailed { .. })));
1384    }
1385
1386    #[test]
1387    fn memory_policy_size_override_rejects_oversized_write() {
1388        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1389        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
1390            memory_path: String::new(),
1391            stale_warning_days: 2,
1392            retrieval_top_k: 5,
1393            validation_enabled: true,
1394            max_content_bytes: Some(8),
1395            max_name_length: None,
1396        }));
1397        let step = write_memory(&mut runtime, "note", "this content is well over eight bytes");
1398        let failed = step.observations.iter().find_map(|o| match o {
1399            KernelObservation::MemoryValidationFailed { error, .. } => Some(error.clone()),
1400            _ => None,
1401        });
1402        assert!(failed.is_some_and(|e| e.contains("too large")));
1403    }
1404
1405    #[test]
1406    fn memory_policy_clamps_retrieval_top_k() {
1407        use crate::mm::memory::MemoryQuery;
1408        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1409        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
1410            memory_path: String::new(),
1411            stale_warning_days: 2,
1412            retrieval_top_k: 3,
1413            validation_enabled: true,
1414            max_content_bytes: None,
1415            max_name_length: None,
1416        }));
1417        let step = runtime.step(KernelInput::new(KernelInputEvent::QueryMemory {
1418            query: MemoryQuery { top_k: 50, ..Default::default() },
1419        }));
1420        let requested = step.observations.iter().find_map(|o| match o {
1421            KernelObservation::MemoryQueried { requested_k, .. } => Some(*requested_k),
1422            _ => None,
1423        });
1424        assert_eq!(requested, Some(3));
1425    }
1426
1427    #[test]
1428    fn default_runtime_uses_requested_top_k_verbatim() {
1429        use crate::mm::memory::MemoryQuery;
1430        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1431        let step = runtime.step(KernelInput::new(KernelInputEvent::QueryMemory {
1432            query: MemoryQuery { top_k: 50, ..Default::default() },
1433        }));
1434        let requested = step.observations.iter().find_map(|o| match o {
1435            KernelObservation::MemoryQueried { requested_k, .. } => Some(*requested_k),
1436            _ => None,
1437        });
1438        assert_eq!(requested, Some(50));
1439    }
1440
1441    #[test]
1442    fn provider_result_now_ms_drives_wall_time_budget() {
1443        let mut runtime = KernelRuntime::new(LoopPolicy {
1444            max_wall_ms: Some(10),
1445            ..LoopPolicy::default()
1446        });
1447        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1448            task: RuntimeTask::new("ship it"),
1449            run_spec: None,
1450        }));
1451        let mut msg = Message::assistant("");
1452        msg.tool_calls.push(ToolCall {
1453            id: "call-1".into(),
1454            name: "echo".into(),
1455            arguments: serde_json::json!({}),
1456        });
1457        runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1458            message: msg,
1459            observed_input_tokens: None,
1460            observed_output_tokens: None,
1461            now_ms: Some(100),
1462        }));
1463        let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1464            results: vec![ToolResult {
1465                call_id: "call-1".into(),
1466                output: crate::types::message::Content::Text("ok".into()),
1467                is_error: false,
1468                is_fatal: false,
1469                error_kind: None,
1470                token_count: None,
1471            }],
1472        }));
1473
1474        assert!(matches!(
1475            step.actions.as_slice(),
1476            [KernelAction::CallProvider { tools, .. }] if tools.is_empty()
1477        ));
1478    }
1479
1480    // ─── Governance gate ───────────────────────────────────────────────────
1481
1482    fn assistant_calling(tool: &str) -> Message {
1483        let mut msg = Message::assistant("");
1484        msg.tool_calls.push(ToolCall {
1485            id: "call-1".into(),
1486            name: tool.into(),
1487            arguments: serde_json::json!({}),
1488        });
1489        msg
1490    }
1491
1492    /// Feed a tool-calling response and return the resulting step.
1493    fn run_with_tool_call(runtime: &mut KernelRuntime, tool: &str) -> KernelStep {
1494        run_with_tool_call_named(runtime, tool, "call-1")
1495    }
1496
1497    fn run_with_tool_call_named(
1498        runtime: &mut KernelRuntime,
1499        tool: &str,
1500        call_id: &str,
1501    ) -> KernelStep {
1502        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1503            task: RuntimeTask::new("do the thing"),
1504            run_spec: None,
1505        }));
1506        runtime.state_machine_mut().take_observations();
1507        runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1508            message: assistant_calling(tool),
1509            observed_input_tokens: None,
1510            observed_output_tokens: None,
1511            now_ms: None,
1512        }))
1513    }
1514
1515    #[test]
1516    fn governance_deny_blocks_tool_and_reprompts() {
1517        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1518        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
1519            default_action: Some(PolicyAction::Allow),
1520            rules: vec![PolicyRule {
1521                tool_pattern: "danger.*".to_string(),
1522                action: PolicyAction::Deny,
1523            }],
1524            vetoed_tools: vec![],
1525            rate_limits: vec![],
1526            constraints: vec![],
1527        }));
1528
1529        let step = run_with_tool_call(&mut runtime, "danger.delete");
1530
1531        // Denied call must NOT reach ExecuteTool; the turn rolls back and re-prompts.
1532        assert!(
1533            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
1534            "denied tool should roll back and re-call provider, got {:?}",
1535            step.actions
1536        );
1537        assert!(
1538            step.observations
1539                .iter()
1540                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
1541            "expected a Rollbacked observation for the denied turn",
1542        );
1543    }
1544
1545    #[test]
1546    fn governance_ask_user_suspends_until_resume() {
1547        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1548        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
1549            default_action: Some(PolicyAction::Allow),
1550            rules: vec![PolicyRule {
1551                tool_pattern: "sensitive.*".to_string(),
1552                action: PolicyAction::AskUser,
1553            }],
1554            vetoed_tools: vec![],
1555            rate_limits: vec![],
1556            constraints: vec![],
1557        }));
1558
1559        let step = run_with_tool_call(&mut runtime, "sensitive.read");
1560
1561        assert!(
1562            step.actions.is_empty(),
1563            "AskUser should suspend without ExecuteTool, got {:?}",
1564            step.actions
1565        );
1566        assert!(
1567            step.observations.iter().any(|o| matches!(
1568                o,
1569                KernelObservation::ToolGated { tool, .. } if tool == "sensitive.read"
1570            )),
1571            "expected a ToolGated observation for the AskUser call",
1572        );
1573        assert!(
1574            step.observations.iter().any(|o| matches!(
1575                o,
1576                KernelObservation::Suspended { reason, .. } if reason == "ask_user"
1577            )),
1578            "expected a Suspended observation",
1579        );
1580
1581        let resumed = runtime.step(KernelInput::new(KernelInputEvent::Resume {
1582            approved_calls: vec!["call-1".to_string()],
1583            denied_calls: vec![],
1584        }));
1585        assert!(
1586            matches!(resumed.actions.as_slice(), [KernelAction::ExecuteTool { .. }]),
1587            "resume with approval should emit ExecuteTool, got {:?}",
1588            resumed.actions
1589        );
1590        assert!(
1591            resumed.observations.iter().any(|o| matches!(
1592                o,
1593                KernelObservation::Resumed { approved, denied, .. }
1594                if approved == &["call-1"] && denied.is_empty()
1595            )),
1596        );
1597    }
1598
1599    #[test]
1600    fn governance_ask_user_resume_all_denied_feeds_tool_results() {
1601        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1602        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
1603            default_action: Some(PolicyAction::Allow),
1604            rules: vec![PolicyRule {
1605                tool_pattern: "sensitive.*".to_string(),
1606                action: PolicyAction::AskUser,
1607            }],
1608            vetoed_tools: vec![],
1609            rate_limits: vec![],
1610            constraints: vec![],
1611        }));
1612        run_with_tool_call(&mut runtime, "sensitive.read");
1613        runtime.state_machine_mut().take_observations();
1614
1615        let step = runtime.step(KernelInput::new(KernelInputEvent::Resume {
1616            approved_calls: vec![],
1617            denied_calls: vec!["call-1".to_string()],
1618        }));
1619        assert!(
1620            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
1621            "all denied should re-prompt provider, got {:?}",
1622            step.actions
1623        );
1624    }
1625
1626    #[test]
1627    fn no_governance_policy_executes_all_tools() {
1628        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1629        let step = run_with_tool_call(&mut runtime, "danger.delete");
1630
1631        // Without a policy the gate is a no-op — behavior is unchanged.
1632        assert!(matches!(
1633            step.actions.as_slice(),
1634            [KernelAction::ExecuteTool { .. }]
1635        ));
1636        assert!(
1637            !step
1638                .observations
1639                .iter()
1640                .any(|o| matches!(o, KernelObservation::ToolGated { .. })),
1641        );
1642    }
1643
1644    fn tool_ok(call_id: &str) -> ToolResult {
1645        ToolResult {
1646            call_id: call_id.into(),
1647            output: crate::types::message::Content::Text("ok".to_string()),
1648            is_error: false,
1649            is_fatal: false,
1650            error_kind: None,
1651            token_count: None,
1652        }
1653    }
1654
1655    #[test]
1656    fn governance_rate_limit_blocks_second_call() {
1657        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1658        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
1659            default_action: Some(PolicyAction::Allow),
1660            rules: vec![],
1661            vetoed_tools: vec![],
1662            rate_limits: vec![RateLimitSpec {
1663                tool: "fetch".to_string(),
1664                max_calls: 1,
1665                window_ms: 60_000,
1666            }],
1667            constraints: vec![],
1668        }));
1669        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1670            task: RuntimeTask::new("fetch twice"),
1671            run_spec: None,
1672        }));
1673        runtime.state_machine_mut().take_observations();
1674
1675        // First call within the window — allowed.
1676        let s1 = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1677            message: assistant_calling("fetch"),
1678            observed_input_tokens: None,
1679            observed_output_tokens: None,
1680            now_ms: Some(1_000),
1681        }));
1682        assert!(
1683            matches!(s1.actions.as_slice(), [KernelAction::ExecuteTool { .. }]),
1684            "first call should execute, got {:?}",
1685            s1.actions
1686        );
1687
1688        // Close the turn so the kernel re-prompts the provider.
1689        runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1690            results: vec![tool_ok("call-1")],
1691        }));
1692        runtime.state_machine_mut().take_observations();
1693
1694        // Second call to the same tool within the window — rate limited → rollback.
1695        let s2 = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1696            message: assistant_calling("fetch"),
1697            observed_input_tokens: None,
1698            observed_output_tokens: None,
1699            now_ms: Some(1_001),
1700        }));
1701        assert!(
1702            matches!(s2.actions.as_slice(), [KernelAction::CallProvider { .. }]),
1703            "rate-limited call should roll back and re-call provider, got {:?}",
1704            s2.actions
1705        );
1706        assert!(
1707            s2.observations
1708                .iter()
1709                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
1710            "expected a Rollbacked observation for the rate-limited turn",
1711        );
1712    }
1713
1714    #[test]
1715    fn governance_constraint_required_param_denies() {
1716        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1717        runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
1718            default_action: Some(PolicyAction::Allow),
1719            rules: vec![],
1720            vetoed_tools: vec![],
1721            rate_limits: vec![],
1722            constraints: vec![ConstraintSpec::Required {
1723                tool: "write".to_string(),
1724                path: "path".to_string(),
1725            }],
1726        }));
1727
1728        // assistant_calling emits empty args `{}` → required "path" is missing → deny.
1729        let step = run_with_tool_call(&mut runtime, "write");
1730        assert!(
1731            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
1732            "missing required param should roll back, got {:?}",
1733            step.actions
1734        );
1735        assert!(
1736            step.observations
1737                .iter()
1738                .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
1739            "expected a Rollbacked observation for the constraint violation",
1740        );
1741    }
1742
1743    // ─── In-kernel signal routing (attention policy) ────────────────────────
1744
1745    fn signal(urgency: crate::types::signal::Urgency, summary: &str) -> crate::types::signal::RuntimeSignal {
1746        use crate::types::signal::{RuntimeSignal, SignalSource, SignalType};
1747        RuntimeSignal::new(SignalSource::Gateway, SignalType::Alert, urgency, summary)
1748    }
1749
1750    fn started_runtime_with_attention(max_queue: u32) -> KernelRuntime {
1751        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1752        runtime.step(KernelInput::new(KernelInputEvent::SetAttentionPolicy {
1753            max_queue_size: max_queue,
1754        }));
1755        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1756            task: RuntimeTask::new("watch for signals"),
1757            run_spec: None,
1758        }));
1759        runtime.state_machine_mut().take_observations();
1760        runtime
1761    }
1762
1763    #[test]
1764    fn attention_policy_critical_signal_interrupts() {
1765        use crate::types::signal::Urgency;
1766        let mut runtime = started_runtime_with_attention(8);
1767        let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
1768            signal: signal(Urgency::Critical, "fire"),
1769        }));
1770        assert!(
1771            matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
1772            "critical signal should drive a provider call, got {:?}",
1773            step.actions
1774        );
1775        assert!(step.observations.iter().any(|o| matches!(
1776            o,
1777            KernelObservation::SignalDisposed { disposition, .. } if disposition == "interrupt_now"
1778        )));
1779    }
1780
1781    #[test]
1782    fn attention_policy_normal_signal_queues_without_action() {
1783        use crate::types::signal::Urgency;
1784        let mut runtime = started_runtime_with_attention(8);
1785        let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
1786            signal: signal(Urgency::Normal, "job"),
1787        }));
1788        assert!(
1789            step.actions.is_empty(),
1790            "normal signal should queue without a provider call, got {:?}",
1791            step.actions
1792        );
1793        assert!(step.observations.iter().any(|o| matches!(
1794            o,
1795            KernelObservation::SignalDisposed { disposition, queue_depth, .. }
1796            if disposition == "queue" && *queue_depth == 1
1797        )));
1798    }
1799
1800    #[test]
1801    fn attention_policy_full_queue_drops() {
1802        use crate::types::signal::Urgency;
1803        let mut runtime = started_runtime_with_attention(1);
1804        runtime.step(KernelInput::new(KernelInputEvent::Signal {
1805            signal: signal(Urgency::Normal, "first"),
1806        }));
1807        let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
1808            signal: signal(Urgency::Normal, "second"),
1809        }));
1810        assert!(step.observations.iter().any(|o| matches!(
1811            o,
1812            KernelObservation::SignalDisposed { disposition, .. } if disposition == "dropped"
1813        )));
1814    }
1815
1816    #[test]
1817    #[test]
1818    fn page_in_populates_knowledge_partition() {
1819        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1820        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryEnabled {
1821            enabled: true,
1822        }));
1823        let before = runtime
1824            .state_machine()
1825            .ctx
1826            .partitions
1827            .knowledge
1828            .messages
1829            .len();
1830        runtime.step(KernelInput::new(KernelInputEvent::PageIn {
1831            entries: vec![crate::mm::PageInEntry {
1832                content: "[memory] prior fix".to_string(),
1833                tokens: Some(10),
1834                source: Some("memory".to_string()),
1835            }],
1836        }));
1837        let after = runtime
1838            .state_machine()
1839            .ctx
1840            .partitions
1841            .knowledge
1842            .messages
1843            .len();
1844        assert!(after > before, "page-in should add knowledge messages");
1845    }
1846
1847    #[test]
1848    fn memory_tool_emits_page_in_requested() {
1849        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1850        runtime.step(KernelInput::new(KernelInputEvent::SetMemoryEnabled {
1851            enabled: true,
1852        }));
1853        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1854            task: RuntimeTask::new("test"),
1855            run_spec: None,
1856        }));
1857        runtime.state_machine_mut().take_observations();
1858
1859        let step = run_with_tool_call(&mut runtime, "memory");
1860        assert!(step.observations.iter().any(|o| matches!(
1861            o,
1862            KernelObservation::PageInRequested { tool, .. } if tool == "memory"
1863        )));
1864    }
1865
1866    #[test]
1867    fn load_workflow_input_drives_dag_to_completion() {
1868        use crate::orchestration::workflow::fanout_synthesize;
1869        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
1870
1871        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1872        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1873            task: RuntimeTask::new("parent task"),
1874            run_spec: None,
1875        }));
1876        runtime.state_machine_mut().take_observations();
1877
1878        // Exercise the full serde round-trip of LoadWorkflow + WorkflowSpec over the ABI.
1879        let spec =
1880            fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
1881        let event = KernelInputEvent::LoadWorkflow {
1882            spec,
1883            parent_session_id: "sess".to_string(),
1884            resumed_completed: Vec::new(),
1885            resumed_submissions: Vec::new(),
1886        };
1887        let json = serde_json::to_string(&event).expect("serialize");
1888        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
1889
1890        let step = runtime.step(KernelInput::new(parsed));
1891        // First batch carries both workers' goals so the SDK can run them.
1892        let batch = step
1893            .observations
1894            .iter()
1895            .find_map(|o| match o {
1896                KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
1897                _ => None,
1898            })
1899            .expect("workflow_batch_spawned");
1900        assert_eq!(batch.len(), 2);
1901        let goals: Vec<&str> = batch.iter().map(|n| n.goal.as_str()).collect();
1902        assert!(goals.contains(&"w0") && goals.contains(&"w1"));
1903        assert_eq!(batch[0].agent_id, "wf-node0");
1904        assert_eq!(batch[0].isolation, "read_only"); // fanout workers are Explore → read_only
1905
1906        let complete = |runtime: &mut KernelRuntime, id: &str| {
1907            runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
1908                result: SubAgentResult {
1909                    agent_id: compact_str::CompactString::new(id),
1910                    result: LoopResult {
1911                        termination: TerminationReason::Completed,
1912                        final_message: None,
1913                        turns_used: 1,
1914                        total_tokens_used: 1,
1915                        loop_continue: None,
1916                        classify_branch: None,
1917                        tournament_winner: None,
1918                    },
1919                },
1920            }))
1921        };
1922
1923        complete(&mut runtime, "wf-node0");
1924        // After both workers, synth becomes the next batch.
1925        let step = complete(&mut runtime, "wf-node1");
1926        assert!(step.observations.iter().any(|o| matches!(
1927            o,
1928            KernelObservation::WorkflowBatchSpawned { nodes, .. }
1929                if nodes.len() == 1 && nodes[0].agent_id == "wf-node2"
1930        )));
1931
1932        // Synth completes → workflow finishes.
1933        let step = complete(&mut runtime, "wf-node2");
1934        assert!(step.observations.iter().any(|o| matches!(
1935            o,
1936            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 3
1937        )));
1938    }
1939
1940    #[test]
1941    fn submit_workflow_nodes_input_appends_a_node_over_the_abi() {
1942        // R3-1: exercise the full serde round-trip of SubmitWorkflowNodes + WorkflowNode over the
1943        // ABI, and confirm the appended node spawns as a workflow batch mid-run.
1944        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1945        use crate::types::agent::AgentRole;
1946        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
1947
1948        let mut runtime = KernelRuntime::new(LoopPolicy::default());
1949        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1950            task: RuntimeTask::new("parent task"),
1951            run_spec: None,
1952        }));
1953        runtime.state_machine_mut().take_observations();
1954
1955        // A single-node workflow: wf-node0 spawns first.
1956        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1957            RuntimeTask::new("root"),
1958            AgentRole::Implement,
1959        )]);
1960        runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
1961            spec,
1962            parent_session_id: "sess".to_string(),
1963            resumed_completed: Vec::new(),
1964            resumed_submissions: Vec::new(),
1965        }));
1966        runtime.state_machine_mut().take_observations();
1967
1968        // Submit a node over the ABI while wf-node0 runs (full serde round-trip).
1969        let event = KernelInputEvent::SubmitWorkflowNodes {
1970            nodes: vec![WorkflowNode::new(RuntimeTask::new("more"), AgentRole::Implement)],
1971            submitter_agent_id: None,
1972        };
1973        let json = serde_json::to_string(&event).expect("serialize");
1974        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
1975        let step = runtime.step(KernelInput::new(parsed));
1976        // The appended node spawns as wf-node1 in a workflow batch.
1977        assert!(step.observations.iter().any(|o| matches!(
1978            o,
1979            KernelObservation::WorkflowBatchSpawned { nodes, .. }
1980                if nodes.len() == 1 && nodes[0].agent_id == "wf-node1" && nodes[0].goal == "more"
1981        )));
1982
1983        let complete = |runtime: &mut KernelRuntime, id: &str| {
1984            runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
1985                result: SubAgentResult {
1986                    agent_id: compact_str::CompactString::new(id),
1987                    result: LoopResult {
1988                        termination: TerminationReason::Completed,
1989                        final_message: None,
1990                        turns_used: 1,
1991                        total_tokens_used: 1,
1992                        loop_continue: None,
1993                        classify_branch: None,
1994                        tournament_winner: None,
1995                    },
1996                },
1997            }))
1998        };
1999        complete(&mut runtime, "wf-node0");
2000        // The workflow finishes only after the submitted node also completes (2 nodes total).
2001        let step = complete(&mut runtime, "wf-node1");
2002        assert!(step.observations.iter().any(|o| matches!(
2003            o,
2004            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 2
2005        )));
2006    }
2007
2008    #[test]
2009    fn submit_workflow_input_bootstraps_a_dag_over_the_abi() {
2010        // M5/G1: a top-level agent authors a whole spec over the ABI (full serde round-trip of
2011        // SubmitWorkflow + WorkflowSpec) with no workflow active → the kernel bootstraps and drives it.
2012        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2013        use crate::types::agent::AgentRole;
2014        use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2015
2016        let mut runtime = KernelRuntime::new(LoopPolicy::default());
2017        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2018            task: RuntimeTask::new("parent task"),
2019            run_spec: None,
2020        }));
2021        runtime.state_machine_mut().take_observations();
2022
2023        // No LoadWorkflow first — the agent itself authors the spec.
2024        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
2025            RuntimeTask::new("authored root"),
2026            AgentRole::Implement,
2027        )]);
2028        let event = KernelInputEvent::SubmitWorkflow {
2029            spec,
2030            parent_session_id: "sess".to_string(),
2031            submitter_agent_id: None,
2032        };
2033        let json = serde_json::to_string(&event).expect("serialize");
2034        let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2035        let step = runtime.step(KernelInput::new(parsed));
2036        // The authored node bootstraps as wf-node0 in a workflow batch.
2037        assert!(step.observations.iter().any(|o| matches!(
2038            o,
2039            KernelObservation::WorkflowBatchSpawned { nodes, .. }
2040                if nodes.len() == 1 && nodes[0].agent_id == "wf-node0" && nodes[0].goal == "authored root"
2041        )));
2042
2043        let step = runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2044            result: SubAgentResult {
2045                agent_id: compact_str::CompactString::new("wf-node0"),
2046                result: LoopResult {
2047                    termination: TerminationReason::Completed,
2048                    final_message: None,
2049                    turns_used: 1,
2050                    total_tokens_used: 1,
2051                    loop_continue: None,
2052                    classify_branch: None,
2053                    tournament_winner: None,
2054                },
2055            },
2056        }));
2057        assert!(step.observations.iter().any(|o| matches!(
2058            o,
2059            KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 1
2060        )));
2061    }
2062
2063    #[test]
2064    fn load_workflow_resumes_from_completed_nodes() {
2065        use crate::orchestration::workflow::fanout_synthesize;
2066
2067        let mut runtime = KernelRuntime::new(LoopPolicy::default());
2068        runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2069            task: RuntimeTask::new("parent task"),
2070            run_spec: None,
2071        }));
2072        runtime.state_machine_mut().take_observations();
2073
2074        // Resume a 2-worker fanout where worker 0 already completed before the interruption.
2075        let spec =
2076            fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2077        let step = runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2078            spec,
2079            parent_session_id: "sess".to_string(),
2080            resumed_completed: vec!["wf-node0".to_string()],
2081            resumed_submissions: Vec::new(),
2082        }));
2083
2084        // Only the remaining worker is re-spawned (node 0 is not re-run).
2085        let batch = step
2086            .observations
2087            .iter()
2088            .find_map(|o| match o {
2089                KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2090                _ => None,
2091            })
2092            .expect("workflow_batch_spawned");
2093        assert_eq!(batch.len(), 1);
2094        assert_eq!(batch[0].agent_id, "wf-node1");
2095    }
2096}