use super::*;
use crate::components::StageHookScripts;
use leviath_scripting::stage_hook::{HookOutcome, run};
fn stage_ctx(stage_name: &str, index: usize, window: &ContextWindow) -> serde_json::Value {
let regions: serde_json::Map<String, serde_json::Value> = window
.regions
.iter()
.map(|r| {
let text = r
.content
.iter()
.map(|e| e.content.as_str())
.collect::<Vec<_>>()
.join("\n");
(r.name.clone(), serde_json::Value::String(text))
})
.collect();
serde_json::json!({
"stage": stage_name,
"stage_index": index,
"regions": regions,
})
}
fn apply_modify(window: &mut ContextWindow, value: &serde_json::Value) -> Result<(), String> {
let Some(obj) = value.as_object() else {
return Err(format!(
"on_stage_enter: 'value' must be a map of region name to content, got: {value}"
));
};
for (name, content) in obj {
let Some(text) = content.as_str() else {
return Err(format!(
"on_stage_enter: region '{name}' must be given a string, got: {content}"
));
};
let Some(region) = window.get_region_mut(name) else {
return Err(format!(
"on_stage_enter: no region '{name}' in this stage's layout"
));
};
region.clear();
if !text.is_empty() {
region
.add_entry(text.to_string(), leviath_core::estimate_tokens(text))
.map_err(|e| format!("on_stage_enter: writing region '{name}': {e}"))?;
}
}
Ok(())
}
pub fn run_stage_enter_hooks(
mut agents: Query<(
Entity,
&StageJustEntered,
&AgentBlueprint,
&StageHookScripts,
&mut ContextWindow,
&mut AgentState,
)>,
) {
crate::tick_scope::clear();
for (entity, entered, bp, scripts, mut window, mut state) in agents.iter_mut() {
crate::tick_scope::enter(entity);
let Some(stage) = bp.0.stages.get(entered.index) else {
continue;
};
let Some(script) = scripts.script_for(stage, "on_stage_enter") else {
continue;
};
let ctx = stage_ctx(&entered.name, entered.index, &window);
let outcome = match run(&script, "on_stage_enter", ctx) {
Ok(o) => o,
Err(e) => {
state.status = AgentStatus::Error {
message: format!("on_stage_enter hook failed: {e}"),
};
continue;
}
};
match outcome {
HookOutcome::Allow => {}
HookOutcome::Modify(value) => {
if let Err(e) = apply_modify(&mut window, &value) {
state.status = AgentStatus::Error { message: e };
}
}
HookOutcome::Cancel(reason) => {
let why = reason.unwrap_or_else(|| "no reason given".to_string());
state.status = AgentStatus::Error {
message: format!("on_stage_enter refused stage '{}': {why}", entered.name),
};
}
HookOutcome::Retry => {
state.status = AgentStatus::Error {
message: format!(
"on_stage_enter returned 'retry', which this hook cannot honour \
(stage '{}' is already entered)",
entered.name
),
};
}
}
}
}
fn refuse(state: &mut AgentState, hook: &str, what: String) {
state.status = AgentStatus::Error {
message: format!("{hook}: {what}"),
};
}
type BeforeInferenceHookQuery = (
Entity,
&'static StageCursor,
&'static AgentBlueprint,
&'static StageHookScripts,
&'static mut ContextWindow,
&'static mut AgentState,
);
pub fn run_before_inference_hooks(
mut agents: Query<BeforeInferenceHookQuery, With<ReadyToInfer>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, cursor, bp, scripts, mut window, mut state) in agents.iter_mut() {
crate::tick_scope::enter(entity);
let Some(stage) = bp.0.stages.get(cursor.index) else {
continue;
};
let Some(script) = scripts.script_for(stage, "before_inference") else {
continue;
};
let ctx = stage_ctx(&stage.name, cursor.index, &window);
match run(&script, "before_inference", ctx) {
Err(e) => refuse(&mut state, "before_inference", format!("hook failed: {e}")),
Ok(HookOutcome::Allow) => {}
Ok(HookOutcome::Modify(value)) => {
if let Err(e) = apply_modify(&mut window, &value) {
refuse(&mut state, "before_inference", e);
}
}
Ok(HookOutcome::Cancel(reason)) => {
let why = reason.unwrap_or_else(|| "no reason given".to_string());
refuse(
&mut state,
"before_inference",
format!("refused the inference: {why}"),
);
commands.entity(entity).remove::<ReadyToInfer>();
}
Ok(HookOutcome::Retry) => refuse(
&mut state,
"before_inference",
"returned 'retry', which this hook cannot honour (nothing has run yet)".to_string(),
),
}
}
}
type AfterInferenceHookQuery = (
Entity,
&'static StageCursor,
&'static AgentBlueprint,
&'static StageHookScripts,
&'static mut crate::components::InferenceResult,
&'static mut AgentState,
);
pub fn run_after_inference_hooks(
mut agents: Query<AfterInferenceHookQuery, With<ProcessResponse>>,
) {
crate::tick_scope::clear();
for (entity, cursor, bp, scripts, mut result, mut state) in agents.iter_mut() {
crate::tick_scope::enter(entity);
let Some(stage) = bp.0.stages.get(cursor.index) else {
continue;
};
let Some(script) = scripts.script_for(stage, "after_inference") else {
continue;
};
let ctx = serde_json::json!({
"stage": stage.name,
"stage_index": cursor.index,
"response": result.response,
"tokens_used": result.tokens_used,
"tool_calls": result
.tool_calls
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>(),
});
match run(&script, "after_inference", ctx) {
Err(e) => refuse(&mut state, "after_inference", format!("hook failed: {e}")),
Ok(HookOutcome::Allow) => {}
Ok(HookOutcome::Modify(value)) => match value.as_str() {
Some(text) => result.response = text.to_string(),
None => refuse(
&mut state,
"after_inference",
format!("'value' must be the replacement response text, got: {value}"),
),
},
Ok(HookOutcome::Cancel(reason)) => {
let why = reason.unwrap_or_else(|| "no reason given".to_string());
refuse(
&mut state,
"after_inference",
format!("rejected the response: {why}"),
);
}
Ok(HookOutcome::Retry) => refuse(
&mut state,
"after_inference",
"returned 'retry', which is not implemented yet - re-inference needs an \
attempt bound so a hook cannot wedge the run"
.to_string(),
),
}
}
}
fn tool_calls_from(value: &serde_json::Value) -> Result<Vec<crate::components::ToolCall>, String> {
let Some(items) = value.as_array() else {
return Err(format!(
"'value' must be an array of #{{ name, arguments }}, got: {value}"
));
};
let mut out = Vec::with_capacity(items.len());
for item in items {
let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
return Err(format!("a replacement call has no 'name': {item}"));
};
out.push(crate::components::ToolCall {
tool_id: format!("hook-{name}-{}", out.len()),
name: name.to_string(),
arguments: item
.get("arguments")
.cloned()
.unwrap_or(serde_json::Value::Null),
thought_signature: None,
});
}
Ok(out)
}
type ToolCallHookQuery = (
Entity,
&'static StageCursor,
&'static AgentBlueprint,
&'static StageHookScripts,
&'static mut crate::components::InferenceResult,
&'static mut AgentState,
);
pub fn run_tool_call_hooks(mut agents: Query<ToolCallHookQuery, With<ReadyForTools>>) {
crate::tick_scope::clear();
for (entity, cursor, bp, scripts, mut result, mut state) in agents.iter_mut() {
crate::tick_scope::enter(entity);
let Some(stage) = bp.0.stages.get(cursor.index) else {
continue;
};
let Some(script) = scripts.script_for(stage, "on_tool_call") else {
continue;
};
if result.tool_calls.is_empty() {
continue;
}
let ctx = serde_json::json!({
"stage": stage.name,
"stage_index": cursor.index,
"tool_calls": result
.tool_calls
.iter()
.map(|c| serde_json::json!({ "name": c.name, "arguments": c.arguments }))
.collect::<Vec<_>>(),
});
match run(&script, "on_tool_call", ctx) {
Err(e) => refuse(&mut state, "on_tool_call", format!("hook failed: {e}")),
Ok(HookOutcome::Allow) => {}
Ok(HookOutcome::Modify(value)) => match tool_calls_from(&value) {
Ok(calls) => result.tool_calls = calls,
Err(e) => refuse(&mut state, "on_tool_call", e),
},
Ok(HookOutcome::Cancel(reason)) => {
let why = reason.unwrap_or_else(|| "no reason given".to_string());
refuse(
&mut state,
"on_tool_call",
format!("refused the tool calls: {why}"),
);
}
Ok(HookOutcome::Retry) => refuse(
&mut state,
"on_tool_call",
"returned 'retry', which this hook cannot honour - cancel the call and let \
the model choose again"
.to_string(),
),
}
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct TerminalHookFired;
type TerminalHookQuery = (
Entity,
&'static StageCursor,
&'static AgentBlueprint,
&'static StageHookScripts,
&'static mut AgentState,
Option<&'static mut crate::persistence::FinalOutput>,
);
pub fn run_terminal_hooks(
mut agents: Query<TerminalHookQuery, Without<TerminalHookFired>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, cursor, bp, scripts, mut state, output) in agents.iter_mut() {
let (hook, subject) = match &state.status {
AgentStatus::Complete => (
"on_completion",
output
.as_ref()
.map(|o| o.0.content.clone())
.unwrap_or_default(),
),
AgentStatus::Error { message } => ("on_error", message.clone()),
_ => continue,
};
crate::tick_scope::enter(entity);
let Some(stage) = bp.0.stages.get(cursor.index) else {
commands.entity(entity).insert(TerminalHookFired);
continue;
};
let Some(script) = scripts.script_for(stage, hook) else {
commands.entity(entity).insert(TerminalHookFired);
continue;
};
commands.entity(entity).insert(TerminalHookFired);
let ctx = serde_json::json!({
"stage": stage.name,
"stage_index": cursor.index,
"status": format!("{}", state.status),
"output": if hook == "on_completion" { subject.clone() } else { String::new() },
"error": if hook == "on_error" { subject.clone() } else { String::new() },
});
match run(&script, hook, ctx) {
Err(e) => refuse(&mut state, hook, format!("hook failed: {e}")),
Ok(HookOutcome::Allow) => {}
Ok(HookOutcome::Modify(value)) => {
let Some(text) = value.as_str() else {
refuse(
&mut state,
hook,
format!("'value' must be replacement text, got: {value}"),
);
continue;
};
match hook {
"on_completion" => match output {
Some(mut o) => o.0.content = text.to_string(),
None => refuse(
&mut state,
hook,
"asked to rewrite the answer, but this run submitted none".to_string(),
),
},
_ => {
state.status = AgentStatus::Error {
message: text.to_string(),
}
}
}
}
Ok(HookOutcome::Cancel(reason)) => {
let why = reason.unwrap_or_else(|| "no reason given".to_string());
refuse(&mut state, hook, format!("rejected the result: {why}"));
}
Ok(HookOutcome::Retry) => refuse(
&mut state,
hook,
"returned 'retry', which this hook cannot honour (the run has finished)"
.to_string(),
),
}
}
}
type StageExitHookQuery = (
Entity,
&'static StageCursor,
&'static AgentBlueprint,
&'static StageHookScripts,
&'static mut ContextWindow,
&'static mut AgentState,
);
pub fn run_stage_exit_hooks(mut agents: Query<StageExitHookQuery, With<ResolveTransition>>) {
crate::tick_scope::clear();
for (entity, cursor, bp, scripts, mut window, mut state) in agents.iter_mut() {
crate::tick_scope::enter(entity);
let Some(stage) = bp.0.stages.get(cursor.index) else {
continue;
};
let Some(script) = scripts.script_for(stage, "on_stage_exit") else {
continue;
};
let ctx = stage_ctx(&stage.name, cursor.index, &window);
match run(&script, "on_stage_exit", ctx) {
Err(e) => refuse(&mut state, "on_stage_exit", format!("hook failed: {e}")),
Ok(HookOutcome::Allow) => {}
Ok(HookOutcome::Modify(value)) => {
if let Err(e) = apply_modify(&mut window, &value) {
refuse(&mut state, "on_stage_exit", e);
}
}
Ok(HookOutcome::Cancel(reason)) => {
let why = reason.unwrap_or_else(|| "no reason given".to_string());
refuse(
&mut state,
"on_stage_exit",
format!("refused to leave stage '{}': {why}", stage.name),
);
}
Ok(HookOutcome::Retry) => refuse(
&mut state,
"on_stage_exit",
"returned 'retry', which this hook cannot honour (the stage is already over)"
.to_string(),
),
}
}
}