leviath_runtime/pipeline/hooks.rs
1//! Running a stage's script hooks (issue #260).
2//!
3//! The contract lives in [`leviath_scripting::stage_hook`]; this module is the
4//! pipeline half - when each hook fires, what the script is shown, and what
5//! each outcome does to the agent.
6//!
7//! # Where `on_stage_enter` fires
8//!
9//! On the [`StageJustEntered`] marker the transition systems already set, and
10//! **before** `sync_tool_stages`, which consumes it. That puts the hook after
11//! the stage's layout and system prompt are in place (so it can read and write
12//! real regions) and before the first inference of the stage is built (so what
13//! it writes is in the request).
14//!
15//! Firing off a marker rather than inside `enter_stage` is deliberate:
16//! `enter_stage` is a pure function called from three places, and threading the
17//! compiled scripts plus an error channel through all of them would put script
18//! execution in the middle of a transition. Here it is one system, one query,
19//! and the failure modes stay at the tick boundary.
20
21use super::*;
22use crate::components::StageHookScripts;
23use leviath_scripting::stage_hook::{HookOutcome, run};
24
25/// The `ctx` a stage hook is shown.
26///
27/// Deliberately a snapshot rather than a handle: Rhai passes by value, so a
28/// script could not mutate a live window even if it were given one, and
29/// building the map is what makes the contract inspectable.
30fn stage_ctx(stage_name: &str, index: usize, window: &ContextWindow) -> serde_json::Value {
31 // Entries joined, not the entry list: a hook that wants to seed or rewrite a
32 // region thinks in text, and handing it the internal entry shape would make
33 // the ctx an implementation detail scripts then depend on.
34 let regions: serde_json::Map<String, serde_json::Value> = window
35 .regions
36 .iter()
37 .map(|r| {
38 let text = r
39 .content
40 .iter()
41 .map(|e| e.content.as_str())
42 .collect::<Vec<_>>()
43 .join("\n");
44 (r.name.clone(), serde_json::Value::String(text))
45 })
46 .collect();
47 serde_json::json!({
48 "stage": stage_name,
49 "stage_index": index,
50 "regions": regions,
51 })
52}
53
54/// Apply a `modify` outcome: write each named region's new content.
55///
56/// A name the window does not have is reported rather than ignored. The script
57/// asked to write somewhere that does not exist, which is a bug in the script
58/// or a stale region name in the blueprint - and silently dropping it would
59/// look exactly like a hook that ran and chose to write nothing.
60fn apply_modify(window: &mut ContextWindow, value: &serde_json::Value) -> Result<(), String> {
61 let Some(obj) = value.as_object() else {
62 return Err(format!(
63 "on_stage_enter: 'value' must be a map of region name to content, got: {value}"
64 ));
65 };
66 for (name, content) in obj {
67 let Some(text) = content.as_str() else {
68 return Err(format!(
69 "on_stage_enter: region '{name}' must be given a string, got: {content}"
70 ));
71 };
72 let Some(region) = window.get_region_mut(name) else {
73 return Err(format!(
74 "on_stage_enter: no region '{name}' in this stage's layout"
75 ));
76 };
77 // Replace, not append: the hook was shown the region's whole text and
78 // returned what it should be. Appending would make a hook that echoes
79 // its input double the region every time the stage is re-entered.
80 region.clear();
81 if !text.is_empty() {
82 region
83 .add_entry(text.to_string(), leviath_core::estimate_tokens(text))
84 .map_err(|e| format!("on_stage_enter: writing region '{name}': {e}"))?;
85 }
86 }
87 Ok(())
88}
89
90/// Run every entering agent's `on_stage_enter` hook.
91///
92/// Ordered before `sync_tool_stages` (which clears [`StageJustEntered`]) and
93/// therefore before the stage's first inference is built.
94pub fn run_stage_enter_hooks(
95 mut agents: Query<(
96 Entity,
97 &StageJustEntered,
98 &AgentBlueprint,
99 &StageHookScripts,
100 &mut ContextWindow,
101 &mut AgentState,
102 )>,
103) {
104 crate::tick_scope::clear();
105 for (entity, entered, bp, scripts, mut window, mut state) in agents.iter_mut() {
106 crate::tick_scope::enter(entity);
107 let Some(stage) = bp.0.stages.get(entered.index) else {
108 continue;
109 };
110 let Some(script) = scripts.script_for(stage, "on_stage_enter") else {
111 continue;
112 };
113
114 let ctx = stage_ctx(&entered.name, entered.index, &window);
115 let outcome = match run(&script, "on_stage_enter", ctx) {
116 Ok(o) => o,
117 Err(e) => {
118 // A hook that fails is not a hook that allowed. Failing the run
119 // is the same stance `enter_stage` takes on a prompt that will
120 // not fit: the stage cannot start as configured.
121 state.status = AgentStatus::Error {
122 message: format!("on_stage_enter hook failed: {e}"),
123 };
124 continue;
125 }
126 };
127
128 match outcome {
129 HookOutcome::Allow => {}
130 HookOutcome::Modify(value) => {
131 if let Err(e) = apply_modify(&mut window, &value) {
132 state.status = AgentStatus::Error { message: e };
133 }
134 }
135 HookOutcome::Cancel(reason) => {
136 let why = reason.unwrap_or_else(|| "no reason given".to_string());
137 state.status = AgentStatus::Error {
138 message: format!("on_stage_enter refused stage '{}': {why}", entered.name),
139 };
140 }
141 // Retrying entry into a stage the agent is already in has no
142 // meaning. Saying so beats treating it as `Allow`, which would let
143 // a script think it had asked for something.
144 HookOutcome::Retry => {
145 state.status = AgentStatus::Error {
146 message: format!(
147 "on_stage_enter returned 'retry', which this hook cannot honour \
148 (stage '{}' is already entered)",
149 entered.name
150 ),
151 };
152 }
153 }
154 }
155}
156
157/// Set the agent's status from a hook outcome the caller could not honour.
158///
159/// Shared by the hooks below so "a failed hook is not an allowed hook" is
160/// written once rather than restated per call site with a chance to drift.
161fn refuse(state: &mut AgentState, hook: &str, what: String) {
162 state.status = AgentStatus::Error {
163 message: format!("{hook}: {what}"),
164 };
165}
166
167/// What `run_before_inference_hooks` selects.
168///
169/// `&'static` is bevy's `WorldQuery` convention, not a claim about
170/// lifetimes: the borrow is bound when the query is fetched.
171type BeforeInferenceHookQuery = (
172 Entity,
173 &'static StageCursor,
174 &'static AgentBlueprint,
175 &'static StageHookScripts,
176 &'static mut ContextWindow,
177 &'static mut AgentState,
178);
179
180/// Run `before_inference` for every agent about to infer.
181///
182/// Scheduled before `dispatch_inference`, on the same `ReadyToInfer` marker it
183/// queries. The context window is assembled by then, so `modify` here reaches
184/// the request: `build_request` reads the window fresh at dispatch.
185///
186/// Fired from its own system rather than inside `dispatch_inference` because
187/// that system's per-agent body runs in parallel on the compute pool, where a
188/// panicking script would be attributed by different machinery. Sequential here
189/// costs a query pass and keeps script failures at the tick boundary.
190pub fn run_before_inference_hooks(
191 mut agents: Query<BeforeInferenceHookQuery, With<ReadyToInfer>>,
192 mut commands: Commands,
193) {
194 crate::tick_scope::clear();
195 for (entity, cursor, bp, scripts, mut window, mut state) in agents.iter_mut() {
196 crate::tick_scope::enter(entity);
197 let Some(stage) = bp.0.stages.get(cursor.index) else {
198 continue;
199 };
200 let Some(script) = scripts.script_for(stage, "before_inference") else {
201 continue;
202 };
203
204 let ctx = stage_ctx(&stage.name, cursor.index, &window);
205 match run(&script, "before_inference", ctx) {
206 Err(e) => refuse(&mut state, "before_inference", format!("hook failed: {e}")),
207 Ok(HookOutcome::Allow) => {}
208 Ok(HookOutcome::Modify(value)) => {
209 if let Err(e) = apply_modify(&mut window, &value) {
210 refuse(&mut state, "before_inference", e);
211 }
212 }
213 // Skipping the call is what `cancel` means here, and the agent
214 // stops rather than silently inferring anyway. `ReadyToInfer` is
215 // removed so `dispatch_inference` does not pick it up this tick.
216 Ok(HookOutcome::Cancel(reason)) => {
217 let why = reason.unwrap_or_else(|| "no reason given".to_string());
218 refuse(
219 &mut state,
220 "before_inference",
221 format!("refused the inference: {why}"),
222 );
223 commands.entity(entity).remove::<ReadyToInfer>();
224 }
225 // Nothing has happened yet, so there is nothing to do again.
226 Ok(HookOutcome::Retry) => refuse(
227 &mut state,
228 "before_inference",
229 "returned 'retry', which this hook cannot honour (nothing has run yet)".to_string(),
230 ),
231 }
232 }
233}
234
235/// What `run_after_inference_hooks` selects.
236///
237/// `&'static` is bevy's `WorldQuery` convention, not a claim about
238/// lifetimes: the borrow is bound when the query is fetched.
239type AfterInferenceHookQuery = (
240 Entity,
241 &'static StageCursor,
242 &'static AgentBlueprint,
243 &'static StageHookScripts,
244 &'static mut crate::components::InferenceResult,
245 &'static mut AgentState,
246);
247
248/// Run `after_inference` with the model's response in hand.
249///
250/// Scheduled before `process_response`, on the `ProcessResponse` marker, so the
251/// hook sees the response before anything is written to context or any tool
252/// call is dispatched from it.
253///
254/// `modify` replaces the response **text**. It deliberately cannot rewrite the
255/// tool calls: those are about to be checked by the policy and taint layers, and
256/// a hook that could rewrite them would be a way around checks the operator
257/// configured. Steering tool calls is `on_tool_call`'s job, where the gate can
258/// see it.
259pub fn run_after_inference_hooks(
260 mut agents: Query<AfterInferenceHookQuery, With<ProcessResponse>>,
261) {
262 crate::tick_scope::clear();
263 for (entity, cursor, bp, scripts, mut result, mut state) in agents.iter_mut() {
264 crate::tick_scope::enter(entity);
265 let Some(stage) = bp.0.stages.get(cursor.index) else {
266 continue;
267 };
268 let Some(script) = scripts.script_for(stage, "after_inference") else {
269 continue;
270 };
271
272 let ctx = serde_json::json!({
273 "stage": stage.name,
274 "stage_index": cursor.index,
275 "response": result.response,
276 "tokens_used": result.tokens_used,
277 // Names only: enough for a hook to notice "it wants to run shell"
278 // without implying it can rewrite the call.
279 "tool_calls": result
280 .tool_calls
281 .iter()
282 .map(|c| c.name.clone())
283 .collect::<Vec<_>>(),
284 });
285
286 match run(&script, "after_inference", ctx) {
287 Err(e) => refuse(&mut state, "after_inference", format!("hook failed: {e}")),
288 Ok(HookOutcome::Allow) => {}
289 Ok(HookOutcome::Modify(value)) => match value.as_str() {
290 Some(text) => result.response = text.to_string(),
291 None => refuse(
292 &mut state,
293 "after_inference",
294 format!("'value' must be the replacement response text, got: {value}"),
295 ),
296 },
297 Ok(HookOutcome::Cancel(reason)) => {
298 let why = reason.unwrap_or_else(|| "no reason given".to_string());
299 refuse(
300 &mut state,
301 "after_inference",
302 format!("rejected the response: {why}"),
303 );
304 }
305 // Re-inferring is a real thing to want here (a malformed answer),
306 // but it needs the request rebuilt and the attempt counted, or a
307 // hook that always retries wedges the run. Refused explicitly until
308 // that is built, rather than silently ignored.
309 Ok(HookOutcome::Retry) => refuse(
310 &mut state,
311 "after_inference",
312 "returned 'retry', which is not implemented yet - re-inference needs an \
313 attempt bound so a hook cannot wedge the run"
314 .to_string(),
315 ),
316 }
317 }
318}
319
320/// Read a hook's replacement tool calls, or say why they are not usable.
321///
322/// Split out so every malformed shape is reachable from a plain value in tests,
323/// without standing up an engine and a world to produce each one.
324fn tool_calls_from(value: &serde_json::Value) -> Result<Vec<crate::components::ToolCall>, String> {
325 let Some(items) = value.as_array() else {
326 return Err(format!(
327 "'value' must be an array of #{{ name, arguments }}, got: {value}"
328 ));
329 };
330 let mut out = Vec::with_capacity(items.len());
331 for item in items {
332 let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
333 return Err(format!("a replacement call has no 'name': {item}"));
334 };
335 out.push(crate::components::ToolCall {
336 // A fresh id: the hook is proposing a call, not editing one in
337 // place, and reusing an id would tie a rewritten call to a
338 // provider record that no longer describes it.
339 tool_id: format!("hook-{name}-{}", out.len()),
340 name: name.to_string(),
341 arguments: item
342 .get("arguments")
343 .cloned()
344 .unwrap_or(serde_json::Value::Null),
345 // Dropped on purpose: the signature is a provider's token for the
346 // call *it* produced, and echoing it back with different arguments
347 // would attribute the hook's call to the model.
348 thought_signature: None,
349 });
350 }
351 Ok(out)
352}
353
354/// What `run_tool_call_hooks` selects.
355///
356/// `&'static` is bevy's `WorldQuery` convention, not a claim about
357/// lifetimes: the borrow is bound when the query is fetched.
358type ToolCallHookQuery = (
359 Entity,
360 &'static StageCursor,
361 &'static AgentBlueprint,
362 &'static StageHookScripts,
363 &'static mut crate::components::InferenceResult,
364 &'static mut AgentState,
365);
366
367/// Run `on_tool_call` before the model's tool calls reach the policy layer.
368///
369/// # Composition with the gate, which is the whole design question
370///
371/// Scheduled **before** `dispatch_tools`, which is where the tool policy, the
372/// taint gate, and the approval prompt live. So whatever this hook leaves in
373/// `InferenceResult` is what those layers then check.
374///
375/// That ordering is the safety property: a hook can *narrow* what runs - veto a
376/// call, rewrite arguments to something tamer - but it cannot widen anything,
377/// because nothing it produces skips the checks. Running it after the gate
378/// would let an approved call be rewritten into an unapproved one, which is a
379/// way around the operator's configuration and is why it is not done that way.
380///
381/// The hook also has no access to `TaintGate`, `GateAutoApprove`, or
382/// `ToolSensitivities` - it cannot mark its own calls approved. Its query says
383/// so, and a test asserts the gate state is untouched across a hook that
384/// rewrites everything.
385pub fn run_tool_call_hooks(mut agents: Query<ToolCallHookQuery, With<ReadyForTools>>) {
386 crate::tick_scope::clear();
387 for (entity, cursor, bp, scripts, mut result, mut state) in agents.iter_mut() {
388 crate::tick_scope::enter(entity);
389 let Some(stage) = bp.0.stages.get(cursor.index) else {
390 continue;
391 };
392 let Some(script) = scripts.script_for(stage, "on_tool_call") else {
393 continue;
394 };
395 if result.tool_calls.is_empty() {
396 continue;
397 }
398
399 let ctx = serde_json::json!({
400 "stage": stage.name,
401 "stage_index": cursor.index,
402 "tool_calls": result
403 .tool_calls
404 .iter()
405 .map(|c| serde_json::json!({ "name": c.name, "arguments": c.arguments }))
406 .collect::<Vec<_>>(),
407 });
408
409 match run(&script, "on_tool_call", ctx) {
410 Err(e) => refuse(&mut state, "on_tool_call", format!("hook failed: {e}")),
411 Ok(HookOutcome::Allow) => {}
412 Ok(HookOutcome::Modify(value)) => match tool_calls_from(&value) {
413 Ok(calls) => result.tool_calls = calls,
414 Err(e) => refuse(&mut state, "on_tool_call", e),
415 },
416 Ok(HookOutcome::Cancel(reason)) => {
417 let why = reason.unwrap_or_else(|| "no reason given".to_string());
418 refuse(
419 &mut state,
420 "on_tool_call",
421 format!("refused the tool calls: {why}"),
422 );
423 }
424 // Re-running a call the hook has already seen would need the batch
425 // rebuilt and the attempt counted, or a hook that always retries
426 // wedges the run. Vetoing and letting the model try again is the
427 // supported shape.
428 Ok(HookOutcome::Retry) => refuse(
429 &mut state,
430 "on_tool_call",
431 "returned 'retry', which this hook cannot honour - cancel the call and let \
432 the model choose again"
433 .to_string(),
434 ),
435 }
436 }
437}
438
439/// Marks an agent whose terminal hook has already run.
440///
441/// A terminal status is not an event - it stays true for every tick until the
442/// agent is unloaded - so without this the hook would fire on each of them. The
443/// marker turns a state into a one-shot.
444#[derive(Component, Debug, Clone, Copy)]
445pub struct TerminalHookFired;
446
447/// What `run_terminal_hooks` selects.
448///
449/// `&'static` is bevy's `WorldQuery` convention, not a claim about
450/// lifetimes: the borrow is bound when the query is fetched.
451type TerminalHookQuery = (
452 Entity,
453 &'static StageCursor,
454 &'static AgentBlueprint,
455 &'static StageHookScripts,
456 &'static mut AgentState,
457 Option<&'static mut crate::persistence::FinalOutput>,
458);
459
460/// Run `on_completion` or `on_error` once, as the run finishes.
461///
462/// Which one fires is the run's own outcome: a completed run gets
463/// `on_completion` with its answer, an errored one gets `on_error` with the
464/// message. A cancelled run gets neither - it was stopped from outside, and a
465/// hook narrating that would be reporting the operator's decision back to them.
466///
467/// `modify` replaces what the hook was shown: the final output for a
468/// completion, the message for an error. `cancel` on a completion is a
469/// meaningful veto - the answer was not acceptable - and turns the run into an
470/// error carrying the reason.
471pub fn run_terminal_hooks(
472 mut agents: Query<TerminalHookQuery, Without<TerminalHookFired>>,
473 mut commands: Commands,
474) {
475 crate::tick_scope::clear();
476 for (entity, cursor, bp, scripts, mut state, output) in agents.iter_mut() {
477 // `Cancelled` is deliberately not here: see the doc comment.
478 let (hook, subject) = match &state.status {
479 AgentStatus::Complete => (
480 "on_completion",
481 output
482 .as_ref()
483 .map(|o| o.0.content.clone())
484 .unwrap_or_default(),
485 ),
486 AgentStatus::Error { message } => ("on_error", message.clone()),
487 _ => continue,
488 };
489 crate::tick_scope::enter(entity);
490
491 let Some(stage) = bp.0.stages.get(cursor.index) else {
492 // Still mark it fired: without a stage there is no hook to look up
493 // and re-checking every tick would be pure work.
494 commands.entity(entity).insert(TerminalHookFired);
495 continue;
496 };
497 let Some(script) = scripts.script_for(stage, hook) else {
498 commands.entity(entity).insert(TerminalHookFired);
499 continue;
500 };
501
502 // Marked before running, not after: a hook that fails must not be
503 // retried on the next tick, which would make a throwing script an
504 // infinite loop rather than one error.
505 commands.entity(entity).insert(TerminalHookFired);
506
507 let ctx = serde_json::json!({
508 "stage": stage.name,
509 "stage_index": cursor.index,
510 "status": format!("{}", state.status),
511 // Named for what it is in each case, so a script reads plainly.
512 "output": if hook == "on_completion" { subject.clone() } else { String::new() },
513 "error": if hook == "on_error" { subject.clone() } else { String::new() },
514 });
515
516 match run(&script, hook, ctx) {
517 Err(e) => refuse(&mut state, hook, format!("hook failed: {e}")),
518 Ok(HookOutcome::Allow) => {}
519 Ok(HookOutcome::Modify(value)) => {
520 let Some(text) = value.as_str() else {
521 refuse(
522 &mut state,
523 hook,
524 format!("'value' must be replacement text, got: {value}"),
525 );
526 continue;
527 };
528 match hook {
529 // Rewriting the answer is the point: a completion hook can
530 // reshape what `lev result` hands back.
531 // Refused rather than dropped when there is no answer to
532 // rewrite: the hook asked to change something that is not
533 // there, and a silently-ignored rewrite reads exactly like
534 // one that happened.
535 "on_completion" => match output {
536 Some(mut o) => o.0.content = text.to_string(),
537 None => refuse(
538 &mut state,
539 hook,
540 "asked to rewrite the answer, but this run submitted none".to_string(),
541 ),
542 },
543 _ => {
544 state.status = AgentStatus::Error {
545 message: text.to_string(),
546 }
547 }
548 }
549 }
550 Ok(HookOutcome::Cancel(reason)) => {
551 let why = reason.unwrap_or_else(|| "no reason given".to_string());
552 refuse(&mut state, hook, format!("rejected the result: {why}"));
553 }
554 // The run is over; there is nothing left to do again.
555 Ok(HookOutcome::Retry) => refuse(
556 &mut state,
557 hook,
558 "returned 'retry', which this hook cannot honour (the run has finished)"
559 .to_string(),
560 ),
561 }
562 }
563}
564
565/// What `run_stage_exit_hooks` selects.
566///
567/// `&'static` is bevy's `WorldQuery` convention, not a claim about
568/// lifetimes: the borrow is bound when the query is fetched.
569type StageExitHookQuery = (
570 Entity,
571 &'static StageCursor,
572 &'static AgentBlueprint,
573 &'static StageHookScripts,
574 &'static mut ContextWindow,
575 &'static mut AgentState,
576);
577
578/// Run `on_stage_exit` as a stage finishes, before its transition is chosen.
579///
580/// On the `ResolveTransition` marker and scheduled before `resolve_transition`,
581/// so a hook can summarise the stage's work or tidy a region while the stage is
582/// still the current one - and before the edge that leaves it is picked.
583///
584/// The window is still the finishing stage's, so `modify` writes there. A
585/// `cancel` errors the run rather than blocking the transition: a stage that
586/// refuses to be left has nowhere to go, and wedging is worse than stopping.
587pub fn run_stage_exit_hooks(mut agents: Query<StageExitHookQuery, With<ResolveTransition>>) {
588 crate::tick_scope::clear();
589 for (entity, cursor, bp, scripts, mut window, mut state) in agents.iter_mut() {
590 crate::tick_scope::enter(entity);
591 let Some(stage) = bp.0.stages.get(cursor.index) else {
592 continue;
593 };
594 let Some(script) = scripts.script_for(stage, "on_stage_exit") else {
595 continue;
596 };
597
598 let ctx = stage_ctx(&stage.name, cursor.index, &window);
599 match run(&script, "on_stage_exit", ctx) {
600 Err(e) => refuse(&mut state, "on_stage_exit", format!("hook failed: {e}")),
601 Ok(HookOutcome::Allow) => {}
602 Ok(HookOutcome::Modify(value)) => {
603 if let Err(e) = apply_modify(&mut window, &value) {
604 refuse(&mut state, "on_stage_exit", e);
605 }
606 }
607 Ok(HookOutcome::Cancel(reason)) => {
608 let why = reason.unwrap_or_else(|| "no reason given".to_string());
609 refuse(
610 &mut state,
611 "on_stage_exit",
612 format!("refused to leave stage '{}': {why}", stage.name),
613 );
614 }
615 Ok(HookOutcome::Retry) => refuse(
616 &mut state,
617 "on_stage_exit",
618 "returned 'retry', which this hook cannot honour (the stage is already over)"
619 .to_string(),
620 ),
621 }
622 }
623}