mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
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:
parent
721224159d
commit
fe42189dc9
3 changed files with 395 additions and 2 deletions
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue