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:
12 files changed
+344
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
@@ -4,6 +4,7 @@ mod client;
|
||||
mod config;
|
||||
mod function;
|
||||
mod graph;
|
||||
mod hooks;
|
||||
mod rag;
|
||||
mod render;
|
||||
mod repl;
|
||||
|
||||
Reference in new issue
Block a user