Skip to main content

agent_sdk_core/application/
hooks.rs

1//! Application-layer coordination over core primitives. Use these services to lower
2//! helpers, drive runs, validate output, coordinate tools, approvals, delivery,
3//! isolation, telemetry, and feature layers. Methods in this layer may call
4//! configured ports, mutate in-memory stores, append journals, or publish events as
5//! documented. This file contains the hooks portion of that contract.
6//!
7use crate::{
8    domain::{AgentError, AgentErrorKind, AgentId, DestinationRef, RunId, SourceRef},
9    error::{CausalIds, RetryClassification},
10    hook_ports::HookExecutorRegistry,
11    hook_records::{HookMutationJournalPlan, HookRecord, HookResponseDecision},
12    journal::JournalCursor,
13    journal_ports::RunJournal,
14    package::RuntimePackageFingerprint,
15    package_hooks::{
16        HookCancellationToken, HookInput, HookPoint, HookResponse, HookResponseClass, HookSpec,
17        HookView, ordered_hooks_for_point, validate_hook_specs,
18    },
19};
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22/// Holds hook lifecycle context application-layer state or configuration.
23/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
24pub struct HookLifecycleContext {
25    /// Run identifier used for lineage, filtering, replay, and dedupe.
26    pub run_id: RunId,
27    /// Agent identifier used for lineage, filtering, and ownership checks.
28    pub agent_id: AgentId,
29    /// Optional host-provided session identifier for grouping related turns.
30    pub session_id: Option<crate::domain::SessionId>,
31    /// Turn identifier for one loop turn within a run.
32    pub turn_id: Option<crate::domain::TurnId>,
33    /// Attempt identifier for retry, repair, provider, or tool execution
34    /// evidence.
35    pub attempt_id: Option<crate::domain::AttemptId>,
36    /// Source label or ref for this item; it is metadata and does not fetch
37    /// content by itself.
38    pub source: SourceRef,
39    /// Destination label or ref for this item; it is metadata and does not
40    /// deliver content by itself.
41    pub destination: Option<DestinationRef>,
42    /// Deterministic package fingerprint used for stale checks, package
43    /// evidence, or replay comparisons.
44    pub package_fingerprint: RuntimePackageFingerprint,
45    /// Cancellation used by this record or request.
46    pub cancellation: HookCancellationToken,
47}
48
49impl HookLifecycleContext {
50    /// Creates a new application::hooks value with explicit
51    /// caller-provided inputs. This constructor is data-only and
52    /// performs no I/O or external side effects.
53    pub fn new(
54        run_id: RunId,
55        agent_id: AgentId,
56        source: SourceRef,
57        package_fingerprint: RuntimePackageFingerprint,
58    ) -> Self {
59        Self {
60            run_id,
61            agent_id,
62            session_id: None,
63            turn_id: None,
64            attempt_id: None,
65            source,
66            destination: None,
67            package_fingerprint,
68            cancellation: HookCancellationToken::default(),
69        }
70    }
71}
72
73/// Holds hook lifecycle coordinator application-layer state or configuration.
74/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
75pub struct HookLifecycleCoordinator<'a, R, J>
76where
77    R: HookExecutorRegistry + ?Sized,
78    J: RunJournal + ?Sized,
79{
80    registry: &'a R,
81    journal: &'a J,
82    next_journal_seq: u64,
83    sequence_allocator: Option<Box<dyn FnMut(u64) -> u64 + 'a>>,
84}
85
86impl<'a, R, J> HookLifecycleCoordinator<'a, R, J>
87where
88    R: HookExecutorRegistry + ?Sized,
89    J: RunJournal + ?Sized,
90{
91    /// Creates a new application::hooks value with explicit
92    /// caller-provided inputs. This constructor is data-only and
93    /// performs no I/O or external side effects.
94    pub fn new(registry: &'a R, journal: &'a J, next_journal_seq: u64) -> Self {
95        Self {
96            registry,
97            journal,
98            next_journal_seq,
99            sequence_allocator: None,
100        }
101    }
102
103    /// Creates a coordinator with a caller-owned journal sequence allocator.
104    /// The allocator is called only when this coordinator is about to append hook journal records.
105    pub fn new_with_sequence_allocator<F>(
106        registry: &'a R,
107        journal: &'a J,
108        next_journal_seq: u64,
109        sequence_allocator: F,
110    ) -> Self
111    where
112        F: FnMut(u64) -> u64 + 'a,
113    {
114        Self {
115            registry,
116            journal,
117            next_journal_seq,
118            sequence_allocator: Some(Box::new(sequence_allocator)),
119        }
120    }
121
122    /// Validates the application::hooks invariants and returns a typed
123    /// error on failure. Validation is pure and does not perform I/O,
124    /// dispatch, journal appends, or adapter calls.
125    pub fn validate_package_hooks(&self, specs: &[HookSpec]) -> Result<(), AgentError> {
126        validate_package_hooks(specs, self.registry)
127    }
128
129    /// Returns the next journal sequence number this coordinator would use.
130    /// Callers that share an external sequence allocator use this to synchronize after hook
131    /// mutation records have been appended.
132    pub fn next_journal_seq(&self) -> u64 {
133        self.next_journal_seq
134    }
135
136    /// Invoke point.
137    /// This invokes the configured hooks for one hook point and returns their responses; hook
138    /// side effects stay behind the registered hook executors.
139    pub fn invoke_point(
140        &mut self,
141        specs: &[HookSpec],
142        point: HookPoint,
143        context: HookLifecycleContext,
144        view: HookView,
145    ) -> Result<Vec<HookInvocationOutcome>, AgentError> {
146        self.invoke_point_guarded(specs, point, context, view, |_, _| Ok(true))
147    }
148
149    /// Invoke point guarded.
150    /// This invokes hooks for one point and lets the guarded domain reject behavior-changing
151    /// responses before they are journaled as accepted.
152    pub fn invoke_point_guarded<F>(
153        &mut self,
154        specs: &[HookSpec],
155        point: HookPoint,
156        context: HookLifecycleContext,
157        view: HookView,
158        mut acceptance_guard: F,
159    ) -> Result<Vec<HookInvocationOutcome>, AgentError>
160    where
161        F: FnMut(&HookSpec, &HookResponse) -> Result<bool, AgentError>,
162    {
163        let hooks = ordered_hooks_for_point(specs, point);
164        let mut outcomes = Vec::with_capacity(hooks.len());
165        for spec in hooks {
166            outcomes.push(self.invoke_one(&spec, &context, view.clone(), &mut acceptance_guard)?);
167        }
168        Ok(outcomes)
169    }
170
171    fn invoke_one<F>(
172        &mut self,
173        spec: &HookSpec,
174        context: &HookLifecycleContext,
175        view: HookView,
176        acceptance_guard: &mut F,
177    ) -> Result<HookInvocationOutcome, AgentError>
178    where
179        F: FnMut(&HookSpec, &HookResponse) -> Result<bool, AgentError>,
180    {
181        spec.validate()?;
182        let invocation_id = format!("hook.invocation.{}", self.next_journal_seq);
183        if context.cancellation.cancelled {
184            return Ok(HookInvocationOutcome::from_record(
185                spec,
186                HookInvocationStatus::Cancelled,
187                HookRecord::cancelled(spec, invocation_id),
188            ));
189        }
190
191        let executor = self.registry.resolve(&spec.executor_ref).ok_or_else(|| {
192            fail_closed_error(
193                spec,
194                context,
195                AgentErrorKind::HostConfigurationNeeded,
196                "missing hook executor ref",
197            )
198        })?;
199        let input = HookInput {
200            hook_id: spec.hook_id.clone(),
201            point: spec.point.clone(),
202            run_id: context.run_id.clone(),
203            agent_id: context.agent_id.clone(),
204            turn_id: context.turn_id.clone(),
205            attempt_id: context.attempt_id.clone(),
206            source: SourceRef::with_kind(crate::domain::SourceKind::Hook, spec.hook_id.as_str()),
207            destination: context.destination.clone(),
208            package_fingerprint: context.package_fingerprint.clone(),
209            view,
210            policy_refs: vec![spec.policy_ref.clone()],
211            cancellation: context.cancellation.clone(),
212        };
213
214        let hook_result = executor.invoke(input);
215        let execution = match hook_result {
216            Ok(execution) => execution,
217            Err(error) if !spec.is_security_relevant() && !spec.failure.fails_closed() => {
218                return Ok(HookInvocationOutcome::from_record(
219                    spec,
220                    HookInvocationStatus::FailedOpen,
221                    HookRecord::failed(spec, invocation_id, error.context().message),
222                ));
223            }
224            Err(error) => {
225                return Err(fail_closed_error(
226                    spec,
227                    context,
228                    error.kind(),
229                    error.context().message,
230                ));
231            }
232        };
233
234        if execution.elapsed_ms > spec.timeout.timeout_ms {
235            return self.handle_timeout(spec, context, invocation_id, execution.elapsed_ms);
236        }
237
238        self.handle_response(
239            spec,
240            context,
241            invocation_id,
242            execution.response,
243            acceptance_guard,
244        )
245    }
246
247    fn handle_timeout(
248        &self,
249        spec: &HookSpec,
250        context: &HookLifecycleContext,
251        invocation_id: String,
252        elapsed_ms: u64,
253    ) -> Result<HookInvocationOutcome, AgentError> {
254        if !spec.is_security_relevant() && !spec.failure.fails_closed() {
255            return Ok(HookInvocationOutcome::from_record(
256                spec,
257                HookInvocationStatus::TimedOutFailOpen,
258                HookRecord::timeout(spec, invocation_id, elapsed_ms),
259            ));
260        }
261        Err(fail_closed_error(
262            spec,
263            context,
264            AgentErrorKind::Timeout,
265            "hook timed out before guarded lifecycle transition",
266        ))
267    }
268
269    fn handle_response<F>(
270        &mut self,
271        spec: &HookSpec,
272        context: &HookLifecycleContext,
273        invocation_id: String,
274        response: HookResponse,
275        acceptance_guard: &mut F,
276    ) -> Result<HookInvocationOutcome, AgentError>
277    where
278        F: FnMut(&HookSpec, &HookResponse) -> Result<bool, AgentError>,
279    {
280        let response_class = response.response_class();
281        if !spec
282            .point
283            .allowed_response_classes()
284            .contains(&response_class)
285        {
286            return Ok(HookInvocationOutcome::rejected(
287                spec,
288                invocation_id,
289                response_class,
290                HookInvocationStatus::RejectedPointMatrix,
291            ));
292        }
293        if !spec
294            .mutation_rights
295            .allows_response_class(response_class.clone())
296        {
297            return Ok(HookInvocationOutcome::rejected(
298                spec,
299                invocation_id,
300                response_class,
301                HookInvocationStatus::RejectedMutationRight,
302            ));
303        }
304
305        if !response.changes_behavior() {
306            return Ok(HookInvocationOutcome::from_record(
307                spec,
308                HookInvocationStatus::Completed,
309                HookRecord::completed(spec, invocation_id, 0),
310            ));
311        }
312        match acceptance_guard(spec, &response) {
313            Ok(true) => {}
314            Ok(false) => {
315                return self.append_rejected_response_decision(
316                    spec,
317                    context,
318                    invocation_id,
319                    response_class,
320                    HookInvocationStatus::RejectedPolicy,
321                );
322            }
323            Err(error) => {
324                self.append_rejected_response_decision(
325                    spec,
326                    context,
327                    invocation_id,
328                    response_class,
329                    HookInvocationStatus::RejectedPolicy,
330                )?;
331                return Err(error);
332            }
333        }
334
335        let journal_seq = self.next_seq_block(3);
336        let record_id = format!("journal.hook.{}.{}", spec.hook_id.as_str(), journal_seq);
337        let plan = HookMutationJournalPlan::accepted_response(
338            journal_seq,
339            record_id,
340            context.run_id.clone(),
341            context.agent_id.clone(),
342            context.session_id.clone(),
343            context.turn_id.clone(),
344            context.attempt_id.clone(),
345            context.source.clone(),
346            spec,
347            invocation_id.clone(),
348            response_class.clone(),
349            context.package_fingerprint.as_str(),
350        );
351        self.journal
352            .append(plan.hook_journal_record.clone())
353            .map_err(|error| {
354                fail_closed_error(
355                    spec,
356                    context,
357                    error.kind(),
358                    "hook response journal append failed before apply",
359                )
360            })?;
361        let _intent_cursor = self
362            .journal
363            .append(plan.intent_journal_record.clone())
364            .map_err(|error| {
365                fail_closed_error(
366                    spec,
367                    context,
368                    error.kind(),
369                    "hook mutation journal append failed before apply",
370                )
371            })?;
372        let terminal_cursor = self
373            .journal
374            .append(plan.result_journal_record.clone())
375            .map_err(|error| {
376                fail_closed_error(
377                    spec,
378                    context,
379                    error.kind(),
380                    "hook mutation terminal result append failed before apply",
381                )
382            })?;
383        Ok(HookInvocationOutcome {
384            hook_id: spec.hook_id.clone(),
385            status: HookInvocationStatus::AppliedJournaledMutation,
386            response_class: Some(response_class),
387            accepted_response: Some(response),
388            journal_cursor: Some(terminal_cursor),
389            journaled_before_apply: true,
390            record: plan.hook_record,
391        })
392    }
393
394    fn append_rejected_response_decision(
395        &mut self,
396        spec: &HookSpec,
397        context: &HookLifecycleContext,
398        invocation_id: String,
399        response_class: HookResponseClass,
400        status: HookInvocationStatus,
401    ) -> Result<HookInvocationOutcome, AgentError> {
402        let journal_seq = self.next_seq_block(1);
403        let record_id = format!("journal.hook.{}.{}", spec.hook_id.as_str(), journal_seq);
404        let (record, journal_record) = HookRecord::rejected_response_journal_record(
405            journal_seq,
406            record_id,
407            context.run_id.clone(),
408            context.agent_id.clone(),
409            context.session_id.clone(),
410            context.turn_id.clone(),
411            context.attempt_id.clone(),
412            context.source.clone(),
413            spec,
414            invocation_id,
415            HookResponseDecision::RejectedPolicy,
416            response_class.clone(),
417            context.package_fingerprint.as_str(),
418        );
419        let cursor = self.journal.append(journal_record).map_err(|error| {
420            fail_closed_error(
421                spec,
422                context,
423                error.kind(),
424                "hook rejected response journal append failed before returning policy rejection",
425            )
426        })?;
427        Ok(HookInvocationOutcome {
428            hook_id: spec.hook_id.clone(),
429            status,
430            response_class: Some(response_class),
431            accepted_response: None,
432            journal_cursor: Some(cursor),
433            journaled_before_apply: false,
434            record,
435        })
436    }
437
438    fn next_seq_block(&mut self, width: u64) -> u64 {
439        let seq = if let Some(sequence_allocator) = &mut self.sequence_allocator {
440            sequence_allocator(width)
441        } else {
442            self.next_journal_seq
443        };
444        self.next_journal_seq = seq.saturating_add(width);
445        seq
446    }
447}
448
449/// Validates the application::hooks invariants and returns a typed
450/// error on failure. Validation is pure and does not perform I/O,
451/// dispatch, journal appends, or adapter calls.
452pub fn validate_package_hooks<R>(specs: &[HookSpec], registry: &R) -> Result<(), AgentError>
453where
454    R: HookExecutorRegistry + ?Sized,
455{
456    validate_hook_specs(specs)?;
457    for spec in specs {
458        if !registry.contains(&spec.executor_ref) {
459            return Err(AgentError::new(
460                AgentErrorKind::InvalidPackage,
461                RetryClassification::HostConfigurationNeeded,
462                format!(
463                    "hook executor {} is not resolved before start_run",
464                    spec.executor_ref.as_str()
465                ),
466            ));
467        }
468    }
469    Ok(())
470}
471
472#[derive(Clone, Debug, Eq, PartialEq)]
473/// Holds hook invocation outcome application-layer state or configuration.
474/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
475pub struct HookInvocationOutcome {
476    /// Stable hook id used for typed lineage, lookup, or dedupe.
477    pub hook_id: crate::package_hooks::HookId,
478    /// Finite status for this record or lifecycle stage.
479    pub status: HookInvocationStatus,
480    /// Classification value for response class.
481    /// Policy and projection paths use it for finite routing decisions.
482    pub response_class: Option<HookResponseClass>,
483    /// Accepted hook response for behavior-changing outcomes.
484    /// Callers may lower this into the guarded domain operation after journal-before-apply
485    /// succeeds.
486    pub accepted_response: Option<HookResponse>,
487    /// Cursor identifying a replay, export, or subscription position.
488    /// Use it to resume without widening the original scope.
489    pub journal_cursor: Option<JournalCursor>,
490    /// Whether journaled before apply is enabled.
491    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
492    pub journaled_before_apply: bool,
493    /// Record used by this record or request.
494    pub record: HookRecord,
495}
496
497impl HookInvocationOutcome {
498    fn from_record(spec: &HookSpec, status: HookInvocationStatus, record: HookRecord) -> Self {
499        Self {
500            hook_id: spec.hook_id.clone(),
501            status,
502            response_class: None,
503            accepted_response: None,
504            journal_cursor: None,
505            journaled_before_apply: false,
506            record,
507        }
508    }
509
510    fn rejected(
511        spec: &HookSpec,
512        invocation_id: String,
513        response_class: HookResponseClass,
514        status: HookInvocationStatus,
515    ) -> Self {
516        let decision = match status {
517            HookInvocationStatus::RejectedMutationRight => {
518                crate::hook_records::HookResponseDecision::RejectedMutationRight
519            }
520            HookInvocationStatus::RejectedPointMatrix => {
521                crate::hook_records::HookResponseDecision::RejectedPointMatrix
522            }
523            HookInvocationStatus::RejectedPolicy => {
524                crate::hook_records::HookResponseDecision::RejectedPolicy
525            }
526            _ => crate::hook_records::HookResponseDecision::RejectedPolicy,
527        };
528        Self {
529            hook_id: spec.hook_id.clone(),
530            status,
531            response_class: Some(response_class.clone()),
532            accepted_response: None,
533            journal_cursor: None,
534            journaled_before_apply: false,
535            record: HookRecord::response_decision(
536                spec,
537                invocation_id,
538                decision,
539                response_class,
540                Vec::new(),
541            ),
542        }
543    }
544}
545
546#[derive(Clone, Debug, Eq, PartialEq)]
547/// Enumerates the finite hook invocation status cases.
548/// Serialized names are part of the SDK contract; update fixtures when variants change.
549pub enum HookInvocationStatus {
550    /// Use this variant when the contract needs to represent completed; selecting it has no side effect by itself.
551    Completed,
552    /// Use this variant when the contract needs to represent applied journaled mutation; selecting it has no side effect by itself.
553    AppliedJournaledMutation,
554    /// Use this variant when the contract needs to represent rejected mutation right; selecting it has no side effect by itself.
555    RejectedMutationRight,
556    /// Use this variant when the contract needs to represent rejected point matrix; selecting it has no side effect by itself.
557    RejectedPointMatrix,
558    /// Use this variant when the contract needs to represent rejected policy; selecting it has no side effect by itself.
559    RejectedPolicy,
560    /// Use this variant when the contract needs to represent timed out fail open; selecting it has no side effect by itself.
561    TimedOutFailOpen,
562    /// Use this variant when the contract needs to represent failed open; selecting it has no side effect by itself.
563    FailedOpen,
564    /// Use this variant when the contract needs to represent cancelled; selecting it has no side effect by itself.
565    Cancelled,
566}
567
568fn fail_closed_error(
569    spec: &HookSpec,
570    context: &HookLifecycleContext,
571    kind: AgentErrorKind,
572    message: impl Into<String>,
573) -> AgentError {
574    let kind = match spec.failure {
575        crate::package_hooks::HookFailurePolicy::Deny => AgentErrorKind::PolicyDenial,
576        crate::package_hooks::HookFailurePolicy::InterruptRun
577        | crate::package_hooks::HookFailurePolicy::FailRun => AgentErrorKind::HookFailure,
578        crate::package_hooks::HookFailurePolicy::FailOpenObserveOnly => kind,
579    };
580    AgentError::new(kind, RetryClassification::RepairNeeded, message).with_causal_ids(CausalIds {
581        run_id: Some(context.run_id.clone()),
582        ..CausalIds::default()
583    })
584}