mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
## What changed - Add `disabled_plugin_ids` to thread startup options, settings overrides, snapshots, and persisted turn contexts. Omitted overrides preserve the current list; an empty list clears it. - Restore the selection on resume and capture it before truncating history for a fork. Prefer the owning thread's latest settings snapshot, falling back only to the latest turn context so stale history cannot resurrect an older selection. - Freeze the selection at turn admission and retain compatibility with histories that lack the new fields. ## Testing Add coverage for owned settings, explicit clears, latest-turn fallback, and legacy snapshots. Extend settings tests to cover plugin selection during runtime restoration and compaction, and verify initial turn persistence without extra settings checkpoints in both history modes. GitOrigin-RevId: 75ffbff5763cd93cc5ac69d73b08b237f59bc009
518 lines
17 KiB
Rust
518 lines
17 KiB
Rust
//! Model-history and persisted-rollout domain types.
|
|
|
|
use std::borrow::Borrow;
|
|
use std::ops::Deref;
|
|
use std::ops::DerefMut;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
|
use codex_protocol::mcp::McpResourceOriginCheckpoint;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::InterAgentCommunication;
|
|
use codex_protocol::protocol::MultiAgentVersion;
|
|
use codex_protocol::protocol::SessionMeta;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::ThreadHistoryMode;
|
|
use codex_protocol::protocol::ThreadSource;
|
|
use codex_protocol::protocol::TokenUsageRecord;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::WorldStateItem;
|
|
use codex_protocol::realtime::RealtimeItem;
|
|
use codex_protocol::security_risk::SecurityRiskScore;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Deserializer;
|
|
use serde::Serialize;
|
|
use serde::Serializer;
|
|
use serde::de::Error as _;
|
|
|
|
/// A model-history item with room for history-only metadata.
|
|
///
|
|
/// Persistence keeps the response item intact and stores its metadata separately.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ResponseItemEnvelope {
|
|
pub item: ResponseItem,
|
|
pub metadata: Option<CodexHarnessMetadata>,
|
|
}
|
|
|
|
/// Metadata owned by the Codex harness and persisted with a response item.
|
|
///
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
|
pub struct CodexHarnessMetadata {
|
|
/// Whether a developer message was supplied by an app-server client.
|
|
#[serde(default)]
|
|
pub client_authored: bool,
|
|
|
|
/// The originating history budget, including any tool-specific allowance.
|
|
/// Measured in tokens and reused when replaying persisted history.
|
|
#[serde(
|
|
default,
|
|
rename = "fallback_token_limit_override",
|
|
skip_serializing_if = "Option::is_none"
|
|
)]
|
|
pub history_truncation_token_limit: Option<usize>,
|
|
|
|
/// Whether a response configuration update was created by the Codex harness itself.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub harness_authored_configuration: bool,
|
|
|
|
/// Producer compatibility for an opaque compaction item, never the currently selected model.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub compaction_model_hash: Option<String>,
|
|
|
|
/// Thread acceptance order, independent of when queued user input reaches model history.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub user_input_order: Option<u64>,
|
|
|
|
/// Copied parent context stays model-visible but must not become child-local authorization.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub inherited_user_message: bool,
|
|
}
|
|
|
|
impl ResponseItemEnvelope {
|
|
/// Wraps a raw Responses API item for persisted history.
|
|
pub fn new(item: ResponseItem) -> Self {
|
|
Self {
|
|
item,
|
|
metadata: None,
|
|
}
|
|
}
|
|
|
|
/// Unwraps the raw Responses API item.
|
|
pub fn into_item(self) -> ResponseItem {
|
|
self.item
|
|
}
|
|
}
|
|
|
|
impl From<ResponseItem> for ResponseItemEnvelope {
|
|
fn from(item: ResponseItem) -> Self {
|
|
Self::new(item)
|
|
}
|
|
}
|
|
|
|
impl Deref for ResponseItemEnvelope {
|
|
type Target = ResponseItem;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.item
|
|
}
|
|
}
|
|
|
|
impl DerefMut for ResponseItemEnvelope {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.item
|
|
}
|
|
}
|
|
|
|
impl Borrow<ResponseItem> for ResponseItemEnvelope {
|
|
fn borrow(&self) -> &ResponseItem {
|
|
&self.item
|
|
}
|
|
}
|
|
|
|
/// Persisted rollout item used by core history and rollout storage.
|
|
#[derive(Debug, Clone)]
|
|
pub enum RolloutItem {
|
|
SessionMeta(SessionMetaLine),
|
|
ResponseItem(ResponseItemEnvelope),
|
|
InterAgentCommunication(InterAgentCommunication),
|
|
InterAgentCommunicationMetadata {
|
|
trigger_turn: bool,
|
|
},
|
|
Compacted(CompactedItem),
|
|
TurnContext(TurnContextItem),
|
|
TokenUsageRecord(TokenUsageRecord),
|
|
WorldState(WorldStateItem),
|
|
SecurityRiskScore(SecurityRiskScore),
|
|
RetainedContext(RetainedContextEvent),
|
|
EventMsg(EventMsg),
|
|
/// Sparse, model-invisible facts used to reconstruct realtime presentation.
|
|
RealtimeItem(RealtimeItem),
|
|
}
|
|
|
|
impl Serialize for RolloutItem {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
rollout_payload::RolloutItemWire::from(self).serialize(serializer)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for RolloutItem {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
rollout_payload::RolloutItemWire::deserialize(deserializer).map(Into::into)
|
|
}
|
|
}
|
|
|
|
impl JsonSchema for RolloutItem {
|
|
fn schema_name() -> String {
|
|
"RolloutItem".to_string()
|
|
}
|
|
|
|
fn schema_id() -> std::borrow::Cow<'static, str> {
|
|
std::borrow::Cow::Borrowed(concat!(module_path!(), "::RolloutItem"))
|
|
}
|
|
|
|
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema {
|
|
rollout_payload::RolloutItemWire::json_schema(generator)
|
|
}
|
|
}
|
|
|
|
mod guardian_history;
|
|
mod reconciled_retained_context;
|
|
mod retained_context;
|
|
|
|
pub use reconciled_retained_context::ReconciledRetainedContext;
|
|
pub use retained_context::RetainedContext;
|
|
pub use retained_context::RetainedContextEntry;
|
|
pub use retained_context::RetainedContextEvent;
|
|
pub use retained_context::RetainedContextOrder;
|
|
pub use retained_context::RetainedInputSource;
|
|
pub use retained_context::RetainedUserMessage;
|
|
pub use retained_context::VerifiedAnswer;
|
|
pub use retained_context::VerifiedQuestionAnswer;
|
|
mod rollout_payload;
|
|
|
|
pub use guardian_history::GuardianHistoryCheckpoint;
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CompactedItem {
|
|
pub message: String,
|
|
pub replacement_history: Option<Vec<ResponseItemEnvelope>>,
|
|
pub guardian_history: Option<GuardianHistoryCheckpoint>,
|
|
pub retained_context: Option<RetainedContext>,
|
|
pub mcp_resource_origins: Option<McpResourceOriginCheckpoint>,
|
|
pub window_number: Option<u64>,
|
|
pub first_window_id: Option<String>,
|
|
pub previous_window_id: Option<String>,
|
|
pub window_id: Option<String>,
|
|
/// Responses API ID for the model-backed compaction request, when one exists.
|
|
pub compaction_response_id: Option<String>,
|
|
/// Snapshot of the latest reachable token usage record when this compaction was written.
|
|
///
|
|
/// `thread/resume` can restore token usage totals from this field without scanning arbitrarily
|
|
/// far past the compaction.
|
|
pub latest_token_usage_record: Option<TokenUsageRecord>,
|
|
}
|
|
|
|
impl Serialize for CompactedItem {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
rollout_payload::CompactedItemWire::from(self).serialize(serializer)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for CompactedItem {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
rollout_payload::CompactedItemWire::deserialize(deserializer)?
|
|
.try_into()
|
|
.map_err(D::Error::custom)
|
|
}
|
|
}
|
|
|
|
impl JsonSchema for CompactedItem {
|
|
fn schema_name() -> String {
|
|
"CompactedItem".to_string()
|
|
}
|
|
|
|
fn schema_id() -> std::borrow::Cow<'static, str> {
|
|
std::borrow::Cow::Borrowed(concat!(module_path!(), "::CompactedItem"))
|
|
}
|
|
|
|
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema {
|
|
rollout_payload::CompactedItemWire::json_schema(generator)
|
|
}
|
|
}
|
|
|
|
impl From<CompactedItem> for ResponseItem {
|
|
fn from(value: CompactedItem) -> Self {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: value.message,
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One persisted rollout JSONL record.
|
|
///
|
|
/// This intentionally does not implement Deserialize: JSONL readers must use
|
|
/// codex_rollout's canonical parser so nested decimal values survive the flattened envelope.
|
|
#[derive(Serialize, Clone, JsonSchema)]
|
|
pub struct RolloutLine {
|
|
pub timestamp: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ordinal: Option<u64>,
|
|
#[serde(flatten)]
|
|
pub item: RolloutItem,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ResumedHistory {
|
|
pub conversation_id: ThreadId,
|
|
pub history: Arc<Vec<RolloutItem>>,
|
|
pub rollout_path: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub enum InitialHistory {
|
|
New,
|
|
Cleared,
|
|
Resumed(ResumedHistory),
|
|
Forked(Vec<RolloutItem>),
|
|
}
|
|
|
|
impl InitialHistory {
|
|
pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool {
|
|
match self {
|
|
Self::New | Self::Cleared => false,
|
|
Self::Resumed(resumed) => resumed.history.iter().any(&mut predicate),
|
|
Self::Forked(items) => items.iter().any(predicate),
|
|
}
|
|
}
|
|
|
|
pub fn forked_from_id(&self) -> Option<ThreadId> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id,
|
|
_ => None,
|
|
}),
|
|
Self::Forked(items) => items.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id),
|
|
_ => None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn session_cwd(&self) -> Option<PathBuf> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => session_cwd_from_items(&resumed.history),
|
|
Self::Forked(items) => session_cwd_from_items(items),
|
|
}
|
|
}
|
|
|
|
pub fn get_rollout_items(&self) -> &[RolloutItem] {
|
|
match self {
|
|
Self::New | Self::Cleared => &[],
|
|
Self::Resumed(resumed) => &resumed.history,
|
|
Self::Forked(items) => items,
|
|
}
|
|
}
|
|
|
|
pub fn get_event_msgs(&self) -> Option<Vec<EventMsg>> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => Some(
|
|
resumed
|
|
.history
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
RolloutItem::EventMsg(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.collect(),
|
|
),
|
|
Self::Forked(items) => Some(
|
|
items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
RolloutItem::EventMsg(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.collect(),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn get_base_instructions(&self) -> Option<BaseInstructions> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(),
|
|
_ => None,
|
|
}),
|
|
Self::Forked(items) => items.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(),
|
|
_ => None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn get_dynamic_tools(&self) -> Option<Vec<DynamicToolSpec>> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(),
|
|
_ => None,
|
|
}),
|
|
Self::Forked(items) => items.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(),
|
|
_ => None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn get_selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> {
|
|
self.get_session_meta()
|
|
.map(|meta| meta.selected_capability_roots.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn get_multi_agent_version(&self) -> Option<MultiAgentVersion> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => {
|
|
multi_agent_version_from_items(&resumed.history, Some(resumed.conversation_id))
|
|
}
|
|
Self::Forked(items) => multi_agent_version_from_items(items, /*thread_id*/ None),
|
|
}
|
|
}
|
|
|
|
pub fn get_history_mode(&self, default_history_mode: ThreadHistoryMode) -> ThreadHistoryMode {
|
|
match self {
|
|
Self::New | Self::Cleared => default_history_mode,
|
|
Self::Resumed(_) | Self::Forked(_) => self
|
|
.get_session_meta()
|
|
.map(|meta| meta.history_mode)
|
|
.unwrap_or(default_history_mode),
|
|
}
|
|
}
|
|
|
|
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
|
|
let meta = self.get_resumed_session_meta()?;
|
|
Some((meta.source.clone(), meta.thread_source.clone()))
|
|
}
|
|
|
|
pub fn get_resumed_thread_source(&self) -> Option<ThreadSource> {
|
|
self.get_resumed_session_meta()
|
|
.and_then(|meta| meta.thread_source.clone())
|
|
}
|
|
|
|
pub fn get_session_originator(&self) -> Option<String> {
|
|
self.get_session_meta()
|
|
.map(|meta| meta.originator.clone())
|
|
.filter(|originator| !originator.is_empty())
|
|
}
|
|
|
|
pub fn get_resumed_parent_thread_id(&self) -> Option<ThreadId> {
|
|
self.get_resumed_session_meta()
|
|
.and_then(|meta| meta.parent_thread_id)
|
|
}
|
|
|
|
fn get_session_meta(&self) -> Option<&SessionMeta> {
|
|
match self {
|
|
Self::New | Self::Cleared => None,
|
|
Self::Resumed(resumed) => resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
|
|
_ => None,
|
|
}),
|
|
Self::Forked(items) => items.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
|
|
_ => None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn get_resumed_session_meta(&self) -> Option<&SessionMeta> {
|
|
match self {
|
|
Self::New | Self::Cleared | Self::Forked(_) => None,
|
|
Self::Resumed(resumed) => resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
|
|
_ => None,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn session_cwd_from_items(items: &[RolloutItem]) -> Option<PathBuf> {
|
|
items.iter().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.cwd.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// Returns a thread's latest plugin selection, with a turn-context fallback.
|
|
///
|
|
/// Forked history may contain ancestor snapshots, and compaction may append a
|
|
/// frozen turn context after an update. Neither can replace thread-owned settings.
|
|
/// Without an owned snapshot, only the latest turn context supplies the initial
|
|
/// selection; a missing field must not resurrect a selection from an older turn.
|
|
pub fn latest_disabled_plugin_ids(items: &[RolloutItem], thread_id: ThreadId) -> Option<&[String]> {
|
|
if let Some(ids) = items.iter().rev().find_map(|item| {
|
|
if let RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) = item
|
|
&& event.thread_id == Some(thread_id)
|
|
{
|
|
Some(event.thread_settings.disabled_plugin_ids.as_slice())
|
|
} else {
|
|
None
|
|
}
|
|
}) {
|
|
return Some(ids);
|
|
}
|
|
items
|
|
.iter()
|
|
.rev()
|
|
.find_map(|item| match item {
|
|
RolloutItem::TurnContext(context) => Some(context),
|
|
_ => None,
|
|
})
|
|
.and_then(|context| context.disabled_plugin_ids.as_deref())
|
|
}
|
|
|
|
fn multi_agent_version_from_items(
|
|
items: &[RolloutItem],
|
|
thread_id: Option<ThreadId>,
|
|
) -> Option<MultiAgentVersion> {
|
|
let session_meta_version = items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::SessionMeta(meta_line)
|
|
if thread_id.is_none_or(|thread_id| meta_line.meta.id == thread_id) =>
|
|
{
|
|
meta_line.meta.multi_agent_version
|
|
}
|
|
_ => None,
|
|
});
|
|
|
|
session_meta_version.or_else(|| {
|
|
items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_version,
|
|
RolloutItem::SessionMeta(_)
|
|
| RolloutItem::ResponseItem(_)
|
|
| RolloutItem::InterAgentCommunication(_)
|
|
| RolloutItem::InterAgentCommunicationMetadata { .. }
|
|
| RolloutItem::Compacted(_)
|
|
| RolloutItem::TokenUsageRecord(_)
|
|
| RolloutItem::WorldState(_)
|
|
| RolloutItem::RetainedContext(_)
|
|
| RolloutItem::SecurityRiskScore(_)
|
|
| RolloutItem::RealtimeItem(_)
|
|
| RolloutItem::EventMsg(_) => None,
|
|
})
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests.rs"]
|
|
mod tests;
|