Skip to main content

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}