merge: harden public hook api
This commit is contained in:
commit
7fff857b4c
|
|
@ -2,8 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! Hooks are the **public** orchestration extension point. They receive
|
//! Hooks are the **public** orchestration extension point. They receive
|
||||||
//! read-only summary information about each event in the Worker
|
//! read-only summary information about each event in the Worker
|
||||||
//! execution loop and return a control-flow action
|
//! execution loop and return a safe public control-flow action.
|
||||||
//! (continue / skip / abort / pause).
|
|
||||||
//!
|
//!
|
||||||
//! Hooks intentionally cannot mutate the Worker's context, history, tool
|
//! Hooks intentionally cannot mutate the Worker's context, history, tool
|
||||||
//! call, or tool result. Internal mechanisms that need such access (e.g.
|
//! 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::{
|
use llm_worker::interceptor::{
|
||||||
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
|
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
|
||||||
};
|
};
|
||||||
use llm_worker::tool::ToolOutput;
|
use llm_worker::tool::{ToolOutput, ToolResult};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Hook-facing prompt-submit action.
|
/// Hook-facing prompt-submit action.
|
||||||
|
|
@ -32,7 +31,7 @@ use serde_json::Value;
|
||||||
pub enum HookPromptAction {
|
pub enum HookPromptAction {
|
||||||
/// Proceed normally.
|
/// Proceed normally.
|
||||||
Continue,
|
Continue,
|
||||||
/// Cancel with a reason.
|
/// Cancel this submitted prompt with a reason.
|
||||||
Cancel(String),
|
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)
|
// Hook input summary types (read-only)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -121,8 +223,8 @@ pub struct AbortInfo {
|
||||||
|
|
||||||
/// Marker trait for hook event kinds.
|
/// Marker trait for hook event kinds.
|
||||||
///
|
///
|
||||||
/// Each event kind specifies its read-only input and the control-flow
|
/// Each event kind specifies its read-only input and the safe public
|
||||||
/// action returned by hooks.
|
/// control-flow action returned by hooks.
|
||||||
pub trait HookEventKind: Send + Sync + 'static {
|
pub trait HookEventKind: Send + Sync + 'static {
|
||||||
/// Read-only input passed to the hook.
|
/// Read-only input passed to the hook.
|
||||||
type Input: Send + Sync;
|
type Input: Send + Sync;
|
||||||
|
|
@ -130,17 +232,18 @@ pub trait HookEventKind: Send + Sync + 'static {
|
||||||
type Output;
|
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;
|
pub struct OnPromptSubmit;
|
||||||
/// Before each LLM request.
|
/// Before each LLM request; may continue, cancel, or yield.
|
||||||
pub struct PreLlmRequest;
|
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;
|
pub struct PreToolCall;
|
||||||
/// After each tool completes.
|
/// After each tool completes; observational except it may abort the run.
|
||||||
pub struct PostToolCall;
|
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;
|
pub struct OnTurnEnd;
|
||||||
/// When execution is interrupted.
|
/// When execution is interrupted; observational only.
|
||||||
pub struct OnAbort;
|
pub struct OnAbort;
|
||||||
|
|
||||||
impl HookEventKind for OnPromptSubmit {
|
impl HookEventKind for OnPromptSubmit {
|
||||||
|
|
@ -150,22 +253,22 @@ impl HookEventKind for OnPromptSubmit {
|
||||||
|
|
||||||
impl HookEventKind for PreLlmRequest {
|
impl HookEventKind for PreLlmRequest {
|
||||||
type Input = PreRequestInfo;
|
type Input = PreRequestInfo;
|
||||||
type Output = PreRequestAction;
|
type Output = HookPreRequestAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEventKind for PreToolCall {
|
impl HookEventKind for PreToolCall {
|
||||||
type Input = ToolCallSummary;
|
type Input = ToolCallSummary;
|
||||||
type Output = PreToolAction;
|
type Output = HookPreToolAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEventKind for PostToolCall {
|
impl HookEventKind for PostToolCall {
|
||||||
type Input = ToolResultSummary;
|
type Input = ToolResultSummary;
|
||||||
type Output = PostToolAction;
|
type Output = HookPostToolAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEventKind for OnTurnEnd {
|
impl HookEventKind for OnTurnEnd {
|
||||||
type Input = TurnEndInfo;
|
type Input = TurnEndInfo;
|
||||||
type Output = TurnEndAction;
|
type Output = HookTurnEndAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEventKind for OnAbort {
|
impl HookEventKind for OnAbort {
|
||||||
|
|
@ -180,9 +283,9 @@ impl HookEventKind for OnAbort {
|
||||||
/// Async hook for a specific event kind.
|
/// Async hook for a specific event kind.
|
||||||
///
|
///
|
||||||
/// Hooks receive a shared reference to the event's read-only input
|
/// Hooks receive a shared reference to the event's read-only input
|
||||||
/// and return a control-flow action. Multiple hooks can be registered
|
/// and return a safe public control-flow action. Multiple hooks can be
|
||||||
/// per event; they are evaluated in registration order and
|
/// registered per event; they are evaluated in registration order and
|
||||||
/// short-circuit on the first non-Continue (or non-Finish) result.
|
/// short-circuit on the first non-continue action.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Hook<E: HookEventKind>: Send + Sync {
|
pub trait Hook<E: HookEventKind>: Send + Sync {
|
||||||
async fn call(&self, input: &E::Input) -> E::Output;
|
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_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
|
||||||
pub(crate) on_abort: Vec<Box<dyn Hook<OnAbort>>>,
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ use session_store::{SystemItem, SystemReminder};
|
||||||
use tools::{TaskEntry, TaskStatus, TaskStore};
|
use tools::{TaskEntry, TaskStatus, TaskStore};
|
||||||
|
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
|
AbortInfo, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
|
||||||
|
HookRegistry, HookTurnEndAction, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
|
||||||
ToolResultSummary, TurnEndInfo,
|
ToolResultSummary, TurnEndInfo,
|
||||||
};
|
};
|
||||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item};
|
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 {
|
for hook in &self.registry.pre_llm_request {
|
||||||
let action = hook.call(&info).await;
|
let action = hook.call(&info).await;
|
||||||
if !matches!(action, PreRequestAction::Continue) {
|
if !matches!(action, HookPreRequestAction::Continue) {
|
||||||
return action;
|
return action.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
|
|
@ -358,8 +359,8 @@ impl Interceptor for PodInterceptor {
|
||||||
};
|
};
|
||||||
for hook in &self.registry.pre_tool_call {
|
for hook in &self.registry.pre_tool_call {
|
||||||
let action = hook.call(&summary).await;
|
let action = hook.call(&summary).await;
|
||||||
if !matches!(action, PreToolAction::Continue) {
|
if !matches!(action, HookPreToolAction::Continue) {
|
||||||
return action;
|
return action.into_worker_action(summary.call_id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if is_task_management_tool(&info.call.name) {
|
if is_task_management_tool(&info.call.name) {
|
||||||
|
|
@ -381,8 +382,8 @@ impl Interceptor for PodInterceptor {
|
||||||
};
|
};
|
||||||
for hook in &self.registry.post_tool_call {
|
for hook in &self.registry.post_tool_call {
|
||||||
let action = hook.call(&summary).await;
|
let action = hook.call(&summary).await;
|
||||||
if !matches!(action, PostToolAction::Continue) {
|
if !matches!(action, HookPostToolAction::Continue) {
|
||||||
return action;
|
return action.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PostToolAction::Continue
|
PostToolAction::Continue
|
||||||
|
|
@ -403,8 +404,8 @@ impl Interceptor for PodInterceptor {
|
||||||
};
|
};
|
||||||
for hook in &self.registry.on_turn_end {
|
for hook in &self.registry.on_turn_end {
|
||||||
let action = hook.call(&info).await;
|
let action = hook.call(&info).await;
|
||||||
if !matches!(action, TurnEndAction::Finish) {
|
if !matches!(action, HookTurnEndAction::Finish) {
|
||||||
return action;
|
return action.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TurnEndAction::Finish
|
TurnEndAction::Finish
|
||||||
|
|
@ -480,16 +481,19 @@ mod tests {
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||||
|
|
||||||
use super::*;
|
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;
|
use session_store::SystemReminderSource;
|
||||||
|
|
||||||
struct CountingHook(Arc<AtomicUsize>);
|
struct CountingHook(Arc<AtomicUsize>);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for CountingHook {
|
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);
|
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())
|
let def = tools::task_tools(TaskStore::new())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|def| {
|
.find(|def| {
|
||||||
|
|
@ -525,15 +529,19 @@ mod tests {
|
||||||
})
|
})
|
||||||
.expect("task tool definition");
|
.expect("task tool definition");
|
||||||
let (meta, tool) = def();
|
let (meta, tool) = def();
|
||||||
let mut info = ToolCallInfo {
|
ToolCallInfo {
|
||||||
call: llm_worker::tool::ToolCall {
|
call: llm_worker::tool::ToolCall {
|
||||||
id: "call-id".into(),
|
id: "call-id".into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
input: serde_json::json!({}),
|
input,
|
||||||
},
|
},
|
||||||
meta,
|
meta,
|
||||||
tool,
|
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;
|
let action = interceptor.pre_tool_call(&mut info).await;
|
||||||
assert!(matches!(action, PreToolAction::Continue));
|
assert!(matches!(action, PreToolAction::Continue));
|
||||||
}
|
}
|
||||||
|
|
@ -739,12 +747,160 @@ mod tests {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for AbortingHook {
|
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);
|
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]
|
#[tokio::test]
|
||||||
async fn pending_history_appends_drains_buffer_into_items() {
|
async fn pending_history_appends_drains_buffer_into_items() {
|
||||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::interceptor::PreToolAction;
|
|
||||||
use llm_worker::llm_client::client::LlmClient;
|
use llm_worker::llm_client::client::LlmClient;
|
||||||
use llm_worker::tool::ToolResult;
|
|
||||||
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use session_store::Store;
|
use session_store::Store;
|
||||||
|
|
||||||
use crate::Pod;
|
use crate::Pod;
|
||||||
use crate::hook::{Hook, PreToolCall, ToolCallSummary};
|
use crate::hook::{Hook, HookPreToolAction, PreToolCall, ToolCallSummary};
|
||||||
|
|
||||||
/// Built-in manifest permission policy for `PreToolCall`.
|
/// Built-in manifest permission policy for `PreToolCall`.
|
||||||
///
|
///
|
||||||
|
|
@ -47,34 +45,28 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreToolCall> for PermissionHook {
|
impl Hook<PreToolCall> for PermissionHook {
|
||||||
async fn call(&self, input: &ToolCallSummary) -> PreToolAction {
|
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
|
||||||
match self.action_for(input) {
|
match self.action_for(input) {
|
||||||
ToolPermissionAction::Allow => PreToolAction::Continue,
|
ToolPermissionAction::Allow => HookPreToolAction::Continue,
|
||||||
ToolPermissionAction::Deny => PreToolAction::SyntheticResult(permission_denied(input)),
|
ToolPermissionAction::Deny => HookPreToolAction::Deny(permission_denied_message(input)),
|
||||||
ToolPermissionAction::Ask => {
|
ToolPermissionAction::Ask => {
|
||||||
PreToolAction::SyntheticResult(permission_ask_unsupported(input))
|
HookPreToolAction::Deny(permission_ask_unsupported_message(input))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn permission_denied(input: &ToolCallSummary) -> ToolResult {
|
fn permission_denied_message(input: &ToolCallSummary) -> String {
|
||||||
ToolResult::error(
|
|
||||||
input.call_id.clone(),
|
|
||||||
format!(
|
format!(
|
||||||
"permission denied: tool `{}` arguments matched the manifest permission policy",
|
"permission denied: tool `{}` arguments matched the manifest permission policy",
|
||||||
input.tool_name
|
input.tool_name
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult {
|
fn permission_ask_unsupported_message(input: &ToolCallSummary) -> String {
|
||||||
ToolResult::error(
|
|
||||||
input.call_id.clone(),
|
|
||||||
format!(
|
format!(
|
||||||
"permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed",
|
"permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed",
|
||||||
input.tool_name
|
input.tool_name
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +115,7 @@ fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::hook::HookPreToolAction;
|
||||||
use manifest::ToolPermissionRule;
|
use manifest::ToolPermissionRule;
|
||||||
|
|
||||||
fn summary(tool_name: &str, arguments: Value) -> ToolCallSummary {
|
fn summary(tool_name: &str, arguments: Value) -> ToolCallSummary {
|
||||||
|
|
@ -168,6 +161,45 @@ mod tests {
|
||||||
assert_eq!(hook.action_for(&input), ToolPermissionAction::Deny);
|
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]
|
#[test]
|
||||||
fn target_prefers_known_builtin_argument_fields() {
|
fn target_prefers_known_builtin_argument_fields() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ use manifest::{
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
Hook, HookPreRequestAction, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd,
|
||||||
PreRequestInfo, PreToolCall,
|
PostToolCall, PreLlmRequest, PreRequestInfo, PreToolCall,
|
||||||
};
|
};
|
||||||
use crate::ipc::alerter::Alerter;
|
use crate::ipc::alerter::Alerter;
|
||||||
use crate::ipc::interceptor::{PodInterceptor, TaskReminderState};
|
use crate::ipc::interceptor::{PodInterceptor, TaskReminderState};
|
||||||
|
|
@ -43,7 +43,6 @@ use crate::runtime::dir;
|
||||||
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
||||||
use crate::workflow::WorkflowResolveError;
|
use crate::workflow::WorkflowResolveError;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::interceptor::PreRequestAction;
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
||||||
};
|
};
|
||||||
|
|
@ -221,9 +220,9 @@ struct UsageTrackingHook {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for UsageTrackingHook {
|
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);
|
self.tracker.note_request(info.item_count);
|
||||||
PreRequestAction::Continue
|
HookPreRequestAction::Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user