Skip to main content

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}