Files
codex/codex-rs/guardian-context/src/transcript.rs
T
felixxia-oai 0337192dfd Centralize Guardian transcript policy in context profiles (#43806)
## What changed

Add `ContextProfile` to `codex-guardian-context` and route synchronous and asynchronous Guardian transcript rendering through it. Move default limits, retention, formatting, and async chunked eviction into the shared crate while preserving the distinct retention policies and host-managed full/delta cursors.

Carry transcript truncation observations through `RenderedTranscript` into context composition, and derive async image collection flags from the resolved profile.

## Testing

Add a profile regression test covering distinct sync/async retention priorities, original entry numbering, omission notes, and async truncation observations. Adapt existing transcript tests to the updated input API.

GitOrigin-RevId: 7f7dc249629d8017e9547085f6617f815ae43a1b
2026-09-08 12:17:28 +00:00

330 lines
13 KiB
Rust

//! Collects bounded conversation evidence before consumer-specific rendering.
//!
//! Both Guardian consumers receive the same role and tool-source attribution,
//! with per-entry caps applied before accumulation. 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.
use std::collections::HashMap;
use codex_protocol::mcp::is_node_repl_backed_tool;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::plaintext_agent_message_content;
use codex_protocol::protocol::InterAgentCommunication;
use crate::ContextSection;
use crate::ConversationTranscriptEntry;
use crate::ConversationTranscriptEntryKind;
use crate::SectionContributor;
use crate::SectionError;
use crate::SectionHistory;
use crate::SectionInput;
use crate::SectionScope;
use crate::truncate_text;
/// Trusted developer marker that preserves an explicit manual action approval.
pub const MANUAL_APPROVAL_DEVELOPER_PREFIX: &str =
"The user has manually approved a specific action that was previously `Rejected`.";
/// Evidence sources included alongside user and assistant conversation messages.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConversationTranscriptOptions {
/// Includes submitted function, custom-tool, shell, and web-search calls.
pub include_tool_calls: bool,
/// Includes function and custom-tool outputs.
pub include_tool_outputs: bool,
/// Includes plaintext reasoning summaries and reasoning content.
pub include_reasoning: bool,
}
impl Default for ConversationTranscriptOptions {
fn default() -> Self {
Self {
include_tool_calls: true,
include_tool_outputs: true,
include_reasoning: false,
}
}
}
/// Per-entry caps resolved by the caller for the current review.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TranscriptEntryLimits {
/// Cap for user, developer, assistant, and plaintext reasoning entries.
pub message_tokens: usize,
/// Cap for tool calls and ordinary tool outputs.
pub tool_tokens: usize,
/// Cap for Node REPL-backed outputs, which sync may retain at a larger size.
pub node_repl_output_tokens: usize,
}
/// Aggregate limits for retaining rendered transcript entries.
///
/// Context profiles apply the sync or async selection rules using these limits.
/// Collection applies per-entry caps before profile retention.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TranscriptRetentionConfig {
/// Budget for rendered user, developer, assistant, and reasoning entries.
pub max_message_transcript_tokens: usize,
/// Separate budget for rendered tool calls and results.
pub max_tool_transcript_tokens: usize,
/// Maximum retained entries other than user messages.
pub max_recent_non_user_entries: usize,
}
/// Evidence sources and per-entry limits supplied on each collection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConversationTranscriptConfig {
/// Evidence sources to include in this request.
pub options: ConversationTranscriptOptions,
/// Per-entry limits applied before accumulating transcript text.
pub entry_limits: TranscriptEntryLimits,
}
/// Shared contributor that extracts parent-conversation evidence.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ConversationTranscriptSection;
impl SectionContributor for ConversationTranscriptSection {
fn scope(&self) -> SectionScope {
SectionScope::Shared
}
fn contribute(&self, input: &SectionInput<'_>) -> Result<Option<ContextSection>, SectionError> {
Ok(Some(ContextSection::ConversationTranscript {
items: collect_transcript(input.history, input.transcript),
}))
}
}
/// Extracts bounded transcript entries without composing other context sections.
///
/// Entries preserve conversation order and role/tool attribution. Per-entry
/// limits apply during collection; context profiles own aggregate retention.
pub fn collect_transcript(
history: &dyn SectionHistory,
config: &ConversationTranscriptConfig,
) -> Vec<ConversationTranscriptEntry> {
let mut entries = Vec::new();
let mut tool_names_by_call_id = HashMap::new();
for item in history.items() {
let (kind, text) = match item {
ResponseItem::Message {
role,
content,
phase,
..
} => {
let text = content
.iter()
.filter_map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text }
if !text.is_empty() =>
{
Some(text.as_str())
}
ContentItem::InputText { .. }
| ContentItem::OutputText { .. }
| ContentItem::InputImage { .. }
| ContentItem::InputAudio { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
let kind = match role.as_str() {
"user" => ConversationTranscriptEntryKind::User,
"developer" if text.starts_with(MANUAL_APPROVAL_DEVELOPER_PREFIX) => {
ConversationTranscriptEntryKind::Developer
}
"assistant"
if matches!(phase, None | Some(MessagePhase::FinalAnswer))
&& !InterAgentCommunication::is_message_content(content) =>
{
ConversationTranscriptEntryKind::ProtectedAssistant
}
"assistant" => ConversationTranscriptEntryKind::Assistant,
_ => continue,
};
(kind, text)
}
ResponseItem::AgentMessage {
author, content, ..
} => {
let Some(text) = plaintext_agent_message_content(content) else {
continue;
};
(
ConversationTranscriptEntryKind::Assistant,
format!("Agent message from {author}:\n{text}"),
)
}
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
}
| ResponseItem::CustomToolCall {
name,
namespace,
input: arguments,
call_id,
..
} => {
tool_names_by_call_id
.insert(call_id.as_str(), (name.as_str(), namespace.as_deref()));
if !config.options.include_tool_calls {
continue;
}
(
ConversationTranscriptEntryKind::ToolCall(format!("tool {name} call")),
arguments.clone(),
)
}
ResponseItem::FunctionCallOutput {
call_id: Some(call_id),
output,
..
}
| ResponseItem::CustomToolCallOutput {
call_id, output, ..
} => {
if !config.options.include_tool_outputs {
continue;
}
let kind = match tool_names_by_call_id.get(call_id.as_str()) {
Some((name, namespace)) if is_node_repl_backed_tool(name, *namespace) => {
ConversationTranscriptEntryKind::NodeReplToolOutput(format!(
"tool {name} result"
))
}
Some((name, _)) => {
ConversationTranscriptEntryKind::ToolOutput(format!("tool {name} result"))
}
None => ConversationTranscriptEntryKind::ToolOutput("tool result".to_string()),
};
let Some(text) = output.body.to_text() else {
continue;
};
(kind, text)
}
ResponseItem::FunctionCallOutput {
call_id: None,
name: Some(name),
namespace,
output,
..
} => {
if !config.options.include_tool_outputs {
continue;
}
let role = match namespace {
Some(namespace) => format!("tool {namespace}.{name} result"),
None => format!("tool {name} result"),
};
(
ConversationTranscriptEntryKind::ToolOutput(role),
output
.body
.to_text()
.unwrap_or_else(|| "[non-text output]".into()),
)
}
ResponseItem::Reasoning {
summary, content, ..
} => {
if !config.options.include_reasoning {
continue;
}
let text = summary
.iter()
.map(|item| match item {
ReasoningItemReasoningSummary::SummaryText { text } => text.as_str(),
})
.chain(content.iter().flatten().map(|item| match item {
ReasoningItemContent::ReasoningText { text }
| ReasoningItemContent::Text { text } => text.as_str(),
}))
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
(ConversationTranscriptEntryKind::Reasoning, text)
}
ResponseItem::LocalShellCall { action, .. } => {
if !config.options.include_tool_calls {
continue;
}
let Ok(text) = serde_json::to_string(action) else {
continue;
};
(
ConversationTranscriptEntryKind::ToolCall("tool shell call".to_string()),
text,
)
}
ResponseItem::WebSearchCall { action, .. } => {
if !config.options.include_tool_calls {
continue;
}
let Some(action) = action else {
continue;
};
let Ok(text) = serde_json::to_string(action) else {
continue;
};
(
ConversationTranscriptEntryKind::ToolCall("tool web_search call".to_string()),
text,
)
}
ResponseItem::FunctionCallOutput {
call_id: None,
name: None,
..
}
| ResponseItem::AdditionalTools { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ConfigurationUpdate { .. }
| ResponseItem::CompactionTrigger { .. }
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => continue,
};
if text.trim().is_empty() {
continue;
}
let token_cap = match &kind {
ConversationTranscriptEntryKind::User
| ConversationTranscriptEntryKind::Developer
| ConversationTranscriptEntryKind::Assistant
| ConversationTranscriptEntryKind::ProtectedAssistant
| ConversationTranscriptEntryKind::Reasoning => config.entry_limits.message_tokens,
ConversationTranscriptEntryKind::ToolCall(_)
| ConversationTranscriptEntryKind::ToolOutput(_) => config.entry_limits.tool_tokens,
ConversationTranscriptEntryKind::NodeReplToolOutput(_) => {
config.entry_limits.node_repl_output_tokens
}
};
entries.push(ConversationTranscriptEntry {
kind,
text: truncate_text(&text, token_cap),
original_bytes: text.len(),
});
}
entries
}
#[cfg(test)]
#[path = "transcript_tests.rs"]
mod tests;