Evicted MultiAgentV2 subagent resumes with parent model and reasoning effort

Open 💬 4 comments Opened Jul 22, 2026 by robertmsale

What version of Codex CLI is running?

codex-cli 0.145.0-alpha.30

What subscription do you have?

ChatGPT Pro 20x

Which model were you using?

Root: gpt-5.6-(sol|terra|luna)

What platform is your computer?

Darwin 25.3.0 arm64 arm

What terminal emulator and version are you using (if applicable)?

Ghostty

Codex doctor report

not available

What issue are you seeing?

A MultiAgentV2 subagent that was spawned with an explicit model and reasoning effort loses those settings after it is evicted from in-memory residency and later reused with followup_task.

Example:

  • Root: gpt-5.6-sol, medium reasoning
  • Child: gpt-5.6-terra, high reasoning
  • First child turn: Terra/high as requested
  • After residency eviction and followup_task: the same child thread is reconstructed as Sol/medium

The child rollout and thread-store record still contain the correct persisted model, provider, and reasoning effort. The loss occurs during reconstruction: AgentControl::ensure_v2_agent_loaded receives the caller/root Config and uses it to reload the child without restoring the child's persisted model selection.

This makes reusable heterogeneous subagents silently become as expensive as their orchestrator after eviction. The task identity and history survive; only the execution model changes.

What steps can reproduce the bug?

  1. Enable features.multi_agent_v2.
  2. Start a root task using one model/reasoning combination, such as gpt-5.6-sol + medium.
  3. Spawn a child with fork_turns: "none", model: "gpt-5.6-terra", and reasoning_effort: "high".
  4. Let the child complete. Confirm its thread metadata/UI reports Terra/high.
  5. Spawn enough additional idle/completed V2 children to exceed the residency limit, causing the original child to be unloaded.
  6. Call followup_task for the original child.
  7. Inspect that child's applied thread settings or the Desktop model/reasoning selector.

Actual result: the original child resumes using the root's Sol/medium settings.

A deterministic core regression test can reproduce this without UI:

  • spawn a child with Terra/high
  • persist its ThreadSettingsApplied metadata
  • shut down/unload the child
  • call ensure_v2_agent_loaded using the root config
  • assert the reloaded child's config snapshot

On the unpatched alpha.30 source, the final assertion observes the root selection.

What is the expected behavior?

Reload a V2 child using the model, model provider, and reasoning effort persisted for that child thread. Runtime policy/environment may still come from the current caller, but the child's execution-model identity should not silently change.

If persisted metadata is missing (older rollouts), falling back to the caller selection is reasonable.

Additional information

I searched for existing reports. Issues such as #15177 and #20077 cover spawn-time override/fork behavior, but this is a separate post-spawn residency-reload path.

Source diagnosis: codex-rs/core/src/agent/control/spawn.rs, AgentControl::ensure_v2_agent_loaded.

I verified a minimal local fix against the alpha.30 source: before reserving/reloading the child, restore config.model, config.model_provider_id, config.model_provider, and config.model_reasoning_effort from read_stored_thread. A focused regression test passes, and an end-to-end forced-eviction test retained Terra/high across the follow-up.

Since PRs are not allowed, here is the exact patch needed to resolve the bug, complete with tests:

From 8e87ad1c0b8cc133ee1ea84c02a703906c6f19cc Mon Sep 17 00:00:00 2001
From: Robert Sale <minute.clubs_0q@icloud.com>
Date: Wed, 22 Jul 2026 11:13:13 -0700
Subject: [PATCH] fix(core): preserve subagent model across residency reload

---
 codex-rs/core/src/agent/control/spawn.rs | 28 +++++++++++
 codex-rs/core/src/agent/control_tests.rs | 60 ++++++++++++++++++++----
 2 files changed, 80 insertions(+), 8 deletions(-)

diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs
index bfbd506520..4bbb10b522 100644
--- a/codex-rs/core/src/agent/control/spawn.rs
+++ b/codex-rs/core/src/agent/control/spawn.rs
@@ -266,6 +266,34 @@ impl AgentControl {
                 include_history: false,
             })
             .await?;
+        // Resume the child with its persisted model selection rather than the
+        // caller's (usually root thread's) selection. Runtime policy and
+        // environment settings still come from the caller below, but the
+        // model/provider metadata belongs to the thread being reloaded. Keep
+        // the caller's complete selection when old metadata lacks a model so
+        // we do not pair an inherited model with an unrelated provider.
+        let mut config = config;
+        if let Some(model) = stored_thread.model.as_ref() {
+            if stored_thread.model_provider.is_empty() {
+                warn!(
+                    thread_id = %thread_id,
+                    model,
+                    "stored V2 agent model has no provider metadata; retaining caller selection"
+                );
+            } else {
+                let provider_id = &stored_thread.model_provider;
+                let provider = config.model_providers.get(provider_id).ok_or_else(|| {
+                    CodexErr::InvalidRequest(format!(
+                        "model provider `{provider_id}` persisted for thread {thread_id} is not configured"
+                    ))
+                })?;
+                config.model = Some(model.clone());
+                config.model_provider_id = provider_id.clone();
+                config.model_provider = provider.clone();
+                config.model_reasoning_effort = stored_thread.reasoning_effort.clone();
+            }
+        }
+
         let stored_source = stored_thread.source.clone();
         let stored_parent_thread_id = stored_thread.parent_thread_id;
         let history = load_agent_model_context(&state, thread_id, stored_thread.history_mode)
diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs
index 97016c0bd4..2703281031 100644
--- a/codex-rs/core/src/agent/control_tests.rs
+++ b/codex-rs/core/src/agent/control_tests.rs
@@ -32,6 +32,7 @@ use codex_protocol::models::ContentItem;
 use codex_protocol::models::MessagePhase;
 use codex_protocol::models::PermissionProfile;
 use codex_protocol::models::ResponseItem;
+use codex_protocol::openai_models::ReasoningEffort;
 use codex_protocol::protocol::AskForApproval;
 use codex_protocol::protocol::CompactedItem;
 use codex_protocol::protocol::ErrorEvent;
@@ -645,10 +646,13 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
     let harness = AgentControlHarness::new_with_config(home, config).await;
     let (parent_thread_id, _parent_thread) = harness.start_paginated_thread().await;
     let agent_path = AgentPath::try_from("/root/worker").expect("agent path");
+    let mut child_config = harness.config.clone();
+    child_config.model = Some("gpt-5.6-terra".to_string());
+    child_config.model_reasoning_effort = Some(ReasoningEffort::High);
     let spawned_agent = harness
         .control
         .spawn_agent_with_metadata(
-            harness.config.clone(),
+            child_config,
             text_input("hello child"),
             Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
                 parent_thread_id,
@@ -676,17 +680,51 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
         )])
         .await
         .expect("child rollout should persist with v2 metadata");
+    let child_snapshot = child_thread.config_snapshot().await;
+    child_thread
+        .session
+        .persist_rollout_items(&[RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
+            ThreadSettingsAppliedEvent {
+                thread_settings: ThreadSettingsSnapshot {
+                    model: child_snapshot.model.clone(),
+                    model_provider_id: child_snapshot.model_provider_id.clone(),
+                    service_tier: child_snapshot.service_tier.clone(),
+                    approval_policy: child_snapshot.approval_policy,
+                    approvals_reviewer: child_snapshot.approvals_reviewer,
+                    permission_profile: child_snapshot.permission_profile.clone(),
+                    active_permission_profile: child_snapshot.active_permission_profile.clone(),
+                    cwd: harness.config.cwd.clone(),
+                    reasoning_effort: child_snapshot.reasoning_effort.clone(),
+                    reasoning_summary: child_snapshot.reasoning_summary,
+                    personality: child_snapshot.personality,
+                    collaboration_mode: child_snapshot.collaboration_mode,
+                },
+            },
+        ))])
+        .await;
     child_thread
         .shutdown_and_wait()
         .await
         .expect("child thread should shut down");
-    let stored_child = child_thread
-        .read_thread(
-            /*include_archived*/ true, /*include_history*/ false,
-        )
-        .await
-        .expect("child metadata should be readable");
+    let stored_child = timeout(Duration::from_secs(10), async {
+        loop {
+            let stored_child = child_thread
+                .read_thread(
+                    /*include_archived*/ true, /*include_history*/ false,
+                )
+                .await
+                .expect("child metadata should be readable");
+            if stored_child.model.as_deref() == Some("gpt-5.6-terra") {
+                break stored_child;
+            }
+            sleep(Duration::from_millis(10)).await;
+        }
+    })
+    .await
+    .expect("child model metadata should be persisted");
     assert_eq!(stored_child.history_mode, ThreadHistoryMode::Paginated);
+    assert_eq!(stored_child.model.as_deref(), Some("gpt-5.6-terra"));
+    assert_eq!(stored_child.reasoning_effort, Some(ReasoningEffort::High));
 
     assert!(
         harness
@@ -706,11 +744,17 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
         .ensure_v2_agent_loaded(harness.config.clone(), spawned_agent.thread_id)
         .await
         .expect("known v2 agent should reload");
-    let _ = harness
+    let reloaded_child = harness
         .manager
         .get_thread(spawned_agent.thread_id)
         .await
         .expect("reloaded child thread should exist");
+    let reloaded_snapshot = reloaded_child.config_snapshot().await;
+    assert_eq!(reloaded_snapshot.model, "gpt-5.6-terra");
+    assert_eq!(
+        reloaded_snapshot.reasoning_effort,
+        Some(ReasoningEffort::High)
+    );
 
     let communication = InterAgentCommunication::new(
         AgentPath::root(),
-- 
2.50.1 (Apple Git-155)

View original on GitHub ↗

4 Comments

robertmsale · 1 month ago

Since desktop now uses v0.146.0-alpha.3, part of that new version includes a change to /codex-rs/core/src/agent/control/spawn.rs where it uses the subagent's role to reconstruct config. This includes model/reasoning if the role has those set otherwise it defaults to the parent model/reasoning. So the problem was half fixed. If explicit model/reasoning are set in the metadata, it should use that. Why add those parameters to the spawn_agent input schema if we're going to discard them later? I think it's impractical to maintain a separate role per model/reasoning combo that share the exact same instructions.

I'm not gonna share another patch, but I really hope this get's taken seriously. I feel like it's not too much to ask the order of precedence for evict -> resume should be.

  1. Manually set model/reasoning by agent in spawn_agent
  2. Role model/reasoning (that was a good one and I appreciate it)
  3. Parent

Thank you for your consideration.

s1lverkin · 1 month ago

Can anyone look at this? It's burning our tokens for no reason.

yevon · 1 month ago

I'm burning tons of tokens due to this kind of bugs in the app, the effort selector doesn't even work for the first chat it doesn't let you to select the light effort, this is crazy the low level of validation of the app

aki-0421 · 28 days ago

I’d like to add my support for this issue.

I was using GPT-5.6 Sol (Max) to orchestrate the work, but I noticed that tasks were consuming substantially more time and context than expected. After analyzing the logs, I identified this issue 😩

Given the significant usability impact and additional cost, I would be grateful for a fix—thank you for looking into it.