From 9693cdd4ace279ed9935dae929872de5ce836b3b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 15 Sep 2026 15:48:14 -0600 Subject: [PATCH] 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() = /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. --- assets/config-template.yaml | 12 +++++++ src/config/agent.rs | 66 +++++++++++++++++++++++++++++++++++++ src/config/app_config.rs | 48 +++++++++++++++++++++++++++ src/config/mod.rs | 60 +++++++++++++++++++++++++++++++++ src/config/paths.rs | 34 ++++++++++++++++++- src/config/role.rs | 53 +++++++++++++++++++++++++++++ src/graph/executor.rs | 2 ++ src/graph/map.rs | 2 ++ src/graph/types.rs | 53 +++++++++++++++++++++++++++++ src/graph/validator.rs | 2 ++ src/hooks.rs | 12 +++++++ src/main.rs | 1 + 12 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/hooks.rs diff --git a/assets/config-template.yaml b/assets/config-template.yaml index 4ac410d..d4b16c9 100644 --- a/assets/config-template.yaml +++ b/assets/config-template.yaml @@ -149,6 +149,18 @@ mcp_tools: null # Per-server MCP tool allowlists (glob patterns # - list_* # slack: [] +# ---- Hooks ---- +# Fire-and-forget external commands that run when lifecycle events fire. Each event name +# maps to a list of named hooks; every listed hook's command runs when that event fires. +# Hooks never block coyote and never change its output or exit code. +# See the [Hooks documentation](https://github.com/Dark-Alex-17/coyote/wiki/Hooks) for more details +hooks: {} # Event -> list of named hook commands (default: none) + # Example: + # hooks: + # turn.completed: + # - name: notify + # command: ./hooks/notify.sh + # ---- Auto-Continue (Todo System) ---- # The auto-continue system provides built-in task tracking for improved reliability. # When enabled, the model can create todo lists and the system will automatically diff --git a/src/config/agent.rs b/src/config/agent.rs index dd1a690..aa54c3d 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -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>>, #[serde(default)] pub global_tools: Vec, + #[serde(default)] + pub hooks: HooksMap, + #[serde(default)] + pub global_hooks: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub skills_enabled: Option, #[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()); diff --git a/src/config/app_config.rs b/src/config/app_config.rs index 7682c6a..5113979 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -1,4 +1,5 @@ use crate::client::{ClientConfig, Model, ModelType, list_models}; +use crate::hooks::HooksMap; use crate::render::{MarkdownRender, RenderOptions}; use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, drain_stale_tty_input, get_env_name}; @@ -52,6 +53,9 @@ pub struct AppConfig { pub enabled_mcp_servers: Option>, pub mcp_tools: Option>>, + #[serde(default)] + pub hooks: HooksMap, + pub auto_continue: bool, pub max_auto_continues: usize, pub inject_todo_instructions: bool, @@ -143,6 +147,8 @@ impl Default for AppConfig { enabled_mcp_servers: None, mcp_tools: None, + hooks: Default::default(), + auto_continue: false, max_auto_continues: 10, inject_todo_instructions: true, @@ -235,6 +241,8 @@ impl AppConfig { enabled_mcp_servers: config.enabled_mcp_servers, mcp_tools: config.mcp_tools, + hooks: config.hooks, + auto_continue: config.auto_continue, max_auto_continues: config.max_auto_continues, inject_todo_instructions: config.inject_todo_instructions, @@ -572,6 +580,12 @@ impl AppConfig { self.enabled_mcp_servers = v.map(|raw| super::csv_to_vec(&raw)); } + if let Ok(v) = env::var(get_env_name("hooks")) + && let Ok(v) = serde_json::from_str(&v) + { + self.hooks = v; + } + if let Some(v) = super::read_env_value::(&get_env_name("repl_prelude")) { self.repl_prelude = v; } @@ -788,6 +802,40 @@ mod tests { assert_eq!(app.clients.len(), 1); } + #[test] + fn from_config_copies_hooks() { + let mut hooks = crate::hooks::HooksMap::new(); + hooks.insert( + "turn.completed".to_string(), + vec![crate::hooks::HookDef { + name: "notify".to_string(), + command: "./hooks/notify.sh".to_string(), + }], + ); + let cfg = Config { + model_id: "test-model".to_string(), + clients: vec![ClientConfig::default()], + hooks: hooks.clone(), + ..Config::default() + }; + + let app = AppConfig::from_config(cfg).unwrap(); + + assert_eq!(app.hooks, hooks); + } + + #[test] + fn from_config_defaults_hooks_to_empty() { + let cfg = Config { + model_id: "test-model".to_string(), + clients: vec![ClientConfig::default()], + ..Config::default() + }; + let app = AppConfig::from_config(cfg).unwrap(); + + assert!(app.hooks.is_empty()); + } + #[test] fn from_config_copies_mapping_fields() { let mut cfg = Config { diff --git a/src/config/mod.rs b/src/config/mod.rs index e59d670..b608ae2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -75,6 +75,7 @@ use crate::client::{ oauth, set_client_models_config, }; use crate::function::{FunctionDeclaration, Functions}; +use crate::hooks::HooksMap; use crate::rag::Rag; use crate::sandbox::SANDBOX_ENV_FLAG; use crate::utils::*; @@ -162,6 +163,7 @@ const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml"; const ROLES_DIR_NAME: &str = "roles"; const SKILLS_DIR_NAME: &str = "skills"; const MACROS_DIR_NAME: &str = "macros"; +const HOOKS_DIR_NAME: &str = "hooks"; const ENV_FILE_NAME: &str = ".env"; const MESSAGES_FILE_NAME: &str = "messages.md"; const SESSIONS_DIR_NAME: &str = "sessions"; @@ -262,6 +264,9 @@ pub struct Config { pub enabled_mcp_servers: Option>, pub mcp_tools: Option>>, + #[serde(default)] + pub hooks: HooksMap, + pub auto_continue: bool, pub max_auto_continues: usize, pub inject_todo_instructions: bool, @@ -350,6 +355,8 @@ impl Default for Config { enabled_mcp_servers: None, mcp_tools: None, + hooks: Default::default(), + auto_continue: false, max_auto_continues: 10, inject_todo_instructions: true, @@ -1393,6 +1400,59 @@ clients: assert!(!CONFIG_TEMPLATE.contains("escalation_timeout")); } + #[test] + fn config_template_carries_hooks_block_without_global_hooks() { + // `global_hooks` is a per-agent whitelist; the global template must never grow it. + assert!(CONFIG_TEMPLATE.contains("\nhooks:")); + assert!(!CONFIG_TEMPLATE.contains("global_hooks")); + } + + #[test] + fn config_template_renders_with_empty_hooks() { + let clients = json!([{ "type": "openai", "api_key": "sk-test" }]); + + let rendered = render_config_template("openai:gpt-4o", None, &clients).unwrap(); + + assert!(rendered.contains("\nhooks:")); + assert!(!rendered.contains("global_hooks")); + + let cfg = Config::load_from_str(&rendered).unwrap(); + assert!(cfg.hooks.is_empty()); + } + + #[test] + fn config_parses_hooks_map_preserving_order() { + let yaml = "\ +hooks: + tool.started: + - name: notify + command: ./hooks/notify.sh + - name: audit + command: ./hooks/audit.sh + turn.completed: + - name: webhook + command: curl -s https://example.com/hook +"; + let cfg: Config = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!( + cfg.hooks.keys().collect::>(), + vec!["tool.started", "turn.completed"] + ); + assert_eq!(cfg.hooks["tool.started"].len(), 2); + assert_eq!(cfg.hooks["tool.started"][0].name, "notify"); + assert_eq!(cfg.hooks["tool.started"][0].command, "./hooks/notify.sh"); + assert_eq!(cfg.hooks["turn.completed"][0].name, "webhook"); + } + + #[test] + fn config_without_hooks_parses_unchanged() { + let cfg: Config = serde_yaml::from_str("model: openai:gpt-4o").unwrap(); + + assert!(cfg.hooks.is_empty()); + assert_eq!(cfg.model_id, "openai:gpt-4o"); + } + #[test] fn config_enabled_macros_empty_string_is_some_empty() { let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap(); diff --git a/src/config/paths.rs b/src/config/paths.rs index a8252f6..5955007 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -2,7 +2,7 @@ use super::role::Role; use super::{ AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, - GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME, + GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, @@ -184,6 +184,14 @@ pub fn role_file(name: &str) -> PathBuf { roles_dir().join(format!("{name}.md")) } +#[allow(dead_code)] +pub fn hooks_dir() -> PathBuf { + match env::var(get_env_name("hooks_dir")) { + Ok(value) => PathBuf::from(value), + Err(_) => local_dir(HOOKS_DIR_NAME), + } +} + pub fn skills_dir() -> PathBuf { match env::var(get_env_name("skills_dir")) { Ok(value) => PathBuf::from(value), @@ -947,6 +955,30 @@ mod tests { } } + #[test] + fn hooks_dir_reflects_env_var_state() { + let env_name = get_env_name("hooks_dir"); + let prev = env::var_os(&env_name); + + unsafe { + env::remove_var(&env_name); + } + assert_eq!(hooks_dir(), config_dir().join("hooks")); + + let probe = PathBuf::from("/tmp/coyote-hooks-dir-probe"); + unsafe { + env::set_var(&env_name, &probe); + } + assert_eq!(hooks_dir(), probe); + + unsafe { + match prev { + Some(v) => env::set_var(&env_name, v), + None => env::remove_var(&env_name), + } + } + } + #[test] fn list_skills_skips_invalid_directory_names() { let unique = time::SystemTime::now() diff --git a/src/config/role.rs b/src/config/role.rs index 5485d07..ebb7775 100644 --- a/src/config/role.rs +++ b/src/config/role.rs @@ -1,6 +1,7 @@ use super::*; use crate::client::{Message, MessageContent, MessageRole, Model}; +use crate::hooks::HooksMap; use anyhow::Result; use fancy_regex::Regex; @@ -99,6 +100,8 @@ pub struct Role { skill_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + hooks: Option, #[serde(skip)] model: Model, @@ -161,6 +164,7 @@ impl Role { role.skill_instructions = value.as_str().map(|v| v.to_string()) } "memory" => role.memory = value.as_bool(), + "hooks" => role.hooks = parse_hooks_map(value), _ => (), } } @@ -248,6 +252,10 @@ impl Role { if let Some(memory) = self.memory { metadata.push(format!("memory: {memory}")); } + if let Some(hooks) = &self.hooks { + let inline = serde_json::to_string(hooks).unwrap_or_else(|_| "{}".to_string()); + metadata.push(format!("hooks: {inline}")); + } if metadata.is_empty() { format!("{}\n", self.prompt) } else if self.prompt.is_empty() { @@ -380,6 +388,11 @@ impl Role { self.memory } + #[allow(dead_code)] + pub fn hooks(&self) -> Option<&HooksMap> { + self.hooks.as_ref() + } + pub fn skills_enabled(&self) -> Option { self.skills_enabled } @@ -544,6 +557,10 @@ fn parse_mcp_tools_map(value: &Value) -> Option>> { Some(mcp_tools) } +fn parse_hooks_map(value: &Value) -> Option { + serde_json::from_value(value.clone()).ok() +} + fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) { let mut text = prompt; let mut search_input = true; @@ -641,6 +658,42 @@ mod tests { ); } + #[test] + fn role_hooks_round_trip_through_export() { + let content = "---\nhooks:\n turn.completed:\n - name: notify\n command: ./hooks/notify.sh\n---\nPrompt"; + + let role = Role::new("test", content); + + let hooks = role.hooks().expect("hooks should be parsed"); + assert_eq!(hooks["turn.completed"][0].name, "notify"); + assert_eq!(hooks["turn.completed"][0].command, "./hooks/notify.sh"); + + let exported = role.export(); + assert!(exported.contains("hooks:")); + + let reparsed = Role::new("test", &exported); + assert_eq!(reparsed.hooks(), role.hooks()); + assert_eq!(reparsed.prompt(), "Prompt"); + } + + #[test] + fn role_without_hooks_has_none_and_export_omits_key() { + let role = Role::new("test", "---\nmemory: true\n---\nPrompt"); + + assert!(role.hooks().is_none()); + assert!(!role.export().contains("hooks:")); + } + + #[test] + fn role_malformed_hooks_degrades_to_none() { + let content = "---\nhooks: [not, a, map]\n---\nPrompt"; + + let role = Role::new("test", content); + + assert!(role.hooks().is_none()); + assert_eq!(role.prompt(), "Prompt"); + } + #[test] fn role_new_no_metadata_has_none_fields() { let role = Role::new("test", "Just a prompt"); diff --git a/src/graph/executor.rs b/src/graph/executor.rs index 71a87b5..fe537d2 100644 --- a/src/graph/executor.rs +++ b/src/graph/executor.rs @@ -928,6 +928,8 @@ mod tests { max_concurrent_agents: None, max_agent_depth: None, global_tools: Vec::new(), + hooks: Default::default(), + global_hooks: Vec::new(), mcp_servers: Vec::new(), mcp_tools: None, skills_enabled: None, diff --git a/src/graph/map.rs b/src/graph/map.rs index 09fb027..514eea5 100644 --- a/src/graph/map.rs +++ b/src/graph/map.rs @@ -497,6 +497,8 @@ mod tests { max_concurrent_agents: None, max_agent_depth: None, global_tools: Vec::new(), + hooks: Default::default(), + global_hooks: Vec::new(), mcp_servers: Vec::new(), mcp_tools: None, skills_enabled: None, diff --git a/src/graph/types.rs b/src/graph/types.rs index 6a8c02f..0ac6c1c 100644 --- a/src/graph/types.rs +++ b/src/graph/types.rs @@ -1,4 +1,5 @@ use crate::config::AgentVariable; +use crate::hooks::HooksMap; use anyhow::Result; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -43,6 +44,12 @@ pub struct Graph { #[serde(default)] pub global_tools: Vec, + #[serde(default)] + pub hooks: HooksMap, + + #[serde(default)] + pub global_hooks: Vec, + #[serde(default)] pub mcp_servers: Vec, @@ -611,6 +618,52 @@ nodes: } } + #[test] + fn graph_hooks_and_global_hooks_parse_and_round_trip() { + let yaml = r#" +name: g +start: e +hooks: + tool.started: + - name: notify + command: ./hooks/notify.sh +global_hooks: + - tool.started.notify +nodes: + e: + id: e + type: end + output: done +"#; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(graph.hooks["tool.started"][0].name, "notify"); + assert_eq!(graph.hooks["tool.started"][0].command, "./hooks/notify.sh"); + assert_eq!(graph.global_hooks, vec!["tool.started.notify"]); + + let reparsed: Graph = + serde_yaml::from_str(&serde_yaml::to_string(&graph).unwrap()).unwrap(); + assert_eq!(reparsed.hooks, graph.hooks); + assert_eq!(reparsed.global_hooks, graph.global_hooks); + } + + #[test] + fn graph_without_hooks_keys_parses_empty() { + let yaml = r#" +name: g +start: e +nodes: + e: + id: e + type: end + output: done +"#; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + + assert!(graph.hooks.is_empty()); + assert!(graph.global_hooks.is_empty()); + } + #[test] fn agent_node_teammates_parses_and_defaults_false() { let yaml = r#" diff --git a/src/graph/validator.rs b/src/graph/validator.rs index b82e983..2797325 100644 --- a/src/graph/validator.rs +++ b/src/graph/validator.rs @@ -1459,6 +1459,8 @@ mod tests { max_concurrent_agents: None, max_agent_depth: None, global_tools: Vec::new(), + hooks: Default::default(), + global_hooks: Vec::new(), mcp_servers: Vec::new(), mcp_tools: None, skills_enabled: None, diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..7b47320 --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,12 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +/// A single named hook: an external command to run when its event fires. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct HookDef { + pub name: String, + pub command: String, +} + +/// Ordered mapping of event name -> hook definitions, shared by every config scope. +pub type HooksMap = IndexMap>; diff --git a/src/main.rs b/src/main.rs index 31f81d8..9ac333a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod client; mod config; mod function; mod graph; +mod hooks; mod rag; mod render; mod repl;