deepstrike_core/syscall/mod.rs
1//! Primitive P1: the single syscall trap boundary.
2//!
3//! M0 scaffold (see `.local-docs/specs/agent-os-three-primitives.md`): types + conversions
4//! only — **no wiring, no behavior change**. A later milestone (M2) generalizes
5//! [`crate::governance::pipeline`] so its request becomes [`Syscall`] and its result becomes
6//! [`Disposition`], and routes spawn / page-in / write-memory through the same gate (today they
7//! bypass governance entirely).
8//!
9//! Concept overlap this primitive collapses: the two parallel decision vocabularies
10//! ([`crate::types::policy::GovernanceVerdict`] and `SignalDisposition`). Tool/spawn/memory
11//! decisions converge on [`Disposition`]; signals feed the P2 scheduler instead.
12
13use crate::mm::PageInRequest;
14use crate::mm::memory::{MemoryQuery, MemoryWriteRequest};
15use crate::scheduler::tcb::WaitReason;
16use crate::types::agent::IsolationManifest;
17use crate::types::message::ToolCall;
18use crate::types::policy::GovernanceVerdict;
19
20/// An effectful request from the SDK that the kernel must adjudicate.
21///
22/// Every side-effecting service request becomes a `Syscall` variant; the opcode is **data**, so
23/// adding a service does not add a new ABI shape (unlike the per-feature `Load*Policy` events today).
24#[derive(Debug, Clone)]
25pub enum Syscall {
26 /// Model-proposed tool call (today: the only thing through the governance gate).
27 Invoke(ToolCall),
28 /// Spawn a sub-agent (today: bypasses the gate).
29 Spawn(IsolationManifest),
30 /// Page long-term memory into working context (today: bypasses the gate).
31 PageIn(PageInRequest),
32 /// Persist a long-term memory entry.
33 WriteMemory(MemoryWriteRequest),
34 /// Retrieve long-term memory entries.
35 QueryMemory(MemoryQuery),
36 /// R3-1: append `count` nodes to the in-flight workflow DAG at runtime. Gating DAG growth through
37 /// the trap lets a `ResourceQuota` backstop a runaway loop-until-done (denied past
38 /// `max_workflow_nodes`); per-node spawns are still gated separately by `Spawn`.
39 SubmitNodes { count: usize },
40 /// M5/G1: an agent authors a whole workflow `spec` (`node_count` nodes). Bootstraps the DAG when
41 /// none is active, else flattens onto it — either way it is gated by the same `max_workflow_nodes`
42 /// quota as `SubmitNodes` (a spec is just a node batch with a bootstrap fast-path), so an
43 /// agent-authored harness cannot overgrow the DAG past the run's budget.
44 LoadWorkflow { node_count: usize },
45}
46
47impl Syscall {
48 /// Stable opcode label for audit/event-log categorization.
49 pub fn opcode(&self) -> &'static str {
50 match self {
51 Self::Invoke(_) => "invoke",
52 Self::Spawn(_) => "spawn",
53 Self::PageIn(_) => "page_in",
54 Self::WriteMemory(_) => "write_memory",
55 Self::QueryMemory(_) => "query_memory",
56 Self::SubmitNodes { .. } => "submit_nodes",
57 Self::LoadWorkflow { .. } => "load_workflow",
58 }
59 }
60}
61
62/// The kernel's adjudication of a [`Syscall`]. Generalizes [`GovernanceVerdict`]:
63/// `AskUser` becomes [`Disposition::Gate`] (suspend the calling task via the P2 TCB),
64/// which is where this primitive meets P2.
65#[derive(Debug, Clone)]
66pub enum Disposition {
67 /// Proceed as requested.
68 Allow,
69 /// Reject. `stage` names the gate stage that vetoed.
70 Deny { stage: &'static str, reason: String },
71 /// Suspend the calling task until an external party resolves it (e.g. human approval).
72 /// `reason` carries the human-readable justification (e.g. the governance `AskUser` reason).
73 Gate { wait: WaitReason, reason: String },
74 /// Accept but queue for later scheduling (backpressure).
75 Defer { slot: u32 },
76 /// Rejected by a rate limiter; retry permitted after the delay.
77 RateLimited { retry_after_ms: u64 },
78}
79
80impl Disposition {
81 pub fn label(&self) -> &'static str {
82 match self {
83 Self::Allow => "allow",
84 Self::Deny { .. } => "deny",
85 Self::Gate { .. } => "gate",
86 Self::Defer { .. } => "defer",
87 Self::RateLimited { .. } => "rate_limited",
88 }
89 }
90
91 /// Whether the syscall may proceed to execution now.
92 pub fn is_allowed(&self) -> bool {
93 matches!(self, Self::Allow)
94 }
95}
96
97/// Bridge from the existing tool-decision vocabulary. `AskUser` → `Gate(Approval)`: a tool
98/// awaiting human approval suspends the task, which M2+M1 realize via the TCB.
99impl From<GovernanceVerdict> for Disposition {
100 fn from(verdict: GovernanceVerdict) -> Self {
101 match verdict {
102 GovernanceVerdict::Allow => Disposition::Allow,
103 GovernanceVerdict::Deny { stage, reason } => Disposition::Deny { stage, reason },
104 GovernanceVerdict::RateLimited { retry_after_ms } => {
105 Disposition::RateLimited { retry_after_ms }
106 }
107 GovernanceVerdict::AskUser { reason } => Disposition::Gate {
108 wait: WaitReason::Approval,
109 reason,
110 },
111 }
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn verdict_allow_maps_to_allow() {
121 let d: Disposition = GovernanceVerdict::Allow.into();
122 assert!(d.is_allowed());
123 assert_eq!(d.label(), "allow");
124 }
125
126 #[test]
127 fn verdict_deny_preserves_stage_and_reason() {
128 let d: Disposition = GovernanceVerdict::Deny {
129 stage: "veto",
130 reason: "blocked".into(),
131 }
132 .into();
133 match d {
134 Disposition::Deny { stage, reason } => {
135 assert_eq!(stage, "veto");
136 assert_eq!(reason, "blocked");
137 }
138 other => panic!("expected Deny, got {other:?}"),
139 }
140 assert!(!Disposition::Deny { stage: "veto", reason: String::new() }.is_allowed());
141 }
142
143 #[test]
144 fn verdict_ask_user_maps_to_gate_approval() {
145 let d: Disposition = GovernanceVerdict::AskUser {
146 reason: "confirm".into(),
147 }
148 .into();
149 assert!(matches!(
150 &d,
151 Disposition::Gate { wait: WaitReason::Approval, reason } if reason == "confirm"
152 ));
153 assert!(!d.is_allowed());
154 }
155
156 #[test]
157 fn verdict_rate_limited_preserves_delay() {
158 let d: Disposition = GovernanceVerdict::RateLimited { retry_after_ms: 500 }.into();
159 assert!(matches!(d, Disposition::RateLimited { retry_after_ms: 500 }));
160 }
161
162 #[test]
163 fn syscall_opcode_labels() {
164 use crate::types::message::ToolCall;
165 let call = ToolCall {
166 id: "c1".into(),
167 name: "read".into(),
168 arguments: serde_json::json!({}),
169 };
170 assert_eq!(Syscall::Invoke(call).opcode(), "invoke");
171 }
172}