1use 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#[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
43pub struct HookEngineCtx<'a> {
48 pub registry: &'a Arc<ProviderRegistry>,
49 pub default_model: &'a str,
50}
51
52fn build_handler_table(
55 hooks: &HooksConfig,
56 builtins: &BuiltinRegistry,
57 rt: &HookEngineCtx<'_>,
58) -> Result<HandlerTable, HookEngineBuildError> {
59 let mut table = HandlerTable::empty();
60
61 for (event_name, entries) in &hooks.buckets {
66 let Some(static_name) = static_event_name(event_name) else {
67 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
84pub 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
96fn 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 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 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 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 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 let _ = other;
244 AgentPromptRender::Json
245 }
246 },
247 timeout_sec: spec.timeout_sec,
248 }
249}
250
251pub 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
266pub 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 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;