Skip to content

Commit 2fef298

Browse files
feat: regex matchers, ContextFill hook, SessionEnd wiring
Regex matchers (Claude Code compat): - Replace glob/exact-match with regex for tool name matching - Edit|Write, mcp__memory__.*, Notebook.* patterns now work - Bash/Bash(pattern) special case preserved, also accepts 'shell' - Notification matcher also upgraded to regex - Removed glob dependency from Cargo.toml ContextFill hook (goose extension): - New HookEventKind::ContextFill fires when context reaches a configured percentage threshold (e.g., 70%) - Matcher is the threshold: {"matcher": "70", ...} - Fires once per threshold crossing, not every turn while above - Hook receives current_tokens, context_limit, fill_percentage - Checked after each LLM response using provider-reported token count - Output injected as context via inject_hook_context SessionEnd hook: - Fires after Stop hook at end of reply_internal - Side-effect only (cleanup, logging) — cannot block Tests (15 total): - 9 exit-code contract tests (unchanged) - 2 real-subprocess tests (unchanged) - 4 new regex matcher tests: alternation, wildcard, exact, invalid
1 parent 40640e2 commit 2fef298

File tree

5 files changed

+232
-16
lines changed

5 files changed

+232
-16
lines changed

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/goose/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ workspace = true
1616

1717
[dependencies]
1818
lru = { workspace = true }
19-
glob = "0.3"
19+
2020

2121
rmcp = { workspace = true, features = [
2222
"client",

crates/goose/src/agents/agent.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,21 @@ impl Agent {
14011401

14021402
if let Some(ref usage) = usage {
14031403
self.update_session_metrics(&session_config.id, session_config.schedule_id.clone(), usage, false).await?;
1404+
1405+
// Check ContextFill hooks after each LLM response
1406+
if let Some(total) = usage.usage.total_tokens {
1407+
let ctx_limit = self.provider().await?.get_model_config().context_limit();
1408+
if let Some(ctx) = hooks.check_context_fill(
1409+
&session_config.id,
1410+
total as usize,
1411+
ctx_limit,
1412+
&self.extension_manager,
1413+
&working_dir,
1414+
cancel_token.clone().unwrap_or_default(),
1415+
).await {
1416+
Self::inject_hook_context(&session_config.id, ctx, &session_manager, &mut conversation).await.ok();
1417+
}
1418+
}
14041419
}
14051420

14061421
if let Some(response) = response {
@@ -1948,6 +1963,20 @@ impl Agent {
19481963
)
19491964
.await;
19501965

1966+
// Fire SessionEnd hook after stop
1967+
let invocation = crate::hooks::HookInvocation::session_end(
1968+
session_id.clone(),
1969+
None,
1970+
);
1971+
let _ = hooks
1972+
.run(
1973+
invocation,
1974+
&self.extension_manager,
1975+
&working_dir,
1976+
cancel_token.clone().unwrap_or_default(),
1977+
)
1978+
.await;
1979+
19511980
if !last_assistant_text.is_empty() {
19521981
tracing::info!(target: "goose::agents::agent", trace_output = last_assistant_text.as_str());
19531982
}

crates/goose/src/hooks/mod.rs

Lines changed: 172 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ use tokio_util::sync::CancellationToken;
1313

1414
pub struct Hooks {
1515
settings: HookSettingsFile,
16+
/// Tracks which ContextFill thresholds have already fired this session.
17+
/// Prevents re-firing every turn while above the threshold.
18+
fired_context_thresholds: std::sync::Mutex<std::collections::HashSet<u32>>,
1619
}
1720

1821
impl Hooks {
@@ -21,7 +24,100 @@ impl Hooks {
2124
tracing::debug!("No hooks config loaded: {}", e);
2225
HookSettingsFile::default()
2326
});
24-
Self { settings }
27+
Self {
28+
settings,
29+
fired_context_thresholds: std::sync::Mutex::new(std::collections::HashSet::new()),
30+
}
31+
}
32+
33+
/// Check context fill and fire ContextFill hooks for any thresholds that have been crossed.
34+
/// Returns context to inject, if any.
35+
///
36+
/// Call this once per turn in the agent loop with the current token count.
37+
pub async fn check_context_fill(
38+
&self,
39+
session_id: &str,
40+
current_tokens: usize,
41+
context_limit: usize,
42+
extension_manager: &crate::agents::extension_manager::ExtensionManager,
43+
working_dir: &Path,
44+
cancel_token: CancellationToken,
45+
) -> Option<String> {
46+
if context_limit == 0 {
47+
return None;
48+
}
49+
50+
let fill_pct = ((current_tokens as f64 / context_limit as f64) * 100.0) as u32;
51+
52+
// Get configured ContextFill thresholds from settings
53+
let event_configs = self
54+
.settings
55+
.get_hooks_for_event(HookEventKind::ContextFill);
56+
if event_configs.is_empty() {
57+
return None;
58+
}
59+
60+
// Find thresholds that are newly crossed
61+
let mut new_thresholds = Vec::new();
62+
{
63+
let mut fired = self
64+
.fired_context_thresholds
65+
.lock()
66+
.unwrap_or_else(|e| e.into_inner());
67+
for config in event_configs {
68+
if let Some(pattern) = &config.matcher {
69+
if let Ok(threshold) = pattern.parse::<u32>() {
70+
if fill_pct >= threshold && !fired.contains(&threshold) {
71+
fired.insert(threshold);
72+
new_thresholds.push(threshold);
73+
}
74+
}
75+
}
76+
}
77+
}
78+
79+
if new_thresholds.is_empty() {
80+
return None;
81+
}
82+
83+
// Fire hooks for each newly crossed threshold.
84+
// fill_percentage is set to the threshold value (not the actual fill) so that
85+
// matches_config can use exact equality to route to the correct config entry.
86+
// The actual fill level is derivable from current_tokens / context_limit.
87+
let mut all_context = Vec::new();
88+
for threshold in new_thresholds {
89+
tracing::info!(
90+
"Context fill {}% crossed threshold {}%, firing hooks",
91+
fill_pct,
92+
threshold
93+
);
94+
let invocation = HookInvocation::context_fill(
95+
session_id.to_string(),
96+
current_tokens,
97+
context_limit,
98+
threshold,
99+
working_dir.to_string_lossy().to_string(),
100+
);
101+
if let Ok(outcome) = self
102+
.run(
103+
invocation,
104+
extension_manager,
105+
working_dir,
106+
cancel_token.clone(),
107+
)
108+
.await
109+
{
110+
if let Some(ctx) = outcome.context {
111+
all_context.push(ctx);
112+
}
113+
}
114+
}
115+
116+
if all_context.is_empty() {
117+
None
118+
} else {
119+
Some(all_context.join("\n"))
120+
}
25121
}
26122

27123
pub async fn run(
@@ -320,19 +416,36 @@ impl Hooks {
320416
Notification => invocation
321417
.notification_type
322418
.as_ref()
323-
.is_some_and(|t| t.contains(pattern)),
419+
.is_some_and(|t| Self::regex_matches(pattern, t)),
324420
PreCompact | PostCompact => {
325421
(invocation.manual_compact && pattern == "manual")
326422
|| (!invocation.manual_compact && pattern == "auto")
327423
}
424+
ContextFill => {
425+
// Matcher is a threshold percentage (e.g., "70").
426+
// Exact equality: check_context_fill sets fill_percentage to the
427+
// specific threshold being fired (not the current fill level),
428+
// preventing double-execution when multiple thresholds cross at once.
429+
if let (Ok(threshold), Some(fill)) =
430+
(pattern.parse::<u32>(), invocation.fill_percentage)
431+
{
432+
fill == threshold
433+
} else {
434+
false
435+
}
436+
}
328437
_ => true,
329438
}
330439
}
331440

332441
/// Match a tool invocation against a Claude Code-style matcher pattern.
333-
/// Supports:
334-
/// "Bash" or "Bash(...)" — maps to developer__shell, optionally matching command content
335-
/// "tool_name" — direct tool name match (goose-native)
442+
///
443+
/// Supports regex patterns (Claude Code compat):
444+
/// "Bash" — maps to developer__shell or shell
445+
/// "Bash(regex)" — developer__shell/shell with command content regex
446+
/// "Edit|Write" — regex alternation matching tool names
447+
/// "mcp__memory__.*" — regex wildcard matching MCP tool names
448+
/// "developer__shell" — exact match (also valid regex)
336449
fn matches_tool(pattern: &str, invocation: &HookInvocation) -> bool {
337450
let tool_name = match &invocation.tool_name {
338451
Some(name) => name,
@@ -341,34 +454,40 @@ impl Hooks {
341454

342455
// Claude Code "Bash" / "Bash(pattern)" syntax
343456
if pattern == "Bash" {
344-
return tool_name == "developer__shell";
457+
return tool_name == "developer__shell" || tool_name == "shell";
345458
}
346459

347460
if let Some(inner) = pattern
348461
.strip_prefix("Bash(")
349462
.and_then(|s| s.strip_suffix(')'))
350463
{
351-
if tool_name != "developer__shell" {
464+
if tool_name != "developer__shell" && tool_name != "shell" {
352465
return false;
353466
}
354-
// Match the inner pattern against the command argument
355467
let command_str = invocation
356468
.tool_input
357469
.as_ref()
358470
.and_then(|v| v.get("command"))
359471
.and_then(|v| v.as_str())
360472
.unwrap_or("");
361473

362-
return Self::glob_match(inner, command_str);
474+
return Self::regex_matches(inner, command_str);
363475
}
364476

365-
// Direct tool name match (goose-native: "developer__shell", "slack__post_message", etc.)
366-
tool_name == pattern
477+
// Regex match against tool name (Claude Code compat: "Edit|Write", "mcp__.*", etc.)
478+
Self::regex_matches(pattern, tool_name)
367479
}
368480

369-
fn glob_match(pattern: &str, text: &str) -> bool {
370-
glob::Pattern::new(pattern)
371-
.map(|p| p.matches(text))
481+
/// Test if `text` matches `pattern` as a full-string regex.
482+
/// Anchors the pattern to match the entire string (not a substring).
483+
fn regex_matches(pattern: &str, text: &str) -> bool {
484+
let anchored = if pattern.starts_with('^') || pattern.ends_with('$') {
485+
pattern.to_string()
486+
} else {
487+
format!("^(?:{})$", pattern)
488+
};
489+
regex::Regex::new(&anchored)
490+
.map(|re| re.is_match(text))
372491
.unwrap_or(false)
373492
}
374493
}
@@ -479,4 +598,43 @@ mod tests {
479598
let result = Hooks::parse_command_output(output, HookEventKind::PreToolUse).unwrap();
480599
assert!(result.is_none());
481600
}
601+
602+
// -- regex_matches: Claude Code-compatible tool matching --
603+
604+
#[test]
605+
fn regex_alternation_matches_either_tool() {
606+
assert!(Hooks::regex_matches("Edit|Write", "Edit"));
607+
assert!(Hooks::regex_matches("Edit|Write", "Write"));
608+
assert!(!Hooks::regex_matches("Edit|Write", "Read"));
609+
}
610+
611+
#[test]
612+
fn regex_wildcard_matches_mcp_tools() {
613+
assert!(Hooks::regex_matches(
614+
"mcp__memory__.*",
615+
"mcp__memory__create_entities"
616+
));
617+
assert!(Hooks::regex_matches(
618+
"mcp__memory__.*",
619+
"mcp__memory__search"
620+
));
621+
assert!(!Hooks::regex_matches(
622+
"mcp__memory__.*",
623+
"mcp__filesystem__read"
624+
));
625+
}
626+
627+
#[test]
628+
fn regex_exact_string_still_works() {
629+
assert!(Hooks::regex_matches("developer__shell", "developer__shell"));
630+
assert!(!Hooks::regex_matches(
631+
"developer__shell",
632+
"developer__shell_extra"
633+
));
634+
}
635+
636+
#[test]
637+
fn regex_invalid_pattern_returns_false() {
638+
assert!(!Hooks::regex_matches("[invalid", "anything"));
639+
}
482640
}

crates/goose/src/hooks/types.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub enum HookEventKind {
2121
TeammateIdle,
2222
TaskCompleted,
2323
ConfigChange,
24+
/// Goose extension: fires when context fills to a configured percentage.
25+
/// Matcher is a threshold percentage (e.g., "70" fires at 70% context fill).
26+
/// Fires once per threshold crossing — not every turn while above.
27+
ContextFill,
2428
}
2529

2630
impl HookEventKind {
@@ -87,6 +91,16 @@ pub struct HookInvocation {
8791

8892
#[serde(default)]
8993
pub manual_compact: bool,
94+
95+
// ContextFill fields
96+
#[serde(skip_serializing_if = "Option::is_none")]
97+
pub current_tokens: Option<usize>,
98+
99+
#[serde(skip_serializing_if = "Option::is_none")]
100+
pub context_limit: Option<usize>,
101+
102+
#[serde(skip_serializing_if = "Option::is_none")]
103+
pub fill_percentage: Option<u32>,
90104
}
91105

92106
impl HookInvocation {
@@ -260,6 +274,22 @@ impl HookInvocation {
260274
..Self::base(HookEventKind::ConfigChange, session_id)
261275
}
262276
}
277+
278+
pub fn context_fill(
279+
session_id: String,
280+
current_tokens: usize,
281+
context_limit: usize,
282+
fill_percentage: u32,
283+
cwd: String,
284+
) -> Self {
285+
Self {
286+
current_tokens: Some(current_tokens),
287+
context_limit: Some(context_limit),
288+
fill_percentage: Some(fill_percentage),
289+
cwd: Some(cwd),
290+
..Self::base(HookEventKind::ContextFill, session_id)
291+
}
292+
}
263293
}
264294

265295
#[derive(Debug, Clone, Default, Deserialize)]

0 commit comments

Comments
 (0)