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},
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    /// Turn identifier for one loop turn within a run.
30    pub turn_id: Option<crate::domain::TurnId>,
31    /// Attempt identifier for retry, repair, provider, or tool execution
32    /// evidence.
33    pub attempt_id: Option<crate::domain::AttemptId>,
34    /// Source label or ref for this item; it is metadata and does not fetch
35    /// content by itself.
36    pub source: SourceRef,
37    /// Destination label or ref for this item; it is metadata and does not
38    /// deliver content by itself.
39    pub destination: Option<DestinationRef>,
40    /// Deterministic package fingerprint used for stale checks, package
41    /// evidence, or replay comparisons.
42    pub package_fingerprint: RuntimePackageFingerprint,
43    /// Cancellation used by this record or request.
44    pub cancellation: HookCancellationToken,
45}
46
47impl HookLifecycleContext {
48    /// Creates a new application::hooks value with explicit
49    /// caller-provided inputs. This constructor is data-only and
50    /// performs no I/O or external side effects.
51    pub fn new(
52        run_id: RunId,
53        agent_id: AgentId,
54        source: SourceRef,
55        package_fingerprint: RuntimePackageFingerprint,
56    ) -> Self {
57        Self {
58            run_id,
59            agent_id,
60            turn_id: None,
61            attempt_id: None,
62            source,
63            destination: None,
64            package_fingerprint,
65            cancellation: HookCancellationToken::default(),
66        }
67    }
68}
69
70/// Holds hook lifecycle coordinator application-layer state or configuration.
71/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
72pub struct HookLifecycleCoordinator<'a, R, J>
73where
74    R: HookExecutorRegistry,
75    J: RunJournal,
76{
77    registry: &'a R,
78    journal: &'a J,
79    next_journal_seq: u64,
80}
81
82impl<'a, R, J> HookLifecycleCoordinator<'a, R, J>
83where
84    R: HookExecutorRegistry,
85    J: RunJournal,
86{
87    /// Creates a new application::hooks value with explicit
88    /// caller-provided inputs. This constructor is data-only and
89    /// performs no I/O or external side effects.
90    pub fn new(registry: &'a R, journal: &'a J, next_journal_seq: u64) -> Self {
91        Self {
92            registry,
93            journal,
94            next_journal_seq,
95        }
96    }
97
98    /// Validates the application::hooks invariants and returns a typed
99    /// error on failure. Validation is pure and does not perform I/O,
100    /// dispatch, journal appends, or adapter calls.
101    pub fn validate_package_hooks(&self, specs: &[HookSpec]) -> Result<(), AgentError> {
102        validate_package_hooks(specs, self.registry)
103    }
104
105    /// Invoke point.
106    /// This invokes the configured hooks for one hook point and returns their responses; hook
107    /// side effects stay behind the registered hook executors.
108    pub fn invoke_point(
109        &mut self,
110        specs: &[HookSpec],
111        point: HookPoint,
112        context: HookLifecycleContext,
113        view: HookView,
114    ) -> Result<Vec<HookInvocationOutcome>, AgentError> {
115        let hooks = ordered_hooks_for_point(specs, point);
116        let mut outcomes = Vec::with_capacity(hooks.len());
117        for spec in hooks {
118            outcomes.push(self.invoke_one(&spec, &context, view.clone())?);
119        }
120        Ok(outcomes)
121    }
122
123    fn invoke_one(
124        &mut self,
125        spec: &HookSpec,
126        context: &HookLifecycleContext,
127        view: HookView,
128    ) -> Result<HookInvocationOutcome, AgentError> {
129        spec.validate()?;
130        let invocation_id = format!("hook.invocation.{}", self.next_journal_seq);
131        if context.cancellation.cancelled {
132            return Ok(HookInvocationOutcome::from_record(
133                spec,
134                HookInvocationStatus::Cancelled,
135                HookRecord::cancelled(spec, invocation_id),
136            ));
137        }
138
139        let executor = self.registry.resolve(&spec.executor_ref).ok_or_else(|| {
140            fail_closed_error(
141                spec,
142                context,
143                AgentErrorKind::HostConfigurationNeeded,
144                "missing hook executor ref",
145            )
146        })?;
147        let input = HookInput {
148            hook_id: spec.hook_id.clone(),
149            point: spec.point.clone(),
150            run_id: context.run_id.clone(),
151            agent_id: context.agent_id.clone(),
152            turn_id: context.turn_id.clone(),
153            attempt_id: context.attempt_id.clone(),
154            source: SourceRef::with_kind(crate::domain::SourceKind::Hook, spec.hook_id.as_str()),
155            destination: context.destination.clone(),
156            package_fingerprint: context.package_fingerprint.clone(),
157            view,
158            policy_refs: vec![spec.policy_ref.clone()],
159            cancellation: context.cancellation.clone(),
160        };
161
162        let hook_result = executor.invoke(input);
163        let execution = match hook_result {
164            Ok(execution) => execution,
165            Err(error) if !spec.is_security_relevant() && !spec.failure.fails_closed() => {
166                return Ok(HookInvocationOutcome::from_record(
167                    spec,
168                    HookInvocationStatus::FailedOpen,
169                    HookRecord::failed(spec, invocation_id, error.context().message),
170                ));
171            }
172            Err(error) => {
173                return Err(fail_closed_error(
174                    spec,
175                    context,
176                    error.kind(),
177                    error.context().message,
178                ));
179            }
180        };
181
182        if execution.elapsed_ms > spec.timeout.timeout_ms {
183            return self.handle_timeout(spec, context, invocation_id, execution.elapsed_ms);
184        }
185
186        self.handle_response(spec, context, invocation_id, execution.response)
187    }
188
189    fn handle_timeout(
190        &self,
191        spec: &HookSpec,
192        context: &HookLifecycleContext,
193        invocation_id: String,
194        elapsed_ms: u64,
195    ) -> Result<HookInvocationOutcome, AgentError> {
196        if !spec.is_security_relevant() && !spec.failure.fails_closed() {
197            return Ok(HookInvocationOutcome::from_record(
198                spec,
199                HookInvocationStatus::TimedOutFailOpen,
200                HookRecord::timeout(spec, invocation_id, elapsed_ms),
201            ));
202        }
203        Err(fail_closed_error(
204            spec,
205            context,
206            AgentErrorKind::Timeout,
207            "hook timed out before guarded lifecycle transition",
208        ))
209    }
210
211    fn handle_response(
212        &mut self,
213        spec: &HookSpec,
214        context: &HookLifecycleContext,
215        invocation_id: String,
216        response: HookResponse,
217    ) -> Result<HookInvocationOutcome, AgentError> {
218        let response_class = response.response_class();
219        if !spec
220            .point
221            .allowed_response_classes()
222            .contains(&response_class)
223        {
224            return Ok(HookInvocationOutcome::rejected(
225                spec,
226                invocation_id,
227                response_class,
228                HookInvocationStatus::RejectedPointMatrix,
229            ));
230        }
231        if !spec
232            .mutation_rights
233            .allows_response_class(response_class.clone())
234        {
235            return Ok(HookInvocationOutcome::rejected(
236                spec,
237                invocation_id,
238                response_class,
239                HookInvocationStatus::RejectedMutationRight,
240            ));
241        }
242
243        if !response.changes_behavior() {
244            return Ok(HookInvocationOutcome::from_record(
245                spec,
246                HookInvocationStatus::Completed,
247                HookRecord::completed(spec, invocation_id, 0),
248            ));
249        }
250
251        let journal_seq = self.next_seq_block(3);
252        let record_id = format!("journal.hook.{}.{}", spec.hook_id.as_str(), journal_seq);
253        let plan = HookMutationJournalPlan::accepted_response(
254            journal_seq,
255            record_id,
256            context.run_id.clone(),
257            context.agent_id.clone(),
258            context.source.clone(),
259            spec,
260            invocation_id.clone(),
261            response_class.clone(),
262            context.package_fingerprint.as_str(),
263        );
264        self.journal
265            .append(plan.hook_journal_record.clone())
266            .map_err(|error| {
267                fail_closed_error(
268                    spec,
269                    context,
270                    error.kind(),
271                    "hook response journal append failed before apply",
272                )
273            })?;
274        let _intent_cursor = self
275            .journal
276            .append(plan.intent_journal_record.clone())
277            .map_err(|error| {
278                fail_closed_error(
279                    spec,
280                    context,
281                    error.kind(),
282                    "hook mutation journal append failed before apply",
283                )
284            })?;
285        let terminal_cursor = self
286            .journal
287            .append(plan.result_journal_record.clone())
288            .map_err(|error| {
289                fail_closed_error(
290                    spec,
291                    context,
292                    error.kind(),
293                    "hook mutation terminal result append failed before apply",
294                )
295            })?;
296        Ok(HookInvocationOutcome {
297            hook_id: spec.hook_id.clone(),
298            status: HookInvocationStatus::AppliedJournaledMutation,
299            response_class: Some(response_class),
300            journal_cursor: Some(terminal_cursor),
301            journaled_before_apply: true,
302            record: plan.hook_record,
303        })
304    }
305
306    fn next_seq_block(&mut self, width: u64) -> u64 {
307        let seq = self.next_journal_seq;
308        self.next_journal_seq += width;
309        seq
310    }
311}
312
313/// Validates the application::hooks invariants and returns a typed
314/// error on failure. Validation is pure and does not perform I/O,
315/// dispatch, journal appends, or adapter calls.
316pub fn validate_package_hooks<R>(specs: &[HookSpec], registry: &R) -> Result<(), AgentError>
317where
318    R: HookExecutorRegistry,
319{
320    validate_hook_specs(specs)?;
321    for spec in specs {
322        if !registry.contains(&spec.executor_ref) {
323            return Err(AgentError::new(
324                AgentErrorKind::InvalidPackage,
325                RetryClassification::HostConfigurationNeeded,
326                format!(
327                    "hook executor {} is not resolved before start_run",
328                    spec.executor_ref.as_str()
329                ),
330            ));
331        }
332    }
333    Ok(())
334}
335
336#[derive(Clone, Debug, Eq, PartialEq)]
337/// Holds hook invocation outcome application-layer state or configuration.
338/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
339pub struct HookInvocationOutcome {
340    /// Stable hook id used for typed lineage, lookup, or dedupe.
341    pub hook_id: crate::package_hooks::HookId,
342    /// Finite status for this record or lifecycle stage.
343    pub status: HookInvocationStatus,
344    /// Classification value for response class.
345    /// Policy and projection paths use it for finite routing decisions.
346    pub response_class: Option<HookResponseClass>,
347    /// Cursor identifying a replay, export, or subscription position.
348    /// Use it to resume without widening the original scope.
349    pub journal_cursor: Option<JournalCursor>,
350    /// Whether journaled before apply is enabled.
351    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
352    pub journaled_before_apply: bool,
353    /// Record used by this record or request.
354    pub record: HookRecord,
355}
356
357impl HookInvocationOutcome {
358    fn from_record(spec: &HookSpec, status: HookInvocationStatus, record: HookRecord) -> Self {
359        Self {
360            hook_id: spec.hook_id.clone(),
361            status,
362            response_class: None,
363            journal_cursor: None,
364            journaled_before_apply: false,
365            record,
366        }
367    }
368
369    fn rejected(
370        spec: &HookSpec,
371        invocation_id: String,
372        response_class: HookResponseClass,
373        status: HookInvocationStatus,
374    ) -> Self {
375        let decision = match status {
376            HookInvocationStatus::RejectedMutationRight => {
377                crate::hook_records::HookResponseDecision::RejectedMutationRight
378            }
379            HookInvocationStatus::RejectedPointMatrix => {
380                crate::hook_records::HookResponseDecision::RejectedPointMatrix
381            }
382            _ => crate::hook_records::HookResponseDecision::RejectedPolicy,
383        };
384        Self {
385            hook_id: spec.hook_id.clone(),
386            status,
387            response_class: Some(response_class.clone()),
388            journal_cursor: None,
389            journaled_before_apply: false,
390            record: HookRecord::response_decision(
391                spec,
392                invocation_id,
393                decision,
394                response_class,
395                Vec::new(),
396            ),
397        }
398    }
399}
400
401#[derive(Clone, Debug, Eq, PartialEq)]
402/// Enumerates the finite hook invocation status cases.
403/// Serialized names are part of the SDK contract; update fixtures when variants change.
404pub enum HookInvocationStatus {
405    /// Use this variant when the contract needs to represent completed; selecting it has no side effect by itself.
406    Completed,
407    /// Use this variant when the contract needs to represent applied journaled mutation; selecting it has no side effect by itself.
408    AppliedJournaledMutation,
409    /// Use this variant when the contract needs to represent rejected mutation right; selecting it has no side effect by itself.
410    RejectedMutationRight,
411    /// Use this variant when the contract needs to represent rejected point matrix; selecting it has no side effect by itself.
412    RejectedPointMatrix,
413    /// Use this variant when the contract needs to represent timed out fail open; selecting it has no side effect by itself.
414    TimedOutFailOpen,
415    /// Use this variant when the contract needs to represent failed open; selecting it has no side effect by itself.
416    FailedOpen,
417    /// Use this variant when the contract needs to represent cancelled; selecting it has no side effect by itself.
418    Cancelled,
419}
420
421fn fail_closed_error(
422    spec: &HookSpec,
423    context: &HookLifecycleContext,
424    kind: AgentErrorKind,
425    message: impl Into<String>,
426) -> AgentError {
427    let kind = match spec.failure {
428        crate::package_hooks::HookFailurePolicy::Deny => AgentErrorKind::PolicyDenial,
429        crate::package_hooks::HookFailurePolicy::InterruptRun
430        | crate::package_hooks::HookFailurePolicy::FailRun => AgentErrorKind::HookFailure,
431        crate::package_hooks::HookFailurePolicy::FailOpenObserveOnly => kind,
432    };
433    AgentError::new(kind, RetryClassification::RepairNeeded, message).with_causal_ids(CausalIds {
434        run_id: Some(context.run_id.clone()),
435        ..CausalIds::default()
436    })
437}