diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 49a2cce1f9..939117778d 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -267,8 +267,8 @@ pub(crate) fn render_guardian_transcript_entries( /// Keep both tool calls and tool results here. The reviewer often needs the /// agent's exact queried path / arguments as well as the returned evidence to /// decide whether the pending approval is justified. -/// Per-entry truncation happens during collection, using the current review's -/// Node REPL cap; the cursor still counts every non-empty evidence entry. +/// Tool-call truncation happens in sync preparation; other caps still apply +/// during collection. The cursor counts every non-empty evidence entry. pub(super) fn collect_guardian_context( history: &dyn SectionHistory, node_repl_result_token_limit: usize, diff --git a/codex-rs/core/tests/suite/guardian_context_budget.rs b/codex-rs/core/tests/suite/guardian_context_budget.rs index 5cbcbc19d5..d424350b19 100644 --- a/codex-rs/core/tests/suite/guardian_context_budget.rs +++ b/codex-rs/core/tests/suite/guardian_context_budget.rs @@ -808,6 +808,9 @@ async fn oversized_action_preserves_review_policy_and_next_review( .message_input_texts("user") .concat(); assert!(context.contains("echo complete-action-review")); + let historical_arguments = + codex_guardian_context::truncate_text(&oversized, /*max_tokens*/ 1_000); + assert!(context.contains(&format!("tool exec_command call: {historical_arguments}\n"))); assert!( requests .last() diff --git a/codex-rs/guardian-context/src/enforcement_tests.rs b/codex-rs/guardian-context/src/enforcement_tests.rs index 9ee73be13a..c7f7d237fa 100644 --- a/codex-rs/guardian-context/src/enforcement_tests.rs +++ b/codex-rs/guardian-context/src/enforcement_tests.rs @@ -294,17 +294,15 @@ fn planned_action_budget_omits_descriptions_without_changing_arguments() { ) }); let required = bounded.render(action_presentation); - let context = (crate::CollectedContext { - sections: vec![crate::ContextSection::PlannedAction(action.clone())], - }) - .compose( - presentation, - crate::PreparedTranscript { - items: Vec::new(), - omission_note: None, - truncations: Vec::new(), - }, - ) + let context = { + let context = crate::CollectedContext { + sections: vec![crate::ContextSection::PlannedAction(action.clone())], + }; + let profile = &crate::ContextProfile::synchronous(); + let transcript = profile + .prepare_transcript(context.transcript_entries(), /*entry_number_offset*/ 0); + context.compose(presentation, transcript) + } .unwrap(); let mut required_items = required.iter().map(|item| text(item)).collect::>(); required_items.push(text("evidence omitted")); diff --git a/codex-rs/guardian-context/src/profile.rs b/codex-rs/guardian-context/src/profile.rs index 3963363bc6..6fa399c6fd 100644 --- a/codex-rs/guardian-context/src/profile.rs +++ b/codex-rs/guardian-context/src/profile.rs @@ -1,4 +1,4 @@ -//! Resolved Guardian evidence profiles and pure transcript retention. +//! Resolved Guardian evidence profiles, transcript admission and retention. //! Sync keeps recent entries; async protects approvals/final answers and evicts //! in cacheable chunks. Hosts still own history snapshots and delivery cursors. //! User messages and manual approvals stay complete until whole-request admission. @@ -148,17 +148,38 @@ impl ContextProfile { .as_ref() .filter(|_| entry.kind == ConversationTranscriptEntryKind::User); let prepare_text = |text: &str| { + let text = match self.target { + ContextTarget::Sync => match &entry.kind { + ConversationTranscriptEntryKind::ToolCall(_) => { + std::borrow::Cow::Owned(crate::truncate_text( + text, + self.transcript.entry_limits.tool_tokens, + )) + } + ConversationTranscriptEntryKind::User + | ConversationTranscriptEntryKind::Developer + | ConversationTranscriptEntryKind::Assistant + | ConversationTranscriptEntryKind::ProtectedAssistant + | ConversationTranscriptEntryKind::Reasoning + | ConversationTranscriptEntryKind::ToolOutput(_) + | ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { + std::borrow::Cow::Borrowed(text) + } + }, + ContextTarget::Async => std::borrow::Cow::Borrowed(text), + }; + let retained_bytes = text.len(); let record = TranscriptRecord::new( &entry.kind, number, - text.to_owned(), + text.into_owned(), retained_source.map(|retained| retained.order.clone()), self.transcript_format, suffix, ); let tokens = TruncationPolicy::Bytes(record.rendered().text_bytes).token_budget(); - (TranscriptContent::Record(record), tokens, text.len()) + (TranscriptContent::Record(record), tokens, retained_bytes) }; let (content, tokens, retained_bytes) = match &entry.content { TranscriptContent::Text(text) => prepare_text(text), diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index b3f7e375d3..78bcc96993 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -2,7 +2,7 @@ //! //! Both Guardian consumers receive the same role and tool-source attribution, //! with generated compaction summaries attributed to the assistant, complete user -//! messages and capped non-user entries. Resolved context profiles +//! messages and capped non-user entries, except sync tool calls prepared later. Resolved context profiles //! apply aggregate retention after the host selects its full/delta slice. Tool outputs with a //! call ID retain their generic label when the call is unavailable. Outputs //! without a call ID require an explicit name. @@ -118,7 +118,7 @@ impl SectionContributor for ConversationTranscriptSection { fn contribute(&self, input: &SectionInput<'_>) -> Result, SectionError> { Ok(Some(ContextSection::ConversationTranscript { - items: collect_transcript(input.history, input.transcript), + items: collect_transcript_for_target(input.history, input.transcript, input.target), })) } } @@ -130,6 +130,14 @@ impl SectionContributor for ConversationTranscriptSection { pub fn collect_transcript( history: &dyn SectionHistory, config: &ConversationTranscriptConfig, +) -> Vec { + collect_transcript_for_target(history, config, crate::ContextTarget::Async) +} + +fn collect_transcript_for_target( + history: &dyn SectionHistory, + config: &ConversationTranscriptConfig, + target: crate::ContextTarget, ) -> Vec { let mut entries = Vec::new(); let mut tool_names_by_call_id = HashMap::new(); @@ -402,6 +410,11 @@ pub fn collect_transcript( | ConversationTranscriptEntryKind::Reasoning => { truncate_text(&text, config.entry_limits.message_tokens) } + ConversationTranscriptEntryKind::ToolCall(_) + if target == crate::ContextTarget::Sync => + { + text + } ConversationTranscriptEntryKind::ToolCall(_) | ConversationTranscriptEntryKind::ToolOutput(_) => { truncate_text(&text, config.entry_limits.tool_tokens)