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