1use 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
31pub struct LayerDenial {
36 pub result: Result<Option<ToolOutput>, ToolError>,
38 pub reason: String,
40}
41
42pub type BeforeToolResult = Option<LayerDenial>;
44
45#[derive(Debug)]
47pub struct LayerContext<'a> {
48 pub conversation_id: Option<&'a str>,
50 pub turn_number: u32,
52}
53
54pub trait RuntimeLayer: Send + Sync {
68 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 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 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 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
113pub 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 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 #[test]
256 fn message_from_legacy_compiles() {
257 let _msg = Message::from_legacy(Role::User, "hello");
258 }
259
260 #[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 #[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 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 #[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 #[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 }
467}