feat(TASK-033): add hook config schema across all scopes

Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:

- Config + AppConfig hooks field, both Default impls, the manual
  from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
  JSON-inline re-serialization in Role::export (malformed maps degrade
  to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
  AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
  only; no global_hooks key)

No engine or dispatch logic yet; existing configs parse unchanged.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:34 -06:00
1 parent e4dfbe906c
commit 9693cdd4ac
12 files changed
+344 -1

No files matched your search

+66
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::hooks::HooksMap;
use crate::{
client::Model,
config::memory,
@@ -848,6 +849,10 @@ pub struct AgentConfig {
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -917,6 +922,8 @@ impl AgentConfig {
reasoning_effort: graph.reasoning_effort.clone(),
description: graph.description.clone(),
global_tools: graph.global_tools.clone(),
hooks: graph.hooks.clone(),
global_hooks: graph.global_hooks.clone(),
mcp_servers: graph.mcp_servers.clone(),
mcp_tools: graph.mcp_tools.clone(),
skills_enabled: graph.skills_enabled,
@@ -965,6 +972,11 @@ impl AgentConfig {
{
self.global_tools = v;
}
if let Ok(v) = env::var(with_prefix("global_hooks"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.global_hooks = v;
}
if let Ok(v) = env::var(with_prefix("mcp_servers"))
&& let Ok(v) = serde_json::from_str(&v)
{
@@ -1336,6 +1348,58 @@ variables:
assert_eq!(config.variables[0].name, "username");
}
#[test]
fn agent_config_hooks_and_global_hooks_round_trip() {
let yaml = "\
name: hooked
instructions: hi
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
global_hooks:
- tool.started.notify
";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.hooks["tool.started"][0].name, "notify");
assert_eq!(config.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(config.global_hooks, vec!["tool.started.notify"]);
let serialized = serde_yaml::to_string(&config).unwrap();
let reparsed: AgentConfig = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(reparsed.hooks, config.hooks);
assert_eq!(reparsed.global_hooks, config.global_hooks);
}
#[test]
fn from_graph_carries_hooks_and_global_hooks() {
let yaml = "\
name: g
start: e
hooks:
turn.completed:
- name: webhook
command: curl -s https://example.com/hook
global_hooks:
- turn.completed.webhook
nodes:
e:
id: e
type: end
output: done
";
let graph: graph::Graph = serde_yaml::from_str(yaml).unwrap();
let config = AgentConfig::from_graph("g", &graph);
assert_eq!(config.hooks, graph.hooks);
assert_eq!(config.global_hooks, graph.global_hooks);
assert_eq!(config.hooks["turn.completed"][0].name, "webhook");
assert_eq!(config.global_hooks, vec!["turn.completed.webhook"]);
}
#[test]
fn agent_config_defaults() {
let yaml = "name: minimal\ninstructions: hi\n";
@@ -1350,6 +1414,8 @@ variables:
assert_eq!(config.escalation_timeout, 0);
assert!(config.mcp_servers.is_empty());
assert!(config.global_tools.is_empty());
assert!(config.hooks.is_empty());
assert!(config.global_hooks.is_empty());
assert!(config.conversation_starters.is_empty());
assert!(config.variables.is_empty());
assert!(config.model_id.is_none());