Add prompt and agent hook types for LLM-based hook evaluation

Prompt hooks make a single-turn LLM call returning {"ok": true/false}.
Agent hooks run a multi-turn LLM tool loop with sandbox access (exec_command, read_file).
Both fail-open on errors/timeouts. Prompt hooks default to 30s timeout,
agent hooks to 60s with max 50 tool rounds. Default model is "haiku".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-05 02:19:09 -05:00
parent 721224159d
commit fe42189dc9
3 changed files with 395 additions and 2 deletions

View file

@ -28,6 +28,15 @@ pub enum HookType {
#[serde(default)]
tls: TlsMode,
},
Prompt {
prompt: String,
model: Option<String>,
},
Agent {
prompt: String,
model: Option<String>,
max_tool_rounds: Option<u32>,
},
}
/// A single hook definition.
@ -71,9 +80,18 @@ impl HookDefinition {
}
/// Timeout duration for this hook.
///
/// Defaults: 30s for prompt hooks, 60s for all others.
#[must_use]
pub fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.timeout_ms.unwrap_or(60_000))
if let Some(ms) = self.timeout_ms {
return std::time::Duration::from_millis(ms);
}
let default_ms = match self.resolved_hook_type() {
Some(HookType::Prompt { .. }) => 30_000,
_ => 60_000,
};
std::time::Duration::from_millis(default_ms)
}
/// Whether this hook runs in the sandbox.
@ -95,6 +113,10 @@ impl HookDefinition {
format!("{event_str}:{short}")
}
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
Some(HookType::Prompt { ref prompt, .. }) | Some(HookType::Agent { ref prompt, .. }) => {
let short = &prompt[..arc_agent::floor_char_boundary(prompt, 20)];
format!("{event_str}:{short}")
}
None => event_str,
}
}
@ -473,6 +495,100 @@ tls = "off"
}
}
#[test]
fn parse_prompt_hook() {
let toml = r#"
[[hooks]]
event = "stage_start"
type = "prompt"
prompt = "Should this stage proceed?"
model = "haiku"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
assert!(matches!(
hook.resolved_hook_type(),
Some(HookType::Prompt { prompt, model })
if prompt == "Should this stage proceed?" && model == Some("haiku".into())
));
}
#[test]
fn parse_agent_hook() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "agent"
prompt = "Verify tests pass."
model = "sonnet"
max_tool_rounds = 10
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
assert!(matches!(
hook.resolved_hook_type(),
Some(HookType::Agent { prompt, model, max_tool_rounds })
if prompt == "Verify tests pass."
&& model == Some("sonnet".into())
&& max_tool_rounds == Some(10)
));
}
#[test]
fn prompt_hook_default_timeout_30s() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "check".into(),
model: None,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.timeout(), std::time::Duration::from_secs(30));
}
#[test]
fn agent_hook_default_timeout_60s() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Agent {
prompt: "check".into(),
model: None,
max_tool_rounds: None,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
}
#[test]
fn effective_name_generated_from_prompt_hook() {
let def = HookDefinition {
name: None,
event: HookEvent::StageStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "Should this stage proceed?".into(),
model: None,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert!(def.effective_name().starts_with("stage_start:"));
}
#[test]
fn parse_multiple_hooks() {
let toml = r#"

View file

@ -7,7 +7,7 @@ use async_trait::async_trait;
use arc_agent::Sandbox;
use super::config::{HookDefinition, HookType, TlsMode};
use super::types::{HookContext, HookDecision, HookResult};
use super::types::{HookContext, HookDecision, HookResult, PromptHookResponse};
/// Trait for executing hooks via different transports.
#[async_trait]
@ -165,6 +165,203 @@ impl HookExecutorImpl {
}
}
/// Parse a prompt/agent hook LLM response into a `HookDecision`.
///
/// Fail-open: invalid JSON or missing fields → `Proceed`.
pub fn parse_prompt_response(response_text: &str) -> HookDecision {
match serde_json::from_str::<PromptHookResponse>(response_text.trim()) {
Ok(resp) if resp.ok => HookDecision::Proceed,
Ok(resp) => HookDecision::Block {
reason: resp.reason,
},
Err(e) => {
tracing::warn!(error = %e, "prompt hook response parse failed, proceeding");
HookDecision::Proceed
}
}
}
/// Execute a prompt hook: single-turn LLM call returning ok/block.
async fn execute_prompt(
prompt: &str,
model: &Option<String>,
context: &HookContext,
timeout: std::time::Duration,
) -> HookDecision {
let result = tokio::time::timeout(timeout, async {
let model_id = model.as_deref().unwrap_or("haiku");
let model_info = arc_llm::catalog::get_model_info(model_id);
let resolved_model = model_info.as_ref().map_or(model_id, |m| m.id.as_str());
let context_json = serde_json::to_string(context).unwrap_or_default();
let system = "You are a hook evaluator for a workflow engine. Given context about a workflow event, evaluate the condition and respond with JSON: {\"ok\": true} or {\"ok\": false, \"reason\": \"...\"}. Respond ONLY with valid JSON.";
let user_msg = format!("Hook prompt: {prompt}\n\nEvent context:\n{context_json}");
let params = arc_llm::generate::GenerateParams::new(resolved_model)
.system(system)
.prompt(user_msg);
match arc_llm::generate::generate(params).await {
Ok(result) => Self::parse_prompt_response(&result.response.text()),
Err(e) => {
tracing::warn!(error = %e, "prompt hook LLM call failed, proceeding");
HookDecision::Proceed
}
}
})
.await;
match result {
Ok(decision) => decision,
Err(_) => {
tracing::warn!("prompt hook timed out, proceeding");
HookDecision::Proceed
}
}
}
/// Execute an agent hook: multi-turn LLM call with sandbox tool access.
///
/// Uses a manual tool loop with `Client::complete()` to avoid closure
/// lifetime issues with the sandbox reference.
async fn execute_agent(
prompt: &str,
model: &Option<String>,
max_tool_rounds: Option<u32>,
context: &HookContext,
sandbox: &dyn Sandbox,
timeout: std::time::Duration,
) -> HookDecision {
let result = tokio::time::timeout(timeout, async {
let model_id = model.as_deref().unwrap_or("haiku");
let model_info = arc_llm::catalog::get_model_info(model_id);
let resolved_model = model_info.as_ref().map_or(model_id, |m| m.id.as_str());
let client = match arc_llm::client::Client::from_env().await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "agent hook client creation failed, proceeding");
return HookDecision::Proceed;
}
};
let context_json = serde_json::to_string(context).unwrap_or_default();
let system = "You are a hook evaluator for a workflow engine. Given context about a workflow event, evaluate the condition and respond with JSON: {\"ok\": true} or {\"ok\": false, \"reason\": \"...\"}. Respond ONLY with valid JSON.";
let user_msg = format!("Hook prompt: {prompt}\n\nEvent context:\n{context_json}");
let tool_defs = vec![
arc_llm::types::ToolDefinition {
name: "exec_command".into(),
description: "Execute a shell command in the sandbox".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to execute" }
},
"required": ["command"]
}),
},
arc_llm::types::ToolDefinition {
name: "read_file".into(),
description: "Read a file from the sandbox".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file to read" }
},
"required": ["path"]
}),
},
];
let mut messages = vec![
arc_llm::types::Message::system(system),
arc_llm::types::Message::user(user_msg),
];
let rounds = max_tool_rounds.unwrap_or(50);
for _ in 0..rounds {
let request = arc_llm::types::Request {
model: resolved_model.to_string(),
messages: messages.clone(),
provider: None,
tools: Some(tool_defs.clone()),
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: None,
stop_sequences: None,
reasoning_effort: None,
metadata: None,
provider_options: None,
};
let response = match client.complete(&request).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "agent hook LLM call failed, proceeding");
return HookDecision::Proceed;
}
};
let tool_calls = response.tool_calls();
if tool_calls.is_empty() {
return Self::parse_prompt_response(&response.text());
}
// Append assistant message with tool calls
messages.push(response.message.clone());
// Execute each tool call against the sandbox
for tc in &tool_calls {
let args = &tc.arguments;
let result_json = match tc.name.as_str() {
"exec_command" => {
let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
match sandbox.exec_command(command, 30_000, None, None, None).await {
Ok(r) => serde_json::json!({
"exit_code": r.exit_code,
"stdout": r.stdout,
"stderr": r.stderr,
}),
Err(e) => serde_json::json!({ "error": e.to_string() }),
}
}
"read_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
match sandbox.read_file(path, None, None).await {
Ok(content) => serde_json::json!({ "content": content }),
Err(e) => serde_json::json!({ "error": e.to_string() }),
}
}
other => serde_json::json!({ "error": format!("unknown tool: {other}") }),
};
messages.push(arc_llm::types::Message::tool_result(
tc.id.clone(),
result_json,
false,
));
}
}
tracing::warn!("agent hook exhausted max tool rounds, proceeding");
HookDecision::Proceed
})
.await;
match result {
Ok(decision) => decision,
Err(_) => {
tracing::warn!("agent hook timed out, proceeding");
HookDecision::Proceed
}
}
}
/// Execute an HTTP hook: POST context JSON and parse the response.
/// Fail-open: non-2xx and connection errors return `Proceed`.
async fn execute_http(
@ -268,6 +465,27 @@ impl HookExecutor for HookExecutorImpl {
Self::execute_http(url, headers, allowed_env_vars, tls, context, definition.timeout())
.await
}
Some(HookType::Prompt {
ref prompt,
ref model,
}) => {
Self::execute_prompt(prompt, model, context, definition.timeout()).await
}
Some(HookType::Agent {
ref prompt,
ref model,
ref max_tool_rounds,
}) => {
Self::execute_agent(
prompt,
model,
*max_tool_rounds,
context,
sandbox,
definition.timeout(),
)
.await
}
None => HookDecision::Block {
reason: Some("no hook type specified".into()),
},
@ -441,6 +659,42 @@ mod tests {
assert!(matches!(result.decision, HookDecision::Block { .. }));
}
// --- parse_prompt_response tests ---
#[test]
fn parse_prompt_response_ok_true() {
assert_eq!(
HookExecutorImpl::parse_prompt_response(r#"{"ok": true}"#),
HookDecision::Proceed,
);
}
#[test]
fn parse_prompt_response_ok_false() {
assert_eq!(
HookExecutorImpl::parse_prompt_response(r#"{"ok": false, "reason": "tests failing"}"#),
HookDecision::Block {
reason: Some("tests failing".into())
},
);
}
#[test]
fn parse_prompt_response_ok_false_no_reason() {
assert_eq!(
HookExecutorImpl::parse_prompt_response(r#"{"ok": false}"#),
HookDecision::Block { reason: None },
);
}
#[test]
fn parse_prompt_response_invalid_json() {
assert_eq!(
HookExecutorImpl::parse_prompt_response("not json"),
HookDecision::Proceed,
);
}
// --- interpolate_env_vars tests ---
#[test]

View file

@ -99,6 +99,14 @@ impl HookContext {
}
}
/// Response returned by prompt/agent hooks from the LLM.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PromptHookResponse {
pub ok: bool,
#[serde(default)]
pub reason: Option<String>,
}
/// Decision returned by blocking hooks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
@ -324,4 +332,19 @@ mod tests {
fn hook_decision_default_is_proceed() {
assert_eq!(HookDecision::default(), HookDecision::Proceed);
}
#[test]
fn prompt_hook_response_ok_true() {
let resp: PromptHookResponse = serde_json::from_str(r#"{"ok": true}"#).unwrap();
assert!(resp.ok);
assert_eq!(resp.reason, None);
}
#[test]
fn prompt_hook_response_ok_false_with_reason() {
let resp: PromptHookResponse =
serde_json::from_str(r#"{"ok": false, "reason": "not ready"}"#).unwrap();
assert!(!resp.ok);
assert_eq!(resp.reason.as_deref(), Some("not ready"));
}
}