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
+2
View File
@@ -50,6 +50,7 @@ pub(crate) fn select_handlers_for_matcher_inputs(
| HookEventName::PostToolUse
| HookEventName::SessionStart
| HookEventName::SubagentStart
| HookEventName::SubagentStop
| HookEventName::PreCompact
| HookEventName::PostCompact => {
if matcher_inputs.is_empty() {
@@ -147,6 +148,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope {
| HookEventName::PreCompact
| HookEventName::PostCompact
| HookEventName::UserPromptSubmit
| HookEventName::SubagentStop
| HookEventName::Stop => HookScope::Turn,
}
}
+1
View File
@@ -71,6 +71,7 @@ impl ConfiguredHandler {
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start",
codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop",
codex_protocol::protocol::HookEventName::Stop => "stop",
}
}
+32 -7
View File
@@ -87,6 +87,7 @@ use crate::schema::PreToolUsePermissionDecisionWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::SubagentStartCommandOutputWire;
use crate::schema::SubagentStopCommandOutputWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
@@ -280,22 +281,46 @@ pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitO
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
Some(stop_output(
wire.universal,
wire.decision,
wire.reason,
"Stop",
))
}
pub(crate) fn parse_subagent_stop(stdout: &str) -> Option<StopOutput> {
let wire: SubagentStopCommandOutputWire = parse_json(stdout)?;
Some(stop_output(
wire.universal,
wire.decision,
wire.reason,
"SubagentStop",
))
}
fn stop_output(
universal: HookUniversalOutputWire,
decision: Option<BlockDecisionWire>,
reason: Option<String>,
event_name: &str,
) -> StopOutput {
let should_block = matches!(decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
&& match reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message("Stop"))
Some(invalid_block_message(event_name))
} else {
None
};
Some(StopOutput {
universal: UniversalOutput::from(wire.universal),
StopOutput {
universal: UniversalOutput::from(universal),
should_block: should_block && invalid_block_reason.is_none(),
reason: wire.reason,
reason,
invalid_block_reason,
})
}
}
impl From<HookUniversalOutputWire> for UniversalOutput {
@@ -18,6 +18,8 @@ pub(crate) struct GeneratedHookSchemas {
pub session_start_command_output: Value,
pub subagent_start_command_input: Value,
pub subagent_start_command_output: Value,
pub subagent_stop_command_input: Value,
pub subagent_stop_command_output: Value,
pub user_prompt_submit_command_input: Value,
pub user_prompt_submit_command_output: Value,
pub stop_command_input: Value,
@@ -83,6 +85,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
"subagent-start.command.output",
include_str!("../../schema/generated/subagent-start.command.output.schema.json"),
),
subagent_stop_command_input: parse_json_schema(
"subagent-stop.command.input",
include_str!("../../schema/generated/subagent-stop.command.input.schema.json"),
),
subagent_stop_command_output: parse_json_schema(
"subagent-stop.command.output",
include_str!("../../schema/generated/subagent-stop.command.output.schema.json"),
),
user_prompt_submit_command_input: parse_json_schema(
"user-prompt-submit.command.input",
include_str!("../../schema/generated/user-prompt-submit.command.input.schema.json"),
@@ -130,6 +140,8 @@ mod tests {
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.subagent_start_command_input["type"], "object");
assert_eq!(schemas.subagent_start_command_output["type"], "object");
assert_eq!(schemas.subagent_stop_command_input["type"], "object");
assert_eq!(schemas.subagent_stop_command_output["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_input["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_output["type"], "object");
assert_eq!(schemas.stop_command_input["type"], "object");