mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Spill large hook outputs from context (#21069)
## Why Large hook outputs can enter model-visible context through hook-specific paths such as `additionalContext` and `Stop` continuation prompts. Without a dedicated cap, one hook can inject a large blob directly into conversation history instead of leaving a bounded preview for the model and preserving the full text elsewhere. ## What - spill hook text once it exceeds a fixed `2_500`-token budget, preserving the full output on disk and leaving a head/tail preview plus saved path in context - add shared hook-output spilling under `CODEX_HOME/hook_outputs/<thread_id>/<uuid>.txt` - apply the cap to both `additionalContext`, `feedback_message`, and `Stop` continuation fragments
This commit is contained in:
@@ -6,14 +6,6 @@ pub(crate) mod schema_loader;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::events::permission_request::PermissionRequestOutcome;
|
||||
use crate::events::permission_request::PermissionRequestRequest;
|
||||
use crate::events::post_tool_use::PostToolUseOutcome;
|
||||
@@ -26,6 +18,15 @@ 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::HookOutputSpiller;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CommandShell {
|
||||
@@ -90,6 +91,7 @@ pub(crate) struct ClaudeHooksEngine {
|
||||
handlers: Vec<ConfiguredHandler>,
|
||||
warnings: Vec<String>,
|
||||
shell: CommandShell,
|
||||
output_spiller: HookOutputSpiller,
|
||||
}
|
||||
|
||||
impl ClaudeHooksEngine {
|
||||
@@ -105,6 +107,7 @@ impl ClaudeHooksEngine {
|
||||
handlers: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
shell,
|
||||
output_spiller: HookOutputSpiller::new(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +121,7 @@ impl ClaudeHooksEngine {
|
||||
handlers: discovered.handlers,
|
||||
warnings: discovered.warnings,
|
||||
shell,
|
||||
output_spiller: HookOutputSpiller::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +159,13 @@ impl ClaudeHooksEngine {
|
||||
request: SessionStartRequest,
|
||||
turn_id: Option<String>,
|
||||
) -> SessionStartOutcome {
|
||||
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
|
||||
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
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
@@ -173,7 +183,16 @@ impl ClaudeHooksEngine {
|
||||
&self,
|
||||
request: PostToolUseRequest,
|
||||
) -> PostToolUseOutcome {
|
||||
crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
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;
|
||||
outcome.feedback_message = self
|
||||
.maybe_spill_text(session_id, outcome.feedback_message)
|
||||
.await;
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_prompt_submit(
|
||||
@@ -187,7 +206,13 @@ impl ClaudeHooksEngine {
|
||||
&self,
|
||||
request: UserPromptSubmitRequest,
|
||||
) -> UserPromptSubmitOutcome {
|
||||
crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await
|
||||
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
|
||||
}
|
||||
|
||||
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
|
||||
@@ -195,7 +220,35 @@ impl ClaudeHooksEngine {
|
||||
}
|
||||
|
||||
pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome {
|
||||
crate::events::stop::run(&self.handlers, &self.shell, request).await
|
||||
let session_id = request.session_id;
|
||||
let mut outcome = crate::events::stop::run(&self.handlers, &self.shell, request).await;
|
||||
outcome.continuation_fragments = self
|
||||
.maybe_spill_prompt_fragments(session_id, outcome.continuation_fragments)
|
||||
.await;
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn maybe_spill_texts(&self, session_id: ThreadId, texts: Vec<String>) -> Vec<String> {
|
||||
self.output_spiller
|
||||
.maybe_spill_texts(session_id, texts)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn maybe_spill_text(&self, session_id: ThreadId, text: Option<String>) -> Option<String> {
|
||||
match text {
|
||||
Some(text) => Some(self.output_spiller.maybe_spill_text(session_id, text).await),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_spill_prompt_fragments(
|
||||
&self,
|
||||
session_id: ThreadId,
|
||||
fragments: Vec<codex_protocol::items::HookPromptFragment>,
|
||||
) -> Vec<codex_protocol::items::HookPromptFragment> {
|
||||
self.output_spiller
|
||||
.maybe_spill_prompt_fragments(session_id, fragments)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod config_rules;
|
||||
mod engine;
|
||||
pub(crate) mod events;
|
||||
mod legacy_notify;
|
||||
mod output_spill;
|
||||
mod registry;
|
||||
mod schema;
|
||||
mod types;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::formatted_truncate_text;
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
const HOOK_OUTPUTS_DIR: &str = "hook_outputs";
|
||||
const HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HookOutputSpiller {
|
||||
output_dir: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl HookOutputSpiller {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
output_dir: AbsolutePathBuf::resolve_path_against_base(std::env::temp_dir(), "/")
|
||||
.join(HOOK_OUTPUTS_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps hook text within the model-visible hook-output budget.
|
||||
///
|
||||
/// Oversized text is written in full under the OS temp directory at
|
||||
/// `<temp_dir>/hook_outputs/<thread_id>/`
|
||||
/// 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 {
|
||||
return text;
|
||||
}
|
||||
|
||||
let path = hook_output_path(&self.output_dir, thread_id);
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(err) = fs::create_dir_all(parent.as_ref()).await
|
||||
{
|
||||
warn!(
|
||||
"failed to create hook output directory {}: {err}",
|
||||
parent.display()
|
||||
);
|
||||
return formatted_truncate_text(
|
||||
&text,
|
||||
TruncationPolicy::Tokens(HOOK_OUTPUT_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),
|
||||
);
|
||||
}
|
||||
|
||||
spilled_hook_output_preview(&text, &path)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_spill_texts(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
texts: Vec<String>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_spill_prompt_fragments(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
fragments: Vec<HookPromptFragment>,
|
||||
) -> Vec<HookPromptFragment> {
|
||||
let mut spilled = Vec::with_capacity(fragments.len());
|
||||
for fragment in fragments {
|
||||
spilled.push(HookPromptFragment {
|
||||
text: self.maybe_spill_text(thread_id, fragment.text).await,
|
||||
hook_run_id: fragment.hook_run_id,
|
||||
});
|
||||
}
|
||||
spilled
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_output_path(output_dir: &AbsolutePathBuf, thread_id: ThreadId) -> AbsolutePathBuf {
|
||||
output_dir
|
||||
.join(thread_id.to_string())
|
||||
.join(format!("{}.txt", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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)),
|
||||
);
|
||||
format!("{}{footer}", formatted_truncate_text(text, preview_policy))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "output_spill_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn small_hook_output_remains_inline() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR);
|
||||
let thread_id = ThreadId::new();
|
||||
let spiller = HookOutputSpiller {
|
||||
output_dir: output_dir.clone(),
|
||||
};
|
||||
|
||||
let output = spiller
|
||||
.maybe_spill_text(thread_id, "short".to_string())
|
||||
.await;
|
||||
|
||||
assert_eq!(output, "short");
|
||||
assert!(!output_dir.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_hook_output_spills_to_file() -> 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 };
|
||||
|
||||
let output = spiller
|
||||
.maybe_spill_text(ThreadId::new(), text.clone())
|
||||
.await;
|
||||
|
||||
assert!(output.contains("tokens truncated"));
|
||||
let path = output
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Full hook output saved to: "))
|
||||
.context("spill path")?;
|
||||
assert_eq!(fs::read_to_string(path).await?, text);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user