feat: memories config (#11731)

This commit is contained in:
jif-oai 2026-02-13 14:18:15 +00:00 committed by GitHub
parent 36541876f4
commit e00080cea3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 253 additions and 44 deletions

View file

@ -434,6 +434,43 @@
}
]
},
"MemoriesToml": {
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
"properties": {
"max_raw_memories_for_global": {
"description": "Maximum number of recent raw memories retained for global consolidation.",
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"max_rollout_age_days": {
"description": "Maximum age of the threads used for memories.",
"format": "int64",
"type": "integer"
},
"max_rollouts_per_startup": {
"description": "Maximum number of rollout candidates processed per pass.",
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"min_rollout_idle_hours": {
"description": "Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.",
"format": "int64",
"type": "integer"
},
"phase_1_model": {
"description": "Model used for thread summarisation.",
"type": "string"
},
"phase_2_model": {
"description": "Model used for memory consolidation.",
"type": "string"
}
},
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@ -1481,6 +1518,14 @@
"description": "Definition for MCP servers that Codex can reach out to for tool calls.",
"type": "object"
},
"memories": {
"allOf": [
{
"$ref": "#/definitions/MemoriesToml"
}
],
"description": "Memories subsystem settings."
},
"model": {
"description": "Optional override of model selection.",
"type": "string"

View file

@ -7,6 +7,8 @@ use crate::config::types::History;
use crate::config::types::McpServerConfig;
use crate::config::types::McpServerDisabledReason;
use crate::config::types::McpServerTransportConfig;
use crate::config::types::MemoriesConfig;
use crate::config::types::MemoriesToml;
use crate::config::types::Notice;
use crate::config::types::NotificationMethod;
use crate::config::types::Notifications;
@ -289,6 +291,9 @@ pub struct Config {
/// Maximum number of agent threads that can be open concurrently.
pub agent_max_threads: Option<usize>,
/// Memories subsystem settings.
pub memories: MemoriesConfig,
/// Directory containing all Codex state (defaults to `~/.codex` but can be
/// overridden by the `CODEX_HOME` environment variable).
pub codex_home: PathBuf,
@ -1006,6 +1011,9 @@ pub struct ConfigToml {
/// Agent-related settings (thread limits, etc.).
pub agents: Option<AgentsToml>,
/// Memories subsystem settings.
pub memories: Option<MemoriesToml>,
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
@ -1771,6 +1779,7 @@ impl Config {
.collect(),
tool_output_token_limit: cfg.tool_output_token_limit,
agent_max_threads,
memories: cfg.memories.unwrap_or_default().into(),
codex_home,
log_dir,
config_layer_stack,
@ -1985,6 +1994,8 @@ mod tests {
use crate::config::types::FeedbackConfigToml;
use crate::config::types::HistoryPersistence;
use crate::config::types::McpServerTransportConfig;
use crate::config::types::MemoriesConfig;
use crate::config::types::MemoriesToml;
use crate::config::types::NotificationMethod;
use crate::config::types::Notifications;
use crate::config_loader::RequirementSource;
@ -2068,6 +2079,47 @@ persistence = "none"
}),
history_no_persistence_cfg.history
);
let memories = r#"
[memories]
max_raw_memories_for_global = 512
max_rollout_age_days = 42
max_rollouts_per_startup = 9
min_rollout_idle_hours = 24
phase_1_model = "gpt-5-mini"
phase_2_model = "gpt-5"
"#;
let memories_cfg =
toml::from_str::<ConfigToml>(memories).expect("TOML deserialization should succeed");
assert_eq!(
Some(MemoriesToml {
max_raw_memories_for_global: Some(512),
max_rollout_age_days: Some(42),
max_rollouts_per_startup: Some(9),
min_rollout_idle_hours: Some(24),
phase_1_model: Some("gpt-5-mini".to_string()),
phase_2_model: Some("gpt-5".to_string()),
}),
memories_cfg.memories
);
let config = Config::load_from_base_config_with_overrides(
memories_cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").path().to_path_buf(),
)
.expect("load config from memories settings");
assert_eq!(
config.memories,
MemoriesConfig {
max_raw_memories_for_global: 512,
max_rollout_age_days: 42,
max_rollouts_per_startup: 9,
min_rollout_idle_hours: 24,
phase_1_model: Some("gpt-5-mini".to_string()),
phase_2_model: Some("gpt-5".to_string()),
}
);
}
#[test]
@ -4047,6 +4099,7 @@ model_verbosity = "high"
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
memories: MemoriesConfig::default(),
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
@ -4156,6 +4209,7 @@ model_verbosity = "high"
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
memories: MemoriesConfig::default(),
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
@ -4263,6 +4317,7 @@ model_verbosity = "high"
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
memories: MemoriesConfig::default(),
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
@ -4356,6 +4411,7 @@ model_verbosity = "high"
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
memories: MemoriesConfig::default(),
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),

View file

@ -23,6 +23,10 @@ use serde::Serialize;
use serde::de::Error as SerdeError;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 8;
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 12;
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL: usize = 1_024;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
@ -353,6 +357,74 @@ pub struct FeedbackConfigToml {
pub enabled: Option<bool>,
}
/// Memories settings loaded from config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MemoriesToml {
/// Maximum number of recent raw memories retained for global consolidation.
pub max_raw_memories_for_global: Option<usize>,
/// Maximum age of the threads used for memories.
pub max_rollout_age_days: Option<i64>,
/// Maximum number of rollout candidates processed per pass.
pub max_rollouts_per_startup: Option<usize>,
/// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.
pub min_rollout_idle_hours: Option<i64>,
/// Model used for thread summarisation.
pub phase_1_model: Option<String>,
/// Model used for memory consolidation.
pub phase_2_model: Option<String>,
}
/// Effective memories settings after defaults are applied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoriesConfig {
pub max_raw_memories_for_global: usize,
pub max_rollout_age_days: i64,
pub max_rollouts_per_startup: usize,
pub min_rollout_idle_hours: i64,
pub phase_1_model: Option<String>,
pub phase_2_model: Option<String>,
}
impl Default for MemoriesConfig {
fn default() -> Self {
Self {
max_raw_memories_for_global: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS,
phase_1_model: None,
phase_2_model: None,
}
}
}
impl From<MemoriesToml> for MemoriesConfig {
fn from(toml: MemoriesToml) -> Self {
let defaults = Self::default();
Self {
max_raw_memories_for_global: toml
.max_raw_memories_for_global
.unwrap_or(defaults.max_raw_memories_for_global)
.min(4096),
max_rollout_age_days: toml
.max_rollout_age_days
.unwrap_or(defaults.max_rollout_age_days)
.clamp(0, 90),
max_rollouts_per_startup: toml
.max_rollouts_per_startup
.unwrap_or(defaults.max_rollouts_per_startup)
.min(128),
min_rollout_idle_hours: toml
.min_rollout_idle_hours
.unwrap_or(defaults.min_rollout_idle_hours)
.clamp(1, 48),
phase_1_model: toml.phase_1_model,
phase_2_model: toml.phase_2_model,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppDisabledReason {

View file

@ -25,10 +25,10 @@ mod artifacts {
/// Phase 1 (startup extraction).
mod phase_one {
/// Default model used for phase 1.
pub(super) const MODEL: &str = "gpt-5.3-codex-spark";
/// Prompt used for phase 1.
pub(super) const PROMPT: &str = include_str!("../../templates/memories/stage_one_system.md");
/// Maximum number of rollout candidates processed per startup pass.
pub(super) const MAX_ROLLOUTS_PER_STARTUP: usize = 8;
/// Concurrency cap for startup memory extraction and consolidation scheduling.
pub(super) const CONCURRENCY_LIMIT: usize = 8;
/// Fallback stage-1 rollout truncation limit (tokens) when model metadata
@ -43,10 +43,6 @@ mod phase_one {
/// Keeping this below 100% leaves room for system instructions, prompt
/// framing, and model output.
pub(super) const CONTEXT_WINDOW_PERCENT: i64 = 70;
/// Maximum rollout age considered for phase-1 extraction.
pub(super) const MAX_ROLLOUT_AGE_DAYS: i64 = 30;
/// Minimum rollout idle time required before phase-1 extraction.
pub(super) const MIN_ROLLOUT_IDLE_HOURS: i64 = 12;
/// Lease duration (seconds) for phase-1 job ownership.
pub(super) const JOB_LEASE_SECONDS: i64 = 3_600;
/// Backoff delay (seconds) before retrying a failed stage-1 extraction job.
@ -57,8 +53,8 @@ mod phase_one {
/// Phase 2 (aka `Consolidation`).
mod phase_two {
/// Maximum number of recent raw memories retained for global consolidation.
pub(super) const MAX_RAW_MEMORIES_FOR_GLOBAL: usize = 1_024;
/// Default model used for phase 2.
pub(super) const MODEL: &str = "gpt-5.3-codex";
/// Lease duration (seconds) for phase-2 consolidation job ownership.
pub(super) const JOB_LEASE_SECONDS: i64 = 3_600;
/// Backoff delay (seconds) before retrying a failed phase-2 consolidation

View file

@ -2,6 +2,8 @@ use crate::Prompt;
use crate::RolloutRecorder;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::config::types::MemoriesConfig;
use crate::error::CodexErr;
use crate::memories::metrics;
use crate::memories::phase_one;
@ -78,9 +80,9 @@ struct StageOneOutput {
/// 2) build one stage-1 request context
/// 3) run stage-1 extraction jobs in parallel
/// 4) emit metrics and logs
pub(in crate::memories) async fn run(session: &Arc<Session>) {
pub(in crate::memories) async fn run(session: &Arc<Session>, config: &Config) {
// 1. Claim startup job.
let Some(claimed_candidates) = claim_startup_jobs(session).await else {
let Some(claimed_candidates) = claim_startup_jobs(session, &config.memories).await else {
return;
};
if claimed_candidates.is_empty() {
@ -93,7 +95,7 @@ pub(in crate::memories) async fn run(session: &Arc<Session>) {
}
// 2. Build request.
let stage_one_context = build_request_context(session).await;
let stage_one_context = build_request_context(session, config).await;
// 3. Run the parallel sampling.
let outcomes = run_jobs(session, claimed_candidates, stage_one_context).await;
@ -129,18 +131,22 @@ impl RequestContext {
pub(in crate::memories) fn from_turn_context(
turn_context: &TurnContext,
turn_metadata_header: Option<String>,
model_info: ModelInfo,
) -> Self {
Self {
model_info: turn_context.model_info.clone(),
model_info,
turn_metadata_header,
otel_manager: turn_context.otel_manager.clone(),
reasoning_effort: turn_context.reasoning_effort,
reasoning_summary: turn_context.reasoning_summary,
turn_metadata_header,
}
}
}
async fn claim_startup_jobs(session: &Arc<Session>) -> Option<Vec<codex_state::Stage1JobClaim>> {
async fn claim_startup_jobs(
session: &Arc<Session>,
memories_config: &MemoriesConfig,
) -> Option<Vec<codex_state::Stage1JobClaim>> {
let Some(state_db) = session.services.state_db.as_deref() else {
// This should not happen.
warn!("state db unavailable while claiming phase-1 startup jobs; skipping");
@ -157,9 +163,9 @@ async fn claim_startup_jobs(session: &Arc<Session>) -> Option<Vec<codex_state::S
session.conversation_id,
codex_state::Stage1StartupClaimParams {
scan_limit: phase_one::THREAD_SCAN_LIMIT,
max_claimed: phase_one::MAX_ROLLOUTS_PER_STARTUP,
max_age_days: phase_one::MAX_ROLLOUT_AGE_DAYS,
min_rollout_idle_hours: phase_one::MIN_ROLLOUT_IDLE_HOURS,
max_claimed: memories_config.max_rollouts_per_startup,
max_age_days: memories_config.max_rollout_age_days,
min_rollout_idle_hours: memories_config.min_rollout_idle_hours,
allowed_sources: allowed_sources.as_slice(),
lease_seconds: phase_one::JOB_LEASE_SECONDS,
},
@ -179,11 +185,22 @@ async fn claim_startup_jobs(session: &Arc<Session>) -> Option<Vec<codex_state::S
}
}
async fn build_request_context(session: &Arc<Session>) -> RequestContext {
async fn build_request_context(session: &Arc<Session>, config: &Config) -> RequestContext {
let model_name = config
.memories
.phase_1_model
.clone()
.unwrap_or(phase_one::MODEL.to_string());
let model = session
.services
.models_manager
.get_model_info(&model_name, config)
.await;
let turn_context = session.new_default_turn().await;
RequestContext::from_turn_context(
turn_context.as_ref(),
turn_context.resolve_turn_metadata_header().await,
model,
)
}

View file

@ -41,6 +41,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
return;
};
let root = memory_root(&config.codex_home);
let max_raw_memories = config.memories.max_raw_memories_for_global;
// 1. Claim the job.
let claim = match job::claim(session, db).await {
@ -64,10 +65,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
};
// 3. Query the memories
let raw_memories = match db
.list_stage1_outputs_for_global(phase_two::MAX_RAW_MEMORIES_FOR_GLOBAL)
.await
{
let raw_memories = match db.list_stage1_outputs_for_global(max_raw_memories).await {
Ok(memories) => memories,
Err(err) => {
tracing::error!("failed to list stage1 outputs from global: {}", err);
@ -80,13 +78,17 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
// 4. Update the file system by syncing the raw memories with the one extracted from DB at
// step 3
// [`rollout_summaries/`]
if let Err(err) = sync_rollout_summaries_from_memories(&root, &raw_memories).await {
if let Err(err) =
sync_rollout_summaries_from_memories(&root, &raw_memories, max_raw_memories).await
{
tracing::error!("failed syncing local memory artifacts for global consolidation: {err}");
job::failed(session, db, &claim, "failed_sync_artifacts").await;
return;
}
// [`raw_memories.md`]
if let Err(err) = rebuild_raw_memories_file_from_memories(&root, &raw_memories).await {
if let Err(err) =
rebuild_raw_memories_file_from_memories(&root, &raw_memories, max_raw_memories).await
{
tracing::error!("failed syncing local memory artifacts for global consolidation: {err}");
job::failed(session, db, &claim, "failed_rebuild_raw_memories").await;
return;
@ -207,20 +209,19 @@ mod agent {
pub(super) fn get_config(config: Arc<Config>) -> Option<Config> {
let root = memory_root(&config.codex_home);
let mut consolidation_config = config.as_ref().clone();
let mut agent_config = config.as_ref().clone();
consolidation_config.cwd = root;
agent_config.cwd = root;
// Approval policy
consolidation_config.permissions.approval_policy =
Constrained::allow_only(AskForApproval::Never);
agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
// Sandbox policy
let mut writable_roots = Vec::new();
match AbsolutePathBuf::from_absolute_path(consolidation_config.codex_home.clone()) {
match AbsolutePathBuf::from_absolute_path(agent_config.codex_home.clone()) {
Ok(codex_home) => writable_roots.push(codex_home),
Err(err) => warn!(
"memory phase-2 consolidation could not add codex_home writable root {}: {err}",
consolidation_config.codex_home.display()
agent_config.codex_home.display()
),
}
// The consolidation agent only needs local codex_home write access and no network.
@ -231,13 +232,21 @@ mod agent {
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
consolidation_config
agent_config
.permissions
.sandbox_policy
.set(consolidation_sandbox_policy)
.ok()?;
Some(consolidation_config)
agent_config.model = Some(
config
.memories
.phase_2_model
.clone()
.unwrap_or(phase_two::MODEL.to_string()),
);
Some(agent_config)
}
pub(super) fn get_prompt(config: Arc<Config>) -> Vec<UserInput> {

View file

@ -35,7 +35,7 @@ pub(crate) fn start_memories_startup_task(
};
// Run phase 1.
phase1::run(&session).await;
phase1::run(&session, &config).await;
// Run phase 2.
phase2::run(&session, config).await;
});

View file

@ -5,7 +5,6 @@ use std::path::Path;
use tracing::warn;
use crate::memories::ensure_layout;
use crate::memories::phase_two;
use crate::memories::raw_memories_file;
use crate::memories::rollout_summaries_dir;
@ -15,21 +14,23 @@ use crate::memories::rollout_summaries_dir;
pub(super) async fn rebuild_raw_memories_file_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
) -> std::io::Result<()> {
ensure_layout(root).await?;
rebuild_raw_memories_file(root, memories).await
rebuild_raw_memories_file(root, memories, max_raw_memories_for_global).await
}
/// Syncs canonical rollout summary files from DB-backed stage-1 output rows.
pub(super) async fn sync_rollout_summaries_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
) -> std::io::Result<()> {
ensure_layout(root).await?;
let retained = memories
.iter()
.take(phase_two::MAX_RAW_MEMORIES_FOR_GLOBAL)
.take(max_raw_memories_for_global)
.collect::<Vec<_>>();
let keep = retained
.iter()
@ -62,10 +63,14 @@ pub(super) async fn sync_rollout_summaries_from_memories(
Ok(())
}
async fn rebuild_raw_memories_file(root: &Path, memories: &[Stage1Output]) -> std::io::Result<()> {
async fn rebuild_raw_memories_file(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
) -> std::io::Result<()> {
let retained = memories
.iter()
.take(phase_two::MAX_RAW_MEMORIES_FOR_GLOBAL)
.take(max_raw_memories_for_global)
.collect::<Vec<_>>();
let mut body = String::from("# Raw Memories\n\n");

View file

@ -1,5 +1,6 @@
use super::storage::rebuild_raw_memories_file_from_memories;
use super::storage::sync_rollout_summaries_from_memories;
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL;
use crate::memories::ensure_layout;
use crate::memories::memory_root;
use crate::memories::raw_memories_file;
@ -70,12 +71,20 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
generated_at: Utc.timestamp_opt(101, 0).single().expect("timestamp"),
}];
sync_rollout_summaries_from_memories(&root, &memories)
.await
.expect("sync rollout summaries");
rebuild_raw_memories_file_from_memories(&root, &memories)
.await
.expect("rebuild raw memories");
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("sync rollout summaries");
rebuild_raw_memories_file_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("rebuild raw memories");
assert!(keep_path.is_file());
assert!(!drop_path.exists());