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