style: applied improved styling to the whole of the hooks integration
This commit is contained in:
17 files changed
+91
-175
No files matched your search
+16
-22
@@ -547,10 +547,6 @@ pub async fn create_openai_compatible_client_config(
|
|||||||
Ok(Some((model, clients)))
|
Ok(Some((model, clients)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Base extras every `llm.request.*` event carries. The three transport
|
|
||||||
/// functions below are the single seam family for these events: they cover
|
|
||||||
/// every provider and every caller, so the `Client` trait and per-provider
|
|
||||||
/// code stay uninstrumented.
|
|
||||||
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
|
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
|
||||||
vec![
|
vec![
|
||||||
("COYOTE_LLM_PROVIDER", client.name().to_string()),
|
("COYOTE_LLM_PROVIDER", client.name().to_string()),
|
||||||
@@ -567,10 +563,6 @@ fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'stat
|
|||||||
extras
|
extras
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aborted` marks a user interrupt; otherwise the error is `api_error` when
|
|
||||||
/// it bottoms out in an [`ApiStatusError`] (which also yields
|
|
||||||
/// `COYOTE_LLM_STATUS`), else `other`. Abort paths that produce no error
|
|
||||||
/// object report `Aborted.`.
|
|
||||||
fn llm_failed_extras(
|
fn llm_failed_extras(
|
||||||
client: &dyn Client,
|
client: &dyn Client,
|
||||||
err: Option<&anyhow::Error>,
|
err: Option<&anyhow::Error>,
|
||||||
@@ -592,6 +584,7 @@ fn llm_failed_extras(
|
|||||||
err.map(|err| format!("{err:#}"))
|
err.map(|err| format!("{err:#}"))
|
||||||
.unwrap_or_else(|| "Aborted.".to_string()),
|
.unwrap_or_else(|| "Aborted.".to_string()),
|
||||||
));
|
));
|
||||||
|
|
||||||
extras
|
extras
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,6 +1129,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::super::access_token::{is_rejected, set_access_token};
|
use super::super::access_token::{is_rejected, set_access_token};
|
||||||
use crate::config::{AppState, WorkingMode};
|
use crate::config::{AppState, WorkingMode};
|
||||||
|
use anyhow::Error;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
@@ -1352,8 +1346,8 @@ mod tests {
|
|||||||
|
|
||||||
/// Wrapped in `.context(...)` so every test below proves the downcast
|
/// Wrapped in `.context(...)` so every test below proves the downcast
|
||||||
/// works through an anyhow context chain, as in the trait methods.
|
/// works through an anyhow context chain, as in the trait methods.
|
||||||
fn api_status_error(status: u16) -> anyhow::Error {
|
fn api_status_error(status: u16) -> Error {
|
||||||
anyhow::Error::new(ApiStatusError {
|
Error::new(ApiStatusError {
|
||||||
status,
|
status,
|
||||||
message: format!("error (status: {status})"),
|
message: format!("error (status: {status})"),
|
||||||
})
|
})
|
||||||
@@ -1709,8 +1703,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error(&self) -> anyhow::Error {
|
fn error(&self) -> Error {
|
||||||
anyhow::Error::new(ApiStatusError {
|
Error::new(ApiStatusError {
|
||||||
status: self.status,
|
status: self.status,
|
||||||
message: format!("error (status: {})", self.status),
|
message: format!("error (status: {})", self.status),
|
||||||
})
|
})
|
||||||
@@ -1757,7 +1751,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn llm_hooks_map(marker: &str) -> crate::hooks::HooksMap {
|
fn llm_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||||
[
|
[
|
||||||
"llm.request.started",
|
"llm.request.started",
|
||||||
"llm.request.completed",
|
"llm.request.completed",
|
||||||
@@ -1787,8 +1781,8 @@ mod tests {
|
|||||||
ctx
|
ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
fn llm_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
crate::hooks::test_sink::snapshot()
|
hooks::test_sink::snapshot()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|capture| capture.hook_name == marker)
|
.filter(|capture| capture.hook_name == marker)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1802,7 +1796,7 @@ mod tests {
|
|||||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
let mut client = paced_client(false);
|
let mut client = paced_client(false);
|
||||||
client.nonstream_replies = true;
|
client.nonstream_replies = true;
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
call_chat_completions(
|
call_chat_completions(
|
||||||
&input,
|
&input,
|
||||||
@@ -1838,7 +1832,7 @@ mod tests {
|
|||||||
let mut ctx = llm_hooked_ctx(marker);
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
let client = FailingStatusClient::new(429);
|
let client = FailingStatusClient::new(429);
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let err = call_chat_completions(
|
let err = call_chat_completions(
|
||||||
&input,
|
&input,
|
||||||
@@ -1885,7 +1879,7 @@ mod tests {
|
|||||||
let mut ctx = llm_hooked_ctx(marker);
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
let client = paced_client(false);
|
let client = paced_client(false);
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
|
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
|
||||||
.await
|
.await
|
||||||
@@ -1911,7 +1905,7 @@ mod tests {
|
|||||||
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
||||||
trigger.set_ctrlc();
|
trigger.set_ctrlc();
|
||||||
});
|
});
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
|
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
|
||||||
.await
|
.await
|
||||||
@@ -1936,7 +1930,7 @@ mod tests {
|
|||||||
let mut ctx = llm_hooked_ctx(marker);
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
let client = paced_client(false);
|
let client = paced_client(false);
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
||||||
.await
|
.await
|
||||||
@@ -1969,7 +1963,7 @@ mod tests {
|
|||||||
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
||||||
trigger.set_ctrlc();
|
trigger.set_ctrlc();
|
||||||
});
|
});
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
|
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
|
||||||
.await
|
.await
|
||||||
@@ -1997,7 +1991,7 @@ mod tests {
|
|||||||
let mut ctx = llm_hooked_ctx(marker);
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
let client = FailingStatusClient::new(500);
|
let client = FailingStatusClient::new(500);
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let err =
|
let err =
|
||||||
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
||||||
|
|||||||
+1
-1
@@ -1489,7 +1489,7 @@ nodes:
|
|||||||
type: end
|
type: end
|
||||||
output: done
|
output: done
|
||||||
";
|
";
|
||||||
let graph: graph::Graph = serde_yaml::from_str(yaml).unwrap();
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
let config = AgentConfig::from_graph("g", &graph);
|
let config = AgentConfig::from_graph("g", &graph);
|
||||||
|
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_config_copies_hooks() {
|
fn from_config_copies_hooks() {
|
||||||
let mut hooks = crate::hooks::HooksMap::new();
|
let mut hooks = HooksMap::new();
|
||||||
hooks.insert(
|
hooks.insert(
|
||||||
"turn.completed".to_string(),
|
"turn.completed".to_string(),
|
||||||
vec![crate::hooks::HookDef {
|
vec![crate::hooks::HookDef {
|
||||||
@@ -845,7 +845,7 @@ mod tests {
|
|||||||
let env_name = get_env_name("hooks");
|
let env_name = get_env_name("hooks");
|
||||||
let prev = env::var_os(&env_name);
|
let prev = env::var_os(&env_name);
|
||||||
|
|
||||||
let mut hooks = crate::hooks::HooksMap::new();
|
let mut hooks = HooksMap::new();
|
||||||
hooks.insert(
|
hooks.insert(
|
||||||
"turn.completed".to_string(),
|
"turn.completed".to_string(),
|
||||||
vec![crate::hooks::HookDef {
|
vec![crate::hooks::HookDef {
|
||||||
|
|||||||
@@ -15,16 +15,8 @@ use std::collections::BTreeSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Hidden per-directory record of the files the builtin installer shipped
|
|
||||||
/// there. Format: one plain filename per line (LF-separated, sorted); no
|
|
||||||
/// path separators, and never the manifest itself. Written non-executable
|
|
||||||
/// through the same atomic-write path as other installed files.
|
|
||||||
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
|
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
|
||||||
|
|
||||||
/// True when `name` aliases the manifest file on some real-world filesystem:
|
|
||||||
/// the comparison ignores trailing dots/spaces (Windows strips them at
|
|
||||||
/// creation) and folds Unicode case (case-insensitive APFS/NTFS volumes fold
|
|
||||||
/// beyond ASCII, e.g. U+017F ſ → s).
|
|
||||||
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
|
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
|
||||||
name.trim_end_matches(['.', ' '])
|
name.trim_end_matches(['.', ' '])
|
||||||
.to_uppercase()
|
.to_uppercase()
|
||||||
@@ -36,8 +28,8 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
|
|||||||
/// absent from `shipped`. The manifest is then rewritten to the names this
|
/// absent from `shipped`. The manifest is then rewritten to the names this
|
||||||
/// installer actually owns: files written this run (`written`), plus shipped
|
/// installer actually owns: files written this run (`written`), plus shipped
|
||||||
/// names it already owned, plus stale files whose removal failed (kept so a
|
/// names it already owned, plus stale files whose removal failed (kept so a
|
||||||
/// later run retries). A shipped name the installer declined to write — a
|
/// later run retries). A shipped name the installer declined to write (e.g., a
|
||||||
/// pre-existing user file with a colliding name — is never claimed.
|
/// pre-existing user file with a colliding name) is never claimed.
|
||||||
///
|
///
|
||||||
/// Failure modes degrade toward keeping files: a missing or unreadable
|
/// Failure modes degrade toward keeping files: a missing or unreadable
|
||||||
/// manifest yields no deletions, and entries that are not plain filenames
|
/// manifest yields no deletions, and entries that are not plain filenames
|
||||||
@@ -137,8 +129,9 @@ fn write_manifest(dir: &Path, names: &BTreeSet<String>) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::utils;
|
use crate::utils;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn fresh_dir(label: &str) -> std::path::PathBuf {
|
fn fresh_dir(label: &str) -> PathBuf {
|
||||||
let dir = utils::temp_file(label, "");
|
let dir = utils::temp_file(label, "");
|
||||||
fs::create_dir_all(&dir).unwrap();
|
fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
|
|||||||
@@ -2418,6 +2418,7 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config;
|
||||||
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
|
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
|
||||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||||
use crate::utils::get_env_name;
|
use crate::utils::get_env_name;
|
||||||
@@ -2710,8 +2711,6 @@ mod tests {
|
|||||||
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
|
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--filter hooks` used to be an inert arm that always produced an empty
|
|
||||||
/// layout; it now selects the bundle's top-level hooks directory.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_filter_hooks_keeps_only_hooks() {
|
fn apply_filter_hooks_keeps_only_hooks() {
|
||||||
let l = RemoteLayout {
|
let l = RemoteLayout {
|
||||||
@@ -3624,8 +3623,9 @@ mod tests {
|
|||||||
fn assert_executable(path: &Path) {
|
fn assert_executable(path: &Path) {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let mode = fs::metadata(path).unwrap().permissions().mode();
|
let mode = fs::metadata(path).unwrap().permissions().mode();
|
||||||
assert!(
|
assert_ne!(
|
||||||
mode & 0o111 != 0,
|
mode & 0o111,
|
||||||
|
0,
|
||||||
"{} must carry the executable bit",
|
"{} must carry the executable bit",
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
@@ -3932,7 +3932,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let _guard = TestVaultConfigGuard::new("prov-builtin");
|
let _guard = TestVaultConfigGuard::new("prov-builtin");
|
||||||
|
|
||||||
crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap();
|
config::install_assets(config::AssetCategory::Macros).unwrap();
|
||||||
|
|
||||||
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
|
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
|
||||||
assert!(!paths::installed_bundles_file().exists());
|
assert!(!paths::installed_bundles_file().exists());
|
||||||
@@ -3957,7 +3957,7 @@ mod tests {
|
|||||||
|
|
||||||
// Capture outcomes before restoring the env var so a failed assertion
|
// Capture outcomes before restoring the env var so a failed assertion
|
||||||
// cannot leave the hooks-dir override pointing at a deleted temp dir.
|
// cannot leave the hooks-dir override pointing at a deleted temp dir.
|
||||||
let result = crate::config::install_assets(crate::config::AssetCategory::Hooks);
|
let result = config::install_assets(AssetCategory::Hooks);
|
||||||
let installed = (
|
let installed = (
|
||||||
hooks_dir.join("notify.sh").is_file(),
|
hooks_dir.join("notify.sh").is_file(),
|
||||||
hooks_dir.join("log-events.sh").is_file(),
|
hooks_dir.join("log-events.sh").is_file(),
|
||||||
|
|||||||
+3
-2
@@ -83,6 +83,7 @@ use crate::utils::*;
|
|||||||
pub use macros::macro_execute;
|
pub use macros::macro_execute;
|
||||||
|
|
||||||
use crate::config::macros::Macro;
|
use crate::config::macros::Macro;
|
||||||
|
use crate::hooks;
|
||||||
use crate::vault::{
|
use crate::vault::{
|
||||||
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
|
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
|
||||||
};
|
};
|
||||||
@@ -420,7 +421,7 @@ pub fn install_builtins() -> Result<()> {
|
|||||||
Agent::install_builtin_agents(false)?;
|
Agent::install_builtin_agents(false)?;
|
||||||
Macro::install_macros(false)?;
|
Macro::install_macros(false)?;
|
||||||
Skill::install_builtin_skills(false)?;
|
Skill::install_builtin_skills(false)?;
|
||||||
crate::hooks::install_builtin_hooks(false)?;
|
hooks::install_builtin_hooks(false)?;
|
||||||
Role::install_builtin_role_hooks(false)?;
|
Role::install_builtin_role_hooks(false)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -522,7 +523,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
|
|||||||
AssetCategory::Macros => Macro::install_macros(true)?,
|
AssetCategory::Macros => Macro::install_macros(true)?,
|
||||||
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
||||||
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
||||||
AssetCategory::Hooks => crate::hooks::install_builtin_hooks(true)?,
|
AssetCategory::Hooks => hooks::install_builtin_hooks(true)?,
|
||||||
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4733,8 +4733,6 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
self.refresh_mcp_tool_filters();
|
self.refresh_mcp_tool_filters();
|
||||||
self.init_agent_session_variables(new_session)?;
|
self.init_agent_session_variables(new_session)?;
|
||||||
// NEW sessions only, and only once the session is fully engaged on
|
|
||||||
// the context; resuming an existing session fires nothing.
|
|
||||||
if created_new_session {
|
if created_new_session {
|
||||||
hooks::fire(HookEvent::SessionStarted, self, &[], None);
|
hooks::fire(HookEvent::SessionStarted, self, &[], None);
|
||||||
}
|
}
|
||||||
@@ -4933,24 +4931,18 @@ impl RequestContext {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the top-level `agent.*` bracket: fires `agent.started` once per
|
|
||||||
/// agent-context lifetime when the ROOT context runs with a loaded agent
|
|
||||||
/// (headless `--agent` dispatch, or entering an agent interactively).
|
|
||||||
/// Spawned sub-agents are bracketed at their spawn seam instead, never
|
|
||||||
/// here. Synthesizes and stores `COYOTE_AGENT_ID` under the
|
|
||||||
/// `top-<agent-name>-<8-char alphanumeric nonce>` scheme; calling this
|
|
||||||
/// again while the bracket is open, or without an agent, is a no-op —
|
|
||||||
/// per-message granularity belongs to `turn.*`, not `agent.*`.
|
|
||||||
pub fn top_level_agent_started(&mut self) {
|
pub fn top_level_agent_started(&mut self) {
|
||||||
if self.top_level_agent_id.is_some() {
|
if self.top_level_agent_id.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(agent) = self.agent.as_ref() else {
|
let Some(agent) = self.agent.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8);
|
let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8);
|
||||||
let id = format!("top-{}-{nonce}", agent.name());
|
let id = format!("top-{}-{nonce}", agent.name());
|
||||||
self.top_level_agent_id = Some(id.clone());
|
self.top_level_agent_id = Some(id.clone());
|
||||||
|
|
||||||
hooks::fire(
|
hooks::fire(
|
||||||
HookEvent::AgentStarted,
|
HookEvent::AgentStarted,
|
||||||
self,
|
self,
|
||||||
@@ -4959,15 +4951,11 @@ impl RequestContext {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Closes the top-level `agent.*` bracket: fires `agent.completed`, or
|
|
||||||
/// `agent.failed` carrying `COYOTE_AGENT_ERROR` when the run ended in an
|
|
||||||
/// error. No-op unless [`Self::top_level_agent_started`] opened the
|
|
||||||
/// bracket; the stored id is consumed so exactly one terminal event
|
|
||||||
/// fires per lifetime.
|
|
||||||
pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
|
pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
|
||||||
let Some(id) = self.top_level_agent_id.take() else {
|
let Some(id) = self.top_level_agent_id.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
match error {
|
match error {
|
||||||
None => hooks::fire(
|
None => hooks::fire(
|
||||||
HookEvent::AgentCompleted,
|
HookEvent::AgentCompleted,
|
||||||
|
|||||||
+21
-34
@@ -1039,9 +1039,9 @@ Input 1
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hooks_fixture_dir(label: &str) -> std::path::PathBuf {
|
fn hooks_fixture_dir(label: &str) -> PathBuf {
|
||||||
let dir = crate::utils::temp_file(label, "");
|
let dir = temp_file(label, "");
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
create_dir_all(&dir).unwrap();
|
||||||
dir.join("hooks")
|
dir.join("hooks")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,24 +1052,17 @@ Input 1
|
|||||||
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
|
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
"#!/bin/sh\n"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
|
||||||
"a.sh\n"
|
"a.sh\n"
|
||||||
);
|
);
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let mode = std::fs::metadata(dir.join("a.sh"))
|
let mode = fs::metadata(dir.join("a.sh")).unwrap().permissions().mode();
|
||||||
.unwrap()
|
|
||||||
.permissions()
|
|
||||||
.mode();
|
|
||||||
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
|
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
|
||||||
let manifest_mode =
|
let manifest_mode = fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
|
||||||
std::fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.permissions()
|
.permissions()
|
||||||
.mode();
|
.mode();
|
||||||
@@ -1079,28 +1072,22 @@ Input 1
|
|||||||
"the manifest must never be executable"
|
"the manifest must never be executable"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn role_hooks_install_overwrites_only_with_force() {
|
fn role_hooks_install_overwrites_only_with_force() {
|
||||||
let dir = hooks_fixture_dir("role-hooks-force-");
|
let dir = hooks_fixture_dir("role-hooks-force-");
|
||||||
let shipped = fixture(&[("a.sh", "new content\n")]);
|
let shipped = fixture(&[("a.sh", "new content\n")]);
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
|
fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
|
||||||
|
|
||||||
install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap();
|
install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
|
||||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
|
||||||
"SENTINEL"
|
|
||||||
);
|
|
||||||
|
|
||||||
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap();
|
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
|
||||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
"new content\n"
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1113,7 +1100,7 @@ Input 1
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
std::fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false)
|
install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1125,17 +1112,17 @@ Input 1
|
|||||||
assert!(dir.join("keep.sh").exists());
|
assert!(dir.join("keep.sh").exists());
|
||||||
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
|
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
"keep.sh\n"
|
"keep.sh\n"
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn role_hooks_never_claim_a_preexisting_user_file() {
|
fn role_hooks_never_claim_a_preexisting_user_file() {
|
||||||
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
|
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(dir.join("a.sh"), "user-owned").unwrap();
|
fs::write(dir.join("a.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
|
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1146,11 +1133,11 @@ Input 1
|
|||||||
|
|
||||||
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
|
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
read_to_string(dir.join("a.sh")).unwrap(),
|
||||||
"user-owned",
|
"user-owned",
|
||||||
"the user file survives the hook being dropped from the embed"
|
"the user file survives the hook being dropped from the embed"
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1160,6 +1147,6 @@ Input 1
|
|||||||
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
|
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
|
||||||
|
|
||||||
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
|
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
|
||||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1288,7 +1288,7 @@ mod tests {
|
|||||||
fn session_deserialization_ignores_injected_role_hooks() {
|
fn session_deserialization_ignores_injected_role_hooks() {
|
||||||
// A session file is attacker-writable data: a `role_hooks:` key
|
// A session file is attacker-writable data: a `role_hooks:` key
|
||||||
// crafted into it must never deserialize into executable hook
|
// crafted into it must never deserialize into executable hook
|
||||||
// config — hooks are only ever re-read from the role file itself.
|
// config. Hooks are only ever re-read from the role file itself.
|
||||||
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
|
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
|
||||||
let session: Session = serde_yaml::from_str(yaml).unwrap();
|
let session: Session = serde_yaml::from_str(yaml).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+12
-12
@@ -54,10 +54,6 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|
|||||||
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
|
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal-event hooks for one job, resolved on the caller's context before
|
|
||||||
/// the task is spawned (`build_env_snapshot` precedent) so the detached
|
|
||||||
/// closure owns everything it fires with. Session and agent names are
|
|
||||||
/// snapshotted because the context is gone by fire time.
|
|
||||||
struct JobHookSnapshot {
|
struct JobHookSnapshot {
|
||||||
completed: Vec<ResolvedHook>,
|
completed: Vec<ResolvedHook>,
|
||||||
failed: Vec<ResolvedHook>,
|
failed: Vec<ResolvedHook>,
|
||||||
@@ -92,9 +88,11 @@ impl JobHookSnapshot {
|
|||||||
} else {
|
} else {
|
||||||
(HookEvent::JobFailed, self.failed)
|
(HookEvent::JobFailed, self.failed)
|
||||||
};
|
};
|
||||||
|
|
||||||
if resolved.is_empty() {
|
if resolved.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let base_envs = hooks::base_envs_parts(
|
let base_envs = hooks::base_envs_parts(
|
||||||
event,
|
event,
|
||||||
self.session_name.as_deref(),
|
self.session_name.as_deref(),
|
||||||
@@ -104,9 +102,11 @@ impl JobHookSnapshot {
|
|||||||
("COYOTE_JOB_ID", self.job_id),
|
("COYOTE_JOB_ID", self.job_id),
|
||||||
("COYOTE_TOOL_NAME", self.tool),
|
("COYOTE_TOOL_NAME", self.tool),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(error) = error {
|
if let Some(error) = error {
|
||||||
extras.push(("COYOTE_JOB_ERROR", error));
|
extras.push(("COYOTE_JOB_ERROR", error));
|
||||||
}
|
}
|
||||||
|
|
||||||
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
|
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3208,13 +3208,13 @@ mod tests {
|
|||||||
assert!(ctx.supervisor.is_none(), "no job may be spawned");
|
assert!(ctx.supervisor.is_none(), "no job may be spawned");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn job_hooks_map(marker: &str) -> crate::hooks::HooksMap {
|
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||||
["job.started", "job.completed", "job.failed"]
|
["job.started", "job.completed", "job.failed"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
(
|
(
|
||||||
event.to_string(),
|
event.to_string(),
|
||||||
vec![crate::hooks::HookDef {
|
vec![hooks::HookDef {
|
||||||
name: marker.to_string(),
|
name: marker.to_string(),
|
||||||
command: "true".to_string(),
|
command: "true".to_string(),
|
||||||
}],
|
}],
|
||||||
@@ -3230,8 +3230,8 @@ mod tests {
|
|||||||
RequestContext::new(app, WorkingMode::Cmd)
|
RequestContext::new(app, WorkingMode::Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn job_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
crate::hooks::test_sink::snapshot()
|
hooks::test_sink::snapshot()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|capture| capture.hook_name == marker)
|
.filter(|capture| capture.hook_name == marker)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -3245,7 +3245,7 @@ mod tests {
|
|||||||
let marker = "job-hooks-ok-x7x";
|
let marker = "job-hooks-ok-x7x";
|
||||||
let mut ctx = job_hooked_ctx(marker);
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
ctx.declared_function_names.insert("echo".into());
|
ctx.declared_function_names.insert("echo".into());
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
|
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
|
||||||
.await
|
.await
|
||||||
@@ -3300,7 +3300,7 @@ mod tests {
|
|||||||
let marker = "job-hooks-fail-x7x";
|
let marker = "job-hooks-fail-x7x";
|
||||||
let mut ctx = job_hooked_ctx(marker);
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
ctx.declared_function_names.insert("false".into());
|
ctx.declared_function_names.insert("false".into());
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
|
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
|
||||||
.await
|
.await
|
||||||
@@ -3344,7 +3344,7 @@ mod tests {
|
|||||||
filter.push_layer(LayerSource::Global, &[]);
|
filter.push_layer(LayerSource::Global, &[]);
|
||||||
runtime.tool_filters.insert("fixture".to_string(), filter);
|
runtime.tool_filters.insert("fixture".to_string(), filter);
|
||||||
ctx.tool_scope.mcp_runtime = runtime;
|
ctx.tool_scope.mcp_runtime = runtime;
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let started = handle_start(
|
let started = handle_start(
|
||||||
&mut ctx,
|
&mut ctx,
|
||||||
@@ -3382,7 +3382,7 @@ mod tests {
|
|||||||
fn rejected_job_start_fires_no_hooks() {
|
fn rejected_job_start_fires_no_hooks() {
|
||||||
let marker = "job-hooks-rejected-x7x";
|
let marker = "job-hooks-rejected-x7x";
|
||||||
let mut ctx = job_hooked_ctx(marker);
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let result = run_async(handle_start(
|
let result = run_async(handle_start(
|
||||||
&mut ctx,
|
&mut ctx,
|
||||||
|
|||||||
+11
-17
@@ -1539,12 +1539,6 @@ impl ToolCall {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates the call, firing `tool.*` hooks around it: `tool.started`
|
|
||||||
/// once the name and arguments are resolved, then exactly one of
|
|
||||||
/// `tool.completed` / `tool.failed`. A failure before resolution
|
|
||||||
/// (unknown tool, malformed arguments) fires `tool.failed` alone. Hooks
|
|
||||||
/// are pure observers — the returned value is exactly what the tool
|
|
||||||
/// produced.
|
|
||||||
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
|
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
|
||||||
let result = self.eval_inner(ctx).await;
|
let result = self.eval_inner(ctx).await;
|
||||||
if let Err(err) = &result {
|
if let Err(err) = &result {
|
||||||
@@ -5308,13 +5302,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_hooks_map(marker: &str) -> crate::hooks::HooksMap {
|
fn tool_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||||
["tool.started", "tool.completed", "tool.failed"]
|
["tool.started", "tool.completed", "tool.failed"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
(
|
(
|
||||||
event.to_string(),
|
event.to_string(),
|
||||||
vec![crate::hooks::HookDef {
|
vec![hooks::HookDef {
|
||||||
name: marker.to_string(),
|
name: marker.to_string(),
|
||||||
command: "true".to_string(),
|
command: "true".to_string(),
|
||||||
}],
|
}],
|
||||||
@@ -5334,8 +5328,8 @@ mod tests {
|
|||||||
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
crate::hooks::test_sink::snapshot()
|
hooks::test_sink::snapshot()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|capture| capture.hook_name == marker)
|
.filter(|capture| capture.hook_name == marker)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -5351,7 +5345,7 @@ mod tests {
|
|||||||
config.auto_continue = true;
|
config.auto_continue = true;
|
||||||
});
|
});
|
||||||
ctx.tool_scope.functions.append_todo_functions();
|
ctx.tool_scope.functions.append_todo_functions();
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let output =
|
let output =
|
||||||
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
|
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
|
||||||
@@ -5389,7 +5383,7 @@ mod tests {
|
|||||||
ctx.tool_scope
|
ctx.tool_scope
|
||||||
.functions
|
.functions
|
||||||
.append_mcp_meta_functions(vec![tools_only("fixture")]);
|
.append_mcp_meta_functions(vec![tools_only("fixture")]);
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
|
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
|
||||||
.eval(&mut ctx)
|
.eval(&mut ctx)
|
||||||
@@ -5424,7 +5418,7 @@ mod tests {
|
|||||||
parameters: JsonSchema::default(),
|
parameters: JsonSchema::default(),
|
||||||
agent: false,
|
agent: false,
|
||||||
});
|
});
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
|
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
|
||||||
|
|
||||||
@@ -5454,7 +5448,7 @@ mod tests {
|
|||||||
parameters: JsonSchema::default(),
|
parameters: JsonSchema::default(),
|
||||||
agent: false,
|
agent: false,
|
||||||
});
|
});
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
|
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
|
||||||
|
|
||||||
@@ -5485,7 +5479,7 @@ mod tests {
|
|||||||
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
||||||
plain.tool_scope.functions.append_todo_functions();
|
plain.tool_scope.functions.append_todo_functions();
|
||||||
let call = call_with_args("todo__done", json!({}));
|
let call = call_with_args("todo__done", json!({}));
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let hooked_output = run_async(call.eval(&mut hooked)).unwrap();
|
let hooked_output = run_async(call.eval(&mut hooked)).unwrap();
|
||||||
let plain_output = run_async(call.eval(&mut plain)).unwrap();
|
let plain_output = run_async(call.eval(&mut plain)).unwrap();
|
||||||
@@ -5517,7 +5511,7 @@ mod tests {
|
|||||||
let marker = "tool-hooks-user-x7x";
|
let marker = "tool-hooks-user-x7x";
|
||||||
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
|
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
|
||||||
ctx.tool_scope.functions.append_user_interaction_functions();
|
ctx.tool_scope.functions.append_user_interaction_functions();
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
|
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
|
||||||
|
|
||||||
@@ -5554,7 +5548,7 @@ mod tests {
|
|||||||
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
|
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
|
||||||
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
||||||
let call = call_with_args("nonexistent_tool", json!({}));
|
let call = call_with_args("nonexistent_tool", json!({}));
|
||||||
let _guard = crate::hooks::test_sink::install();
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
let hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
|
let hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
|
||||||
let plain_err = run_async(call.eval(&mut plain)).unwrap_err();
|
let plain_err = run_async(call.eval(&mut plain)).unwrap_err();
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ impl AgentNodeExecutor {
|
|||||||
parent_ctx: &mut RequestContext,
|
parent_ctx: &mut RequestContext,
|
||||||
retire_peer_on_return: bool,
|
retire_peer_on_return: bool,
|
||||||
) -> Result<AgentExecutionOutcome> {
|
) -> Result<AgentExecutionOutcome> {
|
||||||
// The teammate identity doubles as the agent's id for observers; the
|
|
||||||
// run consumes it off the ctx, so it is captured up front. Nodes
|
|
||||||
// without one are identified by their node id.
|
|
||||||
let agent_hook_id = parent_ctx
|
let agent_hook_id = parent_ctx
|
||||||
.peer_assignment
|
.peer_assignment
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -52,9 +49,6 @@ impl AgentNodeExecutor {
|
|||||||
retire_peer_on_return,
|
retire_peer_on_return,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Fired at the point of failure, before a declared fallback converts
|
|
||||||
// it into a successful route: observers must see the agent's failure
|
|
||||||
// even when the node itself continues.
|
|
||||||
if let Err(e) = &result {
|
if let Err(e) = &result {
|
||||||
hooks::fire(
|
hooks::fire(
|
||||||
HookEvent::AgentFailed,
|
HookEvent::AgentFailed,
|
||||||
|
|||||||
+10
-16
@@ -514,12 +514,6 @@ fn provision_frontier_peers(
|
|||||||
(Some(registry), assignments)
|
(Some(registry), assignments)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-resolved `graph.node.completed`/`graph.node.failed` snapshots plus
|
|
||||||
/// the metadata one branch task needs to fire them. Captured per branch
|
|
||||||
/// before the branch is spawned: every parallel super-step branch owns its
|
|
||||||
/// emitter, so node metadata never crosses between concurrent tasks and no
|
|
||||||
/// task borrows the context it was forked from. Base envs are rebuilt at
|
|
||||||
/// fire time so the timestamp reflects when the node actually finished.
|
|
||||||
struct NodeHookEmitter {
|
struct NodeHookEmitter {
|
||||||
completed: Vec<ResolvedHook>,
|
completed: Vec<ResolvedHook>,
|
||||||
failed: Vec<ResolvedHook>,
|
failed: Vec<ResolvedHook>,
|
||||||
@@ -1152,8 +1146,8 @@ mod integration_tests {
|
|||||||
which::which(name).is_ok()
|
which::which(name).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hooks_config(entries: &[(&str, &str)]) -> crate::hooks::HooksMap {
|
fn hooks_config(entries: &[(&str, &str)]) -> hooks::HooksMap {
|
||||||
let mut map = crate::hooks::HooksMap::new();
|
let mut map = hooks::HooksMap::new();
|
||||||
for (event, name) in entries {
|
for (event, name) in entries {
|
||||||
map.entry((*event).to_string()).or_default().push(HookDef {
|
map.entry((*event).to_string()).or_default().push(HookDef {
|
||||||
name: (*name).to_string(),
|
name: (*name).to_string(),
|
||||||
@@ -1163,7 +1157,7 @@ mod integration_tests {
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ctx_with_global_hooks(hooks: crate::hooks::HooksMap) -> RequestContext {
|
fn ctx_with_global_hooks(hooks: hooks::HooksMap) -> RequestContext {
|
||||||
let mut app = AppState::test_default();
|
let mut app = AppState::test_default();
|
||||||
app.config = Arc::new(AppConfig {
|
app.config = Arc::new(AppConfig {
|
||||||
hooks,
|
hooks,
|
||||||
@@ -1172,8 +1166,8 @@ mod integration_tests {
|
|||||||
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hook_captures_named(name: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
crate::hooks::test_sink::snapshot()
|
hooks::test_sink::snapshot()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|capture| capture.hook_name == name)
|
.filter(|capture| capture.hook_name == name)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1328,7 +1322,7 @@ nodes:
|
|||||||
/// node also yields `graph.node.completed`, never `graph.node.failed`.
|
/// node also yields `graph.node.completed`, never `graph.node.failed`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
|
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
|
||||||
let _sink = crate::hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let mut ctx = ctx_with_global_hooks(hooks_config(&[
|
let mut ctx = ctx_with_global_hooks(hooks_config(&[
|
||||||
("agent.failed", "t037_fb_agent_failed"),
|
("agent.failed", "t037_fb_agent_failed"),
|
||||||
("graph.node.completed", "t037_fb_node_completed"),
|
("graph.node.completed", "t037_fb_node_completed"),
|
||||||
@@ -1404,7 +1398,7 @@ nodes:
|
|||||||
eprintln!("skipping: bash not available");
|
eprintln!("skipping: bash not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _sink = crate::hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let ws = TestWorkspace::new();
|
let ws = TestWorkspace::new();
|
||||||
for script in [
|
for script in [
|
||||||
"dispatcher.sh",
|
"dispatcher.sh",
|
||||||
@@ -1512,7 +1506,7 @@ nodes:
|
|||||||
/// event stays gated off because the agent whitelisted nothing.
|
/// event stays gated off because the agent whitelisted nothing.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
|
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
|
||||||
let _sink = crate::hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let mut ctx =
|
let mut ctx =
|
||||||
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
|
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
|
||||||
|
|
||||||
@@ -1571,7 +1565,7 @@ nodes:
|
|||||||
/// failure then propagates out of the executor.
|
/// failure then propagates out of the executor.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
|
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
|
||||||
let _sink = crate::hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
|
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
|
||||||
"graph.node.failed",
|
"graph.node.failed",
|
||||||
"t037_gf_node_failed",
|
"t037_gf_node_failed",
|
||||||
@@ -1626,7 +1620,7 @@ nodes:
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_warn_collector();
|
||||||
let _sink = crate::hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let mut ctx = make_ctx();
|
let mut ctx = make_ctx();
|
||||||
|
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
|
|||||||
+1
-10
@@ -248,10 +248,6 @@ fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec<Resol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fires every hook configured for `event` in `ctx`. Fire-and-forget: each
|
|
||||||
/// hook runs in its own detached task and nothing about its outcome — exit
|
|
||||||
/// status, stdout, stderr — is awaited or read. `payload` carries the tool
|
|
||||||
/// arguments JSON for `tool.*` events; pass `None` otherwise.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn fire(
|
pub fn fire(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
@@ -263,6 +259,7 @@ pub fn fire(
|
|||||||
if resolved.is_empty() {
|
if resolved.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fire_resolved(event, resolved, base_envs(event, ctx), extras, payload);
|
fire_resolved(event, resolved, base_envs(event, ctx), extras, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +404,6 @@ pub(crate) fn base_envs_parts(
|
|||||||
envs
|
envs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the payload file fresh (`create_new`) with owner-only permissions,
|
|
||||||
/// so a pre-existing path — symlink or otherwise — is an error. On failure
|
|
||||||
/// the hook still runs, just without `COYOTE_HOOK_PAYLOAD_FILE`.
|
|
||||||
async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
|
async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
|
||||||
let mut options = tokio::fs::OpenOptions::new();
|
let mut options = tokio::fs::OpenOptions::new();
|
||||||
options.write(true).create_new(true);
|
options.write(true).create_new(true);
|
||||||
@@ -423,9 +417,6 @@ async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static PAYLOAD_DIR_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
|
static PAYLOAD_DIR_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
/// Directory payload files are written to: `std::env::temp_dir()`, except in
|
|
||||||
/// tests, where `PAYLOAD_DIR_OVERRIDE` redirects it without touching
|
|
||||||
/// process-global state like `TMPDIR`.
|
|
||||||
fn payload_dir() -> PathBuf {
|
fn payload_dir() -> PathBuf {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if let Some(dir) = PAYLOAD_DIR_OVERRIDE
|
if let Some(dir) = PAYLOAD_DIR_OVERRIDE
|
||||||
|
|||||||
@@ -706,11 +706,6 @@ async fn shell_execute(
|
|||||||
if code == 0 && app.save_shell_history {
|
if code == 0 && app.save_shell_history {
|
||||||
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
||||||
}
|
}
|
||||||
// `process::exit` never returns, so the dispatch
|
|
||||||
// wrapper's bracket close in `run()` is unreachable
|
|
||||||
// from here: close the top-level agent bracket now.
|
|
||||||
// The agent run itself succeeded — the exit code
|
|
||||||
// belongs to the user's shell command, not to us.
|
|
||||||
ctx.top_level_agent_finished(None);
|
ctx.top_level_agent_finished(None);
|
||||||
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||||
process::exit(code);
|
process::exit(code);
|
||||||
@@ -760,9 +755,6 @@ async fn shell_execute(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The LLM portion of one shell-execute turn — everything the turn bracket
|
|
||||||
/// measures. The interactive follow-up menu (execute/revise/describe/copy)
|
|
||||||
/// runs after the turn has already completed.
|
|
||||||
async fn shell_execute_turn(
|
async fn shell_execute_turn(
|
||||||
ctx: &mut RequestContext,
|
ctx: &mut RequestContext,
|
||||||
input: &Input,
|
input: &Input,
|
||||||
|
|||||||
@@ -1695,11 +1695,6 @@ fn print_turn_divider() {
|
|||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-compression seam for the ask loop: `session.compressed` fires only
|
|
||||||
/// when compression actually succeeded. A failed auto-compression is
|
|
||||||
/// survivable mid-session, so the error is logged and swallowed rather
|
|
||||||
/// than aborting the turn — unlike `.compress session`, which surfaces
|
|
||||||
/// the failure to the user.
|
|
||||||
async fn auto_compress_session(ctx: &mut RequestContext) {
|
async fn auto_compress_session(ctx: &mut RequestContext) {
|
||||||
match ctx.compress_session().await {
|
match ctx.compress_session().await {
|
||||||
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None),
|
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None),
|
||||||
|
|||||||
@@ -203,13 +203,6 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
|||||||
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
|
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
|
||||||
out.push(read_mixin(path)?);
|
out.push(read_mixin(path)?);
|
||||||
}
|
}
|
||||||
// Mirrors the role-hook working directory (role hook commands run with
|
|
||||||
// cwd = roles_dir), so `./hooks/*.sbx-mixin.yaml` beside a role resolves
|
|
||||||
// the same way the role's hook commands do — intentionally NOT
|
|
||||||
// paths::hooks_dir(). The agent-hook working directory
|
|
||||||
// (`<agents>/<agent>/hooks/` — agent hooks run with cwd = the agent's
|
|
||||||
// data dir) is deliberately not scanned here either; agent-dir scanning
|
|
||||||
// above covers `<agents>/<agent>/*.sbx-mixin.yaml` only.
|
|
||||||
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
|
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
|
||||||
out.push(read_mixin(path)?);
|
out.push(read_mixin(path)?);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user