mirror of
https://github.com/openai/codex.git
synced 2026-05-28 06:55:01 +00:00
# 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.
196 lines
5.8 KiB
Rust
196 lines
5.8 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
use codex_protocol::protocol::HookEventName;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct HooksFile {
|
|
#[serde(default)]
|
|
pub hooks: HookEventsToml,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct HooksToml {
|
|
#[serde(flatten)]
|
|
pub events: HookEventsToml,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub state: BTreeMap<String, HookStateToml>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct HookStateToml {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub enabled: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub trusted_hash: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct HookEventsToml {
|
|
#[serde(rename = "PreToolUse", default)]
|
|
pub pre_tool_use: Vec<MatcherGroup>,
|
|
#[serde(rename = "PermissionRequest", default)]
|
|
pub permission_request: Vec<MatcherGroup>,
|
|
#[serde(rename = "PostToolUse", default)]
|
|
pub post_tool_use: Vec<MatcherGroup>,
|
|
#[serde(rename = "PreCompact", default)]
|
|
pub pre_compact: Vec<MatcherGroup>,
|
|
#[serde(rename = "PostCompact", default)]
|
|
pub post_compact: Vec<MatcherGroup>,
|
|
#[serde(rename = "SessionStart", default)]
|
|
pub session_start: Vec<MatcherGroup>,
|
|
#[serde(rename = "UserPromptSubmit", default)]
|
|
pub user_prompt_submit: Vec<MatcherGroup>,
|
|
#[serde(rename = "SubagentStart", default)]
|
|
pub subagent_start: Vec<MatcherGroup>,
|
|
#[serde(rename = "SubagentStop", default)]
|
|
pub subagent_stop: Vec<MatcherGroup>,
|
|
#[serde(rename = "Stop", default)]
|
|
pub stop: Vec<MatcherGroup>,
|
|
}
|
|
|
|
impl HookEventsToml {
|
|
pub fn is_empty(&self) -> bool {
|
|
let Self {
|
|
pre_tool_use,
|
|
permission_request,
|
|
post_tool_use,
|
|
pre_compact,
|
|
post_compact,
|
|
session_start,
|
|
user_prompt_submit,
|
|
subagent_start,
|
|
subagent_stop,
|
|
stop,
|
|
} = self;
|
|
pre_tool_use.is_empty()
|
|
&& permission_request.is_empty()
|
|
&& post_tool_use.is_empty()
|
|
&& pre_compact.is_empty()
|
|
&& post_compact.is_empty()
|
|
&& session_start.is_empty()
|
|
&& user_prompt_submit.is_empty()
|
|
&& subagent_start.is_empty()
|
|
&& subagent_stop.is_empty()
|
|
&& stop.is_empty()
|
|
}
|
|
|
|
pub fn handler_count(&self) -> usize {
|
|
let Self {
|
|
pre_tool_use,
|
|
permission_request,
|
|
post_tool_use,
|
|
pre_compact,
|
|
post_compact,
|
|
session_start,
|
|
user_prompt_submit,
|
|
subagent_start,
|
|
subagent_stop,
|
|
stop,
|
|
} = self;
|
|
[
|
|
pre_tool_use,
|
|
permission_request,
|
|
post_tool_use,
|
|
pre_compact,
|
|
post_compact,
|
|
session_start,
|
|
user_prompt_submit,
|
|
subagent_start,
|
|
subagent_stop,
|
|
stop,
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|group| group.hooks.len())
|
|
.sum()
|
|
}
|
|
|
|
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 10] {
|
|
[
|
|
(HookEventName::PreToolUse, self.pre_tool_use),
|
|
(HookEventName::PermissionRequest, self.permission_request),
|
|
(HookEventName::PostToolUse, self.post_tool_use),
|
|
(HookEventName::PreCompact, self.pre_compact),
|
|
(HookEventName::PostCompact, self.post_compact),
|
|
(HookEventName::SessionStart, self.session_start),
|
|
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
|
|
(HookEventName::SubagentStart, self.subagent_start),
|
|
(HookEventName::SubagentStop, self.subagent_stop),
|
|
(HookEventName::Stop, self.stop),
|
|
]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct MatcherGroup {
|
|
#[serde(default)]
|
|
pub matcher: Option<String>,
|
|
#[serde(default)]
|
|
pub hooks: Vec<HookHandlerConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(tag = "type")]
|
|
pub enum HookHandlerConfig {
|
|
#[serde(rename = "command")]
|
|
Command {
|
|
command: String,
|
|
#[serde(default, rename = "commandWindows", alias = "command_windows")]
|
|
command_windows: Option<String>,
|
|
#[serde(default, rename = "timeout")]
|
|
timeout_sec: Option<u64>,
|
|
#[serde(default)]
|
|
r#async: bool,
|
|
#[serde(default, rename = "statusMessage")]
|
|
status_message: Option<String>,
|
|
},
|
|
#[serde(rename = "prompt")]
|
|
Prompt {},
|
|
#[serde(rename = "agent")]
|
|
Agent {},
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ManagedHooksRequirementsToml {
|
|
pub managed_dir: Option<PathBuf>,
|
|
pub windows_managed_dir: Option<PathBuf>,
|
|
#[serde(flatten)]
|
|
pub hooks: HookEventsToml,
|
|
}
|
|
|
|
impl ManagedHooksRequirementsToml {
|
|
pub fn is_empty(&self) -> bool {
|
|
let Self {
|
|
managed_dir,
|
|
windows_managed_dir,
|
|
hooks,
|
|
} = self;
|
|
managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
|
|
}
|
|
|
|
pub fn handler_count(&self) -> usize {
|
|
self.hooks.handler_count()
|
|
}
|
|
|
|
pub fn managed_dir_for_current_platform(&self) -> Option<&Path> {
|
|
#[cfg(windows)]
|
|
{
|
|
self.windows_managed_dir.as_deref()
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
self.managed_dir.as_deref()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "hooks_tests.rs"]
|
|
mod tests;
|