feat(TASK-049): add escalation.raised/answered, session.resumed, and agent.interrupted hook events
- escalation.raised fires in the child ctx after queue submit; escalation.answered fires in the parent ctx from the taken request, so both agree on origin envs - escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist never applies to these root-level events - session.resumed fires on the use_session resume leg, inverse of session.started - agent.interrupted replaces agent.completed/failed when the abort signal was ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm (agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal; exit_agent and the shell-exit path stay non-interrupted by passing no signal - document the deliberate non-fire paths (direct prompts, escalation timeout and parent-drop, graph approval nodes)
This commit is contained in:
8 files changed
+468
-41
No files matched your search
@@ -119,6 +119,7 @@ pub enum HookEvent {
|
||||
TurnInterrupted,
|
||||
TurnFailed,
|
||||
SessionStarted,
|
||||
SessionResumed,
|
||||
SessionEnded,
|
||||
SessionCompressed,
|
||||
ToolStarted,
|
||||
@@ -129,7 +130,10 @@ pub enum HookEvent {
|
||||
LlmRequestFailed,
|
||||
AgentStarted,
|
||||
AgentCompleted,
|
||||
AgentInterrupted,
|
||||
AgentFailed,
|
||||
EscalationRaised,
|
||||
EscalationAnswered,
|
||||
GraphNodeStarted,
|
||||
GraphNodeCompleted,
|
||||
GraphNodeFailed,
|
||||
@@ -146,6 +150,7 @@ impl HookEvent {
|
||||
HookEvent::TurnInterrupted => "turn.interrupted",
|
||||
HookEvent::TurnFailed => "turn.failed",
|
||||
HookEvent::SessionStarted => "session.started",
|
||||
HookEvent::SessionResumed => "session.resumed",
|
||||
HookEvent::SessionEnded => "session.ended",
|
||||
HookEvent::SessionCompressed => "session.compressed",
|
||||
HookEvent::ToolStarted => "tool.started",
|
||||
@@ -156,7 +161,10 @@ impl HookEvent {
|
||||
HookEvent::LlmRequestFailed => "llm.request.failed",
|
||||
HookEvent::AgentStarted => "agent.started",
|
||||
HookEvent::AgentCompleted => "agent.completed",
|
||||
HookEvent::AgentInterrupted => "agent.interrupted",
|
||||
HookEvent::AgentFailed => "agent.failed",
|
||||
HookEvent::EscalationRaised => "escalation.raised",
|
||||
HookEvent::EscalationAnswered => "escalation.answered",
|
||||
HookEvent::GraphNodeStarted => "graph.node.started",
|
||||
HookEvent::GraphNodeCompleted => "graph.node.completed",
|
||||
HookEvent::GraphNodeFailed => "graph.node.failed",
|
||||
@@ -220,6 +228,17 @@ fn resolve_hooks(
|
||||
let event_name = event.as_str();
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
// Escalations are a root/user-level concern: the user wires these hooks
|
||||
// to hear about children asking for help, so requiring every agent to
|
||||
// whitelist them through `global_hooks` would silence exactly the events
|
||||
// they were set up for. The per-agent gate never applies here (this also
|
||||
// skips the unknown-entry diagnostics: whitelisting an escalation hook is
|
||||
// harmless and needs no warning).
|
||||
let agent_gate = match event {
|
||||
HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None,
|
||||
_ => agent_gate,
|
||||
};
|
||||
|
||||
if let Some(defs) = global_hooks.get(event_name) {
|
||||
let cwd = paths::config_dir();
|
||||
for def in defs {
|
||||
@@ -1326,6 +1345,7 @@ mod tests {
|
||||
(HookEvent::TurnInterrupted, "turn.interrupted"),
|
||||
(HookEvent::TurnFailed, "turn.failed"),
|
||||
(HookEvent::SessionStarted, "session.started"),
|
||||
(HookEvent::SessionResumed, "session.resumed"),
|
||||
(HookEvent::SessionEnded, "session.ended"),
|
||||
(HookEvent::SessionCompressed, "session.compressed"),
|
||||
(HookEvent::ToolStarted, "tool.started"),
|
||||
@@ -1336,7 +1356,10 @@ mod tests {
|
||||
(HookEvent::LlmRequestFailed, "llm.request.failed"),
|
||||
(HookEvent::AgentStarted, "agent.started"),
|
||||
(HookEvent::AgentCompleted, "agent.completed"),
|
||||
(HookEvent::AgentInterrupted, "agent.interrupted"),
|
||||
(HookEvent::AgentFailed, "agent.failed"),
|
||||
(HookEvent::EscalationRaised, "escalation.raised"),
|
||||
(HookEvent::EscalationAnswered, "escalation.answered"),
|
||||
(HookEvent::GraphNodeStarted, "graph.node.started"),
|
||||
(HookEvent::GraphNodeCompleted, "graph.node.completed"),
|
||||
(HookEvent::GraphNodeFailed, "graph.node.failed"),
|
||||
@@ -1376,6 +1399,38 @@ mod tests {
|
||||
assert_eq!(names(&resolved), ["own"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_events_bypass_the_agent_whitelist_gate() {
|
||||
let mut global = hooks_map("escalation.raised", &[("watch", "cmd-raised")]);
|
||||
global.extend(hooks_map(
|
||||
"escalation.answered",
|
||||
&[("watch", "cmd-answered")],
|
||||
));
|
||||
global.extend(hooks_map("tool.started", &[("watch", "cmd-tool")]));
|
||||
|
||||
for event in [HookEvent::EscalationRaised, HookEvent::EscalationAnswered] {
|
||||
let resolved = resolve_hooks(event, &global, Some((&[], "gated-agent")), None, None);
|
||||
assert_eq!(
|
||||
names(&resolved),
|
||||
["watch"],
|
||||
"{} must resolve without a whitelist entry",
|
||||
event.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
let resolved = resolve_hooks(
|
||||
HookEvent::ToolStarted,
|
||||
&global,
|
||||
Some((&[], "gated-agent")),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
resolved.is_empty(),
|
||||
"non-escalation events stay behind the whitelist gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_admits_only_exact_global_entries() {
|
||||
let global = hooks_map(
|
||||
|
||||
Reference in new issue
Block a user