diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 2cb33836b3..f62516be9a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 122d6dc781..1aab529ff4 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2346,6 +2346,8 @@ }, "HookEventName": { "enum": [ + "preModelRequest", + "postModelRequest", "preToolUse", "permissionRequest", "postToolUse", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 71959264ba..ae5861969e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 14330ebf18..d7e7b030de 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index f2faa53496..3a5dabd109 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index ad1681a6e5..a39a792603 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -7,6 +7,8 @@ }, "HookEventName": { "enum": [ + "preModelRequest", + "postModelRequest", "preToolUse", "permissionRequest", "postToolUse", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 0f11b12c0c..8014f1a926 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -7,6 +7,8 @@ }, "HookEventName": { "enum": [ + "preModelRequest", + "postModelRequest", "preToolUse", "permissionRequest", "postToolUse", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 837e0b2660..fbf38c764f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -22,6 +22,8 @@ }, "HookEventName": { "enum": [ + "preModelRequest", + "postModelRequest", "preToolUse", "permissionRequest", "postToolUse", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index f3832617f1..2123c0b8eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -110,6 +110,8 @@ }, "HookEventName": { "enum": [ + "preModelRequest", + "postModelRequest", "preToolUse", "permissionRequest", "postToolUse", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 7287d2f1c3..77ef348efc 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index e8e507d122..084cb8115d 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 5425272c30..f1d63296df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 777a5319e2..caa3e02df0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -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, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, SessionEnd: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, Interrupt: Array, }; +export type ManagedHooksRequirements = { PreModelRequest: Array, PostModelRequest: Array, managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, SessionEnd: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, Interrupt: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 2953911602..f9d7b14c6b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -439,7 +439,7 @@ pub struct ConfigRequirements { pub in_app_browser: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] - pub hooks: Option, + pub hooks: Option>, pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, @@ -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, + #[serde(rename = "PostModelRequest", default)] + #[ts(rename = "PostModelRequest")] + pub post_model_request: Vec, pub managed_dir: Option, pub windows_managed_dir: Option, #[serde(rename = "PreToolUse")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index b2f944e1ae..3345e167f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -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 } ); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index d903a6fce2..ff87f767a6 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -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), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 464c55e94a..567a8bb224 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -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"); diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 31f82cb829..7fd1eef6cd 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -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, + #[serde(rename = "PostModelRequest", default)] + pub post_model_request: Vec, #[serde(rename = "PreToolUse", default)] pub pre_tool_use: Vec, #[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); 12] { + pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec); 14] { self.matcher_groups_mut() .map(|(event, groups)| (event, std::mem::take(groups))) } - pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec); 12] { + pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec); 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, pub windows_managed_dir: Option, #[serde(flatten)] - pub hooks: HookEventsToml, + pub hooks: Box, } impl ManagedHooksRequirementsToml { diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index fbf1a5b752..e3b9721e89 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -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() - }, + }), } ); } diff --git a/codex-rs/config/src/requirements_layers/hooks.rs b/codex-rs/config/src/requirements_layers/hooks.rs index 01ed3eab47..1859fb9fd3 100644 --- a/codex-rs/config/src/requirements_layers/hooks.rs +++ b/codex-rs/config/src/requirements_layers/hooks.rs @@ -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); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a2542a02b4..6ea1ea8e1b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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": { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 4cbb06f81f..d8f66cb67b 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -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 { + 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, + 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; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a60ce15c26..483b2472f8 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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(), }; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8925df02ba..c9720799e2 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -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, diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 2081c42cd2..18c66090d7 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -231,6 +231,7 @@ pub(crate) struct HandleOutputCtx { pub step_context: Arc, pub turn_store: Arc, 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); diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index daad318bb2..b942ac7364 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -314,6 +314,7 @@ fn output_context(session: Arc, turn_context: Arc) -> Hand step_context, turn_store: Arc::new(ExtensionData::new(turn_context.sub_id.clone())), tool_runtime, + defer_tool_dispatch: false, cancellation_token: CancellationToken::new(), } } diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index e0b50c48dd..70ff50c5e4 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -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) -> 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) -> 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 { diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 0be828930f..5fa16c0392 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -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> { + 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, + 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(()) +} diff --git a/codex-rs/hooks/schema/generated/model-request.command.input.schema.json b/codex-rs/hooks/schema/generated/model-request.command.input.schema.json new file mode 100644 index 0000000000..86bf69b399 --- /dev/null +++ b/codex-rs/hooks/schema/generated/model-request.command.input.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 1986462a4a..b5b07e658d 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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 diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 8f17b6714b..e84d998093 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -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", diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index f1d46adc26..f7bdc97d0d 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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", diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index f082dbf144..e5eadbe31a 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -144,7 +144,7 @@ fn managed_hooks_for_current_platform( } else { None }, - hooks, + hooks: Box::new(hooks), } } diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 2a58d51634..d1f0f59ba5 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -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, } } diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 3d5ea1baad..0b8c44ba0d 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -8,3 +8,5 @@ pub mod session_end; pub mod session_start; pub mod stop; pub mod user_prompt_submit; + +pub mod model_request; diff --git a/codex-rs/hooks/src/events/model_request.rs b/codex-rs/hooks/src/events/model_request.rs new file mode 100644 index 0000000000..221f2b70cf --- /dev/null +++ b/codex-rs/hooks/src/events/model_request.rs @@ -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, + #[schemars(required, schema_with = "token_usage_schema")] + token_usage: Option, + }, +} + +// 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::(), + 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, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(flatten)] + pub event: ModelRequestHookEvent, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &ModelRequestHookInput, +) -> Vec { + 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 { + 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, +) -> 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, + } +} diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index cf981707b9..e795c795bd 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -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() } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 9be500af78..9e02b987ff 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -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; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 2a5e1ec1d1..6ae2b142a8 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -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) -> 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, @@ -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 { + 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 + Send + 'static) { + self.engine.command_runtime.schedule_async_task(task); + } + + pub fn preview_model_request( + &self, + request: &crate::ModelRequestHookInput, + ) -> Vec { + crate::events::model_request::preview(&self.engine.handlers, request) + } + + pub async fn run_model_request( + &self, + request: crate::ModelRequestHookInput, + ) -> Vec { + crate::events::model_request::run(&self.engine, request).await + } } pub fn list_hooks(config: HooksConfig) -> HookListOutcome { diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index bb50807af5..0da8e17d52 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -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::()?, + )?; + write_schema( &generated_dir.join(POST_TOOL_USE_INPUT_FIXTURE), schema_json::()?, @@ -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::() + .expect("serialize model request input schema"), schema_json::().expect("serialize pre tool use input schema"), schema_json::() .expect("serialize permission request input schema"), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e2457f8104..dc5c2a3bb4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1703,6 +1703,8 @@ pub enum HookEventName { SubagentStop, Stop, Interrupt, + PreModelRequest, + PostModelRequest, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index b73eca721c..37c288b4ef 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -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")); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__compact_hooks_keep_the_last_event_and_selection_visible.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__compact_hooks_keep_the_last_event_and_selection_visible.snap index 41ad692c00..f153a34cfc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__compact_hooks_keep_the_last_event_and_selection_visible.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__compact_hooks_keep_the_last_event_and_selection_visible.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_model_requests.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_model_requests.snap new file mode 100644 index 0000000000..ee8751bf86 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_model_requests.snap @@ -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 diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index f1fc03a58f..96882cc439 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -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), )), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 9395f0e817..b3079d5b65 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -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")]