Skip to main content

vtcode_core/subagents/
model.rs

1use anyhow::{Context, Result};
2use vtcode_config::SubagentSpec;
3use vtcode_config::core::{CustomProviderConfig, ProviderOverrideConfig};
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9use crate::config::VTCodeConfig;
10use crate::config::constants::models;
11use crate::config::models::{ModelId, Provider};
12use crate::core::agent::types::AgentType;
13use crate::llm::auto_lightweight_model;
14use crate::llm::factory::{infer_provider, infer_provider_from_model};
15use crate::prompts::resource_cache::{ResourceCache, canonical_cache_path, fingerprint_files};
16use vtcode_commons::VtCodePaths;
17
18// ─── Model Resolution ───────────────────────────────────────────────────────
19
20/// Resolves the model for a subagent given an optional request override.
21///
22/// When `requested` is `None`, `"inherit"`, or empty, the parent model is inherited.
23/// Special aliases like `"small"`, `"haiku"`, `"sonnet"`, and `"opus"` are mapped
24/// to concrete model identifiers based on the active provider.
25pub fn resolve_subagent_model(
26    vt_cfg: &VTCodeConfig,
27    parent_model: &str,
28    parent_provider: &str,
29    requested: Option<&str>,
30    agent_name: &str,
31) -> Result<ModelId> {
32    let requested = requested.unwrap_or("inherit").trim();
33    if requested.eq_ignore_ascii_case("inherit") || requested.is_empty() {
34        return resolve_inherit_model(vt_cfg, parent_model, parent_provider, agent_name);
35    }
36    resolve_explicit_model(vt_cfg, parent_provider, parent_model, requested, agent_name)
37}
38
39fn resolve_inherit_model(
40    vt_cfg: &VTCodeConfig,
41    parent_model: &str,
42    parent_provider: &str,
43    agent_name: &str,
44) -> Result<ModelId> {
45    if let Ok(model) = parent_model.parse::<ModelId>() {
46        return Ok(model);
47    }
48    if parent_provider.eq_ignore_ascii_case("copilot") {
49        let fallback = ModelId::default_orchestrator_for_provider(Provider::Copilot);
50        tracing::warn!(
51            agent_name,
52            parent_model = parent_model.trim(),
53            parent_provider = parent_provider.trim(),
54            fallback_model = &*fallback.as_str(),
55            "Falling back to the default Copilot subagent model because the inherited parent model identifier is not supported internally"
56        );
57        return Ok(fallback);
58    }
59
60    finalize_subagent_model(&RuntimeModelSources::from_config(vt_cfg), parent_model, parent_provider, agent_name)
61}
62
63/// Narrow, read-only view of the config-defined runtime model sources that are
64/// not part of the built-in [`ModelId`] catalog.
65///
66/// Isolating these two fields (instead of passing the whole [`VTCodeConfig`])
67/// keeps the custom-model fallback policy independently testable and prevents
68/// the resolution logic from coupling to unrelated config surface.
69struct RuntimeModelSources<'a> {
70    provider_overrides: &'a BTreeMap<String, ProviderOverrideConfig>,
71    custom_providers: &'a [CustomProviderConfig],
72}
73
74impl<'a> RuntimeModelSources<'a> {
75    fn from_config(vt_cfg: &'a VTCodeConfig) -> Self {
76        Self {
77            provider_overrides: &vt_cfg.provider_overrides,
78            custom_providers: &vt_cfg.custom_providers,
79        }
80    }
81}
82
83/// Normalizes `raw_model` and resolves it into a [`ModelId`], attaching a
84/// consistent subagent context message on failure.
85///
86/// Shared by the inherit and explicit resolution paths so both apply the same
87/// alias normalization, custom-model fallback, and error wording.
88fn finalize_subagent_model(
89    sources: &RuntimeModelSources<'_>,
90    raw_model: &str,
91    provider_hint: &str,
92    agent_name: &str,
93) -> Result<ModelId> {
94    let normalized = normalize_subagent_model_alias(raw_model);
95    parse_model_or_custom(sources, normalized, provider_hint)
96        .with_context(|| format!("Failed to resolve model '{normalized}' for subagent {agent_name}"))
97}
98
99/// Parses `model` into a [`ModelId`], falling back to a runtime [`ModelId::Custom`]
100/// variant for user-defined providers and local providers.
101///
102/// Local providers (Ollama, LM Studio, llama.cpp), `[providers.<name>]`
103/// overrides, and `[[custom_providers]]` endpoints expose arbitrary model
104/// identifiers that are not part of the built-in catalog, so a strict
105/// `parse::<ModelId>()` would incorrectly reject valid runtime models. This
106/// helper honors those identifiers instead of failing subagent resolution.
107///
108/// Config-defined models are only honored when they belong to the active
109/// provider (`provider_hint`); this preserves the invalid-override fallback for
110/// unrelated providers and avoids mis-routing a model to the wrong endpoint.
111fn parse_model_or_custom(sources: &RuntimeModelSources<'_>, model: &str, provider_hint: &str) -> Result<ModelId> {
112    let trimmed = model.trim();
113    if let Ok(parsed) = trimmed.parse::<ModelId>() {
114        return Ok(parsed);
115    }
116
117    let provider_hint = provider_hint.trim();
118    let hinted_provider = provider_hint.parse::<Provider>().ok();
119
120    // Built-in provider overrides (`[providers.<name>]`), scoped to the active provider.
121    for (provider_key, override_cfg) in sources.provider_overrides {
122        let matches_hint = match hinted_provider {
123            Some(active) => provider_key.parse::<Provider>().ok() == Some(active),
124            None => provider_key.eq_ignore_ascii_case(provider_hint),
125        };
126        if matches_hint && override_cfg.models.iter().any(|m| m.trim() == trimmed) {
127            return Ok(ModelId::Custom(provider_key.clone(), trimmed.to_string()));
128        }
129    }
130
131    // Custom OpenAI-compatible providers (`[[custom_providers]]`), matched by key.
132    for custom in sources.custom_providers {
133        if custom.name.eq_ignore_ascii_case(provider_hint) && custom.effective_models().iter().any(|m| m == trimmed) {
134            return Ok(ModelId::Custom(custom.name.to_lowercase(), trimmed.to_string()));
135        }
136    }
137
138    // Local providers expose arbitrary runtime model identifiers that cannot be
139    // validated against the built-in catalog; honor them as custom models.
140    if let Some(provider) = hinted_provider.filter(|provider| provider.is_local()) {
141        return Ok(ModelId::Custom(provider.to_string(), trimmed.to_string()));
142    }
143
144    Ok(trimmed.parse::<ModelId>()?)
145}
146
147fn resolve_explicit_model(
148    vt_cfg: &VTCodeConfig,
149    parent_provider: &str,
150    parent_model: &str,
151    requested: &str,
152    agent_name: &str,
153) -> Result<ModelId> {
154    let resolved = if requested.eq_ignore_ascii_case("small") {
155        resolve_lightweight_model(vt_cfg, parent_provider, parent_model, agent_name)
156    } else if matches!(requested.to_ascii_lowercase().as_str(), "haiku" | "sonnet" | "opus") {
157        alias_model_for_provider(parent_provider, requested, parent_model)
158    } else {
159        requested.to_string()
160    };
161
162    finalize_subagent_model(&RuntimeModelSources::from_config(vt_cfg), resolved.as_str(), parent_provider, agent_name)
163}
164
165fn resolve_lightweight_model(
166    vt_cfg: &VTCodeConfig,
167    parent_provider: &str,
168    parent_model: &str,
169    agent_name: &str,
170) -> String {
171    if vt_cfg.agent.small_model.model.trim().is_empty() {
172        return auto_lightweight_model(parent_provider, parent_model);
173    }
174
175    let configured = vt_cfg.agent.small_model.model.trim();
176    let active_provider = infer_provider(Some(parent_provider), parent_model);
177    let configured_provider = infer_provider_from_model(configured).or_else(|| infer_provider(None, configured));
178
179    if configured_provider.is_some() && configured_provider != active_provider {
180        tracing::warn!(
181            agent_name,
182            configured_model = configured,
183            active_provider = active_provider
184                .map(|provider| provider.to_string())
185                .unwrap_or_else(|| parent_provider.to_string()),
186            "Ignoring cross-provider lightweight subagent model; using same-provider automatic route"
187        );
188        auto_lightweight_model(parent_provider, parent_model)
189    } else {
190        configured.to_string()
191    }
192}
193
194/// Resolves the effective subagent model, preferring the explicit override, then the spec model,
195/// then falling back to the parent model.
196pub fn resolve_effective_subagent_model(
197    vt_cfg: &VTCodeConfig,
198    parent_model: &str,
199    parent_provider: &str,
200    model_override: Option<&str>,
201    spec_model: Option<&str>,
202    agent_name: &str,
203) -> Result<ModelId> {
204    if let Some(requested) = model_override {
205        match resolve_subagent_model(vt_cfg, parent_model, parent_provider, Some(requested), agent_name) {
206            Ok(model) => return Ok(model),
207            Err(err) => {
208                return handle_model_override_failure(
209                    vt_cfg,
210                    parent_model,
211                    parent_provider,
212                    requested,
213                    spec_model,
214                    agent_name,
215                    err,
216                );
217            }
218        }
219    }
220
221    match resolve_subagent_model(vt_cfg, parent_model, parent_provider, spec_model, agent_name) {
222        Ok(model) => Ok(model),
223        Err(err) if spec_model.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("small")) => {
224            tracing::warn!(
225                agent_name,
226                error = %err,
227                "Failed to resolve lightweight subagent model from spec; falling back to parent model"
228            );
229            resolve_subagent_model(vt_cfg, parent_model, parent_provider, Some("inherit"), agent_name)
230        }
231        Err(err) => Err(err),
232    }
233}
234
235fn handle_model_override_failure(
236    vt_cfg: &VTCodeConfig,
237    parent_model: &str,
238    parent_provider: &str,
239    requested: &str,
240    spec_model: Option<&str>,
241    agent_name: &str,
242    err: anyhow::Error,
243) -> Result<ModelId> {
244    if requested.trim().eq_ignore_ascii_case("small") {
245        tracing::warn!(
246            agent_name,
247            requested_model = requested.trim(),
248            error = %err,
249            "Failed to bootstrap lightweight subagent model; falling back to parent model"
250        );
251        return resolve_subagent_model(vt_cfg, parent_model, parent_provider, Some("inherit"), agent_name);
252    }
253    let fallback = spec_model.map(str::trim).filter(|v| !v.is_empty()).unwrap_or("inherit");
254    tracing::warn!(
255        agent_name,
256        requested_model = requested.trim(),
257        fallback_model = fallback,
258        error = %err,
259        "Failed to resolve subagent model override; falling back"
260    );
261    let model = resolve_subagent_model(vt_cfg, parent_model, parent_provider, spec_model, agent_name)
262        .or_else(|_| resolve_subagent_model(vt_cfg, parent_model, parent_provider, Some("inherit"), agent_name))?;
263    Ok(model)
264}
265
266fn normalize_subagent_model_alias(model: &str) -> &str {
267    match model.trim() {
268        "claude-haiku-4.5" => models::anthropic::CLAUDE_SONNET_5,
269        "claude-sonnet-4.6" => models::anthropic::CLAUDE_SONNET_5,
270        "claude-opus-4.8" => models::anthropic::CLAUDE_OPUS_5,
271        other => other,
272    }
273}
274
275fn alias_model_for_provider(parent_provider: &str, alias: &str, parent_model: &str) -> String {
276    match infer_provider(Some(parent_provider), parent_model) {
277        Some(Provider::Anthropic) => match alias.to_ascii_lowercase().as_str() {
278            "haiku" => models::anthropic::CLAUDE_SONNET_5.to_string(),
279            "opus" => models::anthropic::CLAUDE_OPUS_5.to_string(),
280            _ => models::anthropic::CLAUDE_SONNET_5.to_string(),
281        },
282        Some(Provider::OpenAI) => match alias.to_ascii_lowercase().as_str() {
283            "haiku" => models::openai::GPT_5_6_LUNA.to_string(),
284            "opus" => models::openai::GPT_5_6_SOL.to_string(),
285            _ => models::openai::GPT_5_6_SOL.to_string(),
286        },
287        Some(Provider::Gemini) => match alias.to_ascii_lowercase().as_str() {
288            "haiku" => models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
289            _ => models::google::GEMINI_3_7_FLASH.to_string(),
290        },
291        _ => parent_model.to_string(),
292    }
293}
294
295/// Maps a subagent spec name to its corresponding [`AgentType`] variant.
296pub fn agent_type_for_spec(spec: &SubagentSpec) -> AgentType {
297    match spec.name.as_str() {
298        "explorer" | "explore" => AgentType::Explore,
299        "plan" => AgentType::Plan,
300        "worker" | "general" | "general-purpose" | "default" => AgentType::General,
301        _ => AgentType::Custom(spec.name.clone()),
302    }
303}
304
305// ─── Memory Appendix ────────────────────────────────────────────────────────
306
307use super::constants::{SUBAGENT_MEMORY_BYTES_LIMIT, SUBAGENT_MEMORY_HIGHLIGHT_LIMIT, SUBAGENT_MEMORY_LINE_LIMIT};
308use crate::persistent_memory::extract_memory_highlights;
309use vtcode_config::SubagentMemoryScope;
310
311/// Loads the persistent memory appendix for a subagent, including key-point highlights.
312///
313/// Returns `None` when no memory scope is configured. Creates the memory directory
314/// if it does not exist, and returns a guidance prompt when the memory file is absent.
315pub fn load_memory_appendix(
316    workspace_root: &Path,
317    agent_name: &str,
318    scope: Option<SubagentMemoryScope>,
319) -> Result<Option<String>> {
320    let Some(scope) = scope else {
321        return Ok(None);
322    };
323
324    let memory_dir = agent_memory_dir(workspace_root, agent_name, scope)?;
325    VtCodePaths::ensure_user_dir(&memory_dir)
326        .with_context(|| format!("Failed to create subagent memory directory {}", memory_dir.display()))?;
327    let memory_file = memory_dir.join("MEMORY.md");
328    if !memory_file.exists() {
329        return Ok(Some(format!(
330            "Persistent memory file: {}. Create or update `MEMORY.md` with concise reusable notes when you discover stable repository conventions.",
331            memory_file.display()
332        )));
333    }
334
335    let content =
336        std::fs::read_to_string(&memory_file).with_context(|| format!("Failed to read {}", memory_file.display()))?;
337    Ok(Some(render_memory_appendix(&memory_file, &content)))
338}
339
340/// Async variant of [`load_memory_appendix`] for child-agent prompt assembly.
341///
342/// Directory creation, existence checks, and file reads use Tokio's filesystem
343/// boundary so launching a child agent does not block an executor worker.
344pub async fn load_memory_appendix_async(
345    workspace_root: &Path,
346    agent_name: &str,
347    scope: Option<SubagentMemoryScope>,
348) -> Result<Option<String>> {
349    let Some(scope) = scope else {
350        return Ok(None);
351    };
352
353    let memory_dir = agent_memory_dir(workspace_root, agent_name, scope)?;
354    VtCodePaths::ensure_user_dir(&memory_dir)
355        .with_context(|| format!("Failed to create subagent memory directory {}", memory_dir.display()))?;
356    let memory_file = memory_dir.join("MEMORY.md");
357    if !tokio::fs::try_exists(&memory_file).await.unwrap_or(false) {
358        return Ok(Some(format!(
359            "Persistent memory file: {}. Create or update `MEMORY.md` with concise reusable notes when you discover stable repository conventions.",
360            memory_file.display()
361        )));
362    }
363
364    let content = tokio::fs::read_to_string(&memory_file)
365        .await
366        .with_context(|| format!("Failed to read {}", memory_file.display()))?;
367    Ok(Some(render_memory_appendix(&memory_file, &content)))
368}
369
370fn render_memory_appendix(memory_file: &Path, content: &str) -> String {
371    let (excerpt, truncated) = memory_excerpt(content);
372    let highlights = extract_memory_highlights(&excerpt, SUBAGENT_MEMORY_HIGHLIGHT_LIMIT);
373    let mut appendix = String::new();
374    appendix.push_str(&format!(
375        "Persistent memory file: {}.\nRead and maintain `MEMORY.md` for durable learnings.",
376        memory_file.display()
377    ));
378
379    if !highlights.is_empty() {
380        appendix.push_str("\n\nKey points:\n");
381        for highlight in highlights {
382            appendix.push_str("- ");
383            appendix.push_str(&highlight);
384            appendix.push('\n');
385        }
386    }
387
388    appendix.push_str("\nOpen `MEMORY.md` when exact wording or more detail matters.");
389    if truncated {
390        appendix.push_str("\nMemory indexing stopped after the configured startup budget.");
391    }
392
393    appendix
394}
395
396/// Loads a read-only memory appendix for the primary agent from a subagent's memory scope.
397///
398/// Unlike [`load_memory_appendix`], this does not create directories or prompt for writes.
399pub fn load_primary_memory_appendix(
400    workspace_root: &Path,
401    agent_name: &str,
402    scope: Option<SubagentMemoryScope>,
403) -> Result<Option<String>> {
404    let Some(scope) = scope else {
405        return Ok(None);
406    };
407
408    load_primary_memory_appendix_cached(agent_memory_dir(workspace_root, agent_name, scope)?.join("MEMORY.md"))
409}
410
411/// Async variant of [`load_primary_memory_appendix`] for prompt assembly.
412/// Cache misses, metadata scans, and memory parsing run on Tokio's blocking
413/// pool so an interactive request never monopolizes a runtime worker with
414/// synchronous filesystem work.
415pub async fn load_primary_memory_appendix_async(
416    workspace_root: &Path,
417    agent_name: &str,
418    scope: Option<SubagentMemoryScope>,
419) -> Result<Option<String>> {
420    let Some(scope) = scope else {
421        return Ok(None);
422    };
423
424    let memory_file = agent_memory_dir(workspace_root, agent_name, scope)?.join("MEMORY.md");
425    tokio::task::spawn_blocking(move || load_primary_memory_appendix_cached(memory_file))
426        .await
427        .context("primary-agent memory cache task failed")?
428}
429
430fn load_primary_memory_appendix_cached(memory_file: PathBuf) -> Result<Option<String>> {
431    let cache_key = canonical_cache_path(&memory_file);
432    if let Some(cached) = primary_memory_cache().fast_get(&cache_key) {
433        return Ok((*cached).clone());
434    }
435
436    primary_memory_cache().with_load_gate(|| {
437        if let Some(cached) = primary_memory_cache().fast_get(&cache_key) {
438            return Ok((*cached).clone());
439        }
440
441        let fingerprint = fingerprint_files(&[memory_file.as_path()]);
442        if let Some(cached) = primary_memory_cache().get_if_unchanged(&cache_key, &fingerprint) {
443            return Ok((*cached).clone());
444        }
445
446        let appendix = load_primary_memory_appendix_uncached(&memory_file)?;
447        primary_memory_cache().insert(cache_key, appendix.clone(), fingerprint);
448        Ok(appendix)
449    })
450}
451
452fn load_primary_memory_appendix_uncached(memory_file: &Path) -> Result<Option<String>> {
453    let content = match std::fs::read_to_string(memory_file) {
454        Ok(content) => content,
455        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
456        Err(error) => {
457            return Err(error).with_context(|| format!("Failed to read {}", memory_file.display()));
458        }
459    };
460    let (excerpt, truncated) = memory_excerpt(&content);
461    let highlights = extract_memory_highlights(&excerpt, SUBAGENT_MEMORY_HIGHLIGHT_LIMIT);
462    let mut appendix = String::new();
463    appendix.push_str(&format!(
464        "Primary-agent memory file: {}.\nLoaded read-only for this request.",
465        memory_file.display()
466    ));
467
468    if !highlights.is_empty() {
469        appendix.push_str("\n\nKey points:\n");
470        for highlight in highlights {
471            appendix.push_str("- ");
472            appendix.push_str(&highlight);
473            appendix.push('\n');
474        }
475    }
476
477    if truncated {
478        appendix.push_str("\nMemory indexing stopped after the configured startup budget.");
479    }
480
481    Ok(Some(appendix))
482}
483
484static PRIMARY_MEMORY_APPENDIX_CACHE: OnceLock<ResourceCache<PathBuf, Option<String>>> = OnceLock::new();
485
486fn primary_memory_cache() -> &'static ResourceCache<PathBuf, Option<String>> {
487    PRIMARY_MEMORY_APPENDIX_CACHE.get_or_init(ResourceCache::default)
488}
489
490fn agent_memory_dir(workspace_root: &Path, agent_name: &str, scope: SubagentMemoryScope) -> Result<PathBuf> {
491    match scope {
492        SubagentMemoryScope::Project => Ok(workspace_root.join(".vtcode/agent-memory").join(agent_name)),
493        SubagentMemoryScope::Local => Ok(workspace_root.join(".vtcode/agent-memory-local").join(agent_name)),
494        SubagentMemoryScope::User => VtCodePaths::resolve()
495            .and_then(|paths| paths.state_path("agent-memory"))
496            .map(|path| path.join(agent_name))
497            .context("could not resolve VT Code state directory for user subagent memory"),
498    }
499}
500
501fn memory_excerpt(content: &str) -> (String, bool) {
502    let total_lines = content.lines().count();
503    let mut bytes = 0usize;
504    let mut excerpt_lines = Vec::new();
505    for line in content.lines().take(SUBAGENT_MEMORY_LINE_LIMIT) {
506        let next_bytes = bytes.saturating_add(line.len() + 1);
507        if next_bytes > SUBAGENT_MEMORY_BYTES_LIMIT {
508            break;
509        }
510        bytes = next_bytes;
511        excerpt_lines.push(line);
512    }
513
514    let truncated = excerpt_lines.len() < total_lines;
515    (excerpt_lines.join("\n"), truncated)
516}
517
518#[cfg(test)]
519mod memory_cache_tests {
520    use super::{load_primary_memory_appendix, load_primary_memory_appendix_async, primary_memory_cache};
521    use tempfile::TempDir;
522    use vtcode_config::SubagentMemoryScope;
523
524    fn memory_file(workspace: &TempDir) -> std::path::PathBuf {
525        workspace.path().join(".vtcode/agent-memory/reviewer/MEMORY.md")
526    }
527
528    #[test]
529    #[serial_test::serial]
530    fn primary_memory_cache_refreshes_edits_and_missing_files() {
531        primary_memory_cache().clear();
532        let workspace = TempDir::new().expect("workspace");
533
534        assert!(
535            load_primary_memory_appendix(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project))
536                .expect("missing memory")
537                .is_none()
538        );
539
540        let path = memory_file(&workspace);
541        std::fs::create_dir_all(path.parent().expect("memory parent")).expect("memory directory");
542        std::fs::write(&path, "# Reviewer Memory\n\n- old guidance\n").expect("old memory");
543        primary_memory_cache().force_metadata_poll();
544        let first = load_primary_memory_appendix(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project))
545            .expect("first memory")
546            .expect("memory appendix");
547        assert!(first.contains("old guidance"));
548
549        std::fs::write(&path, "# Reviewer Memory\n\n- new guidance\n").expect("new memory");
550        primary_memory_cache().force_metadata_poll();
551        let second = load_primary_memory_appendix(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project))
552            .expect("updated memory")
553            .expect("updated appendix");
554        assert!(second.contains("new guidance"));
555    }
556
557    #[test]
558    #[serial_test::serial]
559    fn primary_memory_cache_honors_ttl_refresh() {
560        primary_memory_cache().clear();
561        let workspace = TempDir::new().expect("workspace");
562        let path = memory_file(&workspace);
563        std::fs::create_dir_all(path.parent().expect("memory parent")).expect("memory directory");
564        std::fs::write(&path, "# Reviewer Memory\n\n- old guidance\n").expect("old memory");
565
566        let first = load_primary_memory_appendix(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project))
567            .expect("first memory")
568            .expect("memory appendix");
569        assert!(first.contains("old guidance"));
570        std::fs::write(&path, "# Reviewer Memory\n\n- new guidance\n").expect("new memory");
571        primary_memory_cache().force_expiration();
572        let second = load_primary_memory_appendix(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project))
573            .expect("expired memory")
574            .expect("expired appendix");
575        assert!(second.contains("new guidance"));
576    }
577
578    #[tokio::test(flavor = "current_thread")]
579    #[serial_test::serial]
580    async fn concurrent_primary_memory_misses_return_identical_appendices() {
581        primary_memory_cache().clear();
582        let workspace = TempDir::new().expect("workspace");
583        let path = memory_file(&workspace);
584        std::fs::create_dir_all(path.parent().expect("memory parent")).expect("memory directory");
585        std::fs::write(&path, "# Reviewer Memory\n\n- shared guidance\n").expect("memory");
586
587        let (first, second) = tokio::join!(
588            load_primary_memory_appendix_async(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project)),
589            load_primary_memory_appendix_async(workspace.path(), "reviewer", Some(SubagentMemoryScope::Project)),
590        );
591        assert_eq!(first.expect("first appendix"), second.expect("second appendix"));
592    }
593}