Skip to main content

chio_tower/
kernel_service.rs

1//! Tower service wrapper for kernel tool-call dispatch.
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex};
8use std::task::{Context, Poll};
9use std::time::{Duration, Instant};
10
11use tower::limit::{ConcurrencyLimit, ConcurrencyLimitLayer};
12use tower::load_shed::{LoadShed, LoadShedLayer};
13use tower::timeout::TimeoutLayer;
14use tower::Service;
15use tower_layer::Layer;
16
17/// Tenant identifier carried for tower-side admission layers.
18///
19/// The kernel remains responsible for capability validation and receipt
20/// semantics. This value is only used by tower middleware that needs an
21/// external partition key, such as later per-tenant limits.
22pub type TenantId = String;
23
24/// Default maximum number of tenant limiter buckets retained by a service.
25pub const DEFAULT_MAX_TENANT_CONCURRENCY_BUCKETS: usize = 1024;
26
27/// Tower service that dispatches tool-call requests through a shared kernel.
28#[derive(Clone)]
29pub struct KernelService {
30    kernel: Arc<chio_kernel::ChioKernel>,
31}
32
33impl KernelService {
34    /// Create a new kernel dispatch service.
35    pub fn new(kernel: Arc<chio_kernel::ChioKernel>) -> Self {
36        Self { kernel }
37    }
38
39    /// Return the shared kernel used by this service.
40    pub fn kernel(&self) -> &Arc<chio_kernel::ChioKernel> {
41        &self.kernel
42    }
43}
44
45/// Request accepted by [`KernelService`].
46pub struct KernelRequest {
47    /// Tool-call request evaluated by the kernel.
48    pub call: chio_kernel::ToolCallRequest,
49    /// Tenant partition key for tower middleware. Capability semantics stay
50    /// inside `chio-kernel`.
51    pub tenant_id: TenantId,
52}
53
54impl KernelRequest {
55    /// Create a new kernel service request.
56    pub fn new(call: chio_kernel::ToolCallRequest, tenant_id: impl Into<TenantId>) -> Self {
57        Self {
58            call,
59            tenant_id: tenant_id.into(),
60        }
61    }
62}
63
64/// Response produced by [`KernelService`].
65pub type KernelResponse = chio_kernel::ToolCallResponse;
66
67/// Errors returned by the kernel service stack.
68#[derive(Debug, thiserror::Error)]
69pub enum KernelServiceError {
70    /// Inner kernel evaluation failed.
71    #[error("kernel: {0}")]
72    Kernel(#[from] chio_kernel::KernelError),
73    /// Tenant or global service saturation caused load shedding.
74    #[error("overloaded")]
75    Overloaded,
76    /// The per-tenant bucket table is at capacity and this tenant has no bucket.
77    /// Distinct from `Overloaded` (a per-tenant concurrency shed) so a full table
78    /// is observable separately; maps to the same shed edge.
79    #[error("tenant bucket table is full")]
80    TenantTableFull,
81    /// Request exceeded the configured tower timeout.
82    #[error("timeout")]
83    Timeout,
84    /// Middleware returned an unexpected error shape.
85    #[error("middleware: {0}")]
86    Middleware(String),
87}
88
89impl Service<KernelRequest> for KernelService {
90    type Response = KernelResponse;
91    type Error = KernelServiceError;
92    type Future =
93        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
94
95    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
96        Poll::Ready(Ok(()))
97    }
98
99    fn call(&mut self, req: KernelRequest) -> Self::Future {
100        let kernel = Arc::clone(&self.kernel);
101
102        Box::pin(async move {
103            // Translate the kernel error explicitly rather than via the blanket
104            // `?`/`From` conversion so an RSS soft-ceiling shed surfaces as the
105            // retryable service overload variant (see `map_kernel_error`).
106            let response = kernel
107                .evaluate_tool_call(&req.call)
108                .await
109                .map_err(map_kernel_error)?;
110            Ok(response)
111        })
112    }
113}
114
115/// Translate a kernel evaluation error into the tower service error.
116///
117/// The RSS soft ceiling sheds new admissions with
118/// [`chio_kernel::KernelError::Overloaded`]. The blanket `From<KernelError>` maps
119/// that into the opaque [`KernelServiceError::Kernel`], which callers keying
120/// retry/backpressure on [`KernelServiceError::Overloaded`] (the variant tower
121/// load shedding produces) would treat as an ordinary kernel failure. Map
122/// `Overloaded` explicitly to the retryable service overload variant so an RSS
123/// shed reaches the same shed edge as tower-side load shedding. Every other
124/// kernel error keeps the `Kernel` shape.
125fn map_kernel_error(error: chio_kernel::KernelError) -> KernelServiceError {
126    match error {
127        chio_kernel::KernelError::Overloaded { .. } => KernelServiceError::Overloaded,
128        other => KernelServiceError::Kernel(other),
129    }
130}
131
132/// Trace layer for kernel service calls.
133#[derive(Clone, Debug, Default)]
134pub struct KernelTraceLayer;
135
136impl<S> Layer<S> for KernelTraceLayer {
137    type Service = KernelTraceService<S>;
138
139    fn layer(&self, inner: S) -> Self::Service {
140        KernelTraceService { inner }
141    }
142}
143
144/// Trace service emitted by [`KernelTraceLayer`].
145#[derive(Clone, Debug)]
146pub struct KernelTraceService<S> {
147    inner: S,
148}
149
150impl<S> Service<KernelRequest> for KernelTraceService<S>
151where
152    S: Service<KernelRequest, Response = KernelResponse, Error = KernelServiceError>
153        + Send
154        + 'static,
155    S::Future: Send + 'static,
156{
157    type Response = KernelResponse;
158    type Error = KernelServiceError;
159    type Future =
160        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
161
162    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
163        self.inner.poll_ready(cx)
164    }
165
166    fn call(&mut self, req: KernelRequest) -> Self::Future {
167        let request_id = req.call.request_id.clone();
168        let tenant_id = req.tenant_id.clone();
169        let tool_name = req.call.tool_name.clone();
170        let server_id = req.call.server_id.clone();
171        let started = Instant::now();
172        let future = self.inner.call(req);
173
174        Box::pin(async move {
175            tracing::debug!(
176                request_id = %request_id,
177                tenant_id = %tenant_id,
178                tool_name = %tool_name,
179                server_id = %server_id,
180                "kernel service request started"
181            );
182
183            let result = future.await;
184            match &result {
185                Ok(response) => {
186                    tracing::debug!(
187                        request_id = %response.request_id,
188                        tenant_id = %tenant_id,
189                        verdict = ?response.verdict,
190                        elapsed_ms = started.elapsed().as_millis(),
191                        "kernel service request finished"
192                    );
193                }
194                Err(error) => {
195                    tracing::warn!(
196                        request_id = %request_id,
197                        tenant_id = %tenant_id,
198                        elapsed_ms = started.elapsed().as_millis(),
199                        error = %error,
200                        "kernel service request failed"
201                    );
202                }
203            }
204            result
205        })
206    }
207}
208
209/// Default idle window after which an unused tenant bucket may be reaped to make
210/// room for a new tenant.
211const DEFAULT_TENANT_IDLE_REAP_SECS: u64 = 3600;
212
213/// Per-tenant concurrency limiter for kernel requests.
214///
215/// Each tenant key gets its own bounded tower concurrency service. Waiting for
216/// capacity in one tenant partition does not consume readiness for another
217/// tenant partition.
218#[derive(Clone, Debug)]
219pub struct TenantConcurrencyLimitLayer {
220    per_tenant_limit: usize,
221    max_tenants: usize,
222    tenant_idle_reap_secs: u64,
223}
224
225impl TenantConcurrencyLimitLayer {
226    /// Create a new per-tenant concurrency limit layer.
227    pub fn new(per_tenant_limit: usize) -> Self {
228        Self {
229            per_tenant_limit,
230            max_tenants: DEFAULT_MAX_TENANT_CONCURRENCY_BUCKETS,
231            tenant_idle_reap_secs: DEFAULT_TENANT_IDLE_REAP_SECS,
232        }
233    }
234
235    /// Set the maximum number of tenant limiter buckets retained at once.
236    pub fn with_max_tenants(mut self, max_tenants: usize) -> Self {
237        self.max_tenants = max_tenants;
238        self
239    }
240
241    /// Set the idle window after which an unused tenant bucket may be reaped so
242    /// a new tenant is not permanently blocked by a full table.
243    pub fn with_tenant_idle_reap_secs(mut self, secs: u64) -> Self {
244        self.tenant_idle_reap_secs = secs;
245        self
246    }
247}
248
249impl<S> Layer<S> for TenantConcurrencyLimitLayer {
250    type Service = TenantConcurrencyLimitService<S>;
251
252    fn layer(&self, inner: S) -> Self::Service {
253        TenantConcurrencyLimitService {
254            inner,
255            per_tenant_limit: self.per_tenant_limit,
256            max_tenants: self.max_tenants,
257            tenant_idle_reap_secs: self.tenant_idle_reap_secs,
258            tenants: Arc::new(Mutex::new(HashMap::new())),
259        }
260    }
261}
262
263/// Service emitted by [`TenantConcurrencyLimitLayer`].
264#[derive(Clone, Debug)]
265pub struct TenantConcurrencyLimitService<S> {
266    inner: S,
267    per_tenant_limit: usize,
268    max_tenants: usize,
269    tenant_idle_reap_secs: u64,
270    tenants: Arc<Mutex<HashMap<TenantId, TenantBucketEntry<S>>>>,
271}
272
273type TenantBucketService<S> = LoadShed<ConcurrencyLimit<S>>;
274/// A tenant's bucket service, its last-use instant (for idle reap), and a live
275/// in-flight-call counter so a bucket with active calls is never reaped.
276type TenantBucketEntry<S> = (TenantBucketService<S>, std::time::Instant, Arc<AtomicUsize>);
277
278/// RAII guard that decrements a tenant bucket's in-flight counter when a
279/// dispatched call finishes (or its future is dropped/cancelled). Held for the
280/// full duration of `call` so an idle-reap sweep skips buckets with active
281/// calls: reaping an active bucket and recreating a fresh semaphore would let a
282/// tenant exceed `per_tenant_limit`.
283#[derive(Debug)]
284struct InFlightGuard(Arc<AtomicUsize>);
285
286impl Drop for InFlightGuard {
287    fn drop(&mut self) {
288        self.0.fetch_sub(1, Ordering::SeqCst);
289    }
290}
291
292impl<S> TenantConcurrencyLimitService<S>
293where
294    S: Clone,
295{
296    fn service_for_tenant(
297        &self,
298        tenant_id: &TenantId,
299    ) -> Result<(TenantBucketService<S>, InFlightGuard), KernelServiceError> {
300        let mut tenants = self.tenants.lock().map_err(|_| {
301            KernelServiceError::Middleware("tenant concurrency limit state poisoned".to_string())
302        })?;
303
304        if let Some((service, last_use, in_flight)) = tenants.get_mut(tenant_id) {
305            *last_use = std::time::Instant::now();
306            in_flight.fetch_add(1, Ordering::SeqCst);
307            return Ok((service.clone(), InFlightGuard(Arc::clone(in_flight))));
308        }
309
310        if tenants.len() >= self.max_tenants {
311            // Reap the most-idle tenant so a new tenant is not permanently
312            // blocked; only then declare the table full. Never reap a bucket that
313            // still has in-flight calls: recreating its
314            // semaphore would let that tenant exceed `per_tenant_limit`.
315            let idle = std::time::Duration::from_secs(self.tenant_idle_reap_secs);
316            let victim = tenants
317                .iter()
318                .filter(|(_, (_, last, in_flight))| {
319                    last.elapsed() >= idle && in_flight.load(Ordering::SeqCst) == 0
320                })
321                .max_by_key(|(_, (_, last, _))| last.elapsed())
322                .map(|(k, _)| k.clone());
323            match victim {
324                Some(v) => {
325                    tenants.remove(&v);
326                }
327                None => return Err(KernelServiceError::TenantTableFull),
328            }
329        }
330
331        let service = ConcurrencyLimitLayer::new(self.per_tenant_limit).layer(self.inner.clone());
332        let service = LoadShedLayer::new().layer(service);
333        let in_flight = Arc::new(AtomicUsize::new(1));
334        let guard = InFlightGuard(Arc::clone(&in_flight));
335        tenants.insert(
336            tenant_id.clone(),
337            (service.clone(), std::time::Instant::now(), in_flight),
338        );
339        Ok((service, guard))
340    }
341}
342
343impl<S> Service<KernelRequest> for TenantConcurrencyLimitService<S>
344where
345    S: Service<KernelRequest, Error = KernelServiceError> + Clone + Send + 'static,
346    S::Future: Send + 'static,
347    S::Response: Send + 'static,
348{
349    type Response = S::Response;
350    type Error = KernelServiceError;
351    type Future =
352        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
353
354    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
355        Poll::Ready(Ok(()))
356    }
357
358    fn call(&mut self, req: KernelRequest) -> Self::Future {
359        let acquired = self.service_for_tenant(&req.tenant_id);
360
361        Box::pin(async move {
362            // Hold the in-flight guard for the whole call so a concurrent
363            // idle-reap sweep never evicts this tenant's active bucket.
364            let (mut service, _in_flight) = acquired?;
365            poll_ready_once(&mut service)?;
366            service.call(req).await.map_err(normalize_tower_error)
367        })
368    }
369}
370
371fn poll_ready_once<S>(service: &mut TenantBucketService<S>) -> Result<(), KernelServiceError>
372where
373    TenantBucketService<S>: Service<KernelRequest, Error = tower::BoxError>,
374{
375    let waker = std::task::Waker::noop();
376    let mut cx = Context::from_waker(waker);
377    match service.poll_ready(&mut cx) {
378        Poll::Ready(Ok(())) => Ok(()),
379        Poll::Ready(Err(error)) => Err(normalize_tower_error(error)),
380        Poll::Pending => Err(KernelServiceError::Overloaded),
381    }
382}
383
384#[derive(Clone, Debug, Default)]
385struct KernelTimeoutErrorLayer;
386
387impl<S> Layer<S> for KernelTimeoutErrorLayer {
388    type Service = KernelTimeoutErrorService<S>;
389
390    fn layer(&self, inner: S) -> Self::Service {
391        KernelTimeoutErrorService { inner }
392    }
393}
394
395#[derive(Clone, Debug)]
396struct KernelTimeoutErrorService<S> {
397    inner: S,
398}
399
400impl<S, Request> Service<Request> for KernelTimeoutErrorService<S>
401where
402    S: Service<Request, Error = tower::BoxError>,
403    S::Future: Send + 'static,
404    Request: Send + 'static,
405{
406    type Response = S::Response;
407    type Error = KernelServiceError;
408    type Future =
409        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
410
411    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
412        self.inner.poll_ready(cx).map_err(normalize_tower_error)
413    }
414
415    fn call(&mut self, request: Request) -> Self::Future {
416        let future = self.inner.call(request);
417        Box::pin(async move { future.await.map_err(normalize_tower_error) })
418    }
419}
420
421fn normalize_tower_error(error: tower::BoxError) -> KernelServiceError {
422    let error = match error.downcast::<KernelServiceError>() {
423        Ok(kernel_error) => return *kernel_error,
424        Err(error) => error,
425    };
426
427    if error.is::<tower::timeout::error::Elapsed>() {
428        return KernelServiceError::Timeout;
429    }
430
431    if error.is::<tower::load_shed::error::Overloaded>() {
432        return KernelServiceError::Overloaded;
433    }
434
435    KernelServiceError::Middleware(error.to_string())
436}
437
438/// Build the layered kernel service stack.
439///
440/// Request flow is trace, then timeout, then per-tenant load shedding and
441/// concurrency, then kernel dispatch. Auth prechecks wrap around this stack.
442pub fn build_layered(
443    kernel: Arc<chio_kernel::ChioKernel>,
444    per_tenant_limit: usize,
445    request_timeout: Duration,
446) -> impl Service<KernelRequest, Response = KernelResponse, Error = KernelServiceError> + Clone {
447    let service = KernelService::new(kernel);
448    let service = TenantConcurrencyLimitLayer::new(per_tenant_limit).layer(service);
449    let service = TimeoutLayer::new(request_timeout).layer(service);
450    let service = KernelTimeoutErrorLayer.layer(service);
451    KernelTraceLayer.layer(service)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    use chio_core_types::capability::scope::{ChioScope, Operation, ToolGrant};
459    use chio_core_types::crypto::Keypair;
460    use chio_kernel::{
461        ChioKernel, KernelConfig, KernelError, NestedFlowBridge, ToolCallOutput, ToolCallRequest,
462        ToolServerConnection, ToolServerStreamResult, DEFAULT_CHECKPOINT_BATCH_SIZE,
463        DEFAULT_MAX_STREAM_DURATION_SECS, DEFAULT_MAX_STREAM_TOTAL_BYTES,
464    };
465    use tower::ServiceExt;
466
467    struct EchoServer;
468
469    #[async_trait::async_trait]
470    impl ToolServerConnection for EchoServer {
471        fn server_id(&self) -> &str {
472            "srv-a"
473        }
474
475        fn tool_names(&self) -> Vec<String> {
476            vec!["echo".to_string()]
477        }
478
479        async fn invoke(
480            &self,
481            tool_name: &str,
482            arguments: serde_json::Value,
483            _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
484        ) -> Result<serde_json::Value, KernelError> {
485            Ok(serde_json::json!({
486                "tool": tool_name,
487                "arguments": arguments,
488            }))
489        }
490
491        async fn invoke_stream(
492            &self,
493            _tool_name: &str,
494            _arguments: serde_json::Value,
495            _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
496        ) -> Result<Option<ToolServerStreamResult>, KernelError> {
497            Ok(None)
498        }
499    }
500
501    #[test]
502    fn table_full_is_distinct_from_per_tenant_overload() {
503        // At cap, a third distinct tenant sees the distinct TenantTableFull (not
504        // the per-tenant Overloaded), and a non-idle table does not reap, so the
505        // third tenant is refused rather than admitted.
506        #[derive(Clone, Debug)]
507        struct MockInner;
508
509        let service = TenantConcurrencyLimitLayer::new(1)
510            .with_max_tenants(2)
511            .layer(MockInner);
512        assert!(service.service_for_tenant(&"tenant-a".to_string()).is_ok());
513        assert!(service.service_for_tenant(&"tenant-b".to_string()).is_ok());
514        match service.service_for_tenant(&"tenant-c".to_string()) {
515            Err(KernelServiceError::TenantTableFull) => {}
516            other => panic!("expected TenantTableFull, got {other:?}"),
517        }
518    }
519
520    #[test]
521    fn idle_tenant_is_reaped_to_admit_a_new_tenant() {
522        // With a zero idle window every existing tenant is immediately reapable,
523        // so a full table admits a new tenant by evicting the most-idle one.
524        #[derive(Clone, Debug)]
525        struct MockInner;
526
527        let service = TenantConcurrencyLimitLayer::new(1)
528            .with_max_tenants(2)
529            .with_tenant_idle_reap_secs(0)
530            .layer(MockInner);
531        assert!(service.service_for_tenant(&"tenant-a".to_string()).is_ok());
532        assert!(service.service_for_tenant(&"tenant-b".to_string()).is_ok());
533        assert!(
534            service.service_for_tenant(&"tenant-c".to_string()).is_ok(),
535            "an idle tenant should be reaped to admit a new one"
536        );
537    }
538
539    #[test]
540    fn in_flight_tenant_is_not_reaped_even_when_idle_timed() {
541        // A bucket with an in-flight call must never be reaped. Recreating its
542        // semaphore would let the tenant exceed per_tenant_limit.
543        #[derive(Clone, Debug)]
544        struct MockInner;
545
546        let service = TenantConcurrencyLimitLayer::new(1)
547            .with_max_tenants(1)
548            .with_tenant_idle_reap_secs(0)
549            .layer(MockInner);
550
551        // Hold tenant-a's in-flight guard so its bucket has an active call.
552        let held = match service.service_for_tenant(&"tenant-a".to_string()) {
553            Ok(acquired) => acquired,
554            Err(error) => panic!("tenant-a should be admitted: {error:?}"),
555        };
556
557        // Table is full (max_tenants=1). Even with a zero idle window
558        // (time-reapable), tenant-a has an in-flight call, so it must NOT be
559        // reaped and the new tenant is refused.
560        match service.service_for_tenant(&"tenant-b".to_string()) {
561            Err(KernelServiceError::TenantTableFull) => {}
562            other => {
563                panic!("expected TenantTableFull (active tenant not reaped), got {other:?}")
564            }
565        }
566
567        // Once the in-flight call finishes, the idle tenant becomes reapable.
568        drop(held);
569        assert!(
570            service.service_for_tenant(&"tenant-b".to_string()).is_ok(),
571            "an idle tenant with no in-flight call should be reaped"
572        );
573    }
574
575    fn make_config() -> KernelConfig {
576        KernelConfig {
577            keypair: Keypair::generate(),
578            ca_public_keys: vec![],
579            max_delegation_depth: 5,
580            policy_hash: "test-policy-hash".to_string(),
581            allow_sampling: false,
582            allow_sampling_tool_use: false,
583            allow_elicitation: false,
584            max_stream_duration_secs: DEFAULT_MAX_STREAM_DURATION_SECS,
585            max_stream_total_bytes: DEFAULT_MAX_STREAM_TOTAL_BYTES,
586            require_web3_evidence: false,
587            allow_ephemeral_receipt_log: true,
588            allow_ephemeral_revocation_store: true,
589            checkpoint_batch_size: DEFAULT_CHECKPOINT_BATCH_SIZE,
590            retention_config: None,
591            memory_budget: chio_kernel::MemoryBudgetConfig::defaults(),
592            deadlines: chio_kernel::HotPathDeadlineConfig::default(),
593        }
594    }
595
596    fn make_grant() -> ToolGrant {
597        ToolGrant {
598            server_id: "srv-a".to_string(),
599            tool_name: "echo".to_string(),
600            operations: vec![Operation::Invoke],
601            constraints: vec![],
602            max_invocations: None,
603            max_cost_per_invocation: None,
604            max_total_cost: None,
605            dpop_required: None,
606        }
607    }
608
609    fn make_scope() -> ChioScope {
610        ChioScope {
611            grants: vec![make_grant()],
612            ..ChioScope::default()
613        }
614    }
615
616    fn make_kernel_request(kernel: &ChioKernel) -> KernelRequest {
617        let agent_keypair = Keypair::generate();
618        let capability = kernel
619            .issue_capability(&agent_keypair.public_key(), make_scope(), 60)
620            .unwrap_or_else(|error| panic!("issue capability failed: {error}"));
621        let call = ToolCallRequest {
622            request_id: "req-kernel-service".to_string(),
623            capability,
624            tool_name: "echo".to_string(),
625            server_id: "srv-a".to_string(),
626            agent_id: agent_keypair.public_key().to_hex(),
627            arguments: serde_json::json!({ "message": "hello" }),
628            dpop_proof: None,
629            execution_nonce: None,
630            governed_intent: None,
631            approval_token: None,
632            approval_tokens: Vec::new(),
633            threshold_approval_proposal: None,
634            supplemental_authorization: None,
635            model_metadata: None,
636            federated_origin_kernel_id: None,
637        };
638        KernelRequest::new(call, "tenant-a")
639    }
640
641    #[tokio::test]
642    async fn kernel_service_dispatches_through_kernel() {
643        let mut kernel = ChioKernel::new(make_config());
644        kernel.register_tool_server(Box::new(EchoServer));
645        let request = make_kernel_request(&kernel);
646        let mut service = build_layered(Arc::new(kernel), 16, Duration::from_secs(5));
647
648        let response = service
649            .ready()
650            .await
651            .unwrap_or_else(|error| panic!("service ready failed: {error}"))
652            .call(request)
653            .await
654            .unwrap_or_else(|error| panic!("service call failed: {error}"));
655
656        assert_eq!(response.verdict, chio_kernel::Verdict::Allow);
657        match response.output {
658            Some(ToolCallOutput::Value(value)) => {
659                assert_eq!(value["tool"], "echo");
660                assert_eq!(value["arguments"]["message"], "hello");
661            }
662            other => panic!("expected value output, got {other:?}"),
663        }
664        assert_eq!(response.receipt.body().tool_name, "echo");
665    }
666
667    #[tokio::test]
668    async fn timeout_layer_maps_elapsed_error() {
669        let inner = tower::service_fn(|_request: KernelRequest| async {
670            tokio::time::sleep(Duration::from_millis(50)).await;
671            Ok::<(), KernelServiceError>(())
672        });
673        let timeout = TimeoutLayer::new(Duration::from_millis(1)).layer(inner);
674        let mut service = KernelTimeoutErrorLayer.layer(timeout);
675        let kernel = ChioKernel::new(make_config());
676        let request = make_kernel_request(&kernel);
677
678        let result = service
679            .ready()
680            .await
681            .unwrap_or_else(|error| panic!("service ready failed: {error}"))
682            .call(request)
683            .await;
684        let Err(error) = result else {
685            panic!("timeout should fail");
686        };
687
688        assert!(matches!(error, KernelServiceError::Timeout));
689    }
690
691    #[test]
692    fn rss_shed_kernel_overload_maps_to_service_overloaded() {
693        // An RSS soft-ceiling shed surfaces as KernelError::Overloaded and must
694        // reach the RETRYABLE service overload variant, not the opaque Kernel
695        // wrapper, so callers keying retry/backpressure on Overloaded treat the
696        // shed as backpressure.
697        let mapped = map_kernel_error(KernelError::Overloaded {
698            resource: chio_kernel::OverloadResource::Allocation,
699        });
700        assert!(
701            matches!(mapped, KernelServiceError::Overloaded),
702            "an RSS shed must map to the retryable Overloaded variant, got {mapped:?}"
703        );
704
705        // The blanket `From`/`?` conversion maps the same shed to the opaque
706        // Kernel variant, which is why call() must use map_kernel_error instead.
707        let via_from: KernelServiceError = KernelError::Overloaded {
708            resource: chio_kernel::OverloadResource::Allocation,
709        }
710        .into();
711        assert!(
712            matches!(via_from, KernelServiceError::Kernel(_)),
713            "the blanket From maps Overloaded to Kernel; call() must use map_kernel_error"
714        );
715
716        // Every other kernel error keeps the Kernel shape.
717        let other = map_kernel_error(KernelError::Internal("boom".to_string()));
718        assert!(matches!(other, KernelServiceError::Kernel(_)));
719    }
720
721    struct ParkingServer {
722        invoked: Arc<std::sync::atomic::AtomicBool>,
723    }
724
725    #[async_trait::async_trait]
726    impl ToolServerConnection for ParkingServer {
727        fn server_id(&self) -> &str {
728            "srv-a"
729        }
730
731        fn tool_names(&self) -> Vec<String> {
732            vec!["echo".to_string()]
733        }
734
735        async fn invoke(
736            &self,
737            _tool_name: &str,
738            _arguments: serde_json::Value,
739            _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
740        ) -> Result<serde_json::Value, KernelError> {
741            self.invoked
742                .store(true, std::sync::atomic::Ordering::SeqCst);
743            std::future::pending::<Result<serde_json::Value, KernelError>>().await
744        }
745
746        async fn invoke_stream(
747            &self,
748            _tool_name: &str,
749            _arguments: serde_json::Value,
750            _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
751        ) -> Result<Option<ToolServerStreamResult>, KernelError> {
752            Ok(None)
753        }
754    }
755
756    #[tokio::test(start_paused = true)]
757    async fn build_layered_timeout_drop_records_cancellation_receipt() {
758        let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
759        let mut kernel = ChioKernel::new(make_config());
760        kernel.register_tool_server(Box::new(ParkingServer {
761            invoked: Arc::clone(&invoked),
762        }));
763        let request = make_kernel_request(&kernel);
764        let kernel = Arc::new(kernel);
765        let mut service = build_layered(Arc::clone(&kernel), 16, Duration::from_millis(1));
766
767        let result = service
768            .ready()
769            .await
770            .unwrap_or_else(|error| panic!("service ready failed: {error}"))
771            .call(request)
772            .await;
773        let Err(error) = result else {
774            panic!("a parked dispatch must time out");
775        };
776        assert!(matches!(error, KernelServiceError::Timeout));
777        assert!(
778            invoked.load(std::sync::atomic::Ordering::SeqCst),
779            "tool dispatch must have been entered before the timeout elapsed"
780        );
781
782        let receipt_log = kernel.receipt_log();
783        assert_eq!(
784            receipt_log.len(),
785            1,
786            "build_layered timeout must record exactly one cancellation receipt"
787        );
788        let Some(receipt) = receipt_log.get(0) else {
789            panic!("cancellation receipt missing from the kernel receipt log");
790        };
791        assert!(receipt.is_cancelled());
792    }
793}