mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
554 lines
20 KiB
Rust
554 lines
20 KiB
Rust
use std::ops::Range;
|
|
use std::path::Path;
|
|
|
|
pub(crate) use super::log_entries::LogEntry;
|
|
use super::log_entries::dynamic_tool_output_items_to_text;
|
|
use super::log_entries::exec_output;
|
|
use super::log_entries::is_reflections_storage_tool;
|
|
pub(crate) use super::log_entries::log_entries_from_items;
|
|
use super::log_entries::push_blank_line_if_needed;
|
|
use super::log_entries::reflections_storage_tool_call_metadata;
|
|
use super::log_entries::reflections_storage_tool_response_metadata;
|
|
use codex_analytics::CompactionTrigger;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::RolloutItem;
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
|
|
pub(crate) struct TranscriptInput<'a> {
|
|
pub(crate) events: &'a [EventMsg],
|
|
pub(crate) trigger: CompactionTrigger,
|
|
pub(crate) context_window_size: Option<i64>,
|
|
pub(crate) rollout_path: &'a Path,
|
|
}
|
|
|
|
pub(crate) fn events_since_last_compaction(items: &[RolloutItem]) -> Vec<EventMsg> {
|
|
let range = item_range_since_last_compaction(items);
|
|
|
|
items[range]
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
RolloutItem::EventMsg(event) => Some(event.clone()),
|
|
RolloutItem::SessionMeta(_)
|
|
| RolloutItem::ResponseItem(_)
|
|
| RolloutItem::Compacted(_)
|
|
| RolloutItem::TurnContext(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn item_range_since_last_compaction(items: &[RolloutItem]) -> Range<usize> {
|
|
let start_index = items
|
|
.iter()
|
|
.rposition(|item| matches!(item, RolloutItem::Compacted(_)))
|
|
.map_or(0, |index| index + 1);
|
|
start_index..items.len()
|
|
}
|
|
|
|
pub(crate) fn render(input: TranscriptInput<'_>) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("# Reflections Log\n\n");
|
|
out.push_str("- schema: reflections.transcript.v1\n");
|
|
out.push_str(&format!("- trigger: {}\n", trigger_label(input.trigger)));
|
|
out.push_str(&format!(
|
|
"- context_window_size: {}\n",
|
|
input
|
|
.context_window_size
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "unavailable".to_string())
|
|
));
|
|
out.push_str(&format!(
|
|
"- source_rollout: {}\n",
|
|
input.rollout_path.display()
|
|
));
|
|
out.push('\n');
|
|
|
|
let mut index = 1usize;
|
|
for event in input.events {
|
|
if push_event(&mut out, index, event) {
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn push_event(out: &mut String, index: usize, event: &EventMsg) -> bool {
|
|
match event {
|
|
EventMsg::UserMessage(event) => {
|
|
let mut text = event.message.clone();
|
|
if let Some(images) = event.images.as_ref().filter(|images| !images.is_empty()) {
|
|
push_blank_line_if_needed(&mut text);
|
|
text.push_str("images:\n");
|
|
for image in images {
|
|
text.push_str(&format!("- {image}\n"));
|
|
}
|
|
}
|
|
if !event.local_images.is_empty() {
|
|
push_blank_line_if_needed(&mut text);
|
|
text.push_str("local_images:\n");
|
|
for image in &event.local_images {
|
|
text.push_str(&format!("- {}\n", image.display()));
|
|
}
|
|
}
|
|
push_message(out, index, "user", &text);
|
|
true
|
|
}
|
|
EventMsg::AgentMessage(event) => {
|
|
let heading = event.phase.as_ref().map_or_else(
|
|
|| "assistant".to_string(),
|
|
|phase| format!("assistant {phase:?}").to_lowercase(),
|
|
);
|
|
push_message(out, index, &heading, &event.message);
|
|
true
|
|
}
|
|
EventMsg::McpToolCallBegin(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
&format!(
|
|
"tool_call mcp.{}.{}",
|
|
event.invocation.server, event.invocation.tool
|
|
),
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"arguments": event.invocation.arguments,
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::McpToolCallEnd(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
&format!(
|
|
"tool_result mcp.{}.{}",
|
|
event.invocation.server, event.invocation.tool
|
|
),
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"arguments": event.invocation.arguments,
|
|
"success": event.is_success(),
|
|
"result": event.result,
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::WebSearchBegin(event) => {
|
|
push_json(out, index, "tool_call web_search", event);
|
|
true
|
|
}
|
|
EventMsg::WebSearchEnd(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
"tool_result web_search",
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"query": event.query,
|
|
"action": event.action,
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::ImageGenerationBegin(event) => {
|
|
push_json(out, index, "tool_call image_generation", event);
|
|
true
|
|
}
|
|
EventMsg::ImageGenerationEnd(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
"tool_result image_generation",
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"status": event.status,
|
|
"revised_prompt": event.revised_prompt,
|
|
"result": event.result,
|
|
"saved_path": event.saved_path.as_ref().map(|path| path.display().to_string()),
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::ExecCommandBegin(event) => {
|
|
let mut text = String::new();
|
|
text.push_str(&format!("call_id: {}\n", event.call_id));
|
|
text.push_str(&format!("cwd: {}\n", event.cwd.display()));
|
|
text.push_str(&format!(
|
|
"command: {}\n",
|
|
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
|
));
|
|
if let Some(input) = event.interaction_input.as_deref() {
|
|
text.push_str(&format!("interaction_input: {input}\n"));
|
|
}
|
|
push_fenced(out, index, "tool_call exec_command", "text", &text);
|
|
true
|
|
}
|
|
EventMsg::ExecCommandEnd(event) => {
|
|
let mut text = String::new();
|
|
text.push_str(&format!("call_id: {}\n", event.call_id));
|
|
text.push_str(&format!("status: {:?}\n", event.status));
|
|
text.push_str(&format!("exit_code: {}\n", event.exit_code));
|
|
text.push_str(&format!("cwd: {}\n", event.cwd.display()));
|
|
text.push_str(&format!(
|
|
"command: {}\n",
|
|
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
|
));
|
|
let output = exec_output(event);
|
|
if !output.is_empty() {
|
|
text.push_str("\noutput:\n");
|
|
text.push_str(&output);
|
|
if !output.ends_with('\n') {
|
|
text.push('\n');
|
|
}
|
|
}
|
|
push_fenced(out, index, "tool_result exec_command", "text", &text);
|
|
true
|
|
}
|
|
EventMsg::ViewImageToolCall(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
"tool_call view_image",
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"path": event.path.display().to_string(),
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::DynamicToolCallRequest(event) => {
|
|
if is_reflections_storage_tool(&event.tool) {
|
|
push_json(
|
|
out,
|
|
index,
|
|
&format!("tool_call {}", event.tool),
|
|
&reflections_storage_tool_call_metadata(
|
|
&event.tool,
|
|
&event.call_id,
|
|
&event.arguments,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
push_json(
|
|
out,
|
|
index,
|
|
&format!("tool_call {}", event.tool),
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"arguments": event.arguments,
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::DynamicToolCallResponse(event) => {
|
|
if is_reflections_storage_tool(&event.tool) {
|
|
push_json(
|
|
out,
|
|
index,
|
|
&format!("tool_result {}", event.tool),
|
|
&reflections_storage_tool_response_metadata(
|
|
&event.tool,
|
|
&event.call_id,
|
|
event.success,
|
|
event.error.as_deref(),
|
|
&event.content_items,
|
|
&event.arguments,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
let text = format!(
|
|
"call_id: {}\nsuccess: {}\nerror: {}\n\n{}",
|
|
event.call_id,
|
|
event.success,
|
|
event.error.as_deref().unwrap_or(""),
|
|
dynamic_tool_output_items_to_text(&event.content_items)
|
|
);
|
|
push_fenced(
|
|
out,
|
|
index,
|
|
&format!("tool_result {}", event.tool),
|
|
"text",
|
|
&text,
|
|
);
|
|
true
|
|
}
|
|
EventMsg::PatchApplyBegin(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
"tool_call apply_patch",
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"auto_approved": event.auto_approved,
|
|
"changes": event.changes,
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::PatchApplyEnd(event) => {
|
|
push_json(
|
|
out,
|
|
index,
|
|
"tool_result apply_patch",
|
|
&json!({
|
|
"call_id": event.call_id,
|
|
"success": event.success,
|
|
"status": event.status,
|
|
"stdout": event.stdout,
|
|
"stderr": event.stderr,
|
|
"changes": event.changes,
|
|
}),
|
|
);
|
|
true
|
|
}
|
|
EventMsg::Error(_)
|
|
| EventMsg::Warning(_)
|
|
| EventMsg::RealtimeConversationStarted(_)
|
|
| EventMsg::RealtimeConversationRealtime(_)
|
|
| EventMsg::RealtimeConversationClosed(_)
|
|
| EventMsg::RealtimeConversationSdp(_)
|
|
| EventMsg::ModelReroute(_)
|
|
| EventMsg::ContextCompacted(_)
|
|
| EventMsg::ThreadRolledBack(_)
|
|
| EventMsg::TurnStarted(_)
|
|
| EventMsg::TurnComplete(_)
|
|
| EventMsg::TokenCount(_)
|
|
| EventMsg::AgentMessageDelta(_)
|
|
| EventMsg::AgentReasoning(_)
|
|
| EventMsg::AgentReasoningDelta(_)
|
|
| EventMsg::AgentReasoningRawContent(_)
|
|
| EventMsg::AgentReasoningRawContentDelta(_)
|
|
| EventMsg::AgentReasoningSectionBreak(_)
|
|
| EventMsg::SessionConfigured(_)
|
|
| EventMsg::ThreadNameUpdated(_)
|
|
| EventMsg::McpStartupUpdate(_)
|
|
| EventMsg::McpStartupComplete(_)
|
|
| EventMsg::ExecCommandOutputDelta(_)
|
|
| EventMsg::TerminalInteraction(_)
|
|
| EventMsg::ExecApprovalRequest(_)
|
|
| EventMsg::RequestPermissions(_)
|
|
| EventMsg::RequestUserInput(_)
|
|
| EventMsg::ElicitationRequest(_)
|
|
| EventMsg::ApplyPatchApprovalRequest(_)
|
|
| EventMsg::GuardianAssessment(_)
|
|
| EventMsg::DeprecationNotice(_)
|
|
| EventMsg::BackgroundEvent(_)
|
|
| EventMsg::UndoStarted(_)
|
|
| EventMsg::UndoCompleted(_)
|
|
| EventMsg::StreamError(_)
|
|
| EventMsg::TurnDiff(_)
|
|
| EventMsg::GetHistoryEntryResponse(_)
|
|
| EventMsg::McpListToolsResponse(_)
|
|
| EventMsg::ListSkillsResponse(_)
|
|
| EventMsg::RealtimeConversationListVoicesResponse(_)
|
|
| EventMsg::SkillsUpdateAvailable
|
|
| EventMsg::PlanUpdate(_)
|
|
| EventMsg::TurnAborted(_)
|
|
| EventMsg::ShutdownComplete
|
|
| EventMsg::EnteredReviewMode(_)
|
|
| EventMsg::ExitedReviewMode(_)
|
|
| EventMsg::RawResponseItem(_)
|
|
| EventMsg::ItemStarted(_)
|
|
| EventMsg::ItemCompleted(_)
|
|
| EventMsg::HookStarted(_)
|
|
| EventMsg::HookCompleted(_)
|
|
| EventMsg::AgentMessageContentDelta(_)
|
|
| EventMsg::PlanDelta(_)
|
|
| EventMsg::ReasoningContentDelta(_)
|
|
| EventMsg::ReasoningRawContentDelta(_)
|
|
| EventMsg::CollabAgentSpawnBegin(_)
|
|
| EventMsg::CollabAgentSpawnEnd(_)
|
|
| EventMsg::CollabAgentInteractionBegin(_)
|
|
| EventMsg::CollabAgentInteractionEnd(_)
|
|
| EventMsg::CollabWaitingBegin(_)
|
|
| EventMsg::CollabWaitingEnd(_)
|
|
| EventMsg::CollabCloseBegin(_)
|
|
| EventMsg::CollabCloseEnd(_)
|
|
| EventMsg::CollabResumeBegin(_)
|
|
| EventMsg::CollabResumeEnd(_) => false,
|
|
}
|
|
}
|
|
|
|
fn push_message(out: &mut String, index: usize, heading: &str, text: &str) {
|
|
push_fenced(out, index, heading, "text", text);
|
|
}
|
|
|
|
fn push_fenced(out: &mut String, index: usize, heading: &str, language: &str, text: &str) {
|
|
out.push_str(&format!("## msg-{index:06} {heading}\n\n"));
|
|
let fence = fence_for(text);
|
|
out.push_str(&format!("{fence}{language}\n"));
|
|
out.push_str(text);
|
|
if !text.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
out.push_str(&format!("{fence}\n\n"));
|
|
}
|
|
|
|
fn push_json<T: Serialize>(out: &mut String, index: usize, heading: &str, value: &T) {
|
|
let text = serde_json::to_string_pretty(value)
|
|
.unwrap_or_else(|err| format!("<failed to serialize visible event: {err}>"));
|
|
push_fenced(out, index, heading, "json", &text);
|
|
}
|
|
|
|
fn fence_for(text: &str) -> String {
|
|
let longest_run = longest_backtick_run(text);
|
|
"`".repeat(longest_run.max(2) + 1)
|
|
}
|
|
|
|
fn longest_backtick_run(text: &str) -> usize {
|
|
let mut longest = 0usize;
|
|
let mut current = 0usize;
|
|
for ch in text.chars() {
|
|
if ch == '`' {
|
|
current += 1;
|
|
longest = longest.max(current);
|
|
} else {
|
|
current = 0;
|
|
}
|
|
}
|
|
longest
|
|
}
|
|
|
|
fn trigger_label(trigger: CompactionTrigger) -> &'static str {
|
|
match trigger {
|
|
CompactionTrigger::Manual => "manual_compact",
|
|
CompactionTrigger::Auto => "auto_compact",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::TranscriptInput;
|
|
use super::events_since_last_compaction;
|
|
use super::render;
|
|
use codex_analytics::CompactionTrigger;
|
|
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
|
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ReasoningItemContent;
|
|
use codex_protocol::models::ReasoningItemReasoningSummary;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::AgentMessageEvent;
|
|
use codex_protocol::protocol::AgentReasoningEvent;
|
|
use codex_protocol::protocol::CompactedItem;
|
|
use codex_protocol::protocol::DynamicToolCallResponseEvent;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::RolloutItem;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn transcript_renders_visible_events_only() {
|
|
let items = vec![
|
|
RolloutItem::ResponseItem(ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "AGENTS.md instructions should be omitted".to_string(),
|
|
}],
|
|
end_turn: None,
|
|
phase: None,
|
|
}),
|
|
RolloutItem::ResponseItem(ResponseItem::Reasoning {
|
|
id: "rs_1".to_string(),
|
|
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
|
text: "checked tests".to_string(),
|
|
}],
|
|
content: Some(vec![ReasoningItemContent::ReasoningText {
|
|
text: "hidden chain of thought".to_string(),
|
|
}]),
|
|
encrypted_content: None,
|
|
}),
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: "please test".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
})),
|
|
RolloutItem::EventMsg(EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
|
|
call_id: "call-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
tool: "lookup_ticket".to_string(),
|
|
arguments: serde_json::json!({"id": "T-1"}),
|
|
})),
|
|
RolloutItem::EventMsg(EventMsg::DynamicToolCallResponse(
|
|
DynamicToolCallResponseEvent {
|
|
call_id: "call-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
tool: "lookup_ticket".to_string(),
|
|
arguments: serde_json::json!({"id": "T-1"}),
|
|
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
|
text: "ok".to_string(),
|
|
}],
|
|
success: true,
|
|
error: None,
|
|
duration: Duration::from_millis(10),
|
|
},
|
|
)),
|
|
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: "done".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
})),
|
|
RolloutItem::EventMsg(EventMsg::AgentReasoning(AgentReasoningEvent {
|
|
text: "reasoning summary should be omitted".to_string(),
|
|
})),
|
|
];
|
|
|
|
let events = events_since_last_compaction(&items);
|
|
let transcript = render(TranscriptInput {
|
|
events: &events,
|
|
trigger: CompactionTrigger::Manual,
|
|
context_window_size: Some(98304),
|
|
rollout_path: Path::new("/tmp/rollout.jsonl"),
|
|
});
|
|
|
|
assert!(transcript.contains("## msg-000001 user"));
|
|
assert!(transcript.contains("## msg-000002 tool_call lookup_ticket"));
|
|
assert!(transcript.contains("## msg-000003 tool_result lookup_ticket"));
|
|
assert!(transcript.contains("## msg-000004 assistant"));
|
|
assert!(transcript.contains("done"));
|
|
assert!(!transcript.contains("AGENTS.md instructions should be omitted"));
|
|
assert!(!transcript.contains("checked tests"));
|
|
assert!(!transcript.contains("hidden chain of thought"));
|
|
assert!(!transcript.contains("reasoning summary should be omitted"));
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_uses_events_after_last_compaction() {
|
|
let items = vec![
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: "old window".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
})),
|
|
RolloutItem::Compacted(CompactedItem {
|
|
message: "handoff".to_string(),
|
|
replacement_history: None,
|
|
}),
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: "current window".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
})),
|
|
];
|
|
|
|
let events = events_since_last_compaction(&items);
|
|
let transcript = render(TranscriptInput {
|
|
events: &events,
|
|
trigger: CompactionTrigger::Auto,
|
|
context_window_size: None,
|
|
rollout_path: Path::new("/tmp/rollout.jsonl"),
|
|
});
|
|
|
|
assert!(transcript.contains("current window"));
|
|
assert!(!transcript.contains("old window"));
|
|
}
|
|
}
|