mirror of
https://github.com/openai/codex.git
synced 2026-11-14 14:49:31 +00:00
Add shared Guardian transcript collection (#41422)
## What changed - Add a reusable transcript contributor for synchronous and asynchronous Guardian context, with borrowed conversation history and request-specific configuration. - Preserve conversation order and role or tool attribution while applying separate per-entry limits for messages, ordinary tools, and Node REPL-backed outputs. - Make tool calls, tool outputs, and reasoning independently configurable, and expose transcript collection without requiring section composition. - Register the transcript contributor in a process-wide default registry that retains no request state. ## Testing - Add unit coverage for role and tool attribution, optional evidence sources, orphaned and named tool outputs, registry reuse, and request-specific entry limits. GitOrigin-RevId: 2b60f8db4618de8a7175f8ee0183375a526a87db
This commit is contained in:
1 parent
f742dabc6f
commit
0918cd2c08
7 files changed
+753
-5
No files matched your search
@@ -0,0 +1,329 @@
|
||||
//! 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. Consumers retain their own
|
||||
//! transcript selection, aggregate budgets, and formatting. 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.
|
||||
///
|
||||
/// Sync and async consumers keep their existing selection rules. These limits
|
||||
/// configure those rules without introducing another sync/async policy selector.
|
||||
/// Collection applies per-entry caps; aggregate retention remains with the host.
|
||||
#[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 {
|
||||
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; consumers own aggregate retention and rendering.
|
||||
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::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;
|
||||
Reference in new issue
Block a user