Skip to main content

deepstrike_core/runtime/
kernel.rs

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