From e00080cea31982f1b2d78d084217beccfe813de7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 13 Feb 2026 14:18:15 +0000 Subject: [PATCH] feat: memories config (#11731) --- codex-rs/core/config.schema.json | 45 +++++++++++++++++ codex-rs/core/src/config/mod.rs | 56 +++++++++++++++++++++ codex-rs/core/src/config/types.rs | 72 +++++++++++++++++++++++++++ codex-rs/core/src/memories/mod.rs | 12 ++--- codex-rs/core/src/memories/phase1.rs | 37 ++++++++++---- codex-rs/core/src/memories/phase2.rs | 37 ++++++++------ codex-rs/core/src/memories/start.rs | 2 +- codex-rs/core/src/memories/storage.rs | 15 ++++-- codex-rs/core/src/memories/tests.rs | 21 +++++--- 9 files changed, 253 insertions(+), 44 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b95a36a78..c712d51fa 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d5de27a40..5cfdcf044 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// 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, + /// Memories subsystem settings. + pub memories: Option, + /// User-level skill config entries keyed by SKILL.md path. pub skills: Option, @@ -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::(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(), diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 5f54c1df2..80c16281b 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -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, } +/// 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, + /// Maximum age of the threads used for memories. + pub max_rollout_age_days: Option, + /// Maximum number of rollout candidates processed per pass. + pub max_rollouts_per_startup: Option, + /// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended. + pub min_rollout_idle_hours: Option, + /// Model used for thread summarisation. + pub phase_1_model: Option, + /// Model used for memory consolidation. + pub phase_2_model: Option, +} + +/// 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, + pub phase_2_model: Option, +} + +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 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 { diff --git a/codex-rs/core/src/memories/mod.rs b/codex-rs/core/src/memories/mod.rs index 2892f1faa..22fe0fb9c 100644 --- a/codex-rs/core/src/memories/mod.rs +++ b/codex-rs/core/src/memories/mod.rs @@ -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 diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index e11d29009..a0cf88cc8 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -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) { +pub(in crate::memories) async fn run(session: &Arc, 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) { } // 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, + 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) -> Option> { +async fn claim_startup_jobs( + session: &Arc, + memories_config: &MemoriesConfig, +) -> Option> { 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) -> Option) -> Option) -> RequestContext { +async fn build_request_context(session: &Arc, 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, ) } diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index a43094a72..28048498c 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -41,6 +41,7 @@ pub(super) async fn run(session: &Arc, config: Arc) { 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, config: Arc) { }; // 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, config: Arc) { // 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) -> Option { 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) -> Vec { diff --git a/codex-rs/core/src/memories/start.rs b/codex-rs/core/src/memories/start.rs index 3ac5fae85..b93846857 100644 --- a/codex-rs/core/src/memories/start.rs +++ b/codex-rs/core/src/memories/start.rs @@ -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; }); diff --git a/codex-rs/core/src/memories/storage.rs b/codex-rs/core/src/memories/storage.rs index dd39815b3..be888d367 100644 --- a/codex-rs/core/src/memories/storage.rs +++ b/codex-rs/core/src/memories/storage.rs @@ -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::>(); 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::>(); let mut body = String::from("# Raw Memories\n\n"); diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 82e4517a5..5e0068565 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -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());