Files
codex/codex-rs/hooks/src/output_spill.rs
T
tongzhou wang 1b6e0d8145 Track content provenance through prompt assembly and compaction (#53831)
## Why

Message roles and content classifications do not identify who supplied each context part. Source attribution needs to survive prompt assembly and history truncation, including messages that combine text from different producers.

## What changed

- Add per-part `ContentItemMetadata` for user input, developer instructions, `AGENTS.md`, skills, harness context, command hooks, and tool context with optional callable namespaces.
- Carry attribution alongside content and classifications through contextual fragments, extension prompts, hook output, and agent messages. Keep tool-provided context-window hints separate from harness text.
- Preserve the source of retained text slices during compaction and attribute generated truncation notices to the harness. Default missing historical attribution to unknown.
- Attribute host MCP calls using cached tool namespaces only when catalog revisions remain stable, without starting servers or refreshing catalogs for attribution.
- Send the new metadata to Responses Lite and strip it from requests to endpoints that do not support it.

## Testing

Extend compaction tests to cover mixed sources, missing or extra metadata entries, and discarded media. Update Responses Lite request assertions to verify endpoint-specific attribution handling.

GitOrigin-RevId: b0ebc929b7c60f3ec49d1affbf897312d63edcc8
2026-10-14 02:01:49 +00:00

136 lines
4.5 KiB
Rust

use crate::HookContext;
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";
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<usize>) -> 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 context: HookContext,
pub limit: AdditionalContextLimit,
}
#[derive(Clone)]
pub(crate) struct HookOutputSpiller {
output_dir: AbsolutePathBuf,
}
impl HookOutputSpiller {
pub(crate) fn new(thread_id: ThreadId) -> Self {
Self {
output_dir: AbsolutePathBuf::resolve_path_against_base(std::env::temp_dir(), "/")
.join(HOOK_OUTPUTS_DIR)
.join(thread_id.to_string()),
}
}
/// 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, text: String) -> String {
self.maybe_spill_text_with_limit(text, AdditionalContextLimit::default())
.await
}
pub(crate) async fn maybe_spill_text_with_limit(
&self,
text: String,
limit: AdditionalContextLimit,
) -> String {
let token_limit = limit.token_limit;
if token_limit == 0 || approx_token_count(&text) <= token_limit {
return text;
}
let path = self.output_dir.join(format!("{}.txt", Uuid::new_v4()));
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(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(token_limit));
}
spilled_hook_output_preview(&text, &path, token_limit)
}
pub(crate) async fn maybe_spill_additional_contexts(
&self,
contexts: Vec<AdditionalContext>,
) -> Vec<HookContext> {
let mut spilled = Vec::with_capacity(contexts.len());
for AdditionalContext { mut context, limit } in contexts {
context.text = self.maybe_spill_text_with_limit(context.text, limit).await;
spilled.push(context);
}
spilled
}
pub(crate) async fn maybe_spill_prompt_fragments(
&self,
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(fragment.text).await,
hook_run_id: fragment.hook_run_id,
metadata: fragment.metadata,
});
}
spilled
}
}
/// Builds the model-visible replacement for a spilled hook output.
///
/// The path footer is budgeted before truncation so adding the recovery path
/// 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(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;