1use rhai::{AST, Dynamic, Engine, Scope};
29
30const REGION_HOOK_MAX_OPERATIONS: u64 = 100_000;
34
35#[derive(Debug, Clone)]
38pub struct RegionScript {
39 pub path: String,
41 ast: AST,
42 has_on_write: bool,
43 has_on_overflow: bool,
44}
45
46impl RegionScript {
47 pub fn has_on_write(&self) -> bool {
49 self.has_on_write
50 }
51
52 pub fn has_on_overflow(&self) -> bool {
54 self.has_on_overflow
55 }
56}
57
58fn 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
67pub 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 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
117fn 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 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
141pub fn run_render(
145 script: &RegionScript,
146 ctx: serde_json::Value,
147) -> crate::Result<serde_json::Value> {
148 call(script, "render", ctx)
149}
150
151pub 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
160pub 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 #[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 #[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 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 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 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 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 #[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 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}