Skip to content

Commit dd04793

Browse files
authored
feat: add support for preToolUse and postToolUse hook (#2875)
1 parent bc4ec5c commit dd04793

File tree

13 files changed

+1037
-52
lines changed

13 files changed

+1037
-52
lines changed

crates/chat-cli/src/cli/agent/hook.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use std::collections::HashMap;
21
use std::fmt::Display;
32

43
use schemars::JsonSchema;
@@ -11,23 +10,26 @@ const DEFAULT_TIMEOUT_MS: u64 = 30_000;
1110
const DEFAULT_MAX_OUTPUT_SIZE: usize = 1024 * 10;
1211
const DEFAULT_CACHE_TTL_SECONDS: u64 = 0;
1312

14-
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema)]
15-
pub struct Hooks(HashMap<HookTrigger, Hook>);
16-
1713
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, JsonSchema, Hash)]
1814
#[serde(rename_all = "camelCase")]
1915
pub enum HookTrigger {
2016
/// Triggered during agent spawn
2117
AgentSpawn,
2218
/// Triggered per user message submission
2319
UserPromptSubmit,
20+
/// Triggered before tool execution
21+
PreToolUse,
22+
/// Triggered after tool execution
23+
PostToolUse,
2424
}
2525

2626
impl Display for HookTrigger {
2727
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2828
match self {
2929
HookTrigger::AgentSpawn => write!(f, "agentSpawn"),
3030
HookTrigger::UserPromptSubmit => write!(f, "userPromptSubmit"),
31+
HookTrigger::PreToolUse => write!(f, "preToolUse"),
32+
HookTrigger::PostToolUse => write!(f, "postToolUse"),
3133
}
3234
}
3335
}
@@ -61,6 +63,11 @@ pub struct Hook {
6163
#[serde(default = "Hook::default_cache_ttl_seconds")]
6264
pub cache_ttl_seconds: u64,
6365

66+
/// Optional glob matcher for hook
67+
/// Currently used for matching tool name of PreToolUse and PostToolUse hook
68+
#[serde(skip_serializing_if = "Option::is_none")]
69+
pub matcher: Option<String>,
70+
6471
#[schemars(skip)]
6572
#[serde(default, skip_serializing)]
6673
pub source: Source,
@@ -73,6 +80,7 @@ impl Hook {
7380
timeout_ms: Self::default_timeout_ms(),
7481
max_output_size: Self::default_max_output_size(),
7582
cache_ttl_seconds: Self::default_cache_ttl_seconds(),
83+
matcher: None,
7684
source,
7785
}
7886
}

crates/chat-cli/src/cli/agent/legacy/hooks.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ impl From<LegacyHook> for Option<Hook> {
8080
timeout_ms: value.timeout_ms,
8181
max_output_size: value.max_output_size,
8282
cache_ttl_seconds: value.cache_ttl_seconds,
83+
matcher: None,
8384
source: Default::default(),
8485
})
8586
}

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,7 @@ mod tests {
959959
use serde_json::json;
960960

961961
use super::*;
962+
use crate::cli::agent::hook::Source;
962963
const INPUT: &str = r#"
963964
{
964965
"name": "some_agent",
@@ -968,21 +969,21 @@ mod tests {
968969
"fetch": { "command": "fetch3.1", "args": [] },
969970
"git": { "command": "git-mcp", "args": [] }
970971
},
971-
"tools": [
972+
"tools": [
972973
"@git"
973974
],
974975
"toolAliases": {
975976
"@gits/some_tool": "some_tool2"
976977
},
977-
"allowedTools": [
978-
"fs_read",
978+
"allowedTools": [
979+
"fs_read",
979980
"@fetch",
980981
"@gits/git_status"
981982
],
982-
"resources": [
983+
"resources": [
983984
"file://~/my-genai-prompts/unittest.md"
984985
],
985-
"toolsSettings": {
986+
"toolsSettings": {
986987
"fs_write": { "allowedPaths": ["~/**"] },
987988
"@git/git_status": { "git_user": "$GIT_USER" }
988989
}
@@ -1353,4 +1354,70 @@ mod tests {
13531354

13541355
assert_eq!(agents.get_active().and_then(|a| a.model.as_ref()), None);
13551356
}
1357+
1358+
#[test]
1359+
fn test_agent_with_hooks() {
1360+
let agent_json = json!({
1361+
"name": "test-agent",
1362+
"hooks": {
1363+
"agentSpawn": [
1364+
{
1365+
"command": "git status"
1366+
}
1367+
],
1368+
"preToolUse": [
1369+
{
1370+
"matcher": "fs_write",
1371+
"command": "validate-tool.sh"
1372+
},
1373+
{
1374+
"matcher": "fs_read",
1375+
"command": "enforce-tdd.sh"
1376+
}
1377+
],
1378+
"postToolUse": [
1379+
{
1380+
"matcher": "fs_write",
1381+
"command": "format-python.sh"
1382+
}
1383+
]
1384+
}
1385+
});
1386+
1387+
let agent: Agent = serde_json::from_value(agent_json).expect("Failed to deserialize agent");
1388+
1389+
// Verify agent name
1390+
assert_eq!(agent.name, "test-agent");
1391+
1392+
// Verify agentSpawn hook
1393+
assert!(agent.hooks.contains_key(&HookTrigger::AgentSpawn));
1394+
let agent_spawn_hooks = &agent.hooks[&HookTrigger::AgentSpawn];
1395+
assert_eq!(agent_spawn_hooks.len(), 1);
1396+
assert_eq!(agent_spawn_hooks[0].command, "git status");
1397+
assert_eq!(agent_spawn_hooks[0].matcher, None);
1398+
1399+
// Verify preToolUse hooks
1400+
assert!(agent.hooks.contains_key(&HookTrigger::PreToolUse));
1401+
let pre_tool_hooks = &agent.hooks[&HookTrigger::PreToolUse];
1402+
assert_eq!(pre_tool_hooks.len(), 2);
1403+
1404+
assert_eq!(pre_tool_hooks[0].command, "validate-tool.sh");
1405+
assert_eq!(pre_tool_hooks[0].matcher, Some("fs_write".to_string()));
1406+
1407+
assert_eq!(pre_tool_hooks[1].command, "enforce-tdd.sh");
1408+
assert_eq!(pre_tool_hooks[1].matcher, Some("fs_read".to_string()));
1409+
1410+
// Verify postToolUse hooks
1411+
assert!(agent.hooks.contains_key(&HookTrigger::PostToolUse));
1412+
1413+
// Verify default values are set correctly
1414+
for hooks in agent.hooks.values() {
1415+
for hook in hooks {
1416+
assert_eq!(hook.timeout_ms, 30_000);
1417+
assert_eq!(hook.max_output_size, 10_240);
1418+
assert_eq!(hook.cache_ttl_seconds, 0);
1419+
assert_eq!(hook.source, Source::Agent);
1420+
}
1421+
}
1422+
}
13561423
}

0 commit comments

Comments
 (0)