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 7f5b7cf081..6691e30019 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 @@ -8761,6 +8761,15 @@ "oneOf": [ { "properties": { + "additionalContextLimit": { + "description": "Approximate token threshold for spilling this hook's `additionalContext` to disk. `null` uses 2,500 tokens; `0` disables spilling for this hook. The threshold is evaluated against the original context; a spilled preview also includes recovery metadata.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, "async": { "type": "boolean" }, @@ -11211,6 +11220,15 @@ }, "HookMetadata": { "properties": { + "additionalContextLimit": { + "description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, "command": { "type": [ "string", 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 3d87934c5b..cb53e921bb 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 @@ -4954,6 +4954,15 @@ "oneOf": [ { "properties": { + "additionalContextLimit": { + "description": "Approximate token threshold for spilling this hook's `additionalContext` to disk. `null` uses 2,500 tokens; `0` disables spilling for this hook. The threshold is evaluated against the original context; a spilled preview also includes recovery metadata.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, "async": { "type": "boolean" }, @@ -7515,6 +7524,15 @@ }, "HookMetadata": { "properties": { + "additionalContextLimit": { + "description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, "command": { "type": [ "string", 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 54c75f363a..b1eaf4eb07 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -187,6 +187,15 @@ "oneOf": [ { "properties": { + "additionalContextLimit": { + "description": "Approximate token threshold for spilling this hook's `additionalContext` to disk. `null` uses 2,500 tokens; `0` disables spilling for this hook. The threshold is evaluated against the original context; a spilled preview also includes recovery metadata.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, "async": { "type": "boolean" }, 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 fa71c18f4f..b0840b792d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -46,6 +46,15 @@ }, "HookMetadata": { "properties": { + "additionalContextLimit": { + "description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, "command": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts index 42b05cf8cc..38adeb2b1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -2,4 +2,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; +export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, +/** + * Approximate token threshold for spilling this hook's `additionalContext` to disk. + * `null` uses 2,500 tokens; `0` disables spilling for this hook. The threshold is + * evaluated against the original context; a spilled preview also includes recovery + * metadata. + */ +additionalContextLimit: number | null, } | { "type": "prompt", } | { "type": "agent", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 94e3c30c92..82244f0a5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -7,4 +7,9 @@ import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; import type { HookTrustStatus } from "./HookTrustStatus"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, +/** + * Configured `additionalContext` spill threshold. + * `null` uses 2,500 tokens; `0` disables spilling. + */ +additionalContextLimit: number | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; 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 2462b8b239..035e837f3d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -484,6 +484,13 @@ pub enum ConfiguredHookHandler { #[serde(rename = "statusMessage")] #[ts(rename = "statusMessage")] status_message: Option, + /// Approximate token threshold for spilling this hook's `additionalContext` to disk. + /// `null` uses 2,500 tokens; `0` disables spilling for this hook. The threshold is + /// evaluated against the original context; a spilled preview also includes recovery + /// metadata. + #[serde(rename = "additionalContextLimit")] + #[ts(rename = "additionalContextLimit")] + additional_context_limit: Option, }, #[serde(rename = "prompt")] #[ts(rename = "prompt")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index a39db6dd36..dcf2dbf8b3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -519,6 +519,9 @@ pub struct HookMetadata { pub command: Option, pub timeout_sec: u64, pub status_message: Option, + /// Configured `additionalContext` spill threshold. + /// `null` uses 2,500 tokens; `0` disables spilling. + pub additional_context_limit: Option, pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ec60f46c73..75959db182 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -248,7 +248,7 @@ Example with notification opt-out: - `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -1832,6 +1832,7 @@ For unmanaged hooks, `currentHash` and `trustStatus` describe whether the curren "command": "python3 /Users/me/hook.py", "timeoutSec": 5, "statusMessage": "running hook", + "additionalContextLimit": null, "sourcePath": "/Users/me/.codex/config.toml", "source": "user", "pluginId": null, diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 6cb4385151..860e188cfe 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -72,6 +72,7 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { command: hook.command.clone(), timeout_sec: hook.timeout_sec, status_message: hook.status_message.clone(), + additional_context_limit: hook.additional_context_limit, source_path: hook.source_path.clone(), source: hook.source.into(), plugin_id: hook.plugin_id.clone(), 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 33d4bcb4f2..467d1e1986 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -460,12 +460,14 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand timeout_sec, r#async, status_message, + additional_context_limit, } => ConfiguredHookHandler::Command { command, command_windows, timeout_sec, r#async, status_message, + additional_context_limit, }, CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index b1ef93d70f..3c7704f223 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ConfiguredHookHandler; use codex_app_server_protocol::ForcedChatgptWorkspaceIds; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -49,11 +50,21 @@ fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn config_requirements_read_includes_allow_remote_control() -> Result<()> { +async fn config_requirements_read_includes_remote_control_and_managed_hooks() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("requirements.toml"), - "allow_remote_control = false\n", + r#"allow_remote_control = false + +[hooks] + +[[hooks.SessionStart]] + +[[hooks.SessionStart.hooks]] +type = "command" +command = "echo managed" +additionalContextLimit = 4096 +"#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -69,12 +80,24 @@ async fn config_requirements_read_includes_allow_remote_control() -> Result<()> ) .await??; let response: ConfigRequirementsReadResponse = to_response(response)?; + let requirements = response + .requirements + .expect("managed requirements should be returned"); + assert_eq!(requirements.allow_remote_control, Some(false)); assert_eq!( - response - .requirements - .expect("managed requirements should be returned") - .allow_remote_control, - Some(false) + requirements + .hooks + .expect("managed hooks should be returned") + .session_start[0] + .hooks, + vec![ConfiguredHookHandler::Command { + command: "echo managed".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + additional_context_limit: Some(4_096), + }] ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 35b19addd8..b02180c750 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -47,6 +47,7 @@ fn command_hook_hash( command: &str, timeout_sec: u64, status_message: Option<&str>, + additional_context_limit: Option, ) -> String { let identity = NormalizedHookIdentity { event_name, @@ -58,6 +59,7 @@ fn command_hook_hash( timeout_sec: Some(timeout_sec), r#async: false, status_message: status_message.map(ToOwned::to_owned), + additional_context_limit, }], }, }; @@ -80,6 +82,7 @@ type = "command" command = "python3 /tmp/listed-hook.py" timeout = 5 statusMessage = "running listed hook" +additionalContextLimit = 4096 "#, )?; Ok(()) @@ -168,6 +171,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { command: Some("python3 /tmp/listed-hook.py".to_string()), timeout_sec: 5, status_message: Some("running listed hook".to_string()), + additional_context_limit: Some(4_096), source_path: config_path, source: HookSource::User, plugin_id: None, @@ -180,6 +184,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { "python3 /tmp/listed-hook.py", /*timeout_sec*/ 5, Some("running listed hook"), + /*additional_context_limit*/ Some(4_096), ), trust_status: HookTrustStatus::Untrusted, }], @@ -250,6 +255,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { command: Some("echo plugin hook".to_string()), timeout_sec: 7, status_message: Some("running plugin hook".to_string()), + additional_context_limit: None, source_path: plugin_hooks_path, source: HookSource::Plugin, plugin_id: Some("demo@test".to_string()), @@ -262,6 +268,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { "echo plugin hook", /*timeout_sec*/ 7, Some("running plugin hook"), + /*additional_context_limit*/ None, ), trust_status: HookTrustStatus::Untrusted, }], @@ -464,6 +471,7 @@ timeout = 5 command: Some("echo project hook".to_string()), timeout_sec: 5, status_message: None, + additional_context_limit: None, source_path: project_config_path, source: HookSource::Project, plugin_id: None, @@ -476,6 +484,7 @@ timeout = 5 "echo project hook", /*timeout_sec*/ 5, /*status_message*/ None, + /*additional_context_limit*/ None, ), trust_status: HookTrustStatus::Untrusted, }], diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 371f6a91b5..935e2e1d63 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -158,6 +158,16 @@ pub enum HookHandlerConfig { r#async: bool, #[serde(default, rename = "statusMessage")] status_message: Option, + /// Approximate token threshold for spilling this hook's `additionalContext` to disk. + /// Unset uses 2,500 tokens; `0` disables spilling for this hook. The threshold is + /// evaluated against the original context; a spilled preview also includes recovery + /// metadata. + #[serde( + default, + rename = "additionalContextLimit", + skip_serializing_if = "Option::is_none" + )] + additional_context_limit: Option, }, #[serde(rename = "prompt")] Prompt {}, diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 3d51840fc2..b869fe2561 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -23,7 +23,8 @@ fn hooks_file_deserializes_existing_json_shape() { "type": "command", "command": "python3 /tmp/pre.py", "timeout": 10, - "statusMessage": "checking" + "statusMessage": "checking", + "additionalContextLimit": 4096 } ] } @@ -46,6 +47,7 @@ fn hooks_file_deserializes_existing_json_shape() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: Some(4096), }], }], ..Default::default() @@ -90,6 +92,7 @@ type = "command" command = "python3 /tmp/pre.py" timeout = 10 statusMessage = "checking" +additionalContextLimit = 4096 "#, ) .expect("hook events TOML should deserialize"); @@ -105,6 +108,7 @@ statusMessage = "checking" timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: Some(4096), }], }], ..Default::default() @@ -142,6 +146,7 @@ command = "python3 /tmp/pre.py" timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() @@ -187,6 +192,7 @@ command = "python3 /enterprise/place/pre.py" timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() @@ -223,6 +229,7 @@ command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() @@ -258,9 +265,26 @@ commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() } ); } + +#[test] +fn hook_handler_omits_unset_additional_context_limit() { + let handler = HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + additional_context_limit: None, + }; + + let serialized = serde_json::to_value(handler).expect("hook handler should serialize"); + + assert_eq!(serialized.get("additionalContextLimit"), None); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8ef43b6e94..cd9d5507d6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1198,6 +1198,12 @@ "oneOf": [ { "properties": { + "additionalContextLimit": { + "description": "Approximate token threshold for spilling this hook's `additionalContext` to disk. Unset uses 2,500 tokens; `0` disables spilling for this hook. The threshold is evaluated against the original context; a spilled preview also includes recovery metadata.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, "async": { "default": false, "type": "boolean" diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f80fd944d4..6d1c20f71b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1503,6 +1503,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { timeout_sec: Some(600), r#async: false, status_message: None, + additional_context_limit: None, }], }, }; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 5b33390734..2b7ca823e5 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -842,6 +842,34 @@ print(json.dumps({{ Ok(()) } +fn write_session_start_hooks_with_individual_context_limits( + home: &Path, + limited_additional_context: &str, + expanded_additional_context: &str, +) -> Result<()> { + let mut hook_handlers = Vec::new(); + for (additional_context, additional_context_limit) in [ + (limited_additional_context, 1), + (expanded_additional_context, 100), + ] { + hook_handlers.push(serde_json::json!({ + "type": "command", + "command": format!("echo {additional_context}"), + "additionalContextLimit": additional_context_limit, + })); + } + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": hook_handlers, + }] + } + }); + + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_compact_session_start_hook_with_context( home: &Path, additional_context: &str, @@ -1441,6 +1469,59 @@ async fn session_start_hook_spills_large_additional_context() -> Result<()> { Ok(()) } +#[tokio::test] +async fn session_start_hooks_apply_additional_context_limits_individually() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello from the reef"), + ev_completed("resp-1"), + ]), + ) + .await; + let limited_additional_context = "spill this limited reef context".to_string(); + let expanded_additional_context = "keep this expanded reef context inline".to_string(); + + let test = test_codex() + .with_pre_build_hook({ + let limited_additional_context = limited_additional_context.clone(); + let expanded_additional_context = expanded_additional_context.clone(); + move |home| { + write_session_start_hooks_with_individual_context_limits( + home, + &limited_additional_context, + &expanded_additional_context, + ) + .expect("failed to write session start hook test fixtures"); + } + }) + .with_config(trust_discovered_hooks) + .build(&server) + .await?; + + test.submit_turn("hello").await?; + + let request = response.single_request(); + let developer_messages = request.message_input_texts("developer"); + let spilled_message = developer_messages + .iter() + .find(|message| spilled_hook_output_path(message).is_some()) + .context("spilled limited session start context")?; + let path = spilled_hook_output_path(spilled_message).context("spill path")?; + assert_eq!(fs::read_to_string(path)?, limited_additional_context); + assert!( + developer_messages + .iter() + .any(|message| message == &expanded_additional_context), + "expected the expanded-limit hook context inline, got {developer_messages:?}" + ); + Ok(()) +} + #[tokio::test] async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/src/declarations.rs b/codex-rs/hooks/src/declarations.rs index 468fbc947a..42ca76a2ed 100644 --- a/codex-rs/hooks/src/declarations.rs +++ b/codex-rs/hooks/src/declarations.rs @@ -69,6 +69,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }, ], }], diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index ce48be5efb..adce766908 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -29,6 +29,8 @@ use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; use crate::events::session_end::SESSION_END_DEFAULT_TIMEOUT_SEC; use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC; +use crate::output_spill::AdditionalContextLimit; +use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; @@ -468,6 +470,7 @@ fn append_matcher_groups( timeout_sec, r#async, status_message, + additional_context_limit, } => { let command = if cfg!(windows) { command_windows.unwrap_or(command) @@ -501,12 +504,33 @@ fn append_matcher_groups( source.path.display() )); } + let additional_context_limit = if matches!( + event_name, + codex_protocol::protocol::HookEventName::PreToolUse + | codex_protocol::protocol::HookEventName::PostToolUse + | codex_protocol::protocol::HookEventName::SessionStart + | codex_protocol::protocol::HookEventName::UserPromptSubmit + | codex_protocol::protocol::HookEventName::SubagentStart + ) { + additional_context_limit + } else { + if additional_context_limit.is_some() { + warnings.push(format!( + "ignoring additionalContextLimit for {event_name:?} hook in {}: this event cannot emit additionalContext", + source.path.display() + )); + } + None + }; + let normalized_additional_context_limit = additional_context_limit + .filter(|limit| *limit != DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT); let normalized_handler = HookHandlerConfig::Command { command: command.clone(), command_windows: None, timeout_sec: Some(timeout_sec), r#async, status_message: status_message.clone(), + additional_context_limit: normalized_additional_context_limit, }; let current_hash = command_hook_hash(event_name, matcher, &group, normalized_handler); @@ -529,6 +553,7 @@ fn append_matcher_groups( command: Some(command.clone()), timeout_sec, status_message: status_message.clone(), + additional_context_limit, source_path: source.path.clone(), source: source.source, plugin_id: source.plugin_id.clone(), @@ -551,6 +576,9 @@ fn append_matcher_groups( command, timeout_sec, status_message, + additional_context_limit: AdditionalContextLimit::from_config( + additional_context_limit, + ), source_path: source.path.clone(), source: source.source, display_order: *display_order, @@ -703,7 +731,10 @@ mod tests { use pretty_assertions::assert_eq; use super::ConfiguredHandler; + use super::HookListEntry; use super::append_matcher_groups; + use crate::output_spill::AdditionalContextLimit; + use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT; use codex_config::HookHandlerConfig; use codex_config::HookStateToml; use codex_config::MatcherGroup; @@ -795,10 +826,113 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], } } + fn command_group_with_additional_context_limit( + additional_context_limit: usize, + ) -> MatcherGroup { + MatcherGroup { + matcher: None, + hooks: vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + additional_context_limit: Some(additional_context_limit), + }], + } + } + + fn discover_command( + event_name: HookEventName, + additional_context_limit: Option, + ) -> (ConfiguredHandler, HookListEntry, Vec) { + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + let mut handlers = Vec::new(); + let mut entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + append_matcher_groups( + &mut handlers, + &mut entries, + &mut warnings, + &mut display_order, + &hook_handler_source(&source_path, &hook_states), + event_name, + vec![match additional_context_limit { + Some(limit) => command_group_with_additional_context_limit(limit), + None => command_group(/*matcher*/ None), + }], + ); + (handlers.remove(0), entries.remove(0), warnings) + } + + #[test] + fn supported_events_retain_per_handler_additional_context_limit_and_hash_it() { + for event_name in [ + HookEventName::PreToolUse, + HookEventName::PostToolUse, + HookEventName::SessionStart, + HookEventName::UserPromptSubmit, + HookEventName::SubagentStart, + ] { + let (_, default_entry, _) = + discover_command(event_name, /*additional_context_limit*/ None); + let (explicit_default_handler, explicit_default_entry, _) = discover_command( + event_name, + /*additional_context_limit*/ Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT), + ); + let (custom_handler, custom_entry, _) = + discover_command(event_name, /*additional_context_limit*/ Some(20_000)); + let (unlimited_handler, unlimited_entry, _) = + discover_command(event_name, /*additional_context_limit*/ Some(0)); + + assert_eq!( + custom_handler.additional_context_limit, + AdditionalContextLimit::from_config(Some(20_000)) + ); + assert_eq!(custom_entry.additional_context_limit, Some(20_000)); + assert_ne!(default_entry.current_hash, custom_entry.current_hash); + assert_eq!( + explicit_default_handler.additional_context_limit, + AdditionalContextLimit::from_config(Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT)) + ); + assert_eq!( + explicit_default_entry.additional_context_limit, + Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT) + ); + assert_eq!( + default_entry.current_hash, + explicit_default_entry.current_hash + ); + assert_eq!( + unlimited_handler.additional_context_limit, + AdditionalContextLimit::from_config(Some(0)) + ); + assert_eq!(unlimited_entry.additional_context_limit, Some(0)); + assert_ne!(default_entry.current_hash, unlimited_entry.current_hash); + } + } + + #[test] + fn unsupported_event_warns_and_ignores_additional_context_limit() { + let source_path = source_path(); + let (handler, _, warnings) = discover_command( + HookEventName::Stop, + /*additional_context_limit*/ Some(4_096), + ); + + assert_eq!(handler.additional_context_limit, Default::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("ignoring additionalContextLimit for Stop hook")); + assert!(warnings[0].contains(&source_path.display().to_string())); + } + #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { let mut handlers = Vec::new(); @@ -826,6 +960,7 @@ mod tests { command: "echo hello".to_string(), timeout_sec: 600, status_message: None, + additional_context_limit: Default::default(), source_path: source_path.clone(), source: hook_source(), display_order: 0, @@ -861,6 +996,7 @@ mod tests { command: "echo hello".to_string(), timeout_sec: 600, status_message: None, + additional_context_limit: Default::default(), source_path: source_path.clone(), source: hook_source(), display_order: 0, @@ -894,6 +1030,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }, HookHandlerConfig::Command { command: "echo clamped".to_string(), @@ -901,6 +1038,7 @@ mod tests { timeout_sec: Some(600), r#async: true, status_message: None, + additional_context_limit: None, }, ], }], @@ -1088,6 +1226,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() @@ -1118,6 +1257,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + additional_context_limit: None, }], }], ); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index f7a6526295..a5c7552067 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -236,6 +236,7 @@ mod tests { command: command.to_string(), timeout_sec: 5, status_message: None, + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: HookSource::User, display_order, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index ae1703eec1..0aaa4b0d33 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -22,6 +22,7 @@ use crate::events::stop::StopOutcome; use crate::events::stop::StopRequest; use crate::events::user_prompt_submit::UserPromptSubmitOutcome; use crate::events::user_prompt_submit::UserPromptSubmitRequest; +use crate::output_spill::AdditionalContextLimit; use crate::output_spill::HookOutputSpiller; use codex_config::ConfigLayerStack; use codex_plugin::PluginHookSource; @@ -47,6 +48,7 @@ pub(crate) struct ConfiguredHandler { pub command: String, pub timeout_sec: u64, pub status_message: Option, + pub additional_context_limit: AdditionalContextLimit, pub source_path: AbsolutePathBuf, pub source: HookSource, pub display_order: i64, @@ -89,6 +91,7 @@ pub struct HookListEntry { pub command: Option, pub timeout_sec: u64, pub status_message: Option, + pub additional_context_limit: Option, pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, @@ -174,23 +177,19 @@ impl ClaudeHooksEngine { request: SessionStartRequest, turn_id: Option, ) -> SessionStartOutcome { - let session_id = request.session_id; - let mut outcome = - crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await; - outcome.additional_contexts = self - .maybe_spill_texts(session_id, outcome.additional_contexts) - .await; - outcome + crate::events::session_start::run( + &self.handlers, + &self.shell, + &self.output_spiller, + request, + turn_id, + ) + .await } pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { - let session_id = request.session_id; - let mut outcome = - crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await; - outcome.additional_contexts = self - .maybe_spill_texts(session_id, outcome.additional_contexts) - .await; - outcome + crate::events::pre_tool_use::run(&self.handlers, &self.shell, &self.output_spiller, request) + .await } pub(crate) async fn run_permission_request( @@ -205,11 +204,13 @@ impl ClaudeHooksEngine { request: PostToolUseRequest, ) -> PostToolUseOutcome { let session_id = request.session_id; - let mut outcome = - crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await; - outcome.additional_contexts = self - .maybe_spill_texts(session_id, outcome.additional_contexts) - .await; + let mut outcome = crate::events::post_tool_use::run( + &self.handlers, + &self.shell, + &self.output_spiller, + request, + ) + .await; outcome.feedback_message = self .maybe_spill_text(session_id, outcome.feedback_message) .await; @@ -246,13 +247,13 @@ impl ClaudeHooksEngine { &self, request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { - let session_id = request.session_id; - let mut outcome = - crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await; - outcome.additional_contexts = self - .maybe_spill_texts(session_id, outcome.additional_contexts) - .await; - outcome + crate::events::user_prompt_submit::run( + &self.handlers, + &self.shell, + &self.output_spiller, + request, + ) + .await } pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec { @@ -276,12 +277,6 @@ impl ClaudeHooksEngine { outcome } - async fn maybe_spill_texts(&self, session_id: ThreadId, texts: Vec) -> Vec { - self.output_spiller - .maybe_spill_texts(session_id, texts) - .await - } - async fn maybe_spill_text(&self, session_id: ThreadId, text: Option) -> Option { match text { Some(text) => Some(self.output_spiller.maybe_spill_text(session_id, text).await), diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 544c67e7ce..e323fffe53 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -66,6 +66,7 @@ fn pre_tool_use_hook_events(command: impl Into) -> HookEventsToml { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() @@ -173,6 +174,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() @@ -279,6 +281,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() @@ -358,6 +361,7 @@ fn unknown_requirement_source_hooks_stay_managed() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() @@ -426,6 +430,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() @@ -667,6 +672,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() @@ -1320,6 +1326,7 @@ print(json.dumps({ timeout_sec: Some(10), r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() @@ -1439,6 +1446,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { timeout_sec: Some(5), r#async: false, status_message: None, + additional_context_limit: None, }], }], ..Default::default() diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 8f0934c044..c67602086c 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -7,6 +7,7 @@ use codex_protocol::protocol::HookRunSummary; use crate::engine::ConfiguredHandler; use crate::engine::dispatcher; +use crate::output_spill::AdditionalContext; /// Identifies a thread-spawned subagent when a normal hook runs inside it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -34,19 +35,23 @@ pub(crate) fn trimmed_non_empty(text: &str) -> Option { pub(crate) fn append_additional_context( entries: &mut Vec, - additional_contexts_for_model: &mut Vec, + additional_contexts_for_model: &mut Vec, + handler: &ConfiguredHandler, additional_context: String, ) { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Context, text: additional_context.clone(), }); - additional_contexts_for_model.push(additional_context); + additional_contexts_for_model.push(AdditionalContext { + text: additional_context, + limit: handler.additional_context_limit, + }); } pub(crate) fn flatten_additional_contexts<'a>( - additional_contexts: impl IntoIterator, -) -> Vec { + additional_contexts: impl IntoIterator, +) -> Vec { additional_contexts .into_iter() .flat_map(|chunk| chunk.iter().cloned()) diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index cb3080219a..3542172056 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -600,6 +600,7 @@ mod tests { command: "python3 compact_hook.py".to_string(), timeout_sec: 5, status_message: Some("running compact hook".to_string()), + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 71adb11cda..ce0f449008 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -16,6 +16,8 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::output_spill::AdditionalContext; +use crate::output_spill::HookOutputSpiller; use crate::schema::PostToolUseCommandInput; use crate::schema::SubagentCommandInputFields; @@ -46,7 +48,7 @@ pub struct PostToolUseOutcome { #[derive(Debug, Default, PartialEq, Eq)] struct PostToolUseHandlerData { should_block: bool, - additional_contexts_for_model: Vec, + additional_contexts_for_model: Vec, feedback_messages_for_model: Vec, } @@ -70,8 +72,10 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + output_spiller: &HookOutputSpiller, request: PostToolUseRequest, ) -> PostToolUseOutcome { + let session_id = request.session_id; let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); let matched = dispatcher::select_handlers_for_matcher_inputs( handlers, @@ -115,6 +119,9 @@ pub(crate) async fn run( .iter() .map(|result| result.data.additional_contexts_for_model.as_slice()), ); + let additional_contexts = output_spiller + .maybe_spill_additional_contexts(session_id, additional_contexts) + .await; let should_block = results.iter().any(|result| result.data.should_block); let feedback_message = common::join_text_chunks( results @@ -199,6 +206,7 @@ fn parse_completed( common::append_additional_context( &mut entries, &mut additional_contexts_for_model, + handler, additional_context, ); } @@ -327,6 +335,8 @@ mod tests { use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::events::common; + use crate::output_spill::AdditionalContext; + use crate::output_spill::AdditionalContextLimit; #[test] fn command_input_uses_request_tool_name() { @@ -365,8 +375,10 @@ mod tests { #[test] fn additional_context_is_recorded() { + let mut handler = handler(); + handler.additional_context_limit = AdditionalContextLimit::from_config(Some(17)); let parsed = parse_completed( - &handler(), + &handler, run_result( Some(0), r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"Remember the bash cleanup note."}}"#, @@ -379,7 +391,10 @@ mod tests { parsed.data, PostToolUseHandlerData { should_block: false, - additional_contexts_for_model: vec!["Remember the bash cleanup note.".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "Remember the bash cleanup note.".to_string(), + limit: AdditionalContextLimit::from_config(Some(17)), + }], feedback_messages_for_model: Vec::new(), } ); @@ -564,6 +579,7 @@ mod tests { command: "python3 post_tool_use_hook.py".to_string(), timeout_sec: 5, status_message: Some("running post tool use hook".to_string()), + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index b3579aba82..b630798b66 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -16,6 +16,8 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::output_spill::AdditionalContext; +use crate::output_spill::HookOutputSpiller; use crate::schema::PreToolUseCommandInput; use crate::schema::SubagentCommandInputFields; @@ -47,7 +49,7 @@ pub struct PreToolUseOutcome { struct PreToolUseHandlerData { should_block: bool, block_reason: Option, - additional_contexts_for_model: Vec, + additional_contexts_for_model: Vec, updated_input: Option, } @@ -71,8 +73,10 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + output_spiller: &HookOutputSpiller, request: PreToolUseRequest, ) -> PreToolUseOutcome { + let session_id = request.session_id; let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); let matched = dispatcher::select_handlers_for_matcher_inputs( handlers, @@ -121,6 +125,9 @@ pub(crate) async fn run( .iter() .map(|result| result.data.additional_contexts_for_model.as_slice()), ); + let additional_contexts = output_spiller + .maybe_spill_additional_contexts(session_id, additional_contexts) + .await; let updated_input = if should_block { None } else { @@ -227,6 +234,7 @@ fn parse_completed( common::append_additional_context( &mut entries, &mut additional_contexts_for_model, + handler, additional_context, ); } @@ -331,6 +339,8 @@ mod tests { use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::events::common; + use crate::output_spill::AdditionalContext; + use crate::output_spill::AdditionalContextLimit; #[test] fn command_input_uses_request_tool_name() { @@ -508,7 +518,10 @@ mod tests { PreToolUseHandlerData { should_block: true, block_reason: Some("do not run that".to_string()), - additional_contexts_for_model: vec!["remember this".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "remember this".to_string(), + limit: Default::default(), + }], updated_input: None, } ); @@ -588,8 +601,10 @@ mod tests { #[test] fn additional_context_is_recorded() { + let mut handler = handler(); + handler.additional_context_limit = AdditionalContextLimit::from_config(Some(13)); let parsed = parse_completed( - &handler(), + &handler, run_result( Some(0), r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#, @@ -603,7 +618,10 @@ mod tests { PreToolUseHandlerData { should_block: true, block_reason: Some("do not run that".to_string()), - additional_contexts_for_model: vec!["nope".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "nope".to_string(), + limit: AdditionalContextLimit::from_config(Some(13)), + }], updated_input: None, } ); @@ -745,6 +763,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 5, status_message: None, + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/session_end_tests.rs b/codex-rs/hooks/src/events/session_end_tests.rs index ab687fdcb5..f3228f9dc9 100644 --- a/codex-rs/hooks/src/events/session_end_tests.rs +++ b/codex-rs/hooks/src/events/session_end_tests.rs @@ -65,6 +65,7 @@ fn handler(matcher: Option<&str>) -> ConfiguredHandler { command: "echo hook".to_string(), timeout_sec: 2, status_message: None, + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index bd1aa2096f..a26df2426a 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -15,6 +15,8 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::output_spill::AdditionalContext; +use crate::output_spill::HookOutputSpiller; use crate::schema::NullableString; use crate::schema::SessionStartCommandInput; use crate::schema::SubagentStartCommandInput; @@ -88,7 +90,7 @@ pub struct SessionStartOutcome { struct SessionStartHandlerData { should_stop: bool, stop_reason: Option, - additional_contexts_for_model: Vec, + additional_contexts_for_model: Vec, } pub(crate) fn preview( @@ -108,9 +110,11 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + output_spiller: &HookOutputSpiller, request: SessionStartRequest, turn_id: Option, ) -> SessionStartOutcome { + let session_id = request.session_id; let matched = dispatcher::select_handlers( handlers, request.target.event_name(), @@ -199,6 +203,9 @@ pub(crate) async fn run( .iter() .map(|result| result.data.additional_contexts_for_model.as_slice()), ); + let additional_contexts = output_spiller + .maybe_spill_additional_contexts(session_id, additional_contexts) + .await; SessionStartOutcome { hook_events: results.into_iter().map(|result| result.completed).collect(), @@ -258,6 +265,7 @@ fn parse_completed( common::append_additional_context( &mut entries, &mut additional_contexts_for_model, + handler, additional_context, ); } @@ -297,6 +305,7 @@ fn parse_completed( common::append_additional_context( &mut entries, &mut additional_contexts_for_model, + handler, additional_context, ); } @@ -357,11 +366,15 @@ mod tests { use super::parse_completed; use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; + use crate::output_spill::AdditionalContext; + use crate::output_spill::AdditionalContextLimit; #[test] fn plain_stdout_becomes_model_context() { + let mut handler = handler(); + handler.additional_context_limit = AdditionalContextLimit::from_config(Some(7)); let parsed = parse_completed( - &handler(), + &handler, run_result(Some(0), "hello from hook\n", ""), /*turn_id*/ None, ); @@ -371,7 +384,10 @@ mod tests { SessionStartHandlerData { should_stop: false, stop_reason: None, - additional_contexts_for_model: vec!["hello from hook".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "hello from hook".to_string(), + limit: AdditionalContextLimit::from_config(Some(7)), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); @@ -401,7 +417,10 @@ mod tests { SessionStartHandlerData { should_stop: true, stop_reason: Some("pause".to_string()), - additional_contexts_for_model: vec!["do not inject".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "do not inject".to_string(), + limit: Default::default(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); @@ -452,8 +471,10 @@ mod tests { #[test] fn subagent_start_plain_stdout_becomes_model_context() { + let mut handler = handler_for(HookEventName::SubagentStart); + handler.additional_context_limit = AdditionalContextLimit::from_config(Some(4_096)); let parsed = parse_completed( - &handler_for(HookEventName::SubagentStart), + &handler, run_result(Some(0), "hello from subagent hook\n", ""), /*turn_id*/ Some("turn-1".to_string()), ); @@ -463,7 +484,10 @@ mod tests { SessionStartHandlerData { should_stop: false, stop_reason: None, - additional_contexts_for_model: vec!["hello from subagent hook".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "hello from subagent hook".to_string(), + limit: AdditionalContextLimit::from_config(Some(4_096)), + }], } ); assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); @@ -494,7 +518,10 @@ mod tests { SessionStartHandlerData { should_stop: false, stop_reason: None, - additional_contexts_for_model: vec!["child context".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "child context".to_string(), + limit: Default::default(), + }], } ); assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); @@ -519,6 +546,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 600, status_message: None, + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 24920c569b..a17e305b3c 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -635,6 +635,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 600, status_message: None, + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 2934bd3523..e6ef8f6afd 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -15,6 +15,8 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::output_spill::AdditionalContext; +use crate::output_spill::HookOutputSpiller; use crate::schema::NullableString; use crate::schema::SubagentCommandInputFields; use crate::schema::UserPromptSubmitCommandInput; @@ -43,7 +45,7 @@ pub struct UserPromptSubmitOutcome { struct UserPromptSubmitHandlerData { should_stop: bool, stop_reason: Option, - additional_contexts_for_model: Vec, + additional_contexts_for_model: Vec, } pub(crate) fn preview( @@ -63,8 +65,10 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + output_spiller: &HookOutputSpiller, request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { + let session_id = request.session_id; let matched = dispatcher::select_handlers( handlers, HookEventName::UserPromptSubmit, @@ -121,6 +125,9 @@ pub(crate) async fn run( .iter() .map(|result| result.data.additional_contexts_for_model.as_slice()), ); + let additional_contexts = output_spiller + .maybe_spill_additional_contexts(session_id, additional_contexts) + .await; UserPromptSubmitOutcome { hook_events: results.into_iter().map(|result| result.completed).collect(), @@ -168,6 +175,7 @@ fn parse_completed( common::append_additional_context( &mut entries, &mut additional_contexts_for_model, + handler, additional_context, ); } @@ -210,6 +218,7 @@ fn parse_completed( common::append_additional_context( &mut entries, &mut additional_contexts_for_model, + handler, additional_context, ); } @@ -287,6 +296,7 @@ mod tests { use super::parse_completed; use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; + use crate::output_spill::AdditionalContext; #[test] fn continue_false_preserves_context_for_later_turns() { @@ -305,7 +315,10 @@ mod tests { UserPromptSubmitHandlerData { should_stop: true, stop_reason: Some("pause".to_string()), - additional_contexts_for_model: vec!["do not inject".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "do not inject".to_string(), + limit: Default::default(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); @@ -341,7 +354,10 @@ mod tests { UserPromptSubmitHandlerData { should_stop: true, stop_reason: Some("slow down".to_string()), - additional_contexts_for_model: vec!["do not inject".to_string()], + additional_contexts_for_model: vec![AdditionalContext { + text: "do not inject".to_string(), + limit: Default::default(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -424,6 +440,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 5, status_message: None, + additional_context_limit: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/output_spill.rs b/codex-rs/hooks/src/output_spill.rs index b1828c0825..5297d9d256 100644 --- a/codex-rs/hooks/src/output_spill.rs +++ b/codex-rs/hooks/src/output_spill.rs @@ -9,7 +9,32 @@ use tracing::warn; use uuid::Uuid; const HOOK_OUTPUTS_DIR: &str = "hook_outputs"; -const HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500; +pub(crate) const DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AdditionalContextLimit { + token_limit: usize, +} + +impl AdditionalContextLimit { + pub(crate) fn from_config(value: Option) -> Self { + Self { + token_limit: value.unwrap_or(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT), + } + } +} + +impl Default for AdditionalContextLimit { + fn default() -> Self { + Self::from_config(/*value*/ None) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AdditionalContext { + pub text: String, + pub limit: AdditionalContextLimit, +} #[derive(Clone)] pub(crate) struct HookOutputSpiller { @@ -31,7 +56,18 @@ impl HookOutputSpiller { /// and replaced with the same head/tail preview style used for other truncated /// output, plus a path back to the preserved full text. pub(crate) async fn maybe_spill_text(&self, thread_id: ThreadId, text: String) -> String { - if approx_token_count(&text) <= HOOK_OUTPUT_TOKEN_LIMIT { + self.maybe_spill_text_with_limit(thread_id, text, AdditionalContextLimit::default()) + .await + } + + async fn maybe_spill_text_with_limit( + &self, + thread_id: ThreadId, + text: String, + limit: AdditionalContextLimit, + ) -> String { + let token_limit = limit.token_limit; + if token_limit == 0 || approx_token_count(&text) <= token_limit { return text; } @@ -43,31 +79,28 @@ impl HookOutputSpiller { "failed to create hook output directory {}: {err}", parent.display() ); - return formatted_truncate_text( - &text, - TruncationPolicy::Tokens(HOOK_OUTPUT_TOKEN_LIMIT), - ); + return formatted_truncate_text(&text, TruncationPolicy::Tokens(token_limit)); } if let Err(err) = fs::write(path.as_ref(), &text).await { warn!("failed to write hook output {}: {err}", path.display()); - return formatted_truncate_text( - &text, - TruncationPolicy::Tokens(HOOK_OUTPUT_TOKEN_LIMIT), - ); + return formatted_truncate_text(&text, TruncationPolicy::Tokens(token_limit)); } - spilled_hook_output_preview(&text, &path) + spilled_hook_output_preview(&text, &path, token_limit) } - pub(crate) async fn maybe_spill_texts( + pub(crate) async fn maybe_spill_additional_contexts( &self, thread_id: ThreadId, - texts: Vec, + contexts: Vec, ) -> Vec { - let mut spilled = Vec::with_capacity(texts.len()); - for text in texts { - spilled.push(self.maybe_spill_text(thread_id, text).await); + let mut spilled = Vec::with_capacity(contexts.len()); + for context in contexts { + spilled.push( + self.maybe_spill_text_with_limit(thread_id, context.text, context.limit) + .await, + ); } spilled } @@ -97,12 +130,11 @@ fn hook_output_path(output_dir: &AbsolutePathBuf, thread_id: ThreadId) -> Absolu /// Builds the model-visible replacement for a spilled hook output. /// /// The path footer is budgeted before truncation so adding the recovery path -/// does not let the preview grow past the hook-output limit. -fn spilled_hook_output_preview(text: &str, path: &AbsolutePathBuf) -> String { +/// does not consume the configured preview budget. +fn spilled_hook_output_preview(text: &str, path: &AbsolutePathBuf, token_limit: usize) -> String { let footer = format!("\n\nFull hook output saved to: {}", path.display()); - let preview_policy = TruncationPolicy::Tokens( - HOOK_OUTPUT_TOKEN_LIMIT.saturating_sub(approx_token_count(&footer)), - ); + let preview_policy = + TruncationPolicy::Tokens(token_limit.saturating_sub(approx_token_count(&footer))); format!("{}{footer}", formatted_truncate_text(text, preview_policy)) } diff --git a/codex-rs/hooks/src/output_spill_tests.rs b/codex-rs/hooks/src/output_spill_tests.rs index 6c5f9b5848..253d37c841 100644 --- a/codex-rs/hooks/src/output_spill_tests.rs +++ b/codex-rs/hooks/src/output_spill_tests.rs @@ -40,3 +40,39 @@ async fn large_hook_output_spills_to_file() -> Result<()> { assert_eq!(fs::read_to_string(path).await?, text); Ok(()) } + +#[tokio::test] +async fn additional_contexts_apply_limits_individually() -> Result<()> { + let dir = tempdir()?; + let limited_text = "limited hook output ".repeat(1_000); + let unlimited_text = "unlimited hook output ".repeat(5_000); + assert!(approx_token_count(&unlimited_text) > 10_000); + let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR); + let spiller = HookOutputSpiller { output_dir }; + let output = spiller + .maybe_spill_additional_contexts( + ThreadId::new(), + vec![ + AdditionalContext { + text: limited_text.clone(), + limit: AdditionalContextLimit::from_config(Some(1)), + }, + AdditionalContext { + text: unlimited_text.clone(), + limit: AdditionalContextLimit::from_config(Some(0)), + }, + AdditionalContext { + text: unlimited_text.clone(), + limit: AdditionalContextLimit::from_config(Some(usize::MAX)), + }, + ], + ) + .await; + let [limited_output, zero_limit_output, high_limit_output] = output.as_slice() else { + panic!("expected one output for each additional context"); + }; + assert!(limited_output.contains("Full hook output saved to:")); + assert_eq!(zero_limit_output, &unlimited_text); + assert_eq!(high_limit_output, &unlimited_text); + Ok(()) +} 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 e5d88fd0b5..53e5d62665 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -492,6 +492,14 @@ impl HooksBrowserView { Some(MAX_COMMAND_DETAIL_LINES), )); lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); + if let Some(limit) = hook.additional_context_limit { + let value = if limit == 0 { + "unlimited".to_string() + } else { + format!("limit: {limit} approximate tokens") + }; + lines.push(detail_line("Context", &value)); + } lines.push(detail_line("Trust", hook_trust_label(hook.trust_status))); lines } @@ -936,6 +944,7 @@ mod tests { command: Some(command.to_string()), timeout_sec: 30, status_message: None, + additional_context_limit: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source, plugin_id: plugin_id.map(str::to_string), @@ -1080,6 +1089,35 @@ mod tests { assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112)); } + #[test] + fn renders_handler_additional_context_limit() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut configured_hook = hook( + "path:context-limit", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + configured_hook.additional_context_limit = Some(0); + let mut view = HooksBrowserView::new( + vec![configured_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_additional_context_limit", + render_lines(&view, /*width*/ 112) + ); + } + #[test] fn renders_untrusted_enabled_handler_as_inactive() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_additional_context_limit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_additional_context_limit.snap new file mode 100644 index 0000000000..9f7a0807cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_additional_context_limit.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command /tmp/pre-tool-use.sh + Timeout 30s + Context unlimited + Trust Trusted + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 800922aa70..83f136cc24 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1258,6 +1258,7 @@ approval_policy = "never" timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + additional_context_limit: None, }], }], ..Default::default() diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 97c53f7144..da12cd9288 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -311,6 +311,7 @@ mod tests { command: Some("/tmp/hook.sh".to_string()), timeout_sec: 30, status_message: None, + additional_context_limit: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: HookSource::User, plugin_id: None,