chio_kernel/runtime.rs
1use chio_core::capability::{
2 governance::{GovernedApprovalToken, GovernedTransactionIntent, ThresholdApprovalProposal},
3 scope::ModelMetadata,
4 token::CapabilityToken,
5};
6use chio_core::receipt::body::ChioReceipt;
7use chio_core::session::{
8 CreateElicitationOperation, CreateElicitationResult, CreateMessageOperation,
9 CreateMessageResult, OperationContext, OperationTerminalState, RequestId, RootDefinition,
10};
11
12use crate::dpop;
13use crate::execution_nonce::SignedExecutionNonce;
14use crate::{AgentId, KernelError, ServerId};
15
16/// Verdict of a guard or capability evaluation.
17///
18/// This is the kernel's own verdict type, distinct from `chio_core::receipt::decision::Decision`.
19/// The kernel uses this internally; it maps to `chio_core::receipt::decision::Decision` when
20/// building receipts.
21///
22/// The `PendingApproval` variant is a marker: the payload (`ApprovalRequest`)
23/// is returned separately via [`crate::approval::HitlVerdict`] so existing
24/// call sites that pattern-match on `Verdict` and rely on its `Copy` semantics
25/// keep compiling without change. The public contract is: `Allow`, `Deny`, and
26/// `PendingApproval` are the three possible outcomes of guard evaluation, and
27/// callers receive the full approval request via the richer HITL API surface
28/// when they need it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Verdict {
31 /// The action is allowed.
32 Allow,
33 /// The action is denied.
34 Deny,
35 /// The action is suspended pending a human decision. Look up the
36 /// associated `ApprovalRequest` via the HITL API.
37 PendingApproval,
38}
39
40/// A tool call request as seen by the kernel.
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub struct ToolCallRequest {
43 /// Unique request identifier.
44 pub request_id: String,
45 /// The signed capability token authorizing this call.
46 pub capability: CapabilityToken,
47 /// The tool to invoke.
48 pub tool_name: String,
49 /// The server hosting the tool.
50 pub server_id: ServerId,
51 /// The calling agent's identifier (hex-encoded public key).
52 pub agent_id: AgentId,
53 /// Tool arguments.
54 pub arguments: serde_json::Value,
55 /// Optional DPoP proof. Required when the matched grant has `dpop_required == Some(true)`.
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub dpop_proof: Option<dpop::DpopProof>,
58 /// Optional execution nonce presented for a strict nonce-protected
59 /// dispatch. The nonce is minted by an allow evaluation and consumed
60 /// exactly once before the tool server is invoked.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub execution_nonce: Option<SignedExecutionNonce>,
63 /// Optional governed transaction intent bound to this invocation.
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub governed_intent: Option<GovernedTransactionIntent>,
66 /// Optional approval token authorizing this governed invocation.
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub approval_token: Option<GovernedApprovalToken>,
69 /// Bounded threshold approval set. Requests must not also set `approval_token`.
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub approval_tokens: Vec<GovernedApprovalToken>,
72 /// Policy-authority-signed proposal binding a threshold approval set.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub threshold_approval_proposal: Option<ThresholdApprovalProposal>,
75 /// Opaque authenticated extension for a composition-installed verifier.
76 /// The kernel never accepts quota authority directly from this wrapper.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub supplemental_authorization:
79 Option<chio_core::capability::supplemental_authorization::OpaqueSupplementalAuthorization>,
80 /// Optional metadata describing the model executing the calling
81 /// agent. Consumed by `Constraint::ModelConstraint` enforcement.
82 ///
83 /// Absent when callers omit it; when the matched grant carries a
84 /// `ModelConstraint` with any requirement, the call is denied.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub model_metadata: Option<ModelMetadata>,
87 /// Identifier of the origin kernel when this request crosses a federation
88 /// boundary (agent in Org A invoking a tool in Org B). When set, the
89 /// local (tool-host) kernel persists the signed receipt locally before
90 /// requesting bilateral co-signing from the origin kernel. Absent for
91 /// intra-org calls.
92 ///
93 /// The field is skipped from wire serialization when `None` so the
94 /// wire format stays byte-identical.
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub federated_origin_kernel_id: Option<String>,
97}
98
99impl ToolCallRequest {
100 pub fn validate_authorization_extensions(&self) -> Result<(), chio_core::Error> {
101 self.approval_artifact_digest()?;
102 Ok(())
103 }
104
105 pub fn approval_artifact_digest(&self) -> Result<Option<String>, chio_core::Error> {
106 if self.approval_token.is_some() && !self.approval_tokens.is_empty() {
107 return Err(chio_core::Error::CanonicalJson(
108 "request supplies both singular and threshold approval tokens".to_string(),
109 ));
110 }
111 if let Some(token) = self.approval_token.as_ref() {
112 return token.artifact_digest().map(Some);
113 }
114 if self.approval_tokens.is_empty() {
115 return if self.threshold_approval_proposal.is_none() {
116 Ok(None)
117 } else {
118 Err(chio_core::Error::CanonicalJson(
119 "threshold approval proposal has no approval tokens".to_string(),
120 ))
121 };
122 }
123 if self.approval_tokens.len()
124 > chio_core::capability::threshold_approval::MAX_THRESHOLD_APPROVAL_TOKENS
125 {
126 return Err(chio_core::Error::CanonicalJson(format!(
127 "threshold approval set exceeds {} tokens",
128 chio_core::capability::threshold_approval::MAX_THRESHOLD_APPROVAL_TOKENS
129 )));
130 }
131 let proposal = self.threshold_approval_proposal.as_ref().ok_or_else(|| {
132 chio_core::Error::CanonicalJson(
133 "threshold approval tokens have no signed proposal".to_string(),
134 )
135 })?;
136 let token_digests = self
137 .approval_tokens
138 .iter()
139 .map(GovernedApprovalToken::artifact_digest)
140 .collect::<Result<Vec<_>, chio_core::Error>>()?;
141 chio_core::capability::governance::VerifiedApprovalSetBody::new(token_digests, proposal)?
142 .approval_set_hash()
143 .map(Some)
144 }
145}
146
147/// The kernel's response to a tool call request.
148///
149/// The `execution_nonce` field is a sibling so the `Verdict` enum can keep
150/// its `Copy` semantics. The nonce is only populated for `Verdict::Allow`
151/// and only when the kernel has an `ExecutionNonceConfig` installed;
152/// non-allow responses and nonce-disabled deployments continue to carry
153/// `None` here.
154#[derive(Debug)]
155pub struct ToolCallResponse {
156 /// Correlation identifier (matches the request).
157 pub request_id: String,
158 /// The kernel's verdict.
159 pub verdict: Verdict,
160 /// The tool's output payload, which may be a direct value or a stream.
161 pub output: Option<ToolCallOutput>,
162 /// Denial reason (populated when verdict is Deny).
163 pub reason: Option<String>,
164 /// Explicit terminal lifecycle state for this request.
165 pub terminal_state: OperationTerminalState,
166 /// Signed receipt attesting to this decision.
167 pub receipt: ChioReceipt,
168 /// Short-lived, single-use execution nonce bound to this allow verdict.
169 /// Populated only on `Verdict::Allow` when an `ExecutionNonceConfig` is
170 /// installed on the kernel. Deployments without a config leave this
171 /// `None` and keep working.
172 ///
173 /// Boxed so the deny/cancel/incomplete hot paths (which all carry
174 /// `None`) don't widen the `SessionOperationResponse::ToolCall`
175 /// variant and trip clippy's `large_enum_variant`.
176 pub execution_nonce: Option<Box<SignedExecutionNonce>>,
177}
178
179/// Streamed tool output emitted before the final tool response frame.
180#[derive(Debug, Clone, PartialEq)]
181pub struct ToolCallChunk {
182 pub data: serde_json::Value,
183}
184
185/// Complete streamed output captured by the kernel.
186#[derive(Debug, Clone, PartialEq)]
187pub struct ToolCallStream {
188 pub chunks: Vec<ToolCallChunk>,
189}
190
191impl ToolCallStream {
192 pub fn chunk_count(&self) -> u64 {
193 self.chunks.len() as u64
194 }
195}
196
197/// Sum the canonical byte size of a materialized stream and deny with
198/// `Overloaded { StreamBytes }` if it exceeds `max_total_bytes` (0 = unlimited).
199/// Uses the same per-chunk measurement as truncate_stream_to_limits, so the
200/// at-arrival count and the finalize-time count agree by construction.
201pub fn enforce_stream_byte_limit(
202 stream: &ToolCallStream,
203 max_total_bytes: u64,
204) -> Result<(), KernelError> {
205 if max_total_bytes == 0 {
206 return Ok(());
207 }
208 let mut total: u64 = 0;
209 for chunk in &stream.chunks {
210 let bytes = crate::canonical_json_bytes(&chunk.data)
211 .map_err(|e| KernelError::Internal(format!("failed to size stream chunk: {e}")))?;
212 total = total.saturating_add(bytes.len() as u64);
213 if total > max_total_bytes {
214 return Err(KernelError::Overloaded {
215 resource: crate::OverloadResource::StreamBytes,
216 });
217 }
218 }
219 Ok(())
220}
221
222/// Fallible per-chunk push for KernelError-returning accumulators. Denies before
223/// materializing past `max_total_bytes` (StreamBytes) OR past `max_chunks`
224/// retained chunks (StreamChunks), and maps a failed allocation under strict
225/// overcommit to a typed deny (Allocation) rather than an abort.
226///
227/// The chunk-count bound closes the tiny-chunk gap in the byte-only bound: a
228/// connector using this as its advertised accumulation-time limit could otherwise
229/// accept millions of tiny chunks that each stay under `max_total_bytes` while
230/// `acc` retains millions of `ToolCallChunk` objects (and receipt signing later
231/// allocates a hash per chunk). Both caps use `0 = unlimited`.
232pub fn push_chunk_bounded(
233 acc: &mut Vec<ToolCallChunk>,
234 running_bytes: &mut u64,
235 chunk: ToolCallChunk,
236 max_total_bytes: u64,
237 max_chunks: u64,
238) -> Result<(), KernelError> {
239 // Chunk-count bound: shed before retaining another chunk when the retained
240 // count is already at the cap, so a flood of tiny chunks under the byte cap
241 // still cannot grow `acc` (or the per-chunk signing preimage) without bound.
242 if max_chunks > 0 && acc.len() as u64 >= max_chunks {
243 return Err(KernelError::Overloaded {
244 resource: crate::OverloadResource::StreamChunks,
245 });
246 }
247 let chunk_bytes = crate::canonical_json_bytes(&chunk.data)
248 .map_err(|e| KernelError::Internal(format!("failed to size stream chunk: {e}")))?
249 .len() as u64;
250 let next = running_bytes.saturating_add(chunk_bytes);
251 if max_total_bytes > 0 && next > max_total_bytes {
252 return Err(KernelError::Overloaded {
253 resource: crate::OverloadResource::StreamBytes,
254 });
255 }
256 acc.try_reserve(1).map_err(|_| KernelError::Overloaded {
257 resource: crate::OverloadResource::Allocation,
258 })?;
259 acc.push(chunk);
260 *running_bytes = next;
261 Ok(())
262}
263
264/// Output produced by a tool invocation.
265#[derive(Debug, Clone, PartialEq)]
266pub enum ToolCallOutput {
267 Value(serde_json::Value),
268 Stream(ToolCallStream),
269}
270
271/// Stream-capable tool-server result.
272#[derive(Debug, Clone, PartialEq)]
273pub enum ToolServerStreamResult {
274 Complete(ToolCallStream),
275 Incomplete {
276 stream: ToolCallStream,
277 reason: String,
278 },
279}
280
281/// Tool-server output produced after validation and guard checks.
282#[derive(Debug, Clone, PartialEq)]
283pub enum ToolServerOutput {
284 Value(serde_json::Value),
285 Stream(ToolServerStreamResult),
286}
287
288/// Bridge exposed to tool-server implementations while a parent request is in flight.
289///
290/// Wrapped servers can use this to trigger negotiated server-to-client requests such as
291/// `roots/list` and `sampling/createMessage`, or to surface wrapped MCP notifications,
292/// without escaping kernel mediation.
293pub trait NestedFlowBridge: Send {
294 fn parent_request_id(&self) -> &RequestId;
295
296 fn poll_parent_cancellation(&mut self) -> Result<(), KernelError> {
297 Ok(())
298 }
299
300 fn list_roots(&mut self) -> Result<Vec<RootDefinition>, KernelError>;
301
302 fn create_message(
303 &mut self,
304 operation: CreateMessageOperation,
305 ) -> Result<CreateMessageResult, KernelError>;
306
307 fn create_elicitation(
308 &mut self,
309 operation: CreateElicitationOperation,
310 ) -> Result<CreateElicitationResult, KernelError>;
311
312 fn notify_elicitation_completed(&mut self, elicitation_id: &str) -> Result<(), KernelError>;
313
314 fn notify_resource_updated(&mut self, uri: &str) -> Result<(), KernelError>;
315
316 fn notify_resources_list_changed(&mut self) -> Result<(), KernelError>;
317}
318
319/// Raw client transport used by the kernel to service nested flows on behalf of a parent request.
320///
321/// The kernel owns lineage, policy, and in-flight bookkeeping. Implementors only move the nested
322/// request or notification across the client transport and return the decoded response.
323pub trait NestedFlowClient: Send {
324 fn poll_parent_cancellation(
325 &mut self,
326 _parent_context: &OperationContext,
327 ) -> Result<(), KernelError> {
328 Ok(())
329 }
330
331 fn list_roots(
332 &mut self,
333 parent_context: &OperationContext,
334 child_context: &OperationContext,
335 ) -> Result<Vec<RootDefinition>, KernelError>;
336
337 fn create_message(
338 &mut self,
339 parent_context: &OperationContext,
340 child_context: &OperationContext,
341 operation: &CreateMessageOperation,
342 ) -> Result<CreateMessageResult, KernelError>;
343
344 fn create_elicitation(
345 &mut self,
346 parent_context: &OperationContext,
347 child_context: &OperationContext,
348 operation: &CreateElicitationOperation,
349 ) -> Result<CreateElicitationResult, KernelError>;
350
351 fn notify_elicitation_completed(
352 &mut self,
353 parent_context: &OperationContext,
354 elicitation_id: &str,
355 ) -> Result<(), KernelError>;
356
357 fn notify_resource_updated(
358 &mut self,
359 parent_context: &OperationContext,
360 uri: &str,
361 ) -> Result<(), KernelError>;
362
363 fn notify_resources_list_changed(
364 &mut self,
365 parent_context: &OperationContext,
366 ) -> Result<(), KernelError>;
367}
368
369/// Cost reported by a tool server after invocation.
370///
371/// Tool servers that track monetary costs override `invoke_with_cost` and
372/// return this struct. Servers that do not override return `None` via the
373/// default implementation, and the kernel charges `max_cost_per_invocation`
374/// as a worst-case debit.
375#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
376pub struct ToolInvocationCost {
377 /// Cost in the currency's smallest unit (e.g. cents for USD).
378 pub units: u64,
379 /// ISO 4217 currency code.
380 pub currency: String,
381 /// Optional cost breakdown for audit.
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub breakdown: Option<serde_json::Value>,
384}
385
386/// Trait representing a connection to a tool server.
387///
388/// The kernel holds one `ToolServerConnection` per registered server. In
389/// production this is an mTLS connection over UDS or TCP. For testing,
390/// an in-process implementation can be used.
391#[async_trait::async_trait]
392pub trait ToolServerConnection: Send + Sync {
393 /// The server's unique identifier.
394 fn server_id(&self) -> &str;
395
396 /// List the tool names available on this server.
397 fn tool_names(&self) -> Vec<String>;
398
399 /// Return whether the registered tool is explicitly declared read-only.
400 ///
401 /// The conservative default keeps unannotated tools side-effecting for
402 /// durable admission. Implementations should return `true` only from
403 /// authenticated manifest metadata owned by the registered connection.
404 fn tool_is_read_only(&self, _tool_name: &str) -> bool {
405 false
406 }
407
408 /// Invoke a tool on this server. The kernel has already validated the
409 /// capability and run guards before calling this.
410 async fn invoke(
411 &self,
412 tool_name: &str,
413 arguments: serde_json::Value,
414 nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
415 ) -> Result<serde_json::Value, KernelError>;
416
417 /// Invoke a tool and optionally report the actual cost of the invocation.
418 ///
419 /// Tool servers that track monetary costs should override this method.
420 /// The default implementation delegates to `invoke` and returns `None`
421 /// cost, meaning the kernel will charge `max_cost_per_invocation` as
422 /// the worst-case debit.
423 async fn invoke_with_cost(
424 &self,
425 tool_name: &str,
426 arguments: serde_json::Value,
427 nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
428 ) -> Result<(serde_json::Value, Option<ToolInvocationCost>), KernelError> {
429 let value = self
430 .invoke(tool_name, arguments, nested_flow_bridge)
431 .await?;
432 Ok((value, None))
433 }
434
435 /// Whether this server measures the realized cost of an invocation it
436 /// dispatches.
437 ///
438 /// The default is `true`: a server that returns `None` cost from
439 /// `invoke_with_cost` is asserting that the realized cost equals the
440 /// authorized ceiling, and the kernel reconciles and settles that as a
441 /// completed spend.
442 ///
443 /// A server that returns `false` does not execute the target tool and
444 /// cannot measure a realized cost (for example a pre-execution
445 /// authorization gate that dispatches a pass-through while the real tool
446 /// runs elsewhere). For such a server the kernel reverses the
447 /// pre-execution hold and signs a provisional, unreconciled receipt
448 /// instead of a settled authoritative spend, since no cost was realized on
449 /// this path. Real reconciliation happens at the execution site.
450 fn measures_realized_cost(&self) -> bool {
451 true
452 }
453
454 /// Invoke a tool that can emit multiple streamed chunks before its final terminal state.
455 ///
456 /// Servers that do not support streaming can ignore this and rely on `invoke`.
457 async fn invoke_stream(
458 &self,
459 tool_name: &str,
460 arguments: serde_json::Value,
461 nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
462 ) -> Result<Option<ToolServerStreamResult>, KernelError> {
463 let _ = (tool_name, arguments, nested_flow_bridge);
464 Ok(None)
465 }
466
467 /// Drain asynchronous events emitted after a tool invocation has already returned.
468 ///
469 /// Native tool servers can use this to surface late URL-elicitation completions and
470 /// catalog/resource notifications without depending on a still-live request-local bridge.
471 async fn drain_events(&self) -> Result<Vec<ToolServerEvent>, KernelError> {
472 Ok(vec![])
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
477pub enum ToolServerEvent {
478 ElicitationCompleted { elicitation_id: String },
479 ResourceUpdated { uri: String },
480 ResourcesListChanged,
481 ToolsListChanged,
482 PromptsListChanged,
483}