mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
## Why Transcript limits could shorten user instructions or omit later messages before Guardian evaluated the complete request budget, losing restrictions or prior approvals even when they would fit. ## What changed - Keep user messages and manual approvals complete and in source order through transcript collection and retention. - Preserve historical instructions while synchronous review can still compact history. If the final request still cannot fit, discard optional evidence before shortening older historical entries with truncation markers, preserving later restrictions where possible. - Warn that shortened instructions and approvals are incomplete and that missing evidence does not authorize actions. - Let asynchronous review defer to synchronous review when complete instructions exceed its budget. ## Testing Add coverage for complete instruction retention, approval and restriction ordering, marked truncation of oversized Unicode text, compaction before truncation, and asynchronous fallback when instructions do not fit. GitOrigin-RevId: e6159e876231347986b937ee7e07f944b83bfbe6
337 lines
13 KiB
Rust
337 lines
13 KiB
Rust
//! Collects bounded conversation evidence before consumer-specific rendering.
|
|
//!
|
|
//! Both Guardian consumers receive the same role and tool-source attribution,
|
|
//! with complete user messages and capped non-user entries. 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 assistant and plaintext reasoning entries; user and manual approvals stay complete.
|
|
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.
|
|
/// User messages and manual approvals survive these soft limits; the complete
|
|
/// request budget can shorten them with markers after other recovery is exhausted.
|
|
#[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 and manual approvals.
|
|
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. Non-user
|
|
/// 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 original_bytes = text.len();
|
|
let text = match &kind {
|
|
ConversationTranscriptEntryKind::User | ConversationTranscriptEntryKind::Developer => {
|
|
text
|
|
}
|
|
ConversationTranscriptEntryKind::Assistant
|
|
| ConversationTranscriptEntryKind::ProtectedAssistant
|
|
| ConversationTranscriptEntryKind::Reasoning => {
|
|
truncate_text(&text, config.entry_limits.message_tokens)
|
|
}
|
|
ConversationTranscriptEntryKind::ToolCall(_)
|
|
| ConversationTranscriptEntryKind::ToolOutput(_) => {
|
|
truncate_text(&text, config.entry_limits.tool_tokens)
|
|
}
|
|
ConversationTranscriptEntryKind::NodeReplToolOutput(_) => {
|
|
truncate_text(&text, config.entry_limits.node_repl_output_tokens)
|
|
}
|
|
};
|
|
entries.push(ConversationTranscriptEntry {
|
|
kind,
|
|
text,
|
|
original_bytes,
|
|
});
|
|
}
|
|
|
|
entries
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "transcript_tests.rs"]
|
|
mod tests;
|