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