use std::collections::BTreeMap;
use std::sync::Arc;
use futures::future::BoxFuture;
use serde_json::{Map, Value};
use super::{HookCtx, HookError, StepHandler};
use crate::tool::SkillEntry;
pub struct BuiltinRegistry {
step_factories: BTreeMap<String, Box<dyn Fn() -> Arc<dyn StepHandler> + Send + Sync>>,
}
impl BuiltinRegistry {
pub fn defaults() -> Self {
let mut reg = Self {
step_factories: BTreeMap::new(),
};
reg.register_step("tracing-audit", || Arc::new(TracingAuditHook));
reg.register_step("redact-secrets", || Arc::new(RedactSecretsHook));
reg
}
pub fn register_step<F>(&mut self, name: &str, factory: F)
where
F: Fn() -> Arc<dyn StepHandler> + Send + Sync + 'static,
{
self.step_factories
.insert(name.to_string(), Box::new(factory));
}
pub fn lookup_step(&self, name: &str) -> Option<Arc<dyn StepHandler>> {
self.step_factories.get(name).map(|f| f())
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.step_factories.keys().map(String::as_str)
}
}
impl Default for BuiltinRegistry {
fn default() -> Self {
Self::defaults()
}
}
pub struct TracingAuditHook;
impl StepHandler for TracingAuditHook {
fn handle_step<'a>(
&'a self,
envelope: &'a Value,
_ctx: HookCtx<'a>,
) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
Box::pin(async move {
let tool = envelope.get("tool").and_then(Value::as_str).unwrap_or("?");
let is_error = envelope
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
tracing::info!(
target: "defect_agent::hooks::audit",
tool = %tool,
outcome = if is_error { "error" } else { "ok" },
"tool call completed",
);
Ok(None)
})
}
}
pub struct RedactSecretsHook;
const SECRET_KEY_NEEDLES: &[&str] = &[
"password",
"secret",
"token",
"api_key",
"apikey",
"authorization",
];
impl StepHandler for RedactSecretsHook {
fn handle_step<'a>(
&'a self,
envelope: &'a Value,
_ctx: HookCtx<'a>,
) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
let verdict = envelope
.get("args")
.and_then(Value::as_object)
.map(redact_object)
.filter(|r| r.changed)
.map(|r| serde_json::json!({ "args": Value::Object(r.value) }));
Box::pin(async move { Ok(verdict) })
}
}
struct Redacted {
value: Map<String, Value>,
changed: bool,
}
fn redact_object(obj: &Map<String, Value>) -> Redacted {
let mut out = Map::with_capacity(obj.len());
let mut changed = false;
for (key, value) in obj {
if key_is_secret(key) {
out.insert(key.clone(), Value::String("***".to_string()));
changed = true;
} else {
out.insert(key.clone(), value.clone());
}
}
Redacted {
value: out,
changed,
}
}
fn key_is_secret(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
SECRET_KEY_NEEDLES
.iter()
.any(|needle| lower.contains(needle))
}
pub struct SkillManifestHook {
skills: Arc<BTreeMap<String, SkillEntry>>,
}
impl SkillManifestHook {
pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
Self { skills }
}
}
fn render_skill_manifest(skills: &BTreeMap<String, SkillEntry>) -> Option<String> {
if skills.is_empty() {
return None;
}
let mut out = String::from(
"## Available Skills\n\n\
Load a skill's full instructions with the `skill` tool (by name) when the task matches:\n",
);
for (name, entry) in skills {
out.push_str(&format!("- **{name}**: {}\n", entry.description));
}
for (name, entry) in skills {
if entry.always {
out.push_str(&format!("\n## Skill: {name}\n\n{}\n", entry.body));
}
}
Some(out)
}
impl StepHandler for SkillManifestHook {
fn handle_step<'a>(
&'a self,
_envelope: &'a Value,
_ctx: HookCtx<'a>,
) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
let verdict = render_skill_manifest(&self.skills)
.map(|manifest| serde_json::json!({ "additional_context": [manifest] }));
Box::pin(async move { Ok(verdict) })
}
}
pub struct SkillTriggersHook {
skills: Arc<BTreeMap<String, SkillEntry>>,
}
impl SkillTriggersHook {
pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
Self { skills }
}
}
fn extract_path_tokens(prompt: &str) -> Vec<String> {
prompt
.split_whitespace()
.filter_map(|raw| {
let trimmed = raw.trim_matches(|c: char| {
c == '`' || c == '"' || c == '\'' || c == '(' || c == ')' || c == '[' || c == ']'
});
let trimmed = trimmed.trim_end_matches([',', '.', ':', ';']);
let token = trimmed.strip_prefix("./").unwrap_or(trimmed);
if token.is_empty() {
return None;
}
if is_path_like(token) {
Some(token.to_string())
} else {
None
}
})
.collect()
}
fn is_path_like(token: &str) -> bool {
if token.contains('/') {
return true;
}
match token.rsplit_once('.') {
Some((stem, ext)) => {
!stem.is_empty() && !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
}
None => false,
}
}
fn skill_triggered(entry: &SkillEntry, prompt_lower: &str, path_tokens: &[String]) -> bool {
let keyword_hit = entry
.triggers
.keywords
.iter()
.any(|kw| !kw.is_empty() && prompt_lower.contains(&kw.to_ascii_lowercase()));
if keyword_hit {
return true;
}
match &entry.triggers.globs {
Some(set) => path_tokens.iter().any(|t| set.is_match(t)),
None => false,
}
}
impl StepHandler for SkillTriggersHook {
fn handle_step<'a>(
&'a self,
envelope: &'a Value,
_ctx: HookCtx<'a>,
) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
let prompt = envelope.get("input").and_then(Value::as_str).unwrap_or("");
let prompt_lower = prompt.to_ascii_lowercase();
let path_tokens = extract_path_tokens(prompt);
let hints: Vec<String> = self
.skills
.iter()
.filter(|(_, e)| !e.always)
.filter(|(_, e)| skill_triggered(e, &prompt_lower, &path_tokens))
.map(|(name, _)| {
format!(
"Detected skill `{name}` is relevant to the current task; \
load it with the `skill` tool when needed."
)
})
.collect();
let verdict = (!hints.is_empty()).then(|| serde_json::json!({ "prepend_input": hints }));
Box::pin(async move { Ok(verdict) })
}
}
pub struct GoalGate {
goal: Arc<crate::session::GoalState>,
}
impl GoalGate {
pub fn new(goal: Arc<crate::session::GoalState>) -> Self {
Self { goal }
}
fn briefing(&self) -> String {
format!(
"## Goal\n\n\
You are running in goal-driven mode. Your objective:\n\n{}\n\n\
Work autonomously across as many turns as needed to achieve this goal. \
When — and only when — the goal is genuinely and fully achieved, call the \
`goal_done` tool to finish the run. Do not call it prematurely. If you stop \
without calling `goal_done`, you will be prompted to keep working.",
self.goal.objective()
)
}
}
impl StepHandler for GoalGate {
fn handle_step<'a>(
&'a self,
envelope: &'a Value,
_ctx: HookCtx<'a>,
) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
let event = envelope
.get("hook_event")
.and_then(Value::as_str)
.unwrap_or("");
let verdict = match event {
"after_session_enter" => {
serde_json::json!({ "additional_context": [self.briefing()] })
}
_ if self.goal.is_reached() => serde_json::json!({ "control": "proceed" }),
_ => serde_json::json!({
"control": "continue",
"additional_context": [format!(
"The goal \"{}\" is not yet complete. Keep working toward it. \
Once it is genuinely achieved, call the `goal_done` tool to finish.",
self.goal.objective()
)],
}),
};
Box::pin(async move { Ok(Some(verdict)) })
}
}
#[cfg(test)]
mod tests;