Skip to main content

leviath_scripting/
region_hook.rs

1//! Script-backed custom region hooks (`RegionKind::Custom`).
2//!
3//! A custom region's `.rhai` script owns the region's behavior through up to
4//! three functions, each a **return-value contract** (Rhai passes arguments by
5//! value, so mutating the `ctx` argument in place has no effect - the script
6//! must return its result):
7//!
8//! - `render(ctx)` - **required**. Shapes the region's contribution to the
9//!   assembled context. Returns a string (one system block) or a map
10//!   `#{ system: ..., messages: [...] }`.
11//! - `on_write(ctx)` - optional. Sees each incoming entry; returns a string
12//!   (replace the content), `true`/`()` (accept unchanged), or `false` (drop).
13//! - `on_overflow(ctx)` - optional. Chooses what to evict under budget
14//!   pressure; returns an array of entry indices to drop.
15//!
16//! This module owns compilation (once, at agent spawn - the CLI resolves the
17//! blueprint-dir-relative path and reads the file) and the three call
18//! entry points. Interpretation of the returned values - block/message
19//! construction, index validation, fallbacks - lives with the caller
20//! (`leviath-runtime`), which receives plain [`serde_json::Value`]s so it
21//! needs no `rhai` dependency of its own.
22//!
23//! Execution runs on a fresh hardened engine per call (see [`crate::harden`])
24//! over the precompiled AST: no filesystem, no network, no `eval`, operation-
25//! bounded. The registered helpers are the same pure function/type sets every
26//! Leviath script engine gets.
27
28use rhai::{AST, Dynamic, Engine, Scope};
29
30/// Operation budget for region hooks: pure data transforms, same policy as
31/// `ScriptEngine` validators/transforms (not the 500k the IO-driving script
32/// tools/providers get).
33const REGION_HOOK_MAX_OPERATIONS: u64 = 100_000;
34
35/// A compiled custom-region script, ready to call. Compiled once at spawn and
36/// shared via `Arc` on the runtime's context window, keyed by `path`.
37#[derive(Debug, Clone)]
38pub struct RegionScript {
39    /// The script path as written in the blueprint - log/error context only.
40    pub path: String,
41    ast: AST,
42    has_on_write: bool,
43    has_on_overflow: bool,
44}
45
46impl RegionScript {
47    /// Whether the script defines `on_write(ctx)`.
48    pub fn has_on_write(&self) -> bool {
49        self.has_on_write
50    }
51
52    /// Whether the script defines `on_overflow(ctx)`.
53    pub fn has_on_overflow(&self) -> bool {
54        self.has_on_overflow
55    }
56}
57
58/// Build the hardened engine every region-hook call runs on.
59fn build_engine() -> Engine {
60    let mut engine = Engine::new();
61    crate::harden(&mut engine, REGION_HOOK_MAX_OPERATIONS);
62    crate::functions::register_functions(&mut engine);
63    crate::types::register_types(&mut engine);
64    engine
65}
66
67/// Compile a custom-region script and verify its shape: `render(ctx)` must
68/// exist with exactly one parameter; `on_write`/`on_overflow` are recorded
69/// when present (also arity 1). Used by the CLI at spawn (fail-fast: a
70/// missing or broken script is a spawn error, not a silent runtime fallback)
71/// and by `lev validate`.
72pub fn compile(path: &str, source: &str) -> crate::Result<RegionScript> {
73    let engine = build_engine();
74    let ast = engine
75        .compile(source)
76        .map_err(|e| crate::Error::CompilationFailed(format!("{path}: {e}")))?;
77
78    let arity_of = |name: &str| -> Option<usize> {
79        ast.iter_functions()
80            .find(|f| f.name == name)
81            .map(|f| f.params.len())
82    };
83
84    match arity_of("render") {
85        Some(1) => {}
86        Some(n) => {
87            return Err(crate::Error::ValidationFailed(format!(
88                "{path}: fn render must take exactly one parameter (ctx), found {n}"
89            )));
90        }
91        None => {
92            return Err(crate::Error::ValidationFailed(format!(
93                "{path}: script must define fn render(ctx)"
94            )));
95        }
96    }
97    // Optional hooks: present-but-wrong-arity is an authoring error worth
98    // failing on at spawn, not a silent "hook never fires".
99    for optional in ["on_write", "on_overflow"] {
100        if let Some(n) = arity_of(optional)
101            && n != 1
102        {
103            return Err(crate::Error::ValidationFailed(format!(
104                "{path}: fn {optional} must take exactly one parameter (ctx), found {n}"
105            )));
106        }
107    }
108
109    Ok(RegionScript {
110        path: path.to_string(),
111        has_on_write: arity_of("on_write").is_some(),
112        has_on_overflow: arity_of("on_overflow").is_some(),
113        ast,
114    })
115}
116
117/// Call one of the script's functions with `ctx` and hand back the raw result
118/// as JSON. The caller interprets the shape; a value that cannot be
119/// represented as JSON (a function pointer, say) is a validation error.
120fn call(
121    script: &RegionScript,
122    fn_name: &str,
123    ctx: serde_json::Value,
124) -> crate::Result<serde_json::Value> {
125    let engine = build_engine();
126    // Total conversion: every JSON value has a Dynamic representation, so a
127    // failure here is a programmer error, not a script error (same stance as
128    // the provider layer's request_to_dynamic).
129    let ctx_dyn = rhai::serde::to_dynamic(ctx).expect("JSON always converts to Dynamic");
130    let result: Dynamic = engine
131        .call_fn(&mut Scope::new(), &script.ast, fn_name, (ctx_dyn,))
132        .map_err(|e| crate::Error::ExecutionFailed(format!("{}: {fn_name}: {e}", script.path)))?;
133    rhai::serde::from_dynamic::<serde_json::Value>(&result).map_err(|e| {
134        crate::Error::ValidationFailed(format!(
135            "{}: {fn_name} returned a value that is not plain data: {e}",
136            script.path
137        ))
138    })
139}
140
141/// Run `render(ctx)`. The result is a JSON string (one system block) or an
142/// object with optional `system` / `messages` fields - shape validation is the
143/// caller's job.
144pub fn run_render(
145    script: &RegionScript,
146    ctx: serde_json::Value,
147) -> crate::Result<serde_json::Value> {
148    call(script, "render", ctx)
149}
150
151/// Run `on_write(ctx)`. Callers must check [`RegionScript::has_on_write`]
152/// first - calling a missing function is an execution error.
153pub fn run_on_write(
154    script: &RegionScript,
155    ctx: serde_json::Value,
156) -> crate::Result<serde_json::Value> {
157    call(script, "on_write", ctx)
158}
159
160/// Run `on_overflow(ctx)`. Callers must check
161/// [`RegionScript::has_on_overflow`] first.
162pub fn run_on_overflow(
163    script: &RegionScript,
164    ctx: serde_json::Value,
165) -> crate::Result<serde_json::Value> {
166    call(script, "on_overflow", ctx)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use serde_json::json;
173
174    fn ctx() -> serde_json::Value {
175        json!({
176            "region": { "name": "brain", "budget": 1000, "current_tokens": 10, "entry_count": 1 },
177            "entries": [ { "content": "hello", "tokens": 2, "kind": "text" } ],
178            "stage_name": "plan",
179            "stage_iterations": 3,
180            "model": "test-model",
181            "window": { "total_tokens": 10, "max_tokens": 2000 },
182        })
183    }
184
185    // ─── compile ─────────────────────────────────────────────────────────
186
187    #[test]
188    fn compile_accepts_render_only_script() {
189        let s = compile("r.rhai", "fn render(ctx) { \"out\" }").unwrap();
190        assert_eq!(s.path, "r.rhai");
191        assert!(!s.has_on_write());
192        assert!(!s.has_on_overflow());
193    }
194
195    #[test]
196    fn compile_detects_optional_hooks() {
197        let src = r#"
198            fn render(ctx) { "out" }
199            fn on_write(ctx) { true }
200            fn on_overflow(ctx) { [] }
201        "#;
202        let s = compile("r.rhai", src).unwrap();
203        assert!(s.has_on_write());
204        assert!(s.has_on_overflow());
205    }
206
207    #[test]
208    fn compile_rejects_syntax_errors() {
209        let err = compile("bad.rhai", "fn render(ctx) {").unwrap_err();
210        assert!(
211            err.to_string().starts_with("Script compilation failed"),
212            "{err}"
213        );
214        assert!(err.to_string().contains("bad.rhai"));
215    }
216
217    #[test]
218    fn compile_rejects_missing_render() {
219        let err = compile("no.rhai", "fn on_write(ctx) { true }").unwrap_err();
220        assert!(
221            err.to_string().starts_with("Script validation failed"),
222            "{err}"
223        );
224        assert!(err.to_string().contains("must define fn render"));
225    }
226
227    #[test]
228    fn compile_rejects_wrong_render_arity() {
229        let err = compile("a.rhai", "fn render(a, b) { \"x\" }").unwrap_err();
230        assert!(err.to_string().contains("exactly one parameter"), "{err}");
231    }
232
233    #[test]
234    fn compile_rejects_wrong_optional_hook_arity() {
235        let src = "fn render(ctx) { \"x\" }\nfn on_write() { true }";
236        let err = compile("w.rhai", src).unwrap_err();
237        assert!(err.to_string().contains("on_write"), "{err}");
238        let src = "fn render(ctx) { \"x\" }\nfn on_overflow(a, b) { [] }";
239        let err = compile("o.rhai", src).unwrap_err();
240        assert!(err.to_string().contains("on_overflow"), "{err}");
241    }
242
243    // ─── render ──────────────────────────────────────────────────────────
244
245    #[test]
246    fn render_returns_string() {
247        let s = compile("r.rhai", "fn render(ctx) { `[${ctx.region.name}] ok` }").unwrap();
248        let out = run_render(&s, ctx()).unwrap();
249        assert_eq!(out, json!("[brain] ok"));
250    }
251
252    #[test]
253    fn render_sees_full_ctx() {
254        // The script reads every advertised ctx field and echoes them back -
255        // locks the ctx schema from the script's point of view.
256        let src = r#"
257            fn render(ctx) {
258                #{
259                    system: `${ctx.stage_name}/${ctx.stage_iterations}/${ctx.model}`,
260                    messages: [
261                        #{ role: "user", content: ctx.entries[0].content },
262                    ],
263                    budget: ctx.region.budget,
264                    window_max: ctx.window.max_tokens,
265                }
266            }
267        "#;
268        let s = compile("r.rhai", src).unwrap();
269        let out = run_render(&s, ctx()).unwrap();
270        assert_eq!(out["system"], json!("plan/3/test-model"));
271        assert_eq!(out["messages"][0]["content"], json!("hello"));
272        assert_eq!(out["budget"], json!(1000));
273        assert_eq!(out["window_max"], json!(2000));
274    }
275
276    #[test]
277    fn render_returns_unit_as_null() {
278        // The caller treats non-string/non-map as a hook error; the scripting
279        // layer just reports it faithfully.
280        let s = compile("r.rhai", "fn render(ctx) { }").unwrap();
281        let out = run_render(&s, ctx()).unwrap();
282        assert_eq!(out, serde_json::Value::Null);
283    }
284
285    #[test]
286    fn render_runtime_throw_is_execution_error() {
287        let s = compile("r.rhai", "fn render(ctx) { throw \"boom\" }").unwrap();
288        let err = run_render(&s, ctx()).unwrap_err();
289        assert!(
290            err.to_string().starts_with("Script execution failed"),
291            "{err}"
292        );
293        assert!(err.to_string().contains("boom"), "{err}");
294    }
295
296    #[test]
297    fn render_infinite_loop_hits_operation_limit() {
298        let s = compile("r.rhai", "fn render(ctx) { loop { } }").unwrap();
299        let err = run_render(&s, ctx()).unwrap_err();
300        assert!(
301            err.to_string().starts_with("Script execution failed"),
302            "{err}"
303        );
304    }
305
306    #[test]
307    fn render_unrepresentable_return_is_validation_error() {
308        // A function pointer cannot cross the JSON boundary.
309        let s = compile("r.rhai", "fn render(ctx) { Fn(\"render\") }").unwrap();
310        let err = run_render(&s, ctx()).unwrap_err();
311        assert!(
312            err.to_string().starts_with("Script validation failed"),
313            "{err}"
314        );
315    }
316
317    #[test]
318    fn hooks_cannot_reach_disabled_engine_surface() {
319        // The sandbox holds for hooks: `eval` is disabled outright, so a
320        // script trying to use it never even compiles.
321        let err = compile("r.rhai", "fn render(ctx) { eval(\"1+1\") }").unwrap_err();
322        assert!(
323            err.to_string().starts_with("Script compilation failed"),
324            "{err}"
325        );
326        assert!(err.to_string().contains("eval"), "{err}");
327    }
328
329    // ─── on_write / on_overflow ──────────────────────────────────────────
330
331    #[test]
332    fn on_write_returns_replacement_string() {
333        let src = r#"
334            fn render(ctx) { "" }
335            fn on_write(ctx) { ctx.entry.content.to_upper() }
336        "#;
337        let s = compile("w.rhai", src).unwrap();
338        let out = run_on_write(
339            &s,
340            json!({ "region": { "name": "brain" }, "entry": { "content": "hi", "kind": "text", "tokens": 1 }, "stage_name": "plan" }),
341        )
342        .unwrap();
343        assert_eq!(out, json!("HI"));
344    }
345
346    #[test]
347    fn on_write_returns_booleans_and_unit() {
348        for (body, expected) in [
349            ("true", json!(true)),
350            ("false", json!(false)),
351            ("", serde_json::Value::Null),
352        ] {
353            let src = format!("fn render(ctx) {{ \"\" }}\nfn on_write(ctx) {{ {body} }}");
354            let s = compile("w.rhai", &src).unwrap();
355            let out = run_on_write(&s, json!({"entry": {"content": "x"}})).unwrap();
356            assert_eq!(out, expected, "body: {body}");
357        }
358    }
359
360    #[test]
361    fn on_overflow_returns_index_array() {
362        let src = r#"
363            fn render(ctx) { "" }
364            fn on_overflow(ctx) {
365                let drops = [];
366                for (entry, i) in ctx.entries {
367                    if entry.kind != "tool_result" { drops.push(i); }
368                }
369                drops
370            }
371        "#;
372        let s = compile("o.rhai", src).unwrap();
373        let out = run_on_overflow(
374            &s,
375            json!({
376                "region": { "name": "brain" },
377                "entries": [
378                    { "content": "a", "kind": "text" },
379                    { "content": "b", "kind": "tool_result" },
380                    { "content": "c", "kind": "text" },
381                ],
382                "needed_tokens": 10,
383            }),
384        )
385        .unwrap();
386        assert_eq!(out, json!([0, 2]));
387    }
388
389    #[test]
390    fn calling_a_missing_hook_is_an_execution_error() {
391        // Callers gate on has_on_write/has_on_overflow; if they get it wrong
392        // the failure is an ordinary error, not a panic.
393        let s = compile("r.rhai", "fn render(ctx) { \"\" }").unwrap();
394        assert!(!s.has_on_write());
395        let err = run_on_write(&s, json!({})).unwrap_err();
396        assert!(
397            err.to_string().starts_with("Script execution failed"),
398            "{err}"
399        );
400    }
401}