From 5158e9780e7c7983efb899baf4a427b313587b22 Mon Sep 17 00:00:00 2001 From: Hung Date: Fri, 13 Nov 2026 21:32:15 +0000 Subject: [PATCH] Add opt-in file-backed tool audio (#61394) ## What changed - Add the disabled-by-default `file_backed_tool_audio` feature to prepare fresh function and custom tool audio through the attachment store before persistence. Store returned file IDs in history and preserve them on replay without uploading again. - Validate and budget mixed text and audio output before uploading retained clips, preserving audio token charges and producer limits. Resolve tool audio durations before continuation compaction checks. - Keep inline audio on backend upload failures and use text placeholders for invalid or rejected clips. Replace unavailable tool audio files with placeholders in prompts while retaining the original history, so restored files can be used again. - Treat file-backed tool audio as a competing source when attributing audio or unknown-file errors to user uploads. ## Testing Add coverage for direct tool and Code Mode audio across follow-up turns, file expiration, restoration, and resume in both legacy and paginated history. Extend tests for truncation before upload, upload failure handling, bounded error placeholders, and error attribution. GitOrigin-RevId: 66c1e7699c7d92f98c3a2d0f5cecbbd63147eb6c --- .../app-server-exports-stable.json.zst | Bin 170647 -> 170701 bytes codex-rs/core/config.schema.json | 6 + .../core/src/compact_remote_v2_attempt.rs | 4 +- codex-rs/core/src/context_manager/history.rs | 9 +- .../context_manager/history_rejected_audio.rs | 34 ++- .../history_rejected_audio_tests.rs | 44 ++- ..._availability.rs => media_availability.rs} | 40 ++- codex-rs/core/src/context_manager/mod.rs | 4 +- codex-rs/core/src/session/file_audio.rs | 215 +++++++++++++- codex-rs/core/src/session/file_audio_tests.rs | 120 ++++++++ codex-rs/core/src/session/mod.rs | 18 +- codex-rs/core/src/session/turn.rs | 6 +- codex-rs/core/src/stream_events_utils.rs | 2 +- codex-rs/core/src/tools/code_mode/output.rs | 4 + codex-rs/core/src/tools/context.rs | 4 + codex-rs/core/src/tools/parallel.rs | 8 +- codex-rs/core/src/tools/registry.rs | 20 +- codex-rs/core/src/tools/registry_tests.rs | 43 ++- codex-rs/core/src/tools/router_tests.rs | 2 +- .../core/tests/suite/audio_duration_store.rs | 38 +++ codex-rs/core/tests/suite/audio_truncation.rs | 59 +++- .../tests/suite/scenarios_audio_file_tests.rs | 265 ++++++++++++++++++ ..._backed_custom_tool_call_output_trace.snap | 136 +++++++++ ...ile_backed_function_call_output_trace.snap | 103 +++++++ codex-rs/features/src/lib.rs | 8 + codex-rs/history/src/lib.rs | 9 + codex-rs/protocol/src/models.rs | 8 + codex-rs/tools/src/tool_output.rs | 9 + .../audio/src/audio_preparation_tests.rs | 10 + codex-rs/utils/audio/src/lib.rs | 71 +++-- codex-rs/utils/output-truncation/src/lib.rs | 70 ++++- .../output-truncation/src/truncate_tests.rs | 24 ++ 32 files changed, 1283 insertions(+), 110 deletions(-) rename codex-rs/core/src/context_manager/{image_availability.rs => media_availability.rs} (71%) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_custom_tool_call_output_trace.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_function_call_output_trace.snap diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index c4ed2837ec555d0709b7f80381efdd07848614d2..7adf228b1ad4c0a7813839af28a413b023341c7f 100644 GIT binary patch delta 24130 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 z%0D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�S00AO^!le)@TwXi6Jjeg= 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 diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9069235c69..0cf6e96e98 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1049,6 +1049,9 @@ "fast_mode": { "type": "boolean" }, + "file_backed_tool_audio": { + "type": "boolean" + }, "gateway_setup_wizard": { "type": "boolean" }, @@ -8205,6 +8208,9 @@ "fast_mode": { "type": "boolean" }, + "file_backed_tool_audio": { + "type": "boolean" + }, "gateway_setup_wizard": { "type": "boolean" }, diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index eaa8f6fa87..e0f2ab5cbc 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -18,7 +18,7 @@ use crate::compact_remote_history::trim_function_call_history_to_fit_context_win use crate::context::world_state::WorldState; use crate::context_manager::ContextManager; use crate::context_manager::rejected_audio; -use crate::context_manager::replace_unresolvable_image_files; +use crate::context_manager::replace_unresolvable_media_files; use crate::responses_metadata::CompactionTurnMetadata; use crate::session::CompactionPrefix; use crate::session::session::Session; @@ -340,7 +340,7 @@ pub(crate) async fn run_background_compaction( ) -> CodexResult { tokio::select! { _ = shutdown_cancellation.cancelled() => return Err(CodexErr::TurnAborted), - () = replace_unresolvable_image_files( + () = replace_unresolvable_media_files( prepared_input.input.iter_mut(), sess.services.image_store.as_ref(), ) => {}, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index ee06ba8edd..42c894f294 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -9,7 +9,7 @@ //! Live usage boundaries advance only when a response supplies usable usage. //! Oversized instructions keep an incomplete excerpt for bounded root review, including //! sources recovered from legacy Guardian checkpoints before their raw history is dropped. -//! Prompt projections replace unavailable file images without changing retained history. +//! Prompt projections replace unavailable file images and tool audio without changing retained history. #[path = "history_rejected_audio.rs"] pub(crate) mod rejected_audio; @@ -25,7 +25,7 @@ use crate::context::world_state::WorldState; use crate::context::world_state::WorldStateSnapshot; use crate::context::world_state::WorldStateUpdate; use crate::context_manager::normalize; -use crate::context_manager::replace_unresolvable_image_files; +use crate::context_manager::replace_unresolvable_media_files; use crate::event_mapping::contextual_dev_fragments; use crate::event_mapping::is_contextual_user_message_content; use crate::event_mapping::parse_turn_item; @@ -680,6 +680,7 @@ impl ContextManager { self.sampling.record(&mut processed); if let ResponseItem::FunctionCallOutput { output, .. } | ResponseItem::CustomToolCallOutput { output, .. } = &mut processed.item + && !metadata.is_some_and(|metadata| metadata.tool_audio_budget_applied) { // The override already includes the tool's serialization allowance. let policy = metadata @@ -716,7 +717,7 @@ impl ContextManager { /// Returns the history prepared for sending to the model. This applies a proper /// normalization and drops un-suited items. Unsupported image and audio content /// is stripped from messages and tool outputs according to `input_modalities`. - /// Unavailable file images are replaced with text placeholders. + /// Unavailable file images and tool audio are replaced with text placeholders. pub(crate) async fn for_prompt( self, input_modalities: &[InputModality], @@ -736,7 +737,7 @@ impl ContextManager { image_store: &dyn AttachmentStore, ) -> Vec { self.normalize_history(input_modalities); - replace_unresolvable_image_files( + replace_unresolvable_media_files( Arc::make_mut(&mut self.items) .iter_mut() .map(|envelope| &mut envelope.item), diff --git a/codex-rs/core/src/context_manager/history_rejected_audio.rs b/codex-rs/core/src/context_manager/history_rejected_audio.rs index 75e7f3d236..229a360f6d 100644 --- a/codex-rs/core/src/context_manager/history_rejected_audio.rs +++ b/codex-rs/core/src/context_manager/history_rejected_audio.rs @@ -58,6 +58,7 @@ pub(crate) fn pending_audio_confirmation( enum FileErrorSource<'a> { Audio(&'a str), InlineAudio, + ToolFileAudio, FileImage, Opaque, } @@ -85,9 +86,12 @@ fn visit_sources<'a>(item: &'a ResponseItem, mut visit: impl FnMut(FileErrorSour | ResponseItem::CustomToolCallOutput { output, .. } => { for part in output.content_items().unwrap_or_default() { match part { - FunctionCallOutputContentItem::InputAudio { .. } => { - visit(FileErrorSource::InlineAudio) - } + FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::File { .. }, + } => visit(FileErrorSource::ToolFileAudio), + FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::Inline { .. }, + } => visit(FileErrorSource::InlineAudio), FunctionCallOutputContentItem::InputImage { image: ImageReference::File { .. }, .. @@ -141,8 +145,8 @@ fn opaque_content(item: &ResponseItem) -> Option<&str> { pub(crate) struct SubmittedFileAudio { file_ids: Vec, candidate: Option, - has_inline_audio: bool, - has_file_image: bool, + has_competing_audio: bool, + has_competing_file: bool, } impl SubmittedFileAudio { @@ -152,14 +156,18 @@ impl SubmittedFileAudio { clear_checkpoints: impl Iterator + Clone, ) -> Option { let mut file_ids = Vec::new(); - let mut has_inline_audio = false; - let mut has_file_image = false; + let mut has_competing_audio = false; + let mut has_competing_file = false; let mut unknown_checkpoint = false; for item in items { visit_sources(item, |source| match source { FileErrorSource::Audio(id) => file_ids.push(id), - FileErrorSource::InlineAudio => has_inline_audio = true, - FileErrorSource::FileImage => has_file_image = true, + FileErrorSource::InlineAudio => has_competing_audio = true, + FileErrorSource::ToolFileAudio => { + has_competing_audio = true; + has_competing_file = true; + } + FileErrorSource::FileImage => has_competing_file = true, FileErrorSource::Opaque => { unknown_checkpoint |= !opaque_content(item).is_some_and(|ciphertext| { clear_checkpoints.clone().any(|known| known == ciphertext) @@ -185,8 +193,8 @@ impl SubmittedFileAudio { Some(Self { file_ids: file_ids.into_iter().map(str::to_owned).collect(), candidate, - has_inline_audio, - has_file_image, + has_competing_audio, + has_competing_file, }) } @@ -195,9 +203,9 @@ impl SubmittedFileAudio { let unique_file = self.file_ids.len() == 1; let attributable = match error { CodexErrorDetails::InvalidAudioInput { per_recording, .. } => { - !self.has_inline_audio && (unique_file || *per_recording) + !self.has_competing_audio && (unique_file || *per_recording) } - _ => unique_file && !self.has_file_image && is_unknown_file_error(error), + _ => unique_file && !self.has_competing_file && is_unknown_file_error(error), }; attributable.then_some(index) } diff --git a/codex-rs/core/src/context_manager/history_rejected_audio_tests.rs b/codex-rs/core/src/context_manager/history_rejected_audio_tests.rs index 6609c63468..22461e697d 100644 --- a/codex-rs/core/src/context_manager/history_rejected_audio_tests.rs +++ b/codex-rs/core/src/context_manager/history_rejected_audio_tests.rs @@ -108,6 +108,38 @@ fn tool_output_audio_makes_an_invalid_audio_error_ambiguous() { } } +#[test] +fn only_file_backed_tool_audio_competes_with_a_user_upload_for_an_unknown_file_error() { + use serde_json::json; + for kind in ["function_call_output", "custom_tool_call_output"] { + for (part, expected) in [ + ( + json!({"type":"input_audio","file_id":"expired_tool_clip"}), + None, + ), + ( + json!({"type":"input_audio","audio_url":"data:audio/wav;base64,AA=="}), + Some("valid_upload"), + ), + ] { + let output = + serde_json::from_value(json!({"type":kind, "call_id":"call", "output":[part]})) + .unwrap(); + let items = [message(vec![file_audio("valid_upload")]), output]; + assert_eq!( + rejected_file_audio_id(&unknown_file_error(), &items).as_deref(), + expected, + "{kind}" + ); + assert_eq!( + rejected_file_audio_id(&audio_error(), &items), + None, + "{kind}" + ); + } + } +} + #[test] fn attributes_audio_error_only_to_one_visible_file_audio() { let error = audio_error(); @@ -145,9 +177,8 @@ fn attributes_audio_error_only_to_one_visible_file_audio() { ); } -#[test] -fn unknown_file_error_counts_the_submitted_projection_not_raw_history() { - let error = CodexErrorDetails::UnexpectedStatus(codex_protocol::error::UnexpectedResponseError { +fn unknown_file_error() -> CodexErrorDetails { + CodexErrorDetails::UnexpectedStatus(codex_protocol::error::UnexpectedResponseError { status: http::StatusCode::NOT_FOUND, body: serde_json::json!({"error": {"message": "Invalid input: Unknown `file_id` in input content."}}).to_string(), url: None, @@ -156,7 +187,12 @@ fn unknown_file_error_counts_the_submitted_projection_not_raw_history() { request_id: None, identity_authorization_error: None, identity_error_code: None, - }); + }) +} + +#[test] +fn unknown_file_error_counts_the_submitted_projection_not_raw_history() { + let error = unknown_file_error(); let unavailable_image_projection = message(vec![ ContentItem::InputText { text: "[Image unavailable]".to_string(), diff --git a/codex-rs/core/src/context_manager/image_availability.rs b/codex-rs/core/src/context_manager/media_availability.rs similarity index 71% rename from codex-rs/core/src/context_manager/image_availability.rs rename to codex-rs/core/src/context_manager/media_availability.rs index c831fd22d1..bb350a234c 100644 --- a/codex-rs/core/src/context_manager/image_availability.rs +++ b/codex-rs/core/src/context_manager/media_availability.rs @@ -1,4 +1,4 @@ -//! Projects unavailable file images into placeholders without changing tool attribution. +//! Projects unavailable images and tool audio into placeholders without changing tool attribution. use std::collections::HashSet; @@ -6,6 +6,7 @@ use codex_attachment_store::AttachmentStore; use codex_context_fragments::AnnotatedContent; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; +use codex_protocol::models::AudioReference; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; use codex_protocol::models::ContentItemMetadata; @@ -15,12 +16,12 @@ use codex_protocol::models::ResponseItem; use futures::StreamExt; use futures::stream; -const MAX_CONCURRENT_IMAGE_AVAILABILITY_CHECKS: usize = 8; +const MAX_CONCURRENT_FILE_AVAILABILITY_CHECKS: usize = 8; -/// Checks distinct file IDs concurrently and replaces only unavailable image content. -pub(crate) async fn replace_unresolvable_image_files<'a>( +/// Checks distinct file IDs concurrently and replaces only media the store confirms is unavailable. +pub(crate) async fn replace_unresolvable_media_files<'a>( items: impl Iterator, - image_store: &dyn AttachmentStore, + store: &dyn AttachmentStore, ) { let items: Vec<_> = items.collect(); let mut file_ids = HashSet::new(); @@ -44,6 +45,9 @@ pub(crate) async fn replace_unresolvable_image_files<'a>( if let FunctionCallOutputContentItem::InputImage { image: ImageReference::File { file_id }, .. + } + | FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::File { file_id }, } = part { file_ids.insert(file_id.clone()); @@ -56,10 +60,10 @@ pub(crate) async fn replace_unresolvable_image_files<'a>( } let mut checks = stream::iter(file_ids) .map(|file_id| async move { - let resolvable = image_store.is_resolvable(&file_id).await; + let resolvable = store.is_resolvable(&file_id).await; (file_id, resolvable) }) - .buffer_unordered(MAX_CONCURRENT_IMAGE_AVAILABILITY_CHECKS); + .buffer_unordered(MAX_CONCURRENT_FILE_AVAILABILITY_CHECKS); let mut unavailable_file_ids = HashSet::new(); while let Some((file_id, resolvable)) = checks.next().await { if !resolvable { @@ -103,14 +107,22 @@ pub(crate) async fn replace_unresolvable_image_files<'a>( | ResponseItem::CustomToolCallOutput { output, .. } => { if let Some(content) = output.content_items_mut() { for part in content { - if let FunctionCallOutputContentItem::InputImage { - image: ImageReference::File { file_id }, - .. - } = part - && unavailable_file_ids.contains(file_id) - { + let (file_id, placeholder) = match part { + FunctionCallOutputContentItem::InputImage { + image: ImageReference::File { file_id }, + .. + } => (file_id, "[image unavailable]"), + FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::File { file_id }, + } => ( + file_id, + "audio content omitted because its file is unavailable", + ), + _ => continue, + }; + if unavailable_file_ids.contains(file_id) { *part = FunctionCallOutputContentItem::InputText { - text: "[image unavailable]".to_string(), + text: placeholder.into(), }; } } diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 67be01956c..4621b8295b 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -1,5 +1,5 @@ mod history; -mod image_availability; +mod media_availability; mod normalize; mod sampling; pub(crate) mod updates; @@ -11,5 +11,5 @@ pub(crate) use history::estimate_item_token_count; pub(crate) use history::estimate_item_token_count_with_audio; pub(crate) use history::is_user_turn_boundary; pub(crate) use history::rejected_audio; -pub(crate) use image_availability::replace_unresolvable_image_files; +pub(crate) use media_availability::replace_unresolvable_media_files; pub(crate) use sampling::sampled_through; diff --git a/codex-rs/core/src/session/file_audio.rs b/codex-rs/core/src/session/file_audio.rs index a94cca9785..15b46fcfd2 100644 --- a/codex-rs/core/src/session/file_audio.rs +++ b/codex-rs/core/src/session/file_audio.rs @@ -8,13 +8,19 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use codex_attachment_store::AttachmentStore; use codex_attachment_store::FormatSpecificMetadata; use codex_attachment_store::ResolveRequest; +use codex_attachment_store::UploadRequest; +use codex_attachment_store::UploadResult; use codex_history::ResponseItemEnvelope; use codex_protocol::models::AudioReference; -use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ResponseItem; use codex_utils_audio::estimate_file_audio_tokens; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_measured_tool_output; use futures::StreamExt; use futures::stream; @@ -37,17 +43,206 @@ pub(crate) async fn snapshot(session: &Session) -> Arc { pub(super) fn file_ids(items: &[ResponseItemEnvelope]) -> impl Iterator { items .iter() - .filter_map(|envelope| match &envelope.item { - ResponseItem::Message { content, .. } => Some(content), - _ => None, + .flat_map(|envelope| envelope.item.file_audio_ids()) +} + +/// Only fresh results pass through the store. A replay keeps the representation in the rollout. +pub(super) async fn prepare_new_items( + items: &mut [ResponseItemEnvelope], + store: &dyn AttachmentStore, + thread_id: &str, + ephemeral: bool, + default_token_limit: usize, +) { + for ResponseItemEnvelope { item, metadata } in items { + let (ResponseItem::FunctionCallOutput { output, .. } + | ResponseItem::CustomToolCallOutput { output, .. }) = item + else { + codex_utils_audio::prepare_response_items(std::slice::from_mut(item)); + continue; + }; + if !output.contains_audio() { + continue; + } + let metadata = metadata.get_or_insert_default(); + // The producer's output limit can be larger than the history limit (e.g. Code Mode). + // Apply the latter to the whole result while audio still has its inline cost. + let limit = metadata + .history_truncation_token_limit + .unwrap_or(default_token_limit); + let producer_budgeted = std::mem::take(&mut metadata.producer_bounded_tool_audio); + // Validate and measure on a worker. Only clips retained by history need their + // decoded bytes materialized for upload, one at a time. + let Some(parts) = output.content_items_mut() else { + continue; + }; + let parts = std::mem::take(parts); + let preserve_producer_limit = + producer_budgeted && metadata.history_truncation_token_limit.is_none(); + let Ok((mut admitted, repair_limit)) = tokio::task::spawn_blocking(move || { + admit_tool_audio(parts, limit, preserve_producer_limit) }) - .flatten() - .filter_map(|part| match part { - ContentItem::InputAudio { + .await + else { + *output = codex_protocol::models::FunctionCallOutputPayload::from_text( + "audio content omitted because it could not be processed".into(), + ); + continue; + }; + let mut replaced_with_text = false; + for (part, charge) in &mut admitted { + if let FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::Inline { audio_url }, + } = part + { + let url = std::mem::take(audio_url); + *part = store_tool_audio(store, thread_id, ephemeral, url).await; + if matches!(part, FunctionCallOutputContentItem::InputText { .. }) { + *charge = 0; + replaced_with_text = true; + } + } + } + if replaced_with_text { + admitted = + truncate_measured_tool_output(admitted, TruncationPolicy::Tokens(repair_limit)); + } + output.body = codex_protocol::models::FunctionCallOutputBody::ContentItems( + admitted.into_iter().map(|(part, _)| part).collect(), + ); + metadata.tool_audio_budget_applied = true; + } +} + +fn admit_tool_audio( + parts: Vec, + history_limit: usize, + preserve_producer_limit: bool, +) -> (Vec<(FunctionCallOutputContentItem, usize)>, usize) { + let mut measured = Vec::with_capacity(parts.len()); + for mut part in parts { + let charge = match &mut part { + FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::Inline { audio_url }, + } => match codex_utils_audio::prepare_and_measure_inline_audio(audio_url) { + Ok(tokens) => tokens, + Err(error) => { + part = FunctionCallOutputContentItem::InputText { + text: error.placeholder().into(), + }; + 0 + } + }, + FunctionCallOutputContentItem::InputAudio { + audio: AudioReference::File { .. }, + } => estimate_file_audio_tokens(/*duration*/ None), + _ => 0, + }; + measured.push((part, charge)); + } + let admitted = truncate_measured_tool_output(measured, TruncationPolicy::Tokens(history_limit)); + // Admission matches inline history. Only storage can introduce a new placeholder + // after that point. With no explicit producer limit, keep such repairs within + // the size of the result the model would otherwise have received. + let repair_limit = if preserve_producer_limit { + admitted + .iter() + .fold(0_usize, |sum, (part, charge)| { + let cost = match part { + FunctionCallOutputContentItem::InputText { text } => approx_token_count(text), + _ => *charge, + }; + sum.saturating_add(cost) + }) + .min(history_limit) + } else { + history_limit + }; + (admitted, repair_limit) +} + +async fn store_tool_audio( + store: &dyn AttachmentStore, + thread_id: &str, + ephemeral: bool, + inline_url: String, +) -> FunctionCallOutputContentItem { + let (mime, data, inline_url) = match tokio::task::spawn_blocking(move || { + codex_utils_audio::decode_audio(&inline_url).map(|(mime, data)| (mime, data, inline_url)) + }) + .await + { + Ok(Ok(decoded)) => decoded, + Ok(Err(error)) => { + return FunctionCallOutputContentItem::InputText { + text: error.placeholder().into(), + }; + } + Err(_) => { + return FunctionCallOutputContentItem::InputText { + text: "audio content omitted because it could not be processed".into(), + }; + } + }; + // These tags come from Core. MCP servers and Code Mode never supply attachment IDs or policy tags. + let tags = HashMap::from([ + ("usage".to_string(), "tool_audio".to_string()), + ("mime_type".to_string(), mime.to_string()), + ]); + let result = store + .upload(UploadRequest { + thread_id: thread_id.to_owned(), + ephemeral, + file_name: None, + tags, + data, + }) + .await; + tool_audio_after_upload(result, mime, inline_url) +} + +fn tool_audio_after_upload( + result: Result, + mime: &str, + mut inline_url: String, +) -> FunctionCallOutputContentItem { + match result { + Ok(UploadResult::File { file_id }) if !file_id.trim().is_empty() => { + FunctionCallOutputContentItem::InputAudio { audio: AudioReference::File { file_id }, - } => Some(file_id), - _ => None, - }) + } + } + Ok(UploadResult::Inline { bytes }) => FunctionCallOutputContentItem::inline_audio( + codex_utils_audio::audio_data_url(mime, &bytes), + ), + Err(error) if error.kind() == codex_attachment_store::AttachmentStoreErrorKind::Backend => { + tracing::warn!(kind = ?error.kind(), "tool audio upload failed; using inline audio"); + // decode_audio validated the base64 and normalized the MIME alias. + if let Some(separator) = inline_url.find(',') { + inline_url.replace_range(..separator, &format!("data:{mime};base64")); + } + FunctionCallOutputContentItem::inline_audio(inline_url) + } + Err(error) + if error.kind() + == codex_attachment_store::AttachmentStoreErrorKind::InvalidAttachment => + { + FunctionCallOutputContentItem::InputText { + text: "audio content omitted because the attachment store rejected the clip".into(), + } + } + Ok(UploadResult::File { .. }) | Err(_) => { + tracing::warn!("could not store tool audio attachment"); + FunctionCallOutputContentItem::InputText { + text: "audio content omitted because it could not be stored".to_owned(), + } + } + } +} + +pub(super) async fn resolve_pending(session: &Session) { + let ids = std::mem::take(&mut session.state.lock().await.pending_audio_duration_ids); + resolve(session, ids).await; } pub(super) async fn resolve(session: &Session, mut file_ids: BTreeSet) { diff --git a/codex-rs/core/src/session/file_audio_tests.rs b/codex-rs/core/src/session/file_audio_tests.rs index 0e12b5b340..5871dbf048 100644 --- a/codex-rs/core/src/session/file_audio_tests.rs +++ b/codex-rs/core/src/session/file_audio_tests.rs @@ -44,3 +44,123 @@ fn history_sums_measured_and_unknown_file_audio() { ); assert_eq!(history.estimated_tokens_since_last_usage(), 12_300); } + +#[test] +fn transient_upload_failure_retains_inline_audio_but_invalid_attachments_are_omitted() { + use codex_attachment_store::AttachmentStoreError; + use codex_attachment_store::AttachmentStoreErrorKind; + use serde_json::json; + for (kind, expected) in [ + ( + AttachmentStoreErrorKind::Backend, + json!({"type":"input_audio", "audio_url":"data:audio/wav;base64,QQ=="}), + ), + ( + AttachmentStoreErrorKind::InvalidAttachment, + json!({"type":"input_text", "text":"audio content omitted because the attachment store rejected the clip"}), + ), + ] { + let result = Err(AttachmentStoreError::new(kind, "upload failed")); + let part = + tool_audio_after_upload(result, "audio/wav", "data:audio/x-wav;base64,QQ==".into()); + assert_eq!(serde_json::to_value(part).unwrap(), expected); + } +} + +async fn prepared_tool_output( + parts: serde_json::Value, + metadata: codex_history::CodexHarnessMetadata, + limit: usize, +) -> serde_json::Value { + let mut item = ResponseItemEnvelope { + item: serde_json::from_value( + serde_json::json!({"type":"function_call_output","call_id":"clip","output":parts}), + ) + .unwrap(), + metadata: Some(metadata), + }; + prepare_new_items( + std::slice::from_mut(&mut item), + &codex_attachment_store::InlineAttachmentStore, + "thread", + /*ephemeral*/ false, + limit, + ) + .await; + let metadata = item.metadata.unwrap(); + assert!(!metadata.producer_bounded_tool_audio); + assert!(metadata.tool_audio_budget_applied); + assert_eq!( + serde_json::to_value(&metadata).unwrap()["tool_audio_budget_applied"], + true + ); + serde_json::to_value(item.item).unwrap()["output"].take() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn producer_bounded_audio_keeps_the_same_parts_as_inline_history() { + use codex_history::CodexHarnessMetadata; + use serde_json::json; + let clip = json!({"type":"input_audio","audio_url":"data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAwF0AAIC7AAACABAAZGF0YQIAAAAAAA=="}); + let caption = json!({"type":"input_text","text":"caption ".repeat(100)}); + let policy = TruncationPolicy::Tokens(20); + let limit = codex_utils_output_truncation::with_serialization_allowance(policy).token_budget(); + let malformed = json!({"type":"input_audio","audio_url":format!("data:audio/wav;base64,{}", "!".repeat(8192))}); + for (parts, keep_clip) in [ + (json!([malformed, clip]), true), + (json!([{"type":"input_audio","audio_url":""}, clip]), true), + (json!([clip, caption]), true), + (json!([caption, clip]), false), + ] { + let mut inline = serde_json::from_value( + json!({"type":"function_call_output","call_id":"clip","output":parts}), + ) + .unwrap(); + codex_utils_audio::prepare_response_items(std::slice::from_mut(&mut inline)); + let mut history = ContextManager::new(uuid_v7_item_id_generator_for_test()); + history.record_items([&inline], policy); + let expected = + serde_json::to_value(history.raw_items().last().unwrap()).unwrap()["output"].take(); + assert_eq!(expected.as_array().unwrap().contains(&clip), keep_clip); + let metadata = CodexHarnessMetadata { + producer_bounded_tool_audio: true, + ..Default::default() + }; + assert_eq!(prepared_tool_output(parts, metadata, limit).await, expected); + } +} + +#[tokio::test] +async fn zero_cost_invalid_audio_cannot_expand_into_unbounded_error_text() { + use codex_history::CodexHarnessMetadata; + use serde_json::json; + for producer_budgeted in [false, true] { + let mut parts = vec![json!({"type":"input_text","text":"keep"})]; + parts.extend((0..100).map(|_| json!({"type":"input_audio","audio_url":""}))); + let metadata = CodexHarnessMetadata { + producer_bounded_tool_audio: producer_budgeted, + ..Default::default() + }; + assert_eq!( + prepared_tool_output(parts.into(), metadata, /*limit*/ 1).await, + json!([{"type":"input_text","text":"keep"},{"type":"input_text","text":"[omitted 100 text items ...]"}]) + ); + } +} + +#[tokio::test] +async fn known_producer_limit_keeps_the_replacement_and_following_caption() { + use serde_json::json; + let caption = json!({"type":"input_text","text":"caption"}); + let parts = json!([{"type":"input_audio","audio_url":""}, caption]); + let metadata = codex_history::CodexHarnessMetadata { + producer_bounded_tool_audio: true, + history_truncation_token_limit: Some(100), + ..Default::default() + }; + let error = codex_utils_audio::decode_audio("").unwrap_err(); + assert_eq!( + prepared_tool_output(parts, metadata, /*limit*/ 1).await, + json!([{"type":"input_text","text":error.placeholder()},caption]) + ); +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a4a4bef0e1..a82aac213c 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3961,9 +3961,25 @@ impl Session { self.services.image_store.as_ref(), )) .await; + let file_backed = turn_context + .config + .features + .enabled(Feature::FileBackedToolAudio); + if file_backed { + Box::pin(file_audio::prepare_new_items( + &mut items, + self.services.image_store.as_ref(), + &self.thread_id.to_string(), + turn_context.config.ephemeral, + with_serialization_allowance(model_info.truncation_policy.into()).token_budget(), + )) + .await; + } for envelope in &mut items { let item = &mut envelope.item; - prepare_audio_response_items(std::slice::from_mut(item)); + if !file_backed { + prepare_audio_response_items(std::slice::from_mut(item)); + } // Most response items get their passthrough turn ID at the durable history boundary. Self::stamp_response_item_for_history(item, &turn_context.sub_id); self.assign_missing_response_item_id(item); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 08e01df6b7..8ebb2e9550 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -678,6 +678,9 @@ pub(crate) async fn run_turn( // Process async hooks only after sampling and its tools have finished. drain_async_hook_results(&sess, &turn_context, /*before_user_prompt*/ false).await; let (has_pending_input, token_status) = async { + // Tool output can add audio without a queued user input. Resolve its + // duration before checking the continuation's compaction threshold. + super::file_audio::resolve_pending(&sess).await; let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await; let token_status = super::context_window::context_window_token_status( @@ -1079,8 +1082,7 @@ pub(crate) async fn run_hooks_and_record_inputs( // The batch has left the input queue. Persist every accepted item before the // optional lookup can suspend this task long enough for an interrupt to abort it. if !input.is_empty() { - let ids = std::mem::take(&mut sess.state.lock().await.pending_audio_duration_ids); - super::file_audio::resolve(sess, ids).await; + super::file_audio::resolve_pending(sess).await; } Ok(blocked_input && !accepted_user_input) } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index ad1df3f04f..63f0dde03f 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -393,7 +393,7 @@ pub(crate) async fn handle_output_item_done( // The call is already in history; resume needs its output even if we never dispatch. let output = ToolCallRuntime::aborted_response(&call, /*elapsed_secs*/ None); - return Ok(output.into_response()); + return Ok(output.into_response(/*file_backed_tool_audio*/ false)); } runtime.handle_tool_call(call, cancellation_token).await }) diff --git a/codex-rs/core/src/tools/code_mode/output.rs b/codex-rs/core/src/tools/code_mode/output.rs index a85b77ac3e..be3ea1bd59 100644 --- a/codex-rs/core/src/tools/code_mode/output.rs +++ b/codex-rs/core/src/tools/code_mode/output.rs @@ -40,6 +40,10 @@ impl CodeModeToolOutput { } impl ToolOutput for CodeModeToolOutput { + fn output_budget_applied(&self) -> bool { + true + } + fn log_output(&self) -> String { self.output.log_output() } diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 4460563e66..5b2845698d 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -178,6 +178,10 @@ pub struct McpToolOutput { } impl ToolOutput for McpToolOutput { + fn output_budget_applied(&self) -> bool { + true + } + fn log_output(&self) -> String { // Logging has its own budget; do not first apply the model-context budget. let output = self.result.log_output(); diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index ea467edd89..98eeb26405 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -112,6 +112,12 @@ impl ToolCallRuntime { ) -> impl std::future::Future> { let error_call = call.clone(); let source = call.direct_source(); + let file_backed_tool_audio = self + .step_context + .turn + .config + .features + .enabled(codex_features::Feature::FileBackedToolAudio); let recorder = self.session.services.executed_tool_calls.clone(); let item_id_generator = Arc::clone(&self.session.item_id_generator); let recorded_call = recorder.prepare_direct_call(&call, &source, &self.step_context); @@ -135,7 +141,7 @@ impl ToolCallRuntime { { call.set_tool_result_metadata(ToolResultMetadata::new(metadata)); } - result.into_response() + result.into_response(file_backed_tool_audio) } Err(FunctionCallError::Fatal(message)) => return Err(CodexErr::Fatal(message)), Err(other) => { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 9269b8dd6b..59ff5bbda9 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -35,6 +35,7 @@ use codex_history::CodexHarnessMetadata; use codex_history::ResponseItemEnvelope; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::EventMsg; use codex_rollout::state_db; @@ -327,7 +328,7 @@ pub(crate) struct AnyToolResult { } impl AnyToolResult { - pub(crate) fn into_response(self) -> ResponseItemEnvelope { + pub(crate) fn into_response(self, file_backed_tool_audio: bool) -> ResponseItemEnvelope { let Self { call_id, payload, @@ -335,12 +336,19 @@ impl AnyToolResult { .. } = self; let history_truncation_token_limit = result.fallback_token_limit_override(); + let item = result.to_response_item(&call_id, &payload).into(); + let producer_bounded_tool_audio = file_backed_tool_audio + && result.output_budget_applied() + && matches!(&item, ResponseItem::FunctionCallOutput { output, .. } + | ResponseItem::CustomToolCallOutput { output, .. } if output.contains_audio()); ResponseItemEnvelope { - item: result.to_response_item(&call_id, &payload).into(), - metadata: history_truncation_token_limit.map(|limit| CodexHarnessMetadata { - history_truncation_token_limit: Some(limit), - ..Default::default() - }), + item, + metadata: (producer_bounded_tool_audio || history_truncation_token_limit.is_some()) + .then_some(CodexHarnessMetadata { + producer_bounded_tool_audio, + history_truncation_token_limit, + ..Default::default() + }), } } diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 10f445297f..01ce0a6e3a 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -640,7 +640,7 @@ fn post_tool_use_feedback_output_preserves_fallback_token_limit_override( }; assert_eq!( - result.into_response(), + result.into_response(/*file_backed_tool_audio*/ false), ResponseItemEnvelope { item: ResponseItem::from(ResponseInputItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -654,6 +654,45 @@ fn post_tool_use_feedback_output_preserves_fallback_token_limit_override( ); } +#[test_case::test_case(true; "file-backed audio enabled")] +#[test_case::test_case(false; "file-backed audio disabled")] +fn mcp_audio_producer_limit_is_transient_until_history_admission(file_backed_tool_audio: bool) { + let result = AnyToolResult { + call_id: "clip".into(), + payload: ToolPayload::Function { + arguments: "{}".into(), + }, + result: Box::new(crate::tools::context::McpToolOutput { + result: codex_protocol::mcp::CallToolResult { + content: vec![serde_json::json!({ + "type": "audio", + "mimeType": "audio/wav", + "data": "UklGRiYAAABXQVZFZm10IBAAAAABAAEAwF0AAIC7AAACABAAZGF0YQIAAAAAAA==", + })], + structured_content: None, + is_error: None, + meta: None, + }, + tool_input: serde_json::json!({}), + result_metadata_capture_allowed: false, + wall_time: Duration::ZERO, + original_image_detail_supported: false, + truncation_policy: TruncationPolicy::Tokens(100), + }), + post_tool_use_payload: None, + }; + + let metadata = result + .into_response(file_backed_tool_audio) + .metadata + .unwrap(); + assert_eq!(metadata.producer_bounded_tool_audio, file_backed_tool_audio); + assert!(!metadata.tool_audio_budget_applied); + let serialized = serde_json::to_value(&metadata).unwrap(); + assert!(serialized.get("producer_bounded_tool_audio").is_none()); + assert!(serialized.get("tool_audio_budget_applied").is_none()); +} + #[test] fn post_tool_use_feedback_output_keeps_code_mode_result_typed() { let result = AnyToolResult { @@ -674,7 +713,7 @@ fn post_tool_use_feedback_output_keeps_code_mode_result_typed() { }; assert_eq!( - result.into_response(), + result.into_response(/*file_backed_tool_audio*/ false), ResponseItemEnvelope::new( ResponseInputItem::FunctionCallOutput { call_id: "call-1".to_string(), diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index aa862f9591..bbb1139206 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -692,7 +692,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow ) .await?; - let response = result.into_response().item; + let response = result.into_response(/*file_backed_tool_audio*/ false).item; match response { ResponseItem::FunctionCallOutput { call_id, output, .. diff --git a/codex-rs/core/tests/suite/audio_duration_store.rs b/codex-rs/core/tests/suite/audio_duration_store.rs index caf7750ec1..1678de6e26 100644 --- a/codex-rs/core/tests/suite/audio_duration_store.rs +++ b/codex-rs/core/tests/suite/audio_duration_store.rs @@ -12,22 +12,60 @@ use codex_attachment_store::ResolveFuture; use codex_attachment_store::ResolveRequest; use codex_attachment_store::UploadFuture; use codex_attachment_store::UploadRequest; +use codex_attachment_store::UploadResult; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; #[derive(Default)] pub(super) struct TestAudioDurationStore { pub(super) slow_lookup_started: tokio::sync::Notify, pub(super) measure_short_clips: bool, + pub(super) tool_uploads: Mutex>, + pub(super) tool_file_expired: AtomicBool, + pub(super) tool_duration_reads: AtomicUsize, } impl AttachmentStore for TestAudioDurationStore { fn upload(&self, request: UploadRequest) -> UploadFuture<'_> { + if request + .tags + .get("usage") + .is_some_and(|usage| usage == "tool_audio") + { + self.tool_uploads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request); + return Box::pin(async { + Ok(UploadResult::File { + file_id: "file_tool_audio".into(), + }) + }); + } InlineAttachmentStore.upload(request) } + fn is_resolvable<'a>( + &'a self, + file_id: &'a str, + ) -> codex_attachment_store::IsResolvableFuture<'a> { + Box::pin(async move { + file_id != "file_tool_audio" || !self.tool_file_expired.load(Ordering::SeqCst) + }) + } + fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a> { Box::pin(async move { assert!(request.download_url_ttl.is_none()); let format_specific = match request.file_id { + "file_tool_audio" => { + self.tool_duration_reads.fetch_add(1, Ordering::SeqCst); + Some(FormatSpecificMetadata::Audio(AudioMetadata { + duration_ms: Some(500), + })) + } "file_audio_1" | "file_audio_7" if self.measure_short_clips => { Some(FormatSpecificMetadata::Audio(AudioMetadata { duration_ms: Some(30_000), diff --git a/codex-rs/core/tests/suite/audio_truncation.rs b/codex-rs/core/tests/suite/audio_truncation.rs index 3e24f5939a..c8909c6886 100644 --- a/codex-rs/core/tests/suite/audio_truncation.rs +++ b/codex-rs/core/tests/suite/audio_truncation.rs @@ -1,8 +1,10 @@ +use super::audio_duration_store::TestAudioDurationStore; use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::StartThreadOptions; use codex_core::TurnInputRequest; +use codex_features::Feature; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; @@ -23,11 +25,11 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; +use std::sync::Arc; const TEST_WAV_SAMPLE_RATE: u32 = 8_000; -const OMITTED_AUDIO_MARKER: &str = "[omitted 1 audio items ...]"; -fn pcm_wav_data_url(sample_count: u32) -> String { +pub(super) fn pcm_wav_data_url(sample_count: u32) -> String { let padding = sample_count % 2; let mut bytes = Vec::new(); bytes.extend_from_slice(b"RIFF"); @@ -49,8 +51,12 @@ fn pcm_wav_data_url(sample_count: u32) -> String { format!("data:audio/wav;base64,{}", BASE64_STANDARD.encode(bytes)) } +#[test_case::test_case(false; "inline")] +#[test_case::test_case(true; "file backed")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<()> { +async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted( + file_backed: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -73,10 +79,21 @@ async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<( ) .await; - let mut builder = test_codex().with_model_info_override("gpt-5.5", |model_info| { - model_info.input_modalities.push(InputModality::Audio); - model_info.truncation_policy = TruncationPolicyConfig::tokens(/*limit*/ 50); - }); + let store = Arc::new(TestAudioDurationStore::default()); + let mut builder = test_codex() + .with_image_store(store.clone()) + .with_config(move |config| { + assert!( + config + .features + .set_enabled(Feature::FileBackedToolAudio, file_backed) + .is_ok() + ); + }) + .with_model_info_override("gpt-5.5", |model_info| { + model_info.input_modalities.push(InputModality::Audio); + model_info.truncation_policy = TruncationPolicyConfig::tokens(/*limit*/ 50); + }); let base_test = builder.build_with_auto_env(&server).await?; let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { name: "codex_app".to_string(), @@ -118,13 +135,24 @@ async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<( else { unreachable!("event guard guarantees DynamicToolCallRequest"); }; + // The four-second clip fits by itself, but the preceding text has consumed 40 + // of the 60 tokens (including the serialization allowance). + let prefix = "note".repeat(40); test.codex .submit(Op::DynamicToolResponse { id: request.call_id, response: DynamicToolResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputAudio { - audio_url: pcm_wav_data_url(/*sample_count*/ 80_000), - }], + content_items: vec![ + DynamicToolCallOutputContentItem::InputText { + text: prefix.clone(), + }, + DynamicToolCallOutputContentItem::InputAudio { + audio_url: pcm_wav_data_url(/*sample_count*/ 32_000), + }, + DynamicToolCallOutputContentItem::InputAudio { + audio_url: pcm_wav_data_url(/*sample_count*/ 80_000), + }, + ], success: true, }, }) @@ -134,6 +162,14 @@ async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<( }) .await; + assert!( + store + .tool_uploads + .lock() + .map_err(|_| anyhow::anyhow!("upload recorder poisoned"))? + .is_empty(), + "over-budget audio must be omitted before upload" + ); let requests = responses_mock.requests(); assert_eq!(requests.len(), 2); let output = requests[1] @@ -145,8 +181,9 @@ async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<( serde_json::from_value::(output)?, FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ + FunctionCallOutputContentItem::InputText { text: prefix }, FunctionCallOutputContentItem::InputText { - text: OMITTED_AUDIO_MARKER.to_string(), + text: "[omitted 2 audio items ...]".to_string(), }, ]), success: None, diff --git a/codex-rs/core/tests/suite/scenarios_audio_file_tests.rs b/codex-rs/core/tests/suite/scenarios_audio_file_tests.rs index b7e66ed2d2..93b62e9ea9 100644 --- a/codex-rs/core/tests/suite/scenarios_audio_file_tests.rs +++ b/codex-rs/core/tests/suite/scenarios_audio_file_tests.rs @@ -2,8 +2,10 @@ use super::super::audio_duration_store::TestAudioDurationStore; use super::*; +use anyhow::Context as _; use codex_protocol::models::AudioReference; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::turn_input::TurnInputSubmission; use core_test_support::responses; use core_test_support::responses::mount_response_sequence; @@ -82,6 +84,269 @@ async fn uploaded_audio_survives_follow_up_and_resume() -> Result<()> { Ok(()) } +/// The model calls a registered tool directly or via Code Mode. Both persist the store's ID. +#[test_case(ToolMode::Direct, "function_call_output", ThreadHistoryMode::Legacy; "function")] +#[test_case(ToolMode::Direct, "function_call_output", ThreadHistoryMode::Paginated; "paginated function")] +#[test_case(ToolMode::CodeMode, "custom_tool_call_output", ThreadHistoryMode::Legacy; "code_mode")] +#[test_case(ToolMode::CodeMode, "custom_tool_call_output", ThreadHistoryMode::Paginated; "paginated code_mode")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_audio_store_id_survives_rollout_and_next_request( + mode: ToolMode, + output: &str, + history_mode: ThreadHistoryMode, +) -> Result<()> { + use std::sync::atomic::Ordering; + + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let call_id = "recording-call"; + let store = Arc::new(TestAudioDurationStore::default()); + let builder = || { + test_codex() + .with_history_mode(history_mode) + .with_image_store(store.clone()) + .with_config(move |config| { + config.allow_persistent_dynamic_tool_updates = true; + assert!(config.features.enable(Feature::FileBackedToolAudio).is_ok()); + assert!( + config + .features + .set_enabled(Feature::CodeMode, mode == ToolMode::CodeMode) + .is_ok() + ); + }) + .with_model_info_override("gpt-5.5", move |model| { + model.input_modalities.push(InputModality::Audio); + // An unknown file reserves 12,000 tokens; this half-second clip must continue without compaction. + model.auto_compact_token_limit = Some(10_000); + model.tool_mode = Some(mode); + model.use_responses_lite = mode == ToolMode::CodeMode; + model.truncation_policy = + codex_protocol::openai_models::TruncationPolicyConfig::tokens( + /*limit*/ 50, + ); + }) + }; + let test = builder().build_with_auto_env(&server).await?; + test.codex + .replace_dynamic_tools(vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { + name: "recording".into(), + description: "Return an audio clip.".into(), + input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), + defer_loading: false, + })]) + .await?; + + let call = if mode == ToolMode::CodeMode { + ev_custom_tool_call( + call_id, + "exec", + r#"audio(await tools.recording({})); text("caption ".repeat(400));"#, + ) + } else { + responses::ev_function_call(call_id, "recording", "{}") + }; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("r1"), call, ev_completed("r1")]), + sse(vec![ + ev_assistant_message("heard", "I received the clip."), + ev_completed("r2"), + ]), + sse(vec![ + ev_assistant_message("expired", "The recording is unavailable."), + ev_completed("r3"), + ]), + sse(vec![ + ev_assistant_message("resumed", "The recording is still unavailable."), + ev_completed("r4"), + ]), + sse(vec![ + ev_assistant_message("restored", "The recording is available again."), + ev_completed("r5"), + ]), + ], + ) + .await; + + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![text( + "Listen to the recording.", + )])) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(call) => Some(call.clone()), + _ => None, + }) + .await; + assert_eq!(request.tool, "recording"); + let wav = super::super::audio_truncation::pcm_wav_data_url(/*sample_count*/ 4_000); + test.codex + .submit(Op::DynamicToolResponse { + id: request.call_id, + response: DynamicToolResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputAudio { + audio_url: wav.clone(), + }], + success: true, + }, + }) + .await?; + let live_parts = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ItemCompleted(event) => match &event.item { + TurnItem::DynamicToolCall(call) if call.tool == "recording" => { + call.content_items.clone() + } + _ => None, + }, + _ => None, + }) + .await; + assert_eq!( + live_parts, + vec![DynamicToolCallOutputContentItem::InputAudio { + audio_url: wav.clone() + }] + ); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert_eq!( + store.tool_duration_reads.load(Ordering::SeqCst), + 1, + "duration resolved during the original tool turn" + ); + store.tool_file_expired.store(true, Ordering::SeqCst); + test.submit_text_turn("Check the recording again.").await?; + let rollout = test.codex.rollout_path().expect("durable thread rollout"); + test.codex.shutdown_and_wait().await?; + // Reopen the durable store in a fresh manager, as a hosted resume would. + let restarted = builder() + .with_home(test.home.clone()) + .build_with_auto_env(&server) + .await?; + let stored = restarted + .thread_store + .load_latest_model_context(codex_thread_store::LoadThreadHistoryParams { + thread_id: test.session_configured.thread_id, + include_archived: true, + }) + .await?; + let resumed = restarted + .thread_manager + .resume_thread_with_history( + restarted.config.clone(), + codex_history::InitialHistory::Resumed(codex_history::ResumedHistory { + history_revision: None, + conversation_id: stored.thread_id, + history: Arc::new(stored.items), + rollout_path: Some(rollout.clone()), + }), + restarted.thread_manager.auth_manager(), + /*parent_trace*/ None, + Default::default(), + ) + .await?; + assert_eq!( + resumed.thread.config_snapshot().await.history_mode, + history_mode + ); + for (expired, prompt) in [ + (true, "Listen after restart."), + (false, "Listen after the file is restored."), + ] { + store.tool_file_expired.store(expired, Ordering::SeqCst); + resumed + .thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![text(prompt)])) + .await?; + wait_for_event(&resumed.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + let requests = mock.requests(); + assert_eq!(requests.len(), 5); + let expected = json!({"type":"input_audio","file_id":"file_tool_audio"}); + let persisted = std::fs::read_to_string(rollout)?; + let items = persisted + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let audio_output = + |item: &serde_json::Value| item["type"] == output && item["call_id"] == call_id; + let history = items + .iter() + .map(|line| &line["payload"]) + .find(|item| audio_output(item)) + .expect("persisted tool output"); + let output_parts = history["output"] + .as_array() + .context("persisted tool content")?; + let audio = output_parts + .iter() + .filter(|part| part["type"] == "input_audio") + .collect::>(); + assert_eq!(audio, vec![&expected]); + if mode == ToolMode::CodeMode { + let caption = output_parts + .iter() + .filter_map(|part| part["text"].as_str()) + .find(|text| text.contains("caption")) + .expect("some caption remains after the clip"); + assert!( + caption.len() < 400, + "history must bound the 2,800-byte caption" + ); + } + let mut unavailable = history["output"].clone(); + for part in unavailable + .as_array_mut() + .context("tool content for the expired-file request")? + { + if *part == expected { + *part = json!({"type":"input_text","text":"audio content omitted because its file is unavailable"}); + } + } + for (request, expected_output) in requests[1..].iter().zip([ + &history["output"], + &unavailable, + &unavailable, + &history["output"], + ]) { + let item = request + .input() + .into_iter() + .find(&audio_output) + .expect("tool result after execution and resume"); + assert_eq!(&item["output"], expected_output); + } + { + let uploads = store + .tool_uploads + .lock() + .map_err(|_| anyhow::anyhow!("upload recorder poisoned"))?; + let [upload] = uploads.as_slice() else { + panic!("expected one upload across execution and resume"); + }; + assert_eq!(upload.tags["usage"], "tool_audio"); + assert_eq!(upload.data, codex_utils_audio::decode_audio(&wav)?.1); + } + insta::assert_snapshot!( + format!("file_backed_{output}_trace"), + context_snapshot::format_request_history_snapshot( + "The tool returns file-backed audio. Follow-up and cold resume omit the expired clip, then use the original file again when it is available.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + Ok(()) +} + #[derive(Clone, Copy, PartialEq, Eq)] enum AudioAtCompaction { Available, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_custom_tool_call_output_trace.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_custom_tool_call_output_trace.snap new file mode 100644 index 0000000000..ef17013129 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_custom_tool_call_output_trace.snap @@ -0,0 +1,136 @@ +--- +source: core/tests/suite/scenarios_audio_file_tests.rs +expression: "context_snapshot::format_request_history_snapshot(\"The tool returns file-backed audio. Follow-up and cold resume omit the expired clip, then use the original file again when it is available.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The tool returns file-backed audio. Follow-up and cold resume omit the expired clip, then use the original file again when it is available. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.5" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (2; hash=3299611bc98d0784): + - namespace/functions; hash=1976557bc4879146 + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - function/recording + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=b573ed69c0b0e6c7 +01:message/developer: + +02:message/developer[2]: + [01] + [02] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +03:message/user: + +04:message/user: + Listen to the recording. +-- request 2 (turn) -- +05:custom_tool_call/exec:audio(await tools.recording({})); text("caption ".repeat(400)); +06:custom_tool_call_output:Script completed + Wall time seconds + Output: + | | caption caption caption caption caption caption caption caption caption caption captio…757 tokens trunca...ption caption caption caption caption caption caption [hash=a35e70d094c6690c] + +## Window 2 (after request 2: input diverged at item 06) +Settings: same as window 1 +-- request 3 (turn) -- +00:additional_tools/developer (2; hash=3299611bc98d0784): + - namespace/functions; hash=1976557bc4879146 + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - function/recording + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=b573ed69c0b0e6c7 +01:message/developer: + +02:message/developer[2]: + [01] + [02] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +03:message/user: + +04:message/user: + Listen to the recording. +05:custom_tool_call/exec:audio(await tools.recording({})); text("caption ".repeat(400)); +06:custom_tool_call_output:Script completed + Wall time seconds + Output: + | audio content omitted because its file is unavailable | caption caption caption caption caption caption caption caption caption caption captio…757 tokens trunca...ption caption caption caption caption caption caption [hash=a35e70d094c6690c] +07:message/assistant: + I received the clip. +08:message/user: + Check the recording again. +-- request 4 (turn) -- +09:message/assistant: + The recording is unavailable. +10:message/user: + +11:message/user: + Listen after restart. + +## Window 3 (after request 4: input diverged at item 06) +Settings: same as window 1 +-- request 5 (turn) -- +00:additional_tools/developer (2; hash=3299611bc98d0784): + - namespace/functions; hash=1976557bc4879146 + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - function/recording + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=b573ed69c0b0e6c7 +01:message/developer: + +02:message/developer[2]: + [01] + [02] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +03:message/user: + +04:message/user: + Listen to the recording. +05:custom_tool_call/exec:audio(await tools.recording({})); text("caption ".repeat(400)); +06:custom_tool_call_output:Script completed + Wall time seconds + Output: + | | caption caption caption caption caption caption caption caption caption caption captio…757 tokens trunca...ption caption caption caption caption caption caption [hash=a35e70d094c6690c] +07:message/assistant: + I received the clip. +08:message/user: + Check the recording again. +09:message/assistant: + The recording is unavailable. +10:message/user: + +11:message/user: + Listen after restart. +12:message/assistant: + The recording is still unavailable. +13:message/user: + Listen after the file is restored. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_function_call_output_trace.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_function_call_output_trace.snap new file mode 100644 index 0000000000..80f69b422e --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__audio_file__file_backed_function_call_output_trace.snap @@ -0,0 +1,103 @@ +--- +source: core/tests/suite/scenarios_audio_file_tests.rs +expression: "context_snapshot::format_request_history_snapshot(\"The tool returns file-backed audio. Follow-up and cold resume omit the expired clip, then use the original file again when it is available.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The tool returns file-backed audio. Follow-up and cold resume omit the expired clip, then use the original file again when it is available. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.5" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (8; hash=fb7f1f10f0db22ba): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[path]; hash=1908de9c0be7118a + - function/recording: Return an audio clip.; args=[]; hash=aeaf4746261bcbb1 + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=b573ed69c0b0e6c7 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer[2]: + [01] + [02] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +02:message/user: + +03:message/user: + Listen to the recording. +-- request 2 (turn) -- +04:function_call/recording:{} +05:function_call_output: + +## Window 2 (after request 2: input diverged at item 05) +Settings: same as window 1 +-- request 3 (turn) -- +00:message/developer: + +01:message/developer[2]: + [01] + [02] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +02:message/user: + +03:message/user: + Listen to the recording. +04:function_call/recording:{} +05:function_call_output:audio content omitted because its file is unavailable +06:message/assistant: + I received the clip. +07:message/user: + Check the recording again. +-- request 4 (turn) -- +08:message/assistant: + The recording is unavailable. +09:message/user: + +10:message/user: + Listen after restart. + +## Window 3 (after request 4: input diverged at item 05) +Settings: same as window 1 +-- request 5 (turn) -- +00:message/developer: + +01:message/developer[2]: + [01] + [02] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +02:message/user: + +03:message/user: + Listen to the recording. +04:function_call/recording:{} +05:function_call_output: +06:message/assistant: + I received the clip. +07:message/user: + Check the recording again. +08:message/assistant: + The recording is unavailable. +09:message/user: + +10:message/user: + Listen after restart. +11:message/assistant: + The recording is still unavailable. +12:message/user: + Listen after the file is restored. diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 669cd73697..bf4742c704 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -402,6 +402,8 @@ pub enum Feature { ImageGenerationAnimation, /// Omit inline image and audio content from app-server item notifications. OmitAppServerNotificationMedia, + /// Allow this host's attachment store to prepare fresh tool audio before persistence. + FileBackedToolAudio, /// Tell the model when a prompt image was resized and include its dimensions. ImageResizeNotice, /// Normalize prompt images with the reviewed image sanitizer. @@ -2036,6 +2038,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::FileBackedToolAudio, + key: "file_backed_tool_audio", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ImageResizeNotice, key: "image_resize_notice", diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index cbf3934e42..8a2ec57b37 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -126,6 +126,15 @@ pub struct CodexHarnessMetadata { )] pub history_truncation_token_limit: Option, + /// Fresh tool output has already passed a producer limit. Used only to budget + /// storage-error placeholders before the item is recorded. Never persisted. + #[serde(skip)] + pub producer_bounded_tool_audio: bool, + + /// Core applied the history limit while tool audio still had its inline cost. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tool_audio_budget_applied: bool, + /// Bounded assistant text confirmed by a successful messaging tool result. /// Captured after input hooks; untrusted context, never user authorization. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 97fa5985c0..05273a0cac 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -2532,6 +2532,14 @@ impl Default for FunctionCallOutputBody { } impl FunctionCallOutputPayload { + pub fn contains_audio(&self) -> bool { + self.content_items().is_some_and(|parts| { + parts + .iter() + .any(|part| matches!(part, FunctionCallOutputContentItem::InputAudio { .. })) + }) + } + pub fn from_text(content: String) -> Self { Self { body: FunctionCallOutputBody::Text(content), diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index 01481a1eaf..5f6f80d34d 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -29,6 +29,11 @@ pub trait ToolOutput: Send { false } + /// Whether the complete model-visible result was already limited by its producer. + fn output_budget_applied(&self) -> bool { + false + } + /// Overrides history's fallback token limit after tool-specific truncation. /// Include any serialization allowance; history uses this limit unchanged. fn fallback_token_limit_override(&self) -> Option { @@ -95,6 +100,10 @@ where (**self).contains_external_context() } + fn output_budget_applied(&self) -> bool { + (**self).output_budget_applied() + } + fn fallback_token_limit_override(&self) -> Option { (**self).fallback_token_limit_override() } diff --git a/codex-rs/utils/audio/src/audio_preparation_tests.rs b/codex-rs/utils/audio/src/audio_preparation_tests.rs index 07e609ea9f..fc2d6abe53 100644 --- a/codex-rs/utils/audio/src/audio_preparation_tests.rs +++ b/codex-rs/utils/audio/src/audio_preparation_tests.rs @@ -210,3 +210,13 @@ fn file_audio_uses_the_measured_length_or_the_full_file_budget() { assert_eq!(super::estimate_file_audio_tokens(duration), expected); } } + +#[test] +fn audio_data_url_encodes_all_padding_lengths_directly_into_the_url() { + for (bytes, expected) in [(b"a".as_slice(), "YQ=="), (b"ab", "YWI="), (b"abc", "YWJj")] { + assert_eq!( + super::audio_data_url("audio/wav", bytes), + format!("data:audio/wav;base64,{expected}") + ); + } +} diff --git a/codex-rs/utils/audio/src/lib.rs b/codex-rs/utils/audio/src/lib.rs index edd626500a..1a8333d0a2 100644 --- a/codex-rs/utils/audio/src/lib.rs +++ b/codex-rs/utils/audio/src/lib.rs @@ -41,7 +41,7 @@ static AUDIO_TOKEN_ESTIMATE_CACHE: LazyLock &'static str { + pub fn placeholder(&self) -> &'static str { match self { AudioPreparationError::InvalidDataUrl { .. } => AUDIO_PROCESSING_ERROR_PLACEHOLDER, AudioPreparationError::UnsupportedFormat => UNSUPPORTED_AUDIO_FORMAT_PLACEHOLDER, @@ -192,7 +192,14 @@ fn estimate_audio_token_count(audio_url: &str) -> usize { /// The same duration-based estimate without accessing the Tokio-dependent cache. /// Suitable for bounded output rendered on a current-thread runtime. fn estimate_audio_token_count_without_cache(audio_url: &str) -> usize { - let Some(duration_seconds) = audio_duration_seconds(audio_url) else { + let Ok((mime, bytes)) = decode_audio(audio_url) else { + return approx_token_count(audio_url); + }; + decoded_audio_token_count(mime, bytes, audio_url) +} + +fn decoded_audio_token_count(mime: &str, bytes: Vec, audio_url: &str) -> usize { + let Some(duration_seconds) = audio_duration_seconds(mime, bytes) else { return approx_token_count(audio_url); }; let token_count = (duration_seconds * f64::from(AUDIO_TOKENS_PER_SECOND)).ceil(); @@ -203,22 +210,7 @@ fn estimate_audio_token_count_without_cache(audio_url: &str) -> usize { } } -fn audio_duration_seconds(audio_url: &str) -> Option { - let (metadata, payload) = audio_url.split_once(',')?; - let metadata = metadata.get("data:".len()..)?; - let mut metadata_parts = metadata.split(';'); - let canonical_mime = canonical_audio_mime(metadata_parts.next()?)?; - if !metadata_parts.any(|part| part.eq_ignore_ascii_case("base64")) { - return None; - } - - let bytes = match BASE64_STANDARD.decode(payload) { - Ok(bytes) => bytes, - Err(error) => { - tracing::trace!(%error, "failed to decode audio payload for token estimation"); - return None; - } - }; +fn audio_duration_seconds(canonical_mime: &str, bytes: Vec) -> Option { let media_source = MediaSourceStream::new(Box::new(Cursor::new(bytes)), Default::default()); let mut hint = Hint::new(); hint.mime_type(canonical_mime); @@ -247,7 +239,44 @@ fn audio_duration_seconds(audio_url: &str) -> Option { duration_seconds.is_finite().then_some(duration_seconds) } +/// Validate the data URL and measure its decoded duration in the same pass. +pub fn prepare_and_measure_inline_audio( + audio_url: &mut String, +) -> Result { + let (mime, bytes) = decode_audio(audio_url)?; + normalize_validated_header(audio_url, mime); + Ok(decoded_audio_token_count(mime, bytes, audio_url)) +} + fn prepare_audio(audio_url: &mut String) -> Result<(), AudioPreparationError> { + let (mime, _) = decode_audio(audio_url)?; + normalize_validated_header(audio_url, mime); + Ok(()) +} + +// The standard base64 decoder has already validated the payload, including padding. +fn normalize_validated_header(url: &mut String, mime: &str) { + let canonical = format!("data:{mime};base64"); + if let Some((header, _)) = url.split_once(',') + && header != canonical + { + url.replace_range(..header.len(), &canonical); + } +} + +/// Canonical inline representation for a validated payload. +pub fn audio_data_url(mime: &str, data: &[u8]) -> String { + let mut url = + String::with_capacity("data:;base64,".len() + mime.len() + data.len().div_ceil(3) * 4); + url.push_str("data:"); + url.push_str(mime); + url.push_str(";base64,"); + BASE64_STANDARD.encode_string(data, &mut url); + url +} + +/// Parses a size-bounded audio data URL. A file-writing host also verifies its media container. +pub fn decode_audio(audio_url: &str) -> Result<(&'static str, Vec), AudioPreparationError> { if !is_data_url(audio_url) { return Err(AudioPreparationError::InvalidDataUrl { reason: "audio input must be a data URL", @@ -300,9 +329,7 @@ fn prepare_audio(audio_url: &mut String) -> Result<(), AudioPreparationError> { return Err(AudioPreparationError::AudioTooLarge { size: bytes.len() }); } - let encoded = BASE64_STANDARD.encode(bytes); - *audio_url = format!("data:{canonical_mime};base64,{encoded}"); - Ok(()) + Ok((canonical_mime, bytes)) } #[cfg(test)] diff --git a/codex-rs/utils/output-truncation/src/lib.rs b/codex-rs/utils/output-truncation/src/lib.rs index b0dcbfdd11..e676a9d3c7 100644 --- a/codex-rs/utils/output-truncation/src/lib.rs +++ b/codex-rs/utils/output-truncation/src/lib.rs @@ -311,13 +311,47 @@ fn truncate_output_items( } } +/// Apply the mixed-output policy to owned, premeasured parts. Audio keeps its original +/// token charge through truncation and storage. The charge for other parts is zero. +pub fn truncate_measured_tool_output( + items: Vec<(FunctionCallOutputContentItem, usize)>, + policy: TruncationPolicy, +) -> Vec<(FunctionCallOutputContentItem, usize)> { + truncate_measured_output_iter( + items + .into_iter() + .map(|(item, charge)| (Cow::Owned(item), charge)), + policy, + &mut [], + |item, charge| (item, charge), + ) +} + fn truncate_output_iter<'a>( items: impl ExactSizeIterator>, policy: TruncationPolicy, estimate_audio_token_count: impl Fn(&AudioReference) -> usize, boundaries: &mut [OutputBoundary], ) -> Vec { - let mut out: Vec = Vec::with_capacity(items.len()); + let measured = items.map(|item| { + let charge = match item.as_ref() { + FunctionCallOutputContentItem::InputAudio { audio } => { + estimate_audio_token_count(audio) + } + _ => 0, + }; + (item, charge) + }); + truncate_measured_output_iter(measured, policy, boundaries, |item, _| item) +} + +fn truncate_measured_output_iter<'a, T>( + items: impl ExactSizeIterator, usize)>, + policy: TruncationPolicy, + boundaries: &mut [OutputBoundary], + emit: impl Fn(FunctionCallOutputContentItem, usize) -> T, +) -> Vec { + let mut out = Vec::with_capacity(items.len()); let mut remaining_budget = match policy { TruncationPolicy::Bytes(_) => policy.byte_budget(), TruncationPolicy::Tokens(_) => policy.token_budget(), @@ -326,7 +360,7 @@ fn truncate_output_iter<'a>( let mut omitted_audio_items = 0usize; let mut next_boundary = 0; - for (index, item) in items.enumerate() { + for (index, (item, audio_tokens)) in items.enumerate() { let item_out = out.len(); let item_policy = match policy { TruncationPolicy::Bytes(_) => TruncationPolicy::Bytes(remaining_budget), @@ -354,7 +388,10 @@ fn truncate_output_iter<'a>( if snippet.is_empty() { omitted_text_items += 1; } else { - out.push(FunctionCallOutputContentItem::InputText { text: snippet }); + out.push(emit( + FunctionCallOutputContentItem::InputText { text: snippet }, + 0, + )); } remaining_budget = 0; false @@ -362,11 +399,10 @@ fn truncate_output_iter<'a>( } FunctionCallOutputContentItem::InputImage { .. } | FunctionCallOutputContentItem::EncryptedContent { .. } => true, - FunctionCallOutputContentItem::InputAudio { audio } => { - let token_cost = estimate_audio_token_count(audio); + FunctionCallOutputContentItem::InputAudio { .. } => { let cost = match policy { - TruncationPolicy::Bytes(_) => approx_bytes_for_tokens(token_cost), - TruncationPolicy::Tokens(_) => token_cost, + TruncationPolicy::Bytes(_) => approx_bytes_for_tokens(audio_tokens), + TruncationPolicy::Tokens(_) => audio_tokens, }; if cost <= remaining_budget { remaining_budget = remaining_budget.saturating_sub(cost); @@ -390,7 +426,7 @@ fn truncate_output_iter<'a>( next_boundary += 1; } if keep { - out.push(item.into_owned()); + out.push(emit(item.into_owned(), audio_tokens)); } } // End boundaries precede generated omission counts: counts are not original @@ -401,14 +437,20 @@ fn truncate_output_iter<'a>( } if omitted_text_items > 0 { - out.push(FunctionCallOutputContentItem::InputText { - text: format!("[omitted {omitted_text_items} text items ...]"), - }); + out.push(emit( + FunctionCallOutputContentItem::InputText { + text: format!("[omitted {omitted_text_items} text items ...]"), + }, + 0, + )); } if omitted_audio_items > 0 { - out.push(FunctionCallOutputContentItem::InputText { - text: format!("[omitted {omitted_audio_items} audio items ...]"), - }); + out.push(emit( + FunctionCallOutputContentItem::InputText { + text: format!("[omitted {omitted_audio_items} audio items ...]"), + }, + 0, + )); } out } diff --git a/codex-rs/utils/output-truncation/src/truncate_tests.rs b/codex-rs/utils/output-truncation/src/truncate_tests.rs index ddb230eb5c..9f8cf0432c 100644 --- a/codex-rs/utils/output-truncation/src/truncate_tests.rs +++ b/codex-rs/utils/output-truncation/src/truncate_tests.rs @@ -8,6 +8,7 @@ use crate::formatted_truncate_text; use crate::mixed_output_at_token_boundaries; use crate::truncate_function_output_items_with_policy; use crate::truncate_mcp_tool_result; +use crate::truncate_measured_tool_output; use crate::truncate_text; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::AudioReference; @@ -577,3 +578,26 @@ fn owned_mixed_output_moves_a_preserved_image_across_text_truncation() { }; assert_eq!(image_url.as_ptr(), url_ptr); } + +#[test] +fn measured_tool_output_keeps_charges_paired_with_retained_clips() { + let clip = FunctionCallOutputContentItem::inline_audio("clip".into()); + let text = FunctionCallOutputContentItem::InputText { + text: "keep".into(), + }; + let omitted = FunctionCallOutputContentItem::InputText { + text: "[omitted 1 audio items ...]".into(), + }; + assert_eq!( + truncate_measured_tool_output( + vec![ + (clip.clone(), 9), + (text.clone(), 0), + (clip.clone(), 2), + (clip.clone(), 1) + ], + TruncationPolicy::Tokens(4) + ), + vec![(text, 0), (clip.clone(), 2), (clip, 1), (omitted, 0)] + ); +}