Add SubagentStop hook (#22873)

# What

<img width="1792" height="1024" alt="image"
src="https://github.com/user-attachments/assets/8f81d232-5813-4994-a61d-e42a05a93a3e"
/>

`SubagentStop` runs when a thread-spawned subagent turn is about to
finish. Thread-spawned subagents use `SubagentStop` instead of the
normal root-agent `Stop` hook.

Configured handlers match on `agent_type`. Hook input includes the
normal stop fields plus:

- `agent_id`: the child thread id.
- `agent_type`: the resolved subagent type.
- `agent_transcript_path`: the child subagent transcript path.
- `transcript_path`: the parent thread transcript path.
- `last_assistant_message`: the final assistant message from the child
turn, when available.
- `stop_hook_active`: `true` when the child is already continuing
because an earlier stop-like hook blocked completion.

`SubagentStop` shares the same completion-control semantics as `Stop`,
scoped to the child turn:

- No decision allows the child turn to finish.
- `decision: "block"` with a non-empty `reason` records that reason as
hook feedback and continues the child with that prompt.
- `continue: false` stops the child turn. If `stopReason` is present,
Codex surfaces it as the stop reason.

# Lifecycle Scope

Only thread-spawned subagents run `SubagentStop`.

Internal/system subagents such as Review, Compact, MemoryConsolidation,
and Other do not run normal `Stop` hooks and do not run `SubagentStop`.
This avoids exposing synthetic matcher labels for internal
implementation paths.

# Stack

1. #22782: add `SubagentStart`.
2. This PR: add `SubagentStop`.
3. #22882: add subagent identity to normal hook inputs.
This commit is contained in:
Abhinav
2026-05-20 14:59:41 -07:00
committed by GitHub
parent 40ad7be2b5
commit eee3e60db3
43 changed files with 813 additions and 77 deletions
+1
View File
@@ -105,6 +105,7 @@ pub(crate) fn matcher_pattern_for_event(
| HookEventName::PostToolUse
| HookEventName::SessionStart
| HookEventName::SubagentStart
| HookEventName::SubagentStop
| HookEventName::PreCompact
| HookEventName::PostCompact => matcher,
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
+141 -34
View File
@@ -18,6 +18,7 @@ use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::NullableString;
use crate::schema::StopCommandInput;
use crate::schema::SubagentStopCommandInput;
#[derive(Debug, Clone)]
pub struct StopRequest {
@@ -29,9 +30,36 @@ pub struct StopRequest {
pub permission_mode: String,
pub stop_hook_active: bool,
pub last_assistant_message: Option<String>,
pub target: StopHookTarget,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum StopHookTarget {
Stop,
SubagentStop {
agent_id: String,
agent_type: String,
agent_transcript_path: Option<PathBuf>,
},
}
impl StopHookTarget {
fn event_name(&self) -> HookEventName {
match self {
Self::Stop => HookEventName::Stop,
Self::SubagentStop { .. } => HookEventName::SubagentStop,
}
}
fn matcher_input(&self) -> Option<&str> {
match self {
Self::Stop => None,
Self::SubagentStop { agent_type, .. } => Some(agent_type.as_str()),
}
}
}
#[derive(Debug, Default)]
pub struct StopOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
@@ -52,12 +80,16 @@ struct StopHandlerData {
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &StopRequest,
request: &StopRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(handlers, HookEventName::Stop, /*matcher_input*/ None)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
dispatcher::select_handlers(
handlers,
request.target.event_name(),
request.target.matcher_input(),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
@@ -65,8 +97,11 @@ pub(crate) async fn run(
shell: &CommandShell,
request: StopRequest,
) -> StopOutcome {
let matched =
dispatcher::select_handlers(handlers, HookEventName::Stop, /*matcher_input*/ None);
let matched = dispatcher::select_handlers(
handlers,
request.target.event_name(),
request.target.matcher_input(),
);
if matched.is_empty() {
return StopOutcome {
hook_events: Vec::new(),
@@ -78,24 +113,67 @@ pub(crate) async fn run(
};
}
let input_json = match serde_json::to_string(&StopCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "Stop".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
stop_hook_active: request.stop_hook_active,
last_assistant_message: NullableString::from_string(request.last_assistant_message.clone()),
}) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize stop hook input: {error}"),
));
let input_json = match request.target {
StopHookTarget::Stop => {
let input = StopCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "Stop".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
stop_hook_active: request.stop_hook_active,
last_assistant_message: NullableString::from_string(
request.last_assistant_message.clone(),
),
};
match serde_json::to_string(&input) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize stop hook input: {error}"),
),
);
}
}
}
StopHookTarget::SubagentStop {
agent_id,
agent_type,
agent_transcript_path,
} => {
let input = SubagentStopCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: NullableString::from_path(request.transcript_path.clone()),
agent_transcript_path: NullableString::from_path(agent_transcript_path),
cwd: request.cwd.display().to_string(),
hook_event_name: "SubagentStop".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
stop_hook_active: request.stop_hook_active,
agent_id,
agent_type,
last_assistant_message: NullableString::from_string(
request.last_assistant_message.clone(),
),
};
match serde_json::to_string(&input) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize subagent stop hook input: {error}"),
),
);
}
}
}
};
@@ -133,6 +211,12 @@ fn parse_completed(
let mut should_block = false;
let mut block_reason = None;
let mut continuation_prompt = None;
let hook_event_name = match handler.event_name {
HookEventName::Stop | HookEventName::SubagentStop => handler.event_name,
event_name => {
panic!("expected stop hook event, got {event_name:?}");
}
};
match run_result.error.as_deref() {
Some(error) => {
@@ -146,7 +230,13 @@ fn parse_completed(
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_stop(&run_result.stdout) {
} else if let Some(parsed) = match hook_event_name {
HookEventName::Stop => output_parser::parse_stop(&run_result.stdout),
HookEventName::SubagentStop => {
output_parser::parse_subagent_stop(&run_result.stdout)
}
_ => unreachable!("validated stop hook event"),
} {
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
@@ -186,9 +276,12 @@ fn parse_completed(
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text:
"Stop hook returned decision:block without a non-empty reason"
.to_string(),
text: match hook_event_name {
HookEventName::Stop => "Stop hook returned decision:block without a non-empty reason",
HookEventName::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason",
_ => unreachable!("validated stop hook event"),
}
.to_string(),
});
}
}
@@ -196,7 +289,14 @@ fn parse_completed(
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid stop hook JSON output".to_string(),
text: match hook_event_name {
HookEventName::Stop => "hook returned invalid stop hook JSON output",
HookEventName::SubagentStop => {
"hook returned invalid subagent stop hook JSON output"
}
_ => unreachable!("validated stop hook event"),
}
.to_string(),
});
}
}
@@ -214,9 +314,16 @@ fn parse_completed(
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text:
"Stop hook exited with code 2 but did not write a continuation prompt to stderr"
.to_string(),
text: match hook_event_name {
HookEventName::Stop => {
"Stop hook exited with code 2 but did not write a continuation prompt to stderr"
}
HookEventName::SubagentStop => {
"SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr"
}
_ => unreachable!("validated stop hook event"),
}
.to_string(),
});
}
}