Skip to main content

harn_vm/orchestration/
hooks.rs

1//! Runtime lifecycle hooks — tool, agent-turn, and worker interception.
2
3use std::cell::RefCell;
4use std::future::Future;
5use std::sync::Arc;
6
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10use harn_parser::diagnostic_codes::Code;
11
12use crate::agent_events::WorkerEvent;
13use crate::llm::helpers::{ReminderPropagate, ReminderRoleHint, ReminderSource, SystemReminder};
14use crate::value::{VmClosure, VmError, VmValue};
15
16mod post_tool;
17mod reminder_fields;
18mod vm_entry;
19use post_tool::{apply_post_tool_action, parse_post_tool_result};
20pub use post_tool::{PostToolAction, PostToolHookResult};
21use vm_entry::{invoke_vm_hook_handler, invoke_vm_lifecycle_hooks};
22
23tokio::task_local! {
24    static HOOK_REMINDER_REPORTS_TASK: Arc<parking_lot::Mutex<Vec<serde_json::Value>>>;
25}
26
27fn record_hook_reminder_report(report: serde_json::Value) {
28    let _ = HOOK_REMINDER_REPORTS_TASK.try_with(|reports| reports.lock().push(report));
29}
30
31pub async fn scope_hook_reminder_reports<F, T>(future: F) -> (T, Vec<serde_json::Value>)
32where
33    F: Future<Output = T>,
34{
35    let reports = Arc::new(parking_lot::Mutex::new(Vec::new()));
36    let output = HOOK_REMINDER_REPORTS_TASK
37        .scope(reports.clone(), future)
38        .await;
39    let reports = std::mem::take(&mut *reports.lock());
40    (output, reports)
41}
42
43/// High-level grouping for a hook event. Drives `parse_session_event` /
44/// `parse_provider_event` routing, reminder support, and the
45/// `clear_session_hooks` filter, so each behavior derives from the
46/// variant's declared kind rather than a hand-maintained match arm.
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
48pub enum HookEventKind {
49    /// Tool-call lifecycle (PreToolUse / PostToolUse).
50    Tool,
51    /// Agent-turn lifecycle (PreAgentTurn / PostAgentTurn).
52    AgentTurn,
53    /// Worker lifecycle — the only kind that rejects reminder effects.
54    Worker,
55    /// Step lifecycle (PreStep / PostStep).
56    Step,
57    /// Notification surfaces (budget / approval / handoff / persona).
58    Notification,
59    /// Session-level lifecycle. Eligible for `parse_session_event` and
60    /// scoped clearing via `clear_session_hooks`.
61    Session,
62}
63
64/// `hook_events!` — single source of truth for `HookEvent`. Emits the
65/// enum, `as_str`, `kind`, `supports_reminder_effects`,
66/// `is_session_lifecycle`, `parse_session_event`, `parse_provider_event`,
67/// `from_worker_event`, and the canonical `ALL` slice. Adding a variant
68/// requires only one new line — every dispatch table is derived.
69///
70/// Each entry has the form
71/// `Variant { kind: Kind [, provider_parse: true] [, aliases: [..]] }`:
72/// `provider_parse` flags variants accepted directly by
73/// `parse_provider_event` (Worker variants are accepted by virtue of
74/// `kind: Worker`); `aliases` lists explicit extra wire names beyond
75/// the auto-derived `snake_case` of the variant identifier.
76macro_rules! hook_events {
77    (
78        $(
79            $(#[$attr:meta])*
80            $variant:ident {
81                kind: $kind:ident
82                $(, provider_parse: $provider_parse:literal)?
83                $(, aliases: [$($alias:literal),* $(,)?])?
84                $(,)?
85            }
86        ),* $(,)?
87    ) => {
88        #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
89        pub enum HookEvent {
90            $(
91                $(#[$attr])*
92                $variant,
93            )*
94        }
95
96        impl HookEvent {
97            /// Canonical PascalCase wire name.
98            pub const fn as_str(self) -> &'static str {
99                match self {
100                    $(Self::$variant => stringify!($variant),)*
101                }
102            }
103
104            /// High-level grouping — drives every other routing predicate.
105            pub const fn kind(self) -> HookEventKind {
106                match self {
107                    $(Self::$variant => HookEventKind::$kind,)*
108                }
109            }
110
111            /// Reminder effects are rejected by Worker events because
112            /// they fire from contexts without a pending tool-call /
113            /// transcript slot for the reminder to attach to.
114            pub const fn supports_reminder_effects(self) -> bool {
115                !matches!(self.kind(), HookEventKind::Worker)
116            }
117
118            /// Whether `clear_session_hooks` and `parse_session_event`
119            /// own this variant.
120            pub const fn is_session_lifecycle(self) -> bool {
121                matches!(self.kind(), HookEventKind::Session)
122            }
123
124            /// All variants in declaration order. Stable enough that
125            /// `parse_*` functions can iterate it.
126            pub const ALL: &'static [Self] = &[$(Self::$variant,)*];
127
128            /// Whether this variant is accepted directly by
129            /// `parse_provider_event` (independent of the
130            /// session-parser fallback). Worker variants are accepted
131            /// implicitly by their kind.
132            const fn in_provider_parse(self) -> bool {
133                match self {
134                    $(Self::$variant => hook_events!(@or_false $($provider_parse)?),)*
135                }
136            }
137
138            /// Explicit non-snake-case aliases declared on the variant.
139            const fn extra_aliases(self) -> &'static [&'static str] {
140                match self {
141                    $(Self::$variant => &[$($($alias),*)?],)*
142                }
143            }
144
145            /// Parse a session-level hook event name. Returns `Err` for
146            /// unknown or non-session events; persona/tool/worker events
147            /// are intentionally rejected so each registration surface
148            /// owns its own event set. Accepts the canonical PascalCase
149            /// spelling, its auto-derived snake_case, and any explicit
150            /// `aliases: [...]` declared in `hook_events!`.
151            pub fn parse_session_event(name: &str) -> Result<Self, String> {
152                let trimmed = name.trim();
153                for &event in Self::ALL.iter().filter(|e| e.is_session_lifecycle()) {
154                    if event_matches_name(event, trimmed) {
155                        return Ok(event);
156                    }
157                }
158                Err(format!("unknown session hook event `{trimmed}`"))
159            }
160
161            /// Parse a reminder-provider event name. Accepts Worker
162            /// events, any variant flagged `provider_parse: true` in
163            /// `hook_events!`, and (by fallback) every session event.
164            pub fn parse_provider_event(name: &str) -> Result<Self, String> {
165                let trimmed = name.trim();
166                for &event in Self::ALL.iter().filter(|e| {
167                    matches!(e.kind(), HookEventKind::Worker) || e.in_provider_parse()
168                }) {
169                    if event_matches_name(event, trimmed) {
170                        return Ok(event);
171                    }
172                }
173                Self::parse_session_event(trimmed)
174                    .map_err(|_| format!("unknown reminder provider event `{trimmed}`"))
175            }
176        }
177    };
178    (@or_false $val:literal) => { $val };
179    (@or_false) => { false };
180}
181
182hook_events! {
183    PreToolUse              { kind: Tool },
184    PostToolUse             { kind: Tool, provider_parse: true },
185    PreAgentTurn            { kind: AgentTurn },
186    PostAgentTurn           { kind: AgentTurn, provider_parse: true },
187    WorkerSpawned           { kind: Worker },
188    WorkerProgressed        { kind: Worker },
189    WorkerWaitingForInput   { kind: Worker },
190    WorkerSuspended         { kind: Worker },
191    WorkerResumed           { kind: Worker },
192    WorkerCompleted         { kind: Worker },
193    WorkerFailed            { kind: Worker },
194    WorkerStopped           { kind: Worker },
195    WorkerCancelled         { kind: Worker },
196    PreStep                 { kind: Step },
197    PostStep                { kind: Step, provider_parse: true },
198    OnBudgetThreshold       { kind: Notification, provider_parse: true },
199    OnApprovalRequested     { kind: Notification },
200    OnHandoffEmitted        { kind: Notification },
201    OnPersonaPaused         { kind: Notification },
202    OnPersonaResumed        { kind: Notification },
203    SessionStart            { kind: Session },
204    SessionEnd              { kind: Session },
205    UserPromptSubmit        { kind: Session },
206    PreCompact              { kind: Session },
207    PostCompact             { kind: Session },
208    PostTurn                { kind: Session },
209    PermissionAsked         { kind: Session },
210    PermissionReplied       { kind: Session },
211    FileEdited              { kind: Session },
212    SessionError            { kind: Session, aliases: ["error"] },
213    SessionIdle             { kind: Session },
214    PreFinish               { kind: Session },
215    PostFinish              { kind: Session },
216    OnUnsettledDetected     { kind: Session },
217    PreSuspend              { kind: Session },
218    PostSuspend             { kind: Session },
219    PreResume               { kind: Session },
220    PostResume              { kind: Session },
221    PreDrain                { kind: Session },
222    PostDrain               { kind: Session },
223    OnDrainDecision         { kind: Session },
224    /// Fired by `__agent_loop_checkpoint(kind, ...)` at every safe
225    /// injection seam in the agent loop. Pattern-match on `payload.kind`
226    /// to subscribe to specific seams (e.g. `kind=="pre_tool_dispatch"`)
227    /// or use `*` to observe every checkpoint pass.
228    LoopCheckpoint          { kind: Session },
229}
230
231impl HookEvent {
232    pub fn from_worker_event(event: WorkerEvent) -> Self {
233        match event {
234            WorkerEvent::WorkerSpawned => Self::WorkerSpawned,
235            WorkerEvent::WorkerProgressed => Self::WorkerProgressed,
236            WorkerEvent::WorkerWaitingForInput => Self::WorkerWaitingForInput,
237            WorkerEvent::WorkerSuspended => Self::WorkerSuspended,
238            WorkerEvent::WorkerResumed => Self::WorkerResumed,
239            WorkerEvent::WorkerCompleted => Self::WorkerCompleted,
240            WorkerEvent::WorkerFailed => Self::WorkerFailed,
241            WorkerEvent::WorkerStopped => Self::WorkerStopped,
242            WorkerEvent::WorkerCancelled => Self::WorkerCancelled,
243        }
244    }
245}
246
247fn pascal_to_snake_buf(pascal: &str, buf: &mut String) {
248    buf.clear();
249    buf.reserve(pascal.len() + 4);
250    for (i, c) in pascal.char_indices() {
251        if c.is_ascii_uppercase() {
252            if i > 0 {
253                buf.push('_');
254            }
255            buf.push(c.to_ascii_lowercase());
256        } else {
257            buf.push(c);
258        }
259    }
260}
261
262fn event_matches_name(event: HookEvent, candidate: &str) -> bool {
263    let pascal = event.as_str();
264    if candidate == pascal {
265        return true;
266    }
267    if event.extra_aliases().contains(&candidate) {
268        return true;
269    }
270    let mut snake = String::new();
271    pascal_to_snake_buf(pascal, &mut snake);
272    candidate == snake
273}
274
275/// Control flow returned by a session-level lifecycle hook.
276///
277/// Most session events are advisory (`Allow`). Veto-capable events —
278/// `UserPromptSubmit`, `PreCompact`, plus the lifecycle gates
279/// `PreSuspend` / `PreResume` / `PreDrain` / `OnDrainDecision` /
280/// `OnUnsettledDetected` — accept `Block`. `PermissionAsked` accepts a
281/// `Decision` short-circuit so hooks can override the dynamic
282/// permission policy entirely. Lifecycle gates that support payload
283/// rewriting (PreSuspend / PreResume / PreDrain / OnDrainDecision /
284/// OnUnsettledDetected) accept `Modify { payload }` to amend the
285/// dispatched event — the dispatcher applies the modified payload
286/// before resuming the lifecycle step. `PreFinish` rejects `Block`
287/// explicitly; the runtime surfaces a dedicated error pointing at
288/// `OnFinish.block_until_settled`.
289#[derive(Clone, Debug)]
290pub enum HookControl {
291    Allow,
292    Block {
293        reason: String,
294    },
295    Decision {
296        kind: String,
297        reason: Option<String>,
298    },
299    Modify {
300        payload: serde_json::Value,
301    },
302}
303
304impl HookControl {
305    pub fn as_str(&self) -> &'static str {
306        match self {
307            Self::Allow => "allow",
308            Self::Block { .. } => "block",
309            Self::Modify { .. } => "modify",
310            Self::Decision { kind, .. } => match kind.as_str() {
311                "allow" => "decision_allow",
312                "deny" => "decision_deny",
313                "ask" => "decision_ask",
314                _ => "decision_unknown",
315            },
316        }
317    }
318}
319
320pub type ReminderSpec = SystemReminder;
321
322/// Side effect emitted by a hook in addition to any control/action
323/// result. Reminder effects are appended to the active session
324/// transcript's pending reminder event set.
325#[derive(Clone, Debug)]
326pub enum HookEffect {
327    Reminder(ReminderSpec),
328}
329
330#[derive(Clone, Debug)]
331struct HookOutcome {
332    control: HookControl,
333    effects: Vec<HookEffect>,
334}
335
336/// Action returned by a PreToolUse hook.
337#[derive(Clone, Debug)]
338pub enum PreToolAction {
339    /// Allow the tool call to proceed unchanged.
340    Allow,
341    /// Deny the tool call with an explanation.
342    Deny(String),
343    /// Allow but replace the arguments.
344    Modify(serde_json::Value),
345    /// Inject a reminder, then continue with the inner pre-tool action.
346    Reminder {
347        spec: ReminderSpec,
348        then: Box<PreToolAction>,
349    },
350}
351
352/// Callback types for legacy tool lifecycle hooks.
353pub type PreToolHookFn = Arc<dyn Fn(&str, &serde_json::Value) -> PreToolAction + Send + Sync>;
354pub type PostToolHookFn = Arc<dyn Fn(&str, &str) -> PostToolAction + Send + Sync>;
355
356/// A registered tool hook with a name pattern and callbacks.
357#[derive(Clone)]
358pub struct ToolHook {
359    /// Glob-style pattern matched against tool names (e.g. `"*"`, `"exec*"`, `"read_file"`).
360    pub pattern: String,
361    /// Called before tool execution. Return `Deny` to reject, `Modify` to rewrite args.
362    pub pre: Option<PreToolHookFn>,
363    /// Called after tool execution with the result text. Return `Modify` to rewrite.
364    pub post: Option<PostToolHookFn>,
365}
366
367impl std::fmt::Debug for ToolHook {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        f.debug_struct("ToolHook")
370            .field("pattern", &self.pattern)
371            .field("has_pre", &self.pre.is_some())
372            .field("has_post", &self.post.is_some())
373            .finish()
374    }
375}
376
377#[derive(Clone)]
378enum PatternMatcher {
379    ToolNameGlob(String),
380    EventExpression {
381        source: String,
382        expression: EventPatternExpression,
383    },
384}
385
386#[derive(Clone)]
387enum EventPatternExpression {
388    MatchAll,
389    NeverMatch,
390    Regex { path: String, regex: Regex },
391    Equals { path: String, value: String },
392    NotEquals { path: String, value: String },
393    PathTruthy(String),
394    ToolNameGlob(String),
395}
396
397impl std::fmt::Debug for PatternMatcher {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
401            Self::EventExpression { source, expression } => f
402                .debug_struct("EventExpression")
403                .field("source", source)
404                .field("expression", expression)
405                .finish(),
406        }
407    }
408}
409
410impl std::fmt::Debug for EventPatternExpression {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        match self {
413            Self::MatchAll => f.write_str("MatchAll"),
414            Self::NeverMatch => f.write_str("NeverMatch"),
415            Self::Regex { path, regex } => f
416                .debug_struct("Regex")
417                .field("path", path)
418                .field("regex", &regex.as_str())
419                .finish(),
420            Self::Equals { path, value } => f
421                .debug_struct("Equals")
422                .field("path", path)
423                .field("value", value)
424                .finish(),
425            Self::NotEquals { path, value } => f
426                .debug_struct("NotEquals")
427                .field("path", path)
428                .field("value", value)
429                .finish(),
430            Self::PathTruthy(path) => f.debug_tuple("PathTruthy").field(path).finish(),
431            Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
432        }
433    }
434}
435
436#[derive(Clone)]
437enum RuntimeHookHandler {
438    NativePreTool(PreToolHookFn),
439    NativePostTool(PostToolHookFn),
440    Vm {
441        handler_name: String,
442        callable: crate::value::VmCallable,
443    },
444}
445
446impl std::fmt::Debug for RuntimeHookHandler {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        match self {
449            Self::NativePreTool(_) => f.write_str("NativePreTool(..)"),
450            Self::NativePostTool(_) => f.write_str("NativePostTool(..)"),
451            Self::Vm { handler_name, .. } => f
452                .debug_struct("Vm")
453                .field("handler_name", handler_name)
454                .finish(),
455        }
456    }
457}
458
459#[derive(Clone, Debug)]
460struct RuntimeHook {
461    event: HookEvent,
462    matcher: PatternMatcher,
463    handler: RuntimeHookHandler,
464}
465
466#[derive(Clone, Debug)]
467pub struct VmLifecycleHookInvocation {
468    callable: crate::value::VmCallable,
469    pub handler_name: String,
470}
471
472impl VmLifecycleHookInvocation {
473    /// Resolve this invocation's handler closure against `vm`, loading the
474    /// handler's module on demand for a lazy manifest hook (a cache hit
475    /// when `vm` already imported the graph).
476    pub async fn resolve(&self, vm: &mut crate::vm::Vm) -> Result<Arc<VmClosure>, VmError> {
477        vm.resolve_callable(&self.callable).await.map_err(|error| {
478            VmError::Runtime(format!(
479                "failed to resolve lifecycle hook '{}': {error}",
480                self.handler_name
481            ))
482        })
483    }
484}
485
486#[derive(Clone, Debug)]
487struct VmLifecycleHookRegistration {
488    handler_name: String,
489    callable: crate::value::VmCallable,
490}
491
492thread_local! {
493    static RUNTIME_HOOKS: RefCell<Vec<RuntimeHook>> = const { RefCell::new(Vec::new()) };
494    /// Pending `FileEdited` notifications queued from sync builtins
495    /// (e.g. `write_file`). Drained at safe async boundaries — typically
496    /// at the start of each agent-loop turn — so VM closure handlers
497    /// can run inside an async builtin context.
498    static FILE_EDIT_QUEUE: RefCell<Vec<FileEditedNotification>> = const { RefCell::new(Vec::new()) };
499    /// Optional singleton PreToolUse hook owned by stdlib opt-in surfaces
500    /// (currently the `path_scope_guard` from #2221). Kept separate from
501    /// `RUNTIME_HOOKS` so the runtime can swap or clear it without
502    /// touching user-registered hooks.
503    static SINGLETON_PRE_TOOL_HOOK: RefCell<Option<PreToolHookFn>> = const { RefCell::new(None) };
504}
505
506/// Install (or replace, with `None`) the singleton runtime pre-tool
507/// hook. The singleton runs ahead of user-registered hooks so a tagged
508/// deny lands in the reminder path before any other hook fires.
509pub fn set_singleton_pre_tool_hook(hook: Option<PreToolHookFn>) {
510    SINGLETON_PRE_TOOL_HOOK.with(|slot| *slot.borrow_mut() = hook);
511}
512
513pub fn singleton_pre_tool_hook() -> Option<PreToolHookFn> {
514    SINGLETON_PRE_TOOL_HOOK.with(|slot| slot.borrow().clone())
515}
516
517#[derive(Clone, Debug)]
518pub struct FileEditedNotification {
519    pub path: String,
520    pub metadata: serde_json::Value,
521}
522
523/// Queue a file-edited notification. Safe to call from sync contexts.
524pub fn queue_file_edited(path: &str, metadata: serde_json::Value) {
525    FILE_EDIT_QUEUE.with(|queue| {
526        queue.borrow_mut().push(FileEditedNotification {
527            path: path.to_string(),
528            metadata,
529        });
530    });
531}
532
533/// Drain queued file-edited notifications. Returns them in the order
534/// they were queued; the caller is responsible for invoking matching
535/// `FileEdited` hooks (async context required).
536pub fn drain_file_edits() -> Vec<FileEditedNotification> {
537    FILE_EDIT_QUEUE.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
538}
539
540pub fn clear_file_edit_queue() {
541    FILE_EDIT_QUEUE.with(|queue| queue.borrow_mut().clear());
542}
543
544// The workspace-wide name matcher (re-exported as
545// `crate::orchestration::glob_match` for the tool-surface, permission, and
546// step-runtime call sites). Semantics live in `harn-glob`.
547pub(crate) use harn_glob::match_name as glob_match;
548
549pub fn register_tool_hook(hook: ToolHook) {
550    if let Some(pre) = hook.pre {
551        RUNTIME_HOOKS.with(|hooks| {
552            hooks.borrow_mut().push(RuntimeHook {
553                event: HookEvent::PreToolUse,
554                matcher: PatternMatcher::ToolNameGlob(hook.pattern.clone()),
555                handler: RuntimeHookHandler::NativePreTool(pre),
556            });
557        });
558    }
559    if let Some(post) = hook.post {
560        RUNTIME_HOOKS.with(|hooks| {
561            hooks.borrow_mut().push(RuntimeHook {
562                event: HookEvent::PostToolUse,
563                matcher: PatternMatcher::ToolNameGlob(hook.pattern),
564                handler: RuntimeHookHandler::NativePostTool(post),
565            });
566        });
567    }
568}
569
570pub fn register_vm_hook(
571    event: HookEvent,
572    pattern: impl Into<String>,
573    handler_name: impl Into<String>,
574    closure: Arc<VmClosure>,
575) {
576    // The handler closure is retained in this thread-local past the lifetime of
577    // the VM that registered it (it may fire from a later, unrelated VM), so
578    // pin its module scope — otherwise the sibling `pub fn`s its body calls
579    // become unresolvable once the registering VM's `module_cache` drops, and
580    // the call falls through to host-bridge dispatch. See
581    // `VmClosure::retained_for_host_registry`. The lazy handler path resolves
582    // and retains the module export set on first fire in the firing VM's
583    // execution-root cache instead.
584    let closure = closure.retained_for_host_registry();
585    RUNTIME_HOOKS.with(|hooks| {
586        hooks.borrow_mut().push(RuntimeHook {
587            event,
588            matcher: compile_event_pattern(pattern.into()),
589            handler: RuntimeHookHandler::Vm {
590                handler_name: handler_name.into(),
591                callable: crate::value::VmCallable::Eager(closure),
592            },
593        });
594    });
595}
596
597/// Register a manifest hook whose handler closure is resolved on first fire.
598pub fn register_vm_hook_lazy(
599    event: HookEvent,
600    pattern: impl Into<String>,
601    handler_name: impl Into<String>,
602    lazy: crate::value::LazyVmCallable,
603) {
604    RUNTIME_HOOKS.with(|hooks| {
605        hooks.borrow_mut().push(RuntimeHook {
606            event,
607            matcher: compile_event_pattern(pattern.into()),
608            handler: RuntimeHookHandler::Vm {
609                handler_name: handler_name.into(),
610                callable: crate::value::VmCallable::Lazy(lazy),
611            },
612        });
613    });
614}
615
616async fn resolve_lifecycle_handler(
617    vm: &mut crate::vm::Vm,
618    callable: &crate::value::VmCallable,
619) -> Result<Arc<VmClosure>, VmError> {
620    vm.resolve_callable(callable).await
621}
622
623pub fn clear_tool_hooks() {
624    RUNTIME_HOOKS.with(|hooks| {
625        hooks
626            .borrow_mut()
627            .retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
628    });
629    set_singleton_pre_tool_hook(None);
630}
631
632pub fn clear_runtime_hooks() {
633    RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
634    set_singleton_pre_tool_hook(None);
635    super::clear_command_policies();
636}
637
638/// Clear only session-level lifecycle hooks (session_start, session_end,
639/// user_prompt_submit, etc.). Leaves tool, persona, step, worker, and
640/// agent-turn hooks installed. Mirrors `clear_tool_hooks()` /
641/// `clear_persona_hooks()` for the new surface.
642pub fn clear_session_hooks() {
643    RUNTIME_HOOKS.with(|hooks| {
644        hooks
645            .borrow_mut()
646            .retain(|hook| !hook.event.is_session_lifecycle());
647    });
648}
649
650fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
651    let mut current = value;
652    for segment in path.split('.') {
653        let serde_json::Value::Object(map) = current else {
654            return None;
655        };
656        current = map.get(segment)?;
657    }
658    Some(current)
659}
660
661fn value_truthy(value: &serde_json::Value) -> bool {
662    match value {
663        serde_json::Value::Null => false,
664        serde_json::Value::Bool(value) => *value,
665        serde_json::Value::Number(value) => value
666            .as_i64()
667            .map(|number| number != 0)
668            .or_else(|| value.as_u64().map(|number| number != 0))
669            .or_else(|| value.as_f64().map(|number| number != 0.0))
670            .unwrap_or(false),
671        serde_json::Value::String(value) => !value.is_empty(),
672        serde_json::Value::Array(values) => !values.is_empty(),
673        serde_json::Value::Object(values) => !values.is_empty(),
674    }
675}
676
677fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
678    match value {
679        Some(serde_json::Value::String(text)) => text.clone(),
680        Some(other) => other.to_string(),
681        None => String::new(),
682    }
683}
684
685fn strip_quoted(value: &str) -> &str {
686    value
687        .trim()
688        .strip_prefix('"')
689        .and_then(|text| text.strip_suffix('"'))
690        .or_else(|| {
691            value
692                .trim()
693                .strip_prefix('\'')
694                .and_then(|text| text.strip_suffix('\''))
695        })
696        .unwrap_or(value.trim())
697}
698
699fn compile_event_pattern(pattern: String) -> PatternMatcher {
700    let trimmed = pattern.trim();
701    let expression = if trimmed.is_empty() || trimmed == "*" {
702        EventPatternExpression::MatchAll
703    } else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
704        match Regex::new(strip_quoted(rhs)) {
705            Ok(regex) => EventPatternExpression::Regex {
706                path: lhs.trim().to_string(),
707                regex,
708            },
709            Err(_) => EventPatternExpression::NeverMatch,
710        }
711    } else if let Some((lhs, rhs)) = trimmed.split_once("==") {
712        EventPatternExpression::Equals {
713            path: lhs.trim().to_string(),
714            value: strip_quoted(rhs).to_string(),
715        }
716    } else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
717        EventPatternExpression::NotEquals {
718            path: lhs.trim().to_string(),
719            value: strip_quoted(rhs).to_string(),
720        }
721    } else if trimmed.contains('.') {
722        EventPatternExpression::PathTruthy(trimmed.to_string())
723    } else {
724        EventPatternExpression::ToolNameGlob(trimmed.to_string())
725    };
726    PatternMatcher::EventExpression {
727        source: pattern,
728        expression,
729    }
730}
731
732fn expression_matches(
733    source: &str,
734    expression: &EventPatternExpression,
735    payload: &serde_json::Value,
736) -> bool {
737    let pattern = source.trim();
738    if pattern.is_empty() || pattern == "*" {
739        return true;
740    }
741    if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
742        if glob_match(pattern, target) {
743            return true;
744        }
745    }
746    match expression {
747        EventPatternExpression::MatchAll => true,
748        EventPatternExpression::NeverMatch => false,
749        EventPatternExpression::Regex { path, regex } => {
750            let value = value_to_pattern_string(value_at_path(payload, path));
751            regex.is_match(&value)
752        }
753        EventPatternExpression::Equals { path, value } => {
754            value_to_pattern_string(value_at_path(payload, path)) == *value
755        }
756        EventPatternExpression::NotEquals { path, value } => {
757            value_to_pattern_string(value_at_path(payload, path)) != *value
758        }
759        EventPatternExpression::PathTruthy(path) => {
760            value_at_path(payload, path).is_some_and(value_truthy)
761        }
762        EventPatternExpression::ToolNameGlob(pattern) => glob_match(
763            pattern,
764            &value_to_pattern_string(value_at_path(payload, "tool.name")),
765        ),
766    }
767}
768
769fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
770    match &hook.matcher {
771        PatternMatcher::ToolNameGlob(pattern) => {
772            tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
773        }
774        PatternMatcher::EventExpression { source, expression } => {
775            expression_matches(source, expression, payload)
776        }
777    }
778}
779
780fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
781    RUNTIME_HOOKS.with(|hooks| {
782        hooks
783            .borrow()
784            .iter()
785            .filter(|hook| hook.event == event)
786            .cloned()
787            .collect()
788    })
789}
790
791fn reminder_error(context: &str, message: impl Into<String>) -> VmError {
792    VmError::Runtime(format!("{context}: {}", message.into()))
793}
794
795fn reminder_code_error(context: &str, code: Code, message: impl Into<String>) -> VmError {
796    reminder_error(context, format!("{}: {}", code.as_str(), message.into()))
797}
798
799fn unsupported_reminder_event_error(event: HookEvent, context: &str) -> VmError {
800    reminder_code_error(
801        context,
802        Code::ReminderUnsupportedHookEvent,
803        format!(
804            "{} does not support reminder effects; use a session, tool, step, or persona hook",
805            event.as_str()
806        ),
807    )
808}
809
810fn required_reminder_spec_string(
811    options: &crate::value::DictMap,
812    key: &str,
813    context: &str,
814) -> Result<String, VmError> {
815    match options.get(key) {
816        Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(value.to_string()),
817        Some(VmValue::String(_)) | None | Some(VmValue::Nil) => Err(reminder_error(
818            context,
819            format!("`{key}` must be a non-empty string"),
820        )),
821        Some(other) => Err(reminder_error(
822            context,
823            format!("`{key}` must be a string, got {}", other.type_name()),
824        )),
825    }
826}
827
828fn optional_reminder_spec_string(
829    options: &crate::value::DictMap,
830    key: &str,
831    context: &str,
832) -> Result<Option<String>, VmError> {
833    match options.get(key) {
834        None | Some(VmValue::Nil) => Ok(None),
835        Some(VmValue::String(value)) => {
836            let trimmed = value.trim();
837            if trimmed.is_empty() {
838                Ok(None)
839            } else {
840                Ok(Some(trimmed.to_string()))
841            }
842        }
843        Some(other) => Err(reminder_error(
844            context,
845            format!("`{key}` must be a string or nil, got {}", other.type_name()),
846        )),
847    }
848}
849
850fn optional_reminder_spec_bool(
851    options: &crate::value::DictMap,
852    key: &str,
853    context: &str,
854) -> Result<Option<bool>, VmError> {
855    match options.get(key) {
856        None | Some(VmValue::Nil) => Ok(None),
857        Some(VmValue::Bool(value)) => Ok(Some(*value)),
858        Some(other) => Err(reminder_error(
859            context,
860            format!("`{key}` must be a bool or nil, got {}", other.type_name()),
861        )),
862    }
863}
864
865fn reminder_spec_tags(
866    options: &crate::value::DictMap,
867    context: &str,
868) -> Result<Vec<String>, VmError> {
869    match options.get("tags") {
870        None | Some(VmValue::Nil) => Ok(Vec::new()),
871        Some(VmValue::List(values)) => {
872            let mut tags = Vec::new();
873            for value in values.iter() {
874                let VmValue::String(tag) = value else {
875                    return Err(reminder_error(
876                        context,
877                        format!("`tags` entries must be strings, got {}", value.type_name()),
878                    ));
879                };
880                let trimmed = tag.trim();
881                if trimmed.is_empty() {
882                    return Err(reminder_error(
883                        context,
884                        "`tags` entries must be non-empty strings",
885                    ));
886                }
887                if !tags.iter().any(|existing| existing == trimmed) {
888                    tags.push(trimmed.to_string());
889                }
890            }
891            Ok(tags)
892        }
893        Some(other) => Err(reminder_error(
894            context,
895            format!("`tags` must be a list or nil, got {}", other.type_name()),
896        )),
897    }
898}
899
900fn optional_reminder_spec_ttl(
901    options: &crate::value::DictMap,
902    context: &str,
903) -> Result<Option<i64>, VmError> {
904    match options.get("ttl_turns") {
905        None | Some(VmValue::Nil) => Ok(None),
906        Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value)),
907        Some(VmValue::Int(_)) => Err(reminder_error(context, "`ttl_turns` must be > 0")),
908        Some(other) => Err(reminder_error(
909            context,
910            format!(
911                "`ttl_turns` must be an int or nil, got {}",
912                other.type_name()
913            ),
914        )),
915    }
916}
917
918fn optional_reminder_spec_propagate(
919    options: &crate::value::DictMap,
920    context: &str,
921) -> Result<Option<ReminderPropagate>, VmError> {
922    optional_reminder_spec_string(options, "propagate", context)?
923        .map(|value| match value.as_str() {
924            "all" => Ok(ReminderPropagate::All),
925            "session" => Ok(ReminderPropagate::Session),
926            "none" => Ok(ReminderPropagate::None),
927            _ => Err(reminder_code_error(
928                context,
929                Code::ReminderUnknownPropagate,
930                "`propagate` must be one of all, session, or none",
931            )),
932        })
933        .transpose()
934}
935
936fn parse_reminder_spec(value: &VmValue, context: &str) -> Result<ReminderSpec, VmError> {
937    let Some(options) = value.as_dict() else {
938        return Err(reminder_error(
939            context,
940            format!("reminder spec must be a dict, got {}", value.type_name()),
941        ));
942    };
943    const ALLOWED: &[&str] = &[
944        "body",
945        "tags",
946        "dedupe_key",
947        "ttl_turns",
948        "preserve_on_compact",
949        "propagate",
950        "role_hint",
951        "authority",
952    ];
953    let unknown = options
954        .keys()
955        .filter(|key| !ALLOWED.contains(&key.as_str()))
956        .map(|key| key.as_str())
957        .collect::<Vec<_>>();
958    if !unknown.is_empty() {
959        return Err(reminder_code_error(
960            context,
961            Code::ReminderUnknownOption,
962            format!("unknown reminder option(s): {}", unknown.join(", ")),
963        ));
964    }
965    let role_hint = optional_reminder_spec_string(options, "role_hint", context)?;
966    let role_hint = reminder_fields::role_hint(role_hint.as_deref())
967        .map_err(|message| reminder_error(context, message))?;
968    let authority = optional_reminder_spec_string(options, "authority", context)?;
969    let authority = reminder_fields::authority(authority.as_deref())
970        .map_err(|message| reminder_error(context, message))?;
971    Ok(SystemReminder {
972        id: uuid::Uuid::now_v7().to_string(),
973        tags: reminder_spec_tags(options, context)?,
974        dedupe_key: optional_reminder_spec_string(options, "dedupe_key", context)?,
975        ttl_turns: optional_reminder_spec_ttl(options, context)?,
976        preserve_on_compact: optional_reminder_spec_bool(options, "preserve_on_compact", context)?
977            .unwrap_or(false),
978        propagate: optional_reminder_spec_propagate(options, context)?
979            .unwrap_or(ReminderPropagate::Session),
980        role_hint: role_hint.unwrap_or(ReminderRoleHint::System),
981        authority: authority.unwrap_or_default(),
982        source: ReminderSource::Hook,
983        body: required_reminder_spec_string(options, "body", context)?,
984        fired_at_turn: 0,
985        originating_agent_id: None,
986    })
987}
988
989fn looks_like_reminder_spec(map: &crate::value::DictMap) -> bool {
990    map.contains_key("body")
991        && !map.contains_key("deny")
992        && !map.contains_key("args")
993        && !map.contains_key("result")
994        && !map.contains_key("output")
995        && !map.contains_key("modify")
996        && !map.contains_key("block")
997        && !map.contains_key("decision")
998        && !map.contains_key("action")
999        && !map.contains_key("control")
1000}
1001
1002fn parse_hook_effect_item(event: HookEvent, value: &VmValue) -> Result<HookEffect, VmError> {
1003    let context = format!("{} hook reminder", event.as_str());
1004    if let Some(map) = value.as_dict() {
1005        if let Some(reminder) = map.get("reminder") {
1006            if !event.supports_reminder_effects() {
1007                return Err(unsupported_reminder_event_error(event, &context));
1008            }
1009            return Ok(HookEffect::Reminder(parse_reminder_spec(
1010                reminder, &context,
1011            )?));
1012        }
1013        if matches!(
1014            map.get("type")
1015                .or_else(|| map.get("kind"))
1016                .map(|value| value.display())
1017                .as_deref(),
1018            Some("reminder" | "Reminder")
1019        ) {
1020            if !event.supports_reminder_effects() {
1021                return Err(unsupported_reminder_event_error(event, &context));
1022            }
1023            let spec = map
1024                .get("spec")
1025                .or_else(|| map.get("reminder"))
1026                .ok_or_else(|| reminder_error(&context, "reminder effect missing `spec`"))?;
1027            return Ok(HookEffect::Reminder(parse_reminder_spec(spec, &context)?));
1028        }
1029        if looks_like_reminder_spec(map) {
1030            if !event.supports_reminder_effects() {
1031                return Err(unsupported_reminder_event_error(event, &context));
1032            }
1033            return Ok(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1034        }
1035    }
1036    Err(reminder_error(
1037        &context,
1038        "hook effect must be {reminder: {...}} or a reminder spec",
1039    ))
1040}
1041
1042pub fn parse_hook_effects(event: HookEvent, value: &VmValue) -> Result<Vec<HookEffect>, VmError> {
1043    let Some(map) = value.as_dict() else {
1044        if let VmValue::List(items) = value {
1045            return items
1046                .iter()
1047                .map(|item| parse_hook_effect_item(event, item))
1048                .collect();
1049        }
1050        return Ok(Vec::new());
1051    };
1052
1053    let mut effects = Vec::new();
1054    if let Some(items) = map.get("effects") {
1055        match items {
1056            VmValue::List(list) => {
1057                for item in list.iter() {
1058                    effects.push(parse_hook_effect_item(event, item)?);
1059                }
1060            }
1061            other => effects.push(parse_hook_effect_item(event, other)?),
1062        }
1063    }
1064    if let Some(reminder) = map.get("reminder") {
1065        let context = format!("{} hook reminder", event.as_str());
1066        if !event.supports_reminder_effects() {
1067            return Err(unsupported_reminder_event_error(event, &context));
1068        }
1069        effects.push(HookEffect::Reminder(parse_reminder_spec(
1070            reminder, &context,
1071        )?));
1072    } else if effects.is_empty() && looks_like_reminder_spec(map) {
1073        let context = format!("{} hook reminder", event.as_str());
1074        if !event.supports_reminder_effects() {
1075            return Err(unsupported_reminder_event_error(event, &context));
1076        }
1077        effects.push(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1078    }
1079    Ok(effects)
1080}
1081
1082fn action_value_after_effects(value: VmValue, default_action: VmValue) -> VmValue {
1083    let VmValue::Dict(map) = value else {
1084        return value;
1085    };
1086    if let Some(then) = map.get("then") {
1087        return then.clone();
1088    }
1089    let has_effects = map.contains_key("effects")
1090        || map.contains_key("reminder")
1091        || looks_like_reminder_spec(map.as_ref());
1092    if !has_effects {
1093        return VmValue::Dict(map);
1094    }
1095    let mut action = map.as_ref().clone();
1096    action.remove("effects");
1097    action.remove("reminder");
1098    action.remove("then");
1099    if action.keys().any(|key| {
1100        matches!(
1101            key.as_str(),
1102            "deny" | "args" | "result" | "output" | "modify" | "block" | "decision" | "action"
1103        )
1104    }) {
1105        VmValue::dict(action)
1106    } else {
1107        default_action
1108    }
1109}
1110
1111pub fn collect_hook_effects_and_action(
1112    event: HookEvent,
1113    value: VmValue,
1114    default_action: VmValue,
1115) -> Result<(VmValue, Vec<HookEffect>), VmError> {
1116    let mut current = value;
1117    let mut effects = Vec::new();
1118    for _ in 0..32 {
1119        let current_effects = parse_hook_effects(event, &current)?;
1120        if current_effects.is_empty() {
1121            return Ok((current, effects));
1122        }
1123        effects.extend(current_effects);
1124        current = action_value_after_effects(current, default_action.clone());
1125    }
1126    Err(VmError::Runtime(format!(
1127        "{} hook reminder return nested too deeply",
1128        event.as_str()
1129    )))
1130}
1131
1132fn inject_hook_effects(
1133    session_id: &str,
1134    effects: Vec<HookEffect>,
1135    event: Option<HookEvent>,
1136) -> Result<(), VmError> {
1137    if effects.is_empty() {
1138        return Ok(());
1139    }
1140    let target_session = if session_id.is_empty() {
1141        crate::agent_sessions::current_session_id().unwrap_or_default()
1142    } else {
1143        session_id.to_string()
1144    };
1145    if target_session.is_empty() {
1146        return Ok(());
1147    }
1148    for effect in effects {
1149        match effect {
1150            HookEffect::Reminder(spec) => {
1151                let reminder_id = spec.id.clone();
1152                let tags = spec.tags.clone();
1153                let dedupe_key = spec.dedupe_key.clone();
1154                let role_hint = spec.role_hint.as_str();
1155                let source = spec.source.as_str();
1156                let ttl_turns = spec.ttl_turns;
1157                let report = crate::agent_sessions::inject_reminder(&target_session, spec)
1158                    .map_err(VmError::Runtime)?;
1159                record_hook_reminder_report(serde_json::json!({
1160                    "hook_event": event.map(|event| event.as_str()),
1161                    "session_id": &target_session,
1162                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1163                    "reminder_id": reminder_id,
1164                    "tags": tags,
1165                    "dedupe_key": dedupe_key,
1166                    "role_hint": role_hint,
1167                    "source": source,
1168                    "ttl_turns": ttl_turns,
1169                    "deduped_count": report.deduped_count,
1170                }));
1171            }
1172        }
1173    }
1174    Ok(())
1175}
1176
1177pub fn inject_hook_effects_into_current_session(effects: Vec<HookEffect>) -> Result<(), VmError> {
1178    inject_hook_effects("", effects, None)
1179}
1180
1181fn wrap_pre_tool_effects(effects: Vec<HookEffect>, mut action: PreToolAction) -> PreToolAction {
1182    for effect in effects.into_iter().rev() {
1183        match effect {
1184            HookEffect::Reminder(spec) => {
1185                action = PreToolAction::Reminder {
1186                    spec,
1187                    then: Box::new(action),
1188                };
1189            }
1190        }
1191    }
1192    action
1193}
1194
1195fn wrap_post_tool_effects(effects: Vec<HookEffect>, mut action: PostToolAction) -> PostToolAction {
1196    for effect in effects.into_iter().rev() {
1197        match effect {
1198            HookEffect::Reminder(spec) => {
1199                action = PostToolAction::Reminder {
1200                    spec,
1201                    then: Box::new(action),
1202                };
1203            }
1204        }
1205    }
1206    action
1207}
1208
1209fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
1210    let (value, effects) =
1211        collect_hook_effects_and_action(HookEvent::PreToolUse, value, VmValue::Nil)?;
1212    match value {
1213        VmValue::Nil => Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow)),
1214        VmValue::Dict(map) => {
1215            if let Some(reason) = map.get("deny") {
1216                return Ok(wrap_pre_tool_effects(
1217                    effects,
1218                    PreToolAction::Deny(reason.display()),
1219                ));
1220            }
1221            if let Some(args) = map.get("args") {
1222                return Ok(wrap_pre_tool_effects(
1223                    effects,
1224                    PreToolAction::Modify(crate::llm::vm_value_to_json(args)),
1225                ));
1226            }
1227            Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow))
1228        }
1229        other => Err(VmError::Runtime(format!(
1230            "PreToolUse hook must return nil or {{deny, args}}, got {}",
1231            other.type_name()
1232        ))),
1233    }
1234}
1235
1236pub fn apply_pre_tool_action(
1237    action: PreToolAction,
1238    current_args: &mut serde_json::Value,
1239) -> Result<Option<String>, VmError> {
1240    match action {
1241        PreToolAction::Allow => Ok(None),
1242        PreToolAction::Deny(reason) => Ok(Some(reason)),
1243        PreToolAction::Modify(new_args) => {
1244            *current_args = new_args;
1245            Ok(None)
1246        }
1247        PreToolAction::Reminder { spec, then } => {
1248            inject_hook_effects(
1249                "",
1250                vec![HookEffect::Reminder(spec)],
1251                Some(HookEvent::PreToolUse),
1252            )?;
1253            apply_pre_tool_action(*then, current_args)
1254        }
1255    }
1256}
1257
1258/// Run all matching PreToolUse hooks. Returns the final action.
1259pub async fn run_pre_tool_hooks(
1260    tool_name: &str,
1261    args: &serde_json::Value,
1262) -> Result<PreToolAction, VmError> {
1263    run_pre_tool_hooks_with_ctx(None, tool_name, args).await
1264}
1265
1266pub async fn run_pre_tool_hooks_with_ctx(
1267    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1268    tool_name: &str,
1269    args: &serde_json::Value,
1270) -> Result<PreToolAction, VmError> {
1271    let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
1272    let mut current_args = args.clone();
1273    // Singleton runtime hook (currently the stdlib path_scope_guard) runs
1274    // before user-registered hooks so a tagged deny lands in the
1275    // PostToolUse / reminder path before any other hook fires.
1276    if let Some(singleton) = singleton_pre_tool_hook() {
1277        let action = singleton(tool_name, &current_args);
1278        if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1279            return Ok(PreToolAction::Deny(reason));
1280        }
1281    }
1282    for hook in &hooks {
1283        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1284            Some(serde_json::json!({
1285                "event": HookEvent::PreToolUse.as_str(),
1286                "tool": {
1287                    "name": tool_name,
1288                    "args": current_args.clone(),
1289                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1290                },
1291                "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1292            }))
1293        } else {
1294            None
1295        };
1296        if !hook_matches(
1297            hook,
1298            Some(tool_name),
1299            payload.as_ref().unwrap_or(&serde_json::Value::Null),
1300        ) {
1301            continue;
1302        }
1303        let action = match &hook.handler {
1304            RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, &current_args),
1305            RuntimeHookHandler::Vm { .. } => {
1306                let payload = payload.as_ref().ok_or_else(|| {
1307                    VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
1308                })?;
1309                let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1310                    continue;
1311                };
1312                parse_pre_tool_result(value)?
1313            }
1314            RuntimeHookHandler::NativePostTool(_) => continue,
1315        };
1316        if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1317            return Ok(PreToolAction::Deny(reason));
1318        }
1319    }
1320    if current_args != *args {
1321        Ok(PreToolAction::Modify(current_args))
1322    } else {
1323        Ok(PreToolAction::Allow)
1324    }
1325}
1326
1327/// Run all matching PostToolUse hooks. Returns the (possibly modified) result.
1328pub async fn run_post_tool_hooks(
1329    tool_name: &str,
1330    args: &serde_json::Value,
1331    result: &str,
1332) -> Result<PostToolHookResult, VmError> {
1333    run_post_tool_hooks_with_ctx(None, tool_name, args, result).await
1334}
1335
1336pub async fn run_post_tool_hooks_with_ctx(
1337    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1338    tool_name: &str,
1339    args: &serde_json::Value,
1340    result: &str,
1341) -> Result<PostToolHookResult, VmError> {
1342    let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
1343    let mut current = PostToolHookResult::unchanged(result);
1344    for hook in &hooks {
1345        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1346            Some(serde_json::json!({
1347                "event": HookEvent::PostToolUse.as_str(),
1348                "tool": {
1349                    "name": tool_name,
1350                    "args": args,
1351                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1352                },
1353                "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1354                "result": {
1355                    "text": current.text.clone(),
1356                },
1357            }))
1358        } else {
1359            None
1360        };
1361        if !hook_matches(
1362            hook,
1363            Some(tool_name),
1364            payload.as_ref().unwrap_or(&serde_json::Value::Null),
1365        ) {
1366            continue;
1367        }
1368        let action = match &hook.handler {
1369            RuntimeHookHandler::NativePostTool(post) => post(tool_name, &current.text),
1370            RuntimeHookHandler::Vm { .. } => {
1371                let payload = payload.as_ref().ok_or_else(|| {
1372                    VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
1373                })?;
1374                let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1375                    continue;
1376                };
1377                parse_post_tool_result(value)?
1378            }
1379            RuntimeHookHandler::NativePreTool(_) => continue,
1380        };
1381        match action {
1382            PostToolAction::Pass => {}
1383            PostToolAction::Modify(new_result) => {
1384                current.text = new_result;
1385            }
1386            PostToolAction::Truncate {
1387                result,
1388                dropped_bytes,
1389            } => {
1390                current.text = result;
1391                current.dropped_bytes = current.dropped_bytes.saturating_add(dropped_bytes);
1392            }
1393            PostToolAction::Reminder { spec, then } => {
1394                inject_hook_effects(
1395                    "",
1396                    vec![HookEffect::Reminder(spec)],
1397                    Some(HookEvent::PostToolUse),
1398                )?;
1399                current = apply_post_tool_action(*then, current)?;
1400            }
1401        }
1402    }
1403    Ok(current)
1404}
1405
1406pub async fn run_lifecycle_hooks(
1407    event: HookEvent,
1408    payload: &serde_json::Value,
1409) -> Result<(), VmError> {
1410    run_lifecycle_hooks_with_ctx(None, event, payload).await
1411}
1412
1413pub async fn run_lifecycle_hooks_with_ctx(
1414    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1415    event: HookEvent,
1416    payload: &serde_json::Value,
1417) -> Result<(), VmError> {
1418    let registrations = matching_vm_lifecycle_registrations(event, payload);
1419    if registrations.is_empty() {
1420        return Ok(());
1421    }
1422    invoke_vm_lifecycle_hooks(ctx, event, registrations, payload).await
1423}
1424
1425/// Run veto-capable session-level lifecycle hooks. Successive hooks see
1426/// `Allow`; the first non-`Allow` return short-circuits and is returned
1427/// to the caller. Hook invocations and decisions are captured on the
1428/// active session's transcript under `hook_call`, `hook_returned`, and
1429/// `hook_vetoed` so a replay reproduces the same control flow.
1430///
1431/// `Modify` does not short-circuit: subsequent hooks see the rewritten
1432/// payload, and the final `HookControl::Modify` returned by the chain
1433/// carries the merged payload back to the dispatcher so the recording
1434/// layer captures the post-modify shape (replay determinism). If a
1435/// later hook in the same chain returns `Allow`, the merged
1436/// `Modify { payload }` from earlier hooks is still surfaced.
1437pub async fn run_lifecycle_hooks_with_control(
1438    event: HookEvent,
1439    payload: &serde_json::Value,
1440) -> Result<HookControl, VmError> {
1441    run_lifecycle_hooks_with_control_with_ctx(None, event, payload).await
1442}
1443
1444pub async fn run_lifecycle_hooks_with_control_with_ctx(
1445    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1446    event: HookEvent,
1447    payload: &serde_json::Value,
1448) -> Result<HookControl, VmError> {
1449    let registrations = matching_vm_lifecycle_registrations(event, payload);
1450    if registrations.is_empty() {
1451        return Ok(HookControl::Allow);
1452    }
1453    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
1454        return Err(VmError::Runtime(
1455            "session lifecycle hook requires an async builtin VM context".to_string(),
1456        ));
1457    };
1458    let session_id = payload
1459        .get("session")
1460        .and_then(|v| v.get("id"))
1461        .and_then(|v| v.as_str())
1462        .unwrap_or("")
1463        .to_string();
1464    let mut current_payload = payload.clone();
1465    let mut accumulated_modify: Option<serde_json::Value> = None;
1466    for registration in registrations {
1467        // First-party registered lifecycle hook (see `invoke_vm_lifecycle_hooks`):
1468        // the runtime chose to invoke this closure, so its body's bridge/builtin
1469        // calls are a trusted bridge call and must not trip the agent loop's
1470        // active execution policy. Held across the invocation await.
1471        let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
1472        let harness = vm.root_harness_value().ok_or_else(|| {
1473            VmError::Runtime(
1474                "lifecycle hook entrypoint requires Harness, but no root Harness is installed"
1475                    .to_string(),
1476            )
1477        })?;
1478        let arg = crate::stdlib::json_to_vm_value(&current_payload);
1479        record_hook_call(
1480            &session_id,
1481            event,
1482            &registration.handler_name,
1483            &current_payload,
1484        );
1485        let closure = resolve_lifecycle_handler(&mut vm, &registration.callable).await?;
1486        let raw = vm.call_closure_pub(&closure, &[harness, arg]).await?;
1487        if let Some(ctx) = ctx {
1488            ctx.forward_output(&vm.take_output());
1489        }
1490        let outcome = parse_hook_outcome(event, &raw)?;
1491        record_hook_returned(
1492            &session_id,
1493            event,
1494            &registration.handler_name,
1495            &outcome.control,
1496            &raw,
1497        );
1498        inject_hook_effects(session_id.as_str(), outcome.effects, Some(event))?;
1499        match outcome.control {
1500            HookControl::Allow => continue,
1501            HookControl::Modify { payload: modified } => {
1502                current_payload = modified.clone();
1503                accumulated_modify = Some(modified);
1504            }
1505            other @ (HookControl::Block { .. } | HookControl::Decision { .. }) => {
1506                record_hook_vetoed(&session_id, event, &registration.handler_name, &other);
1507                return Ok(other);
1508            }
1509        }
1510    }
1511    if let Some(payload) = accumulated_modify {
1512        Ok(HookControl::Modify { payload })
1513    } else {
1514        Ok(HookControl::Allow)
1515    }
1516}
1517
1518fn parse_hook_outcome(event: HookEvent, value: &VmValue) -> Result<HookOutcome, VmError> {
1519    let effects = parse_hook_effects(event, value)?;
1520    let action_value = if matches!(value, VmValue::List(_)) {
1521        VmValue::Nil
1522    } else {
1523        action_value_after_effects(value.clone(), VmValue::Nil)
1524    };
1525    let control = parse_hook_control(event, &action_value)?;
1526    Ok(HookOutcome { control, effects })
1527}
1528
1529/// Public alias for the internal `parse_hook_control`. Used by the
1530/// pipeline-finish dispatcher (`fire_finish_lifecycle_event`) to
1531/// translate the action half of a hook return value into a control
1532/// signal so it can honor the lifecycle table (PreFinish rejects
1533/// Block, OnUnsettledDetected respects Block, etc.).
1534pub fn parse_hook_control_for_finish(
1535    event: HookEvent,
1536    value: &VmValue,
1537) -> Result<HookControl, VmError> {
1538    parse_hook_control(event, value)
1539}
1540
1541fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
1542    match value {
1543        VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
1544        VmValue::Bool(false) => Ok(HookControl::Block {
1545            reason: format!("{} hook returned false", event.as_str()),
1546        }),
1547        VmValue::Dict(map) => {
1548            if let Some(decision) = map.get("decision") {
1549                let kind = decision.display();
1550                let kind_norm = kind.trim().to_ascii_lowercase();
1551                if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
1552                    return Err(VmError::Runtime(format!(
1553                        "{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
1554                        event.as_str()
1555                    )));
1556                }
1557                let reason = map.get("reason").and_then(|v| match v {
1558                    VmValue::Nil => None,
1559                    other => Some(other.display()),
1560                });
1561                return Ok(HookControl::Decision {
1562                    kind: kind_norm,
1563                    reason,
1564                });
1565            }
1566            let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
1567            if block {
1568                let reason = map
1569                    .get("reason")
1570                    .map(|v| v.display())
1571                    .unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
1572                return Ok(HookControl::Block { reason });
1573            }
1574            if let Some(modify) = map.get("modify") {
1575                return Ok(HookControl::Modify {
1576                    payload: crate::llm::vm_value_to_json(modify),
1577                });
1578            }
1579            Ok(HookControl::Allow)
1580        }
1581        other => Err(VmError::Runtime(format!(
1582            "{} hook must return nil, bool, or a control dict; got {}",
1583            event.as_str(),
1584            other.type_name()
1585        ))),
1586    }
1587}
1588
1589fn vm_value_truthy(value: &VmValue) -> bool {
1590    match value {
1591        VmValue::Nil => false,
1592        VmValue::Bool(value) => *value,
1593        VmValue::Int(value) => *value != 0,
1594        VmValue::Float(value) => *value != 0.0,
1595        VmValue::String(value) => !value.is_empty(),
1596        VmValue::List(value) => !value.is_empty(),
1597        VmValue::Dict(value) => !value.is_empty(),
1598        _ => true,
1599    }
1600}
1601
1602fn record_hook_call(
1603    session_id: &str,
1604    event: HookEvent,
1605    handler: &str,
1606    payload: &serde_json::Value,
1607) {
1608    if session_id.is_empty() {
1609        return;
1610    }
1611    let metadata = serde_json::json!({
1612        "event": event.as_str(),
1613        "handler": handler,
1614        "payload": payload,
1615    });
1616    let entry = crate::llm::helpers::transcript_event(
1617        "hook_call",
1618        "system",
1619        "internal",
1620        &format!("hook {} invoked: {}", event.as_str(), handler),
1621        Some(metadata),
1622    );
1623    let _ = crate::agent_sessions::append_event(session_id, entry);
1624}
1625
1626fn record_hook_returned(
1627    session_id: &str,
1628    event: HookEvent,
1629    handler: &str,
1630    control: &HookControl,
1631    raw: &VmValue,
1632) {
1633    if session_id.is_empty() {
1634        return;
1635    }
1636    let metadata = serde_json::json!({
1637        "event": event.as_str(),
1638        "handler": handler,
1639        "result": control.as_str(),
1640        "raw": crate::llm::vm_value_to_json(raw),
1641    });
1642    let entry = crate::llm::helpers::transcript_event(
1643        "hook_returned",
1644        "system",
1645        "internal",
1646        &format!(
1647            "hook {} returned {} from {}",
1648            event.as_str(),
1649            control.as_str(),
1650            handler
1651        ),
1652        Some(metadata),
1653    );
1654    let _ = crate::agent_sessions::append_event(session_id, entry);
1655}
1656
1657fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
1658    if session_id.is_empty() {
1659        return;
1660    }
1661    let (reason, decision) = match control {
1662        HookControl::Allow => return,
1663        HookControl::Block { reason } => (reason.clone(), None),
1664        HookControl::Decision { kind, reason } => (
1665            reason.clone().unwrap_or_else(|| format!("decision={kind}")),
1666            Some(kind.clone()),
1667        ),
1668        HookControl::Modify { .. } => return,
1669    };
1670    let metadata = serde_json::json!({
1671        "event": event.as_str(),
1672        "handler": handler,
1673        "reason": reason,
1674        "decision": decision,
1675    });
1676    let entry = crate::llm::helpers::transcript_event(
1677        "hook_vetoed",
1678        "system",
1679        "internal",
1680        &format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
1681        Some(metadata),
1682    );
1683    let _ = crate::agent_sessions::append_event(session_id, entry);
1684}
1685
1686pub fn matching_vm_lifecycle_hooks(
1687    event: HookEvent,
1688    payload: &serde_json::Value,
1689) -> Vec<VmLifecycleHookInvocation> {
1690    matching_vm_lifecycle_registrations(event, payload)
1691        .into_iter()
1692        .map(|registration| VmLifecycleHookInvocation {
1693            callable: registration.callable,
1694            handler_name: registration.handler_name,
1695        })
1696        .collect()
1697}
1698
1699fn matching_vm_lifecycle_registrations(
1700    event: HookEvent,
1701    payload: &serde_json::Value,
1702) -> Vec<VmLifecycleHookRegistration> {
1703    RUNTIME_HOOKS.with(|hooks| {
1704        hooks
1705            .borrow()
1706            .iter()
1707            .filter(|hook| hook.event == event)
1708            .filter(|hook| hook_matches(hook, None, payload))
1709            .filter_map(|hook| match &hook.handler {
1710                RuntimeHookHandler::Vm {
1711                    callable,
1712                    handler_name,
1713                } => Some(VmLifecycleHookRegistration {
1714                    handler_name: handler_name.clone(),
1715                    callable: callable.clone(),
1716                }),
1717                RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
1718                    None
1719                }
1720            })
1721            .collect()
1722    })
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727    use super::*;
1728
1729    fn vm_string(value: &str) -> VmValue {
1730        VmValue::String(arcstr::ArcStr::from(value))
1731    }
1732
1733    fn dict(entries: Vec<(&str, VmValue)>) -> VmValue {
1734        VmValue::dict(
1735            entries
1736                .into_iter()
1737                .map(|(key, value)| (crate::value::intern_key(key), value))
1738                .collect::<crate::value::DictMap>(),
1739        )
1740    }
1741
1742    fn error_message(result: Result<Vec<HookEffect>, VmError>) -> String {
1743        match result.expect_err("expected hook reminder parse error") {
1744            VmError::Runtime(message) => message,
1745            other => panic!("expected runtime error, got {other:?}"),
1746        }
1747    }
1748
1749    #[test]
1750    fn unknown_reminder_option_reports_code() {
1751        let value = dict(vec![(
1752            "reminder",
1753            dict(vec![
1754                ("body", vm_string("remember this")),
1755                ("typo_key", VmValue::Bool(true)),
1756            ]),
1757        )]);
1758        let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1759        assert!(message.contains(Code::ReminderUnknownOption.as_str()));
1760        assert!(message.contains("typo_key"), "{message}");
1761    }
1762
1763    #[test]
1764    fn unknown_reminder_propagate_reports_specific_code() {
1765        let value = dict(vec![(
1766            "reminder",
1767            dict(vec![
1768                ("body", vm_string("remember this")),
1769                ("propagate", vm_string("workspace")),
1770            ]),
1771        )]);
1772        let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1773        assert!(message.contains(Code::ReminderUnknownPropagate.as_str()));
1774        assert!(message.contains("propagate"), "{message}");
1775    }
1776
1777    #[test]
1778    fn worker_events_reject_reminder_effects_with_specific_code() {
1779        let value = dict(vec![(
1780            "reminder",
1781            dict(vec![("body", vm_string("worker lifecycle"))]),
1782        )]);
1783        let message = error_message(parse_hook_effects(HookEvent::WorkerSpawned, &value));
1784        assert!(message.contains(Code::ReminderUnsupportedHookEvent.as_str()));
1785        assert!(message.contains("WorkerSpawned"), "{message}");
1786    }
1787
1788    #[test]
1789    fn post_tool_result_parses_explicit_truncation_metadata() {
1790        let action = parse_post_tool_result(dict(vec![
1791            ("result", vm_string("bounded")),
1792            ("truncated", VmValue::Bool(true)),
1793            ("dropped_bytes", VmValue::Int(17)),
1794        ]))
1795        .expect("typed post-tool truncation");
1796
1797        match action {
1798            PostToolAction::Truncate {
1799                result,
1800                dropped_bytes,
1801            } => {
1802                assert_eq!(result, "bounded");
1803                assert_eq!(dropped_bytes, 17);
1804            }
1805            other => panic!("expected typed truncation, got {other:?}"),
1806        }
1807    }
1808
1809    #[test]
1810    fn post_tool_result_rejects_unquantified_truncation() {
1811        let error = parse_post_tool_result(dict(vec![
1812            ("result", vm_string("bounded")),
1813            ("truncated", VmValue::Bool(true)),
1814        ]))
1815        .expect_err("truncation without dropped bytes must fail");
1816        assert!(error.to_string().contains("dropped_bytes"));
1817    }
1818
1819    #[test]
1820    fn as_str_round_trips_through_serde() {
1821        // The macro relies on serde's default unit-variant encoding
1822        // (identifier = wire name) instead of a per-variant
1823        // `#[serde(rename)]`. Lock that contract so a future variant
1824        // can't drift by accident.
1825        for &event in HookEvent::ALL {
1826            let json = serde_json::to_string(&event).unwrap();
1827            assert_eq!(json, format!("\"{}\"", event.as_str()));
1828            let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1829            assert_eq!(parsed, event);
1830        }
1831    }
1832
1833    #[test]
1834    fn parse_session_event_accepts_both_spellings_for_every_session_variant() {
1835        // The macro auto-derives snake_case from the PascalCase
1836        // identifier; this test guards against a future variant whose
1837        // name doesn't round-trip cleanly (e.g. unexpected punctuation).
1838        for &event in HookEvent::ALL.iter().filter(|e| e.is_session_lifecycle()) {
1839            let pascal = event.as_str();
1840            let mut snake = String::new();
1841            pascal_to_snake_buf(pascal, &mut snake);
1842            assert_eq!(
1843                HookEvent::parse_session_event(pascal).unwrap(),
1844                event,
1845                "PascalCase `{pascal}`",
1846            );
1847            assert_eq!(
1848                HookEvent::parse_session_event(&snake).unwrap(),
1849                event,
1850                "snake_case `{snake}`",
1851            );
1852        }
1853    }
1854
1855    #[test]
1856    fn parse_session_event_rejects_non_session_variants() {
1857        // Tool, agent-turn, worker, step, and notification events must
1858        // not be accepted by the session parser — each surface owns
1859        // its own event set.
1860        for &event in HookEvent::ALL.iter().filter(|e| !e.is_session_lifecycle()) {
1861            let err = HookEvent::parse_session_event(event.as_str())
1862                .expect_err("non-session event slipped through");
1863            assert!(err.contains("unknown session hook event"), "{err}");
1864        }
1865    }
1866
1867    #[test]
1868    fn parse_provider_event_accepts_worker_and_session_and_flagged_variants() {
1869        // Worker variants are accepted by kind, session variants by
1870        // the fallback, and explicitly-flagged variants
1871        // (`provider_parse: true`) by the first-pass loop. The whole
1872        // set should round-trip.
1873        for &event in HookEvent::ALL.iter().filter(|e| {
1874            matches!(e.kind(), HookEventKind::Worker | HookEventKind::Session)
1875                || e.in_provider_parse()
1876        }) {
1877            assert_eq!(
1878                HookEvent::parse_provider_event(event.as_str()).unwrap(),
1879                event,
1880                "{event:?}",
1881            );
1882        }
1883    }
1884
1885    #[test]
1886    fn session_error_accepts_legacy_short_alias() {
1887        // `SessionError` carries an explicit `"error"` alias for
1888        // backward compat with the original event name.
1889        assert_eq!(
1890            HookEvent::parse_session_event("error").unwrap(),
1891            HookEvent::SessionError,
1892        );
1893        assert_eq!(
1894            HookEvent::parse_session_event("SessionError").unwrap(),
1895            HookEvent::SessionError,
1896        );
1897        assert_eq!(
1898            HookEvent::parse_session_event("session_error").unwrap(),
1899            HookEvent::SessionError,
1900        );
1901    }
1902
1903    #[test]
1904    fn supports_reminder_effects_excludes_only_worker_kind() {
1905        for &event in HookEvent::ALL {
1906            let supports = event.supports_reminder_effects();
1907            let expected = !matches!(event.kind(), HookEventKind::Worker);
1908            assert_eq!(
1909                supports,
1910                expected,
1911                "{event:?} ({:?}) reminder support disagrees with kind",
1912                event.kind(),
1913            );
1914        }
1915    }
1916
1917    #[test]
1918    fn from_worker_event_covers_every_worker_variant() {
1919        for worker in WorkerEvent::ALL {
1920            let event = HookEvent::from_worker_event(worker);
1921            assert!(
1922                matches!(event.kind(), HookEventKind::Worker),
1923                "WorkerEvent::{worker:?} mapped to non-Worker kind {:?}",
1924                event.kind(),
1925            );
1926            assert_eq!(event.as_str(), worker.as_str());
1927        }
1928    }
1929}