defect-agent 0.1.0-alpha.4

Core agent runtime for defect: turn loop, context compaction, tools and session orchestration.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Builtin hook handlers.
//!
//! In-process Rust handlers with zero external dependencies. During CLI assembly, they
//! are looked up by name in [`BuiltinRegistry`], instantiated, and registered into
//! [`super::HandlerTable`] of `DefaultHookEngine`.

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;

/// Registry mapping builtin handler names to factory closures.
///
/// When the CLI assembles `DefaultHookEngine`, it feeds `HookHandlerSpec::Builtin { name
/// }` to
/// [`Self::lookup_step`]. Unknown names fail fast at config-load time, so users don't
/// discover
/// typos mid-turn.
///
/// The factory signature is `Fn() -> Arc<dyn HookHandler>`: handlers have no per-config
/// parameters, and multiple `[[hooks.*]]` entries referencing the same builtin share a
/// single
/// `Arc`. If a builtin later needs configuration parameters, upgrade `name` to a
/// structured
/// enum and switch the registry to `match` dispatch.
pub struct BuiltinRegistry {
    /// A map from name to `Arc<dyn StepHandler>` factory.
    step_factories: BTreeMap<String, Box<dyn Fn() -> Arc<dyn StepHandler> + Send + Sync>>,
}

impl BuiltinRegistry {
    /// Default registry: `tracing-audit` + `redact-secrets`.
    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
    }

    /// Register a builtin step handler factory. Duplicate names overwrite previous
    /// entries, allowing tests to stub and replace default behavior.
    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));
    }

    /// Look up a step handler by name. `None` means the configuration layer should
    /// fail-fast with an error.
    pub fn lookup_step(&self, name: &str) -> Option<Arc<dyn StepHandler>> {
        self.step_factories.get(name).map(|f| f())
    }

    /// Lists registered builtin names, used by the `defect hooks list` CLI.
    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()
    }
}

// tracing-audit

/// Converts `Post*ToolUse` events into structured tracing records.
///
/// Intended to be attached to `[[hooks.post_tool_use]]` /
/// `[[hooks.post_tool_use_failure]]` for an audit trail; attaching it to other events
/// will cause [`StepHandler::handle_step`] to simply `Pass` through.
pub struct TracingAuditHook;

impl StepHandler for TracingAuditHook {
    /// Step model: consumes an `after_tool_apply` envelope `{tool, is_error}`, writes a
    /// structured audit log, and produces no verdict.
    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)
        })
    }
}

// ---------------------------------------------------------------------------
// redact-secrets
// ---------------------------------------------------------------------------

/// On `PreToolUse`, performs in-place replacement of likely sensitive fields in `args`.
///
/// Matches (case-insensitive substring): `password` / `secret` / `token` / `api_key`
/// / `apikey` / `authorization`. When matched, the field value is replaced with `"***"`
/// and patched into `args`.
///
/// Only operates when `args` is an `Object`; other shapes (arrays, strings) are left
/// untouched — the shape of `args` is defined by the tool itself, and deep recursive
/// rewriting could break tool semantics.
///
/// Does not handle `password=xxx` embedded inside a `bash` `command` string — that would
/// require shell lexing, which is beyond the stability guarantees of this builtin.
pub struct RedactSecretsHook;

const SECRET_KEY_NEEDLES: &[&str] = &[
    "password",
    "secret",
    "token",
    "api_key",
    "apikey",
    "authorization",
];

impl StepHandler for RedactSecretsHook {
    /// Step model: consumes the `before_tool_apply` envelope `{tool, args}`, redacts
    /// potentially sensitive fields in `args` in place, and returns a `{args:
    /// <redacted>}` verdict if any were found (the engine applies it back to the step,
    /// modifying `args`).
    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))
}

// ---------------------------------------------------------------------------
// skill-manifest
// ---------------------------------------------------------------------------

/// On `SessionStart`, appends the L1 manifest of available skills (`name + description`)
/// to the system prompt suffix, so the model is aware of which skills it can load on
/// demand via the `skill` tool.
///
/// This is the L1 injection point for progressive disclosure. Note that the
/// `skill` tool's own description already embeds the same catalog (see
/// [`crate::tool::SkillTool`]), so this hook is an **optional enhancement**: when
/// installed, it also places the manifest in the system prompt (more robust for clients
/// that do not count tool descriptions toward the attention budget). Both paths originate
/// from the same skill index, so they will not diverge.
///
/// Unlike other builtins, this handler holds a skill index and **cannot** be constructed
/// via the parameterless factory [`BuiltinRegistry::defaults`]. Instead, it is registered
/// during CLI assembly using a closure that captures the index (see `defect_cli::hooks`).
pub struct SkillManifestHook {
    skills: Arc<BTreeMap<String, SkillEntry>>,
}

impl SkillManifestHook {
    /// Constructs from a loaded skill index. The caller **must not** register this hook
    /// when `skills` is empty (the manifest would be an empty segment, wasting tokens).
    pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
        Self { skills }
    }
}

/// Renders the session-start injection: a level-1 manifest (name + description for every
/// skill) plus the full body of each `always` skill (always-on, injected directly into
/// the system prompt). Returns `None` for an empty index (no empty segment injected).
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));
    }
    // Always-on skills: inline the body of any skill marked `always: true` so the model
    // has those instructions from the start, without needing to call the `skill` tool.
    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 {
    /// In the step model, inject the L1 skill manifest as `additional_context` during
    /// `after_session_enter`
    /// (the engine applies it back to the step, appending it to the system prompt
    /// suffix).
    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) })
    }
}

// ---------------------------------------------------------------------------
// skill-triggers
// ---------------------------------------------------------------------------

/// On `before_ingest`, automatically activate relevant skills based on the user prompt.
/// When a match is found, insert a **L1 hint** (e.g. "Detected skill X relevance; use the
/// `skill` tool if needed") before the prompt, rather than injecting the full skill body.
/// This follows progressive disclosure: the model decides whether to actually load the
/// skill.
///
/// Trigger conditions (any one triggers):
/// - **keyword**: any of the skill's `triggers.keywords` is a case-insensitive substring
///   of the prompt text.
/// - **glob**: any "path-like token" extracted from the prompt text matches one of the
///   skill's `triggers.globs`.
///
/// Skills with `always` trigger are already injected in full at session start, so they
/// are skipped here to avoid duplicate hints.
///
/// Like [`SkillManifestHook`], this hook holds a skill index and is registered via a
/// closure that captures the index (see `defect_cli::hooks`).
pub struct SkillTriggersHook {
    skills: Arc<BTreeMap<String, SkillEntry>>,
}

impl SkillTriggersHook {
    /// Constructs from the already-loaded skill index. The caller **must not** register
    /// this hook when `skills` is empty.
    pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
        Self { skills }
    }
}

/// Extract path-like tokens from a prompt string (best-effort, no NLP).
///
/// Split on whitespace, strip surrounding quotes/backticks/brackets and trailing
/// punctuation. A token is considered a path if it either:
/// (1) contains `/` (e.g. `crates/agent/src/foo.rs`); or (2) ends with an extension
/// `xxx.ext` (e.g. `Cargo.toml` / `main.rs`). Strip leading `./`. Bare words (no `/` and
/// no extension) are not paths — they are left for keyword matching.
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()
}

/// Whether the token is "path-like": contains `/`, or matches `name.ext` (a dot followed
/// by one or more alphanumeric characters at the end).
fn is_path_like(token: &str) -> bool {
    if token.contains('/') {
        return true;
    }
    // Ending extension: at least one alphanumeric character after the last `.`, and the
    // dot is not at the start.
    match token.rsplit_once('.') {
        Some((stem, ext)) => {
            !stem.is_empty() && !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
        }
        None => false,
    }
}

/// Returns whether a single skill is activated by the prompt: keyword substring OR glob
/// matches a path token.
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 {
    /// In the `before_ingest` step, read the prompt text and, for each matched skill,
    /// prepend an L1 hint (a `prepend_input` verdict). Return `None` if no skill matches.
    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) })
    }
}

// ---------------------------------------------------------------------------
// goal-gate
// ---------------------------------------------------------------------------

/// The core hook for the `--goal` goal-driven loop, **subscribing to two events**
/// (dispatched via the `hook_event` envelope):
///
/// - `after_session_enter`: Injects the goal description + `goal_done` usage contract as
///   `additional_context` into the system prompt suffix — **effective from turn 1**. This
///   lets the model know the goal and that it must actively call `goal_done` upon
///   completion from the start, avoiding an extra wasted turn waiting for the first
///   voluntary stop.
/// - `before_turn_end`: On voluntary turn stop, reads
///   [`GoalState::is_reached`](crate::session::GoalState::is_reached): if reached (model
///   called `goal_done`) → `proceed` to end; otherwise → `continue` to extend the turn +
///   inject an English prompt reminder.
///
/// The hard cap on extensions is enforced by the turn loop's
/// [`crate::session::TurnConfig::max_hook_continues`] (mapped from `--max-turns`) — this
/// hook only checks "is it done?", it does not count extensions itself.
///
/// Like [`SkillManifestHook`], this is a stateful builtin (holds `Arc<GoalState>`) and
/// cannot be constructed via [`BuiltinRegistry::defaults`]'s parameterless factory —
/// during CLI assembly, a closure capturing the state is registered for both events under
/// `--goal` (see `defect_cli::hooks`).
pub struct GoalGate {
    goal: Arc<crate::session::GoalState>,
}

impl GoalGate {
    pub fn new(goal: Arc<crate::session::GoalState>) -> Self {
        Self { goal }
    }

    /// Injected into the system prompt from turn 1 onward: goal description + `goal_done`
    /// contract.
    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 {
    /// Dispatches on the envelope's `hook_event`:
    /// - `after_session_enter` → injects goal description and contract
    ///   (`additional_context`)
    /// - `before_turn_end` → if reached, proceed; otherwise continue with a prompt
    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()] })
            }
            // before_turn_end (and fallback): check if the goal is reached.
            _ 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;