mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Remove the hard-coded 9,000-token cap for encrypted agent messages in both synchronous and asynchronous guardian context. Use `message_tokens` directly when converting the configured limit to a byte budget, allowing larger messages when the configured limit permits them. Messages that exceed the configured budget still produce an omission notice; encrypted content remains intact when retained. GitOrigin-RevId: a9032ca19102ebad44be8df1ac4d4892d4404ead
489 lines
21 KiB
Rust
489 lines
21 KiB
Rust
//! Collects bounded conversation evidence before consumer-specific rendering.
|
|
//!
|
|
//! 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, with sync entry caps deferred to request preparation. 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 codex_history::RetainedContextEntry;
|
|
use codex_protocol::protocol::TruncationPolicy;
|
|
use std::collections::HashMap;
|
|
|
|
use codex_protocol::mcp::is_node_repl_backed_tool;
|
|
use codex_protocol::models::AgentMessageInputContent;
|
|
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::TranscriptContent;
|
|
use crate::truncate_text;
|
|
|
|
pub(crate) const TRANSCRIPT_OMISSION_NOTICE: &str = "Some conversation entries were omitted.";
|
|
|
|
/// 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`.";
|
|
|
|
/// Copied approval messages are model context, not a child's trusted approval evidence.
|
|
/// Filter before dropping envelope provenance, including when replaying review checkpoints.
|
|
pub fn is_inherited_manual_approval(envelope: &codex_history::ResponseItemEnvelope) -> bool {
|
|
envelope
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.inherited_user_message)
|
|
&& matches!(&envelope.item, ResponseItem::Message { role, content, .. }
|
|
if role == "developer" && content.iter().any(|part|
|
|
matches!(part, ContentItem::InputText { text } | ContentItem::OutputText { text , .. }
|
|
if text.starts_with(MANUAL_APPROVAL_DEVELOPER_PREFIX))))
|
|
}
|
|
|
|
/// 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_for_target(input.history, input.transcript, input.target),
|
|
}))
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
collect_transcript_for_target(history, config, crate::ContextTarget::Async)
|
|
}
|
|
|
|
fn collect_transcript_for_target(
|
|
history: &dyn SectionHistory,
|
|
config: &ConversationTranscriptConfig,
|
|
target: crate::ContextTarget,
|
|
) -> Vec<ConversationTranscriptEntry> {
|
|
let mut entries = Vec::new();
|
|
let mut tool_names_by_call_id = HashMap::new();
|
|
let mut heartbeat_versions = HashMap::new();
|
|
// Positional legacy labels cannot establish reusable delivery proof, including
|
|
// through transcript copies. The separate retained section stays authoritative.
|
|
let retained_context = history
|
|
.retained_context()
|
|
.filter(|context| !crate::retained_instructions::has_legacy_order(context));
|
|
|
|
for (item, mut source) in history.items_with_sources() {
|
|
let (kind, mut text) = match item {
|
|
ResponseItem::Message {
|
|
role,
|
|
content,
|
|
phase,
|
|
internal_chat_message_metadata_passthrough,
|
|
..
|
|
} => {
|
|
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() {
|
|
// Local compaction uses the user wire role for model context, but
|
|
// its generated summary is not user authorization.
|
|
"user"
|
|
if internal_chat_message_metadata_passthrough
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.content_item_kinds.as_ref())
|
|
.is_some_and(|kinds| {
|
|
kinds.iter().any(|kind| kind.0 == "compaction.summary")
|
|
}) =>
|
|
{
|
|
ConversationTranscriptEntryKind::Assistant
|
|
}
|
|
"user" => ConversationTranscriptEntryKind::User,
|
|
"developer" if text.starts_with(MANUAL_APPROVAL_DEVELOPER_PREFIX) => {
|
|
ConversationTranscriptEntryKind::Developer
|
|
}
|
|
"assistant"
|
|
if matches!(
|
|
phase,
|
|
None | Some(MessagePhase::PartialAnswer | MessagePhase::FinalAnswer)
|
|
) && !InterAgentCommunication::is_message_content(content) =>
|
|
{
|
|
ConversationTranscriptEntryKind::ProtectedAssistant
|
|
}
|
|
"assistant" => ConversationTranscriptEntryKind::Assistant,
|
|
_ => continue,
|
|
};
|
|
(kind, text)
|
|
}
|
|
ResponseItem::AgentMessage {
|
|
author, content, ..
|
|
} => {
|
|
if content
|
|
.iter()
|
|
.any(|part| matches!(part, AgentMessageInputContent::EncryptedContent { .. }))
|
|
{
|
|
// Ciphertext cannot be truncated. Keep the complete native item, or
|
|
// an explicit omission at the same cursor position. Serialized bytes
|
|
// conservatively charge the encrypted payload to the message budget.
|
|
let bytes = serde_json::to_vec(item).map_or(usize::MAX, |item| item.len());
|
|
let content = if target == crate::ContextTarget::Sync {
|
|
TranscriptContent::AgentMessage(Box::new(item.clone()))
|
|
} else {
|
|
let limit = codex_protocol::protocol::TruncationPolicy::Tokens(
|
|
config.entry_limits.message_tokens,
|
|
)
|
|
.byte_budget();
|
|
if bytes <= limit {
|
|
TranscriptContent::AgentMessage(Box::new(item.clone()))
|
|
} else {
|
|
TranscriptContent::Text(TRANSCRIPT_OMISSION_NOTICE.to_owned())
|
|
}
|
|
};
|
|
entries.push(ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::Assistant,
|
|
content,
|
|
original_bytes: bytes,
|
|
retained_source: None,
|
|
});
|
|
continue;
|
|
}
|
|
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();
|
|
if let Some(heartbeat) = codex_history::Heartbeat::from_message(item) {
|
|
match heartbeat_versions.get(heartbeat.automation_id) {
|
|
Some((instructions, number)) if *instructions == heartbeat.instructions => {
|
|
// A reference no longer delivers the complete source instruction.
|
|
source = None;
|
|
text = format!(
|
|
"Scheduled automation {} ran at {}. Instructions unchanged from transcript entry [{}]; this is a replay of that earlier instruction, not a new human instruction. If the referenced instructions are unavailable, do not infer authorization from this reference.",
|
|
heartbeat.automation_id, heartbeat.timestamp, number
|
|
);
|
|
}
|
|
_ => {
|
|
heartbeat_versions.insert(
|
|
heartbeat.automation_id,
|
|
(heartbeat.instructions, entries.len() + 1),
|
|
);
|
|
}
|
|
}
|
|
} else if codex_history::UserInputOrigin::from_message(item)
|
|
== codex_history::UserInputOrigin::Heartbeat
|
|
{
|
|
// Unknown scheduler envelopes may change instructions; do not bridge them.
|
|
heartbeat_versions.clear();
|
|
}
|
|
let text = match &kind {
|
|
ConversationTranscriptEntryKind::User | ConversationTranscriptEntryKind::Developer => {
|
|
text
|
|
}
|
|
ConversationTranscriptEntryKind::Assistant
|
|
| ConversationTranscriptEntryKind::ProtectedAssistant
|
|
| ConversationTranscriptEntryKind::Reasoning
|
|
if target == crate::ContextTarget::Sync =>
|
|
{
|
|
text
|
|
}
|
|
ConversationTranscriptEntryKind::Assistant
|
|
| ConversationTranscriptEntryKind::ProtectedAssistant
|
|
| ConversationTranscriptEntryKind::Reasoning => {
|
|
truncate_text(&text, config.entry_limits.message_tokens)
|
|
}
|
|
ConversationTranscriptEntryKind::ToolCall(_)
|
|
| ConversationTranscriptEntryKind::ToolOutput(_)
|
|
if target == crate::ContextTarget::Sync =>
|
|
{
|
|
text
|
|
}
|
|
ConversationTranscriptEntryKind::ToolCall(_)
|
|
| ConversationTranscriptEntryKind::ToolOutput(_) => {
|
|
truncate_text(&text, config.entry_limits.tool_tokens)
|
|
}
|
|
ConversationTranscriptEntryKind::NodeReplToolOutput(_)
|
|
if target == crate::ContextTarget::Sync =>
|
|
{
|
|
text
|
|
}
|
|
ConversationTranscriptEntryKind::NodeReplToolOutput(_) => {
|
|
truncate_text(&text, config.entry_limits.node_repl_output_tokens)
|
|
}
|
|
};
|
|
entries.push(ConversationTranscriptEntry {
|
|
retained_source: retained_context.and_then(|context| {
|
|
let source = source.filter(|source| {
|
|
source.complete
|
|
&& kind == ConversationTranscriptEntryKind::User
|
|
&& source.id.role == codex_history::RetainedSourceRole::User
|
|
&& Some(source.id.message_id.as_str())
|
|
== item.id().map(codex_protocol::ResponseItemId::as_str)
|
|
&& source.id.turn_id == item.turn_id().unwrap_or_default()
|
|
})?;
|
|
crate::retained_instructions::source_order_labels(context).find_map(
|
|
|(order, entry)| {
|
|
if context.source(entry).as_ref() != Some(source) {
|
|
return None;
|
|
}
|
|
let RetainedContextEntry::UserMessage(message) = entry else {
|
|
return None;
|
|
};
|
|
let rendered = format!(
|
|
"Retained source order: {order}\n{}",
|
|
crate::GuardianRootMessage::User(message.text.clone()).render()
|
|
);
|
|
(rendered.len()
|
|
<= TruncationPolicy::Tokens(
|
|
crate::retained_instructions::MAX_INSTRUCTION_TOKENS,
|
|
)
|
|
.byte_budget())
|
|
.then(|| crate::RetainedTranscriptSource {
|
|
order,
|
|
source: source.clone(),
|
|
})
|
|
},
|
|
)
|
|
}),
|
|
kind,
|
|
content: TranscriptContent::Text(text),
|
|
original_bytes,
|
|
});
|
|
}
|
|
|
|
entries
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "transcript_tests.rs"]
|
|
mod tests;
|