Add observation-only model request lifecycle hooks (#55144)

## What changed

- Add `PreModelRequest` and `PostModelRequest` hooks around model request attempts, including retries. Pass model, session, turn, and optional subagent context; report completion, failure, or cancellation with errors and token usage when available.
- Treat hook output as diagnostic data without allowing it to veto requests or alter model context. Wait for synchronous post-request hooks before dispatching tools, while asynchronous hooks allow streaming tool dispatch to continue.
- Apply memory-consolidation hook filtering and expose the new events through configuration, protocol schemas, generated clients, and the hooks browser.

## Testing

Add integration coverage for failures and retries, ignored control directives, cancellation, synchronous and asynchronous tool ordering, interrupted-turn resume and token accounting, and memory-consolidation filtering. Update hook schema fixtures and hooks browser snapshots.

GitOrigin-RevId: 6ed7b39fc8bb94cf467e350d07d97fcc729c21ce
This commit is contained in:
Vivian Fang
2026-10-18 22:43:56 +00:00
committed by copyberry
parent 8c076942cb
commit 70b3bb6d88
46 changed files with 1014 additions and 60 deletions
+2
View File
@@ -1488,6 +1488,8 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
HookEventName::PostToolUse => "PostToolUse",
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::PreModelRequest => "PreModelRequest",
HookEventName::PostModelRequest => "PostModelRequest",
HookEventName::SessionStart => "SessionStart",
HookEventName::SessionEnd => "SessionEnd",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
@@ -2346,6 +2346,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -13325,6 +13325,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -14502,6 +14504,13 @@
},
"type": "array"
},
"PostModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
@@ -14514,6 +14523,13 @@
},
"type": "array"
},
"PreModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
@@ -9252,6 +9252,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -10494,6 +10496,13 @@
},
"type": "array"
},
"PostModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -10506,6 +10515,13 @@
},
"type": "array"
},
"PreModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -829,6 +829,13 @@
},
"type": "array"
},
"PostModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -841,6 +848,13 @@
},
"type": "array"
},
"PreModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -7,6 +7,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -7,6 +7,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -22,6 +22,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -110,6 +110,8 @@
},
"HookEventName": {
"enum": [
"preModelRequest",
"postModelRequest",
"preToolUse",
"permissionRequest",
"postToolUse",
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop" | "interrupt";
export type HookEventName = "preModelRequest" | "postModelRequest" | "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop" | "interrupt";
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, Interrupt: Array<ConfiguredHookMatcherGroup>, };
export type ManagedHooksRequirements = { PreModelRequest: Array<ConfiguredHookMatcherGroup>, PostModelRequest: Array<ConfiguredHookMatcherGroup>, managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, Interrupt: Array<ConfiguredHookMatcherGroup>, };
@@ -439,7 +439,7 @@ pub struct ConfigRequirements {
pub in_app_browser: Option<InAppBrowserRequirements>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
#[experimental("configRequirements/read.hooks")]
pub hooks: Option<ManagedHooksRequirements>,
pub hooks: Option<Box<ManagedHooksRequirements>>,
pub enforce_residency: Option<ResidencyRequirement>,
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
@@ -612,6 +612,12 @@ pub struct InAppBrowserRequirements {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ManagedHooksRequirements {
#[serde(rename = "PreModelRequest", default)]
#[ts(rename = "PreModelRequest")]
pub pre_model_request: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "PostModelRequest", default)]
#[ts(rename = "PostModelRequest")]
pub post_model_request: Vec<ConfiguredHookMatcherGroup>,
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(rename = "PreToolUse")]
@@ -17,7 +17,7 @@ use serde::Serialize;
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop, Interrupt
PreModelRequest, PostModelRequest, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop, Interrupt
}
);
@@ -559,7 +559,10 @@ fn map_requirements_to_api(
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
hooks: requirements
.hooks
.map(map_hooks_requirements_to_api)
.map(Box::new),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
@@ -714,6 +717,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
hooks,
} = hooks;
let HookEventsToml {
pre_model_request,
post_model_request,
pre_tool_use,
permission_request,
post_tool_use,
@@ -726,11 +731,13 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
subagent_stop,
stop,
interrupt,
} = hooks;
} = *hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_model_request: map_hook_matcher_groups_to_api(pre_model_request),
post_model_request: map_hook_matcher_groups_to_api(post_model_request),
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
+1 -2
View File
@@ -2195,7 +2195,6 @@ mod tests {
use crate::ComputerUseMacosRequirementsToml;
use crate::ComputerUseWindowsExeRequirementToml;
use crate::ComputerUseWindowsRequirementsToml;
use crate::HookEventsToml;
use crate::McpServerCommandMatcher;
use crate::McpServerIdentity;
use crate::McpServerValueMatcher;
@@ -4406,7 +4405,7 @@ command = "python3 /enterprise/hooks/pre.py"
.set(ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
windows_managed_dir: None,
hooks: HookEventsToml::default(),
hooks: Default::default(),
})
.expect_err("managed hooks should reject drift");
+18 -4
View File
@@ -37,6 +37,10 @@ pub struct HookStateToml {
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HookEventsToml {
#[serde(rename = "PreModelRequest", default)]
pub pre_model_request: Vec<MatcherGroup>,
#[serde(rename = "PostModelRequest", default)]
pub post_model_request: Vec<MatcherGroup>,
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
@@ -66,6 +70,8 @@ pub struct HookEventsToml {
impl HookEventsToml {
pub fn is_empty(&self) -> bool {
let Self {
pre_model_request,
post_model_request,
pre_tool_use,
permission_request,
post_tool_use,
@@ -79,7 +85,9 @@ impl HookEventsToml {
stop,
interrupt,
} = self;
pre_tool_use.is_empty()
pre_model_request.is_empty()
&& post_model_request.is_empty()
&& pre_tool_use.is_empty()
&& permission_request.is_empty()
&& post_tool_use.is_empty()
&& pre_compact.is_empty()
@@ -95,6 +103,8 @@ impl HookEventsToml {
pub fn handler_count(&self) -> usize {
let Self {
pre_model_request,
post_model_request,
pre_tool_use,
permission_request,
post_tool_use,
@@ -109,6 +119,8 @@ impl HookEventsToml {
interrupt,
} = self;
[
pre_model_request,
post_model_request,
pre_tool_use,
permission_request,
post_tool_use,
@@ -128,15 +140,17 @@ impl HookEventsToml {
.sum()
}
pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec<MatcherGroup>); 12] {
pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec<MatcherGroup>); 14] {
self.matcher_groups_mut()
.map(|(event, groups)| (event, std::mem::take(groups)))
}
pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec<MatcherGroup>); 12] {
pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec<MatcherGroup>); 14] {
use HookEventName as Event;
[
(Event::PreModelRequest, &mut self.pre_model_request),
(Event::PostModelRequest, &mut self.post_model_request),
(Event::PreToolUse, &mut self.pre_tool_use),
(Event::PermissionRequest, &mut self.permission_request),
(Event::PostToolUse, &mut self.post_tool_use),
@@ -224,7 +238,7 @@ pub struct ManagedHooksRequirementsToml {
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(flatten)]
pub hooks: HookEventsToml,
pub hooks: Box<HookEventsToml>,
}
impl ManagedHooksRequirementsToml {
+2 -2
View File
@@ -257,7 +257,7 @@ command = "python3 /enterprise/place/pre.py"
ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: HookEventsToml {
hooks: Box::new(HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
@@ -270,7 +270,7 @@ command = "python3 /enterprise/place/pre.py"
}],
}],
..Default::default()
},
}),
}
);
}
@@ -99,7 +99,7 @@ impl HookMergeState {
incoming_inactive_dir,
source,
);
changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks);
changed |= append_hook_events(&mut existing.value.hooks, *incoming.hooks);
if changed {
merge_output_source(&mut existing.source, source);
}
@@ -204,6 +204,8 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -
// Destructure without `..` so new hook events cannot be introduced without
// deciding whether requirements layer merging should append them.
let HookEventsToml {
pre_model_request,
post_model_request,
pre_tool_use,
permission_request,
post_tool_use,
@@ -219,6 +221,8 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -
} = incoming;
let mut changed = false;
changed |= append_vec(&mut existing.pre_model_request, pre_model_request);
changed |= append_vec(&mut existing.post_model_request, post_model_request);
changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use);
changed |= append_vec(&mut existing.permission_request, permission_request);
changed |= append_vec(&mut existing.post_tool_use, post_tool_use);
+14
View File
@@ -2307,6 +2307,13 @@
},
"type": "array"
},
"PostModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"default": [],
"items": {
@@ -2321,6 +2328,13 @@
},
"type": "array"
},
"PreModelRequest": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"default": [],
"items": {
+38
View File
@@ -994,6 +994,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::PostToolUse => "PostToolUse",
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::PreModelRequest => "PreModelRequest",
HookEventName::PostModelRequest => "PostModelRequest",
HookEventName::SessionStart => "SessionStart",
HookEventName::SessionEnd => "SessionEnd",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
@@ -1072,6 +1074,42 @@ fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
}
}
pub(crate) fn hooks_for_model_request(
session: &Session,
step: &StepContext,
) -> Arc<codex_hooks::Hooks> {
let hooks = session.hooks();
match step.turn.session_source {
SessionSource::Internal(InternalSessionSource::MemoryConsolidation)
| SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => {
Arc::new(hooks.for_memory_consolidation(executor_hook_sources_for_step(step)))
}
_ => hooks,
}
}
pub(crate) async fn run_model_request_hooks(
session: &Arc<Session>,
step: &StepContext,
hooks: &codex_hooks::Hooks,
event: codex_hooks::ModelRequestHookEvent,
) {
let subagent = thread_spawn_subagent_hook_context(session, &step.turn);
let request = codex_hooks::ModelRequestHookInput {
session_id: session.session_id().to_string(),
turn_id: step.turn.sub_id.clone(),
cwd: tool_hook_cwd(&step.environments, &step.turn),
transcript_path: session.hook_transcript_path().await,
model: step.settings.model_info.slug.clone(),
event,
agent_id: subagent.as_ref().map(|context| context.agent_id.clone()),
agent_type: subagent.map(|context| context.agent_type),
};
emit_hook_started_events(session, &step.turn, hooks.preview_model_request(&request)).await;
let events = hooks.run_model_request(request).await;
emit_hook_completed_events(session, &step.turn, events).await;
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+1
View File
@@ -13088,6 +13088,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
step_context: StepContext::for_test(Arc::clone(&tc)),
turn_store: Arc::new(codex_extension_api::ExtensionData::new(tc.sub_id.clone())),
tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)),
defer_tool_dispatch: false,
cancellation_token: CancellationToken::new(),
};
+80 -4
View File
@@ -17,10 +17,12 @@ use crate::cyber_access_program;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::feedback_tags;
use crate::hook_runtime::drain_async_hook_results;
use crate::hook_runtime::hooks_for_model_request;
use crate::hook_runtime::inspect_pending_input;
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_model_request_hooks;
use crate::hook_runtime::run_pending_session_start_hooks;
use crate::hook_runtime::run_turn_stop_hooks;
use crate::mcp_skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
@@ -79,6 +81,9 @@ use codex_extension_api::TurnInputEnvironment;
use codex_features::Feature;
use codex_file_system::FindUpErrorPolicy;
use codex_file_system::find_nearest_ancestor_with_markers;
use codex_hooks::ModelRequestHookEvent::PostModelRequest;
use codex_hooks::ModelRequestHookEvent::PreModelRequest;
use codex_hooks::ModelRequestStatus;
use codex_login::CodexAuth;
use codex_model_provider::RemoteCompactionSupport;
use codex_protocol::ResponseItemId;
@@ -101,6 +106,8 @@ use codex_protocol::protocol::AgentReasoningSectionBreakEvent;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::PlanDeltaEvent;
use codex_protocol::protocol::ReasoningContentDeltaEvent;
@@ -2648,7 +2655,6 @@ async fn try_run_sampling_request(
step_context.settings.model_info.slug.as_str(),
turn_context.provider.info().name.as_str(),
);
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
if client_session.inference_tools_changed(&step_context.tool_router.model_visible_specs()) {
turn_context.turn_timing_state.record_tools_change();
}
@@ -2668,7 +2674,45 @@ async fn try_run_sampling_request(
let effort = sess
.reasoning_effort_for_request(&step_context.settings, super::RequestEffortUsage::Sampling)
.await;
let mut stream = client_session
let hooks = hooks_for_model_request(&sess, &step_context);
let post_mode = hooks.execution_mode_for_event(HookEventName::PostModelRequest);
let defer_tool_dispatch = post_mode == Some(HookExecutionMode::Sync);
let finish_post_request = post_mode.map(|_| {
let (post_request, completed) = tokio::sync::oneshot::channel();
let (hook_done, wait_for_hook) = tokio::sync::oneshot::channel();
hooks.schedule_async_task({
let hooks = Arc::clone(&hooks);
let session = Arc::clone(&sess);
let step = Arc::clone(&step_context);
async move {
let event = completed.await.unwrap_or(PostModelRequest {
status: ModelRequestStatus::Cancelled,
error: None,
token_usage: None,
});
run_model_request_hooks(&session, &step, &hooks, event).await;
let _ = hook_done.send(());
}
});
let cancellation_token = cancellation_token.clone();
async move |event| {
let _ = post_request.send(event);
if defer_tool_dispatch {
// Interrupt the wait, not the session-owned hook or final token accounting.
let _ = wait_for_hook.or_cancel(&cancellation_token).await;
}
}
});
if hooks
.execution_mode_for_event(HookEventName::PreModelRequest)
.is_some()
{
run_model_request_hooks(&sess, &step_context, &hooks, PreModelRequest).await;
}
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
let mut response_completed = false;
let mut request_token_usage = None;
let mut stream = match client_session
.stream(
prompt,
&step_context.settings.model_info,
@@ -2681,7 +2725,21 @@ async fn try_run_sampling_request(
)
.instrument(trace_span!("stream_request"))
.or_cancel(&cancellation_token)
.await??;
.await?
{
Ok(stream) => stream,
Err(error) => {
if let Some(finish_post_request) = finish_post_request {
finish_post_request(PostModelRequest {
status: ModelRequestStatus::Failed,
error: Some(error.to_string()),
token_usage: None,
})
.await;
}
return Err(error);
}
};
if cancellation_token.is_cancelled() {
return Err(CodexErr::TurnAborted);
}
@@ -2771,6 +2829,7 @@ async fn try_run_sampling_request(
Some(Ok(event)) => event,
Some(Err(err)) => {
if let CodexErrorDetails::ResponseIncomplete(incomplete) = err.details() {
request_token_usage = incomplete.token_usage.clone();
sess.record_incomplete_response_usage(
&turn_context,
&step_context.settings,
@@ -2866,6 +2925,7 @@ async fn try_run_sampling_request(
step_context: Arc::clone(&step_context),
turn_store: Arc::clone(&turn_store),
tool_runtime: tool_runtime.clone(),
defer_tool_dispatch,
cancellation_token: cancellation_token.child_token(),
};
@@ -3075,6 +3135,8 @@ async fn try_run_sampling_request(
usage_metadata,
end_turn,
} => {
response_completed = true;
request_token_usage = token_usage.clone();
sess.services
.analytics_events_client
.track_code_mode_tool_call(
@@ -3271,7 +3333,21 @@ async fn try_run_sampling_request(
};
drop(sampling_span);
drop(sampling_timing_guard);
if let Some(finish_post_request) = finish_post_request {
let (status, error) = match (response_completed, &outcome) {
(true, _) => (ModelRequestStatus::Completed, None),
(false, Err(error)) if !matches!(error.details(), CodexErrorDetails::TurnAborted) => {
(ModelRequestStatus::Failed, Some(error.to_string()))
}
_ => (ModelRequestStatus::Cancelled, None),
};
finish_post_request(PostModelRequest {
status,
error,
token_usage: request_token_usage,
})
.await;
}
flush_assistant_text_segments_all(
&sess,
&turn_context,
+17 -5
View File
@@ -231,6 +231,7 @@ pub(crate) struct HandleOutputCtx {
pub step_context: Arc<StepContext>,
pub turn_store: Arc<ExtensionData>,
pub tool_runtime: ToolCallRuntime,
pub defer_tool_dispatch: bool,
pub cancellation_token: CancellationToken,
}
@@ -347,11 +348,22 @@ pub(crate) async fn handle_output_item_done(
.await;
let cancellation_token = ctx.cancellation_token.child_token();
let tool_future: InFlightFuture<'static> = Box::pin(
ctx.tool_runtime
.clone()
.handle_tool_call(call, cancellation_token),
);
let runtime = ctx.tool_runtime.clone();
// handle_tool_call dispatches immediately, not when its future is polled.
// With a synchronous post-model hook, dispatch only when the turn drains tools.
let tool_future: InFlightFuture<'static> = if ctx.defer_tool_dispatch {
Box::pin(async move {
if cancellation_token.is_cancelled() {
// The call is already in history; resume needs its output even if we never dispatch.
let output =
ToolCallRuntime::aborted_response(&call, /*elapsed_secs*/ None);
return Ok(output.into_response());
}
runtime.handle_tool_call(call, cancellation_token).await
})
} else {
Box::pin(runtime.handle_tool_call(call, cancellation_token))
};
output.needs_follow_up = true;
output.tool_future = Some(tool_future);
@@ -314,6 +314,7 @@ fn output_context(session: Arc<Session>, turn_context: Arc<TurnContext>) -> Hand
step_context,
turn_store: Arc::new(ExtensionData::new(turn_context.sub_id.clone())),
tool_runtime,
defer_tool_dispatch: false,
cancellation_token: CancellationToken::new(),
}
}
+8 -5
View File
@@ -273,7 +273,7 @@ impl ToolCallRuntime {
match dispatch_handle.await {
Ok(result) => result,
Err(err) if err.is_cancelled() => {
let response = Self::aborted_response(&call, secs);
let response = Self::aborted_response(&call, Some(secs));
call_trace::result_ready(
thread_id,
&abort_turn.sub_id,
@@ -313,7 +313,7 @@ impl ToolCallRuntime {
.await;
result = Ok(Self::aborted_response(
&call,
started.elapsed().as_secs_f32().max(0.1),
Some(started.elapsed().as_secs_f32().max(0.1)),
));
}
// Use one completion measurement for logging and response formatting.
@@ -362,18 +362,21 @@ impl ToolCallRuntime {
}
}
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
pub(crate) fn aborted_response(call: &ToolCall, elapsed_secs: Option<f32>) -> AnyToolResult {
AnyToolResult {
call_id: call.call_id.clone(),
payload: call.payload.clone(),
result: Box::new(AbortedToolOutput {
message: Self::abort_message(call, secs),
message: Self::abort_message(call, elapsed_secs),
}),
post_tool_use_payload: None,
}
}
fn abort_message(call: &ToolCall, secs: f32) -> String {
fn abort_message(call: &ToolCall, elapsed_secs: Option<f32>) -> String {
let Some(secs) = elapsed_secs else {
return "aborted by user before dispatch".to_string();
};
if call.tool_name.is_default_namespace() && call.tool_name.name == "exec_command" {
format!("Wall time: {secs:.1} seconds\naborted by user")
} else {
+296
View File
@@ -31,6 +31,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -5985,3 +5986,298 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<(
Ok(())
}
fn write_lifecycle_hooks(home: &Path, events: &[&str]) -> Result<()> {
let mut hooks = serde_json::Map::new();
for event in events {
let log = home.join(format!("{event}.jsonl"));
let command = format!(
"cat >> '{log}'; echo >> '{log}'; printf '%s' '{{\"continue\":false,\"decision\":\"block\",\"reason\":\"ignored-observer-control\"}}'",
log = log.display(),
);
hooks.insert(
(*event).into(),
json!([{"hooks": [{"type": "command", "command": command}]}]),
);
}
fs::write(home.join("hooks.json"), json!({"hooks": hooks}).to_string())?;
Ok(())
}
async fn wait_for_hook_inputs(home: &Path, event: &str, count: usize) -> Result<Vec<Value>> {
timeout(Duration::from_secs(10), async {
loop {
if let Ok(inputs) = read_hook_inputs_from_log(&home.join(format!("{event}.jsonl")))
&& inputs.len() >= count
{
return Ok(inputs);
}
sleep(Duration::from_millis(10)).await;
}
})
.await?
}
#[tokio::test]
async fn model_request_hooks_report_failures_and_retries_but_cannot_veto() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let requests = mount_sse_sequence(
&server,
vec![
sse(vec![ev_response_created("failed-attempt")]),
sse(vec![
ev_response_created("successful-attempt"),
ev_assistant_message("answer", "done"),
ev_completed_with_tokens("successful-attempt", /*total_tokens*/ 42),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
write_lifecycle_hooks(home, &["PreModelRequest", "PostModelRequest"]).unwrap()
})
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
test.submit_turn("respond").await?;
for request in requests.requests() {
assert!(
!request
.body_json()
.to_string()
.contains("ignored-observer-control")
);
}
let pre = wait_for_hook_inputs(test.codex_home_path(), "PreModelRequest", /*count*/ 2).await?;
let post =
wait_for_hook_inputs(test.codex_home_path(), "PostModelRequest", /*count*/ 2).await?;
assert_eq!(pre.len(), 2);
assert_eq!(post.len(), 2);
assert_eq!(post[0]["status"], "failed");
assert!(post[0]["error"].is_string());
assert_eq!(post[1]["status"], "completed");
assert_eq!(post[1]["error"], Value::Null);
assert_eq!(post[0]["token_usage"], Value::Null);
assert_eq!(
post[1]["token_usage"],
json!({
"input_tokens": 42,
"cached_input_tokens": 0,
"cache_write_input_tokens": 0,
"output_tokens": 0,
"reasoning_output_tokens": 0,
"total_tokens": 42,
})
);
for (before, after) in pre.iter().zip(&post) {
let mut common = after.clone();
common["hook_event_name"] = json!("PreModelRequest");
let fields = common.as_object_mut().unwrap();
fields.remove("status");
fields.remove("error");
fields.remove("token_usage");
assert_eq!(before, &common);
assert!(before["session_id"].is_string());
assert!(before["cwd"].is_string());
}
Ok(())
}
#[tokio::test]
async fn model_request_observer_reports_cancellation() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let request_received = Arc::new(tokio::sync::Notify::new());
let notify = Arc::clone(&request_received);
responses::mount_response_once_match(
&server,
move |_: &wiremock::Request| {
notify.notify_one();
true
},
responses::sse_response(responses::sse_completed("initial"))
.set_delay(Duration::from_secs(60)),
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
write_lifecycle_hooks(home, &["PreModelRequest", "PostModelRequest"]).unwrap()
})
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
test.codex
.start_or_steer_turn(codex_core::TurnInputRequest::user_input(vec![
codex_protocol::user_input::UserInput::Text {
text: "respond".into(),
text_elements: vec![],
},
]))
.await?;
timeout(Duration::from_secs(10), request_received.notified()).await?;
test.codex
.submit(codex_protocol::protocol::Op::Interrupt)
.await?;
let post =
wait_for_hook_inputs(test.codex_home_path(), "PostModelRequest", /*count*/ 1).await?;
assert_eq!(post.len(), 1);
assert_eq!(post[0]["status"], "cancelled");
assert_eq!(post[0]["token_usage"], Value::Null);
Ok(())
}
#[test_case::test_case(Some(false), false; "sync finishes before dispatch")]
#[test_case::test_case(Some(false), true; "resume after interrupt preserves aborted custom call and usage")]
#[test_case::test_case(Some(true), false; "async dispatches before stream ends")]
#[test_case::test_case(None, false; "no post hook dispatches before stream ends")]
#[tokio::test]
async fn model_request_post_hook_orders_tool_dispatch(
hook_async: Option<bool>,
interrupt: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let (finish_stream, gate) = oneshot::channel();
let call = if interrupt {
ev_apply_patch_custom_tool_call(
"call",
"*** Begin Patch\n*** Add File: should-not-exist\n+unexpected\n*** End Patch",
)
} else {
ev_function_call("call", "test_sync_tool", "{}")
};
let first = vec![
StreamingSseChunk {
gate: None,
body: sse(vec![ev_response_created("first"), call]),
},
StreamingSseChunk {
gate: Some(gate),
body: sse_event(ev_completed_with_tokens("first", /*total_tokens*/ 42)),
},
];
let second = vec![StreamingSseChunk {
gate: None,
body: sse(vec![ev_response_created("second"), ev_completed("second")]),
}];
let (server, _) = start_streaming_sse_server(vec![first, second]).await;
let test = test_codex()
.with_model("test-gpt-5.1-codex")
.with_pre_build_hook(move |home| {
let order = home.join("order").display().to_string();
let tool = format!("echo tool >> '{order}'");
let mut hooks =
json!({"PreToolUse": [{"hooks": [{"type": "command", "command": tool}]}]});
if let Some(hook_async) = hook_async {
let started = home.join("post-started");
let release = home.join("release-post");
let post = format!(
"touch '{}'; while test ! -e '{}'; do sleep 0.02; done; echo post >> '{order}'",
started.display(),
release.display(),
);
hooks["PostModelRequest"] = json!([{"hooks": [{
"type": "command", "command": post, "async": hook_async,
}]}]);
}
fs::write(home.join("hooks.json"), json!({"hooks": hooks}).to_string())
.expect("write hook configuration");
})
.with_config(trust_discovered_hooks)
.build_with_streaming_server(&server)
.await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "use the tool".into(),
text_elements: vec![],
}]))
.await?;
let home = test.codex_home_path();
let order = home.join("order");
let sync_post = hook_async == Some(false);
if !sync_post {
// Dispatch must not depend on the terminal response event, which we still hold.
fs_wait::wait_for_path_exists(&order, Duration::from_secs(10)).await?;
}
finish_stream.send(()).expect("release response stream");
let release = home.join("release-post");
if sync_post {
fs_wait::wait_for_path_exists(home.join("post-started"), Duration::from_secs(10)).await?;
}
if interrupt {
test.codex.submit(Op::Interrupt).await?;
wait_for_event(&test.codex, |event| matches!(event,
EventMsg::TokenCount(ev) if ev.info.as_ref().is_some_and(|info| info.total_token_usage.total_tokens == 42)
)).await;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnAborted(_))
})
.await;
assert!(
!order.exists(),
"interrupt must not dispatch the deferred tool"
);
fs::write(release, "")?;
assert!(!test.workspace_path("should-not-exist").exists());
let server = start_mock_server().await;
let requests = mount_sse_once(&server, responses::sse_completed("resumed")).await;
let resumed = test_codex().restart(&server, &test).await?;
let usage = resumed
.codex
.token_usage_info()
.await
.context("restored usage")?;
assert_eq!(usage.total_token_usage.total_tokens, 42);
resumed.submit_turn("continue after interrupt").await?;
let (output, _) = requests
.single_request()
.custom_tool_call_output_content_and_success("call")
.context("aborted custom tool output")?;
assert_eq!(output.as_deref(), Some("aborted by user before dispatch"));
} else {
fs::write(release, "")?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let events = fs::read_to_string(order)?;
assert!(
events.starts_with(if sync_post { "post\ntool\n" } else { "tool\n" }),
"{events}"
);
}
Ok(())
}
#[tokio::test]
async fn memory_consolidation_skips_user_model_request_hooks() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = mount_sse_once(&server, responses::sse_completed("memory")).await;
let mut test = test_codex()
.with_pre_build_hook(|home| {
write_lifecycle_hooks(home, &["PreModelRequest", "PostModelRequest"])
.expect("write synchronous model request hooks");
})
.with_config(trust_discovered_hooks)
.build(&server)
.await?;
let thread = test
.thread_manager
.start_thread(StartThreadOptions {
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
..StartThreadOptions::new(test.config.clone())
})
.await?;
test.codex = thread.thread;
test.submit_turn("consolidate").await?;
response.single_request();
let home = test.codex_home_path();
for event in ["PreModelRequest", "PostModelRequest"] {
assert!(!home.join(format!("{event}.jsonl")).exists(), "{event} ran");
}
Ok(())
}
@@ -0,0 +1,141 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ModelRequestStatus": {
"enum": [
"completed",
"failed",
"cancelled"
],
"type": "string"
},
"NullableString": {
"type": [
"string",
"null"
]
},
"TokenUsage": {
"properties": {
"cache_write_input_tokens": {
"default": 0,
"format": "int64",
"type": "integer"
},
"cached_input_tokens": {
"format": "int64",
"type": "integer"
},
"input_tokens": {
"format": "int64",
"type": "integer"
},
"output_tokens": {
"format": "int64",
"type": "integer"
},
"reasoning_output_tokens": {
"format": "int64",
"type": "integer"
},
"total_tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"cached_input_tokens",
"input_tokens",
"output_tokens",
"reasoning_output_tokens",
"total_tokens"
],
"type": "object"
}
},
"oneOf": [
{
"properties": {
"hook_event_name": {
"enum": [
"PreModelRequest"
],
"type": "string"
}
},
"required": [
"hook_event_name"
],
"type": "object"
},
{
"properties": {
"error": {
"$ref": "#/definitions/NullableString"
},
"hook_event_name": {
"enum": [
"PostModelRequest"
],
"type": "string"
},
"status": {
"$ref": "#/definitions/ModelRequestStatus"
},
"token_usage": {
"anyOf": [
{
"$ref": "#/definitions/TokenUsage"
},
{
"type": "null"
}
]
}
},
"required": [
"error",
"hook_event_name",
"status",
"token_usage"
],
"type": "object"
}
],
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"model": {
"type": "string"
},
"session_id": {
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"type": "string"
}
},
"required": [
"cwd",
"model",
"session_id",
"transcript_path",
"turn_id"
],
"title": "model-request.command.input",
"type": "object"
}
+1 -1
View File
@@ -259,7 +259,7 @@ fn append_managed_requirement_handlers(
config_layer_stack: None,
plugin_root: None,
},
managed_hooks.get().hooks.clone(),
managed_hooks.get().hooks.as_ref().clone(),
policy,
);
required_load_errors
+9 -3
View File
@@ -67,9 +67,11 @@ pub(crate) fn select_handlers_for_matcher_inputs(
.any(|input| matches_matcher(handler.matcher.as_ref(), Some(input)))
}
}
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => {
true
}
HookEventName::PreModelRequest
| HookEventName::PostModelRequest
| HookEventName::UserPromptSubmit
| HookEventName::Stop
| HookEventName::Interrupt => true,
})
.cloned()
.collect()
@@ -267,6 +269,8 @@ pub(crate) fn scope_for_event(event_name: HookEventName) -> HookScope {
| HookEventName::UserPromptSubmit
| HookEventName::SubagentStop
| HookEventName::Stop
| HookEventName::PreModelRequest
| HookEventName::PostModelRequest
| HookEventName::Interrupt => HookScope::Turn,
}
}
@@ -278,6 +282,8 @@ pub(crate) fn hook_event_name_label(event_name: HookEventName) -> &'static str {
HookEventName::PostToolUse => "PostToolUse",
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::PreModelRequest => "PreModelRequest",
HookEventName::PostModelRequest => "PostModelRequest",
HookEventName::SessionStart => "SessionStart",
HookEventName::SessionEnd => "SessionEnd",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
+21
View File
@@ -145,6 +145,25 @@ pub(crate) struct HandlerRunResult {
}
impl ConfiguredHandler {
/// Internal memory work runs policy and executor hooks, not user or project hooks.
pub(crate) fn runs_for_memory_consolidation(&self) -> bool {
matches!(self.source_path, HandlerSourcePath::ExecutorScoped { .. })
|| match self.source {
HookSource::User
| HookSource::Project
| HookSource::SessionFlags
| HookSource::Plugin => false,
HookSource::System
| HookSource::Mdm
| HookSource::CloudRequirements
| HookSource::CloudManagedConfig
| HookSource::LegacyManagedConfigFile
| HookSource::LegacyManagedConfigMdm
// Required hooks can have unknown attribution; retain them fail-closed.
| HookSource::Unknown => true,
}
}
pub(crate) fn execution_mode(&self) -> HookExecutionMode {
if matches!(self.source_path, HandlerSourcePath::ExecutorScoped { .. }) {
return HookExecutionMode::Async;
@@ -178,6 +197,8 @@ impl ConfiguredHandler {
codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use",
codex_protocol::protocol::HookEventName::PreCompact => "pre-compact",
codex_protocol::protocol::HookEventName::PostCompact => "post-compact",
codex_protocol::protocol::HookEventName::PreModelRequest => "pre-model-request",
codex_protocol::protocol::HookEventName::PostModelRequest => "post-model-request",
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::SessionEnd => "session-end",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
+1 -1
View File
@@ -144,7 +144,7 @@ fn managed_hooks_for_current_platform(
} else {
None
},
hooks,
hooks: Box::new(hooks),
}
}
+5 -1
View File
@@ -125,7 +125,11 @@ pub(crate) fn matcher_pattern_for_event(
| HookEventName::SubagentStop
| HookEventName::PreCompact
| HookEventName::PostCompact => matcher,
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => None,
HookEventName::PreModelRequest
| HookEventName::PostModelRequest
| HookEventName::UserPromptSubmit
| HookEventName::Stop
| HookEventName::Interrupt => None,
}
}
+2
View File
@@ -8,3 +8,5 @@ pub mod session_end;
pub mod session_start;
pub mod stop;
pub mod user_prompt_submit;
pub mod model_request;
+168
View File
@@ -0,0 +1,168 @@
//! Observation-only lifecycle hooks. Output is logged, never interpreted as control flow.
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::TokenUsage;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Serialize;
use super::common;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::HandlerRunResult;
use crate::engine::dispatcher;
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[serde(tag = "hook_event_name")]
pub enum ModelRequestHookEvent {
PreModelRequest,
PostModelRequest {
status: ModelRequestStatus,
#[schemars(with = "crate::schema::NullableString")]
error: Option<String>,
#[schemars(required, schema_with = "token_usage_schema")]
token_usage: Option<TokenUsage>,
},
}
// Hook schemas otherwise omit null from Option fields, but usage is always present on post.
fn token_usage_schema(
generator: &mut schemars::r#gen::SchemaGenerator,
) -> schemars::schema::Schema {
schemars::schema::SchemaObject {
subschemas: Some(Box::new(schemars::schema::SubschemaValidation {
any_of: Some(vec![
generator.subschema_for::<TokenUsage>(),
generator.subschema_for::<()>(),
]),
..Default::default()
})),
..Default::default()
}
.into()
}
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModelRequestStatus {
Completed,
Failed,
Cancelled,
}
impl ModelRequestHookEvent {
fn hook_event(&self) -> HookEventName {
match self {
Self::PreModelRequest => HookEventName::PreModelRequest,
Self::PostModelRequest { .. } => HookEventName::PostModelRequest,
}
}
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[schemars(rename = "model-request.command.input")]
pub struct ModelRequestHookInput {
pub session_id: String,
pub turn_id: String,
pub cwd: AbsolutePathBuf,
#[schemars(with = "crate::schema::NullableString")]
pub transcript_path: Option<std::path::PathBuf>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
#[serde(flatten)]
pub event: ModelRequestHookEvent,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &ModelRequestHookInput,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
request.event.hook_event(),
/*matcher_input*/ None,
)
.iter()
.map(dispatcher::running_summary)
.collect()
}
pub(crate) async fn run(
engine: &ClaudeHooksEngine,
request: ModelRequestHookInput,
) -> Vec<HookCompletedEvent> {
let matched = dispatcher::select_handlers(
&engine.handlers,
request.event.hook_event(),
/*matcher_input*/ None,
);
if matched.is_empty() {
return Vec::new();
}
let input = match serde_json::to_string(&request) {
Ok(input) => input,
Err(error) => {
return common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
error.to_string(),
);
}
};
dispatcher::execute_handlers(
engine,
matched,
input,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await
.into_iter()
.map(|result| result.completed)
.collect()
}
fn parse_completed(
handler: &ConfiguredHandler,
result: HandlerRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<()> {
let status = if result.error.is_none() && result.exit_code == Some(0) {
HookRunStatus::Completed
} else {
HookRunStatus::Failed
};
let mut entries = Vec::new();
// Observer stdout is diagnostic, including any JSON that looks like a control directive.
if !result.stdout.trim().is_empty() {
tracing::debug!(stdout = %result.stdout, "model request hook output");
}
if let Some(text) = common::trimmed_non_empty(&result.stderr) {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text,
});
}
if let Some(error) = &result.error {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.clone(),
});
}
dispatcher::ParsedHandler {
completed: HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &result, status, entries),
},
data: (),
completion_order: 0,
}
}
+1 -19
View File
@@ -8,7 +8,6 @@ use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Map;
use serde_json::Value;
@@ -70,24 +69,7 @@ impl StopHookTarget {
.into_iter()
.filter(|handler| {
!matches!(self, Self::MemoryConsolidation)
|| matches!(
handler.source_path,
HandlerSourcePath::ExecutorScoped { .. }
)
|| match handler.source {
HookSource::User
| HookSource::Project
| HookSource::SessionFlags
| HookSource::Plugin => false,
HookSource::System
| HookSource::Mdm
| HookSource::CloudRequirements
| HookSource::CloudManagedConfig
| HookSource::LegacyManagedConfigFile
| HookSource::LegacyManagedConfigMdm
// Required hooks can have unknown attribution; retain them fail-closed.
| HookSource::Unknown => true,
}
|| handler.runs_for_memory_consolidation()
})
.collect()
}
+9 -1
View File
@@ -21,7 +21,9 @@ pub use engine::dispatcher::hook_execution_mode_label;
pub use engine::dispatcher::hook_handler_type_label;
pub use events::common::SubagentHookContext;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 12] = [
pub const HOOK_EVENT_NAMES: [&str; 14] = [
"PreModelRequest",
"PostModelRequest",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
@@ -102,6 +104,8 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str {
HookEventName::PostToolUse => "post_tool_use",
HookEventName::PreCompact => "pre_compact",
HookEventName::PostCompact => "post_compact",
HookEventName::PreModelRequest => "pre_model_request",
HookEventName::PostModelRequest => "post_model_request",
HookEventName::SessionStart => "session_start",
HookEventName::SessionEnd => "session_end",
HookEventName::UserPromptSubmit => "user_prompt_submit",
@@ -124,3 +128,7 @@ pub fn hook_key(
hook_event_key_label(event_name)
)
}
pub use events::model_request::ModelRequestHookEvent;
pub use events::model_request::ModelRequestHookInput;
pub use events::model_request::ModelRequestStatus;
+40
View File
@@ -1,5 +1,6 @@
use crate::engine::ClaudeHooksEngine;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::HookListEntry;
use crate::engine::command_runner::CommandHookRuntime;
use crate::events::compact::PostCompactRequest;
@@ -32,8 +33,11 @@ use codex_config::ConfigLayerStack;
use codex_plugin::ExecutorPluginHookSource;
use codex_plugin::PluginHookSource;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
use std::ffi::OsString;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
@@ -115,6 +119,13 @@ impl Hooks {
hooks
}
pub fn for_memory_consolidation(&self, executor_hooks: Vec<ExecutorPluginHookSource>) -> Self {
let mut hooks = self.with_executor_hooks(executor_hooks);
Arc::make_mut(&mut hooks.engine.handlers)
.retain(ConfiguredHandler::runs_for_memory_consolidation);
hooks
}
fn from_config(
config: HooksConfig,
mcp_executor: Arc<dyn HookMcpExecutor>,
@@ -299,6 +310,35 @@ impl Hooks {
pub async fn run_interrupt(&self, request: InterruptRequest) -> InterruptOutcome {
self.engine.run_interrupt(request).await
}
/// None if unconfigured; Sync if any handler must finish before continuing.
pub fn execution_mode_for_event(&self, event: HookEventName) -> Option<HookExecutionMode> {
self.engine
.handlers
.iter()
.filter(|handler| handler.event_name == event)
.map(ConfiguredHandler::execution_mode)
.min_by_key(|mode| matches!(mode, HookExecutionMode::Async))
}
/// Run a hook task independently of the turn, but abort and join it at session shutdown.
pub fn schedule_async_task(&self, task: impl Future<Output = ()> + Send + 'static) {
self.engine.command_runtime.schedule_async_task(task);
}
pub fn preview_model_request(
&self,
request: &crate::ModelRequestHookInput,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
crate::events::model_request::preview(&self.engine.handlers, request)
}
pub async fn run_model_request(
&self,
request: crate::ModelRequestHookInput,
) -> Vec<codex_protocol::protocol::HookCompletedEvent> {
crate::events::model_request::run(&self.engine, request).await
}
}
pub fn list_hooks(config: HooksConfig) -> HookListOutcome {
+13
View File
@@ -15,6 +15,7 @@ use std::path::PathBuf;
use crate::events::common::SubagentHookContext;
const GENERATED_DIR: &str = "generated";
const MODEL_REQUEST_INPUT_FIXTURE: &str = "model-request.command.input.schema.json";
const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json";
@@ -641,6 +642,11 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
write_schema(
&generated_dir.join(MODEL_REQUEST_INPUT_FIXTURE),
schema_json::<crate::ModelRequestHookInput>()?,
)?;
write_schema(
&generated_dir.join(POST_TOOL_USE_INPUT_FIXTURE),
schema_json::<PostToolUseCommandInput>()?,
@@ -890,6 +896,7 @@ mod tests {
use super::INTERRUPT_INPUT_FIXTURE;
use super::INTERRUPT_OUTPUT_FIXTURE;
use super::InterruptCommandInput;
use super::MODEL_REQUEST_INPUT_FIXTURE;
use super::NullableString;
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
@@ -939,6 +946,9 @@ mod tests {
fn expected_fixture(name: &str) -> &'static str {
match name {
MODEL_REQUEST_INPUT_FIXTURE => {
include_str!("../schema/generated/model-request.command.input.schema.json")
}
POST_TOOL_USE_INPUT_FIXTURE => {
include_str!("../schema/generated/post-tool-use.command.input.schema.json")
}
@@ -1037,6 +1047,7 @@ mod tests {
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
for fixture in [
MODEL_REQUEST_INPUT_FIXTURE,
POST_TOOL_USE_INPUT_FIXTURE,
POST_TOOL_USE_OUTPUT_FIXTURE,
PERMISSION_REQUEST_INPUT_FIXTURE,
@@ -1172,6 +1183,8 @@ mod tests {
#[test]
fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() {
let schemas = [
schema_json::<crate::ModelRequestHookInput>()
.expect("serialize model request input schema"),
schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
schema_json::<PermissionRequestCommandInput>()
.expect("serialize permission request input schema"),
+2
View File
@@ -1703,6 +1703,8 @@ pub enum HookEventName {
SubagentStop,
Stop,
Interrupt,
PreModelRequest,
PostModelRequest,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
@@ -676,6 +676,8 @@ fn event_label(event_name: HookEventName) -> &'static str {
HookEventName::PostToolUse => "PostToolUse",
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::PreModelRequest => "PreModelRequest",
HookEventName::PostModelRequest => "PostModelRequest",
HookEventName::SessionStart => "SessionStart",
HookEventName::SessionEnd => "SessionEnd",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
@@ -693,6 +695,8 @@ fn event_description(event_name: HookEventName) -> &'static str {
HookEventName::PostToolUse => "After a tool executes",
HookEventName::PreCompact => "Before context compaction",
HookEventName::PostCompact => "After context compaction",
HookEventName::PreModelRequest => "Before a model request",
HookEventName::PostModelRequest => "After a model request, including failures",
HookEventName::SessionStart => "When a new session starts",
HookEventName::SessionEnd => "Right before a session ends",
HookEventName::UserPromptSubmit => "When the user submits a prompt",
@@ -891,6 +895,11 @@ mod tests {
fn renders_event_browser() {
let mut view = view();
assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112));
view.state.selected_idx = Some(view.event_rows().len() - 1);
assert_snapshot!(
"hooks_browser_model_requests",
render_lines(&view, /*width*/ 112),
);
view.keymap.accept.clear();
assert!(!render_lines(&view, /*width*/ 112).contains("enter details"));
}
@@ -9,12 +9,12 @@ expression: text
Event Active/Installed
↑
SessionEnd 0/0
UserPromptSubmit 0/0
SubagentStart 0/0
SubagentStop 0/0
Stop 0/0
› Interrupt 0/0
Interrupt 0/0
PreModelRequest 0/0
› PostModelRequest 0/0
enter details · esc close
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
Hooks
Lifecycle hooks from config and enabled plugins.
Event Installed Active Description
↑
SessionEnd 0 0 Right before a session ends
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
Stop 0 0 Right before Codex ends its turn
Interrupt 0 0 Right before an interrupted turn is aborted
PreModelRequest 0 0 Before a model request
› PostModelRequest 0 0 After a model request, including failures
enter details · esc close
+2 -2
View File
@@ -1386,7 +1386,7 @@ approval_policy = "never"
std::path::PathBuf::from("/enterprise/hooks")
}),
windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
hooks: Box::new(HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
@@ -1399,7 +1399,7 @@ approval_policy = "never"
}],
}],
..Default::default()
},
}),
}),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)),
@@ -1984,6 +1984,8 @@ class HookErrorInfo(BaseModel):
class HookEventName(Enum):
pre_model_request = "preModelRequest"
post_model_request = "postModelRequest"
pre_tool_use = "preToolUse"
permission_request = "permissionRequest"
post_tool_use = "postToolUse"
@@ -2422,8 +2424,14 @@ class ManagedHooksRequirements(BaseModel):
list[ConfiguredHookMatcherGroup], Field(alias="PermissionRequest")
]
post_compact: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PostCompact")]
post_model_request: Annotated[
list[ConfiguredHookMatcherGroup] | None, Field(alias="PostModelRequest")
] = []
post_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PostToolUse")]
pre_compact: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreCompact")]
pre_model_request: Annotated[
list[ConfiguredHookMatcherGroup] | None, Field(alias="PreModelRequest")
] = []
pre_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreToolUse")]
session_end: Annotated[list[ConfiguredHookMatcherGroup] | None, Field(alias="SessionEnd")] = []
session_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SessionStart")]