Skip to main content

defect_cli/
hooks.rs

1//! Translates `defect-config` hook configuration into the agent's [`DefaultHookEngine`].
2//!
3//! Hook assembly — the agent crate does not depend on the config crate, so translation
4//! happens during CLI assembly; this is also where we fail-fast with "unknown builtin
5//! name".
6//!
7//! All three handler variants are wired up:
8//! - `Builtin { name }` → looks up [`BuiltinRegistry`] by name; unknown name triggers
9//!   [`HookEngineBuildError::UnknownBuiltin`] fail-fast
10//! - `Command(_)` → [`CommandHandler::new`] (either direct argv spawn or explicit shell)
11//! - `Prompt(_)` → [`PromptHandler::new`]; during CLI assembly the current default
12//!   provider/model is injected (when `HookPromptSpec.model = None`, falls back to the
13//!   session default model)
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use defect_agent::hooks::builtin::BuiltinRegistry;
19use defect_agent::hooks::command::{CommandHandler, CommandSpec, ShellKind as AgentShellKind};
20use defect_agent::hooks::prompt::{PromptHandler, PromptRender as AgentPromptRender, PromptSpec};
21use defect_agent::hooks::{
22    DefaultHookEngine, HandlerTable, HookMatcher as AgentHookMatcher, StepHandler, StepHandlerEntry,
23};
24use defect_agent::llm::{LlmProvider, ProviderRegistry};
25use defect_config::{
26    HookCommandSpec, HookHandlerSpec, HookMatcher as ConfigHookMatcher, HookPromptRender,
27    HookPromptSpec, HookShellKind, HooksConfig,
28};
29
30/// Build errors.
31///
32/// `Configuration` is a fallback for invalid combinations not caught by the configuration
33/// layer (in theory the config loader has already fail-fast once).
34#[derive(Debug, thiserror::Error)]
35pub enum HookEngineBuildError {
36    #[error("unknown builtin hook handler `{name}` (available: {available})")]
37    UnknownBuiltin { name: String, available: String },
38
39    #[error("hook configuration invalid: {0}")]
40    Configuration(String),
41}
42
43/// Runtime context needed when assembling the hook engine.
44///
45/// `Prompt` handlers need an LLM provider; `registry` provides model-id-based provider
46/// selection and a fallback model when the hook does not specify one.
47pub struct HookEngineCtx<'a> {
48    pub registry: &'a Arc<ProviderRegistry>,
49    pub default_model: &'a str,
50}
51
52/// Constructs a [`HandlerTable`] from the `[hooks]` section (excluding auto-mounted
53/// builtins).
54fn build_handler_table(
55    hooks: &HooksConfig,
56    builtins: &BuiltinRegistry,
57    rt: &HookEngineCtx<'_>,
58) -> Result<HandlerTable, HookEngineBuildError> {
59    let mut table = HandlerTable::empty();
60
61    // The event bucket keys in config are the step's `event_name` (1:1, already validated
62    // by the config layer).
63    // `event_name` must be `&'static str` (the bucket key of `HandlerTable`) — taken from
64    // `step::ALL_EVENT_NAMES` as a static string.
65    for (event_name, entries) in &hooks.buckets {
66        let Some(static_name) = static_event_name(event_name) else {
67            // The config layer already fail-fasts on unknown event names; skip here as a
68            // safety net.
69            continue;
70        };
71        for entry in entries {
72            let matcher = translate_matcher(&entry.matcher);
73            let (handler, timeout) = build_handler(&entry.handler, builtins, rt)?;
74            let mut hook = StepHandlerEntry::new(matcher, handler).with_name(entry.name.clone());
75            if let Some(t) = timeout {
76                hook = hook.with_timeout(t);
77            }
78            table.push_step(static_name, hook);
79        }
80    }
81    Ok(table)
82}
83
84/// Build a [`DefaultHookEngine`] from the `[hooks]` section and the builtin registry.
85pub fn build_hook_engine(
86    hooks: &HooksConfig,
87    builtins: &BuiltinRegistry,
88    rt: &HookEngineCtx<'_>,
89) -> Result<DefaultHookEngine, HookEngineBuildError> {
90    let table = build_handler_table(hooks, builtins, rt)?;
91    let engine = DefaultHookEngine::new();
92    engine.reload(table);
93    Ok(engine)
94}
95
96/// Converts the config's event name (owned `String`) to the `&'static str` used by the
97/// step model.
98fn static_event_name(name: &str) -> Option<&'static str> {
99    defect_agent::hooks::step::ALL_EVENT_NAMES
100        .iter()
101        .copied()
102        .find(|&n| n == name)
103}
104
105fn build_handler(
106    spec: &HookHandlerSpec,
107    builtins: &BuiltinRegistry,
108    rt: &HookEngineCtx<'_>,
109) -> Result<(Arc<dyn StepHandler>, Option<Duration>), HookEngineBuildError> {
110    match spec {
111        HookHandlerSpec::Builtin { name } => {
112            let handler = builtins.lookup_step(name).ok_or_else(|| {
113                let available = builtins.names().collect::<Vec<_>>().join(", ");
114                HookEngineBuildError::UnknownBuiltin {
115                    name: name.clone(),
116                    available,
117                }
118            })?;
119            Ok((handler, None))
120        }
121        HookHandlerSpec::Command(cmd) => {
122            let agent_spec = translate_command(cmd);
123            let handler = CommandHandler::new(agent_spec);
124            let timeout = handler.timeout();
125            Ok((Arc::new(handler) as Arc<dyn StepHandler>, timeout))
126        }
127        HookHandlerSpec::Prompt(prompt) => {
128            let provider = resolve_prompt_provider(prompt, rt)?;
129            let agent_spec = translate_prompt(prompt, provider, rt.default_model.to_string());
130            let handler = PromptHandler::new(agent_spec);
131            let timeout = handler.timeout();
132            Ok((Arc::new(handler) as Arc<dyn StepHandler>, timeout))
133        }
134        // `HookHandlerSpec` is non_exhaustive — new variants force the CLI to add an
135        // explicit branch, preventing silent no-ops.
136        other => Err(HookEngineBuildError::Configuration(format!(
137            "unrecognized hook handler form: {other:?}"
138        ))),
139    }
140}
141
142fn resolve_prompt_provider(
143    spec: &HookPromptSpec,
144    rt: &HookEngineCtx<'_>,
145) -> Result<Arc<dyn LlmProvider>, HookEngineBuildError> {
146    let model_id = spec.model.as_deref().unwrap_or(rt.default_model);
147    // The `model` field of a prompt hook has no provider dimension — take the first entry
148    // that declares it by bare id.
149    let entry = rt.registry.first_entry_for_model(model_id).ok_or_else(|| {
150        HookEngineBuildError::Configuration(format!(
151            "prompt hook references unknown model `{model_id}` (no provider registered for it)"
152        ))
153    })?;
154    Ok(Arc::clone(entry.provider()))
155}
156
157fn translate_matcher(m: &ConfigHookMatcher) -> AgentHookMatcher {
158    let mut out = AgentHookMatcher::default();
159    out.tool = m.tool.clone();
160    out.tool_glob = m.tool_glob.clone();
161    out.safety = m.safety.clone();
162    out
163}
164
165fn translate_command(spec: &HookCommandSpec) -> CommandSpec {
166    match spec {
167        HookCommandSpec::Argv {
168            argv,
169            argv_windows,
170            cwd,
171            env,
172            timeout_sec,
173        } => CommandSpec::Argv {
174            argv: argv.clone(),
175            argv_windows: argv_windows.clone(),
176            cwd: cwd.clone(),
177            env: env.clone(),
178            timeout_sec: *timeout_sec,
179        },
180        HookCommandSpec::Shell {
181            shell,
182            command,
183            cwd,
184            env,
185            timeout_sec,
186        } => CommandSpec::Shell {
187            shell: translate_shell(shell),
188            command: command.clone(),
189            cwd: cwd.clone(),
190            env: env.clone(),
191            timeout_sec: *timeout_sec,
192        },
193        // Fallback for `non_exhaustive` – conservatively produce an empty argv on unknown
194        // variants, letting the agent layer report the error.
195        other => {
196            let _ = other;
197            CommandSpec::Argv {
198                argv: Vec::new(),
199                argv_windows: None,
200                cwd: None,
201                env: Default::default(),
202                timeout_sec: None,
203            }
204        }
205    }
206}
207
208fn translate_shell(shell: &HookShellKind) -> AgentShellKind {
209    match shell {
210        HookShellKind::Sh => AgentShellKind::Sh,
211        HookShellKind::Bash => AgentShellKind::Bash,
212        HookShellKind::Pwsh => AgentShellKind::Pwsh,
213        HookShellKind::Cmd => AgentShellKind::Cmd,
214        HookShellKind::Custom { program, args } => AgentShellKind::Custom {
215            program: program.clone(),
216            args: args.clone(),
217        },
218        // Fallback for non_exhaustive variant
219        other => {
220            let _ = other;
221            AgentShellKind::Sh
222        }
223    }
224}
225
226fn translate_prompt(
227    spec: &HookPromptSpec,
228    provider: Arc<dyn LlmProvider>,
229    fallback_model: String,
230) -> PromptSpec {
231    PromptSpec {
232        provider,
233        model: spec.model.clone(),
234        fallback_model,
235        system: spec.system.clone(),
236        render: match &spec.render {
237            HookPromptRender::Json => AgentPromptRender::Json,
238            HookPromptRender::Template { template } => AgentPromptRender::Template {
239                template: template.clone(),
240            },
241            other => {
242                // Fallback for non_exhaustive — default to Json.
243                let _ = other;
244                AgentPromptRender::Json
245            }
246        },
247        timeout_sec: spec.timeout_sec,
248    }
249}
250
251/// Wraps a hook engine in an [`Arc`] so that the session/turn main loop can uniformly
252/// hold an `Arc<dyn HookEngine>`. When `HooksConfig::is_empty`, uses
253/// [`defect_agent::hooks::NoopHookEngine`] for a zero-overhead path.
254pub fn build_engine_arc(
255    hooks: &HooksConfig,
256    builtins: &BuiltinRegistry,
257    rt: &HookEngineCtx<'_>,
258) -> Result<Arc<dyn defect_agent::hooks::HookEngine>, HookEngineBuildError> {
259    if hooks.is_empty() {
260        return Ok(Arc::new(defect_agent::hooks::NoopHookEngine));
261    }
262    let engine = build_hook_engine(hooks, builtins, rt)?;
263    Ok(Arc::new(engine))
264}
265
266/// Hook engine for the main session: automatically mounts two skill builtins on top of
267/// the user's `[hooks]` configuration (when any skill is discovered) —
268/// - `skill-manifest` → `after_session_enter`: injects the L1 manifest + always-on body;
269/// - `skill-triggers` → `before_ingest`: auto-activates relevant skills based on the
270///   prompt.
271///
272/// This makes "auto-activation" work out of the box without requiring users to write
273/// `[[hooks.*]]` manually. Both hooks have empty matchers (they match all triggers under
274/// that event). When the skill index is empty, nothing is mounted (keeping zero
275/// overhead), and when the user also has no `[hooks]` configured, it falls through to
276/// [`NoopHookEngine`](defect_agent::hooks::NoopHookEngine). Sub-agent profiles do not
277/// take this path (they still use [`build_engine_arc`]), so skill hooks do not leak into
278/// sub-agents.
279pub fn build_main_session_engine(
280    hooks: &HooksConfig,
281    builtins: &BuiltinRegistry,
282    rt: &HookEngineCtx<'_>,
283    skills: &Arc<std::collections::BTreeMap<String, defect_agent::tool::SkillEntry>>,
284    goal: Option<&Arc<defect_agent::session::GoalState>>,
285) -> Result<Arc<dyn defect_agent::hooks::HookEngine>, HookEngineBuildError> {
286    let mount_skills = !skills.is_empty();
287    if hooks.is_empty() && !mount_skills && goal.is_none() {
288        return Ok(Arc::new(defect_agent::hooks::NoopHookEngine));
289    }
290
291    let mut table = build_handler_table(hooks, builtins, rt)?;
292    if mount_skills {
293        use defect_agent::hooks::builtin::{SkillManifestHook, SkillTriggersHook};
294        table.push_step(
295            "after_session_enter",
296            StepHandlerEntry::new(
297                AgentHookMatcher::default(),
298                Arc::new(SkillManifestHook::new(skills.clone())),
299            )
300            .with_name(Some("skill-manifest".to_string())),
301        );
302        table.push_step(
303            "before_ingest",
304            StepHandlerEntry::new(
305                AgentHookMatcher::default(),
306                Arc::new(SkillTriggersHook::new(skills.clone())),
307            )
308            .with_name(Some("skill-triggers".to_string())),
309        );
310    }
311    // `--goal` mode: attach a `GoalGate` to two events — `after_session_enter` injects
312    // the goal description and the `goal_done` contract (active from turn 1, so the model
313    // knows from startup that it must call `goal_done` upon completion), and
314    // `before_turn_end` drives the "exit only when achieved" loop. Both mount points
315    // share the same `GoalState`.
316    if let Some(goal) = goal {
317        use defect_agent::hooks::builtin::GoalGate;
318        table.push_step(
319            "after_session_enter",
320            StepHandlerEntry::new(
321                AgentHookMatcher::default(),
322                Arc::new(GoalGate::new(goal.clone())),
323            )
324            .with_name(Some("goal-gate".to_string())),
325        );
326        table.push_step(
327            "before_turn_end",
328            StepHandlerEntry::new(
329                AgentHookMatcher::default(),
330                Arc::new(GoalGate::new(goal.clone())),
331            )
332            .with_name(Some("goal-gate".to_string())),
333        );
334    }
335
336    let engine = DefaultHookEngine::new();
337    engine.reload(table);
338    Ok(Arc::new(engine))
339}
340
341#[cfg(test)]
342mod tests;