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
Unverified
parent 40ad7be2b5
commit eee3e60db3
43 changed files with 813 additions and 77 deletions
+69
View File
@@ -29,6 +29,8 @@ const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input
const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json";
const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json";
const SUBAGENT_START_OUTPUT_FIXTURE: &str = "subagent-start.command.output.schema.json";
const SUBAGENT_STOP_INPUT_FIXTURE: &str = "subagent-stop.command.input.schema.json";
const SUBAGENT_STOP_OUTPUT_FIXTURE: &str = "subagent-stop.command.output.schema.json";
const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
@@ -91,6 +93,8 @@ pub(crate) enum HookEventNameWire {
UserPromptSubmit,
#[serde(rename = "SubagentStart")]
SubagentStart,
#[serde(rename = "SubagentStop")]
SubagentStop,
#[serde(rename = "Stop")]
Stop,
}
@@ -399,6 +403,21 @@ pub(crate) struct StopCommandOutputWire {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "subagent-stop.command.output")]
pub(crate) struct SubagentStopCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<BlockDecisionWire>,
/// Claude requires `reason` when `decision` is `block`; we enforce that
/// semantic rule during output parsing rather than in the JSON schema.
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum BlockDecisionWire {
#[serde(rename = "block")]
@@ -495,6 +514,27 @@ pub(crate) struct StopCommandInput {
pub last_assistant_message: NullableString,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "subagent-stop.command.input")]
pub(crate) struct SubagentStopCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub agent_transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "subagent_stop_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub stop_hook_active: bool,
pub agent_id: String,
pub agent_type: String,
pub last_assistant_message: NullableString,
}
pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
@@ -563,6 +603,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE),
schema_json::<SubagentStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE),
schema_json::<SubagentStopCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE),
schema_json::<SubagentStopCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
@@ -658,6 +706,10 @@ fn subagent_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SubagentStart")
}
fn subagent_stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SubagentStop")
}
fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Stop")
}
@@ -730,8 +782,11 @@ mod tests {
use super::STOP_OUTPUT_FIXTURE;
use super::SUBAGENT_START_INPUT_FIXTURE;
use super::SUBAGENT_START_OUTPUT_FIXTURE;
use super::SUBAGENT_STOP_INPUT_FIXTURE;
use super::SUBAGENT_STOP_OUTPUT_FIXTURE;
use super::StopCommandInput;
use super::SubagentStartCommandInput;
use super::SubagentStopCommandInput;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
use super::UserPromptSubmitCommandInput;
@@ -791,6 +846,12 @@ mod tests {
SUBAGENT_START_OUTPUT_FIXTURE => {
include_str!("../schema/generated/subagent-start.command.output.schema.json")
}
SUBAGENT_STOP_INPUT_FIXTURE => {
include_str!("../schema/generated/subagent-stop.command.input.schema.json")
}
SUBAGENT_STOP_OUTPUT_FIXTURE => {
include_str!("../schema/generated/subagent-stop.command.output.schema.json")
}
STOP_INPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.input.schema.json")
}
@@ -828,6 +889,8 @@ mod tests {
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE,
SUBAGENT_START_INPUT_FIXTURE,
SUBAGENT_START_OUTPUT_FIXTURE,
SUBAGENT_STOP_INPUT_FIXTURE,
SUBAGENT_STOP_OUTPUT_FIXTURE,
STOP_INPUT_FIXTURE,
STOP_OUTPUT_FIXTURE,
] {
@@ -875,6 +938,11 @@ mod tests {
.expect("serialize subagent start input schema"),
)
.expect("parse subagent start input schema");
let subagent_stop: Value = serde_json::from_slice(
&schema_json::<SubagentStopCommandInput>()
.expect("serialize subagent stop input schema"),
)
.expect("parse subagent stop input schema");
let stop: Value = serde_json::from_slice(
&schema_json::<StopCommandInput>().expect("serialize stop input schema"),
)
@@ -888,6 +956,7 @@ mod tests {
&post_compact,
&user_prompt_submit,
&subagent_start,
&subagent_stop,
&stop,
] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");