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

+12
View File
@@ -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
+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());
+48
View File
@@ -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<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[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::<String>(&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 {
+60
View File
@@ -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<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[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<_>>(),
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();
+33 -1
View File
@@ -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()
+53
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
memory: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hooks: Option<HooksMap>,
#[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<bool> {
self.skills_enabled
}
@@ -544,6 +557,10 @@ fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
Some(mcp_tools)
}
fn parse_hooks_map(value: &Value) -> Option<HooksMap> {
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");
+2
View File
@@ -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,
+2
View File
@@ -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,
+53
View File
@@ -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<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<String>,
@@ -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#"
+2
View File
@@ -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,
+12
View File
@@ -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<String, Vec<HookDef>>;
+1
View File
@@ -4,6 +4,7 @@ mod client;
mod config;
mod function;
mod graph;
mod hooks;
mod rag;
mod render;
mod repl;