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