Skip to main content

zeph_core/
runtime_layer.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `RuntimeLayer` trait: middleware hooks for LLM calls and tool dispatch (#2286).
5//!
6//! Provides interception points before and after each LLM chat call and each tool execution.
7//! Layers are composed in a stack: each layer is called in order, and any layer may
8//! short-circuit the actual call by returning `Some(result)` from `before_chat` or `before_tool`.
9//!
10//! # MVP
11//!
12//! No layers are registered at bootstrap — the `runtime_layers` vec in `Agent` defaults to empty,
13//! making the hook loops zero-cost (no iteration, no allocation).
14//!
15//! Future layers (rate limiting, guardrails, cost tracking, audit logging) add themselves to
16//! the vec at bootstrap without modifying the agent loop.
17//!
18//! # Implementation note
19//!
20//! Default implementations return `Box::pin(std::future::ready(...))`. This allocates once per
21//! call per registered layer. For the MVP empty-vec case, no allocation occurs. Real layers
22//! should keep their work minimal to avoid blocking the agent loop.
23
24use std::future::Future;
25use std::pin::Pin;
26
27use zeph_llm::provider::{ChatResponse, Message, ToolDefinition};
28use zeph_tools::ToolError;
29use zeph_tools::executor::{ToolCall, ToolOutput};
30
31/// Short-circuit outcome returned by a layer that blocks tool execution.
32///
33/// Carries both the result to inject in place of the real tool call and a human-readable
34/// `reason` string that is propagated to `ZEPH_DENY_REASON` in `permission_denied` hooks.
35pub struct LayerDenial {
36    /// Result injected in place of the actual tool execution.
37    pub result: Result<Option<ToolOutput>, ToolError>,
38    /// Human-readable reason exposed via the `ZEPH_DENY_REASON` hook environment variable.
39    pub reason: String,
40}
41
42/// Short-circuit result type for `before_tool`: `Some(denial)` bypasses tool execution.
43pub type BeforeToolResult = Option<LayerDenial>;
44
45/// Context available to runtime layers during interception.
46#[derive(Debug)]
47pub struct LayerContext<'a> {
48    /// The current conversation ID, if known.
49    pub conversation_id: Option<&'a str>,
50    /// The agent turn counter (increments per user message).
51    pub turn_number: u32,
52}
53
54/// Middleware layer that wraps LLM calls and tool dispatch.
55///
56/// Layers are composed in a stack; each layer may inspect, modify, or short-circuit
57/// the request before passing it to the next layer (or the real executor).
58///
59/// All methods have default implementations that are no-ops, so implementors only need
60/// to override the hooks they care about.
61///
62/// # Short-circuiting
63///
64/// Returning `Some(result)` from `before_chat` or `before_tool` bypasses the actual
65/// LLM call or tool execution. Subsequent layers are still called with `after_chat` /
66/// `after_tool` using the short-circuit result.
67pub trait RuntimeLayer: Send + Sync {
68    /// Called before an LLM chat call.
69    ///
70    /// Return `Some(response)` to short-circuit the actual LLM call.
71    /// Return `None` to proceed normally.
72    fn before_chat<'a>(
73        &'a self,
74        _ctx: &'a LayerContext<'_>,
75        _messages: &'a [Message],
76        _tools: &'a [ToolDefinition],
77    ) -> Pin<Box<dyn Future<Output = Option<ChatResponse>> + Send + 'a>> {
78        Box::pin(std::future::ready(None))
79    }
80
81    /// Called after an LLM chat call completes (or was short-circuited).
82    fn after_chat<'a>(
83        &'a self,
84        _ctx: &'a LayerContext<'_>,
85        _response: &'a ChatResponse,
86    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
87        Box::pin(std::future::ready(()))
88    }
89
90    /// Called before tool execution.
91    ///
92    /// Return `Some(result)` to short-circuit the actual tool execution.
93    /// Return `None` to proceed normally.
94    fn before_tool<'a>(
95        &'a self,
96        _ctx: &'a LayerContext<'_>,
97        _call: &'a ToolCall,
98    ) -> Pin<Box<dyn Future<Output = BeforeToolResult> + Send + 'a>> {
99        Box::pin(std::future::ready(None))
100    }
101
102    /// Called after tool execution completes (or was short-circuited).
103    fn after_tool<'a>(
104        &'a self,
105        _ctx: &'a LayerContext<'_>,
106        _call: &'a ToolCall,
107        _result: &'a Result<Option<ToolOutput>, ToolError>,
108    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
109        Box::pin(std::future::ready(()))
110    }
111}
112
113/// No-op layer that passes everything through unchanged.
114///
115/// Useful as a placeholder, for testing, or as a base for custom layers that only
116/// need to override a subset of hooks.
117pub struct NoopLayer;
118
119impl RuntimeLayer for NoopLayer {}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::assert_matches;
125    use zeph_llm::provider::Role;
126
127    struct CountingLayer {
128        before_chat_calls: std::sync::atomic::AtomicU32,
129        after_chat_calls: std::sync::atomic::AtomicU32,
130    }
131
132    impl CountingLayer {
133        fn new() -> Self {
134            Self {
135                before_chat_calls: std::sync::atomic::AtomicU32::new(0),
136                after_chat_calls: std::sync::atomic::AtomicU32::new(0),
137            }
138        }
139    }
140
141    impl RuntimeLayer for CountingLayer {
142        fn before_chat<'a>(
143            &'a self,
144            _ctx: &'a LayerContext<'_>,
145            _messages: &'a [Message],
146            _tools: &'a [ToolDefinition],
147        ) -> Pin<Box<dyn Future<Output = Option<ChatResponse>> + Send + 'a>> {
148            self.before_chat_calls
149                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
150            Box::pin(std::future::ready(None))
151        }
152
153        fn after_chat<'a>(
154            &'a self,
155            _ctx: &'a LayerContext<'_>,
156            _response: &'a ChatResponse,
157        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
158            self.after_chat_calls
159                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
160            Box::pin(std::future::ready(()))
161        }
162    }
163
164    #[test]
165    fn noop_layer_compiles_and_is_runtime_layer() {
166        // Compile-time test: NoopLayer must implement RuntimeLayer.
167        fn assert_runtime_layer<T: RuntimeLayer>() {}
168        assert_runtime_layer::<NoopLayer>();
169    }
170
171    #[tokio::test]
172    async fn noop_layer_before_chat_returns_none() {
173        let layer = NoopLayer;
174        let ctx = LayerContext {
175            conversation_id: None,
176            turn_number: 0,
177        };
178        let result = layer.before_chat(&ctx, &[], &[]).await;
179        assert!(result.is_none());
180    }
181
182    #[tokio::test]
183    async fn noop_layer_before_tool_returns_none() {
184        let layer = NoopLayer;
185        let ctx = LayerContext {
186            conversation_id: None,
187            turn_number: 0,
188        };
189        let call = ToolCall {
190            tool_id: "shell".into(),
191            params: serde_json::Map::new(),
192            caller_id: None,
193            context: None,
194
195            tool_call_id: String::new(),
196            skill_name: None,
197        };
198        let result = layer.before_tool(&ctx, &call).await;
199        assert!(result.is_none());
200    }
201
202    #[tokio::test]
203    async fn layer_hooks_are_called() {
204        use std::sync::Arc;
205        let layer = Arc::new(CountingLayer::new());
206        let ctx = LayerContext {
207            conversation_id: Some("conv-1"),
208            turn_number: 3,
209        };
210        let resp = ChatResponse::Text("hello".into());
211
212        let _ = layer.before_chat(&ctx, &[], &[]).await;
213        layer.after_chat(&ctx, &resp).await;
214
215        assert_eq!(
216            layer
217                .before_chat_calls
218                .load(std::sync::atomic::Ordering::Relaxed),
219            1
220        );
221        assert_eq!(
222            layer
223                .after_chat_calls
224                .load(std::sync::atomic::Ordering::Relaxed),
225            1
226        );
227    }
228
229    #[tokio::test]
230    async fn short_circuit_layer_returns_response() {
231        struct ShortCircuitLayer;
232        impl RuntimeLayer for ShortCircuitLayer {
233            fn before_chat<'a>(
234                &'a self,
235                _ctx: &'a LayerContext<'_>,
236                _messages: &'a [Message],
237                _tools: &'a [ToolDefinition],
238            ) -> Pin<Box<dyn Future<Output = Option<ChatResponse>> + Send + 'a>> {
239                Box::pin(std::future::ready(Some(ChatResponse::Text(
240                    "short-circuited".into(),
241                ))))
242            }
243        }
244
245        let layer = ShortCircuitLayer;
246        let ctx = LayerContext {
247            conversation_id: None,
248            turn_number: 0,
249        };
250        let result = layer.before_chat(&ctx, &[], &[]).await;
251        assert_matches!(result, Some(ChatResponse::Text(ref s)) if s == "short-circuited");
252    }
253
254    // Verify that Role is accessible from zeph_llm imports (ensures crate boundary is correct).
255    #[test]
256    fn message_from_legacy_compiles() {
257        let _msg = Message::from_legacy(Role::User, "hello");
258    }
259
260    /// Two layers registered in order [A, B]: `before_chat` must be called A then B,
261    /// and `after_chat` must be called A then B (forward order for both in MVP's loop).
262    #[tokio::test]
263    async fn multiple_layers_called_in_registration_order() {
264        use std::sync::{Arc, Mutex};
265
266        struct OrderLayer {
267            id: u32,
268            log: Arc<Mutex<Vec<String>>>,
269        }
270        impl RuntimeLayer for OrderLayer {
271            fn before_chat<'a>(
272                &'a self,
273                _ctx: &'a LayerContext<'_>,
274                _messages: &'a [Message],
275                _tools: &'a [ToolDefinition],
276            ) -> Pin<Box<dyn Future<Output = Option<ChatResponse>> + Send + 'a>> {
277                let entry = format!("before_{}", self.id);
278                self.log.lock().unwrap().push(entry);
279                Box::pin(std::future::ready(None))
280            }
281
282            fn after_chat<'a>(
283                &'a self,
284                _ctx: &'a LayerContext<'_>,
285                _response: &'a ChatResponse,
286            ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
287                let entry = format!("after_{}", self.id);
288                self.log.lock().unwrap().push(entry);
289                Box::pin(std::future::ready(()))
290            }
291        }
292
293        let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
294        let layer_a = OrderLayer {
295            id: 1,
296            log: Arc::clone(&log),
297        };
298        let layer_b = OrderLayer {
299            id: 2,
300            log: Arc::clone(&log),
301        };
302
303        let ctx = LayerContext {
304            conversation_id: None,
305            turn_number: 0,
306        };
307        let resp = ChatResponse::Text("ok".into());
308
309        layer_a.before_chat(&ctx, &[], &[]).await;
310        layer_b.before_chat(&ctx, &[], &[]).await;
311        layer_a.after_chat(&ctx, &resp).await;
312        layer_b.after_chat(&ctx, &resp).await;
313
314        let events = log.lock().unwrap().clone();
315        assert_eq!(
316            events,
317            vec!["before_1", "before_2", "after_1", "after_2"],
318            "hooks must fire in registration order"
319        );
320    }
321
322    /// `after_chat` must receive the short-circuit response produced by `before_chat`.
323    #[tokio::test]
324    async fn after_chat_receives_short_circuit_response() {
325        use std::sync::{Arc, Mutex};
326
327        struct CapturingAfter {
328            captured: Arc<Mutex<Option<String>>>,
329        }
330        impl RuntimeLayer for CapturingAfter {
331            fn after_chat<'a>(
332                &'a self,
333                _ctx: &'a LayerContext<'_>,
334                response: &'a ChatResponse,
335            ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
336                if let ChatResponse::Text(t) = response {
337                    *self.captured.lock().unwrap() = Some(t.clone());
338                }
339                Box::pin(std::future::ready(()))
340            }
341        }
342
343        let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
344        let layer = CapturingAfter {
345            captured: Arc::clone(&captured),
346        };
347        let ctx = LayerContext {
348            conversation_id: None,
349            turn_number: 0,
350        };
351
352        // Simulate: before_chat short-circuits; caller passes result to after_chat.
353        let sc_response = ChatResponse::Text("short-circuit".into());
354        layer.after_chat(&ctx, &sc_response).await;
355
356        let got = captured.lock().unwrap().clone();
357        assert_eq!(
358            got.as_deref(),
359            Some("short-circuit"),
360            "after_chat must receive the short-circuit response"
361        );
362    }
363
364    /// Two layers registered in order [A, B]: `before_tool` must fire A then B,
365    /// and `after_tool` must fire A then B (forward order for both).
366    #[tokio::test]
367    async fn multi_layer_before_after_tool_ordering() {
368        use std::sync::{Arc, Mutex};
369
370        struct ToolOrderLayer {
371            id: u32,
372            log: Arc<Mutex<Vec<String>>>,
373        }
374        impl RuntimeLayer for ToolOrderLayer {
375            fn before_tool<'a>(
376                &'a self,
377                _ctx: &'a LayerContext<'_>,
378                _call: &'a ToolCall,
379            ) -> Pin<Box<dyn Future<Output = BeforeToolResult> + Send + 'a>> {
380                self.log
381                    .lock()
382                    .unwrap()
383                    .push(format!("before_tool_{}", self.id));
384                Box::pin(std::future::ready(None))
385            }
386
387            fn after_tool<'a>(
388                &'a self,
389                _ctx: &'a LayerContext<'_>,
390                _call: &'a ToolCall,
391                _result: &'a Result<Option<ToolOutput>, ToolError>,
392            ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
393                self.log
394                    .lock()
395                    .unwrap()
396                    .push(format!("after_tool_{}", self.id));
397                Box::pin(std::future::ready(()))
398            }
399        }
400
401        let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
402        let layer_a = ToolOrderLayer {
403            id: 1,
404            log: Arc::clone(&log),
405        };
406        let layer_b = ToolOrderLayer {
407            id: 2,
408            log: Arc::clone(&log),
409        };
410
411        let ctx = LayerContext {
412            conversation_id: None,
413            turn_number: 0,
414        };
415        let call = ToolCall {
416            tool_id: "shell".into(),
417            params: serde_json::Map::new(),
418            caller_id: None,
419            context: None,
420
421            tool_call_id: String::new(),
422            skill_name: None,
423        };
424        let result: Result<Option<ToolOutput>, ToolError> = Ok(None);
425
426        layer_a.before_tool(&ctx, &call).await;
427        layer_b.before_tool(&ctx, &call).await;
428        layer_a.after_tool(&ctx, &call, &result).await;
429        layer_b.after_tool(&ctx, &call, &result).await;
430
431        let events = log.lock().unwrap().clone();
432        assert_eq!(
433            events,
434            vec![
435                "before_tool_1",
436                "before_tool_2",
437                "after_tool_1",
438                "after_tool_2"
439            ],
440            "tool hooks must fire in registration order"
441        );
442    }
443
444    /// `NoopLayer` `after_tool` returns `()` without errors.
445    #[tokio::test]
446    async fn noop_layer_after_tool_returns_unit() {
447        use zeph_tools::executor::ToolOutput;
448
449        let layer = NoopLayer;
450        let ctx = LayerContext {
451            conversation_id: None,
452            turn_number: 0,
453        };
454        let call = ToolCall {
455            tool_id: "shell".into(),
456            params: serde_json::Map::new(),
457            caller_id: None,
458            context: None,
459
460            tool_call_id: String::new(),
461            skill_name: None,
462        };
463        let result: Result<Option<ToolOutput>, zeph_tools::ToolError> = Ok(None);
464        layer.after_tool(&ctx, &call, &result).await;
465        // No assertion needed — the test verifies it compiles and doesn't panic.
466    }
467}