1use serde::{Deserialize, Serialize};
9
10use crate::context::pressure::PressureAction;
11use crate::context::renderer::RenderedContext;
12use crate::context::task_state::TaskUpdate;
13use crate::context::token_engine::ContextTokenEngine;
14use crate::runtime::session::RollbackReason;
15use crate::scheduler::policy::SchedulerBudget;
16use crate::scheduler::state_machine::{LoopAction, LoopEvent, LoopStateMachine};
17use crate::types::agent::AgentRunSpec;
18use crate::types::capability::{CapabilityCommand, CapabilityDescriptor, CapabilityKind};
19use crate::types::message::{Message, ToolCall, ToolResult, ToolSchema};
20use crate::types::milestone::{MilestoneCheckResult, MilestoneContract};
21use crate::types::result::{LoopResult, SubAgentResult};
22use crate::types::signal::RuntimeSignal;
23use crate::types::skill::SkillMetadata;
24use crate::types::task::RuntimeTask;
25
26pub const KERNEL_ABI_VERSION: u32 = 1;
27
28#[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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PolicyRule {
52 pub tool_pattern: String,
53 pub action: PolicyAction,
54}
55
56#[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#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum ConstraintSpec {
71 Required { tool: String, path: String },
73 Enum {
75 tool: String,
76 path: String,
77 values: Vec<String>,
78 },
79 Range {
81 tool: String,
82 path: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 min: Option<f64>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 max: Option<f64>,
87 },
88}
89
90fn default_signal_queue_size() -> u32 {
91 64
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct KernelInput {
96 pub version: u32,
97 pub event: KernelInputEvent,
98}
99
100impl KernelInput {
101 pub fn new(event: KernelInputEvent) -> Self {
102 Self {
103 version: KERNEL_ABI_VERSION,
104 event,
105 }
106 }
107}
108
109#[derive(Debug, Clone, 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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub group_tokens_base: Option<u64>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub group_spawns_base: Option<u32>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub group_rounds_base: Option<u32>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub repeat_fuse: Option<crate::governance::repeat_fuse::RepeatFuseConfig>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub criteria_gate: Option<bool>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub knowledge_budget_ratio: Option<f64>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub entropy_watch: Option<crate::scheduler::entropy::EntropyWatchConfig>,
183}
184
185pub(crate) fn build_governance_pipeline(
189 default_action: Option<PolicyAction>,
190 rules: Vec<PolicyRule>,
191 vetoed_tools: Vec<String>,
192 rate_limits: Vec<RateLimitSpec>,
193 constraints: Vec<ConstraintSpec>,
194) -> crate::governance::pipeline::GovernancePipeline {
195 use crate::governance::constraint::{ConstraintRule, ParamConstraint};
196 use crate::governance::permission::PermissionRule;
197 use crate::governance::rate_limit::RateLimit;
198 let default = default_action.unwrap_or(PolicyAction::Allow).into();
199 let mut pipeline = crate::governance::pipeline::GovernancePipeline::new(default);
200 for rule in rules {
201 pipeline.permission.add_rule(PermissionRule {
202 tool_pattern: rule.tool_pattern.into(),
203 action: rule.action.into(),
204 });
205 }
206 for tool in vetoed_tools {
207 pipeline.veto.block_tool(tool);
208 }
209 for rl in rate_limits {
210 pipeline.rate_limiter.set_limit(
211 rl.tool,
212 RateLimit {
213 max_calls: rl.max_calls,
214 window_ms: rl.window_ms,
215 },
216 );
217 }
218 for c in constraints {
219 let (tool_name, param_path, rule) = match c {
220 ConstraintSpec::Required { tool, path } => (tool, path, ConstraintRule::Required),
221 ConstraintSpec::Enum { tool, path, values } => (tool, path, ConstraintRule::Enum(values)),
222 ConstraintSpec::Range { tool, path, min, max } => {
223 (tool, path, ConstraintRule::Range { min, max })
224 }
225 };
226 pipeline.constraints.add(ParamConstraint {
227 tool_name,
228 param_path,
229 rule,
230 });
231 }
232 pipeline
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "kind", rename_all = "snake_case")]
237pub enum KernelInputEvent {
238 SetTools {
239 tools: Vec<ToolSchema>,
240 },
241 SetAvailableSkills {
242 skills: Vec<SkillMetadata>,
243 },
244 SkillActivated {
248 name: String,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
253 lease_turns: Option<u32>,
254 },
255 SkillDeactivated {
260 name: String,
261 },
262 SetStableCoreTools {
265 tool_ids: Vec<String>,
266 },
267 SetMemoryEnabled {
268 enabled: bool,
269 },
270 SetKnowledgeEnabled {
271 enabled: bool,
272 },
273 SetPlanToolEnabled {
274 enabled: bool,
275 },
276 SetTokenizer {
277 name: String,
278 },
279 AddSystemMessage {
280 content: String,
281 tokens: u32,
282 },
283 AddKnowledgeMessage {
284 content: String,
285 tokens: u32,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 key: Option<String>,
290 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
292 pinned: bool,
293 },
294 RemoveKnowledge {
297 key: String,
298 },
299 AddHistoryMessage {
300 message: Message,
301 tokens: Option<u32>,
302 },
303 PreloadHistory {
304 messages: Vec<Message>,
305 },
306 MountCapability {
307 capability: CapabilityDescriptor,
308 },
309 UnmountCapability {
310 capability_kind: CapabilityKind,
311 id: String,
312 },
313 LoadMilestoneContract {
314 contract: MilestoneContract,
315 },
316 LoadGovernancePolicy {
320 #[serde(default)]
321 default_action: Option<PolicyAction>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
323 rules: Vec<PolicyRule>,
324 #[serde(default, skip_serializing_if = "Vec::is_empty")]
325 vetoed_tools: Vec<String>,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
329 rate_limits: Vec<RateLimitSpec>,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 constraints: Vec<ConstraintSpec>,
332 },
333 SetAttentionPolicy {
336 #[serde(default = "default_signal_queue_size")]
337 max_queue_size: u32,
338 },
339 ForceCompact,
340 UpdateTask {
341 update: TaskUpdate,
342 },
343 StartRun {
344 task: RuntimeTask,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 run_spec: Option<AgentRunSpec>,
347 },
348 ConfigureRun {
353 config: RunConfig,
354 },
355 CapabilityCommand {
356 command: CapabilityCommand,
357 },
358 Resume {
359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 approved_calls: Vec<String>,
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 denied_calls: Vec<String>,
366 },
367 SetKnowledgeBudget {
370 ratio: f64,
371 },
372 SetCriteriaGate {
375 enabled: bool,
376 },
377 SetRepeatFuse {
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 enabled: Option<bool>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 deny_after: Option<u32>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 terminate_after: Option<u32>,
386 },
387 SetEntropyWatch {
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 enabled: Option<bool>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 threshold: Option<f64>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 hysteresis: Option<f64>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 cooldown_turns: Option<u32>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 notify_model: Option<bool>,
402 },
403 SetSchedulerBudget {
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 max_wall_ms: Option<u64>,
409 },
410 SetResourceQuota {
416 quota: crate::governance::quota::ResourceQuota,
417 },
418 ProviderResult {
419 message: Message,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 observed_input_tokens: Option<u32>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 observed_output_tokens: Option<u32>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
428 now_ms: Option<u64>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
433 stop_reason: Option<String>,
434 },
435 ToolResults {
436 results: Vec<ToolResult>,
437 },
438 ProviderError {
445 message: String,
446 },
447 Signal {
448 signal: RuntimeSignal,
449 },
450 MilestoneResult {
451 result: MilestoneCheckResult,
452 },
453 SpawnSubAgent {
455 spec: AgentRunSpec,
456 parent_session_id: String,
457 },
458 LoadWorkflow {
463 spec: crate::orchestration::workflow::WorkflowSpec,
464 parent_session_id: String,
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 resumed_completed: Vec<String>,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
472 resumed_submissions: Vec<Vec<crate::orchestration::workflow::WorkflowNode>>,
473 #[serde(default)]
476 resumed_submission_bases: Vec<u32>,
477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
482 resumed_results: Vec<crate::orchestration::workflow::ResumedCompletion>,
483 },
484 SubAgentCompleted {
486 result: SubAgentResult,
487 },
488 SubmitWorkflowNodes {
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
494 nodes: Vec<crate::orchestration::workflow::WorkflowNode>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
499 submitter_agent_id: Option<String>,
500 },
501 SubmitWorkflow {
507 spec: crate::orchestration::workflow::WorkflowSpec,
508 #[serde(default)]
510 parent_session_id: String,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
514 submitter_agent_id: Option<String>,
515 },
516 PageIn {
519 #[serde(default, skip_serializing_if = "Vec::is_empty")]
520 entries: Vec<crate::mm::PageInEntry>,
521 },
522 SetMemoryPolicy {
525 #[serde(default)]
526 memory_path: String,
527 #[serde(default = "default_stale_days")]
528 stale_warning_days: u32,
529 #[serde(default = "default_top_k")]
530 retrieval_top_k: usize,
531 #[serde(default = "default_validation_enabled")]
532 validation_enabled: bool,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 max_content_bytes: Option<u32>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 max_name_length: Option<usize>,
539 },
540 WriteMemory {
542 memory: crate::mm::memory::MemoryWriteRequest,
543 },
544 QueryMemory {
546 query: crate::mm::memory::MemoryQuery,
547 },
548 Timeout,
549}
550
551fn default_stale_days() -> u32 { 2 }
552fn default_top_k() -> usize { 5 }
553fn default_validation_enabled() -> bool { true }
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct KernelStep {
557 pub version: u32,
558 pub actions: Vec<KernelAction>,
559 pub observations: Vec<KernelObservation>,
560}
561
562impl KernelStep {
563 fn empty(observations: Vec<KernelObservation>) -> Self {
564 Self {
565 version: KERNEL_ABI_VERSION,
566 actions: Vec::new(),
567 observations,
568 }
569 }
570
571 fn single(action: LoopAction, observations: Vec<KernelObservation>) -> Self {
572 Self {
573 version: KERNEL_ABI_VERSION,
574 actions: vec![action.into()],
575 observations,
576 }
577 }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581#[serde(tag = "kind", rename_all = "snake_case")]
582pub enum KernelAction {
583 CallProvider {
584 context: RenderedContext,
585 tools: Vec<ToolSchema>,
586 },
587 ExecuteTool {
588 calls: Vec<ToolCall>,
589 },
590 EvaluateMilestone {
591 phase_id: String,
592 criteria: Vec<String>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
594 verifier: Option<crate::types::milestone::MilestoneVerifier>,
595 #[serde(default, skip_serializing_if = "Vec::is_empty")]
596 required_evidence: Vec<String>,
597 },
598 Done {
599 result: LoopResult,
600 },
601}
602
603impl From<LoopAction> for KernelAction {
604 fn from(action: LoopAction) -> Self {
605 match action {
606 LoopAction::AwaitingResume => {
607 panic!("AwaitingResume must not be converted to KernelAction")
608 }
609 LoopAction::CallLLM { context, tools } => Self::CallProvider { context, tools },
610 LoopAction::ExecuteTools { calls } => Self::ExecuteTool { calls },
611 LoopAction::EvaluateMilestone {
612 phase_id,
613 criteria,
614 verifier,
615 required_evidence,
616 } => Self::EvaluateMilestone {
617 phase_id,
618 criteria,
619 verifier,
620 required_evidence,
621 },
622 LoopAction::Done { result } => Self::Done { result },
623 }
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628#[serde(tag = "kind", rename_all = "snake_case")]
629pub enum KernelObservation {
630 Compressed {
634 #[serde(default)]
635 turn: u32,
636 action: KernelPressureAction,
637 rho_after: f64,
638 summary: Option<String>,
639 archived: Vec<Message>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 tier_hint: Option<String>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
646 invalidates_prefix_at: Option<usize>,
647 },
648 Renewed {
649 sprint: u32,
650 },
651 KnowledgeSwept {
655 turn: u32,
656 #[serde(default, skip_serializing_if = "Vec::is_empty")]
657 removed_keys: Vec<String>,
658 tokens_freed: u32,
659 },
660 KnowledgeBudgetExceeded {
664 turn: u32,
665 used: u32,
666 budget: u32,
667 },
668 Rollbacked {
669 turn: u32,
670 checkpoint_history_len: u32,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
672 reason: Option<RollbackReason>,
673 },
674 CapabilityChanged {
675 turn: u32,
676 #[serde(default, skip_serializing_if = "Vec::is_empty")]
677 added: Vec<String>,
678 #[serde(default, skip_serializing_if = "Vec::is_empty")]
679 removed: Vec<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 change_kind: Option<String>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 capability_id: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 version: Option<String>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
687 mounted_by: Option<String>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
689 mount_reason: Option<String>,
690 },
691 MilestoneAdvanced {
692 turn: u32,
693 phase_id: String,
694 capabilities_unlocked: Vec<String>,
695 },
696 MilestoneBlocked {
697 turn: u32,
698 phase_id: String,
699 reason: String,
700 },
701 CheckpointTaken {
703 turn: u32,
704 history_len: u32,
705 },
706 RepeatFuseTripped {
710 turn: u32,
711 signature: String,
712 count: u32,
713 action: String,
714 },
715 CriteriaGateFired {
718 turn: u32,
719 criteria: Vec<String>,
720 },
721 EntropySample {
726 turn: u32,
727 score: f64,
728 score_version: u32,
729 rho: f64,
730 repeat_pressure: f64,
731 failure_rate: f64,
732 rollbacks_in_window: u32,
733 window_turns: u32,
734 },
735 EntropyAlert {
739 turn: u32,
740 score: f64,
741 threshold: f64,
742 },
743 AgentProcessChanged {
745 turn: u32,
746 agent_id: String,
747 parent_session_id: String,
748 role: String,
749 isolation: String,
750 context_inheritance: String,
751 state: String,
752 #[serde(default, skip_serializing_if = "Vec::is_empty")]
753 permitted_capability_ids: Vec<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
755 result_termination: Option<String>,
756 },
757 WorkflowBatchSpawned {
760 turn: u32,
761 nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
766 budget: Option<crate::orchestration::workflow::WorkflowBudget>,
767 },
768 WorkflowCompleted {
770 turn: u32,
771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
772 completed: Vec<String>,
773 #[serde(default, skip_serializing_if = "Vec::is_empty")]
774 failed: Vec<String>,
775 },
776 AgentPreempted {
782 turn: u32,
783 #[serde(default, skip_serializing_if = "Vec::is_empty")]
784 agent_ids: Vec<String>,
785 reason: String,
786 },
787 RoundPaced {
789 turn: u32,
790 round: u32,
791 decision: crate::types::result::PaceDecision,
792 },
793 WorkflowNodesSubmitted {
798 turn: u32,
799 base: u32,
800 count: u32,
801 #[serde(default, skip_serializing_if = "Option::is_none")]
804 submitter: Option<String>,
805 },
806 ToolGated {
809 turn: u32,
810 call_id: String,
811 tool: String,
812 reason: String,
813 },
814 SignalDisposed {
816 turn: u32,
817 signal_id: String,
818 disposition: String,
819 queue_depth: u32,
820 },
821 BudgetExceeded { turn: u32, budget: String },
823 Suspended {
825 turn: u32,
826 reason: String,
827 #[serde(default, skip_serializing_if = "Vec::is_empty")]
828 pending_calls: Vec<String>,
829 },
830 Resumed {
832 turn: u32,
833 #[serde(default, skip_serializing_if = "Vec::is_empty")]
834 approved: Vec<String>,
835 #[serde(default, skip_serializing_if = "Vec::is_empty")]
836 denied: Vec<String>,
837 },
838 MemoryWritten {
840 turn: u32,
841 memory_id: String,
842 memory_kind: String,
843 size_bytes: u32,
844 },
845 MemoryValidationFailed {
847 turn: u32,
848 memory_id: String,
849 error: String,
850 },
851 MemoryQueried {
853 turn: u32,
854 query_context: String,
855 requested_k: usize,
856 requires_async_response: bool,
857 },
858 LargeResultSpooled {
860 turn: u32,
861 call_id: String,
862 tool: String,
863 original_size: u32,
864 preview_size: u32,
865 spool_ref: Option<String>,
866 },
867}
868
869#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
870#[serde(rename_all = "snake_case")]
871pub enum KernelPressureAction {
872 None,
873 SnipCompact,
874 MicroCompact,
875 ContextCollapse,
876 AutoCompact,
877}
878
879impl From<PressureAction> for KernelPressureAction {
880 fn from(action: PressureAction) -> Self {
881 match action {
882 PressureAction::None => Self::None,
883 PressureAction::SnipCompact => Self::SnipCompact,
884 PressureAction::MicroCompact => Self::MicroCompact,
885 PressureAction::ContextCollapse => Self::ContextCollapse,
886 PressureAction::AutoCompact => Self::AutoCompact,
887 }
888 }
889}
890
891pub struct KernelRuntime {
894 sm: LoopStateMachine,
895}
896
897impl KernelRuntime {
898 pub fn new(policy: SchedulerBudget) -> Self {
899 Self {
900 sm: LoopStateMachine::new(policy),
901 }
902 }
903
904 pub fn state_machine(&self) -> &LoopStateMachine {
905 &self.sm
906 }
907
908 pub fn state_machine_mut(&mut self) -> &mut LoopStateMachine {
909 &mut self.sm
910 }
911
912 pub fn is_terminal(&self) -> bool {
913 self.sm.is_terminal()
914 }
915
916 pub fn local_subagents_spawned(&self) -> u32 {
919 self.sm.local_subagents_spawned()
920 }
921
922 pub fn step(&mut self, input: KernelInput) -> KernelStep {
923 if input.version != KERNEL_ABI_VERSION {
927 self.sm.observations.push(KernelObservation::ToolGated {
928 turn: self.sm.turn,
929 call_id: String::new(),
930 tool: "kernel_abi".to_string(),
931 reason: format!(
932 "kernel ABI version mismatch: input v{}, kernel v{}",
933 input.version, KERNEL_ABI_VERSION
934 ),
935 });
936 return KernelStep::empty(self.sm.take_observations());
937 }
938 let action = match input.event {
939 KernelInputEvent::SetTools { tools } => {
940 self.sm.tools = tools;
941 return KernelStep::empty(self.sm.take_observations());
942 }
943 KernelInputEvent::SetAvailableSkills { skills } => {
944 self.sm.ctx.set_available_skills(skills);
945 return KernelStep::empty(self.sm.take_observations());
946 }
947 KernelInputEvent::SkillActivated { name, lease_turns } => {
948 let expires_at_turn = lease_turns.map(|n| self.sm.turn.saturating_add(n));
952 self.sm.ctx.activate_skill_leased(name, expires_at_turn);
953 return KernelStep::empty(self.sm.take_observations());
954 }
955 KernelInputEvent::SkillDeactivated { name } => {
956 self.sm.ctx.deactivate_skill(&name);
957 return KernelStep::empty(self.sm.take_observations());
958 }
959 KernelInputEvent::SetStableCoreTools { tool_ids } => {
960 self.sm.ctx.set_stable_core_tools(tool_ids.into_iter().map(Into::into));
961 return KernelStep::empty(self.sm.take_observations());
962 }
963 KernelInputEvent::SetMemoryEnabled { enabled } => {
964 self.sm.ctx.set_memory_enabled(enabled);
965 return KernelStep::empty(self.sm.take_observations());
966 }
967 KernelInputEvent::SetKnowledgeEnabled { enabled } => {
968 self.sm.ctx.set_knowledge_enabled(enabled);
969 return KernelStep::empty(self.sm.take_observations());
970 }
971 KernelInputEvent::SetPlanToolEnabled { enabled } => {
972 self.sm.ctx.set_plan_tool_enabled(enabled);
973 return KernelStep::empty(self.sm.take_observations());
974 }
975 KernelInputEvent::SetTokenizer { .. } => {
976 self.sm.ctx.engine = ContextTokenEngine::char_approx();
980 return KernelStep::empty(self.sm.take_observations());
981 }
982 KernelInputEvent::AddSystemMessage { content, tokens } => {
983 self.sm
984 .ctx
985 .partitions
986 .system
987 .push(Message::system(content), tokens.max(1));
988 return KernelStep::empty(self.sm.take_observations());
989 }
990 KernelInputEvent::AddKnowledgeMessage { content, tokens, key, pinned } => {
991 self.sm.ctx.push_knowledge_entry(
1004 key.map(compact_str::CompactString::from),
1005 Message::system(content),
1006 tokens.max(1),
1007 pinned,
1008 );
1009 return KernelStep::empty(self.sm.take_observations());
1010 }
1011 KernelInputEvent::RemoveKnowledge { key } => {
1012 self.sm.ctx.remove_knowledge(&key);
1013 return KernelStep::empty(self.sm.take_observations());
1014 }
1015 KernelInputEvent::AddHistoryMessage { message, tokens } => {
1016 let tokens = tokens.unwrap_or_else(|| self.sm.ctx.engine.count_message(&message));
1017 self.sm.ctx.push_history(message, tokens.max(1));
1018 return KernelStep::empty(self.sm.take_observations());
1019 }
1020 KernelInputEvent::PreloadHistory { messages } => {
1021 self.sm.preload_history(messages);
1022 return KernelStep::empty(self.sm.take_observations());
1023 }
1024 KernelInputEvent::MountCapability { capability } => {
1025 self.sm.mount_capability(capability, None, None);
1026 return KernelStep::empty(self.sm.take_observations());
1027 }
1028 KernelInputEvent::UnmountCapability {
1029 capability_kind,
1030 id,
1031 } => {
1032 self.sm.unmount_capability(capability_kind, &id);
1033 return KernelStep::empty(self.sm.take_observations());
1034 }
1035 KernelInputEvent::LoadMilestoneContract { contract } => {
1036 self.sm.load_milestone_contract(contract);
1037 return KernelStep::empty(self.sm.take_observations());
1038 }
1039 KernelInputEvent::LoadGovernancePolicy {
1040 default_action,
1041 rules,
1042 vetoed_tools,
1043 rate_limits,
1044 constraints,
1045 } => {
1046 self.sm.set_governance(build_governance_pipeline(
1047 default_action,
1048 rules,
1049 vetoed_tools,
1050 rate_limits,
1051 constraints,
1052 ));
1053 return KernelStep::empty(self.sm.take_observations());
1054 }
1055 KernelInputEvent::ConfigureRun { config } => {
1056 let RunConfig {
1063 tools,
1064 available_skills,
1065 stable_core_tools,
1066 memory_enabled,
1067 knowledge_enabled,
1068 plan_tool_enabled,
1069 tokenizer,
1070 governance,
1071 attention_max_queue_size,
1072 scheduler_max_wall_ms,
1073 resource_quota,
1074 group_tokens_base,
1075 group_spawns_base,
1076 group_rounds_base,
1077 repeat_fuse,
1078 criteria_gate,
1079 knowledge_budget_ratio,
1080 entropy_watch,
1081 } = config;
1082 if let Some(tools) = tools {
1083 self.sm.tools = tools;
1084 }
1085 if let Some(skills) = available_skills {
1086 self.sm.ctx.set_available_skills(skills);
1087 }
1088 if let Some(ids) = stable_core_tools {
1089 self.sm.ctx.set_stable_core_tools(ids.into_iter().map(Into::into));
1090 }
1091 if let Some(enabled) = memory_enabled {
1092 self.sm.ctx.set_memory_enabled(enabled);
1093 }
1094 if let Some(enabled) = knowledge_enabled {
1095 self.sm.ctx.set_knowledge_enabled(enabled);
1096 }
1097 if let Some(enabled) = plan_tool_enabled {
1098 self.sm.ctx.set_plan_tool_enabled(enabled);
1099 }
1100 if tokenizer.is_some() {
1101 self.sm.ctx.engine = ContextTokenEngine::char_approx();
1102 }
1103 if let Some(g) = governance {
1104 self.sm.set_governance(build_governance_pipeline(
1105 g.default_action,
1106 g.rules,
1107 g.vetoed_tools,
1108 g.rate_limits,
1109 g.constraints,
1110 ));
1111 }
1112 if let Some(max_queue) = attention_max_queue_size {
1113 self.sm.set_attention(max_queue as usize);
1114 }
1115 if let Some(ms) = scheduler_max_wall_ms {
1116 self.sm.set_wall_budget(Some(ms));
1117 }
1118 if let Some(quota) = resource_quota {
1119 self.sm.set_resource_quota(quota);
1120 }
1121 if let Some(base) = group_tokens_base {
1122 self.sm.seed_group_budget(base);
1123 }
1124 if let Some(base) = group_spawns_base {
1125 self.sm.seed_group_spawns(base);
1126 }
1127 if let Some(base) = group_rounds_base {
1128 self.sm.seed_group_rounds(base);
1129 }
1130 if let Some(fuse) = repeat_fuse {
1131 self.sm.set_repeat_fuse(fuse);
1132 }
1133 if let Some(enabled) = criteria_gate {
1134 self.sm.set_criteria_gate(enabled);
1135 }
1136 if let Some(ratio) = knowledge_budget_ratio {
1137 self.sm.ctx.config.knowledge_budget_ratio = ratio;
1138 }
1139 if let Some(watch) = entropy_watch {
1140 self.sm.set_entropy_watch(watch);
1141 }
1142 return KernelStep::empty(self.sm.take_observations());
1143 }
1144 KernelInputEvent::SetAttentionPolicy { max_queue_size } => {
1145 self.sm.set_attention(max_queue_size as usize);
1146 return KernelStep::empty(self.sm.take_observations());
1147 }
1148 KernelInputEvent::PageIn { entries } => {
1149 self.sm.apply_page_in(&entries);
1150 return KernelStep::empty(self.sm.take_observations());
1151 }
1152 KernelInputEvent::ForceCompact => {
1153 self.sm.force_compact();
1154 return KernelStep::empty(self.sm.take_observations());
1155 }
1156 KernelInputEvent::UpdateTask { update } => {
1157 self.sm.ctx.update_task(update);
1158 return KernelStep::empty(self.sm.take_observations());
1159 }
1160 KernelInputEvent::StartRun { task, run_spec } => {
1161 self.sm.run_spec = run_spec;
1162 self.sm.start(task)
1163 }
1164 KernelInputEvent::CapabilityCommand { command } => {
1165 self.sm.execute_capability_command(command);
1166 return KernelStep::empty(self.sm.take_observations());
1167 }
1168 KernelInputEvent::Resume { approved_calls, denied_calls } => {
1169 self.sm.resume_from_suspend(approved_calls, denied_calls)
1170 }
1171 KernelInputEvent::SetSchedulerBudget { max_wall_ms } => {
1172 self.sm.set_wall_budget(max_wall_ms);
1173 return KernelStep::empty(self.sm.take_observations());
1174 }
1175 KernelInputEvent::SetResourceQuota { quota } => {
1176 self.sm.set_resource_quota(quota);
1177 return KernelStep::empty(self.sm.take_observations());
1178 }
1179 KernelInputEvent::SetCriteriaGate { enabled } => {
1180 self.sm.set_criteria_gate(enabled);
1181 return KernelStep::empty(self.sm.take_observations());
1182 }
1183 KernelInputEvent::SetKnowledgeBudget { ratio } => {
1184 self.sm.ctx.config.knowledge_budget_ratio = ratio;
1185 return KernelStep::empty(self.sm.take_observations());
1186 }
1187 KernelInputEvent::SetRepeatFuse { enabled, deny_after, terminate_after } => {
1188 let mut cfg = self.sm.repeat_fuse_config();
1189 if let Some(e) = enabled { cfg.enabled = e; }
1190 if let Some(d) = deny_after { cfg.deny_after = d; }
1191 if let Some(t) = terminate_after { cfg.terminate_after = t; }
1192 self.sm.set_repeat_fuse(cfg);
1193 return KernelStep::empty(self.sm.take_observations());
1194 }
1195 KernelInputEvent::SetEntropyWatch { enabled, threshold, hysteresis, cooldown_turns, notify_model } => {
1196 let mut cfg = self.sm.entropy_watch_config();
1197 if let Some(e) = enabled { cfg.enabled = e; }
1198 if let Some(t) = threshold { cfg.threshold = t; }
1199 if let Some(h) = hysteresis { cfg.hysteresis = h; }
1200 if let Some(c) = cooldown_turns { cfg.cooldown_turns = c; }
1201 if let Some(n) = notify_model { cfg.notify_model = n; }
1202 self.sm.set_entropy_watch(cfg);
1203 return KernelStep::empty(self.sm.take_observations());
1204 }
1205 KernelInputEvent::ProviderResult {
1206 message,
1207 observed_input_tokens,
1208 observed_output_tokens: _,
1209 now_ms,
1210 stop_reason,
1211 } => {
1212 if let Some(tokens) = observed_input_tokens {
1213 self.sm.ctx.set_observed_prompt_tokens(tokens);
1214 }
1215 if let Some(ms) = now_ms {
1218 self.sm.set_observed_time(ms);
1219 }
1220 self.sm.set_pending_stop_reason(stop_reason);
1222 self.sm.feed(LoopEvent::LLMResponse { message })
1223 }
1224 KernelInputEvent::ToolResults { results } => {
1225 self.sm.feed(LoopEvent::ToolResults { results })
1226 }
1227 KernelInputEvent::ProviderError { message } => {
1228 self.sm.recover_from_provider_error(&message)
1231 }
1232 KernelInputEvent::Signal { signal } => match self.sm.signal_event(signal) {
1233 Some(action) => action,
1234 None => return KernelStep::empty(self.sm.take_observations()),
1237 },
1238 KernelInputEvent::MilestoneResult { result } => {
1239 self.sm.feed(LoopEvent::MilestoneResult { result })
1240 }
1241 KernelInputEvent::SpawnSubAgent {
1242 spec,
1243 parent_session_id,
1244 } => {
1245 self.sm.spawn_sub_agent(spec, &parent_session_id)
1246 }
1247 KernelInputEvent::LoadWorkflow {
1248 spec,
1249 parent_session_id,
1250 resumed_completed,
1251 resumed_submissions,
1252 resumed_submission_bases,
1253 resumed_results,
1254 } => {
1255 self.sm.ensure_started_for_workflow(&spec);
1259 if resumed_completed.is_empty()
1260 && resumed_results.is_empty()
1261 && resumed_submissions.is_empty()
1262 {
1263 self.sm.load_workflow(spec, &parent_session_id)
1264 } else {
1265 use crate::orchestration::workflow::ResumedCompletion;
1267 let mut completed: Vec<ResumedCompletion> = resumed_completed
1268 .iter()
1269 .filter(|id| resumed_results.iter().all(|r| &r.agent_id != *id))
1270 .map(ResumedCompletion::bare)
1271 .collect();
1272 completed.extend(resumed_results);
1273 self.sm.load_workflow_resumed(
1274 spec,
1275 &parent_session_id,
1276 &resumed_submissions,
1277 &resumed_submission_bases,
1278 &completed,
1279 )
1280 }
1281 }
1282 KernelInputEvent::SubAgentCompleted { result } => {
1283 self.sm.feed(LoopEvent::SubAgentCompleted { result })
1284 }
1285 KernelInputEvent::SubmitWorkflowNodes {
1286 nodes,
1287 submitter_agent_id,
1288 } => {
1289 self.sm
1290 .submit_workflow_nodes(nodes, submitter_agent_id.as_deref())
1291 }
1292 KernelInputEvent::SubmitWorkflow {
1293 spec,
1294 parent_session_id,
1295 submitter_agent_id,
1296 } => {
1297 self.sm.submit_workflow(
1298 spec,
1299 &parent_session_id,
1300 submitter_agent_id.as_deref(),
1301 )
1302 }
1303 KernelInputEvent::SetMemoryPolicy {
1304 memory_path,
1305 stale_warning_days,
1306 retrieval_top_k,
1307 validation_enabled,
1308 max_content_bytes,
1309 max_name_length,
1310 } => {
1311 self.sm.set_memory_policy(crate::mm::memory::MemoryPolicy {
1315 memory_path,
1316 stale_warning_days,
1317 retrieval_top_k,
1318 validation_enabled,
1319 max_content_bytes,
1320 max_name_length,
1321 });
1322 return KernelStep::empty(self.sm.take_observations());
1323 }
1324 KernelInputEvent::WriteMemory { memory } => {
1325 use crate::mm::memory::validate_memory_write;
1328 let turn = self.sm.turn;
1329 let disposition = self
1333 .sm
1334 .gate_syscall(&crate::syscall::Syscall::WriteMemory(memory.clone()));
1335 if !disposition.is_allowed() {
1336 let error = match disposition {
1337 crate::syscall::Disposition::RateLimited { retry_after_ms } => {
1338 format!("memory write rate limited; retry after {retry_after_ms}ms")
1339 }
1340 crate::syscall::Disposition::Deny { reason, .. } => {
1341 format!("memory write denied: {reason}")
1342 }
1343 _ => "memory write not permitted".to_string(),
1344 };
1345 self.sm.observations.push(
1346 KernelObservation::MemoryValidationFailed {
1347 turn,
1348 memory_id: memory.metadata.name.clone(),
1349 error,
1350 },
1351 );
1352 return KernelStep::empty(self.sm.take_observations());
1353 }
1354 let validation_result = match self.sm.memory_policy() {
1358 Some(p) if !p.validation_enabled => Ok(()),
1359 Some(p) => p.validation().validate(&memory),
1360 None => validate_memory_write(&memory),
1361 };
1362 match validation_result {
1363 Ok(()) => {
1364 self.sm.observations.push(KernelObservation::MemoryWritten {
1366 turn,
1367 memory_id: memory.metadata.name.clone(),
1368 memory_kind: memory
1371 .metadata
1372 .kind
1373 .map(|k| k.label())
1374 .unwrap_or("unclassified")
1375 .to_string(),
1376 size_bytes: memory.content.len() as u32,
1377 });
1378 }
1379 Err(err) => {
1380 use crate::mm::memory::MemoryValidationError;
1382 let error_msg = match err {
1383 MemoryValidationError::MissingRequiredField { field } => format!("Missing required field: {}", field),
1384 MemoryValidationError::ContentTooLarge { size, limit } => format!("Content too large: {} bytes (limit: {})", size, limit),
1385 MemoryValidationError::ForbiddenPattern { pattern, reason } => format!("Forbidden pattern '{}': {}", pattern, reason),
1386 MemoryValidationError::InvalidKind { kind } => format!("Invalid kind: {}", kind),
1387 MemoryValidationError::NameTooLong { length, limit } => format!("Name too long: {} chars (limit: {})", length, limit),
1388 };
1389 self.sm.observations.push(KernelObservation::MemoryValidationFailed {
1390 turn,
1391 memory_id: memory.metadata.name.clone(),
1392 error: error_msg,
1393 });
1394 }
1395 }
1396 return KernelStep::empty(self.sm.take_observations());
1397 }
1398 KernelInputEvent::QueryMemory { query } => {
1399 let turn = self.sm.turn;
1402 let requested_k = match self.sm.memory_policy() {
1404 Some(p) => p.clamp_top_k(query.top_k),
1405 None => query.top_k,
1406 };
1407 self.sm.observations.push(KernelObservation::MemoryQueried {
1408 turn,
1409 query_context: query.current_context.clone(),
1410 requested_k,
1411 requires_async_response: true,
1412 });
1413 return KernelStep::empty(self.sm.take_observations());
1414 }
1415 KernelInputEvent::Timeout => self.sm.feed(LoopEvent::Timeout),
1416 };
1417 if matches!(action, LoopAction::AwaitingResume) {
1418 return KernelStep::empty(self.sm.take_observations());
1419 }
1420 KernelStep::single(action, self.sm.take_observations())
1421 }
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427
1428 #[test]
1429 fn start_run_returns_versioned_provider_action() {
1430 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1431 let step = runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1432 task: RuntimeTask::new("ship it"),
1433 run_spec: None,
1434 }));
1435
1436 assert_eq!(step.version, KERNEL_ABI_VERSION);
1437 assert!(matches!(
1438 step.actions.as_slice(),
1439 [KernelAction::CallProvider { .. }]
1440 ));
1441 }
1442
1443 #[test]
1444 fn provider_text_response_returns_done() {
1445 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1446 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1447 task: RuntimeTask::new("ship it"),
1448 run_spec: None,
1449 }));
1450 let step = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1451 message: Message::assistant("done"),
1452 observed_input_tokens: None,
1453 observed_output_tokens: None,
1454 stop_reason: None,
1455 now_ms: None,
1456 }));
1457
1458 assert!(matches!(
1459 step.actions.as_slice(),
1460 [KernelAction::Done { .. }]
1461 ));
1462 }
1463
1464 #[test]
1465 fn config_inputs_mutate_runtime_without_actions() {
1466 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1467 let step = runtime.step(KernelInput::new(KernelInputEvent::SetTools {
1468 tools: vec![ToolSchema {
1469 name: "echo".into(),
1470 description: "Echo input".to_string(),
1471 parameters: serde_json::json!({"type": "object"}),
1472 }],
1473 }));
1474
1475 assert!(step.actions.is_empty());
1476 assert_eq!(runtime.state_machine().tools.len(), 1);
1477 }
1478
1479 #[test]
1480 fn skill_activated_input_records_active_skill() {
1481 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1484 let mut debug = SkillMetadata::new("debug", "Debug helper");
1485 debug.allowed_tools = vec!["read".into(), "grep".into()];
1486 runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1487 skills: vec![debug],
1488 }));
1489
1490 let step = runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1491 name: "debug".to_string(),
1492 lease_turns: None,
1493 }));
1494
1495 assert!(step.actions.is_empty(), "activation is config, not an action");
1496 assert!(runtime.state_machine().ctx.active_skills.contains_key("debug"));
1497 let filter = runtime.state_machine().ctx.active_skill_tool_filter().unwrap();
1498 assert_eq!(filter.len(), 2);
1499 }
1500
1501 #[test]
1502 fn skill_deactivated_rewidens_toolset_and_unpins_knowledge() {
1503 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1506 let mut debug = SkillMetadata::new("debug", "Debug helper");
1507 debug.allowed_tools = vec!["read".into(), "grep".into()];
1508 runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1509 skills: vec![debug],
1510 }));
1511 runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1512 name: "debug".to_string(),
1513 lease_turns: None,
1514 }));
1515 runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1516 content: "debug skill content".to_string(),
1517 tokens: 5,
1518 key: Some("skill:debug".to_string()),
1519 pinned: false,
1520 }));
1521 assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_some());
1522
1523 runtime.step(KernelInput::new(KernelInputEvent::SkillDeactivated {
1524 name: "debug".to_string(),
1525 }));
1526 let sm = runtime.state_machine();
1527 assert!(!sm.ctx.active_skills.contains_key("debug"));
1528 assert!(sm.ctx.active_skill_tool_filter().is_none(), "toolset re-widens");
1529 assert!(
1530 sm.ctx.partitions.knowledge.entries[0].evict_at_boundary,
1531 "knowledge pin marked for the boundary sweep"
1532 );
1533 }
1534
1535 #[test]
1536 fn skill_lease_expires_after_turns_and_reactivation_renarrows() {
1537 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1541 let mut debug = SkillMetadata::new("debug", "Debug helper");
1542 debug.allowed_tools = vec!["read".into(), "grep".into()];
1543 runtime.step(KernelInput::new(KernelInputEvent::SetAvailableSkills {
1544 skills: vec![debug],
1545 }));
1546 runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1547 name: "debug".to_string(),
1548 lease_turns: Some(1),
1549 }));
1550 assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_some());
1551
1552 run_with_tool_call(&mut runtime, "read");
1555 runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1556 results: vec![ToolResult {
1557 call_id: "call-1".into(),
1558 output: crate::types::message::Content::Text("ok".into()),
1559 is_error: false,
1560 is_fatal: false,
1561 error_kind: None,
1562 token_count: None,
1563 }],
1564 }));
1565 runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1566 message: Message::assistant("done"),
1567 observed_input_tokens: None,
1568 observed_output_tokens: None,
1569 stop_reason: None,
1570 now_ms: None,
1571 }));
1572 assert!(
1573 !runtime.state_machine().ctx.active_skills.contains_key("debug"),
1574 "lease expired after the turn advanced"
1575 );
1576 assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_none());
1577
1578 runtime.step(KernelInput::new(KernelInputEvent::SkillActivated {
1580 name: "debug".to_string(),
1581 lease_turns: None,
1582 }));
1583 assert!(runtime.state_machine().ctx.active_skill_tool_filter().is_some());
1584 }
1585
1586 #[test]
1587 fn update_task_input_mutates_task_state() {
1588 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1589 let step = runtime.step(KernelInput::new(KernelInputEvent::UpdateTask {
1590 update: TaskUpdate {
1591 progress: Some("tools executed".to_string()),
1592 ..Default::default()
1593 },
1594 }));
1595
1596 assert!(step.actions.is_empty());
1597 assert_eq!(
1598 runtime.state_machine().ctx.partitions.task_state.progress,
1599 "tools executed"
1600 );
1601 }
1602
1603 #[test]
1604 fn add_knowledge_message_enters_knowledge_partition() {
1605 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1606 let step = runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1607 content: "skill: debug".to_string(),
1608 tokens: 10,
1609 key: None,
1610 pinned: false,
1611 }));
1612
1613 assert!(step.actions.is_empty());
1614 assert_eq!(
1615 runtime.state_machine().ctx.partitions.knowledge.len(),
1616 1
1617 );
1618 }
1619
1620 #[test]
1621 fn knowledge_budget_exceeded_observed_in_live_loop() {
1622 let mut runtime = KernelRuntime::new(SchedulerBudget {
1626 max_tokens: 100,
1627 ..SchedulerBudget::default()
1628 });
1629 runtime.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1630 content: "reference".to_string(),
1631 tokens: 40,
1632 key: None,
1633 pinned: false,
1634 }));
1635 run_with_tool_call(&mut runtime, "search");
1636 let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1638 results: vec![ToolResult {
1639 call_id: "call-1".into(),
1640 output: crate::types::message::Content::Text("ok".into()),
1641 is_error: false,
1642 is_fatal: false,
1643 error_kind: None,
1644 token_count: None,
1645 }],
1646 }));
1647 assert!(step.observations.iter().any(|o| matches!(
1648 o,
1649 KernelObservation::KnowledgeBudgetExceeded { used: 40, budget: 25, .. }
1650 )));
1651
1652 let mut runtime2 = KernelRuntime::new(SchedulerBudget {
1654 max_tokens: 100,
1655 ..SchedulerBudget::default()
1656 });
1657 runtime2.step(KernelInput::new(KernelInputEvent::SetKnowledgeBudget { ratio: 0.9 }));
1658 runtime2.step(KernelInput::new(KernelInputEvent::AddKnowledgeMessage {
1659 content: "reference".to_string(),
1660 tokens: 40,
1661 key: None,
1662 pinned: false,
1663 }));
1664 run_with_tool_call(&mut runtime2, "search");
1665 let step2 = runtime2.step(KernelInput::new(KernelInputEvent::ToolResults {
1666 results: vec![ToolResult {
1667 call_id: "call-1".into(),
1668 output: crate::types::message::Content::Text("ok".into()),
1669 is_error: false,
1670 is_fatal: false,
1671 error_kind: None,
1672 token_count: None,
1673 }],
1674 }));
1675 assert!(!step2.observations.iter().any(|o| matches!(
1676 o,
1677 KernelObservation::KnowledgeBudgetExceeded { .. }
1678 )));
1679 }
1680
1681 #[test]
1682 fn keyed_add_knowledge_dedupes_and_remove_marks() {
1683 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1687 for content in ["v1", "v2"] {
1688 let ev: KernelInputEvent = serde_json::from_value(serde_json::json!({
1689 "kind": "add_knowledge_message",
1690 "content": content,
1691 "tokens": 5,
1692 "key": "skill:debug",
1693 }))
1694 .unwrap();
1695 runtime.step(KernelInput::new(ev));
1696 }
1697 let knowledge = &runtime.state_machine().ctx.partitions.knowledge;
1698 assert_eq!(knowledge.len(), 1);
1699 assert_eq!(
1700 knowledge.messages().next().and_then(|m| m.content.as_text()),
1701 Some("v1"),
1702 "upsert deferred to boundary — original bytes still rendered"
1703 );
1704
1705 let ev: KernelInputEvent = serde_json::from_value(serde_json::json!({
1706 "kind": "remove_knowledge",
1707 "key": "skill:debug",
1708 }))
1709 .unwrap();
1710 runtime.step(KernelInput::new(ev));
1711 assert!(runtime.state_machine().ctx.partitions.knowledge.entries[0].evict_at_boundary);
1712 }
1713
1714 #[test]
1715 fn capability_mount_emits_observation() {
1716 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1717 let step = runtime.step(KernelInput::new(KernelInputEvent::MountCapability {
1718 capability: CapabilityDescriptor::marker(
1719 CapabilityKind::McpServer,
1720 "docs",
1721 "Documentation server",
1722 ),
1723 }));
1724
1725 assert!(step.actions.is_empty());
1726 assert!(matches!(
1727 step.observations.as_slice(),
1728 [KernelObservation::CapabilityChanged { .. }]
1729 ));
1730 }
1731
1732 #[test]
1733 fn spawn_sub_agent_input_registers_process() {
1734 use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1735
1736 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1737 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1738 task: RuntimeTask::new("parent task"),
1739 run_spec: None,
1740 }));
1741 runtime.state_machine_mut().take_observations();
1742
1743 let spec = AgentRunSpec::new(
1744 AgentIdentity::sub_agent("worker", "worker-session"),
1745 AgentRole::Implement,
1746 "do work",
1747 );
1748 let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1749 spec,
1750 parent_session_id: "parent-session".to_string(),
1751 }));
1752
1753 assert!(step.actions.is_empty());
1754 assert!(step.observations.iter().any(|o| matches!(
1755 o,
1756 KernelObservation::AgentProcessChanged {
1757 agent_id,
1758 parent_session_id,
1759 state,
1760 ..
1761 } if agent_id == "worker" && parent_session_id == "parent-session" && state == "running"
1762 )));
1763 assert_eq!(
1764 runtime
1765 .state_machine()
1766 .agent_process("worker")
1767 .expect("process")
1768 .parent_session_id
1769 .as_str(),
1770 "parent-session"
1771 );
1772 assert!(step.observations.iter().any(|o| matches!(
1773 o,
1774 KernelObservation::Suspended { reason, .. } if reason == "sub_agent_await"
1775 )));
1776 assert!(runtime.state_machine().is_suspended());
1777 assert!(matches!(
1778 runtime.state_machine().wait_reason(),
1779 Some(crate::scheduler::tcb::WaitReason::SubAgentJoin(_))
1780 ));
1781 }
1782
1783 #[test]
1784 fn set_resource_quota_input_denies_spawn_over_quota() {
1785 use crate::governance::quota::ResourceQuota;
1786 use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1787
1788 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1789 let step = runtime.step(KernelInput::new(KernelInputEvent::SetResourceQuota {
1791 quota: ResourceQuota { max_spawn_depth: Some(0), ..ResourceQuota::default() },
1792 }));
1793 assert!(step.actions.is_empty(), "config input yields no actions");
1794
1795 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1796 task: RuntimeTask::new("parent task"),
1797 run_spec: None,
1798 }));
1799 runtime.state_machine_mut().take_observations();
1800
1801 let spec = AgentRunSpec::new(
1802 AgentIdentity::sub_agent("worker", "worker-session"),
1803 AgentRole::Implement,
1804 "do work",
1805 );
1806 let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1807 spec,
1808 parent_session_id: "parent-session".to_string(),
1809 }));
1810
1811 assert!(matches!(
1814 step.actions.as_slice(),
1815 [KernelAction::CallProvider { .. }]
1816 ));
1817 assert!(!step.observations.iter().any(|o| matches!(
1818 o,
1819 KernelObservation::AgentProcessChanged { agent_id, .. } if agent_id == "worker"
1820 )));
1821 assert!(runtime.state_machine().agent_process("worker").is_none());
1822 assert!(!runtime.state_machine().is_suspended());
1823 }
1824
1825 #[test]
1826 fn group_budget_base_enforces_shared_token_cap() {
1827 use crate::types::message::{Content, Message, ToolCall, ToolResult};
1828
1829 fn run_one_turn(group_base: Option<u64>) -> KernelStep {
1833 let mut runtime = KernelRuntime::new(SchedulerBudget {
1834 max_total_tokens: 100,
1835 ..SchedulerBudget::default()
1836 });
1837 runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
1838 config: RunConfig { group_tokens_base: group_base, ..RunConfig::default() },
1839 }));
1840 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1841 task: RuntimeTask::new("task"),
1842 run_spec: None,
1843 }));
1844 let mut msg = Message::assistant("");
1845 msg.token_count = Some(10); msg.tool_calls.push(ToolCall {
1847 id: "c1".into(),
1848 name: "echo".into(),
1849 arguments: serde_json::json!({}),
1850 });
1851 runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
1852 message: msg,
1853 observed_input_tokens: None,
1854 observed_output_tokens: None,
1855 stop_reason: None,
1856 now_ms: None,
1857 }));
1858 runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
1859 results: vec![ToolResult {
1860 call_id: "c1".into(),
1861 output: Content::Text("ok".into()),
1862 is_error: false,
1863 is_fatal: false,
1864 error_kind: None,
1865 token_count: None,
1866 }],
1867 }))
1868 }
1869
1870 let exceeded = |step: &KernelStep| {
1871 step.observations.iter().any(|o| {
1872 matches!(o, KernelObservation::BudgetExceeded { budget, .. } if budget == "token_budget")
1873 })
1874 };
1875
1876 assert!(
1878 exceeded(&run_one_turn(Some(95))),
1879 "group token budget must span the whole domain"
1880 );
1881 assert!(
1883 !exceeded(&run_one_turn(None)),
1884 "no group seed ⇒ per-vehicle budget, well under cap"
1885 );
1886 }
1887
1888 #[test]
1889 fn group_spawns_base_enforces_cumulative_spawn_cap() {
1890 use crate::governance::quota::ResourceQuota;
1891 use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1892
1893 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1896 runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
1897 config: RunConfig {
1898 resource_quota: Some(ResourceQuota {
1899 max_total_subagents: Some(2),
1900 ..ResourceQuota::default()
1901 }),
1902 group_spawns_base: Some(2),
1903 ..RunConfig::default()
1904 },
1905 }));
1906 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1907 task: RuntimeTask::new("task"),
1908 run_spec: None,
1909 }));
1910 runtime.state_machine_mut().take_observations();
1911
1912 let spec = AgentRunSpec::new(
1913 AgentIdentity::sub_agent("worker", "worker-session"),
1914 AgentRole::Implement,
1915 "do work",
1916 );
1917 let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1918 spec,
1919 parent_session_id: "parent-session".to_string(),
1920 }));
1921
1922 assert!(matches!(
1924 step.actions.as_slice(),
1925 [KernelAction::CallProvider { .. }]
1926 ));
1927 assert!(runtime.state_machine().agent_process("worker").is_none());
1928 assert_eq!(runtime.local_subagents_spawned(), 0);
1929 }
1930
1931 #[test]
1932 fn default_runtime_leaves_spawn_unquota_ed() {
1933 use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
1934
1935 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1937 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1938 task: RuntimeTask::new("parent task"),
1939 run_spec: None,
1940 }));
1941 runtime.state_machine_mut().take_observations();
1942
1943 let spec = AgentRunSpec::new(
1944 AgentIdentity::sub_agent("worker", "worker-session"),
1945 AgentRole::Implement,
1946 "do work",
1947 );
1948 runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1949 spec,
1950 parent_session_id: "parent-session".to_string(),
1951 }));
1952 assert!(runtime.state_machine().agent_process("worker").is_some());
1953 assert!(runtime.state_machine().is_suspended());
1954 }
1955
1956 #[test]
1961 fn agent_process_changed_locks_multiword_wire_form() {
1962 use crate::types::agent::{AgentIdentity, AgentIsolation, AgentRole, AgentRunSpec};
1963
1964 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
1965 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
1966 task: RuntimeTask::new("parent task"),
1967 run_spec: None,
1968 }));
1969 runtime.state_machine_mut().take_observations();
1970
1971 let spec = AgentRunSpec::new(
1973 AgentIdentity::sub_agent("worker", "worker-session"),
1974 AgentRole::Verify,
1975 "do work",
1976 )
1977 .with_isolation(AgentIsolation::ReadOnly);
1978 let step = runtime.step(KernelInput::new(KernelInputEvent::SpawnSubAgent {
1979 spec,
1980 parent_session_id: "parent-session".to_string(),
1981 }));
1982
1983 let obs = step
1984 .observations
1985 .iter()
1986 .find(|o| matches!(o, KernelObservation::AgentProcessChanged { .. }))
1987 .expect("agent_process_changed observation");
1988 let json = serde_json::to_value(obs).unwrap();
1989 assert_eq!(json["isolation"], "readonly", "isolation must stay debug-lowercase");
1990 assert_eq!(
1991 json["context_inheritance"], "systemonly",
1992 "context_inheritance must stay debug-lowercase"
1993 );
1994 assert_eq!(json["role"], "verify");
1995 assert_eq!(json["state"], "running");
1996 }
1997
1998 fn write_memory(runtime: &mut KernelRuntime, name: &str, content: &str) -> KernelStep {
2001 use crate::mm::memory::{MemoryMetadata, MemoryWriteRequest};
2002 runtime.step(KernelInput::new(KernelInputEvent::WriteMemory {
2003 memory: MemoryWriteRequest {
2004 metadata: MemoryMetadata {
2005 name: name.to_string(),
2006 description: "desc".to_string(),
2007 ..Default::default()
2008 },
2009 content: content.to_string(),
2010 },
2011 }))
2012 }
2013
2014 #[test]
2015 fn memory_policy_validation_disabled_admits_forbidden_write() {
2016 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2018 runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
2019 memory_path: String::new(),
2020 stale_warning_days: 2,
2021 retrieval_top_k: 5,
2022 validation_enabled: false,
2023 max_content_bytes: None,
2024 max_name_length: None,
2025 }));
2026 let step = write_memory(&mut runtime, "note", "代码模式: foo");
2027 assert!(step
2028 .observations
2029 .iter()
2030 .any(|o| matches!(o, KernelObservation::MemoryWritten { .. })));
2031 assert!(!step
2032 .observations
2033 .iter()
2034 .any(|o| matches!(o, KernelObservation::MemoryValidationFailed { .. })));
2035 }
2036
2037 #[test]
2038 fn default_runtime_accepts_content_hosts_have_not_forbidden() {
2039 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2042 let step = write_memory(&mut runtime, "note", "代码模式: foo");
2043 assert!(step
2044 .observations
2045 .iter()
2046 .any(|o| matches!(o, KernelObservation::MemoryWritten { .. })));
2047 }
2048
2049 #[test]
2050 fn memory_policy_size_override_rejects_oversized_write() {
2051 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2052 runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
2053 memory_path: String::new(),
2054 stale_warning_days: 2,
2055 retrieval_top_k: 5,
2056 validation_enabled: true,
2057 max_content_bytes: Some(8),
2058 max_name_length: None,
2059 }));
2060 let step = write_memory(&mut runtime, "note", "this content is well over eight bytes");
2061 let failed = step.observations.iter().find_map(|o| match o {
2062 KernelObservation::MemoryValidationFailed { error, .. } => Some(error.clone()),
2063 _ => None,
2064 });
2065 assert!(failed.is_some_and(|e| e.contains("too large")));
2066 }
2067
2068 #[test]
2069 fn memory_policy_clamps_retrieval_top_k() {
2070 use crate::mm::memory::MemoryQuery;
2071 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2072 runtime.step(KernelInput::new(KernelInputEvent::SetMemoryPolicy {
2073 memory_path: String::new(),
2074 stale_warning_days: 2,
2075 retrieval_top_k: 3,
2076 validation_enabled: true,
2077 max_content_bytes: None,
2078 max_name_length: None,
2079 }));
2080 let step = runtime.step(KernelInput::new(KernelInputEvent::QueryMemory {
2081 query: MemoryQuery { top_k: 50, ..Default::default() },
2082 }));
2083 let requested = step.observations.iter().find_map(|o| match o {
2084 KernelObservation::MemoryQueried { requested_k, .. } => Some(*requested_k),
2085 _ => None,
2086 });
2087 assert_eq!(requested, Some(3));
2088 }
2089
2090 #[test]
2091 fn default_runtime_uses_requested_top_k_verbatim() {
2092 use crate::mm::memory::MemoryQuery;
2093 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2094 let step = runtime.step(KernelInput::new(KernelInputEvent::QueryMemory {
2095 query: MemoryQuery { top_k: 50, ..Default::default() },
2096 }));
2097 let requested = step.observations.iter().find_map(|o| match o {
2098 KernelObservation::MemoryQueried { requested_k, .. } => Some(*requested_k),
2099 _ => None,
2100 });
2101 assert_eq!(requested, Some(50));
2102 }
2103
2104 #[test]
2105 fn provider_result_now_ms_drives_wall_time_budget() {
2106 let mut runtime = KernelRuntime::new(SchedulerBudget {
2107 max_wall_ms: Some(10),
2108 ..SchedulerBudget::default()
2109 });
2110 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2111 task: RuntimeTask::new("ship it"),
2112 run_spec: None,
2113 }));
2114 let mut msg = Message::assistant("");
2115 msg.tool_calls.push(ToolCall {
2116 id: "call-1".into(),
2117 name: "echo".into(),
2118 arguments: serde_json::json!({}),
2119 });
2120 runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2121 message: msg,
2122 observed_input_tokens: None,
2123 observed_output_tokens: None,
2124 stop_reason: None,
2125 now_ms: Some(100),
2126 }));
2127 let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
2128 results: vec![ToolResult {
2129 call_id: "call-1".into(),
2130 output: crate::types::message::Content::Text("ok".into()),
2131 is_error: false,
2132 is_fatal: false,
2133 error_kind: None,
2134 token_count: None,
2135 }],
2136 }));
2137
2138 assert!(matches!(
2139 step.actions.as_slice(),
2140 [KernelAction::CallProvider { tools, .. }] if tools.is_empty()
2141 ));
2142 }
2143
2144 fn assistant_calling(tool: &str) -> Message {
2147 let mut msg = Message::assistant("");
2148 msg.tool_calls.push(ToolCall {
2149 id: "call-1".into(),
2150 name: tool.into(),
2151 arguments: serde_json::json!({}),
2152 });
2153 msg
2154 }
2155
2156 fn run_with_tool_call(runtime: &mut KernelRuntime, tool: &str) -> KernelStep {
2158 run_with_tool_call_named(runtime, tool, "call-1")
2159 }
2160
2161 fn run_with_tool_call_named(
2162 runtime: &mut KernelRuntime,
2163 tool: &str,
2164 _call_id: &str,
2165 ) -> KernelStep {
2166 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2167 task: RuntimeTask::new("do the thing"),
2168 run_spec: None,
2169 }));
2170 runtime.state_machine_mut().take_observations();
2171 runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2172 message: assistant_calling(tool),
2173 observed_input_tokens: None,
2174 observed_output_tokens: None,
2175 stop_reason: None,
2176 now_ms: None,
2177 }))
2178 }
2179
2180 #[test]
2181 fn governance_deny_blocks_tool_and_reprompts() {
2182 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2183 runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2184 default_action: Some(PolicyAction::Allow),
2185 rules: vec![PolicyRule {
2186 tool_pattern: "danger.*".to_string(),
2187 action: PolicyAction::Deny,
2188 }],
2189 vetoed_tools: vec![],
2190 rate_limits: vec![],
2191 constraints: vec![],
2192 }));
2193
2194 let step = run_with_tool_call(&mut runtime, "danger.delete");
2195
2196 assert!(
2198 matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2199 "denied tool should roll back and re-call provider, got {:?}",
2200 step.actions
2201 );
2202 assert!(
2203 step.observations
2204 .iter()
2205 .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2206 "expected a Rollbacked observation for the denied turn",
2207 );
2208 }
2209
2210 #[test]
2211 fn configure_run_bundle_applies_governance_equivalently_to_load_governance_policy() {
2212 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2215 runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
2216 config: RunConfig {
2217 tools: Some(vec![]),
2218 governance: Some(GovernanceConfig {
2219 default_action: Some(PolicyAction::Allow),
2220 rules: vec![PolicyRule {
2221 tool_pattern: "danger.*".to_string(),
2222 action: PolicyAction::Deny,
2223 }],
2224 ..GovernanceConfig::default()
2225 }),
2226 attention_max_queue_size: Some(32),
2227 ..RunConfig::default()
2228 },
2229 }));
2230
2231 let step = run_with_tool_call(&mut runtime, "danger.delete");
2232
2233 assert!(
2234 matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2235 "bundle-configured deny should roll back and re-call provider, got {:?}",
2236 step.actions
2237 );
2238 assert!(
2239 step.observations
2240 .iter()
2241 .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2242 "expected a Rollbacked observation for the bundle-denied turn",
2243 );
2244 }
2245
2246 #[test]
2247 fn configure_run_entropy_watch_flows_through_the_abi() {
2248 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2251 runtime.step(KernelInput::new(KernelInputEvent::ConfigureRun {
2252 config: RunConfig {
2253 entropy_watch: Some(crate::scheduler::entropy::EntropyWatchConfig {
2254 enabled: true,
2255 threshold: 0.1,
2256 hysteresis: 0.05,
2257 cooldown_turns: 0,
2258 notify_model: false,
2259 }),
2260 ..RunConfig::default()
2261 },
2262 }));
2263 run_with_tool_call(&mut runtime, "step");
2264 let step = runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
2265 results: vec![ToolResult {
2266 call_id: "call-1".into(),
2267 output: crate::types::message::Content::Text("boom".into()),
2268 is_error: true,
2269 is_fatal: false,
2270 error_kind: None,
2271 token_count: None,
2272 }],
2273 }));
2274 assert!(
2275 step.observations
2276 .iter()
2277 .any(|o| matches!(o, KernelObservation::EntropySample { failure_rate, .. } if *failure_rate > 0.9)),
2278 "completed boundary must carry the entropy sample: {:?}",
2279 step.observations
2280 );
2281 assert!(
2282 step.observations
2283 .iter()
2284 .any(|o| matches!(o, KernelObservation::EntropyAlert { threshold, .. } if (*threshold - 0.1).abs() < 1e-9)),
2285 "armed watch + errored turn must alert: {:?}",
2286 step.observations
2287 );
2288 }
2289
2290 #[test]
2291 fn set_entropy_watch_event_parses_from_json_and_applies_partially() {
2292 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2295 let input: KernelInput = serde_json::from_str(
2296 r#"{"version":1,"event":{"kind":"set_entropy_watch","enabled":true,"threshold":0.4}}"#,
2297 )
2298 .expect("granular event must deserialize");
2299 runtime.step(input);
2300 let cfg = runtime.state_machine().entropy_watch_config();
2301 assert!(cfg.enabled);
2302 assert!((cfg.threshold - 0.4).abs() < 1e-9);
2303 assert!((cfg.hysteresis - 0.1).abs() < 1e-9, "absent field keeps the default");
2304 assert_eq!(cfg.cooldown_turns, 4, "absent field keeps the default");
2305 }
2306
2307 #[test]
2308 fn configure_run_round_trips_over_the_abi() {
2309 let event = KernelInputEvent::ConfigureRun {
2312 config: RunConfig {
2313 resource_quota: Some(crate::governance::quota::ResourceQuota {
2314 max_concurrent_subagents: Some(2),
2315 ..Default::default()
2316 }),
2317 scheduler_max_wall_ms: Some(60_000),
2318 plan_tool_enabled: Some(true),
2319 ..RunConfig::default()
2320 },
2321 };
2322 let json = serde_json::to_string(&event).expect("serialize");
2323 let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2324 assert!(matches!(parsed, KernelInputEvent::ConfigureRun { .. }));
2325 }
2326
2327 #[test]
2328 fn governance_ask_user_suspends_until_resume() {
2329 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2330 runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2331 default_action: Some(PolicyAction::Allow),
2332 rules: vec![PolicyRule {
2333 tool_pattern: "sensitive.*".to_string(),
2334 action: PolicyAction::AskUser,
2335 }],
2336 vetoed_tools: vec![],
2337 rate_limits: vec![],
2338 constraints: vec![],
2339 }));
2340
2341 let step = run_with_tool_call(&mut runtime, "sensitive.read");
2342
2343 assert!(
2344 step.actions.is_empty(),
2345 "AskUser should suspend without ExecuteTool, got {:?}",
2346 step.actions
2347 );
2348 assert!(
2349 step.observations.iter().any(|o| matches!(
2350 o,
2351 KernelObservation::ToolGated { tool, .. } if tool == "sensitive.read"
2352 )),
2353 "expected a ToolGated observation for the AskUser call",
2354 );
2355 assert!(
2356 step.observations.iter().any(|o| matches!(
2357 o,
2358 KernelObservation::Suspended { reason, .. } if reason == "ask_user"
2359 )),
2360 "expected a Suspended observation",
2361 );
2362
2363 let resumed = runtime.step(KernelInput::new(KernelInputEvent::Resume {
2364 approved_calls: vec!["call-1".to_string()],
2365 denied_calls: vec![],
2366 }));
2367 assert!(
2368 matches!(resumed.actions.as_slice(), [KernelAction::ExecuteTool { .. }]),
2369 "resume with approval should emit ExecuteTool, got {:?}",
2370 resumed.actions
2371 );
2372 assert!(
2373 resumed.observations.iter().any(|o| matches!(
2374 o,
2375 KernelObservation::Resumed { approved, denied, .. }
2376 if approved == &["call-1"] && denied.is_empty()
2377 )),
2378 );
2379 }
2380
2381 #[test]
2382 fn governance_ask_user_resume_all_denied_feeds_tool_results() {
2383 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2384 runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2385 default_action: Some(PolicyAction::Allow),
2386 rules: vec![PolicyRule {
2387 tool_pattern: "sensitive.*".to_string(),
2388 action: PolicyAction::AskUser,
2389 }],
2390 vetoed_tools: vec![],
2391 rate_limits: vec![],
2392 constraints: vec![],
2393 }));
2394 run_with_tool_call(&mut runtime, "sensitive.read");
2395 runtime.state_machine_mut().take_observations();
2396
2397 let step = runtime.step(KernelInput::new(KernelInputEvent::Resume {
2398 approved_calls: vec![],
2399 denied_calls: vec!["call-1".to_string()],
2400 }));
2401 assert!(
2402 matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2403 "all denied should re-prompt provider, got {:?}",
2404 step.actions
2405 );
2406 }
2407
2408 #[test]
2409 fn no_governance_policy_executes_all_tools() {
2410 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2411 let step = run_with_tool_call(&mut runtime, "danger.delete");
2412
2413 assert!(matches!(
2415 step.actions.as_slice(),
2416 [KernelAction::ExecuteTool { .. }]
2417 ));
2418 assert!(
2419 !step
2420 .observations
2421 .iter()
2422 .any(|o| matches!(o, KernelObservation::ToolGated { .. })),
2423 );
2424 }
2425
2426 fn tool_ok(call_id: &str) -> ToolResult {
2427 ToolResult {
2428 call_id: call_id.into(),
2429 output: crate::types::message::Content::Text("ok".to_string()),
2430 is_error: false,
2431 is_fatal: false,
2432 error_kind: None,
2433 token_count: None,
2434 }
2435 }
2436
2437 #[test]
2438 fn governance_rate_limit_blocks_second_call() {
2439 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2440 runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2441 default_action: Some(PolicyAction::Allow),
2442 rules: vec![],
2443 vetoed_tools: vec![],
2444 rate_limits: vec![RateLimitSpec {
2445 tool: "fetch".to_string(),
2446 max_calls: 1,
2447 window_ms: 60_000,
2448 }],
2449 constraints: vec![],
2450 }));
2451 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2452 task: RuntimeTask::new("fetch twice"),
2453 run_spec: None,
2454 }));
2455 runtime.state_machine_mut().take_observations();
2456
2457 let s1 = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2459 message: assistant_calling("fetch"),
2460 observed_input_tokens: None,
2461 observed_output_tokens: None,
2462 stop_reason: None,
2463 now_ms: Some(1_000),
2464 }));
2465 assert!(
2466 matches!(s1.actions.as_slice(), [KernelAction::ExecuteTool { .. }]),
2467 "first call should execute, got {:?}",
2468 s1.actions
2469 );
2470
2471 runtime.step(KernelInput::new(KernelInputEvent::ToolResults {
2473 results: vec![tool_ok("call-1")],
2474 }));
2475 runtime.state_machine_mut().take_observations();
2476
2477 let s2 = runtime.step(KernelInput::new(KernelInputEvent::ProviderResult {
2479 message: assistant_calling("fetch"),
2480 observed_input_tokens: None,
2481 observed_output_tokens: None,
2482 stop_reason: None,
2483 now_ms: Some(1_001),
2484 }));
2485 assert!(
2486 matches!(s2.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2487 "rate-limited call should roll back and re-call provider, got {:?}",
2488 s2.actions
2489 );
2490 assert!(
2491 s2.observations
2492 .iter()
2493 .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2494 "expected a Rollbacked observation for the rate-limited turn",
2495 );
2496 }
2497
2498 #[test]
2499 fn governance_constraint_required_param_denies() {
2500 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2501 runtime.step(KernelInput::new(KernelInputEvent::LoadGovernancePolicy {
2502 default_action: Some(PolicyAction::Allow),
2503 rules: vec![],
2504 vetoed_tools: vec![],
2505 rate_limits: vec![],
2506 constraints: vec![ConstraintSpec::Required {
2507 tool: "write".to_string(),
2508 path: "path".to_string(),
2509 }],
2510 }));
2511
2512 let step = run_with_tool_call(&mut runtime, "write");
2514 assert!(
2515 matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2516 "missing required param should roll back, got {:?}",
2517 step.actions
2518 );
2519 assert!(
2520 step.observations
2521 .iter()
2522 .any(|o| matches!(o, KernelObservation::Rollbacked { .. })),
2523 "expected a Rollbacked observation for the constraint violation",
2524 );
2525 }
2526
2527 fn signal(urgency: crate::types::signal::Urgency, summary: &str) -> crate::types::signal::RuntimeSignal {
2530 use crate::types::signal::{RuntimeSignal, SignalSource, SignalType};
2531 RuntimeSignal::new(SignalSource::Gateway, SignalType::Alert, urgency, summary)
2532 }
2533
2534 fn started_runtime_with_attention(max_queue: u32) -> KernelRuntime {
2535 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2536 runtime.step(KernelInput::new(KernelInputEvent::SetAttentionPolicy {
2537 max_queue_size: max_queue,
2538 }));
2539 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2540 task: RuntimeTask::new("watch for signals"),
2541 run_spec: None,
2542 }));
2543 runtime.state_machine_mut().take_observations();
2544 runtime
2545 }
2546
2547 #[test]
2548 fn attention_policy_critical_signal_interrupts() {
2549 use crate::types::signal::Urgency;
2550 let mut runtime = started_runtime_with_attention(8);
2551 let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
2552 signal: signal(Urgency::Critical, "fire"),
2553 }));
2554 assert!(
2555 matches!(step.actions.as_slice(), [KernelAction::CallProvider { .. }]),
2556 "critical signal should drive a provider call, got {:?}",
2557 step.actions
2558 );
2559 assert!(step.observations.iter().any(|o| matches!(
2560 o,
2561 KernelObservation::SignalDisposed { disposition, .. } if disposition == "interrupt_now"
2562 )));
2563 }
2564
2565 #[test]
2566 fn attention_policy_normal_signal_queues_without_action() {
2567 use crate::types::signal::Urgency;
2568 let mut runtime = started_runtime_with_attention(8);
2569 let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
2570 signal: signal(Urgency::Normal, "job"),
2571 }));
2572 assert!(
2573 step.actions.is_empty(),
2574 "normal signal should queue without a provider call, got {:?}",
2575 step.actions
2576 );
2577 assert!(step.observations.iter().any(|o| matches!(
2578 o,
2579 KernelObservation::SignalDisposed { disposition, queue_depth, .. }
2580 if disposition == "queue" && *queue_depth == 1
2581 )));
2582 }
2583
2584 #[test]
2585 fn attention_policy_full_queue_drops() {
2586 use crate::types::signal::Urgency;
2587 let mut runtime = started_runtime_with_attention(1);
2588 runtime.step(KernelInput::new(KernelInputEvent::Signal {
2589 signal: signal(Urgency::Normal, "first"),
2590 }));
2591 let step = runtime.step(KernelInput::new(KernelInputEvent::Signal {
2592 signal: signal(Urgency::Normal, "second"),
2593 }));
2594 assert!(step.observations.iter().any(|o| matches!(
2595 o,
2596 KernelObservation::SignalDisposed { disposition, .. } if disposition == "dropped"
2597 )));
2598 }
2599
2600 #[test]
2601 fn page_in_populates_knowledge_partition() {
2602 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2603 runtime.step(KernelInput::new(KernelInputEvent::SetMemoryEnabled {
2604 enabled: true,
2605 }));
2606 let before = runtime.state_machine().ctx.partitions.knowledge.len();
2607 runtime.step(KernelInput::new(KernelInputEvent::PageIn {
2608 entries: vec![crate::mm::PageInEntry {
2609 content: "[memory] prior fix".to_string(),
2610 tokens: Some(10),
2611 source: Some("memory".to_string()),
2612 key: None,
2613 pinned: false,
2614 }],
2615 }));
2616 let after = runtime.state_machine().ctx.partitions.knowledge.len();
2617 assert!(after > before, "page-in should add knowledge messages");
2618 }
2619
2620 #[test]
2621 fn memory_tool_does_not_emit_page_in_requested() {
2622 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2626 runtime.step(KernelInput::new(KernelInputEvent::SetMemoryEnabled {
2627 enabled: true,
2628 }));
2629 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2630 task: RuntimeTask::new("test"),
2631 run_spec: None,
2632 }));
2633 runtime.state_machine_mut().take_observations();
2634
2635 let _step = run_with_tool_call(&mut runtime, "memory");
2636 }
2638
2639 #[test]
2640 fn load_workflow_input_drives_dag_to_completion() {
2641 use crate::orchestration::workflow::fanout_synthesize;
2642 use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2643
2644 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2645 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2646 task: RuntimeTask::new("parent task"),
2647 run_spec: None,
2648 }));
2649 runtime.state_machine_mut().take_observations();
2650
2651 let spec =
2653 fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2654 let event = KernelInputEvent::LoadWorkflow {
2655 spec,
2656 parent_session_id: "sess".to_string(),
2657 resumed_completed: Vec::new(),
2658 resumed_submissions: Vec::new(),
2659 resumed_submission_bases: Vec::new(),
2660 resumed_results: Vec::new(),
2661 };
2662 let json = serde_json::to_string(&event).expect("serialize");
2663 let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2664
2665 let step = runtime.step(KernelInput::new(parsed));
2666 let batch = step
2668 .observations
2669 .iter()
2670 .find_map(|o| match o {
2671 KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2672 _ => None,
2673 })
2674 .expect("workflow_batch_spawned");
2675 assert_eq!(batch.len(), 2);
2676 let goals: Vec<&str> = batch.iter().map(|n| n.goal.as_str()).collect();
2677 assert!(goals.contains(&"w0") && goals.contains(&"w1"));
2678 assert_eq!(batch[0].agent_id, "wf-node0");
2679 assert_eq!(batch[0].isolation, "read_only"); let complete = |runtime: &mut KernelRuntime, id: &str| {
2682 runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2683 result: SubAgentResult {
2684 agent_id: compact_str::CompactString::new(id),
2685 result: LoopResult {
2686 termination: TerminationReason::Completed,
2687 final_message: None,
2688 turns_used: 1,
2689 total_tokens_used: 1,
2690 loop_continue: None,
2691 classify_branch: None,
2692 tournament_winner: None,
2693 pace_decision: None,
2694 },
2695 },
2696 }))
2697 };
2698
2699 complete(&mut runtime, "wf-node0");
2700 let step = complete(&mut runtime, "wf-node1");
2702 assert!(step.observations.iter().any(|o| matches!(
2703 o,
2704 KernelObservation::WorkflowBatchSpawned { nodes, .. }
2705 if nodes.len() == 1 && nodes[0].agent_id == "wf-node2"
2706 )));
2707
2708 let step = complete(&mut runtime, "wf-node2");
2710 assert!(step.observations.iter().any(|o| matches!(
2711 o,
2712 KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 3
2713 )));
2714 }
2715
2716 #[test]
2717 fn load_workflow_self_bootstraps_with_no_prior_start_run() {
2718 use crate::orchestration::workflow::fanout_synthesize;
2722 use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2723
2724 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2725 let spec =
2728 fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2729 let step = runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2730 spec,
2731 parent_session_id: "sess".to_string(),
2732 resumed_completed: Vec::new(),
2733 resumed_submissions: Vec::new(),
2734 resumed_submission_bases: Vec::new(),
2735 resumed_results: Vec::new(),
2736 }));
2737 let batch = step
2738 .observations
2739 .iter()
2740 .find_map(|o| match o {
2741 KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2742 _ => None,
2743 })
2744 .expect("workflow_batch_spawned even without a prior StartRun");
2745 assert_eq!(batch.len(), 2);
2746
2747 let complete = |runtime: &mut KernelRuntime, id: &str| {
2748 runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2749 result: SubAgentResult {
2750 agent_id: compact_str::CompactString::new(id),
2751 result: LoopResult {
2752 termination: TerminationReason::Completed,
2753 final_message: None,
2754 turns_used: 1,
2755 total_tokens_used: 1,
2756 loop_continue: None,
2757 classify_branch: None,
2758 tournament_winner: None,
2759 pace_decision: None,
2760 },
2761 },
2762 }))
2763 };
2764 complete(&mut runtime, "wf-node0");
2765 complete(&mut runtime, "wf-node1");
2766 let step = complete(&mut runtime, "wf-node2");
2767 assert!(step.observations.iter().any(|o| matches!(
2768 o,
2769 KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 3
2770 )));
2771 }
2772
2773 #[test]
2774 fn submit_workflow_nodes_input_appends_a_node_over_the_abi() {
2775 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2778 use crate::types::agent::AgentRole;
2779 use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2780
2781 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2782 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2783 task: RuntimeTask::new("parent task"),
2784 run_spec: None,
2785 }));
2786 runtime.state_machine_mut().take_observations();
2787
2788 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
2790 RuntimeTask::new("root"),
2791 AgentRole::Implement,
2792 )]);
2793 runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2794 spec,
2795 parent_session_id: "sess".to_string(),
2796 resumed_completed: Vec::new(),
2797 resumed_submissions: Vec::new(),
2798 resumed_submission_bases: Vec::new(),
2799 resumed_results: Vec::new(),
2800 }));
2801 runtime.state_machine_mut().take_observations();
2802
2803 let event = KernelInputEvent::SubmitWorkflowNodes {
2805 nodes: vec![WorkflowNode::new(RuntimeTask::new("more"), AgentRole::Implement)],
2806 submitter_agent_id: None,
2807 };
2808 let json = serde_json::to_string(&event).expect("serialize");
2809 let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2810 let step = runtime.step(KernelInput::new(parsed));
2811 assert!(step.observations.iter().any(|o| matches!(
2813 o,
2814 KernelObservation::WorkflowBatchSpawned { nodes, .. }
2815 if nodes.len() == 1 && nodes[0].agent_id == "wf-node1" && nodes[0].goal == "more"
2816 )));
2817
2818 let complete = |runtime: &mut KernelRuntime, id: &str| {
2819 runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2820 result: SubAgentResult {
2821 agent_id: compact_str::CompactString::new(id),
2822 result: LoopResult {
2823 termination: TerminationReason::Completed,
2824 final_message: None,
2825 turns_used: 1,
2826 total_tokens_used: 1,
2827 loop_continue: None,
2828 classify_branch: None,
2829 tournament_winner: None,
2830 pace_decision: None,
2831 },
2832 },
2833 }))
2834 };
2835 complete(&mut runtime, "wf-node0");
2836 let step = complete(&mut runtime, "wf-node1");
2838 assert!(step.observations.iter().any(|o| matches!(
2839 o,
2840 KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 2
2841 )));
2842 }
2843
2844 #[test]
2845 fn submit_workflow_input_bootstraps_a_dag_over_the_abi() {
2846 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2849 use crate::types::agent::AgentRole;
2850 use crate::types::result::{LoopResult, SubAgentResult, TerminationReason};
2851
2852 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2853 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2854 task: RuntimeTask::new("parent task"),
2855 run_spec: None,
2856 }));
2857 runtime.state_machine_mut().take_observations();
2858
2859 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
2861 RuntimeTask::new("authored root"),
2862 AgentRole::Implement,
2863 )]);
2864 let event = KernelInputEvent::SubmitWorkflow {
2865 spec,
2866 parent_session_id: "sess".to_string(),
2867 submitter_agent_id: None,
2868 };
2869 let json = serde_json::to_string(&event).expect("serialize");
2870 let parsed: KernelInputEvent = serde_json::from_str(&json).expect("deserialize");
2871 let step = runtime.step(KernelInput::new(parsed));
2872 assert!(step.observations.iter().any(|o| matches!(
2874 o,
2875 KernelObservation::WorkflowBatchSpawned { nodes, .. }
2876 if nodes.len() == 1 && nodes[0].agent_id == "wf-node0" && nodes[0].goal == "authored root"
2877 )));
2878
2879 let step = runtime.step(KernelInput::new(KernelInputEvent::SubAgentCompleted {
2880 result: SubAgentResult {
2881 agent_id: compact_str::CompactString::new("wf-node0"),
2882 result: LoopResult {
2883 termination: TerminationReason::Completed,
2884 final_message: None,
2885 turns_used: 1,
2886 total_tokens_used: 1,
2887 loop_continue: None,
2888 classify_branch: None,
2889 tournament_winner: None,
2890 pace_decision: None,
2891 },
2892 },
2893 }));
2894 assert!(step.observations.iter().any(|o| matches!(
2895 o,
2896 KernelObservation::WorkflowCompleted { completed, .. } if completed.len() == 1
2897 )));
2898 }
2899
2900 #[test]
2901 fn load_workflow_resumes_from_completed_nodes() {
2902 use crate::orchestration::workflow::fanout_synthesize;
2903
2904 let mut runtime = KernelRuntime::new(SchedulerBudget::default());
2905 runtime.step(KernelInput::new(KernelInputEvent::StartRun {
2906 task: RuntimeTask::new("parent task"),
2907 run_spec: None,
2908 }));
2909 runtime.state_machine_mut().take_observations();
2910
2911 let spec =
2913 fanout_synthesize(vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")], RuntimeTask::new("synth"));
2914 let step = runtime.step(KernelInput::new(KernelInputEvent::LoadWorkflow {
2915 spec,
2916 parent_session_id: "sess".to_string(),
2917 resumed_completed: vec!["wf-node0".to_string()],
2918 resumed_submissions: Vec::new(),
2919 resumed_submission_bases: Vec::new(),
2920 resumed_results: Vec::new(),
2921 }));
2922
2923 let batch = step
2925 .observations
2926 .iter()
2927 .find_map(|o| match o {
2928 KernelObservation::WorkflowBatchSpawned { nodes, .. } => Some(nodes.clone()),
2929 _ => None,
2930 })
2931 .expect("workflow_batch_spawned");
2932 assert_eq!(batch.len(), 1);
2933 assert_eq!(batch[0].agent_id, "wf-node1");
2934 }
2935}