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