Compare commits

...

2 Commits

Author SHA1 Message Date
Abhinav Vedmala
1dcf8a74e0 Fix thread manager sample config initializer 2026-05-28 14:52:20 -07:00
Abhinav Vedmala
a87c44de1d Add hook additionalContext spill limit config 2026-05-28 14:30:38 -07:00
13 changed files with 161 additions and 18 deletions

View File

@@ -279,6 +279,10 @@ pub struct ConfigToml {
/// Token budget applied when storing tool/function outputs in the context manager.
pub tool_output_token_limit: Option<usize>,
/// Token budget for hook-provided additional context before it is spilled to disk.
/// Unset uses the default limit; set to `0` to disable spilling for hook additional context.
pub hook_additional_context_token_limit: Option<usize>,
/// Maximum poll window for background terminal output (`write_stdin`), in milliseconds.
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: Option<u64>,

View File

@@ -4792,6 +4792,12 @@
},
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
},
"hook_additional_context_token_limit": {
"description": "Token budget for hook-provided additional context before it is spilled to disk. Unset uses the default limit; set to `0` to disable spilling for hook additional context.",
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"hooks": {
"allOf": [
{

View File

@@ -356,6 +356,22 @@ consolidation_model = "gpt-5.2"
);
}
#[tokio::test]
async fn load_config_reads_hook_additional_context_token_limit() {
let cfg = toml::from_str::<ConfigToml>("hook_additional_context_token_limit = 4096\n")
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert_eq!(Some(4096), config.hook_additional_context_token_limit);
}
#[test]
fn parses_bundled_skills_config() {
let cfg: ConfigToml = toml::from_str(

View File

@@ -785,6 +785,10 @@ pub struct Config {
/// Token budget applied when storing tool/function outputs in the context manager.
pub tool_output_token_limit: Option<usize>,
/// Token budget for hook-provided additional context before it is spilled to disk.
/// Unset uses the default limit; set to `0` to disable spilling for hook additional context.
pub hook_additional_context_token_limit: Option<usize>,
/// Maximum number of agent threads that can be open concurrently.
pub agent_max_threads: Option<usize>,
/// Maximum runtime in seconds for agent job workers before they are failed.
@@ -3368,6 +3372,7 @@ impl Config {
})
.collect(),
tool_output_token_limit: cfg.tool_output_token_limit,
hook_additional_context_token_limit: cfg.hook_additional_context_token_limit,
agent_max_threads,
agent_max_depth,
agent_roles,

View File

@@ -3275,6 +3275,7 @@ async fn build_hooks_for_config(
legacy_notify_argv: config.notify.clone(),
feature_enabled: config.features.enabled(Feature::CodexHooks),
bypass_hook_trust: config.bypass_hook_trust,
additional_context_token_limit: config.hook_additional_context_token_limit,
config_layer_stack: Some(config.config_layer_stack.clone()),
plugin_hook_sources,
plugin_hook_load_warnings,

View File

@@ -1292,6 +1292,59 @@ async fn session_start_hook_spills_large_additional_context() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn session_start_hook_keeps_large_additional_context_inline_when_limit_is_zero() -> 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 additional_context = "remember the reef ".repeat(800);
let mut builder = test_codex()
.with_pre_build_hook({
let additional_context = additional_context.clone();
move |home| {
if let Err(error) = write_session_start_hook_with_context(home, &additional_context)
{
panic!("failed to write session start hook test fixture: {error}");
}
}
})
.with_config(|config| {
trust_discovered_hooks(config);
config.hook_additional_context_token_limit = Some(0);
});
let test = builder.build(&server).await?;
test.submit_turn("hello").await?;
let request = response.single_request();
let developer_messages = request.message_input_texts("developer");
assert!(
developer_messages
.iter()
.any(|message| message == &additional_context),
"expected unspilled hook additional context, got {developer_messages:?}"
);
assert!(
developer_messages
.iter()
.all(|message| spilled_hook_output_path(message).is_none()),
"hook additional context should not spill when limit is zero"
);
Ok(())
}
#[tokio::test]
async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -102,6 +102,7 @@ pub(crate) struct ClaudeHooksEngine {
warnings: Vec<String>,
shell: CommandShell,
output_spiller: HookOutputSpiller,
additional_context_token_limit: Option<usize>,
}
impl ClaudeHooksEngine {
@@ -112,6 +113,7 @@ impl ClaudeHooksEngine {
plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>,
shell: CommandShell,
additional_context_token_limit: Option<usize>,
) -> Self {
if !enabled {
return Self {
@@ -119,6 +121,7 @@ impl ClaudeHooksEngine {
warnings: Vec::new(),
shell,
output_spiller: HookOutputSpiller::new(),
additional_context_token_limit,
};
}
@@ -134,6 +137,7 @@ impl ClaudeHooksEngine {
warnings: discovered.warnings,
shell,
output_spiller: HookOutputSpiller::new(),
additional_context_token_limit,
}
}
@@ -266,8 +270,13 @@ impl ClaudeHooksEngine {
}
async fn maybe_spill_texts(&self, session_id: ThreadId, texts: Vec<String>) -> Vec<String> {
let token_limit = match self.additional_context_token_limit {
Some(0) => None,
Some(limit) => Some(limit),
None => Some(crate::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT),
};
self.output_spiller
.maybe_spill_texts(session_id, texts)
.maybe_spill_texts_with_limit(session_id, texts, token_limit)
.await
}

View File

@@ -204,6 +204,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.warnings().is_empty());
@@ -218,6 +219,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
additional_context_token_limit: None,
});
assert!(listed.hooks[0].is_managed);
let cwd = cwd();
@@ -307,6 +309,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
let outcome = engine
@@ -386,6 +389,7 @@ fn unknown_requirement_source_hooks_stay_managed() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert_eq!(engine.handlers.len(), 1);
@@ -468,6 +472,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert_eq!(engine.handlers.len(), 1);
@@ -531,6 +536,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert_eq!(engine.handlers.len(), 1);
@@ -692,6 +698,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.warnings().is_empty());
@@ -748,6 +755,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.warnings().is_empty());
@@ -802,6 +810,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.warnings().is_empty());
@@ -872,6 +881,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.handlers.is_empty());
@@ -911,6 +921,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.handlers.is_empty());
@@ -983,6 +994,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.warnings().is_empty());
@@ -1093,6 +1105,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert!(engine.warnings().iter().any(|warning| {
@@ -1186,6 +1199,7 @@ print(json.dumps({
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
@@ -1213,6 +1227,7 @@ print(json.dumps({
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
additional_context_token_limit: None,
});
assert_eq!(
listed.hooks[0].plugin_id.as_deref(),
@@ -1300,6 +1315,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert_eq!(
@@ -1344,6 +1360,7 @@ fn plugin_hook_load_warnings_are_startup_warnings() {
program: String::new(),
args: Vec::new(),
},
/*additional_context_token_limit*/ None,
);
assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);

View File

@@ -15,6 +15,7 @@ pub use declarations::PluginHookDeclaration;
pub use declarations::plugin_hook_declarations;
pub use engine::HookListEntry;
pub use events::common::SubagentHookContext;
pub use output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 10] = [
"PreToolUse",

View File

@@ -9,7 +9,7 @@ use tracing::warn;
use uuid::Uuid;
const HOOK_OUTPUTS_DIR: &str = "hook_outputs";
const HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500;
pub const DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500;
#[derive(Clone)]
pub(crate) struct HookOutputSpiller {
@@ -31,7 +31,20 @@ 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, Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT))
.await
}
pub(crate) async fn maybe_spill_text_with_limit(
&self,
thread_id: ThreadId,
text: String,
token_limit: Option<usize>,
) -> String {
let Some(token_limit) = token_limit else {
return text;
};
if token_limit == 0 || approx_token_count(&text) <= token_limit {
return text;
}
@@ -43,31 +56,29 @@ 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_texts_with_limit(
&self,
thread_id: ThreadId,
texts: Vec<String>,
token_limit: Option<usize>,
) -> Vec<String> {
let mut spilled = Vec::with_capacity(texts.len());
for text in texts {
spilled.push(self.maybe_spill_text(thread_id, text).await);
spilled.push(
self.maybe_spill_text_with_limit(thread_id, text, token_limit)
.await,
);
}
spilled
}
@@ -98,11 +109,10 @@ fn hook_output_path(output_dir: &AbsolutePathBuf, thread_id: ThreadId) -> Absolu
///
/// 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 {
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))
}

View File

@@ -40,3 +40,21 @@ async fn large_hook_output_spills_to_file() -> Result<()> {
assert_eq!(fs::read_to_string(path).await?, text);
Ok(())
}
#[tokio::test]
async fn zero_limit_keeps_large_hook_output_inline() -> Result<()> {
let dir = tempdir()?;
let text = "hook output ".repeat(1_000);
let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR);
let spiller = HookOutputSpiller {
output_dir: output_dir.clone(),
};
let output = spiller
.maybe_spill_text_with_limit(ThreadId::new(), text.clone(), Some(0))
.await;
assert_eq!(output, text);
assert!(!output_dir.exists());
Ok(())
}

View File

@@ -36,6 +36,7 @@ pub struct HooksConfig {
pub plugin_hook_load_warnings: Vec<String>,
pub shell_program: Option<String>,
pub shell_args: Vec<String>,
pub additional_context_token_limit: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -74,6 +75,7 @@ impl Hooks {
program: config.shell_program.unwrap_or_default(),
args: config.shell_args,
},
config.additional_context_token_limit,
);
Self {
after_agent,

View File

@@ -223,6 +223,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
project_doc_max_bytes: 32 * 1024,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
hook_additional_context_token_limit: None,
agent_max_threads: Some(6),
agent_job_max_runtime_seconds: None,
agent_interrupt_message_enabled: false,