From 26cc28631406375d55d2915c5730d42a016847b3 Mon Sep 17 00:00:00 2001 From: ioanbudea-OAI Date: Fri, 30 Oct 2026 23:25:19 +0000 Subject: [PATCH] Add elastic budgeting for Guardian context preparation (#58454) ## What changed Introduce `ContextBudgeting::Elastic` to prepare captured root conversation and Node REPL evidence without the fixed per-section request caps. Elastic preparation preserves message text and candidates for later allocation while retaining storage omission notices and legacy source deduplication. Keep `ContextBudgeting::Fixed` as the default and use it at existing call sites. ## Testing Add tests covering elastic root message preservation and legacy source deduplication, plus REPL text, images, and omission counts in text-only and multimodal modes. GitOrigin-RevId: ce4fae9d725694456525a413fb511166df6f5e2c --- .../src/agent/control/user_authorization.rs | 8 +- codex-rs/core/src/guardian/input_budget.rs | 10 +- .../core/src/guardian/input_budget_tests.rs | 8 +- codex-rs/core/src/guardian/review_session.rs | 6 +- .../src/node_repl_review_evidence.rs | 1 + codex-rs/guardian-context/src/lib.rs | 1 + .../guardian-context/src/preparation/mod.rs | 17 ++- .../guardian-context/src/preparation/repl.rs | 15 ++- .../guardian-context/src/preparation/root.rs | 89 ++++++++++---- .../src/preparation/source_tests.rs | 111 ++++++++++++++++++ .../guardian-context/src/registry_tests.rs | 1 + 11 files changed, 227 insertions(+), 40 deletions(-) create mode 100644 codex-rs/guardian-context/src/preparation/source_tests.rs diff --git a/codex-rs/core/src/agent/control/user_authorization.rs b/codex-rs/core/src/agent/control/user_authorization.rs index b1bbb9094e..13d2c3d244 100644 --- a/codex-rs/core/src/agent/control/user_authorization.rs +++ b/codex-rs/core/src/agent/control/user_authorization.rs @@ -47,7 +47,10 @@ struct CapturedRootConversation { } impl RootConversationSource for CapturedRootConversation { - fn prepare(&self) -> codex_guardian_context::PreparedRootContext { + fn prepare( + &self, + budgeting: codex_guardian_context::ContextBudgeting, + ) -> codex_guardian_context::PreparedRootContext { let root_history = &self.history; // Join calls to host-confirmed outputs in this snapshot. Older outputs without // captured text cannot establish what was sent, including after a hook rewrite. @@ -245,6 +248,7 @@ impl RootConversationSource for CapturedRootConversation { codex_guardian_context::prepare_root_context( &request_candidates, root_history.original_legacy_user_messages(), + budgeting, ) } } @@ -298,7 +302,7 @@ impl LocalAgentControl { history: root_history, handoff_boundaries, }); - let prepared = request_candidates.prepare(); + let prepared = request_candidates.prepare(codex_guardian_context::ContextBudgeting::Fixed); let root_evidence = root_thread .session .services diff --git a/codex-rs/core/src/guardian/input_budget.rs b/codex-rs/core/src/guardian/input_budget.rs index cc3b5ecd50..9ba8ef3f2f 100644 --- a/codex-rs/core/src/guardian/input_budget.rs +++ b/codex-rs/core/src/guardian/input_budget.rs @@ -31,7 +31,9 @@ use crate::session::turn::build_prompt; use crate::session::turn_context::TurnContext; #[derive(Clone)] -pub(crate) struct PendingReviewContext(pub ComposedContext); +pub(crate) struct PendingReviewContext { + pub context: ComposedContext, +} pub(super) use codex_guardian_reviewer::CheckpointRecovery; @@ -76,7 +78,7 @@ pub(crate) async fn check_pending(session: &Session, turn: &TurnContext) -> Code codex_protocol::protocol::TruncationPolicy::Bytes(base.text.len()).token_budget(); let maximum = effective_input_token_limit(turn.model_info(), turn.config.model_context_window) .saturating_sub(super::request_budget::INPUT_TOKEN_MARGIN); - let mut context = pending.0.clone(); + let mut context = pending.context.clone(); let history = session .clone_history() .await @@ -129,7 +131,7 @@ pub(crate) async fn finalize( "Guardian expects one review input".to_owned(), )); }; - let mut context = pending.0.clone(); + let mut context = pending.context.clone(); let model = &step.settings.model_info; let history = session.clone_history().await; let recovery = session @@ -361,7 +363,7 @@ pub(crate) async fn finalize( } } step.turn.extension_data.insert(RetainedReviewContext { - context: pending.0.retained_instructions(), + context: pending.context.retained_instructions(), history_version, }); session diff --git a/codex-rs/core/src/guardian/input_budget_tests.rs b/codex-rs/core/src/guardian/input_budget_tests.rs index e8bd9d362f..bb4b9dc0bf 100644 --- a/codex-rs/core/src/guardian/input_budget_tests.rs +++ b/codex-rs/core/src/guardian/input_budget_tests.rs @@ -61,7 +61,7 @@ async fn finalization_exports_loss_totals_only_for_accepted_input(window: i64) { session .services .thread_extension_data - .insert(PendingReviewContext(context)); + .insert(PendingReviewContext { context }); assert_eq!( finalize(&session, &step, &mut input, HistoryTruncation::Preserve) .await @@ -177,7 +177,7 @@ async fn cancelled_startup_does_not_record_unselected_review_evidence() { session .services .thread_extension_data - .insert(PendingReviewContext(context)); + .insert(PendingReviewContext { context }); session .set_session_startup_prewarm( crate::session::startup_prewarm::SessionStartupPrewarmHandle::new( @@ -234,7 +234,7 @@ async fn feasibility_rejects_required_evidence_inside_the_reserved_margin() { session .services .thread_extension_data - .insert(PendingReviewContext(context)); + .insert(PendingReviewContext { context }); assert!(check_pending(&session, &turn).await.is_err()); } @@ -257,7 +257,7 @@ async fn finalization_overflow_marks_the_reviewer_exhausted() { session .services .thread_extension_data - .insert(PendingReviewContext(context)); + .insert(PendingReviewContext { context }); assert!( finalize(&session, &step, &mut input, HistoryTruncation::Preserve) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 65005cc725..250a5dbcce 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -555,9 +555,9 @@ async fn run_review_on_session( .session .services .thread_extension_data - .insert(super::input_budget::PendingReviewContext( - prompt_items.context, - )); + .insert(super::input_budget::PendingReviewContext { + context: prompt_items.context, + }); review_session .session .services diff --git a/codex-rs/ext/guardian-reviewer/src/node_repl_review_evidence.rs b/codex-rs/ext/guardian-reviewer/src/node_repl_review_evidence.rs index cf4efa494f..fd01ac4b95 100644 --- a/codex-rs/ext/guardian-reviewer/src/node_repl_review_evidence.rs +++ b/codex-rs/ext/guardian-reviewer/src/node_repl_review_evidence.rs @@ -264,6 +264,7 @@ pub struct NodeReplReviewEvidenceSnapshot { impl NodeReplReviewEvidenceSnapshot { pub fn context(&self, mode: NodeReplReviewEvidenceMode) -> NodeReplContext<'_> { NodeReplContext { + budgeting: codex_guardian_context::ContextBudgeting::Fixed, responses: &self.responses, omitted_responses: self.omitted_responses, mode, diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index da45587d22..ddf28d768d 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -104,6 +104,7 @@ pub use composition::ComposedContext; pub use composition::ContextPresentation; pub use composition::MAX_TEXT_PART_ESTIMATED_TOKENS; pub use composition::PreparedTranscript; +pub use preparation::ContextBudgeting; pub use preparation::ContextProfile; pub use preparation::SyncImagePreparer; pub use preparation::SyncPreparation; diff --git a/codex-rs/guardian-context/src/preparation/mod.rs b/codex-rs/guardian-context/src/preparation/mod.rs index 4bb1188168..daa530b116 100644 --- a/codex-rs/guardian-context/src/preparation/mod.rs +++ b/codex-rs/guardian-context/src/preparation/mod.rs @@ -32,6 +32,20 @@ pub(crate) const MAX_INSTRUCTION_TOKENS: usize = 900; const MAX_APPROVAL_REASON_TOKENS: usize = 512; const MAX_DESCRIPTION_TOKENS: usize = 400; +/// Request selection policy; persistent capture and storage bounds are independent. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ContextBudgeting { + /// Preserve existing per-section request selection. + #[default] + Fixed, + /// Keep captured candidates complete until optional entry capping and allocation. + Elastic, +} + +#[cfg(test)] +#[path = "source_tests.rs"] +mod source_tests; + /// Prepares the host-selected transcript suffix and all synchronous context sections. /// Cursor selection remains with the host; its offset addresses the collected entries. /// No storage, reviewer history or cursor state is changed by this operation. @@ -159,7 +173,7 @@ pub(crate) fn prepare_sync_sections(context: &mut CollectedContext) { if let crate::ContextSection::RootConversation { content } = section && let crate::RootConversationContent::Collected(candidates) = content { - let prepared = candidates.prepare(); + let prepared = candidates.prepare(ContextBudgeting::Fixed); *content = crate::RootConversationContent::Prepared( crate::authorization::root_conversation_items(prepared.messages), ); @@ -178,6 +192,7 @@ pub(crate) fn prepare_sync_sections(context: &mut CollectedContext) { responses, omitted_responses: *omitted_responses, mode: *mode, + budgeting: ContextBudgeting::Fixed, }; let items = snapshot.render_inputs(); evidence.content = crate::node_repl::NodeReplEvidenceContent::Prepared(items); diff --git a/codex-rs/guardian-context/src/preparation/repl.rs b/codex-rs/guardian-context/src/preparation/repl.rs index 049a80d8b1..5c456e81d5 100644 --- a/codex-rs/guardian-context/src/preparation/repl.rs +++ b/codex-rs/guardian-context/src/preparation/repl.rs @@ -51,6 +51,8 @@ pub struct NodeReplContext<'a> { pub responses: &'a [Arc], pub omitted_responses: u64, pub mode: NodeReplReviewEvidenceMode, + /// Controls request selection only; host storage and delivery cursors stay unchanged. + pub budgeting: crate::ContextBudgeting, } /// Request-local snapshot, selected and rendered only during sync preparation. @@ -153,6 +155,7 @@ impl NodeReplContext<'_> { intro.len().saturating_add(closing.len()), NodeReplLayout::Multimodal, self.omitted_responses, + self.budgeting, ); let omitted_responses = selected.omitted_responses; @@ -245,6 +248,7 @@ impl ContextualUserFragment for NodeReplContext<'_> { .saturating_add(body.len()), NodeReplLayout::Text, self.omitted_responses, + self.budgeting, ); let omitted_responses = selected.omitted_responses; @@ -274,7 +278,9 @@ impl ContextualUserFragment for NodeReplContext<'_> { body.push('\n'); } } - debug_assert!(body.len() <= max_body_bytes); + debug_assert!( + self.budgeting == crate::ContextBudgeting::Elastic || body.len() <= max_body_bytes + ); input_text(body) } } @@ -298,7 +304,14 @@ fn select_node_repl_responses( framing_bytes: usize, layout: NodeReplLayout, mut omitted_responses: u64, + budgeting: crate::ContextBudgeting, ) -> NodeReplSelection { + if budgeting == crate::ContextBudgeting::Elastic { + return NodeReplSelection { + first: 0, + omitted_responses, + }; + } let notice_bytes = match layout { NodeReplLayout::Text => 64, NodeReplLayout::Multimodal => 160, diff --git a/codex-rs/guardian-context/src/preparation/root.rs b/codex-rs/guardian-context/src/preparation/root.rs index 43a926fd13..b55cdaa81d 100644 --- a/codex-rs/guardian-context/src/preparation/root.rs +++ b/codex-rs/guardian-context/src/preparation/root.rs @@ -1,7 +1,9 @@ -//! Fixed root request selection, including legacy source matching and omission notices. +//! Root request preparation, including legacy source matching and omission notices. +//! Fixed mode keeps its existing caps; elastic mode leaves size selection to admission. //! Collection and persistent storage keep their existing bounds and source ownership. //! Assistant candidates borrow one immutable host snapshot until synchronous preparation ends. +use crate::ContextBudgeting; use crate::GuardianRootMessage; use codex_history::RetainedContextOrder; use codex_history::RetainedUserMessage; @@ -69,7 +71,8 @@ impl std::fmt::Debug for RootConversationCandidates<'_> { /// An immutable host snapshot that lends sources only during synchronous preparation. /// Implementations must preserve capture-time provenance and avoid reading live session state. pub trait RootConversationSource: Send + Sync { - fn prepare(&self) -> PreparedRootContext; + /// Prepares one request view without changing captured sources or storage policy. + fn prepare(&self, budgeting: ContextBudgeting) -> PreparedRootContext; } /// Shared ownership keeps captured source payloads alive without copying them. @@ -82,8 +85,8 @@ impl RootConversationInput { Self(std::sync::Arc::new(source)) } - pub fn prepare(&self) -> PreparedRootContext { - self.0.prepare() + pub fn prepare(&self, budgeting: ContextBudgeting) -> PreparedRootContext { + self.0.prepare(budgeting) } } @@ -174,11 +177,15 @@ fn prepare_root_assistant_messages( retained_assistants: &[crate::RootRetainedAssistant<'_>], delivered_messages: &[crate::RootDeliveredMessage<'_>], mut missing_assistant_context: bool, + budgeting: ContextBudgeting, ) -> PreparedRootAssistants { let delivered = delivered_messages .iter() - .take(MAX_ROOT_MESSAGES) - .map(|message| (message.call_id, message.text)) + .enumerate() + .take_while(|(index, _)| { + budgeting == ContextBudgeting::Elastic || *index < MAX_ROOT_MESSAGES + }) + .map(|(_, message)| (message.call_id, message.text)) .collect::>(); let live = assistant_messages .iter() @@ -205,8 +212,11 @@ fn prepare_root_assistant_messages( }); let order = retained.map(|retained| retained.order).or(source.order); let text = retained - .and_then(|retained| root_retained_assistant(retained.message)) - .unwrap_or_else(|| crate::truncate_text(&text, MAX_ROOT_MESSAGE_TOKENS)); + .and_then(|retained| root_retained_assistant(retained.message, budgeting)) + .unwrap_or_else(|| match budgeting { + ContextBudgeting::Fixed => crate::truncate_text(&text, MAX_ROOT_MESSAGE_TOKENS), + ContextBudgeting::Elastic => text, + }); let message = if order.is_some() { GuardianRootMessage::Assistant(text) } else { @@ -229,7 +239,7 @@ fn prepare_root_assistant_messages( { return None; } - let text = root_retained_assistant(source.message); + let text = root_retained_assistant(source.message, budgeting); missing_assistant_context |= text.is_none(); text.map(|text| RootMessageCandidate { order: Some(source.order), @@ -244,8 +254,16 @@ fn prepare_root_assistant_messages( } } -fn root_retained_assistant(message: &codex_history::RetainedUserMessage) -> Option { - crate::retained_assistant_message(message).map(|_| message.text.clone()) +fn root_retained_assistant( + message: &codex_history::RetainedUserMessage, + budgeting: ContextBudgeting, +) -> Option { + match budgeting { + ContextBudgeting::Fixed => { + crate::retained_assistant_message(message).map(|_| message.text.clone()) + } + ContextBudgeting::Elastic => message.complete.then(|| message.text.clone()), + } } /// Bounded root view plus the existing cache and skill-scope selection metadata. @@ -256,10 +274,11 @@ pub struct PreparedRootContext { pub latest_user_turn_id: Option, } -/// Applies the fixed root request policy without discarding the captured sources. +/// Prepares the selected request policy without changing the captured sources. pub fn prepare_root_context( input: &RootConversationCandidates<'_>, legacy_candidates: impl IntoIterator, + budgeting: ContextBudgeting, ) -> PreparedRootContext { let mut missing_root_instructions = input.missing_root_instructions; let mut missing_verified_answers = input.missing_verified_answers; @@ -269,11 +288,14 @@ pub fn prepare_root_context( .iter() .filter_map(|candidate| { if let GuardianRootMessage::UserInput(text) = &candidate.message + && budgeting == ContextBudgeting::Fixed && !crate::verified_answers::answer_fits(text.len()) { return None; } - let message = if let GuardianRootMessage::User(text) = &candidate.message { + let message = if let GuardianRootMessage::User(text) = &candidate.message + && budgeting == ContextBudgeting::Fixed + { GuardianRootMessage::User(crate::truncate_text(text, MAX_ROOT_MESSAGE_TOKENS)) } else { candidate.message.clone() @@ -288,23 +310,29 @@ pub fn prepare_root_context( let legacy_capacity = MAX_ROOT_MESSAGES.saturating_sub(messages.len()); let mut legacy_messages = VecDeque::new(); let mut latest_legacy_turn_id = None; - if missing_root_instructions && legacy_capacity > 0 { + if missing_root_instructions && (budgeting == ContextBudgeting::Elastic || legacy_capacity > 0) + { for candidate in legacy_candidates { - let text = crate::truncate_text(&candidate.text, MAX_ROOT_MESSAGE_TOKENS); - // Legacy records without IDs historically match after the request-size cap. + // ID-less checkpoints identify sources by their stored excerpt, independently + // of whether request preparation will render the original text. + let retained_text = crate::truncate_text(&candidate.text, MAX_ROOT_MESSAGE_TOKENS); if input.retained_user_sources.iter().any(|source| { if let Some(id) = &source.message_id { candidate.message_id.as_ref() == Some(id) } else { - candidate.turn_id == source.turn_id && text == source.text + candidate.turn_id == source.turn_id && retained_text == source.text } }) { continue; } + let text = match budgeting { + ContextBudgeting::Fixed => retained_text, + ContextBudgeting::Elastic => candidate.text, + }; latest_legacy_turn_id = (!candidate.turn_id.is_empty()).then(|| candidate.turn_id.clone()); legacy_messages.push_back(GuardianRootMessage::User(text)); - if legacy_messages.len() > legacy_capacity { + if budgeting == ContextBudgeting::Fixed && legacy_messages.len() > legacy_capacity { legacy_messages.pop_front(); } } @@ -314,11 +342,17 @@ pub fn prepare_root_context( &input.retained_assistants, &input.delivered_messages, input.missing_assistant_context, + budgeting, ); let mut missing_assistant_context = assistants.missing_context; messages.extend(assistants.messages); messages.sort_by_key(|message| message.order); - let handoff_selection = select_root_handoff_indices(&messages, &input.handoff_boundaries); + let handoff_selection = match budgeting { + ContextBudgeting::Fixed => { + select_root_handoff_indices(&messages, &input.handoff_boundaries) + } + ContextBudgeting::Elastic => None, + }; if let Some(selected) = &handoff_selection { messages = messages .into_iter() @@ -337,7 +371,10 @@ pub fn prepare_root_context( }) .collect(); } - let removed = messages.len().saturating_sub(MAX_ROOT_MESSAGES); + let removed = match budgeting { + ContextBudgeting::Fixed => messages.len().saturating_sub(MAX_ROOT_MESSAGES), + ContextBudgeting::Elastic => 0, + }; for message in &messages[..removed] { missing_root_instructions |= matches!( message.message, @@ -350,11 +387,13 @@ pub fn prepare_root_context( } messages.drain(..removed); messages.sort_by_key(|message| (message.order.is_none(), message.order)); - legacy_messages.drain( - ..legacy_messages - .len() - .saturating_sub(MAX_ROOT_MESSAGES - messages.len()), - ); + if budgeting == ContextBudgeting::Fixed { + legacy_messages.drain( + ..legacy_messages + .len() + .saturating_sub(MAX_ROOT_MESSAGES - messages.len()), + ); + } let mut messages = messages .into_iter() .map(|message| message.message) diff --git a/codex-rs/guardian-context/src/preparation/source_tests.rs b/codex-rs/guardian-context/src/preparation/source_tests.rs new file mode 100644 index 0000000000..5a16009a88 --- /dev/null +++ b/codex-rs/guardian-context/src/preparation/source_tests.rs @@ -0,0 +1,111 @@ +#![cfg(test)] + +//! Checks that elastic preparation keeps captured root and REPL evidence. + +use super::*; +use crate::GuardianRootMessage; +use crate::NodeReplContext; +use crate::NodeReplResponse; +use crate::NodeReplReviewEvidenceMode; +use crate::RootConversationCandidates; +use crate::RootMessageCandidate; +use codex_history::RetainedContextOrder; +use codex_protocol::user_input::UserInput; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +#[test] +fn elastic_root_preparation_preserves_captured_messages() { + const ROOT_RECORDS: usize = 20; + const TEXT_REPEATS: usize = 1_000; + const LEGACY_SOURCE_TOKENS: usize = 900; + let text = "root evidence ".repeat(TEXT_REPEATS); + let legacy = codex_history::RetainedUserMessage { + turn_id: "legacy-turn".to_owned(), + message_id: None, + text: text.clone(), + complete: false, + origin: codex_history::UserInputOrigin::User, + phase: None, + }; + let retained = codex_history::RetainedUserMessage { + text: crate::truncate_text(&text, LEGACY_SOURCE_TOKENS), + ..legacy.clone() + }; + let mut input = RootConversationCandidates { + user_messages: (0..ROOT_RECORDS) + .map(|index| RootMessageCandidate { + order: Some(RetainedContextOrder::Local(u64::try_from(index).unwrap())), + message: GuardianRootMessage::User(text.clone()), + }) + .collect(), + assistant_messages: Vec::new(), + retained_assistants: Vec::new(), + delivered_messages: Vec::new(), + retained_user_sources: vec![retained.clone()], + handoff_boundaries: Vec::new(), + latest_user_turn_id: None, + missing_root_instructions: true, + missing_assistant_context: false, + missing_verified_answers: false, + }; + input.user_messages[0].message = GuardianRootMessage::User(retained.text.clone()); + let fixed = root::prepare_root_context(&input, vec![legacy.clone()], ContextBudgeting::Fixed); + let elastic = root::prepare_root_context(&input, vec![legacy], ContextBudgeting::Elastic); + let mut expected = vec![GuardianRootMessage::User(text); ROOT_RECORDS]; + expected[0] = GuardianRootMessage::User(retained.text); + expected.insert( + /*index*/ 0, + GuardianRootMessage::IncompleteRootInstructions, + ); + expected.insert(/*index*/ 0, GuardianRootMessage::RetainedContextScope); + assert_eq!(elastic.messages, expected); + assert!(fixed.messages.len() < elastic.messages.len()); +} + +#[test] +fn elastic_repl_rendering_keeps_stored_text_images_and_omissions() { + const TEXT_BYTES: usize = 32_001; + const STORAGE_OMISSIONS: u64 = 7; + let text = "x".repeat(TEXT_BYTES); + let image = UserInput::Image { + image: ImageReference::Inline { + image_url: "data:image/png;base64,original".to_owned(), + }, + detail: None, + }; + let responses = [Arc::new(NodeReplResponse { + sequence: 1, + provenance: "source".to_owned(), + items: vec![ + UserInput::Text { + text: text.clone(), + text_elements: Vec::new(), + }, + image.clone(), + ], + })]; + for mode in [ + NodeReplReviewEvidenceMode::TextOnly, + NodeReplReviewEvidenceMode::Multimodal, + ] { + let [fixed, elastic] = + [ContextBudgeting::Fixed, ContextBudgeting::Elastic].map(|budgeting| { + NodeReplContext { + responses: &responses, + omitted_responses: STORAGE_OMISSIONS, + mode, + budgeting, + } + .render_inputs() + }); + let rendered = serde_json::to_string(&elastic).unwrap(); + assert!(!serde_json::to_string(&fixed).unwrap().contains(&text)); + assert!(rendered.contains(&text)); + assert!(rendered.contains(&format!("node_repl_responses=\\\"{STORAGE_OMISSIONS}\\\""))); + assert_eq!( + elastic.contains(&image), + mode == NodeReplReviewEvidenceMode::Multimodal + ); + } +} diff --git a/codex-rs/guardian-context/src/registry_tests.rs b/codex-rs/guardian-context/src/registry_tests.rs index 37cd404bcf..029dcb7a90 100644 --- a/codex-rs/guardian-context/src/registry_tests.rs +++ b/codex-rs/guardian-context/src/registry_tests.rs @@ -235,6 +235,7 @@ fn reused_registry_preserves_section_identity_and_source_roles() { items: repl_items.to_vec(), })]; let repl = super::NodeReplContext { + budgeting: crate::ContextBudgeting::Fixed, responses: &repl_responses, omitted_responses: 0, mode: super::NodeReplReviewEvidenceMode::TextOnly,