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
+75 -25
View File
@@ -14,8 +14,8 @@ use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::StartHookTarget;
use codex_hooks::StopHookTarget;
use codex_hooks::StopOutcome;
use codex_hooks::StopRequest;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
@@ -33,6 +33,7 @@ use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_thread_store::ReadThreadParams;
use serde_json::Value;
use crate::context::ContextualUserFragment;
@@ -288,6 +289,78 @@ pub(crate) async fn run_post_tool_use_hooks(
outcome
}
pub(crate) async fn run_turn_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
// Resolve the stop hook kind from the session source before building the
// request. Root turns run Stop; thread-spawned child turns run SubagentStop.
let (target, transcript_path) = match &turn_context.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
agent_role,
parent_thread_id,
..
}) => {
let agent_type = agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string());
let agent_transcript_path = sess.hook_transcript_path().await;
let parent_transcript_path = match sess
.services
.thread_store
.read_thread(ReadThreadParams {
thread_id: *parent_thread_id,
include_archived: true,
include_history: false,
})
.await
{
Ok(thread) => thread.rollout_path,
Err(error) => {
tracing::warn!(
parent_thread_id = %parent_thread_id,
error = %error,
"failed to resolve parent transcript path for subagent hook"
);
None
}
};
(
StopHookTarget::SubagentStop {
agent_id: sess.thread_id().to_string(),
agent_type,
agent_transcript_path,
},
parent_transcript_path,
)
}
// Internal/synthetic subagents do not expose user-configured lifecycle
// hooks, so there is no Stop or SubagentStop request to dispatch.
SessionSource::SubAgent(_) => return StopOutcome::default(),
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
};
let request = codex_hooks::StopRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
stop_hook_active,
last_assistant_message,
target,
};
let hooks = sess.hooks();
emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await;
let mut outcome = hooks.run_stop(request).await;
emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await;
outcome
}
pub(crate) async fn run_pre_compact_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -352,30 +425,6 @@ pub(crate) async fn run_post_compact_hooks(
}
}
pub(crate) async fn run_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
let request = StopRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
stop_hook_active,
last_assistant_message,
};
let hooks = sess.hooks();
emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await;
let mut outcome = hooks.run_stop(request).await;
emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await;
outcome
}
pub(crate) async fn run_legacy_after_agent_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -638,6 +687,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
};
let hook_source = match run.source {
+2 -2
View File
@@ -23,7 +23,7 @@ use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::hook_runtime::run_legacy_after_agent_hook;
use crate::hook_runtime::run_pending_session_start_hooks;
use crate::hook_runtime::run_stop_hooks;
use crate::hook_runtime::run_turn_stop_hooks;
use crate::injection::ToolMentionKind;
use crate::injection::app_id_from_path;
use crate::injection::tool_kind_for_path;
@@ -360,7 +360,7 @@ pub(crate) async fn run_turn(
if !needs_follow_up {
last_agent_message = sampling_request_last_agent_message;
let stop_outcome = run_stop_hooks(
let stop_outcome = run_turn_stop_hooks(
&sess,
&turn_context,
stop_hook_active,