mirror of
https://github.com/openai/codex.git
synced 2026-11-06 00:30:29 +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
Generated
+1
@@ -3327,6 +3327,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
@@ -17,7 +17,7 @@ pub enum ConversationTranscriptEntryKind {
|
|||||||
ProtectedAssistant,
|
ProtectedAssistant,
|
||||||
/// Named tool invocation, including shell and web-search calls.
|
/// Named tool invocation, including shell and web-search calls.
|
||||||
ToolCall(String),
|
ToolCall(String),
|
||||||
/// Named or unnamed tool result.
|
/// Tool result with a call-derived, explicit, or generic fallback label.
|
||||||
ToolOutput(String),
|
ToolOutput(String),
|
||||||
/// Result from a Node REPL-backed tool that may receive a larger sync cap.
|
/// Result from a Node REPL-backed tool that may receive a larger sync cap.
|
||||||
NodeReplToolOutput(String),
|
NodeReplToolOutput(String),
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
//! Shared context sections for synchronous Guardian review and asynchronous scoring.
|
//! Shared context sections for synchronous Guardian review and asynchronous scoring.
|
||||||
//!
|
//!
|
||||||
|
//! Transcript collection is also available directly, without section composition.
|
||||||
//! Contributor failures abort collection without returning partial context.
|
//! Contributor failures abort collection without returning partial context.
|
||||||
//! Sections carry structured transcript evidence without depending on either
|
//! Sections carry structured transcript evidence without depending on either
|
||||||
//! consumer's rendering, retention, compaction, or request lifecycle.
|
//! consumer's rendering, retention, compaction, or request lifecycle.
|
||||||
//! Registered contributors declare their scope once and are collected only for
|
//! Registered contributors declare their scope once and are collected only for
|
||||||
//! matching context consumers.
|
//! matching context consumers. History and collection settings are borrowed for
|
||||||
|
//! each request so the default registry can be reused without retaining state.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
|
|
||||||
|
use transcript::ConversationTranscriptSection;
|
||||||
|
|
||||||
pub use entry::ConversationTranscriptEntry;
|
pub use entry::ConversationTranscriptEntry;
|
||||||
pub use entry::ConversationTranscriptEntryKind;
|
pub use entry::ConversationTranscriptEntryKind;
|
||||||
|
pub use transcript::ConversationTranscriptConfig;
|
||||||
|
pub use transcript::ConversationTranscriptOptions;
|
||||||
|
pub use transcript::MANUAL_APPROVAL_DEVELOPER_PREFIX;
|
||||||
|
pub use transcript::TranscriptEntryLimits;
|
||||||
|
pub use transcript::TranscriptRetentionConfig;
|
||||||
|
pub use transcript::collect_transcript;
|
||||||
pub use truncation::truncate_text;
|
pub use truncation::truncate_text;
|
||||||
|
|
||||||
mod entry;
|
mod entry;
|
||||||
|
mod transcript;
|
||||||
mod truncation;
|
mod truncation;
|
||||||
|
|
||||||
/// Consumer for which a Guardian context is composed.
|
/// Consumer for which a Guardian context is composed.
|
||||||
@@ -49,12 +61,36 @@ impl SectionScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Borrowed host inputs available while one Guardian context section is built.
|
/// Borrowed host inputs available while one Guardian context section is built.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct SectionInput<'a> {
|
pub struct SectionInput<'a> {
|
||||||
/// Consumer for which the host is collecting context sections.
|
/// Consumer for which the host is collecting context sections.
|
||||||
pub target: ContextTarget,
|
pub target: ContextTarget,
|
||||||
/// Parent conversation history available to this contribution.
|
/// Parent conversation history available to this contribution.
|
||||||
pub history: &'a [ResponseItem],
|
pub history: &'a dyn SectionHistory,
|
||||||
|
/// Evidence sources and per-entry limits for this collection.
|
||||||
|
pub transcript: &'a ConversationTranscriptConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplies repeatable, zero-copy access to a host-owned conversation snapshot.
|
||||||
|
///
|
||||||
|
/// Implementations return a fresh iterator for every call so independently
|
||||||
|
/// registered contributors can inspect the same history without cloning its
|
||||||
|
/// response items or taking ownership away from the host.
|
||||||
|
pub trait SectionHistory: Send + Sync {
|
||||||
|
/// Returns borrowed response items in their original conversation order.
|
||||||
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionHistory for Vec<ResponseItem> {
|
||||||
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
||||||
|
Box::new(self.iter())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const LENGTH: usize> SectionHistory for [ResponseItem; LENGTH] {
|
||||||
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
||||||
|
Box::new(self.iter())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supplies one independently scoped section to Guardian context assembly.
|
/// Supplies one independently scoped section to Guardian context assembly.
|
||||||
@@ -63,6 +99,8 @@ pub struct SectionInput<'a> {
|
|||||||
/// asynchronous scoring, or both. The registry filters contributors by scope
|
/// asynchronous scoring, or both. The registry filters contributors by scope
|
||||||
/// before invoking them. Contributors distinguish sections that do not apply
|
/// before invoking them. Contributors distinguish sections that do not apply
|
||||||
/// from required evidence that could not be collected.
|
/// from required evidence that could not be collected.
|
||||||
|
/// Keep request-specific settings and history in [`SectionInput`] so the same
|
||||||
|
/// contributor can serve concurrent reviews without retaining stale state.
|
||||||
pub trait SectionContributor: Send + Sync {
|
pub trait SectionContributor: Send + Sync {
|
||||||
/// Guardian consumers that should receive this contribution.
|
/// Guardian consumers that should receive this contribution.
|
||||||
fn scope(&self) -> SectionScope;
|
fn scope(&self) -> SectionScope;
|
||||||
@@ -100,6 +138,20 @@ pub struct SectionRegistry {
|
|||||||
contributors: Vec<Arc<dyn SectionContributor>>,
|
contributors: Vec<Arc<dyn SectionContributor>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared, process-lifetime registry of built-in Guardian sections.
|
||||||
|
///
|
||||||
|
/// Contributors store no conversation or configuration state. Each collection
|
||||||
|
/// borrows the current history and settings from [`SectionInput`], so callers
|
||||||
|
/// can reuse this registry across threads, model changes, and review targets.
|
||||||
|
pub fn default_registry() -> &'static SectionRegistry {
|
||||||
|
static REGISTRY: LazyLock<SectionRegistry> = LazyLock::new(|| {
|
||||||
|
let mut registry = SectionRegistry::default();
|
||||||
|
registry.register(ConversationTranscriptSection);
|
||||||
|
registry
|
||||||
|
});
|
||||||
|
®ISTRY
|
||||||
|
}
|
||||||
|
|
||||||
impl SectionRegistry {
|
impl SectionRegistry {
|
||||||
/// Adds a contributor to the end of the section collection order.
|
/// Adds a contributor to the end of the section collection order.
|
||||||
pub fn register(&mut self, contributor: impl SectionContributor + 'static) {
|
pub fn register(&mut self, contributor: impl SectionContributor + 'static) {
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ use pretty_assertions::assert_eq;
|
|||||||
|
|
||||||
use super::ContextSection;
|
use super::ContextSection;
|
||||||
use super::ContextTarget;
|
use super::ContextTarget;
|
||||||
|
use super::ConversationTranscriptConfig;
|
||||||
use super::ConversationTranscriptEntry;
|
use super::ConversationTranscriptEntry;
|
||||||
use super::ConversationTranscriptEntryKind;
|
use super::ConversationTranscriptEntryKind;
|
||||||
|
use super::ConversationTranscriptOptions;
|
||||||
use super::SectionContributor;
|
use super::SectionContributor;
|
||||||
use super::SectionError;
|
use super::SectionError;
|
||||||
use super::SectionInput;
|
use super::SectionInput;
|
||||||
use super::SectionRegistry;
|
use super::SectionRegistry;
|
||||||
use super::SectionScope;
|
use super::SectionScope;
|
||||||
|
use super::TranscriptEntryLimits;
|
||||||
|
|
||||||
struct TestContributor {
|
struct TestContributor {
|
||||||
outcome: Result<Option<&'static str>, SectionError>,
|
outcome: Result<Option<&'static str>, SectionError>,
|
||||||
@@ -28,7 +31,7 @@ impl SectionContributor for TestContributor {
|
|||||||
|
|
||||||
fn contribute(&self, input: &SectionInput<'_>) -> Result<Option<ContextSection>, SectionError> {
|
fn contribute(&self, input: &SectionInput<'_>) -> Result<Option<ContextSection>, SectionError> {
|
||||||
self.invocations.fetch_add(/*val*/ 1, Ordering::Relaxed);
|
self.invocations.fetch_add(/*val*/ 1, Ordering::Relaxed);
|
||||||
let history_len = input.history.len();
|
let history_len = input.history.items().count();
|
||||||
Ok(self
|
Ok(self
|
||||||
.outcome
|
.outcome
|
||||||
.clone()?
|
.clone()?
|
||||||
@@ -47,6 +50,17 @@ fn section(label: &str, history_len: usize) -> ContextSection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn transcript_config() -> ConversationTranscriptConfig {
|
||||||
|
ConversationTranscriptConfig {
|
||||||
|
options: ConversationTranscriptOptions::default(),
|
||||||
|
entry_limits: TranscriptEntryLimits {
|
||||||
|
message_tokens: 2_000,
|
||||||
|
tool_tokens: 1_000,
|
||||||
|
node_repl_output_tokens: 2_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_collects_target_specific_sections_in_registration_order() {
|
fn registry_collects_target_specific_sections_in_registration_order() {
|
||||||
let mut registry = SectionRegistry::default();
|
let mut registry = SectionRegistry::default();
|
||||||
@@ -66,14 +80,17 @@ fn registry_collects_target_specific_sections_in_registration_order() {
|
|||||||
invocations.push(calls);
|
invocations.push(calls);
|
||||||
}
|
}
|
||||||
let history = [ResponseItem::Other];
|
let history = [ResponseItem::Other];
|
||||||
|
let transcript = transcript_config();
|
||||||
|
|
||||||
let sync_sections = registry.collect(&SectionInput {
|
let sync_sections = registry.collect(&SectionInput {
|
||||||
target: ContextTarget::Sync,
|
target: ContextTarget::Sync,
|
||||||
history: &history,
|
history: &history,
|
||||||
|
transcript: &transcript,
|
||||||
});
|
});
|
||||||
let async_sections = registry.collect(&SectionInput {
|
let async_sections = registry.collect(&SectionInput {
|
||||||
target: ContextTarget::Async,
|
target: ContextTarget::Async,
|
||||||
history: &history,
|
history: &history,
|
||||||
|
transcript: &transcript,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -107,6 +124,7 @@ fn registry_skips_optional_sections_and_stops_on_missing_required_evidence() {
|
|||||||
section: "permissions",
|
section: "permissions",
|
||||||
};
|
};
|
||||||
for target in [ContextTarget::Sync, ContextTarget::Async] {
|
for target in [ContextTarget::Sync, ContextTarget::Async] {
|
||||||
|
let transcript = transcript_config();
|
||||||
let mut registry = SectionRegistry::default();
|
let mut registry = SectionRegistry::default();
|
||||||
let mut invocations = Vec::new();
|
let mut invocations = Vec::new();
|
||||||
for outcome in [
|
for outcome in [
|
||||||
@@ -128,6 +146,7 @@ fn registry_skips_optional_sections_and_stops_on_missing_required_evidence() {
|
|||||||
registry.collect(&SectionInput {
|
registry.collect(&SectionInput {
|
||||||
target,
|
target,
|
||||||
history: &[ResponseItem::Other],
|
history: &[ResponseItem::Other],
|
||||||
|
transcript: &transcript,
|
||||||
}),
|
}),
|
||||||
Err(error.clone())
|
Err(error.clone())
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
use codex_protocol::models::ContentItem;
|
||||||
|
use codex_protocol::models::FunctionCallOutputPayload;
|
||||||
|
use codex_protocol::models::LocalShellAction;
|
||||||
|
use codex_protocol::models::LocalShellExecAction;
|
||||||
|
use codex_protocol::models::LocalShellStatus;
|
||||||
|
use codex_protocol::models::MessagePhase;
|
||||||
|
use codex_protocol::models::ResponseItem;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use super::ConversationTranscriptConfig;
|
||||||
|
use super::ConversationTranscriptEntry;
|
||||||
|
use super::ConversationTranscriptEntryKind;
|
||||||
|
use super::ConversationTranscriptOptions;
|
||||||
|
use super::MANUAL_APPROVAL_DEVELOPER_PREFIX;
|
||||||
|
use super::TranscriptEntryLimits;
|
||||||
|
use crate::ContextSection;
|
||||||
|
use crate::ContextTarget;
|
||||||
|
use crate::SectionInput;
|
||||||
|
use crate::collect_transcript;
|
||||||
|
use crate::default_registry;
|
||||||
|
use crate::truncate_text;
|
||||||
|
|
||||||
|
fn transcript_config() -> ConversationTranscriptConfig {
|
||||||
|
ConversationTranscriptConfig {
|
||||||
|
options: ConversationTranscriptOptions::default(),
|
||||||
|
entry_limits: TranscriptEntryLimits {
|
||||||
|
message_tokens: 2_000,
|
||||||
|
tool_tokens: 1_000,
|
||||||
|
node_repl_output_tokens: 2_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(kind: ConversationTranscriptEntryKind, text: &str) -> ConversationTranscriptEntry {
|
||||||
|
ConversationTranscriptEntry {
|
||||||
|
kind,
|
||||||
|
text: text.to_string(),
|
||||||
|
original_bytes: text.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() {
|
||||||
|
let approved_action = format!("{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {{}}");
|
||||||
|
let history = vec![
|
||||||
|
ResponseItem::Message {
|
||||||
|
id: None,
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![ContentItem::InputText {
|
||||||
|
text: "Inspect the workspace.".to_string(),
|
||||||
|
}],
|
||||||
|
phase: None,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
ResponseItem::Message {
|
||||||
|
id: None,
|
||||||
|
role: "developer".to_string(),
|
||||||
|
content: vec![ContentItem::InputText {
|
||||||
|
text: approved_action.clone(),
|
||||||
|
}],
|
||||||
|
phase: None,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
ResponseItem::Message {
|
||||||
|
id: None,
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: vec![ContentItem::OutputText {
|
||||||
|
text: "Inspection complete.".to_string(),
|
||||||
|
}],
|
||||||
|
phase: Some(MessagePhase::FinalAnswer),
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
ResponseItem::FunctionCall {
|
||||||
|
id: None,
|
||||||
|
name: "read_file".to_string(),
|
||||||
|
namespace: Some("mcp__node_repl__".to_string()),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
call_id: "call-1".to_string(),
|
||||||
|
encrypted_function_args: None,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
ResponseItem::FunctionCallOutput {
|
||||||
|
id: None,
|
||||||
|
call_id: Some("call-1".to_string()),
|
||||||
|
name: None,
|
||||||
|
namespace: None,
|
||||||
|
output: FunctionCallOutputPayload::from_text("file contents".to_string()),
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let registry = default_registry();
|
||||||
|
let config = transcript_config();
|
||||||
|
|
||||||
|
let sections = registry
|
||||||
|
.collect(&SectionInput {
|
||||||
|
target: ContextTarget::Async,
|
||||||
|
history: &history,
|
||||||
|
transcript: &config,
|
||||||
|
})
|
||||||
|
.expect("transcript collection should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sections,
|
||||||
|
vec![ContextSection {
|
||||||
|
items: vec![
|
||||||
|
entry(
|
||||||
|
ConversationTranscriptEntryKind::User,
|
||||||
|
"Inspect the workspace."
|
||||||
|
),
|
||||||
|
entry(ConversationTranscriptEntryKind::Developer, &approved_action),
|
||||||
|
entry(
|
||||||
|
ConversationTranscriptEntryKind::ProtectedAssistant,
|
||||||
|
"Inspection complete."
|
||||||
|
),
|
||||||
|
entry(
|
||||||
|
ConversationTranscriptEntryKind::ToolCall("tool read_file call".to_string()),
|
||||||
|
"{}"
|
||||||
|
),
|
||||||
|
entry(
|
||||||
|
ConversationTranscriptEntryKind::NodeReplToolOutput(
|
||||||
|
"tool read_file result".to_string()
|
||||||
|
),
|
||||||
|
"file contents"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sections,
|
||||||
|
vec![ContextSection {
|
||||||
|
items: collect_transcript(&history, &config),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excluded_tool_calls_still_attribute_included_results() {
|
||||||
|
let history = vec![
|
||||||
|
ResponseItem::FunctionCall {
|
||||||
|
id: None,
|
||||||
|
name: "read_file".to_string(),
|
||||||
|
namespace: None,
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
call_id: "call-1".to_string(),
|
||||||
|
encrypted_function_args: None,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
ResponseItem::FunctionCallOutput {
|
||||||
|
id: None,
|
||||||
|
call_id: Some("call-1".to_string()),
|
||||||
|
name: None,
|
||||||
|
namespace: None,
|
||||||
|
output: FunctionCallOutputPayload::from_text("file contents".to_string()),
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let config = ConversationTranscriptConfig {
|
||||||
|
options: ConversationTranscriptOptions {
|
||||||
|
include_tool_calls: false,
|
||||||
|
..ConversationTranscriptOptions::default()
|
||||||
|
},
|
||||||
|
..transcript_config()
|
||||||
|
};
|
||||||
|
let sections = default_registry()
|
||||||
|
.collect(&SectionInput {
|
||||||
|
target: ContextTarget::Async,
|
||||||
|
history: &history,
|
||||||
|
transcript: &config,
|
||||||
|
})
|
||||||
|
.expect("transcript collection should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sections[0].items,
|
||||||
|
vec![entry(
|
||||||
|
ConversationTranscriptEntryKind::ToolOutput("tool read_file result".to_string()),
|
||||||
|
"file contents"
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outputs_with_call_ids_or_explicit_names_are_retained() {
|
||||||
|
let output =
|
||||||
|
|call_id: Option<&str>, name: Option<&str>, text: &str| ResponseItem::FunctionCallOutput {
|
||||||
|
id: None,
|
||||||
|
call_id: call_id.map(str::to_string),
|
||||||
|
name: name.map(str::to_string),
|
||||||
|
namespace: Some("slack".to_string()),
|
||||||
|
output: FunctionCallOutputPayload::from_text(text.to_string()),
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
};
|
||||||
|
let custom_output = |name: Option<&str>, text: &str| ResponseItem::CustomToolCallOutput {
|
||||||
|
id: None,
|
||||||
|
call_id: "missing-custom-call".to_string(),
|
||||||
|
name: name.map(str::to_string),
|
||||||
|
output: FunctionCallOutputPayload::from_text(text.to_string()),
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
};
|
||||||
|
let shell_action = LocalShellAction::Exec(LocalShellExecAction {
|
||||||
|
command: vec!["echo".to_string(), "hello".to_string()],
|
||||||
|
timeout_ms: None,
|
||||||
|
working_directory: None,
|
||||||
|
env: None,
|
||||||
|
user: None,
|
||||||
|
});
|
||||||
|
let shell_text = serde_json::to_string(&shell_action).unwrap();
|
||||||
|
let history = [
|
||||||
|
output(
|
||||||
|
/*call_id*/ None,
|
||||||
|
/*name*/ None,
|
||||||
|
"anonymous output",
|
||||||
|
),
|
||||||
|
output(
|
||||||
|
Some("missing-call"),
|
||||||
|
/*name*/ None,
|
||||||
|
"orphaned function output",
|
||||||
|
),
|
||||||
|
output(
|
||||||
|
/*call_id*/ None,
|
||||||
|
Some("notifications"),
|
||||||
|
"named notification",
|
||||||
|
),
|
||||||
|
output(
|
||||||
|
Some("missing-call"),
|
||||||
|
Some("notifications"),
|
||||||
|
"named orphaned function output",
|
||||||
|
),
|
||||||
|
custom_output(/*name*/ None, "orphaned custom output"),
|
||||||
|
custom_output(Some("notifications"), "named orphaned custom output"),
|
||||||
|
ResponseItem::LocalShellCall {
|
||||||
|
id: None,
|
||||||
|
call_id: Some("shell-1".to_string()),
|
||||||
|
status: LocalShellStatus::Completed,
|
||||||
|
action: shell_action,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
|
output(Some("shell-1"), /*name*/ None, "local shell output"),
|
||||||
|
];
|
||||||
|
let named = entry(
|
||||||
|
ConversationTranscriptEntryKind::ToolOutput("tool slack.notifications result".to_string()),
|
||||||
|
"named notification",
|
||||||
|
);
|
||||||
|
let generic = |text| {
|
||||||
|
entry(
|
||||||
|
ConversationTranscriptEntryKind::ToolOutput("tool result".to_string()),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut config = transcript_config();
|
||||||
|
for target in [ContextTarget::Sync, ContextTarget::Async] {
|
||||||
|
for include_tool_calls in [true, false] {
|
||||||
|
config.options.include_tool_calls = include_tool_calls;
|
||||||
|
let sections = default_registry()
|
||||||
|
.collect(&SectionInput {
|
||||||
|
target,
|
||||||
|
history: &history,
|
||||||
|
transcript: &config,
|
||||||
|
})
|
||||||
|
.expect("transcript collection should succeed");
|
||||||
|
let mut expected = vec![
|
||||||
|
generic("orphaned function output"),
|
||||||
|
named.clone(),
|
||||||
|
generic("named orphaned function output"),
|
||||||
|
generic("orphaned custom output"),
|
||||||
|
generic("named orphaned custom output"),
|
||||||
|
];
|
||||||
|
if include_tool_calls {
|
||||||
|
expected.push(entry(
|
||||||
|
ConversationTranscriptEntryKind::ToolCall("tool shell call".to_string()),
|
||||||
|
&shell_text,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
expected.push(generic("local shell output"));
|
||||||
|
assert_eq!(sections, vec![ContextSection { items: expected }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reused_registry_applies_current_history_sources_and_entry_limits() {
|
||||||
|
let text = "é🙂".repeat(/*n*/ 10_000);
|
||||||
|
let mut history = vec![ResponseItem::Message {
|
||||||
|
id: None,
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![ContentItem::InputText { text: text.clone() }],
|
||||||
|
phase: None,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
}];
|
||||||
|
let registry = default_registry();
|
||||||
|
let mut config = transcript_config();
|
||||||
|
for (target, message_tokens) in [(ContextTarget::Async, 80), (ContextTarget::Sync, 120)] {
|
||||||
|
config.entry_limits.message_tokens = message_tokens;
|
||||||
|
let sections = registry
|
||||||
|
.collect(&SectionInput {
|
||||||
|
target,
|
||||||
|
history: &history,
|
||||||
|
transcript: &config,
|
||||||
|
})
|
||||||
|
.expect("transcript collection should succeed");
|
||||||
|
assert_eq!(
|
||||||
|
sections[0].items,
|
||||||
|
vec![ConversationTranscriptEntry {
|
||||||
|
kind: ConversationTranscriptEntryKind::User,
|
||||||
|
text: truncate_text(&text, message_tokens),
|
||||||
|
original_bytes: text.len(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push(ResponseItem::FunctionCall {
|
||||||
|
id: None,
|
||||||
|
name: "exec_command".to_string(),
|
||||||
|
namespace: None,
|
||||||
|
arguments: text.clone(),
|
||||||
|
call_id: "call-1".to_string(),
|
||||||
|
encrypted_function_args: None,
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
});
|
||||||
|
config.entry_limits.message_tokens = 60;
|
||||||
|
config.entry_limits.tool_tokens = 30;
|
||||||
|
for include_tool_calls in [true, false] {
|
||||||
|
config.options.include_tool_calls = include_tool_calls;
|
||||||
|
let sections = registry
|
||||||
|
.collect(&SectionInput {
|
||||||
|
target: ContextTarget::Async,
|
||||||
|
history: &history,
|
||||||
|
transcript: &config,
|
||||||
|
})
|
||||||
|
.expect("transcript collection should succeed");
|
||||||
|
let mut expected = vec![ConversationTranscriptEntry {
|
||||||
|
kind: ConversationTranscriptEntryKind::User,
|
||||||
|
text: truncate_text(&text, /*max_tokens*/ 60),
|
||||||
|
original_bytes: text.len(),
|
||||||
|
}];
|
||||||
|
if include_tool_calls {
|
||||||
|
expected.push(ConversationTranscriptEntry {
|
||||||
|
kind: ConversationTranscriptEntryKind::ToolCall(
|
||||||
|
"tool exec_command call".to_string(),
|
||||||
|
),
|
||||||
|
text: truncate_text(&text, /*max_tokens*/ 30),
|
||||||
|
original_bytes: text.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(sections[0].items, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user