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