mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## Why Unchanged scheduled heartbeat instructions should not become fresh authorization evidence that supersedes a later human override or floods Guardian review context. ## What changed - Capture heartbeat provenance per input, including when steering an active turn, and persist it in history and retained context. - Coalesce unchanged instructions for each automation at their original acceptance order. Preserve changed instruction versions and ordinary human messages. - Represent repeated heartbeats as references to earlier instructions in Guardian transcripts, while tracking the latest input turn separately for trusted skill selection. - Limit coalescing to recognized, bounded heartbeat envelopes with host-provided provenance. ## Testing Add regression coverage for human overrides across repeated and steered heartbeats, instruction changes, checkpoint restoration, transcript references, envelope parsing, and latest-turn trusted skills in delegated reviews. GitOrigin-RevId: 31e444ddb685a1a618be26d3cc32fb562296946b
359 lines
14 KiB
Rust
359 lines
14 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();
|
|
let mut heartbeat_versions = HashMap::new();
|
|
|
|
for item in history.items() {
|
|
let (kind, mut 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();
|
|
if let Some(heartbeat) = codex_history::Heartbeat::from_message(item) {
|
|
match heartbeat_versions.get(heartbeat.automation_id) {
|
|
Some((instructions, number)) if *instructions == heartbeat.instructions => {
|
|
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 => {
|
|
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;
|