1use std::cell::RefCell;
4use std::future::Future;
5use std::sync::Arc;
6
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10use harn_parser::diagnostic_codes::Code;
11
12use crate::agent_events::WorkerEvent;
13use crate::llm::helpers::{ReminderPropagate, ReminderRoleHint, ReminderSource, SystemReminder};
14use crate::value::{VmClosure, VmError, VmValue};
15
16tokio::task_local! {
17 static HOOK_REMINDER_REPORTS_TASK: Arc<parking_lot::Mutex<Vec<serde_json::Value>>>;
18}
19
20fn record_hook_reminder_report(report: serde_json::Value) {
21 let _ = HOOK_REMINDER_REPORTS_TASK.try_with(|reports| reports.lock().push(report));
22}
23
24pub async fn scope_hook_reminder_reports<F, T>(future: F) -> (T, Vec<serde_json::Value>)
25where
26 F: Future<Output = T>,
27{
28 let reports = Arc::new(parking_lot::Mutex::new(Vec::new()));
29 let output = HOOK_REMINDER_REPORTS_TASK
30 .scope(reports.clone(), future)
31 .await;
32 let reports = std::mem::take(&mut *reports.lock());
33 (output, reports)
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
41pub enum HookEventKind {
42 Tool,
44 AgentTurn,
46 Worker,
48 Step,
50 Notification,
52 Session,
55}
56
57macro_rules! hook_events {
70 (
71 $(
72 $(#[$attr:meta])*
73 $variant:ident {
74 kind: $kind:ident
75 $(, provider_parse: $provider_parse:literal)?
76 $(, aliases: [$($alias:literal),* $(,)?])?
77 $(,)?
78 }
79 ),* $(,)?
80 ) => {
81 #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
82 pub enum HookEvent {
83 $(
84 $(#[$attr])*
85 $variant,
86 )*
87 }
88
89 impl HookEvent {
90 pub const fn as_str(self) -> &'static str {
92 match self {
93 $(Self::$variant => stringify!($variant),)*
94 }
95 }
96
97 pub const fn kind(self) -> HookEventKind {
99 match self {
100 $(Self::$variant => HookEventKind::$kind,)*
101 }
102 }
103
104 pub const fn supports_reminder_effects(self) -> bool {
108 !matches!(self.kind(), HookEventKind::Worker)
109 }
110
111 pub const fn is_session_lifecycle(self) -> bool {
114 matches!(self.kind(), HookEventKind::Session)
115 }
116
117 pub const ALL: &'static [Self] = &[$(Self::$variant,)*];
120
121 const fn in_provider_parse(self) -> bool {
126 match self {
127 $(Self::$variant => hook_events!(@or_false $($provider_parse)?),)*
128 }
129 }
130
131 const fn extra_aliases(self) -> &'static [&'static str] {
133 match self {
134 $(Self::$variant => &[$($($alias),*)?],)*
135 }
136 }
137
138 pub fn parse_session_event(name: &str) -> Result<Self, String> {
145 let trimmed = name.trim();
146 for &event in Self::ALL.iter().filter(|e| e.is_session_lifecycle()) {
147 if event_matches_name(event, trimmed) {
148 return Ok(event);
149 }
150 }
151 Err(format!("unknown session hook event `{trimmed}`"))
152 }
153
154 pub fn parse_provider_event(name: &str) -> Result<Self, String> {
158 let trimmed = name.trim();
159 for &event in Self::ALL.iter().filter(|e| {
160 matches!(e.kind(), HookEventKind::Worker) || e.in_provider_parse()
161 }) {
162 if event_matches_name(event, trimmed) {
163 return Ok(event);
164 }
165 }
166 Self::parse_session_event(trimmed)
167 .map_err(|_| format!("unknown reminder provider event `{trimmed}`"))
168 }
169 }
170 };
171 (@or_false $val:literal) => { $val };
172 (@or_false) => { false };
173}
174
175hook_events! {
176 PreToolUse { kind: Tool },
177 PostToolUse { kind: Tool, provider_parse: true },
178 PreAgentTurn { kind: AgentTurn },
179 PostAgentTurn { kind: AgentTurn, provider_parse: true },
180 WorkerSpawned { kind: Worker },
181 WorkerProgressed { kind: Worker },
182 WorkerWaitingForInput { kind: Worker },
183 WorkerSuspended { kind: Worker },
184 WorkerResumed { kind: Worker },
185 WorkerCompleted { kind: Worker },
186 WorkerFailed { kind: Worker },
187 WorkerStopped { kind: Worker },
188 WorkerCancelled { kind: Worker },
189 PreStep { kind: Step },
190 PostStep { kind: Step, provider_parse: true },
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 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::WorkerStopped => Self::WorkerStopped,
235 WorkerEvent::WorkerCancelled => Self::WorkerCancelled,
236 }
237 }
238}
239
240fn pascal_to_snake_buf(pascal: &str, buf: &mut String) {
241 buf.clear();
242 buf.reserve(pascal.len() + 4);
243 for (i, c) in pascal.char_indices() {
244 if c.is_ascii_uppercase() {
245 if i > 0 {
246 buf.push('_');
247 }
248 buf.push(c.to_ascii_lowercase());
249 } else {
250 buf.push(c);
251 }
252 }
253}
254
255fn event_matches_name(event: HookEvent, candidate: &str) -> bool {
256 let pascal = event.as_str();
257 if candidate == pascal {
258 return true;
259 }
260 if event.extra_aliases().contains(&candidate) {
261 return true;
262 }
263 let mut snake = String::new();
264 pascal_to_snake_buf(pascal, &mut snake);
265 candidate == snake
266}
267
268#[derive(Clone, Debug)]
283pub enum HookControl {
284 Allow,
285 Block {
286 reason: String,
287 },
288 Decision {
289 kind: String,
290 reason: Option<String>,
291 },
292 Modify {
293 payload: serde_json::Value,
294 },
295}
296
297impl HookControl {
298 pub fn as_str(&self) -> &'static str {
299 match self {
300 Self::Allow => "allow",
301 Self::Block { .. } => "block",
302 Self::Modify { .. } => "modify",
303 Self::Decision { kind, .. } => match kind.as_str() {
304 "allow" => "decision_allow",
305 "deny" => "decision_deny",
306 "ask" => "decision_ask",
307 _ => "decision_unknown",
308 },
309 }
310 }
311}
312
313pub type ReminderSpec = SystemReminder;
314
315#[derive(Clone, Debug)]
319pub enum HookEffect {
320 Reminder(ReminderSpec),
321}
322
323#[derive(Clone, Debug)]
324struct HookOutcome {
325 control: HookControl,
326 effects: Vec<HookEffect>,
327}
328
329#[derive(Clone, Debug)]
331pub enum PreToolAction {
332 Allow,
334 Deny(String),
336 Modify(serde_json::Value),
338 Reminder {
340 spec: ReminderSpec,
341 then: Box<PreToolAction>,
342 },
343}
344
345#[derive(Clone, Debug)]
347pub enum PostToolAction {
348 Pass,
350 Modify(String),
352 Reminder {
354 spec: ReminderSpec,
355 then: Box<PostToolAction>,
356 },
357}
358
359pub type PreToolHookFn = Arc<dyn Fn(&str, &serde_json::Value) -> PreToolAction + Send + Sync>;
361pub type PostToolHookFn = Arc<dyn Fn(&str, &str) -> PostToolAction + Send + Sync>;
362
363#[derive(Clone)]
365pub struct ToolHook {
366 pub pattern: String,
368 pub pre: Option<PreToolHookFn>,
370 pub post: Option<PostToolHookFn>,
372}
373
374impl std::fmt::Debug for ToolHook {
375 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376 f.debug_struct("ToolHook")
377 .field("pattern", &self.pattern)
378 .field("has_pre", &self.pre.is_some())
379 .field("has_post", &self.post.is_some())
380 .finish()
381 }
382}
383
384#[derive(Clone)]
385enum PatternMatcher {
386 ToolNameGlob(String),
387 EventExpression {
388 source: String,
389 expression: EventPatternExpression,
390 },
391}
392
393#[derive(Clone)]
394enum EventPatternExpression {
395 MatchAll,
396 NeverMatch,
397 Regex { path: String, regex: Regex },
398 Equals { path: String, value: String },
399 NotEquals { path: String, value: String },
400 PathTruthy(String),
401 ToolNameGlob(String),
402}
403
404impl std::fmt::Debug for PatternMatcher {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 match self {
407 Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
408 Self::EventExpression { source, expression } => f
409 .debug_struct("EventExpression")
410 .field("source", source)
411 .field("expression", expression)
412 .finish(),
413 }
414 }
415}
416
417impl std::fmt::Debug for EventPatternExpression {
418 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419 match self {
420 Self::MatchAll => f.write_str("MatchAll"),
421 Self::NeverMatch => f.write_str("NeverMatch"),
422 Self::Regex { path, regex } => f
423 .debug_struct("Regex")
424 .field("path", path)
425 .field("regex", ®ex.as_str())
426 .finish(),
427 Self::Equals { path, value } => f
428 .debug_struct("Equals")
429 .field("path", path)
430 .field("value", value)
431 .finish(),
432 Self::NotEquals { path, value } => f
433 .debug_struct("NotEquals")
434 .field("path", path)
435 .field("value", value)
436 .finish(),
437 Self::PathTruthy(path) => f.debug_tuple("PathTruthy").field(path).finish(),
438 Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
439 }
440 }
441}
442
443#[derive(Clone, Debug)]
453pub struct LazyVmHookHandler {
454 pub manifest_dir: std::path::PathBuf,
456 pub module_path: std::path::PathBuf,
458 pub function_name: String,
460}
461
462#[derive(Clone)]
463enum RuntimeHookHandler {
464 NativePreTool(PreToolHookFn),
465 NativePostTool(PostToolHookFn),
466 Vm {
467 handler_name: String,
468 closure: Arc<VmClosure>,
469 },
470 LazyVm {
473 handler_name: String,
474 lazy: LazyVmHookHandler,
475 },
476}
477
478impl std::fmt::Debug for RuntimeHookHandler {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 match self {
481 Self::NativePreTool(_) => f.write_str("NativePreTool(..)"),
482 Self::NativePostTool(_) => f.write_str("NativePostTool(..)"),
483 Self::Vm { handler_name, .. } => f
484 .debug_struct("Vm")
485 .field("handler_name", handler_name)
486 .finish(),
487 Self::LazyVm { handler_name, .. } => f
488 .debug_struct("LazyVm")
489 .field("handler_name", handler_name)
490 .finish(),
491 }
492 }
493}
494
495#[derive(Clone, Debug)]
496struct RuntimeHook {
497 event: HookEvent,
498 matcher: PatternMatcher,
499 handler: RuntimeHookHandler,
500}
501
502#[derive(Clone, Debug)]
503pub struct VmLifecycleHookInvocation {
504 closure: Option<Arc<VmClosure>>,
507 lazy: Option<LazyVmHookHandler>,
508 pub handler_name: String,
509}
510
511impl VmLifecycleHookInvocation {
512 pub async fn resolve(&self, vm: &mut crate::vm::Vm) -> Result<Arc<VmClosure>, VmError> {
516 match (&self.closure, &self.lazy) {
517 (Some(closure), _) => Ok(Arc::clone(closure)),
518 (None, Some(lazy)) => resolve_lazy_hook_closure(vm, lazy).await,
519 (None, None) => Err(VmError::Runtime(format!(
520 "lifecycle hook '{}' has no handler",
521 self.handler_name
522 ))),
523 }
524 }
525}
526
527#[derive(Clone, Debug)]
528enum VmLifecycleHandlerRef {
529 Eager(Arc<VmClosure>),
530 Lazy(LazyVmHookHandler),
531}
532
533#[derive(Clone, Debug)]
534struct VmLifecycleHookRegistration {
535 handler_name: String,
536 handler: VmLifecycleHandlerRef,
537}
538
539thread_local! {
540 static RUNTIME_HOOKS: RefCell<Vec<RuntimeHook>> = const { RefCell::new(Vec::new()) };
541 static FILE_EDIT_QUEUE: RefCell<Vec<FileEditedNotification>> = const { RefCell::new(Vec::new()) };
546 static SINGLETON_PRE_TOOL_HOOK: RefCell<Option<PreToolHookFn>> = const { RefCell::new(None) };
551}
552
553pub fn set_singleton_pre_tool_hook(hook: Option<PreToolHookFn>) {
557 SINGLETON_PRE_TOOL_HOOK.with(|slot| *slot.borrow_mut() = hook);
558}
559
560pub fn singleton_pre_tool_hook() -> Option<PreToolHookFn> {
561 SINGLETON_PRE_TOOL_HOOK.with(|slot| slot.borrow().clone())
562}
563
564#[derive(Clone, Debug)]
565pub struct FileEditedNotification {
566 pub path: String,
567 pub metadata: serde_json::Value,
568}
569
570pub fn queue_file_edited(path: &str, metadata: serde_json::Value) {
572 FILE_EDIT_QUEUE.with(|queue| {
573 queue.borrow_mut().push(FileEditedNotification {
574 path: path.to_string(),
575 metadata,
576 });
577 });
578}
579
580pub fn drain_file_edits() -> Vec<FileEditedNotification> {
584 FILE_EDIT_QUEUE.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
585}
586
587pub fn clear_file_edit_queue() {
588 FILE_EDIT_QUEUE.with(|queue| queue.borrow_mut().clear());
589}
590
591pub(crate) use harn_glob::match_name as glob_match;
595
596pub fn register_tool_hook(hook: ToolHook) {
597 if let Some(pre) = hook.pre {
598 RUNTIME_HOOKS.with(|hooks| {
599 hooks.borrow_mut().push(RuntimeHook {
600 event: HookEvent::PreToolUse,
601 matcher: PatternMatcher::ToolNameGlob(hook.pattern.clone()),
602 handler: RuntimeHookHandler::NativePreTool(pre),
603 });
604 });
605 }
606 if let Some(post) = hook.post {
607 RUNTIME_HOOKS.with(|hooks| {
608 hooks.borrow_mut().push(RuntimeHook {
609 event: HookEvent::PostToolUse,
610 matcher: PatternMatcher::ToolNameGlob(hook.pattern),
611 handler: RuntimeHookHandler::NativePostTool(post),
612 });
613 });
614 }
615}
616
617pub fn register_vm_hook(
618 event: HookEvent,
619 pattern: impl Into<String>,
620 handler_name: impl Into<String>,
621 closure: Arc<VmClosure>,
622) {
623 let closure = closure.retained_for_host_registry();
632 RUNTIME_HOOKS.with(|hooks| {
633 hooks.borrow_mut().push(RuntimeHook {
634 event,
635 matcher: compile_event_pattern(pattern.into()),
636 handler: RuntimeHookHandler::Vm {
637 handler_name: handler_name.into(),
638 closure,
639 },
640 });
641 });
642}
643
644pub fn register_vm_hook_lazy(
647 event: HookEvent,
648 pattern: impl Into<String>,
649 handler_name: impl Into<String>,
650 lazy: LazyVmHookHandler,
651) {
652 RUNTIME_HOOKS.with(|hooks| {
653 hooks.borrow_mut().push(RuntimeHook {
654 event,
655 matcher: compile_event_pattern(pattern.into()),
656 handler: RuntimeHookHandler::LazyVm {
657 handler_name: handler_name.into(),
658 lazy,
659 },
660 });
661 });
662}
663
664async fn resolve_lazy_hook_closure(
670 vm: &mut crate::vm::Vm,
671 lazy: &LazyVmHookHandler,
672) -> Result<Arc<VmClosure>, VmError> {
673 let exports = vm
674 .load_module_exports(&lazy.module_path)
675 .await
676 .map_err(|error| {
677 VmError::Runtime(format!(
678 "failed to load manifest hook module '{}': {error}",
679 lazy.module_path.display()
680 ))
681 })?;
682 exports.get(&lazy.function_name).cloned().ok_or_else(|| {
683 VmError::Runtime(format!(
684 "manifest hook handler '{}' is not exported by module '{}'",
685 lazy.function_name,
686 lazy.module_path.display()
687 ))
688 })
689}
690
691async fn resolve_lifecycle_handler(
692 vm: &mut crate::vm::Vm,
693 handler: &VmLifecycleHandlerRef,
694) -> Result<Arc<VmClosure>, VmError> {
695 match handler {
696 VmLifecycleHandlerRef::Eager(closure) => Ok(Arc::clone(closure)),
697 VmLifecycleHandlerRef::Lazy(lazy) => resolve_lazy_hook_closure(vm, lazy).await,
698 }
699}
700
701pub fn clear_tool_hooks() {
702 RUNTIME_HOOKS.with(|hooks| {
703 hooks
704 .borrow_mut()
705 .retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
706 });
707 set_singleton_pre_tool_hook(None);
708}
709
710pub fn clear_runtime_hooks() {
711 RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
712 set_singleton_pre_tool_hook(None);
713 super::clear_command_policies();
714}
715
716pub fn clear_session_hooks() {
721 RUNTIME_HOOKS.with(|hooks| {
722 hooks
723 .borrow_mut()
724 .retain(|hook| !hook.event.is_session_lifecycle());
725 });
726}
727
728fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
729 let mut current = value;
730 for segment in path.split('.') {
731 let serde_json::Value::Object(map) = current else {
732 return None;
733 };
734 current = map.get(segment)?;
735 }
736 Some(current)
737}
738
739fn value_truthy(value: &serde_json::Value) -> bool {
740 match value {
741 serde_json::Value::Null => false,
742 serde_json::Value::Bool(value) => *value,
743 serde_json::Value::Number(value) => value
744 .as_i64()
745 .map(|number| number != 0)
746 .or_else(|| value.as_u64().map(|number| number != 0))
747 .or_else(|| value.as_f64().map(|number| number != 0.0))
748 .unwrap_or(false),
749 serde_json::Value::String(value) => !value.is_empty(),
750 serde_json::Value::Array(values) => !values.is_empty(),
751 serde_json::Value::Object(values) => !values.is_empty(),
752 }
753}
754
755fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
756 match value {
757 Some(serde_json::Value::String(text)) => text.clone(),
758 Some(other) => other.to_string(),
759 None => String::new(),
760 }
761}
762
763fn strip_quoted(value: &str) -> &str {
764 value
765 .trim()
766 .strip_prefix('"')
767 .and_then(|text| text.strip_suffix('"'))
768 .or_else(|| {
769 value
770 .trim()
771 .strip_prefix('\'')
772 .and_then(|text| text.strip_suffix('\''))
773 })
774 .unwrap_or(value.trim())
775}
776
777fn compile_event_pattern(pattern: String) -> PatternMatcher {
778 let trimmed = pattern.trim();
779 let expression = if trimmed.is_empty() || trimmed == "*" {
780 EventPatternExpression::MatchAll
781 } else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
782 match Regex::new(strip_quoted(rhs)) {
783 Ok(regex) => EventPatternExpression::Regex {
784 path: lhs.trim().to_string(),
785 regex,
786 },
787 Err(_) => EventPatternExpression::NeverMatch,
788 }
789 } else if let Some((lhs, rhs)) = trimmed.split_once("==") {
790 EventPatternExpression::Equals {
791 path: lhs.trim().to_string(),
792 value: strip_quoted(rhs).to_string(),
793 }
794 } else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
795 EventPatternExpression::NotEquals {
796 path: lhs.trim().to_string(),
797 value: strip_quoted(rhs).to_string(),
798 }
799 } else if trimmed.contains('.') {
800 EventPatternExpression::PathTruthy(trimmed.to_string())
801 } else {
802 EventPatternExpression::ToolNameGlob(trimmed.to_string())
803 };
804 PatternMatcher::EventExpression {
805 source: pattern,
806 expression,
807 }
808}
809
810fn expression_matches(
811 source: &str,
812 expression: &EventPatternExpression,
813 payload: &serde_json::Value,
814) -> bool {
815 let pattern = source.trim();
816 if pattern.is_empty() || pattern == "*" {
817 return true;
818 }
819 if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
820 if glob_match(pattern, target) {
821 return true;
822 }
823 }
824 match expression {
825 EventPatternExpression::MatchAll => true,
826 EventPatternExpression::NeverMatch => false,
827 EventPatternExpression::Regex { path, regex } => {
828 let value = value_to_pattern_string(value_at_path(payload, path));
829 regex.is_match(&value)
830 }
831 EventPatternExpression::Equals { path, value } => {
832 value_to_pattern_string(value_at_path(payload, path)) == *value
833 }
834 EventPatternExpression::NotEquals { path, value } => {
835 value_to_pattern_string(value_at_path(payload, path)) != *value
836 }
837 EventPatternExpression::PathTruthy(path) => {
838 value_at_path(payload, path).is_some_and(value_truthy)
839 }
840 EventPatternExpression::ToolNameGlob(pattern) => glob_match(
841 pattern,
842 &value_to_pattern_string(value_at_path(payload, "tool.name")),
843 ),
844 }
845}
846
847fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
848 match &hook.matcher {
849 PatternMatcher::ToolNameGlob(pattern) => {
850 tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
851 }
852 PatternMatcher::EventExpression { source, expression } => {
853 expression_matches(source, expression, payload)
854 }
855 }
856}
857
858fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
859 RUNTIME_HOOKS.with(|hooks| {
860 hooks
861 .borrow()
862 .iter()
863 .filter(|hook| hook.event == event)
864 .cloned()
865 .collect()
866 })
867}
868
869async fn invoke_vm_hook_handler(
873 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
874 handler: &RuntimeHookHandler,
875 payload: &serde_json::Value,
876) -> Result<Option<VmValue>, VmError> {
877 let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
878 return Err(VmError::Runtime(
879 "runtime hook requires an async builtin VM context".to_string(),
880 ));
881 };
882 let closure = match handler {
883 RuntimeHookHandler::Vm { closure, .. } => Arc::clone(closure),
884 RuntimeHookHandler::LazyVm { lazy, .. } => resolve_lazy_hook_closure(&mut vm, lazy).await?,
885 _ => return Ok(None),
886 };
887 let arg = crate::stdlib::json_to_vm_value(payload);
888 let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
893 let result = vm.call_closure_pub(&closure, &[arg]).await;
894 if let Some(ctx) = ctx {
895 ctx.forward_output(&vm.take_output());
896 }
897 Ok(Some(result?))
898}
899
900async fn invoke_vm_lifecycle_hooks(
901 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
902 event: HookEvent,
903 registrations: Vec<VmLifecycleHookRegistration>,
904 payload: &serde_json::Value,
905) -> Result<(), VmError> {
906 let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
907 return Err(VmError::Runtime(
908 "runtime hook requires an async builtin VM context".to_string(),
909 ));
910 };
911 let arg = crate::stdlib::json_to_vm_value(payload);
912 let session_id = payload
913 .get("session")
914 .and_then(|v| v.get("id"))
915 .and_then(|v| v.as_str())
916 .unwrap_or("")
917 .to_string();
918 for registration in registrations {
919 let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
924 record_hook_call(&session_id, event, ®istration.handler_name, payload);
925 let closure = resolve_lifecycle_handler(&mut vm, ®istration.handler).await?;
926 let raw = vm.call_closure_pub(&closure, &[arg.clone()]).await?;
927 if let Some(ctx) = ctx {
928 ctx.forward_output(&vm.take_output());
929 }
930 let effects = parse_hook_effects(event, &raw)?;
931 record_hook_returned(
932 &session_id,
933 event,
934 ®istration.handler_name,
935 &HookControl::Allow,
936 &raw,
937 );
938 inject_hook_effects(session_id.as_str(), effects, Some(event))?;
939 }
940 Ok(())
941}
942
943fn reminder_error(context: &str, message: impl Into<String>) -> VmError {
944 VmError::Runtime(format!("{context}: {}", message.into()))
945}
946
947fn reminder_code_error(context: &str, code: Code, message: impl Into<String>) -> VmError {
948 reminder_error(context, format!("{}: {}", code.as_str(), message.into()))
949}
950
951fn unsupported_reminder_event_error(event: HookEvent, context: &str) -> VmError {
952 reminder_code_error(
953 context,
954 Code::ReminderUnsupportedHookEvent,
955 format!(
956 "{} does not support reminder effects; use a session, tool, step, or persona hook",
957 event.as_str()
958 ),
959 )
960}
961
962fn required_reminder_spec_string(
963 options: &crate::value::DictMap,
964 key: &str,
965 context: &str,
966) -> Result<String, VmError> {
967 match options.get(key) {
968 Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(value.to_string()),
969 Some(VmValue::String(_)) | None | Some(VmValue::Nil) => Err(reminder_error(
970 context,
971 format!("`{key}` must be a non-empty string"),
972 )),
973 Some(other) => Err(reminder_error(
974 context,
975 format!("`{key}` must be a string, got {}", other.type_name()),
976 )),
977 }
978}
979
980fn optional_reminder_spec_string(
981 options: &crate::value::DictMap,
982 key: &str,
983 context: &str,
984) -> Result<Option<String>, VmError> {
985 match options.get(key) {
986 None | Some(VmValue::Nil) => Ok(None),
987 Some(VmValue::String(value)) => {
988 let trimmed = value.trim();
989 if trimmed.is_empty() {
990 Ok(None)
991 } else {
992 Ok(Some(trimmed.to_string()))
993 }
994 }
995 Some(other) => Err(reminder_error(
996 context,
997 format!("`{key}` must be a string or nil, got {}", other.type_name()),
998 )),
999 }
1000}
1001
1002fn optional_reminder_spec_bool(
1003 options: &crate::value::DictMap,
1004 key: &str,
1005 context: &str,
1006) -> Result<Option<bool>, VmError> {
1007 match options.get(key) {
1008 None | Some(VmValue::Nil) => Ok(None),
1009 Some(VmValue::Bool(value)) => Ok(Some(*value)),
1010 Some(other) => Err(reminder_error(
1011 context,
1012 format!("`{key}` must be a bool or nil, got {}", other.type_name()),
1013 )),
1014 }
1015}
1016
1017fn reminder_spec_tags(
1018 options: &crate::value::DictMap,
1019 context: &str,
1020) -> Result<Vec<String>, VmError> {
1021 match options.get("tags") {
1022 None | Some(VmValue::Nil) => Ok(Vec::new()),
1023 Some(VmValue::List(values)) => {
1024 let mut tags = Vec::new();
1025 for value in values.iter() {
1026 let VmValue::String(tag) = value else {
1027 return Err(reminder_error(
1028 context,
1029 format!("`tags` entries must be strings, got {}", value.type_name()),
1030 ));
1031 };
1032 let trimmed = tag.trim();
1033 if trimmed.is_empty() {
1034 return Err(reminder_error(
1035 context,
1036 "`tags` entries must be non-empty strings",
1037 ));
1038 }
1039 if !tags.iter().any(|existing| existing == trimmed) {
1040 tags.push(trimmed.to_string());
1041 }
1042 }
1043 Ok(tags)
1044 }
1045 Some(other) => Err(reminder_error(
1046 context,
1047 format!("`tags` must be a list or nil, got {}", other.type_name()),
1048 )),
1049 }
1050}
1051
1052fn optional_reminder_spec_ttl(
1053 options: &crate::value::DictMap,
1054 context: &str,
1055) -> Result<Option<i64>, VmError> {
1056 match options.get("ttl_turns") {
1057 None | Some(VmValue::Nil) => Ok(None),
1058 Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value)),
1059 Some(VmValue::Int(_)) => Err(reminder_error(context, "`ttl_turns` must be > 0")),
1060 Some(other) => Err(reminder_error(
1061 context,
1062 format!(
1063 "`ttl_turns` must be an int or nil, got {}",
1064 other.type_name()
1065 ),
1066 )),
1067 }
1068}
1069
1070fn optional_reminder_spec_propagate(
1071 options: &crate::value::DictMap,
1072 context: &str,
1073) -> Result<Option<ReminderPropagate>, VmError> {
1074 optional_reminder_spec_string(options, "propagate", context)?
1075 .map(|value| match value.as_str() {
1076 "all" => Ok(ReminderPropagate::All),
1077 "session" => Ok(ReminderPropagate::Session),
1078 "none" => Ok(ReminderPropagate::None),
1079 _ => Err(reminder_code_error(
1080 context,
1081 Code::ReminderUnknownPropagate,
1082 "`propagate` must be one of all, session, or none",
1083 )),
1084 })
1085 .transpose()
1086}
1087
1088fn optional_reminder_spec_role_hint(
1089 options: &crate::value::DictMap,
1090 context: &str,
1091) -> Result<Option<ReminderRoleHint>, VmError> {
1092 optional_reminder_spec_string(options, "role_hint", context)?
1093 .map(|value| match value.as_str() {
1094 "system" => Ok(ReminderRoleHint::System),
1095 "developer" => Ok(ReminderRoleHint::Developer),
1096 "user_block" => Ok(ReminderRoleHint::UserBlock),
1097 "ephemeral_cache" => Ok(ReminderRoleHint::EphemeralCache),
1098 _ => Err(reminder_error(
1099 context,
1100 "`role_hint` must be one of system, developer, user_block, or ephemeral_cache",
1101 )),
1102 })
1103 .transpose()
1104}
1105
1106fn parse_reminder_spec(value: &VmValue, context: &str) -> Result<ReminderSpec, VmError> {
1107 let Some(options) = value.as_dict() else {
1108 return Err(reminder_error(
1109 context,
1110 format!("reminder spec must be a dict, got {}", value.type_name()),
1111 ));
1112 };
1113 const ALLOWED: &[&str] = &[
1114 "body",
1115 "tags",
1116 "dedupe_key",
1117 "ttl_turns",
1118 "preserve_on_compact",
1119 "propagate",
1120 "role_hint",
1121 ];
1122 let unknown = options
1123 .keys()
1124 .filter(|key| !ALLOWED.contains(&key.as_str()))
1125 .map(|key| key.as_str())
1126 .collect::<Vec<_>>();
1127 if !unknown.is_empty() {
1128 return Err(reminder_code_error(
1129 context,
1130 Code::ReminderUnknownOption,
1131 format!("unknown reminder option(s): {}", unknown.join(", ")),
1132 ));
1133 }
1134 Ok(SystemReminder {
1135 id: uuid::Uuid::now_v7().to_string(),
1136 tags: reminder_spec_tags(options, context)?,
1137 dedupe_key: optional_reminder_spec_string(options, "dedupe_key", context)?,
1138 ttl_turns: optional_reminder_spec_ttl(options, context)?,
1139 preserve_on_compact: optional_reminder_spec_bool(options, "preserve_on_compact", context)?
1140 .unwrap_or(false),
1141 propagate: optional_reminder_spec_propagate(options, context)?
1142 .unwrap_or(ReminderPropagate::Session),
1143 role_hint: optional_reminder_spec_role_hint(options, context)?
1144 .unwrap_or(ReminderRoleHint::System),
1145 source: ReminderSource::Hook,
1146 body: required_reminder_spec_string(options, "body", context)?,
1147 fired_at_turn: 0,
1148 originating_agent_id: None,
1149 })
1150}
1151
1152fn looks_like_reminder_spec(map: &crate::value::DictMap) -> bool {
1153 map.contains_key("body")
1154 && !map.contains_key("deny")
1155 && !map.contains_key("args")
1156 && !map.contains_key("result")
1157 && !map.contains_key("output")
1158 && !map.contains_key("modify")
1159 && !map.contains_key("block")
1160 && !map.contains_key("decision")
1161 && !map.contains_key("action")
1162 && !map.contains_key("control")
1163}
1164
1165fn parse_hook_effect_item(event: HookEvent, value: &VmValue) -> Result<HookEffect, VmError> {
1166 let context = format!("{} hook reminder", event.as_str());
1167 if let Some(map) = value.as_dict() {
1168 if let Some(reminder) = map.get("reminder") {
1169 if !event.supports_reminder_effects() {
1170 return Err(unsupported_reminder_event_error(event, &context));
1171 }
1172 return Ok(HookEffect::Reminder(parse_reminder_spec(
1173 reminder, &context,
1174 )?));
1175 }
1176 if matches!(
1177 map.get("type")
1178 .or_else(|| map.get("kind"))
1179 .map(|value| value.display())
1180 .as_deref(),
1181 Some("reminder" | "Reminder")
1182 ) {
1183 if !event.supports_reminder_effects() {
1184 return Err(unsupported_reminder_event_error(event, &context));
1185 }
1186 let spec = map
1187 .get("spec")
1188 .or_else(|| map.get("reminder"))
1189 .ok_or_else(|| reminder_error(&context, "reminder effect missing `spec`"))?;
1190 return Ok(HookEffect::Reminder(parse_reminder_spec(spec, &context)?));
1191 }
1192 if looks_like_reminder_spec(map) {
1193 if !event.supports_reminder_effects() {
1194 return Err(unsupported_reminder_event_error(event, &context));
1195 }
1196 return Ok(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1197 }
1198 }
1199 Err(reminder_error(
1200 &context,
1201 "hook effect must be {reminder: {...}} or a reminder spec",
1202 ))
1203}
1204
1205pub fn parse_hook_effects(event: HookEvent, value: &VmValue) -> Result<Vec<HookEffect>, VmError> {
1206 let Some(map) = value.as_dict() else {
1207 if let VmValue::List(items) = value {
1208 return items
1209 .iter()
1210 .map(|item| parse_hook_effect_item(event, item))
1211 .collect();
1212 }
1213 return Ok(Vec::new());
1214 };
1215
1216 let mut effects = Vec::new();
1217 if let Some(items) = map.get("effects") {
1218 match items {
1219 VmValue::List(list) => {
1220 for item in list.iter() {
1221 effects.push(parse_hook_effect_item(event, item)?);
1222 }
1223 }
1224 other => effects.push(parse_hook_effect_item(event, other)?),
1225 }
1226 }
1227 if let Some(reminder) = map.get("reminder") {
1228 let context = format!("{} hook reminder", event.as_str());
1229 if !event.supports_reminder_effects() {
1230 return Err(unsupported_reminder_event_error(event, &context));
1231 }
1232 effects.push(HookEffect::Reminder(parse_reminder_spec(
1233 reminder, &context,
1234 )?));
1235 } else if effects.is_empty() && looks_like_reminder_spec(map) {
1236 let context = format!("{} hook reminder", event.as_str());
1237 if !event.supports_reminder_effects() {
1238 return Err(unsupported_reminder_event_error(event, &context));
1239 }
1240 effects.push(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1241 }
1242 Ok(effects)
1243}
1244
1245fn action_value_after_effects(value: VmValue, default_action: VmValue) -> VmValue {
1246 let VmValue::Dict(map) = value else {
1247 return value;
1248 };
1249 if let Some(then) = map.get("then") {
1250 return then.clone();
1251 }
1252 let has_effects = map.contains_key("effects")
1253 || map.contains_key("reminder")
1254 || looks_like_reminder_spec(map.as_ref());
1255 if !has_effects {
1256 return VmValue::Dict(map);
1257 }
1258 let mut action = map.as_ref().clone();
1259 action.remove("effects");
1260 action.remove("reminder");
1261 action.remove("then");
1262 if action.keys().any(|key| {
1263 matches!(
1264 key.as_str(),
1265 "deny" | "args" | "result" | "output" | "modify" | "block" | "decision" | "action"
1266 )
1267 }) {
1268 VmValue::dict(action)
1269 } else {
1270 default_action
1271 }
1272}
1273
1274pub fn collect_hook_effects_and_action(
1275 event: HookEvent,
1276 value: VmValue,
1277 default_action: VmValue,
1278) -> Result<(VmValue, Vec<HookEffect>), VmError> {
1279 let mut current = value;
1280 let mut effects = Vec::new();
1281 for _ in 0..32 {
1282 let current_effects = parse_hook_effects(event, ¤t)?;
1283 if current_effects.is_empty() {
1284 return Ok((current, effects));
1285 }
1286 effects.extend(current_effects);
1287 current = action_value_after_effects(current, default_action.clone());
1288 }
1289 Err(VmError::Runtime(format!(
1290 "{} hook reminder return nested too deeply",
1291 event.as_str()
1292 )))
1293}
1294
1295fn inject_hook_effects(
1296 session_id: &str,
1297 effects: Vec<HookEffect>,
1298 event: Option<HookEvent>,
1299) -> Result<(), VmError> {
1300 if effects.is_empty() {
1301 return Ok(());
1302 }
1303 let target_session = if session_id.is_empty() {
1304 crate::agent_sessions::current_session_id().unwrap_or_default()
1305 } else {
1306 session_id.to_string()
1307 };
1308 if target_session.is_empty() {
1309 return Ok(());
1310 }
1311 for effect in effects {
1312 match effect {
1313 HookEffect::Reminder(spec) => {
1314 let reminder_id = spec.id.clone();
1315 let tags = spec.tags.clone();
1316 let dedupe_key = spec.dedupe_key.clone();
1317 let role_hint = spec.role_hint.as_str();
1318 let source = spec.source.as_str();
1319 let ttl_turns = spec.ttl_turns;
1320 let report = crate::agent_sessions::inject_reminder(&target_session, spec)
1321 .map_err(VmError::Runtime)?;
1322 record_hook_reminder_report(serde_json::json!({
1323 "hook_event": event.map(|event| event.as_str()),
1324 "session_id": &target_session,
1325 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1326 "reminder_id": reminder_id,
1327 "tags": tags,
1328 "dedupe_key": dedupe_key,
1329 "role_hint": role_hint,
1330 "source": source,
1331 "ttl_turns": ttl_turns,
1332 "deduped_count": report.deduped_count,
1333 }));
1334 }
1335 }
1336 }
1337 Ok(())
1338}
1339
1340pub fn inject_hook_effects_into_current_session(effects: Vec<HookEffect>) -> Result<(), VmError> {
1341 inject_hook_effects("", effects, None)
1342}
1343
1344fn wrap_pre_tool_effects(effects: Vec<HookEffect>, mut action: PreToolAction) -> PreToolAction {
1345 for effect in effects.into_iter().rev() {
1346 match effect {
1347 HookEffect::Reminder(spec) => {
1348 action = PreToolAction::Reminder {
1349 spec,
1350 then: Box::new(action),
1351 };
1352 }
1353 }
1354 }
1355 action
1356}
1357
1358fn wrap_post_tool_effects(effects: Vec<HookEffect>, mut action: PostToolAction) -> PostToolAction {
1359 for effect in effects.into_iter().rev() {
1360 match effect {
1361 HookEffect::Reminder(spec) => {
1362 action = PostToolAction::Reminder {
1363 spec,
1364 then: Box::new(action),
1365 };
1366 }
1367 }
1368 }
1369 action
1370}
1371
1372fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
1373 let (value, effects) =
1374 collect_hook_effects_and_action(HookEvent::PreToolUse, value, VmValue::Nil)?;
1375 match value {
1376 VmValue::Nil => Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow)),
1377 VmValue::Dict(map) => {
1378 if let Some(reason) = map.get("deny") {
1379 return Ok(wrap_pre_tool_effects(
1380 effects,
1381 PreToolAction::Deny(reason.display()),
1382 ));
1383 }
1384 if let Some(args) = map.get("args") {
1385 return Ok(wrap_pre_tool_effects(
1386 effects,
1387 PreToolAction::Modify(crate::llm::vm_value_to_json(args)),
1388 ));
1389 }
1390 Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow))
1391 }
1392 other => Err(VmError::Runtime(format!(
1393 "PreToolUse hook must return nil or {{deny, args}}, got {}",
1394 other.type_name()
1395 ))),
1396 }
1397}
1398
1399fn parse_post_tool_result(value: VmValue) -> Result<PostToolAction, VmError> {
1400 let (value, effects) =
1401 collect_hook_effects_and_action(HookEvent::PostToolUse, value, VmValue::Nil)?;
1402 match value {
1403 VmValue::Nil => Ok(wrap_post_tool_effects(effects, PostToolAction::Pass)),
1404 VmValue::String(text) => Ok(wrap_post_tool_effects(
1405 effects,
1406 PostToolAction::Modify(text.to_string()),
1407 )),
1408 VmValue::Dict(map) => {
1409 if let Some(result) = map.get("result") {
1410 return Ok(wrap_post_tool_effects(
1411 effects,
1412 PostToolAction::Modify(result.display()),
1413 ));
1414 }
1415 Ok(wrap_post_tool_effects(effects, PostToolAction::Pass))
1416 }
1417 other => Err(VmError::Runtime(format!(
1418 "PostToolUse hook must return nil, string, or {{result}}, got {}",
1419 other.type_name()
1420 ))),
1421 }
1422}
1423
1424pub fn apply_pre_tool_action(
1425 action: PreToolAction,
1426 current_args: &mut serde_json::Value,
1427) -> Result<Option<String>, VmError> {
1428 match action {
1429 PreToolAction::Allow => Ok(None),
1430 PreToolAction::Deny(reason) => Ok(Some(reason)),
1431 PreToolAction::Modify(new_args) => {
1432 *current_args = new_args;
1433 Ok(None)
1434 }
1435 PreToolAction::Reminder { spec, then } => {
1436 inject_hook_effects(
1437 "",
1438 vec![HookEffect::Reminder(spec)],
1439 Some(HookEvent::PreToolUse),
1440 )?;
1441 apply_pre_tool_action(*then, current_args)
1442 }
1443 }
1444}
1445
1446fn apply_post_tool_action(action: PostToolAction, current: String) -> Result<String, VmError> {
1447 match action {
1448 PostToolAction::Pass => Ok(current),
1449 PostToolAction::Modify(new_result) => Ok(new_result),
1450 PostToolAction::Reminder { spec, then } => {
1451 inject_hook_effects(
1452 "",
1453 vec![HookEffect::Reminder(spec)],
1454 Some(HookEvent::PostToolUse),
1455 )?;
1456 apply_post_tool_action(*then, current)
1457 }
1458 }
1459}
1460
1461pub async fn run_pre_tool_hooks(
1463 tool_name: &str,
1464 args: &serde_json::Value,
1465) -> Result<PreToolAction, VmError> {
1466 run_pre_tool_hooks_with_ctx(None, tool_name, args).await
1467}
1468
1469pub async fn run_pre_tool_hooks_with_ctx(
1470 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1471 tool_name: &str,
1472 args: &serde_json::Value,
1473) -> Result<PreToolAction, VmError> {
1474 let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
1475 let mut current_args = args.clone();
1476 if let Some(singleton) = singleton_pre_tool_hook() {
1480 let action = singleton(tool_name, ¤t_args);
1481 if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1482 return Ok(PreToolAction::Deny(reason));
1483 }
1484 }
1485 for hook in &hooks {
1486 let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1487 Some(serde_json::json!({
1488 "event": HookEvent::PreToolUse.as_str(),
1489 "tool": {
1490 "name": tool_name,
1491 "args": current_args.clone(),
1492 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1493 },
1494 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1495 }))
1496 } else {
1497 None
1498 };
1499 if !hook_matches(
1500 hook,
1501 Some(tool_name),
1502 payload.as_ref().unwrap_or(&serde_json::Value::Null),
1503 ) {
1504 continue;
1505 }
1506 let action = match &hook.handler {
1507 RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, ¤t_args),
1508 RuntimeHookHandler::Vm { .. } | RuntimeHookHandler::LazyVm { .. } => {
1509 let payload = payload.as_ref().ok_or_else(|| {
1510 VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
1511 })?;
1512 let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1513 continue;
1514 };
1515 parse_pre_tool_result(value)?
1516 }
1517 RuntimeHookHandler::NativePostTool(_) => continue,
1518 };
1519 if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1520 return Ok(PreToolAction::Deny(reason));
1521 }
1522 }
1523 if current_args != *args {
1524 Ok(PreToolAction::Modify(current_args))
1525 } else {
1526 Ok(PreToolAction::Allow)
1527 }
1528}
1529
1530pub async fn run_post_tool_hooks(
1532 tool_name: &str,
1533 args: &serde_json::Value,
1534 result: &str,
1535) -> Result<String, VmError> {
1536 run_post_tool_hooks_with_ctx(None, tool_name, args, result).await
1537}
1538
1539pub async fn run_post_tool_hooks_with_ctx(
1540 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1541 tool_name: &str,
1542 args: &serde_json::Value,
1543 result: &str,
1544) -> Result<String, VmError> {
1545 let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
1546 let mut current = result.to_string();
1547 for hook in &hooks {
1548 let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1549 Some(serde_json::json!({
1550 "event": HookEvent::PostToolUse.as_str(),
1551 "tool": {
1552 "name": tool_name,
1553 "args": args,
1554 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1555 },
1556 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1557 "result": {
1558 "text": current.clone(),
1559 },
1560 }))
1561 } else {
1562 None
1563 };
1564 if !hook_matches(
1565 hook,
1566 Some(tool_name),
1567 payload.as_ref().unwrap_or(&serde_json::Value::Null),
1568 ) {
1569 continue;
1570 }
1571 let action = match &hook.handler {
1572 RuntimeHookHandler::NativePostTool(post) => post(tool_name, ¤t),
1573 RuntimeHookHandler::Vm { .. } | RuntimeHookHandler::LazyVm { .. } => {
1574 let payload = payload.as_ref().ok_or_else(|| {
1575 VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
1576 })?;
1577 let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1578 continue;
1579 };
1580 parse_post_tool_result(value)?
1581 }
1582 RuntimeHookHandler::NativePreTool(_) => continue,
1583 };
1584 match action {
1585 PostToolAction::Pass => {}
1586 PostToolAction::Modify(new_result) => {
1587 current = new_result;
1588 }
1589 PostToolAction::Reminder { spec, then } => {
1590 inject_hook_effects(
1591 "",
1592 vec![HookEffect::Reminder(spec)],
1593 Some(HookEvent::PostToolUse),
1594 )?;
1595 current = apply_post_tool_action(*then, current)?;
1596 }
1597 }
1598 }
1599 Ok(current)
1600}
1601
1602pub async fn run_lifecycle_hooks(
1603 event: HookEvent,
1604 payload: &serde_json::Value,
1605) -> Result<(), VmError> {
1606 run_lifecycle_hooks_with_ctx(None, event, payload).await
1607}
1608
1609pub async fn run_lifecycle_hooks_with_ctx(
1610 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1611 event: HookEvent,
1612 payload: &serde_json::Value,
1613) -> Result<(), VmError> {
1614 let registrations = matching_vm_lifecycle_registrations(event, payload);
1615 if registrations.is_empty() {
1616 return Ok(());
1617 }
1618 invoke_vm_lifecycle_hooks(ctx, event, registrations, payload).await
1619}
1620
1621pub async fn run_lifecycle_hooks_with_control(
1634 event: HookEvent,
1635 payload: &serde_json::Value,
1636) -> Result<HookControl, VmError> {
1637 run_lifecycle_hooks_with_control_with_ctx(None, event, payload).await
1638}
1639
1640pub async fn run_lifecycle_hooks_with_control_with_ctx(
1641 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1642 event: HookEvent,
1643 payload: &serde_json::Value,
1644) -> Result<HookControl, VmError> {
1645 let registrations = matching_vm_lifecycle_registrations(event, payload);
1646 if registrations.is_empty() {
1647 return Ok(HookControl::Allow);
1648 }
1649 let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
1650 return Err(VmError::Runtime(
1651 "session lifecycle hook requires an async builtin VM context".to_string(),
1652 ));
1653 };
1654 let session_id = payload
1655 .get("session")
1656 .and_then(|v| v.get("id"))
1657 .and_then(|v| v.as_str())
1658 .unwrap_or("")
1659 .to_string();
1660 let mut current_payload = payload.clone();
1661 let mut accumulated_modify: Option<serde_json::Value> = None;
1662 for registration in registrations {
1663 let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
1668 let arg = crate::stdlib::json_to_vm_value(¤t_payload);
1669 record_hook_call(
1670 &session_id,
1671 event,
1672 ®istration.handler_name,
1673 ¤t_payload,
1674 );
1675 let closure = resolve_lifecycle_handler(&mut vm, ®istration.handler).await?;
1676 let raw = vm.call_closure_pub(&closure, &[arg]).await?;
1677 if let Some(ctx) = ctx {
1678 ctx.forward_output(&vm.take_output());
1679 }
1680 let outcome = parse_hook_outcome(event, &raw)?;
1681 record_hook_returned(
1682 &session_id,
1683 event,
1684 ®istration.handler_name,
1685 &outcome.control,
1686 &raw,
1687 );
1688 inject_hook_effects(session_id.as_str(), outcome.effects, Some(event))?;
1689 match outcome.control {
1690 HookControl::Allow => continue,
1691 HookControl::Modify { payload: modified } => {
1692 current_payload = modified.clone();
1693 accumulated_modify = Some(modified);
1694 }
1695 other @ (HookControl::Block { .. } | HookControl::Decision { .. }) => {
1696 record_hook_vetoed(&session_id, event, ®istration.handler_name, &other);
1697 return Ok(other);
1698 }
1699 }
1700 }
1701 if let Some(payload) = accumulated_modify {
1702 Ok(HookControl::Modify { payload })
1703 } else {
1704 Ok(HookControl::Allow)
1705 }
1706}
1707
1708fn parse_hook_outcome(event: HookEvent, value: &VmValue) -> Result<HookOutcome, VmError> {
1709 let effects = parse_hook_effects(event, value)?;
1710 let action_value = if matches!(value, VmValue::List(_)) {
1711 VmValue::Nil
1712 } else {
1713 action_value_after_effects(value.clone(), VmValue::Nil)
1714 };
1715 let control = parse_hook_control(event, &action_value)?;
1716 Ok(HookOutcome { control, effects })
1717}
1718
1719pub fn parse_hook_control_for_finish(
1725 event: HookEvent,
1726 value: &VmValue,
1727) -> Result<HookControl, VmError> {
1728 parse_hook_control(event, value)
1729}
1730
1731fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
1732 match value {
1733 VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
1734 VmValue::Bool(false) => Ok(HookControl::Block {
1735 reason: format!("{} hook returned false", event.as_str()),
1736 }),
1737 VmValue::Dict(map) => {
1738 if let Some(decision) = map.get("decision") {
1739 let kind = decision.display();
1740 let kind_norm = kind.trim().to_ascii_lowercase();
1741 if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
1742 return Err(VmError::Runtime(format!(
1743 "{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
1744 event.as_str()
1745 )));
1746 }
1747 let reason = map.get("reason").and_then(|v| match v {
1748 VmValue::Nil => None,
1749 other => Some(other.display()),
1750 });
1751 return Ok(HookControl::Decision {
1752 kind: kind_norm,
1753 reason,
1754 });
1755 }
1756 let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
1757 if block {
1758 let reason = map
1759 .get("reason")
1760 .map(|v| v.display())
1761 .unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
1762 return Ok(HookControl::Block { reason });
1763 }
1764 if let Some(modify) = map.get("modify") {
1765 return Ok(HookControl::Modify {
1766 payload: crate::llm::vm_value_to_json(modify),
1767 });
1768 }
1769 Ok(HookControl::Allow)
1770 }
1771 other => Err(VmError::Runtime(format!(
1772 "{} hook must return nil, bool, or a control dict; got {}",
1773 event.as_str(),
1774 other.type_name()
1775 ))),
1776 }
1777}
1778
1779fn vm_value_truthy(value: &VmValue) -> bool {
1780 match value {
1781 VmValue::Nil => false,
1782 VmValue::Bool(value) => *value,
1783 VmValue::Int(value) => *value != 0,
1784 VmValue::Float(value) => *value != 0.0,
1785 VmValue::String(value) => !value.is_empty(),
1786 VmValue::List(value) => !value.is_empty(),
1787 VmValue::Dict(value) => !value.is_empty(),
1788 _ => true,
1789 }
1790}
1791
1792fn record_hook_call(
1793 session_id: &str,
1794 event: HookEvent,
1795 handler: &str,
1796 payload: &serde_json::Value,
1797) {
1798 if session_id.is_empty() {
1799 return;
1800 }
1801 let metadata = serde_json::json!({
1802 "event": event.as_str(),
1803 "handler": handler,
1804 "payload": payload,
1805 });
1806 let entry = crate::llm::helpers::transcript_event(
1807 "hook_call",
1808 "system",
1809 "internal",
1810 &format!("hook {} invoked: {}", event.as_str(), handler),
1811 Some(metadata),
1812 );
1813 let _ = crate::agent_sessions::append_event(session_id, entry);
1814}
1815
1816fn record_hook_returned(
1817 session_id: &str,
1818 event: HookEvent,
1819 handler: &str,
1820 control: &HookControl,
1821 raw: &VmValue,
1822) {
1823 if session_id.is_empty() {
1824 return;
1825 }
1826 let metadata = serde_json::json!({
1827 "event": event.as_str(),
1828 "handler": handler,
1829 "result": control.as_str(),
1830 "raw": crate::llm::vm_value_to_json(raw),
1831 });
1832 let entry = crate::llm::helpers::transcript_event(
1833 "hook_returned",
1834 "system",
1835 "internal",
1836 &format!(
1837 "hook {} returned {} from {}",
1838 event.as_str(),
1839 control.as_str(),
1840 handler
1841 ),
1842 Some(metadata),
1843 );
1844 let _ = crate::agent_sessions::append_event(session_id, entry);
1845}
1846
1847fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
1848 if session_id.is_empty() {
1849 return;
1850 }
1851 let (reason, decision) = match control {
1852 HookControl::Allow => return,
1853 HookControl::Block { reason } => (reason.clone(), None),
1854 HookControl::Decision { kind, reason } => (
1855 reason.clone().unwrap_or_else(|| format!("decision={kind}")),
1856 Some(kind.clone()),
1857 ),
1858 HookControl::Modify { .. } => return,
1859 };
1860 let metadata = serde_json::json!({
1861 "event": event.as_str(),
1862 "handler": handler,
1863 "reason": reason,
1864 "decision": decision,
1865 });
1866 let entry = crate::llm::helpers::transcript_event(
1867 "hook_vetoed",
1868 "system",
1869 "internal",
1870 &format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
1871 Some(metadata),
1872 );
1873 let _ = crate::agent_sessions::append_event(session_id, entry);
1874}
1875
1876pub fn matching_vm_lifecycle_hooks(
1877 event: HookEvent,
1878 payload: &serde_json::Value,
1879) -> Vec<VmLifecycleHookInvocation> {
1880 matching_vm_lifecycle_registrations(event, payload)
1881 .into_iter()
1882 .map(|registration| match registration.handler {
1883 VmLifecycleHandlerRef::Eager(closure) => VmLifecycleHookInvocation {
1884 closure: Some(closure),
1885 lazy: None,
1886 handler_name: registration.handler_name,
1887 },
1888 VmLifecycleHandlerRef::Lazy(lazy) => VmLifecycleHookInvocation {
1889 closure: None,
1890 lazy: Some(lazy),
1891 handler_name: registration.handler_name,
1892 },
1893 })
1894 .collect()
1895}
1896
1897fn matching_vm_lifecycle_registrations(
1898 event: HookEvent,
1899 payload: &serde_json::Value,
1900) -> Vec<VmLifecycleHookRegistration> {
1901 RUNTIME_HOOKS.with(|hooks| {
1902 hooks
1903 .borrow()
1904 .iter()
1905 .filter(|hook| hook.event == event)
1906 .filter(|hook| hook_matches(hook, None, payload))
1907 .filter_map(|hook| match &hook.handler {
1908 RuntimeHookHandler::Vm {
1909 closure,
1910 handler_name,
1911 } => Some(VmLifecycleHookRegistration {
1912 handler_name: handler_name.clone(),
1913 handler: VmLifecycleHandlerRef::Eager(Arc::clone(closure)),
1914 }),
1915 RuntimeHookHandler::LazyVm { lazy, handler_name } => {
1916 Some(VmLifecycleHookRegistration {
1917 handler_name: handler_name.clone(),
1918 handler: VmLifecycleHandlerRef::Lazy(lazy.clone()),
1919 })
1920 }
1921 RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
1922 None
1923 }
1924 })
1925 .collect()
1926 })
1927}
1928
1929#[cfg(test)]
1930mod tests {
1931 use super::*;
1932
1933 fn vm_string(value: &str) -> VmValue {
1934 VmValue::String(arcstr::ArcStr::from(value))
1935 }
1936
1937 fn dict(entries: Vec<(&str, VmValue)>) -> VmValue {
1938 VmValue::dict(
1939 entries
1940 .into_iter()
1941 .map(|(key, value)| (crate::value::intern_key(key), value))
1942 .collect::<crate::value::DictMap>(),
1943 )
1944 }
1945
1946 fn error_message(result: Result<Vec<HookEffect>, VmError>) -> String {
1947 match result.expect_err("expected hook reminder parse error") {
1948 VmError::Runtime(message) => message,
1949 other => panic!("expected runtime error, got {other:?}"),
1950 }
1951 }
1952
1953 #[test]
1954 fn unknown_reminder_option_reports_code() {
1955 let value = dict(vec![(
1956 "reminder",
1957 dict(vec![
1958 ("body", vm_string("remember this")),
1959 ("typo_key", VmValue::Bool(true)),
1960 ]),
1961 )]);
1962 let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1963 assert!(message.contains(Code::ReminderUnknownOption.as_str()));
1964 assert!(message.contains("typo_key"), "{message}");
1965 }
1966
1967 #[test]
1968 fn unknown_reminder_propagate_reports_specific_code() {
1969 let value = dict(vec![(
1970 "reminder",
1971 dict(vec![
1972 ("body", vm_string("remember this")),
1973 ("propagate", vm_string("workspace")),
1974 ]),
1975 )]);
1976 let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1977 assert!(message.contains(Code::ReminderUnknownPropagate.as_str()));
1978 assert!(message.contains("propagate"), "{message}");
1979 }
1980
1981 #[test]
1982 fn worker_events_reject_reminder_effects_with_specific_code() {
1983 let value = dict(vec![(
1984 "reminder",
1985 dict(vec![("body", vm_string("worker lifecycle"))]),
1986 )]);
1987 let message = error_message(parse_hook_effects(HookEvent::WorkerSpawned, &value));
1988 assert!(message.contains(Code::ReminderUnsupportedHookEvent.as_str()));
1989 assert!(message.contains("WorkerSpawned"), "{message}");
1990 }
1991
1992 #[test]
1993 fn as_str_round_trips_through_serde() {
1994 for &event in HookEvent::ALL {
1999 let json = serde_json::to_string(&event).unwrap();
2000 assert_eq!(json, format!("\"{}\"", event.as_str()));
2001 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
2002 assert_eq!(parsed, event);
2003 }
2004 }
2005
2006 #[test]
2007 fn parse_session_event_accepts_both_spellings_for_every_session_variant() {
2008 for &event in HookEvent::ALL.iter().filter(|e| e.is_session_lifecycle()) {
2012 let pascal = event.as_str();
2013 let mut snake = String::new();
2014 pascal_to_snake_buf(pascal, &mut snake);
2015 assert_eq!(
2016 HookEvent::parse_session_event(pascal).unwrap(),
2017 event,
2018 "PascalCase `{pascal}`",
2019 );
2020 assert_eq!(
2021 HookEvent::parse_session_event(&snake).unwrap(),
2022 event,
2023 "snake_case `{snake}`",
2024 );
2025 }
2026 }
2027
2028 #[test]
2029 fn parse_session_event_rejects_non_session_variants() {
2030 for &event in HookEvent::ALL.iter().filter(|e| !e.is_session_lifecycle()) {
2034 let err = HookEvent::parse_session_event(event.as_str())
2035 .expect_err("non-session event slipped through");
2036 assert!(err.contains("unknown session hook event"), "{err}");
2037 }
2038 }
2039
2040 #[test]
2041 fn parse_provider_event_accepts_worker_and_session_and_flagged_variants() {
2042 for &event in HookEvent::ALL.iter().filter(|e| {
2047 matches!(e.kind(), HookEventKind::Worker | HookEventKind::Session)
2048 || e.in_provider_parse()
2049 }) {
2050 assert_eq!(
2051 HookEvent::parse_provider_event(event.as_str()).unwrap(),
2052 event,
2053 "{event:?}",
2054 );
2055 }
2056 }
2057
2058 #[test]
2059 fn session_error_accepts_legacy_short_alias() {
2060 assert_eq!(
2063 HookEvent::parse_session_event("error").unwrap(),
2064 HookEvent::SessionError,
2065 );
2066 assert_eq!(
2067 HookEvent::parse_session_event("SessionError").unwrap(),
2068 HookEvent::SessionError,
2069 );
2070 assert_eq!(
2071 HookEvent::parse_session_event("session_error").unwrap(),
2072 HookEvent::SessionError,
2073 );
2074 }
2075
2076 #[test]
2077 fn supports_reminder_effects_excludes_only_worker_kind() {
2078 for &event in HookEvent::ALL {
2079 let supports = event.supports_reminder_effects();
2080 let expected = !matches!(event.kind(), HookEventKind::Worker);
2081 assert_eq!(
2082 supports,
2083 expected,
2084 "{event:?} ({:?}) reminder support disagrees with kind",
2085 event.kind(),
2086 );
2087 }
2088 }
2089
2090 #[test]
2091 fn from_worker_event_covers_every_worker_variant() {
2092 for worker in WorkerEvent::ALL {
2093 let event = HookEvent::from_worker_event(worker);
2094 assert!(
2095 matches!(event.kind(), HookEventKind::Worker),
2096 "WorkerEvent::{worker:?} mapped to non-Worker kind {:?}",
2097 event.kind(),
2098 );
2099 assert_eq!(event.as_str(), worker.as_str());
2100 }
2101 }
2102}