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 RUNTIME_HOOKS.with(|hooks| {
624 hooks.borrow_mut().push(RuntimeHook {
625 event,
626 matcher: compile_event_pattern(pattern.into()),
627 handler: RuntimeHookHandler::Vm {
628 handler_name: handler_name.into(),
629 closure,
630 },
631 });
632 });
633}
634
635pub fn register_vm_hook_lazy(
638 event: HookEvent,
639 pattern: impl Into<String>,
640 handler_name: impl Into<String>,
641 lazy: LazyVmHookHandler,
642) {
643 RUNTIME_HOOKS.with(|hooks| {
644 hooks.borrow_mut().push(RuntimeHook {
645 event,
646 matcher: compile_event_pattern(pattern.into()),
647 handler: RuntimeHookHandler::LazyVm {
648 handler_name: handler_name.into(),
649 lazy,
650 },
651 });
652 });
653}
654
655async fn resolve_lazy_hook_closure(
661 vm: &mut crate::vm::Vm,
662 lazy: &LazyVmHookHandler,
663) -> Result<Arc<VmClosure>, VmError> {
664 let exports = vm
665 .load_module_exports(&lazy.module_path)
666 .await
667 .map_err(|error| {
668 VmError::Runtime(format!(
669 "failed to load manifest hook module '{}': {error}",
670 lazy.module_path.display()
671 ))
672 })?;
673 exports.get(&lazy.function_name).cloned().ok_or_else(|| {
674 VmError::Runtime(format!(
675 "manifest hook handler '{}' is not exported by module '{}'",
676 lazy.function_name,
677 lazy.module_path.display()
678 ))
679 })
680}
681
682async fn resolve_lifecycle_handler(
683 vm: &mut crate::vm::Vm,
684 handler: &VmLifecycleHandlerRef,
685) -> Result<Arc<VmClosure>, VmError> {
686 match handler {
687 VmLifecycleHandlerRef::Eager(closure) => Ok(Arc::clone(closure)),
688 VmLifecycleHandlerRef::Lazy(lazy) => resolve_lazy_hook_closure(vm, lazy).await,
689 }
690}
691
692pub fn clear_tool_hooks() {
693 RUNTIME_HOOKS.with(|hooks| {
694 hooks
695 .borrow_mut()
696 .retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
697 });
698 set_singleton_pre_tool_hook(None);
699}
700
701pub fn clear_runtime_hooks() {
702 RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
703 set_singleton_pre_tool_hook(None);
704 super::clear_command_policies();
705}
706
707pub fn clear_session_hooks() {
712 RUNTIME_HOOKS.with(|hooks| {
713 hooks
714 .borrow_mut()
715 .retain(|hook| !hook.event.is_session_lifecycle());
716 });
717}
718
719fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
720 let mut current = value;
721 for segment in path.split('.') {
722 let serde_json::Value::Object(map) = current else {
723 return None;
724 };
725 current = map.get(segment)?;
726 }
727 Some(current)
728}
729
730fn value_truthy(value: &serde_json::Value) -> bool {
731 match value {
732 serde_json::Value::Null => false,
733 serde_json::Value::Bool(value) => *value,
734 serde_json::Value::Number(value) => value
735 .as_i64()
736 .map(|number| number != 0)
737 .or_else(|| value.as_u64().map(|number| number != 0))
738 .or_else(|| value.as_f64().map(|number| number != 0.0))
739 .unwrap_or(false),
740 serde_json::Value::String(value) => !value.is_empty(),
741 serde_json::Value::Array(values) => !values.is_empty(),
742 serde_json::Value::Object(values) => !values.is_empty(),
743 }
744}
745
746fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
747 match value {
748 Some(serde_json::Value::String(text)) => text.clone(),
749 Some(other) => other.to_string(),
750 None => String::new(),
751 }
752}
753
754fn strip_quoted(value: &str) -> &str {
755 value
756 .trim()
757 .strip_prefix('"')
758 .and_then(|text| text.strip_suffix('"'))
759 .or_else(|| {
760 value
761 .trim()
762 .strip_prefix('\'')
763 .and_then(|text| text.strip_suffix('\''))
764 })
765 .unwrap_or(value.trim())
766}
767
768fn compile_event_pattern(pattern: String) -> PatternMatcher {
769 let trimmed = pattern.trim();
770 let expression = if trimmed.is_empty() || trimmed == "*" {
771 EventPatternExpression::MatchAll
772 } else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
773 match Regex::new(strip_quoted(rhs)) {
774 Ok(regex) => EventPatternExpression::Regex {
775 path: lhs.trim().to_string(),
776 regex,
777 },
778 Err(_) => EventPatternExpression::NeverMatch,
779 }
780 } else if let Some((lhs, rhs)) = trimmed.split_once("==") {
781 EventPatternExpression::Equals {
782 path: lhs.trim().to_string(),
783 value: strip_quoted(rhs).to_string(),
784 }
785 } else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
786 EventPatternExpression::NotEquals {
787 path: lhs.trim().to_string(),
788 value: strip_quoted(rhs).to_string(),
789 }
790 } else if trimmed.contains('.') {
791 EventPatternExpression::PathTruthy(trimmed.to_string())
792 } else {
793 EventPatternExpression::ToolNameGlob(trimmed.to_string())
794 };
795 PatternMatcher::EventExpression {
796 source: pattern,
797 expression,
798 }
799}
800
801fn expression_matches(
802 source: &str,
803 expression: &EventPatternExpression,
804 payload: &serde_json::Value,
805) -> bool {
806 let pattern = source.trim();
807 if pattern.is_empty() || pattern == "*" {
808 return true;
809 }
810 if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
811 if glob_match(pattern, target) {
812 return true;
813 }
814 }
815 match expression {
816 EventPatternExpression::MatchAll => true,
817 EventPatternExpression::NeverMatch => false,
818 EventPatternExpression::Regex { path, regex } => {
819 let value = value_to_pattern_string(value_at_path(payload, path));
820 regex.is_match(&value)
821 }
822 EventPatternExpression::Equals { path, value } => {
823 value_to_pattern_string(value_at_path(payload, path)) == *value
824 }
825 EventPatternExpression::NotEquals { path, value } => {
826 value_to_pattern_string(value_at_path(payload, path)) != *value
827 }
828 EventPatternExpression::PathTruthy(path) => {
829 value_at_path(payload, path).is_some_and(value_truthy)
830 }
831 EventPatternExpression::ToolNameGlob(pattern) => glob_match(
832 pattern,
833 &value_to_pattern_string(value_at_path(payload, "tool.name")),
834 ),
835 }
836}
837
838fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
839 match &hook.matcher {
840 PatternMatcher::ToolNameGlob(pattern) => {
841 tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
842 }
843 PatternMatcher::EventExpression { source, expression } => {
844 expression_matches(source, expression, payload)
845 }
846 }
847}
848
849fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
850 RUNTIME_HOOKS.with(|hooks| {
851 hooks
852 .borrow()
853 .iter()
854 .filter(|hook| hook.event == event)
855 .cloned()
856 .collect()
857 })
858}
859
860async fn invoke_vm_hook_handler(
864 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
865 handler: &RuntimeHookHandler,
866 payload: &serde_json::Value,
867) -> Result<Option<VmValue>, VmError> {
868 let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
869 return Err(VmError::Runtime(
870 "runtime hook requires an async builtin VM context".to_string(),
871 ));
872 };
873 let closure = match handler {
874 RuntimeHookHandler::Vm { closure, .. } => Arc::clone(closure),
875 RuntimeHookHandler::LazyVm { lazy, .. } => resolve_lazy_hook_closure(&mut vm, lazy).await?,
876 _ => return Ok(None),
877 };
878 let arg = crate::stdlib::json_to_vm_value(payload);
879 let result = vm.call_closure_pub(&closure, &[arg]).await;
880 if let Some(ctx) = ctx {
881 ctx.forward_output(&vm.take_output());
882 }
883 Ok(Some(result?))
884}
885
886async fn invoke_vm_lifecycle_hooks(
887 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
888 event: HookEvent,
889 registrations: Vec<VmLifecycleHookRegistration>,
890 payload: &serde_json::Value,
891) -> Result<(), VmError> {
892 let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
893 return Err(VmError::Runtime(
894 "runtime hook requires an async builtin VM context".to_string(),
895 ));
896 };
897 let arg = crate::stdlib::json_to_vm_value(payload);
898 let session_id = payload
899 .get("session")
900 .and_then(|v| v.get("id"))
901 .and_then(|v| v.as_str())
902 .unwrap_or("")
903 .to_string();
904 for registration in registrations {
905 record_hook_call(&session_id, event, ®istration.handler_name, payload);
906 let closure = resolve_lifecycle_handler(&mut vm, ®istration.handler).await?;
907 let raw = vm.call_closure_pub(&closure, &[arg.clone()]).await?;
908 if let Some(ctx) = ctx {
909 ctx.forward_output(&vm.take_output());
910 }
911 let effects = parse_hook_effects(event, &raw)?;
912 record_hook_returned(
913 &session_id,
914 event,
915 ®istration.handler_name,
916 &HookControl::Allow,
917 &raw,
918 );
919 inject_hook_effects(session_id.as_str(), effects, Some(event))?;
920 }
921 Ok(())
922}
923
924fn reminder_error(context: &str, message: impl Into<String>) -> VmError {
925 VmError::Runtime(format!("{context}: {}", message.into()))
926}
927
928fn reminder_code_error(context: &str, code: Code, message: impl Into<String>) -> VmError {
929 reminder_error(context, format!("{}: {}", code.as_str(), message.into()))
930}
931
932fn unsupported_reminder_event_error(event: HookEvent, context: &str) -> VmError {
933 reminder_code_error(
934 context,
935 Code::ReminderUnsupportedHookEvent,
936 format!(
937 "{} does not support reminder effects; use a session, tool, step, or persona hook",
938 event.as_str()
939 ),
940 )
941}
942
943fn required_reminder_spec_string(
944 options: &crate::value::DictMap,
945 key: &str,
946 context: &str,
947) -> Result<String, VmError> {
948 match options.get(key) {
949 Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(value.to_string()),
950 Some(VmValue::String(_)) | None | Some(VmValue::Nil) => Err(reminder_error(
951 context,
952 format!("`{key}` must be a non-empty string"),
953 )),
954 Some(other) => Err(reminder_error(
955 context,
956 format!("`{key}` must be a string, got {}", other.type_name()),
957 )),
958 }
959}
960
961fn optional_reminder_spec_string(
962 options: &crate::value::DictMap,
963 key: &str,
964 context: &str,
965) -> Result<Option<String>, VmError> {
966 match options.get(key) {
967 None | Some(VmValue::Nil) => Ok(None),
968 Some(VmValue::String(value)) => {
969 let trimmed = value.trim();
970 if trimmed.is_empty() {
971 Ok(None)
972 } else {
973 Ok(Some(trimmed.to_string()))
974 }
975 }
976 Some(other) => Err(reminder_error(
977 context,
978 format!("`{key}` must be a string or nil, got {}", other.type_name()),
979 )),
980 }
981}
982
983fn optional_reminder_spec_bool(
984 options: &crate::value::DictMap,
985 key: &str,
986 context: &str,
987) -> Result<Option<bool>, VmError> {
988 match options.get(key) {
989 None | Some(VmValue::Nil) => Ok(None),
990 Some(VmValue::Bool(value)) => Ok(Some(*value)),
991 Some(other) => Err(reminder_error(
992 context,
993 format!("`{key}` must be a bool or nil, got {}", other.type_name()),
994 )),
995 }
996}
997
998fn reminder_spec_tags(
999 options: &crate::value::DictMap,
1000 context: &str,
1001) -> Result<Vec<String>, VmError> {
1002 match options.get("tags") {
1003 None | Some(VmValue::Nil) => Ok(Vec::new()),
1004 Some(VmValue::List(values)) => {
1005 let mut tags = Vec::new();
1006 for value in values.iter() {
1007 let VmValue::String(tag) = value else {
1008 return Err(reminder_error(
1009 context,
1010 format!("`tags` entries must be strings, got {}", value.type_name()),
1011 ));
1012 };
1013 let trimmed = tag.trim();
1014 if trimmed.is_empty() {
1015 return Err(reminder_error(
1016 context,
1017 "`tags` entries must be non-empty strings",
1018 ));
1019 }
1020 if !tags.iter().any(|existing| existing == trimmed) {
1021 tags.push(trimmed.to_string());
1022 }
1023 }
1024 Ok(tags)
1025 }
1026 Some(other) => Err(reminder_error(
1027 context,
1028 format!("`tags` must be a list or nil, got {}", other.type_name()),
1029 )),
1030 }
1031}
1032
1033fn optional_reminder_spec_ttl(
1034 options: &crate::value::DictMap,
1035 context: &str,
1036) -> Result<Option<i64>, VmError> {
1037 match options.get("ttl_turns") {
1038 None | Some(VmValue::Nil) => Ok(None),
1039 Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value)),
1040 Some(VmValue::Int(_)) => Err(reminder_error(context, "`ttl_turns` must be > 0")),
1041 Some(other) => Err(reminder_error(
1042 context,
1043 format!(
1044 "`ttl_turns` must be an int or nil, got {}",
1045 other.type_name()
1046 ),
1047 )),
1048 }
1049}
1050
1051fn optional_reminder_spec_propagate(
1052 options: &crate::value::DictMap,
1053 context: &str,
1054) -> Result<Option<ReminderPropagate>, VmError> {
1055 optional_reminder_spec_string(options, "propagate", context)?
1056 .map(|value| match value.as_str() {
1057 "all" => Ok(ReminderPropagate::All),
1058 "session" => Ok(ReminderPropagate::Session),
1059 "none" => Ok(ReminderPropagate::None),
1060 _ => Err(reminder_code_error(
1061 context,
1062 Code::ReminderUnknownPropagate,
1063 "`propagate` must be one of all, session, or none",
1064 )),
1065 })
1066 .transpose()
1067}
1068
1069fn optional_reminder_spec_role_hint(
1070 options: &crate::value::DictMap,
1071 context: &str,
1072) -> Result<Option<ReminderRoleHint>, VmError> {
1073 optional_reminder_spec_string(options, "role_hint", context)?
1074 .map(|value| match value.as_str() {
1075 "system" => Ok(ReminderRoleHint::System),
1076 "developer" => Ok(ReminderRoleHint::Developer),
1077 "user_block" => Ok(ReminderRoleHint::UserBlock),
1078 "ephemeral_cache" => Ok(ReminderRoleHint::EphemeralCache),
1079 _ => Err(reminder_error(
1080 context,
1081 "`role_hint` must be one of system, developer, user_block, or ephemeral_cache",
1082 )),
1083 })
1084 .transpose()
1085}
1086
1087fn parse_reminder_spec(value: &VmValue, context: &str) -> Result<ReminderSpec, VmError> {
1088 let Some(options) = value.as_dict() else {
1089 return Err(reminder_error(
1090 context,
1091 format!("reminder spec must be a dict, got {}", value.type_name()),
1092 ));
1093 };
1094 const ALLOWED: &[&str] = &[
1095 "body",
1096 "tags",
1097 "dedupe_key",
1098 "ttl_turns",
1099 "preserve_on_compact",
1100 "propagate",
1101 "role_hint",
1102 ];
1103 let unknown = options
1104 .keys()
1105 .filter(|key| !ALLOWED.contains(&key.as_str()))
1106 .map(|key| key.as_str())
1107 .collect::<Vec<_>>();
1108 if !unknown.is_empty() {
1109 return Err(reminder_code_error(
1110 context,
1111 Code::ReminderUnknownOption,
1112 format!("unknown reminder option(s): {}", unknown.join(", ")),
1113 ));
1114 }
1115 Ok(SystemReminder {
1116 id: uuid::Uuid::now_v7().to_string(),
1117 tags: reminder_spec_tags(options, context)?,
1118 dedupe_key: optional_reminder_spec_string(options, "dedupe_key", context)?,
1119 ttl_turns: optional_reminder_spec_ttl(options, context)?,
1120 preserve_on_compact: optional_reminder_spec_bool(options, "preserve_on_compact", context)?
1121 .unwrap_or(false),
1122 propagate: optional_reminder_spec_propagate(options, context)?
1123 .unwrap_or(ReminderPropagate::Session),
1124 role_hint: optional_reminder_spec_role_hint(options, context)?
1125 .unwrap_or(ReminderRoleHint::System),
1126 source: ReminderSource::Hook,
1127 body: required_reminder_spec_string(options, "body", context)?,
1128 fired_at_turn: 0,
1129 originating_agent_id: None,
1130 })
1131}
1132
1133fn looks_like_reminder_spec(map: &crate::value::DictMap) -> bool {
1134 map.contains_key("body")
1135 && !map.contains_key("deny")
1136 && !map.contains_key("args")
1137 && !map.contains_key("result")
1138 && !map.contains_key("output")
1139 && !map.contains_key("modify")
1140 && !map.contains_key("block")
1141 && !map.contains_key("decision")
1142 && !map.contains_key("action")
1143 && !map.contains_key("control")
1144}
1145
1146fn parse_hook_effect_item(event: HookEvent, value: &VmValue) -> Result<HookEffect, VmError> {
1147 let context = format!("{} hook reminder", event.as_str());
1148 if let Some(map) = value.as_dict() {
1149 if let Some(reminder) = map.get("reminder") {
1150 if !event.supports_reminder_effects() {
1151 return Err(unsupported_reminder_event_error(event, &context));
1152 }
1153 return Ok(HookEffect::Reminder(parse_reminder_spec(
1154 reminder, &context,
1155 )?));
1156 }
1157 if matches!(
1158 map.get("type")
1159 .or_else(|| map.get("kind"))
1160 .map(|value| value.display())
1161 .as_deref(),
1162 Some("reminder" | "Reminder")
1163 ) {
1164 if !event.supports_reminder_effects() {
1165 return Err(unsupported_reminder_event_error(event, &context));
1166 }
1167 let spec = map
1168 .get("spec")
1169 .or_else(|| map.get("reminder"))
1170 .ok_or_else(|| reminder_error(&context, "reminder effect missing `spec`"))?;
1171 return Ok(HookEffect::Reminder(parse_reminder_spec(spec, &context)?));
1172 }
1173 if looks_like_reminder_spec(map) {
1174 if !event.supports_reminder_effects() {
1175 return Err(unsupported_reminder_event_error(event, &context));
1176 }
1177 return Ok(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1178 }
1179 }
1180 Err(reminder_error(
1181 &context,
1182 "hook effect must be {reminder: {...}} or a reminder spec",
1183 ))
1184}
1185
1186pub fn parse_hook_effects(event: HookEvent, value: &VmValue) -> Result<Vec<HookEffect>, VmError> {
1187 let Some(map) = value.as_dict() else {
1188 if let VmValue::List(items) = value {
1189 return items
1190 .iter()
1191 .map(|item| parse_hook_effect_item(event, item))
1192 .collect();
1193 }
1194 return Ok(Vec::new());
1195 };
1196
1197 let mut effects = Vec::new();
1198 if let Some(items) = map.get("effects") {
1199 match items {
1200 VmValue::List(list) => {
1201 for item in list.iter() {
1202 effects.push(parse_hook_effect_item(event, item)?);
1203 }
1204 }
1205 other => effects.push(parse_hook_effect_item(event, other)?),
1206 }
1207 }
1208 if let Some(reminder) = map.get("reminder") {
1209 let context = format!("{} hook reminder", event.as_str());
1210 if !event.supports_reminder_effects() {
1211 return Err(unsupported_reminder_event_error(event, &context));
1212 }
1213 effects.push(HookEffect::Reminder(parse_reminder_spec(
1214 reminder, &context,
1215 )?));
1216 } else if effects.is_empty() && looks_like_reminder_spec(map) {
1217 let context = format!("{} hook reminder", event.as_str());
1218 if !event.supports_reminder_effects() {
1219 return Err(unsupported_reminder_event_error(event, &context));
1220 }
1221 effects.push(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1222 }
1223 Ok(effects)
1224}
1225
1226fn action_value_after_effects(value: VmValue, default_action: VmValue) -> VmValue {
1227 let VmValue::Dict(map) = value else {
1228 return value;
1229 };
1230 if let Some(then) = map.get("then") {
1231 return then.clone();
1232 }
1233 let has_effects = map.contains_key("effects")
1234 || map.contains_key("reminder")
1235 || looks_like_reminder_spec(map.as_ref());
1236 if !has_effects {
1237 return VmValue::Dict(map);
1238 }
1239 let mut action = map.as_ref().clone();
1240 action.remove("effects");
1241 action.remove("reminder");
1242 action.remove("then");
1243 if action.keys().any(|key| {
1244 matches!(
1245 key.as_str(),
1246 "deny" | "args" | "result" | "output" | "modify" | "block" | "decision" | "action"
1247 )
1248 }) {
1249 VmValue::dict(action)
1250 } else {
1251 default_action
1252 }
1253}
1254
1255pub fn collect_hook_effects_and_action(
1256 event: HookEvent,
1257 value: VmValue,
1258 default_action: VmValue,
1259) -> Result<(VmValue, Vec<HookEffect>), VmError> {
1260 let mut current = value;
1261 let mut effects = Vec::new();
1262 for _ in 0..32 {
1263 let current_effects = parse_hook_effects(event, ¤t)?;
1264 if current_effects.is_empty() {
1265 return Ok((current, effects));
1266 }
1267 effects.extend(current_effects);
1268 current = action_value_after_effects(current, default_action.clone());
1269 }
1270 Err(VmError::Runtime(format!(
1271 "{} hook reminder return nested too deeply",
1272 event.as_str()
1273 )))
1274}
1275
1276fn inject_hook_effects(
1277 session_id: &str,
1278 effects: Vec<HookEffect>,
1279 event: Option<HookEvent>,
1280) -> Result<(), VmError> {
1281 if effects.is_empty() {
1282 return Ok(());
1283 }
1284 let target_session = if session_id.is_empty() {
1285 crate::agent_sessions::current_session_id().unwrap_or_default()
1286 } else {
1287 session_id.to_string()
1288 };
1289 if target_session.is_empty() {
1290 return Ok(());
1291 }
1292 for effect in effects {
1293 match effect {
1294 HookEffect::Reminder(spec) => {
1295 let reminder_id = spec.id.clone();
1296 let tags = spec.tags.clone();
1297 let dedupe_key = spec.dedupe_key.clone();
1298 let role_hint = spec.role_hint.as_str();
1299 let source = spec.source.as_str();
1300 let ttl_turns = spec.ttl_turns;
1301 let report = crate::agent_sessions::inject_reminder(&target_session, spec)
1302 .map_err(VmError::Runtime)?;
1303 record_hook_reminder_report(serde_json::json!({
1304 "hook_event": event.map(|event| event.as_str()),
1305 "session_id": &target_session,
1306 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1307 "reminder_id": reminder_id,
1308 "tags": tags,
1309 "dedupe_key": dedupe_key,
1310 "role_hint": role_hint,
1311 "source": source,
1312 "ttl_turns": ttl_turns,
1313 "deduped_count": report.deduped_count,
1314 }));
1315 }
1316 }
1317 }
1318 Ok(())
1319}
1320
1321pub fn inject_hook_effects_into_current_session(effects: Vec<HookEffect>) -> Result<(), VmError> {
1322 inject_hook_effects("", effects, None)
1323}
1324
1325fn wrap_pre_tool_effects(effects: Vec<HookEffect>, mut action: PreToolAction) -> PreToolAction {
1326 for effect in effects.into_iter().rev() {
1327 match effect {
1328 HookEffect::Reminder(spec) => {
1329 action = PreToolAction::Reminder {
1330 spec,
1331 then: Box::new(action),
1332 };
1333 }
1334 }
1335 }
1336 action
1337}
1338
1339fn wrap_post_tool_effects(effects: Vec<HookEffect>, mut action: PostToolAction) -> PostToolAction {
1340 for effect in effects.into_iter().rev() {
1341 match effect {
1342 HookEffect::Reminder(spec) => {
1343 action = PostToolAction::Reminder {
1344 spec,
1345 then: Box::new(action),
1346 };
1347 }
1348 }
1349 }
1350 action
1351}
1352
1353fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
1354 let (value, effects) =
1355 collect_hook_effects_and_action(HookEvent::PreToolUse, value, VmValue::Nil)?;
1356 match value {
1357 VmValue::Nil => Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow)),
1358 VmValue::Dict(map) => {
1359 if let Some(reason) = map.get("deny") {
1360 return Ok(wrap_pre_tool_effects(
1361 effects,
1362 PreToolAction::Deny(reason.display()),
1363 ));
1364 }
1365 if let Some(args) = map.get("args") {
1366 return Ok(wrap_pre_tool_effects(
1367 effects,
1368 PreToolAction::Modify(crate::llm::vm_value_to_json(args)),
1369 ));
1370 }
1371 Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow))
1372 }
1373 other => Err(VmError::Runtime(format!(
1374 "PreToolUse hook must return nil or {{deny, args}}, got {}",
1375 other.type_name()
1376 ))),
1377 }
1378}
1379
1380fn parse_post_tool_result(value: VmValue) -> Result<PostToolAction, VmError> {
1381 let (value, effects) =
1382 collect_hook_effects_and_action(HookEvent::PostToolUse, value, VmValue::Nil)?;
1383 match value {
1384 VmValue::Nil => Ok(wrap_post_tool_effects(effects, PostToolAction::Pass)),
1385 VmValue::String(text) => Ok(wrap_post_tool_effects(
1386 effects,
1387 PostToolAction::Modify(text.to_string()),
1388 )),
1389 VmValue::Dict(map) => {
1390 if let Some(result) = map.get("result") {
1391 return Ok(wrap_post_tool_effects(
1392 effects,
1393 PostToolAction::Modify(result.display()),
1394 ));
1395 }
1396 Ok(wrap_post_tool_effects(effects, PostToolAction::Pass))
1397 }
1398 other => Err(VmError::Runtime(format!(
1399 "PostToolUse hook must return nil, string, or {{result}}, got {}",
1400 other.type_name()
1401 ))),
1402 }
1403}
1404
1405pub fn apply_pre_tool_action(
1406 action: PreToolAction,
1407 current_args: &mut serde_json::Value,
1408) -> Result<Option<String>, VmError> {
1409 match action {
1410 PreToolAction::Allow => Ok(None),
1411 PreToolAction::Deny(reason) => Ok(Some(reason)),
1412 PreToolAction::Modify(new_args) => {
1413 *current_args = new_args;
1414 Ok(None)
1415 }
1416 PreToolAction::Reminder { spec, then } => {
1417 inject_hook_effects(
1418 "",
1419 vec![HookEffect::Reminder(spec)],
1420 Some(HookEvent::PreToolUse),
1421 )?;
1422 apply_pre_tool_action(*then, current_args)
1423 }
1424 }
1425}
1426
1427fn apply_post_tool_action(action: PostToolAction, current: String) -> Result<String, VmError> {
1428 match action {
1429 PostToolAction::Pass => Ok(current),
1430 PostToolAction::Modify(new_result) => Ok(new_result),
1431 PostToolAction::Reminder { spec, then } => {
1432 inject_hook_effects(
1433 "",
1434 vec![HookEffect::Reminder(spec)],
1435 Some(HookEvent::PostToolUse),
1436 )?;
1437 apply_post_tool_action(*then, current)
1438 }
1439 }
1440}
1441
1442pub async fn run_pre_tool_hooks(
1444 tool_name: &str,
1445 args: &serde_json::Value,
1446) -> Result<PreToolAction, VmError> {
1447 run_pre_tool_hooks_with_ctx(None, tool_name, args).await
1448}
1449
1450pub async fn run_pre_tool_hooks_with_ctx(
1451 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1452 tool_name: &str,
1453 args: &serde_json::Value,
1454) -> Result<PreToolAction, VmError> {
1455 let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
1456 let mut current_args = args.clone();
1457 if let Some(singleton) = singleton_pre_tool_hook() {
1461 let action = singleton(tool_name, ¤t_args);
1462 if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1463 return Ok(PreToolAction::Deny(reason));
1464 }
1465 }
1466 for hook in &hooks {
1467 let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1468 Some(serde_json::json!({
1469 "event": HookEvent::PreToolUse.as_str(),
1470 "tool": {
1471 "name": tool_name,
1472 "args": current_args.clone(),
1473 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1474 },
1475 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1476 }))
1477 } else {
1478 None
1479 };
1480 if !hook_matches(
1481 hook,
1482 Some(tool_name),
1483 payload.as_ref().unwrap_or(&serde_json::Value::Null),
1484 ) {
1485 continue;
1486 }
1487 let action = match &hook.handler {
1488 RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, ¤t_args),
1489 RuntimeHookHandler::Vm { .. } | RuntimeHookHandler::LazyVm { .. } => {
1490 let payload = payload.as_ref().ok_or_else(|| {
1491 VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
1492 })?;
1493 let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1494 continue;
1495 };
1496 parse_pre_tool_result(value)?
1497 }
1498 RuntimeHookHandler::NativePostTool(_) => continue,
1499 };
1500 if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1501 return Ok(PreToolAction::Deny(reason));
1502 }
1503 }
1504 if current_args != *args {
1505 Ok(PreToolAction::Modify(current_args))
1506 } else {
1507 Ok(PreToolAction::Allow)
1508 }
1509}
1510
1511pub async fn run_post_tool_hooks(
1513 tool_name: &str,
1514 args: &serde_json::Value,
1515 result: &str,
1516) -> Result<String, VmError> {
1517 run_post_tool_hooks_with_ctx(None, tool_name, args, result).await
1518}
1519
1520pub async fn run_post_tool_hooks_with_ctx(
1521 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1522 tool_name: &str,
1523 args: &serde_json::Value,
1524 result: &str,
1525) -> Result<String, VmError> {
1526 let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
1527 let mut current = result.to_string();
1528 for hook in &hooks {
1529 let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1530 Some(serde_json::json!({
1531 "event": HookEvent::PostToolUse.as_str(),
1532 "tool": {
1533 "name": tool_name,
1534 "args": args,
1535 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1536 },
1537 "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1538 "result": {
1539 "text": current.clone(),
1540 },
1541 }))
1542 } else {
1543 None
1544 };
1545 if !hook_matches(
1546 hook,
1547 Some(tool_name),
1548 payload.as_ref().unwrap_or(&serde_json::Value::Null),
1549 ) {
1550 continue;
1551 }
1552 let action = match &hook.handler {
1553 RuntimeHookHandler::NativePostTool(post) => post(tool_name, ¤t),
1554 RuntimeHookHandler::Vm { .. } | RuntimeHookHandler::LazyVm { .. } => {
1555 let payload = payload.as_ref().ok_or_else(|| {
1556 VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
1557 })?;
1558 let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1559 continue;
1560 };
1561 parse_post_tool_result(value)?
1562 }
1563 RuntimeHookHandler::NativePreTool(_) => continue,
1564 };
1565 match action {
1566 PostToolAction::Pass => {}
1567 PostToolAction::Modify(new_result) => {
1568 current = new_result;
1569 }
1570 PostToolAction::Reminder { spec, then } => {
1571 inject_hook_effects(
1572 "",
1573 vec![HookEffect::Reminder(spec)],
1574 Some(HookEvent::PostToolUse),
1575 )?;
1576 current = apply_post_tool_action(*then, current)?;
1577 }
1578 }
1579 }
1580 Ok(current)
1581}
1582
1583pub async fn run_lifecycle_hooks(
1584 event: HookEvent,
1585 payload: &serde_json::Value,
1586) -> Result<(), VmError> {
1587 run_lifecycle_hooks_with_ctx(None, event, payload).await
1588}
1589
1590pub async fn run_lifecycle_hooks_with_ctx(
1591 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1592 event: HookEvent,
1593 payload: &serde_json::Value,
1594) -> Result<(), VmError> {
1595 let registrations = matching_vm_lifecycle_registrations(event, payload);
1596 if registrations.is_empty() {
1597 return Ok(());
1598 }
1599 invoke_vm_lifecycle_hooks(ctx, event, registrations, payload).await
1600}
1601
1602pub async fn run_lifecycle_hooks_with_control(
1615 event: HookEvent,
1616 payload: &serde_json::Value,
1617) -> Result<HookControl, VmError> {
1618 run_lifecycle_hooks_with_control_with_ctx(None, event, payload).await
1619}
1620
1621pub async fn run_lifecycle_hooks_with_control_with_ctx(
1622 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1623 event: HookEvent,
1624 payload: &serde_json::Value,
1625) -> Result<HookControl, VmError> {
1626 let registrations = matching_vm_lifecycle_registrations(event, payload);
1627 if registrations.is_empty() {
1628 return Ok(HookControl::Allow);
1629 }
1630 let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
1631 return Err(VmError::Runtime(
1632 "session lifecycle hook requires an async builtin VM context".to_string(),
1633 ));
1634 };
1635 let session_id = payload
1636 .get("session")
1637 .and_then(|v| v.get("id"))
1638 .and_then(|v| v.as_str())
1639 .unwrap_or("")
1640 .to_string();
1641 let mut current_payload = payload.clone();
1642 let mut accumulated_modify: Option<serde_json::Value> = None;
1643 for registration in registrations {
1644 let arg = crate::stdlib::json_to_vm_value(¤t_payload);
1645 record_hook_call(
1646 &session_id,
1647 event,
1648 ®istration.handler_name,
1649 ¤t_payload,
1650 );
1651 let closure = resolve_lifecycle_handler(&mut vm, ®istration.handler).await?;
1652 let raw = vm.call_closure_pub(&closure, &[arg]).await?;
1653 if let Some(ctx) = ctx {
1654 ctx.forward_output(&vm.take_output());
1655 }
1656 let outcome = parse_hook_outcome(event, &raw)?;
1657 record_hook_returned(
1658 &session_id,
1659 event,
1660 ®istration.handler_name,
1661 &outcome.control,
1662 &raw,
1663 );
1664 inject_hook_effects(session_id.as_str(), outcome.effects, Some(event))?;
1665 match outcome.control {
1666 HookControl::Allow => continue,
1667 HookControl::Modify { payload: modified } => {
1668 current_payload = modified.clone();
1669 accumulated_modify = Some(modified);
1670 }
1671 other @ (HookControl::Block { .. } | HookControl::Decision { .. }) => {
1672 record_hook_vetoed(&session_id, event, ®istration.handler_name, &other);
1673 return Ok(other);
1674 }
1675 }
1676 }
1677 if let Some(payload) = accumulated_modify {
1678 Ok(HookControl::Modify { payload })
1679 } else {
1680 Ok(HookControl::Allow)
1681 }
1682}
1683
1684fn parse_hook_outcome(event: HookEvent, value: &VmValue) -> Result<HookOutcome, VmError> {
1685 let effects = parse_hook_effects(event, value)?;
1686 let action_value = if matches!(value, VmValue::List(_)) {
1687 VmValue::Nil
1688 } else {
1689 action_value_after_effects(value.clone(), VmValue::Nil)
1690 };
1691 let control = parse_hook_control(event, &action_value)?;
1692 Ok(HookOutcome { control, effects })
1693}
1694
1695pub fn parse_hook_control_for_finish(
1701 event: HookEvent,
1702 value: &VmValue,
1703) -> Result<HookControl, VmError> {
1704 parse_hook_control(event, value)
1705}
1706
1707fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
1708 match value {
1709 VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
1710 VmValue::Bool(false) => Ok(HookControl::Block {
1711 reason: format!("{} hook returned false", event.as_str()),
1712 }),
1713 VmValue::Dict(map) => {
1714 if let Some(decision) = map.get("decision") {
1715 let kind = decision.display();
1716 let kind_norm = kind.trim().to_ascii_lowercase();
1717 if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
1718 return Err(VmError::Runtime(format!(
1719 "{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
1720 event.as_str()
1721 )));
1722 }
1723 let reason = map.get("reason").and_then(|v| match v {
1724 VmValue::Nil => None,
1725 other => Some(other.display()),
1726 });
1727 return Ok(HookControl::Decision {
1728 kind: kind_norm,
1729 reason,
1730 });
1731 }
1732 let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
1733 if block {
1734 let reason = map
1735 .get("reason")
1736 .map(|v| v.display())
1737 .unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
1738 return Ok(HookControl::Block { reason });
1739 }
1740 if let Some(modify) = map.get("modify") {
1741 return Ok(HookControl::Modify {
1742 payload: crate::llm::vm_value_to_json(modify),
1743 });
1744 }
1745 Ok(HookControl::Allow)
1746 }
1747 other => Err(VmError::Runtime(format!(
1748 "{} hook must return nil, bool, or a control dict; got {}",
1749 event.as_str(),
1750 other.type_name()
1751 ))),
1752 }
1753}
1754
1755fn vm_value_truthy(value: &VmValue) -> bool {
1756 match value {
1757 VmValue::Nil => false,
1758 VmValue::Bool(value) => *value,
1759 VmValue::Int(value) => *value != 0,
1760 VmValue::Float(value) => *value != 0.0,
1761 VmValue::String(value) => !value.is_empty(),
1762 VmValue::List(value) => !value.is_empty(),
1763 VmValue::Dict(value) => !value.is_empty(),
1764 _ => true,
1765 }
1766}
1767
1768fn record_hook_call(
1769 session_id: &str,
1770 event: HookEvent,
1771 handler: &str,
1772 payload: &serde_json::Value,
1773) {
1774 if session_id.is_empty() {
1775 return;
1776 }
1777 let metadata = serde_json::json!({
1778 "event": event.as_str(),
1779 "handler": handler,
1780 "payload": payload,
1781 });
1782 let entry = crate::llm::helpers::transcript_event(
1783 "hook_call",
1784 "system",
1785 "internal",
1786 &format!("hook {} invoked: {}", event.as_str(), handler),
1787 Some(metadata),
1788 );
1789 let _ = crate::agent_sessions::append_event(session_id, entry);
1790}
1791
1792fn record_hook_returned(
1793 session_id: &str,
1794 event: HookEvent,
1795 handler: &str,
1796 control: &HookControl,
1797 raw: &VmValue,
1798) {
1799 if session_id.is_empty() {
1800 return;
1801 }
1802 let metadata = serde_json::json!({
1803 "event": event.as_str(),
1804 "handler": handler,
1805 "result": control.as_str(),
1806 "raw": crate::llm::vm_value_to_json(raw),
1807 });
1808 let entry = crate::llm::helpers::transcript_event(
1809 "hook_returned",
1810 "system",
1811 "internal",
1812 &format!(
1813 "hook {} returned {} from {}",
1814 event.as_str(),
1815 control.as_str(),
1816 handler
1817 ),
1818 Some(metadata),
1819 );
1820 let _ = crate::agent_sessions::append_event(session_id, entry);
1821}
1822
1823fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
1824 if session_id.is_empty() {
1825 return;
1826 }
1827 let (reason, decision) = match control {
1828 HookControl::Allow => return,
1829 HookControl::Block { reason } => (reason.clone(), None),
1830 HookControl::Decision { kind, reason } => (
1831 reason.clone().unwrap_or_else(|| format!("decision={kind}")),
1832 Some(kind.clone()),
1833 ),
1834 HookControl::Modify { .. } => return,
1835 };
1836 let metadata = serde_json::json!({
1837 "event": event.as_str(),
1838 "handler": handler,
1839 "reason": reason,
1840 "decision": decision,
1841 });
1842 let entry = crate::llm::helpers::transcript_event(
1843 "hook_vetoed",
1844 "system",
1845 "internal",
1846 &format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
1847 Some(metadata),
1848 );
1849 let _ = crate::agent_sessions::append_event(session_id, entry);
1850}
1851
1852pub fn matching_vm_lifecycle_hooks(
1853 event: HookEvent,
1854 payload: &serde_json::Value,
1855) -> Vec<VmLifecycleHookInvocation> {
1856 matching_vm_lifecycle_registrations(event, payload)
1857 .into_iter()
1858 .map(|registration| match registration.handler {
1859 VmLifecycleHandlerRef::Eager(closure) => VmLifecycleHookInvocation {
1860 closure: Some(closure),
1861 lazy: None,
1862 handler_name: registration.handler_name,
1863 },
1864 VmLifecycleHandlerRef::Lazy(lazy) => VmLifecycleHookInvocation {
1865 closure: None,
1866 lazy: Some(lazy),
1867 handler_name: registration.handler_name,
1868 },
1869 })
1870 .collect()
1871}
1872
1873fn matching_vm_lifecycle_registrations(
1874 event: HookEvent,
1875 payload: &serde_json::Value,
1876) -> Vec<VmLifecycleHookRegistration> {
1877 RUNTIME_HOOKS.with(|hooks| {
1878 hooks
1879 .borrow()
1880 .iter()
1881 .filter(|hook| hook.event == event)
1882 .filter(|hook| hook_matches(hook, None, payload))
1883 .filter_map(|hook| match &hook.handler {
1884 RuntimeHookHandler::Vm {
1885 closure,
1886 handler_name,
1887 } => Some(VmLifecycleHookRegistration {
1888 handler_name: handler_name.clone(),
1889 handler: VmLifecycleHandlerRef::Eager(Arc::clone(closure)),
1890 }),
1891 RuntimeHookHandler::LazyVm { lazy, handler_name } => {
1892 Some(VmLifecycleHookRegistration {
1893 handler_name: handler_name.clone(),
1894 handler: VmLifecycleHandlerRef::Lazy(lazy.clone()),
1895 })
1896 }
1897 RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
1898 None
1899 }
1900 })
1901 .collect()
1902 })
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907 use super::*;
1908
1909 fn vm_string(value: &str) -> VmValue {
1910 VmValue::String(arcstr::ArcStr::from(value))
1911 }
1912
1913 fn dict(entries: Vec<(&str, VmValue)>) -> VmValue {
1914 VmValue::dict(
1915 entries
1916 .into_iter()
1917 .map(|(key, value)| (crate::value::intern_key(key), value))
1918 .collect::<crate::value::DictMap>(),
1919 )
1920 }
1921
1922 fn error_message(result: Result<Vec<HookEffect>, VmError>) -> String {
1923 match result.expect_err("expected hook reminder parse error") {
1924 VmError::Runtime(message) => message,
1925 other => panic!("expected runtime error, got {other:?}"),
1926 }
1927 }
1928
1929 #[test]
1930 fn unknown_reminder_option_reports_code() {
1931 let value = dict(vec![(
1932 "reminder",
1933 dict(vec![
1934 ("body", vm_string("remember this")),
1935 ("typo_key", VmValue::Bool(true)),
1936 ]),
1937 )]);
1938 let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1939 assert!(message.contains(Code::ReminderUnknownOption.as_str()));
1940 assert!(message.contains("typo_key"), "{message}");
1941 }
1942
1943 #[test]
1944 fn unknown_reminder_propagate_reports_specific_code() {
1945 let value = dict(vec![(
1946 "reminder",
1947 dict(vec![
1948 ("body", vm_string("remember this")),
1949 ("propagate", vm_string("workspace")),
1950 ]),
1951 )]);
1952 let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1953 assert!(message.contains(Code::ReminderUnknownPropagate.as_str()));
1954 assert!(message.contains("propagate"), "{message}");
1955 }
1956
1957 #[test]
1958 fn worker_events_reject_reminder_effects_with_specific_code() {
1959 let value = dict(vec![(
1960 "reminder",
1961 dict(vec![("body", vm_string("worker lifecycle"))]),
1962 )]);
1963 let message = error_message(parse_hook_effects(HookEvent::WorkerSpawned, &value));
1964 assert!(message.contains(Code::ReminderUnsupportedHookEvent.as_str()));
1965 assert!(message.contains("WorkerSpawned"), "{message}");
1966 }
1967
1968 #[test]
1969 fn as_str_round_trips_through_serde() {
1970 for &event in HookEvent::ALL {
1975 let json = serde_json::to_string(&event).unwrap();
1976 assert_eq!(json, format!("\"{}\"", event.as_str()));
1977 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1978 assert_eq!(parsed, event);
1979 }
1980 }
1981
1982 #[test]
1983 fn parse_session_event_accepts_both_spellings_for_every_session_variant() {
1984 for &event in HookEvent::ALL.iter().filter(|e| e.is_session_lifecycle()) {
1988 let pascal = event.as_str();
1989 let mut snake = String::new();
1990 pascal_to_snake_buf(pascal, &mut snake);
1991 assert_eq!(
1992 HookEvent::parse_session_event(pascal).unwrap(),
1993 event,
1994 "PascalCase `{pascal}`",
1995 );
1996 assert_eq!(
1997 HookEvent::parse_session_event(&snake).unwrap(),
1998 event,
1999 "snake_case `{snake}`",
2000 );
2001 }
2002 }
2003
2004 #[test]
2005 fn parse_session_event_rejects_non_session_variants() {
2006 for &event in HookEvent::ALL.iter().filter(|e| !e.is_session_lifecycle()) {
2010 let err = HookEvent::parse_session_event(event.as_str())
2011 .expect_err("non-session event slipped through");
2012 assert!(err.contains("unknown session hook event"), "{err}");
2013 }
2014 }
2015
2016 #[test]
2017 fn parse_provider_event_accepts_worker_and_session_and_flagged_variants() {
2018 for &event in HookEvent::ALL.iter().filter(|e| {
2023 matches!(e.kind(), HookEventKind::Worker | HookEventKind::Session)
2024 || e.in_provider_parse()
2025 }) {
2026 assert_eq!(
2027 HookEvent::parse_provider_event(event.as_str()).unwrap(),
2028 event,
2029 "{event:?}",
2030 );
2031 }
2032 }
2033
2034 #[test]
2035 fn session_error_accepts_legacy_short_alias() {
2036 assert_eq!(
2039 HookEvent::parse_session_event("error").unwrap(),
2040 HookEvent::SessionError,
2041 );
2042 assert_eq!(
2043 HookEvent::parse_session_event("SessionError").unwrap(),
2044 HookEvent::SessionError,
2045 );
2046 assert_eq!(
2047 HookEvent::parse_session_event("session_error").unwrap(),
2048 HookEvent::SessionError,
2049 );
2050 }
2051
2052 #[test]
2053 fn supports_reminder_effects_excludes_only_worker_kind() {
2054 for &event in HookEvent::ALL {
2055 let supports = event.supports_reminder_effects();
2056 let expected = !matches!(event.kind(), HookEventKind::Worker);
2057 assert_eq!(
2058 supports,
2059 expected,
2060 "{event:?} ({:?}) reminder support disagrees with kind",
2061 event.kind(),
2062 );
2063 }
2064 }
2065
2066 #[test]
2067 fn from_worker_event_covers_every_worker_variant() {
2068 for worker in WorkerEvent::ALL {
2069 let event = HookEvent::from_worker_event(worker);
2070 assert!(
2071 matches!(event.kind(), HookEventKind::Worker),
2072 "WorkerEvent::{worker:?} mapped to non-Worker kind {:?}",
2073 event.kind(),
2074 );
2075 assert_eq!(event.as_str(), worker.as_str());
2076 }
2077 }
2078}