Skip to main content

agentmux_srv/migrations/
m0035_agent_owns_its_provider.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Give every agent the provider it actually runs with, before the bundle
5//! stops deciding it (`docs/specs/SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md`
6//! ยง3.3).
7//!
8//! Until now a bound bundle's non-empty `provider` won over the agent's own
9//! (`resolve_effective_provider_id`). From this release the agent's provider
10//! decides, so an agent whose two values differ would switch CLI. They differ
11//! for agents changed through `agent.define` updates, and for agents with no
12//! provider at all that `m0021` gave a `claude` bundle. For each, this copies
13//! the bundle's provider onto the agent, so every agent keeps its CLI.
14//!
15//! **The bundle rows are left as they are.** Bundles live in the store every
16//! channel shares, definitions in each channel's own, and channels migrate
17//! separately: a channel migrating later must still find the bundle's value.
18//! Kept, the values are a "suggested for" hint, and an older build, which
19//! still prefers them, runs the same CLI as this one.
20//!
21//! Same reach as `m0021`: local definitions with a `memory_id` (a definition
22//! known only from the global registry carries none, so the bundle never
23//! overrode it). Writes go through `agent_def_update`, which also updates the
24//! global registry's copy, the one reads prefer. Idempotent: a second run
25//! finds nothing to change.
26
27use std::sync::Arc;
28
29use crate::backend::storage::store::Store;
30use crate::registry::{resolve_shared_definitions_dir, DefinitionStore};
31
32use super::m0021_backfill_agent_bundles::resolve_bundle_store;
33use super::{Migration, MigrationContext, MigrationError, MigrationScope};
34
35pub struct M0035AgentOwnsItsProvider;
36
37impl Migration for M0035AgentOwnsItsProvider {
38    fn id(&self) -> &'static str { "0035_agent_owns_its_provider" }
39    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
40    fn description(&self) -> &'static str {
41        "Copy each bound bundle's provider onto its agent, where it differs"
42    }
43
44    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
45        if !ctx.channel_store_path.exists() {
46            return Ok(());
47        }
48        let mstore = Arc::new(
49            Store::open(&ctx.channel_store_path)
50                .map_err(|e| MigrationError(format!("agent_owns_its_provider: open mstore: {e}")))?,
51        );
52        // Attached so the update also reaches the registry's copy of each
53        // definition, which reads prefer over the local row.
54        if let Some(def_dir) = resolve_shared_definitions_dir() {
55            match DefinitionStore::open(def_dir) {
56                Ok(def_store) => mstore.set_def_registry(Arc::new(def_store)),
57                Err(e) => tracing::warn!(error = %e, "agent_owns_its_provider: global def registry unavailable, updating local rows only"),
58            }
59        }
60        let bundle_store = resolve_bundle_store(ctx, &mstore);
61
62        let defs = mstore
63            .agent_def_list()
64            .map_err(|e| MigrationError(format!("agent_owns_its_provider: list defs: {e}")))?;
65        for def in defs {
66            if def.memory_id.is_empty() {
67                continue;
68            }
69            let bundle_provider = match bundle_store.bundle_get(&def.memory_id) {
70                Ok(Some(b)) => b.provider,
71                Ok(None) => continue,
72                Err(e) => {
73                    tracing::warn!(agent_id = %def.id, error = %e, "agent_owns_its_provider: bundle unreadable, agent left as is");
74                    continue;
75                }
76            };
77            if bundle_provider.is_empty() || bundle_provider == def.provider {
78                continue;
79            }
80            let mut updated = def.clone();
81            updated.provider = bundle_provider.clone();
82            let applied = mstore
83                .agent_def_update(&mut updated)
84                .map_err(|e| MigrationError(format!("agent_owns_its_provider: update {}: {e}", def.id)))?;
85            if applied {
86                tracing::info!(
87                    agent_id = %def.id,
88                    from = %def.provider,
89                    to = %bundle_provider,
90                    "agent_owns_its_provider: agent now carries the provider it runs with"
91                );
92            }
93        }
94        Ok(())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::backend::storage::agents::AgentDefinition;
102    use crate::backend::storage::store::Bundle;
103    // Crate-wide lock on the home override: `up()` resolves the global
104    // registry through it (see m0021's tests for why it must be this one).
105    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_GUARD;
106
107    fn with_isolated_home<T>(f: impl FnOnce() -> T) -> T {
108        let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
109        let home_tmp = tempfile::tempdir().unwrap();
110        std::env::set_var("AGENTMUX_HOME_OVERRIDE", home_tmp.path().to_str().unwrap());
111        let result = f();
112        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
113        result
114    }
115
116    fn ctx_for(channel_path: &std::path::Path, shared_path: &std::path::Path) -> MigrationContext {
117        MigrationContext {
118            home: std::env::temp_dir(),
119            data_dir: std::env::temp_dir(),
120            shared_store_path: shared_path.to_path_buf(),
121            channel_store_path: channel_path.to_path_buf(),
122        }
123    }
124
125    fn agent(id: &str, provider: &str, memory_id: &str) -> AgentDefinition {
126        AgentDefinition {
127            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
128            id: id.to_string(),
129            slug: String::new(),
130            name: id.to_string(),
131            icon: String::new(),
132            provider: provider.to_string(),
133            description: String::new(),
134            working_directory: String::new(),
135            shell: String::new(),
136            provider_flags: String::new(),
137            auto_start: 0,
138            restart_on_crash: 0,
139            idle_timeout_minutes: 0,
140            created_at: 1,
141            agent_type: "host".to_string(),
142            environment: String::new(),
143            agent_bus_id: String::new(),
144            is_seeded: 0,
145            accounts: String::new(),
146            parent_id: String::new(),
147            branch_label: String::new(),
148            updated_at: 1,
149            user_hidden: 0,
150            container_image: String::new(),
151            container_volumes: "[]".to_string(),
152            container_name: String::new(),
153            use_ambient_login: 0,
154            auto_continue_enabled: 0,
155            model_vendor_base_url: String::new(),
156            memory_id: memory_id.to_string(),
157        }
158    }
159
160    fn bundle(id: &str, provider: &str) -> Bundle {
161        Bundle {
162            id: id.to_string(),
163            name: id.to_string(),
164            description: String::new(),
165            is_blank: false,
166            is_global: false,
167            provider: provider.to_string(),
168            model: String::new(),
169            instructions: String::new(),
170            instructions_by_provider: "{}".to_string(),
171            context_files: "[]".to_string(),
172            mcp_servers: "[]".to_string(),
173            skills: "[]".to_string(),
174            sort_order: 0,
175            created_at: 0,
176            updated_at: 0,
177            is_system: false,
178        }
179    }
180
181    #[test]
182    fn each_agent_takes_the_provider_it_runs_with_and_bundles_are_untouched() {
183        with_isolated_home(|| {
184            let dir = tempfile::tempdir().unwrap();
185            let channel = dir.path().join("objects.db");
186            let shared = dir.path().join("shared.db");
187            {
188                let mstore = Store::open(&channel).unwrap();
189                let shared_store = Store::open_shared(&shared).unwrap();
190                for (id, provider) in [("b-drift", "claude"), ("b-empty", "claude"), ("b-same", "codex"), ("b-hint-less", "")] {
191                    shared_store.bundle_upsert(&bundle(id, provider)).unwrap();
192                }
193                // Changed through agent.define after creation; ran as claude.
194                mstore.agent_def_insert(&mut agent("drifted", "codex", "b-drift")).unwrap();
195                // No provider of its own; m0021 gave its bundle claude.
196                mstore.agent_def_insert(&mut agent("empty", "", "b-empty")).unwrap();
197                mstore.agent_def_insert(&mut agent("same", "codex", "b-same")).unwrap();
198                mstore.agent_def_insert(&mut agent("hint-less", "gemini", "b-hint-less")).unwrap();
199                mstore.agent_def_insert(&mut agent("unbound", "gemini", "")).unwrap();
200            }
201
202            let ctx = ctx_for(&channel, &shared);
203            M0035AgentOwnsItsProvider.up(&ctx).unwrap();
204            // Idempotent.
205            M0035AgentOwnsItsProvider.up(&ctx).unwrap();
206
207            let mstore = Store::open(&channel).unwrap();
208            let provider = |id: &str| mstore.agent_def_get(id).unwrap().unwrap().provider;
209            assert_eq!(provider("drifted"), "claude");
210            assert_eq!(provider("empty"), "claude");
211            assert_eq!(provider("same"), "codex");
212            assert_eq!(provider("hint-less"), "gemini");
213            assert_eq!(provider("unbound"), "gemini");
214
215            let shared_store = Store::open_shared(&shared).unwrap();
216            assert_eq!(shared_store.bundle_get("b-drift").unwrap().unwrap().provider, "claude", "bundle rows are kept");
217        });
218    }
219
220    #[test]
221    fn a_missing_channel_store_is_a_no_op() {
222        let dir = tempfile::tempdir().unwrap();
223        let ctx = ctx_for(&dir.path().join("absent.db"), &dir.path().join("shared.db"));
224        M0035AgentOwnsItsProvider.up(&ctx).unwrap();
225    }
226}