merge: harden public hook api

This commit is contained in:
Keisuke Hirata 2026-06-04 02:05:55 +09:00
commit 7fff857b4c
No known key found for this signature in database
4 changed files with 382 additions and 63 deletions

View File

@ -2,8 +2,7 @@
//!
//! Hooks are the **public** orchestration extension point. They receive
//! read-only summary information about each event in the Worker
//! execution loop and return a control-flow action
//! (continue / skip / abort / pause).
//! execution loop and return a safe public control-flow action.
//!
//! Hooks intentionally cannot mutate the Worker's context, history, tool
//! call, or tool result. Internal mechanisms that need such access (e.g.
@ -18,7 +17,7 @@ use async_trait::async_trait;
use llm_worker::interceptor::{
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
};
use llm_worker::tool::ToolOutput;
use llm_worker::tool::{ToolOutput, ToolResult};
use serde_json::Value;
/// Hook-facing prompt-submit action.
@ -32,7 +31,7 @@ use serde_json::Value;
pub enum HookPromptAction {
/// Proceed normally.
Continue,
/// Cancel with a reason.
/// Cancel this submitted prompt with a reason.
Cancel(String),
}
@ -45,6 +44,109 @@ impl From<HookPromptAction> for PromptAction {
}
}
/// Hook-facing pre-LLM-request action.
///
/// Public hooks may observe the request boundary, cancel the run, or yield
/// control back to the caller. They cannot return
/// `PreRequestAction::ContinueWith(Vec<Item>)`; model-visible request/history
/// additions must use durable host-owned paths such as notifications or
/// system-item commits.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookPreRequestAction {
/// Proceed normally.
Continue,
/// Cancel the run with a reason.
Cancel(String),
/// Yield control to the caller for host-owned processing/resume.
Yield,
}
impl From<HookPreRequestAction> for PreRequestAction {
fn from(action: HookPreRequestAction) -> Self {
match action {
HookPreRequestAction::Continue => PreRequestAction::Continue,
HookPreRequestAction::Cancel(reason) => PreRequestAction::Cancel(reason),
HookPreRequestAction::Yield => PreRequestAction::Yield,
}
}
}
/// Hook-facing pre-tool-call action.
///
/// Hooks may continue, pause/abort the call, or deny it with an error
/// string that Pod converts into a synthetic tool result for the current
/// tool call. Hooks cannot express the internal no-result skip path, mutate
/// the tool call arguments, or construct arbitrary `ToolResult` values.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookPreToolAction {
/// Proceed with tool execution.
Continue,
/// Deny this tool call and commit a synthetic error result.
Deny(String),
/// Abort the entire run.
Abort(String),
/// Pause execution.
Pause,
}
impl HookPreToolAction {
pub(crate) fn into_worker_action(self, call_id: String) -> PreToolAction {
match self {
HookPreToolAction::Continue => PreToolAction::Continue,
HookPreToolAction::Deny(reason) => {
PreToolAction::SyntheticResult(ToolResult::error(call_id, reason))
}
HookPreToolAction::Abort(reason) => PreToolAction::Abort(reason),
HookPreToolAction::Pause => PreToolAction::Pause,
}
}
}
/// Hook-facing post-tool-call action.
///
/// Post-tool hooks are observational except that they may abort the run. They
/// cannot rewrite the tool output; adding an explicit bounded transform would
/// require a separate safe public type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookPostToolAction {
/// Proceed normally.
Continue,
/// Abort the entire run.
Abort(String),
}
impl From<HookPostToolAction> for PostToolAction {
fn from(action: HookPostToolAction) -> Self {
match action {
HookPostToolAction::Continue => PostToolAction::Continue,
HookPostToolAction::Abort(reason) => PostToolAction::Abort(reason),
}
}
}
/// Hook-facing turn-end action.
///
/// Turn-end hooks may observe a completed turn and optionally pause further
/// execution. They cannot return
/// `TurnEndAction::ContinueWithMessages(Vec<Item>)`; public hooks must not
/// append arbitrary model-visible messages at turn boundaries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookTurnEndAction {
/// Finish the turn normally.
Finish,
/// Pause execution.
Pause,
}
impl From<HookTurnEndAction> for TurnEndAction {
fn from(action: HookTurnEndAction) -> Self {
match action {
HookTurnEndAction::Finish => TurnEndAction::Finish,
HookTurnEndAction::Pause => TurnEndAction::Pause,
}
}
}
// =============================================================================
// Hook input summary types (read-only)
// =============================================================================
@ -121,8 +223,8 @@ pub struct AbortInfo {
/// Marker trait for hook event kinds.
///
/// Each event kind specifies its read-only input and the control-flow
/// action returned by hooks.
/// Each event kind specifies its read-only input and the safe public
/// control-flow action returned by hooks.
pub trait HookEventKind: Send + Sync + 'static {
/// Read-only input passed to the hook.
type Input: Send + Sync;
@ -130,17 +232,18 @@ pub trait HookEventKind: Send + Sync + 'static {
type Output;
}
/// After receiving user input, before adding to history.
/// After receiving user input, before adding to history; may continue or cancel.
pub struct OnPromptSubmit;
/// Before each LLM request.
/// Before each LLM request; may continue, cancel, or yield.
pub struct PreLlmRequest;
/// Before each tool is executed.
/// Before each tool is executed; may continue, deny with a synthetic result,
/// abort, or pause.
pub struct PreToolCall;
/// After each tool completes.
/// After each tool completes; observational except it may abort the run.
pub struct PostToolCall;
/// When a turn ends with no tool calls.
/// When a turn ends with no tool calls; observational except it may pause.
pub struct OnTurnEnd;
/// When execution is interrupted.
/// When execution is interrupted; observational only.
pub struct OnAbort;
impl HookEventKind for OnPromptSubmit {
@ -150,22 +253,22 @@ impl HookEventKind for OnPromptSubmit {
impl HookEventKind for PreLlmRequest {
type Input = PreRequestInfo;
type Output = PreRequestAction;
type Output = HookPreRequestAction;
}
impl HookEventKind for PreToolCall {
type Input = ToolCallSummary;
type Output = PreToolAction;
type Output = HookPreToolAction;
}
impl HookEventKind for PostToolCall {
type Input = ToolResultSummary;
type Output = PostToolAction;
type Output = HookPostToolAction;
}
impl HookEventKind for OnTurnEnd {
type Input = TurnEndInfo;
type Output = TurnEndAction;
type Output = HookTurnEndAction;
}
impl HookEventKind for OnAbort {
@ -180,9 +283,9 @@ impl HookEventKind for OnAbort {
/// Async hook for a specific event kind.
///
/// Hooks receive a shared reference to the event's read-only input
/// and return a control-flow action. Multiple hooks can be registered
/// per event; they are evaluated in registration order and
/// short-circuit on the first non-Continue (or non-Finish) result.
/// and return a safe public control-flow action. Multiple hooks can be
/// registered per event; they are evaluated in registration order and
/// short-circuit on the first non-continue action.
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &E::Input) -> E::Output;
@ -257,3 +360,32 @@ pub struct HookRegistry {
pub(crate) on_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
pub(crate) on_abort: Vec<Box<dyn Hook<OnAbort>>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_pre_tool_hook_actions_cannot_emit_internal_no_result_skip() {
let continue_action = HookPreToolAction::Continue.into_worker_action("call_1".into());
assert!(matches!(continue_action, PreToolAction::Continue));
let deny_action =
HookPreToolAction::Deny("blocked".into()).into_worker_action("call_2".into());
match deny_action {
PreToolAction::SyntheticResult(result) => {
assert_eq!(result.tool_use_id, "call_2");
assert_eq!(result.summary, "blocked");
assert!(result.is_error);
}
other => panic!("public deny must produce synthetic result, got {other:?}"),
}
let abort_action =
HookPreToolAction::Abort("stop".into()).into_worker_action("call_3".into());
assert!(matches!(abort_action, PreToolAction::Abort(reason) if reason == "stop"));
let pause_action = HookPreToolAction::Pause.into_worker_action("call_4".into());
assert!(matches!(pause_action, PreToolAction::Pause));
}
}

View File

@ -27,7 +27,8 @@ use session_store::{SystemItem, SystemReminder};
use tools::{TaskEntry, TaskStatus, TaskStore};
use crate::hook::{
AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
AbortInfo, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
HookRegistry, HookTurnEndAction, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
ToolResultSummary, TurnEndInfo,
};
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item};
@ -343,8 +344,8 @@ impl Interceptor for PodInterceptor {
};
for hook in &self.registry.pre_llm_request {
let action = hook.call(&info).await;
if !matches!(action, PreRequestAction::Continue) {
return action;
if !matches!(action, HookPreRequestAction::Continue) {
return action.into();
}
}
PreRequestAction::Continue
@ -358,8 +359,8 @@ impl Interceptor for PodInterceptor {
};
for hook in &self.registry.pre_tool_call {
let action = hook.call(&summary).await;
if !matches!(action, PreToolAction::Continue) {
return action;
if !matches!(action, HookPreToolAction::Continue) {
return action.into_worker_action(summary.call_id.clone());
}
}
if is_task_management_tool(&info.call.name) {
@ -381,8 +382,8 @@ impl Interceptor for PodInterceptor {
};
for hook in &self.registry.post_tool_call {
let action = hook.call(&summary).await;
if !matches!(action, PostToolAction::Continue) {
return action;
if !matches!(action, HookPostToolAction::Continue) {
return action.into();
}
}
PostToolAction::Continue
@ -403,8 +404,8 @@ impl Interceptor for PodInterceptor {
};
for hook in &self.registry.on_turn_end {
let action = hook.call(&info).await;
if !matches!(action, TurnEndAction::Finish) {
return action;
if !matches!(action, HookTurnEndAction::Finish) {
return action.into();
}
}
TurnEndAction::Finish
@ -480,16 +481,19 @@ mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize};
use super::*;
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
use crate::hook::{
Hook, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookRegistryBuilder,
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
};
use session_store::SystemReminderSource;
struct CountingHook(Arc<AtomicUsize>);
#[async_trait]
impl Hook<PreLlmRequest> for CountingHook {
async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction {
async fn call(&self, _info: &PreRequestInfo) -> HookPreRequestAction {
self.0.fetch_add(1, Ordering::Relaxed);
PreRequestAction::Continue
HookPreRequestAction::Continue
}
}
@ -516,7 +520,7 @@ mod tests {
)
}
async fn call_pre_tool(interceptor: &PodInterceptor, name: &str) {
fn task_tool_call_info(name: &str, input: serde_json::Value) -> ToolCallInfo {
let def = tools::task_tools(TaskStore::new())
.into_iter()
.find(|def| {
@ -525,15 +529,19 @@ mod tests {
})
.expect("task tool definition");
let (meta, tool) = def();
let mut info = ToolCallInfo {
ToolCallInfo {
call: llm_worker::tool::ToolCall {
id: "call-id".into(),
name: name.into(),
input: serde_json::json!({}),
input,
},
meta,
tool,
};
}
}
async fn call_pre_tool(interceptor: &PodInterceptor, name: &str) {
let mut info = task_tool_call_info(name, serde_json::json!({}));
let action = interceptor.pre_tool_call(&mut info).await;
assert!(matches!(action, PreToolAction::Continue));
}
@ -739,12 +747,160 @@ mod tests {
#[async_trait]
impl Hook<PreLlmRequest> for AbortingHook {
async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction {
async fn call(&self, _info: &PreRequestInfo) -> HookPreRequestAction {
self.0.store(true, Ordering::Relaxed);
PreRequestAction::Cancel("nope".into())
HookPreRequestAction::Cancel("nope".into())
}
}
#[tokio::test]
async fn public_pre_tool_hook_deny_becomes_synthetic_error_and_short_circuits() {
struct DenyToolHook(Arc<AtomicUsize>);
struct CountingToolHook(Arc<AtomicUsize>);
#[async_trait]
impl Hook<PreToolCall> for DenyToolHook {
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
self.0.fetch_add(1, Ordering::Relaxed);
assert_eq!(input.call_id, "call-id");
assert_eq!(input.tool_name, "TaskList");
assert_eq!(input.arguments, serde_json::json!({"scope": "all"}));
HookPreToolAction::Deny("blocked by public hook".into())
}
}
#[async_trait]
impl Hook<PreToolCall> for CountingToolHook {
async fn call(&self, _input: &ToolCallSummary) -> HookPreToolAction {
self.0.fetch_add(1, Ordering::Relaxed);
HookPreToolAction::Continue
}
}
let first_count = Arc::new(AtomicUsize::new(0));
let second_count = Arc::new(AtomicUsize::new(0));
let mut builder = HookRegistryBuilder::new();
builder.add_pre_tool_call(DenyToolHook(first_count.clone()));
builder.add_pre_tool_call(CountingToolHook(second_count.clone()));
let registry = Arc::new(builder.build());
let interceptor = PodInterceptor::new(
registry,
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
TaskStore::new(),
Arc::new(TaskReminderState::new()),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
let action = interceptor.pre_tool_call(&mut info).await;
match action {
PreToolAction::SyntheticResult(result) => {
assert_eq!(result.tool_use_id, "call-id");
assert_eq!(result.summary, "blocked by public hook");
assert_eq!(result.content, None);
assert!(result.is_error);
}
other => panic!("expected synthetic denial, got {other:?}"),
}
assert_eq!(first_count.load(Ordering::Relaxed), 1);
assert_eq!(second_count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn public_post_tool_hooks_observe_output_but_only_abort() {
struct AbortAfterToolHook(Arc<AtomicUsize>);
#[async_trait]
impl Hook<PostToolCall> for AbortAfterToolHook {
async fn call(&self, input: &ToolResultSummary) -> HookPostToolAction {
self.0.fetch_add(1, Ordering::Relaxed);
assert_eq!(input.call_id, "call-id");
assert_eq!(input.tool_name, "TaskList");
assert!(!input.is_error);
assert_eq!(input.output.summary, "ok");
assert_eq!(input.output.content.as_deref(), Some("full"));
HookPostToolAction::Abort("post tool abort".into())
}
}
let count = Arc::new(AtomicUsize::new(0));
let mut builder = HookRegistryBuilder::new();
builder.add_post_tool_call(AbortAfterToolHook(count.clone()));
let registry = Arc::new(builder.build());
let interceptor = PodInterceptor::new(
registry,
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
TaskStore::new(),
Arc::new(TaskReminderState::new()),
PromptCatalog::builtins_only().unwrap(),
None,
);
let info = task_tool_call_info("TaskList", serde_json::json!({}));
let mut result_info = ToolResultInfo {
call: info.call,
result: llm_worker::tool::ToolResult::from_output(
"call-id",
ToolOutput {
summary: "ok".into(),
content: Some("full".into()),
},
),
meta: info.meta,
tool: info.tool,
};
let action = interceptor.post_tool_call(&mut result_info).await;
assert_eq!(action, PostToolAction::Abort("post tool abort".to_string()));
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn public_turn_end_hooks_are_observational_or_pause_only() {
struct PauseTurnEndHook(Arc<AtomicUsize>);
#[async_trait]
impl Hook<OnTurnEnd> for PauseTurnEndHook {
async fn call(&self, input: &TurnEndInfo) -> HookTurnEndAction {
self.0.fetch_add(1, Ordering::Relaxed);
assert_eq!(input.turn_index, 0);
assert_eq!(input.tool_calls_count, 0);
assert_eq!(input.final_text_preview, "done");
HookTurnEndAction::Pause
}
}
let count = Arc::new(AtomicUsize::new(0));
let mut builder = HookRegistryBuilder::new();
builder.add_on_turn_end(PauseTurnEndHook(count.clone()));
let registry = Arc::new(builder.build());
let interceptor = PodInterceptor::new(
registry,
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
TaskStore::new(),
Arc::new(TaskReminderState::new()),
PromptCatalog::builtins_only().unwrap(),
None,
);
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
let action = interceptor.on_turn_end(&history).await;
assert!(matches!(action, TurnEndAction::Pause));
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn pending_history_appends_drains_buffer_into_items() {
let registry = Arc::new(HookRegistryBuilder::new().build());

View File

@ -1,13 +1,11 @@
use async_trait::async_trait;
use llm_worker::interceptor::PreToolAction;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::tool::ToolResult;
use manifest::{ToolPermissionAction, ToolPermissionConfig};
use serde_json::Value;
use session_store::Store;
use crate::Pod;
use crate::hook::{Hook, PreToolCall, ToolCallSummary};
use crate::hook::{Hook, HookPreToolAction, PreToolCall, ToolCallSummary};
/// Built-in manifest permission policy for `PreToolCall`.
///
@ -47,34 +45,28 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
#[async_trait]
impl Hook<PreToolCall> for PermissionHook {
async fn call(&self, input: &ToolCallSummary) -> PreToolAction {
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
match self.action_for(input) {
ToolPermissionAction::Allow => PreToolAction::Continue,
ToolPermissionAction::Deny => PreToolAction::SyntheticResult(permission_denied(input)),
ToolPermissionAction::Allow => HookPreToolAction::Continue,
ToolPermissionAction::Deny => HookPreToolAction::Deny(permission_denied_message(input)),
ToolPermissionAction::Ask => {
PreToolAction::SyntheticResult(permission_ask_unsupported(input))
HookPreToolAction::Deny(permission_ask_unsupported_message(input))
}
}
}
}
fn permission_denied(input: &ToolCallSummary) -> ToolResult {
ToolResult::error(
input.call_id.clone(),
fn permission_denied_message(input: &ToolCallSummary) -> String {
format!(
"permission denied: tool `{}` arguments matched the manifest permission policy",
input.tool_name
),
)
}
fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult {
ToolResult::error(
input.call_id.clone(),
fn permission_ask_unsupported_message(input: &ToolCallSummary) -> String {
format!(
"permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed",
input.tool_name
),
)
}
@ -123,6 +115,7 @@ fn wildcard_match(pattern: &str, text: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::hook::HookPreToolAction;
use manifest::ToolPermissionRule;
fn summary(tool_name: &str, arguments: Value) -> ToolCallSummary {
@ -168,6 +161,45 @@ mod tests {
assert_eq!(hook.action_for(&input), ToolPermissionAction::Deny);
}
#[tokio::test]
async fn deny_and_ask_fail_closed_as_public_deny_actions() {
let deny = PermissionHook::new(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: Vec::new(),
});
let denied = deny
.call(&summary(
"Bash",
serde_json::json!({ "command": "rm -rf target" }),
))
.await;
match denied {
HookPreToolAction::Deny(message) => {
assert!(message.contains("permission denied"));
assert!(message.contains("Bash"));
}
other => panic!("expected fail-closed deny action, got {other:?}"),
}
let ask = PermissionHook::new(ToolPermissionConfig {
default_action: ToolPermissionAction::Ask,
rules: Vec::new(),
});
let asked = ask
.call(&summary(
"Bash",
serde_json::json!({ "command": "git status" }),
))
.await;
match asked {
HookPreToolAction::Deny(message) => {
assert!(message.contains("permission ask unsupported"));
assert!(message.contains("denied fail-closed"));
}
other => panic!("expected ask fail-closed deny action, got {other:?}"),
}
}
#[test]
fn target_prefers_known_builtin_argument_fields() {
assert_eq!(

View File

@ -29,8 +29,8 @@ use manifest::{
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use crate::hook::{
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
PreRequestInfo, PreToolCall,
Hook, HookPreRequestAction, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd,
PostToolCall, PreLlmRequest, PreRequestInfo, PreToolCall,
};
use crate::ipc::alerter::Alerter;
use crate::ipc::interceptor::{PodInterceptor, TaskReminderState};
@ -43,7 +43,6 @@ use crate::runtime::dir;
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
use crate::workflow::WorkflowResolveError;
use async_trait::async_trait;
use llm_worker::interceptor::PreRequestAction;
use protocol::{
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
};
@ -221,9 +220,9 @@ struct UsageTrackingHook {
#[async_trait]
impl Hook<PreLlmRequest> for UsageTrackingHook {
async fn call(&self, info: &PreRequestInfo) -> PreRequestAction {
async fn call(&self, info: &PreRequestInfo) -> HookPreRequestAction {
self.tracker.note_request(info.item_count);
PreRequestAction::Continue
HookPreRequestAction::Continue
}
}