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