mirror of
https://github.com/openai/codex.git
synced 2026-10-27 00:00:53 +00:00
## Why Persisted base instructions need to retain whether they were explicitly customized or generated from a model template. Without that distinction, forks and config-lock replays can treat model-generated instructions as custom and keep the wrong template after a model or personality change. ## What changed - Record custom or model provenance with base instructions in rollouts and config locks. - Preserve custom instructions across model changes, while allowing model-generated instructions to follow the selected model and personality. - Keep legacy rollouts without provenance compatible and propagate provenance through forks and spawned agents. ## Testing - Cover provenance serialization and legacy rollout decoding. - Cover role personality changes, forked model changes, config-lock replay, and multi-agent configuration. GitOrigin-RevId: 801c26559835f558027ce5112700c0cd31fcb21b
184 lines
5.8 KiB
Rust
184 lines
5.8 KiB
Rust
use std::io;
|
|
|
|
use codex_config::ConfigLayerEntry;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::config_toml::ConfigLockfileToml;
|
|
use codex_config::config_toml::ConfigToml;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
use similar::TextDiff;
|
|
|
|
pub(crate) const CONFIG_LOCK_VERSION: u32 = 1;
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub(crate) struct ConfigLockReplayOptions {
|
|
pub allow_codex_version_mismatch: bool,
|
|
}
|
|
|
|
pub(crate) async fn read_config_lock_from_path(
|
|
path: &AbsolutePathBuf,
|
|
) -> io::Result<ConfigLockfileToml> {
|
|
let contents = tokio::fs::read_to_string(path).await.map_err(|err| {
|
|
config_lock_error(format!(
|
|
"failed to read config lock file {}: {err}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
let lockfile: ConfigLockfileToml = toml::from_str(&contents).map_err(|err| {
|
|
config_lock_error(format!(
|
|
"failed to parse config lock file {}: {err}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
validate_config_lock_metadata_shape(&lockfile)?;
|
|
Ok(lockfile)
|
|
}
|
|
|
|
pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml {
|
|
ConfigLockfileToml {
|
|
version: CONFIG_LOCK_VERSION,
|
|
codex_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
base_instructions_provenance: None,
|
|
config,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn validate_config_lock_replay(
|
|
expected_lock: &ConfigLockfileToml,
|
|
actual_lock: &ConfigLockfileToml,
|
|
options: ConfigLockReplayOptions,
|
|
) -> io::Result<()> {
|
|
validate_config_lock_metadata_shape(expected_lock)?;
|
|
validate_config_lock_metadata_shape(actual_lock)?;
|
|
|
|
if !options.allow_codex_version_mismatch
|
|
&& expected_lock.codex_version != actual_lock.codex_version
|
|
{
|
|
return Err(config_lock_error(format!(
|
|
"config lock Codex version mismatch: lock was generated by {}, current version is {}; set debug.config_lockfile.allow_codex_version_mismatch=true to ignore this",
|
|
expected_lock.codex_version, actual_lock.codex_version
|
|
)));
|
|
}
|
|
|
|
let expected_lock = config_lock_for_comparison(expected_lock, options);
|
|
let mut actual_lock = config_lock_for_comparison(actual_lock, options);
|
|
if expected_lock.base_instructions_provenance.is_none() {
|
|
actual_lock.base_instructions_provenance = None;
|
|
}
|
|
if expected_lock != actual_lock {
|
|
let diff = compact_diff("config", &expected_lock, &actual_lock)
|
|
.unwrap_or_else(|err| format!("failed to build config lock diff: {err}"));
|
|
return Err(config_lock_error(format!(
|
|
"replayed effective config does not match config lock: {diff}"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn lock_layer_from_config(
|
|
lock_path: &AbsolutePathBuf,
|
|
lockfile: &ConfigLockfileToml,
|
|
) -> io::Result<ConfigLayerEntry> {
|
|
let value = toml_value(
|
|
&config_without_lock_controls(&lockfile.config),
|
|
"config lock",
|
|
)?;
|
|
Ok(ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: lock_path.clone(),
|
|
profile: None,
|
|
},
|
|
value,
|
|
))
|
|
}
|
|
|
|
pub(crate) fn config_without_lock_controls(config: &ConfigToml) -> ConfigToml {
|
|
let mut config = config.clone();
|
|
clear_config_lock_debug_controls(&mut config);
|
|
config
|
|
}
|
|
|
|
pub(crate) fn clear_config_lock_debug_controls(config: &mut ConfigToml) {
|
|
if let Some(debug) = config.debug.as_mut() {
|
|
debug.config_lockfile = None;
|
|
}
|
|
if config
|
|
.debug
|
|
.as_ref()
|
|
.is_some_and(|debug| debug.config_lockfile.is_none())
|
|
{
|
|
config.debug = None;
|
|
}
|
|
}
|
|
|
|
fn validate_config_lock_metadata_shape(lock: &ConfigLockfileToml) -> io::Result<()> {
|
|
if lock.version != CONFIG_LOCK_VERSION {
|
|
return Err(config_lock_error(format!(
|
|
"unsupported config lock version {}; expected {CONFIG_LOCK_VERSION}",
|
|
lock.version
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn config_lock_for_comparison(
|
|
lockfile: &ConfigLockfileToml,
|
|
options: ConfigLockReplayOptions,
|
|
) -> ConfigLockfileToml {
|
|
let mut lockfile = lockfile.clone();
|
|
clear_config_lock_debug_controls(&mut lockfile.config);
|
|
if let Some(features) = lockfile.config.features.as_mut() {
|
|
features.clear_removed_compatibility_entries();
|
|
}
|
|
if options.allow_codex_version_mismatch {
|
|
lockfile.codex_version.clear();
|
|
}
|
|
lockfile
|
|
}
|
|
|
|
fn config_lock_error(message: impl Into<String>) -> io::Error {
|
|
io::Error::other(message.into())
|
|
}
|
|
|
|
fn compact_diff<T: Serialize>(root: &str, expected: &T, actual: &T) -> io::Result<String> {
|
|
let expected = toml::to_string_pretty(expected).map_err(|err| {
|
|
config_lock_error(format!(
|
|
"failed to serialize expected {root} lock TOML: {err}"
|
|
))
|
|
})?;
|
|
let actual = toml::to_string_pretty(actual).map_err(|err| {
|
|
config_lock_error(format!(
|
|
"failed to serialize actual {root} lock TOML: {err}"
|
|
))
|
|
})?;
|
|
Ok(TextDiff::from_lines(&expected, &actual)
|
|
.unified_diff()
|
|
.context_radius(2)
|
|
.header("expected", "actual")
|
|
.to_string())
|
|
}
|
|
|
|
fn toml_value<T: Serialize>(value: &T, label: &str) -> io::Result<toml::Value> {
|
|
toml::Value::try_from(value)
|
|
.map_err(|err| config_lock_error(format!("failed to serialize {label}: {err}")))
|
|
}
|
|
|
|
pub(crate) fn toml_round_trip<T>(value: &impl Serialize, label: &'static str) -> io::Result<T>
|
|
where
|
|
T: DeserializeOwned + Serialize,
|
|
{
|
|
let value = toml_value(value, label)?;
|
|
let toml = value.clone().try_into().map_err(|err| {
|
|
config_lock_error(format!("failed to convert {label} to TOML shape: {err}"))
|
|
})?;
|
|
let represented_value = toml_value(&toml, label)?;
|
|
if represented_value != value {
|
|
return Err(config_lock_error(format!(
|
|
"resolved {label} cannot be fully represented as TOML"
|
|
)));
|
|
}
|
|
Ok(toml)
|
|
}
|