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
18pub 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
63struct 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
83fn 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
99fn 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 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 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 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
194pub 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
295pub 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
305use 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
311pub 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
340pub 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
396pub 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
411pub 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}