Skip to main content

vtcode_core/subagents/
model.rs

1use anyhow::{Context, Result};
2use vtcode_config::SubagentSpec;
3
4use std::path::{Path, PathBuf};
5
6use crate::config::VTCodeConfig;
7use crate::config::constants::models;
8use crate::config::models::{ModelId, Provider};
9use crate::core::agent::types::AgentType;
10use crate::llm::auto_lightweight_model;
11use crate::llm::factory::{infer_provider, infer_provider_from_model};
12
13// ─── Model Resolution ───────────────────────────────────────────────────────
14
15pub fn resolve_subagent_model(
16    vt_cfg: &VTCodeConfig,
17    parent_model: &str,
18    parent_provider: &str,
19    requested: Option<&str>,
20    agent_name: &str,
21) -> Result<ModelId> {
22    let requested = requested.unwrap_or("inherit").trim();
23    if requested.eq_ignore_ascii_case("inherit") || requested.is_empty() {
24        return resolve_inherit_model(parent_model, parent_provider, agent_name);
25    }
26    resolve_explicit_model(vt_cfg, parent_provider, parent_model, requested, agent_name)
27}
28
29fn resolve_inherit_model(
30    parent_model: &str,
31    parent_provider: &str,
32    agent_name: &str,
33) -> Result<ModelId> {
34    if let Ok(model) = parent_model.parse::<ModelId>() {
35        return Ok(model);
36    }
37    if parent_provider.eq_ignore_ascii_case("copilot") {
38        let fallback = ModelId::default_orchestrator_for_provider(Provider::Copilot);
39        tracing::warn!(
40            agent_name,
41            parent_model = parent_model.trim(),
42            parent_provider = parent_provider.trim(),
43            fallback_model = fallback.as_str(),
44            "Falling back to the default Copilot subagent model because the inherited parent model identifier is not supported internally"
45        );
46        return Ok(fallback);
47    }
48
49    let normalized = normalize_subagent_model_alias(parent_model);
50    normalized.parse::<ModelId>().with_context(|| {
51        format!(
52            "Failed to resolve model '{}' for subagent {}",
53            normalized, agent_name
54        )
55    })
56}
57
58fn resolve_explicit_model(
59    vt_cfg: &VTCodeConfig,
60    parent_provider: &str,
61    parent_model: &str,
62    requested: &str,
63    agent_name: &str,
64) -> Result<ModelId> {
65    let resolved = if requested.eq_ignore_ascii_case("small") {
66        resolve_lightweight_model(vt_cfg, parent_provider, parent_model, agent_name)
67    } else if matches!(
68        requested.to_ascii_lowercase().as_str(),
69        "haiku" | "sonnet" | "opus"
70    ) {
71        alias_model_for_provider(parent_provider, requested, parent_model)
72    } else {
73        requested.to_string()
74    };
75
76    let normalized = normalize_subagent_model_alias(resolved.as_str());
77    normalized.parse::<ModelId>().with_context(|| {
78        format!(
79            "Failed to resolve model '{}' for subagent {}",
80            normalized, agent_name
81        )
82    })
83}
84
85fn resolve_lightweight_model(
86    vt_cfg: &VTCodeConfig,
87    parent_provider: &str,
88    parent_model: &str,
89    agent_name: &str,
90) -> String {
91    if vt_cfg.agent.small_model.model.trim().is_empty() {
92        return auto_lightweight_model(parent_provider, parent_model);
93    }
94
95    let configured = vt_cfg.agent.small_model.model.trim();
96    let active_provider = infer_provider(Some(parent_provider), parent_model);
97    let configured_provider =
98        infer_provider_from_model(configured).or_else(|| infer_provider(None, configured));
99
100    if configured_provider.is_some() && configured_provider != active_provider {
101        tracing::warn!(
102            agent_name,
103            configured_model = configured,
104            active_provider = active_provider
105                .map(|provider| provider.to_string())
106                .unwrap_or_else(|| parent_provider.to_string()),
107            "Ignoring cross-provider lightweight subagent model; using same-provider automatic route"
108        );
109        auto_lightweight_model(parent_provider, parent_model)
110    } else {
111        configured.to_string()
112    }
113}
114
115pub fn resolve_effective_subagent_model(
116    vt_cfg: &VTCodeConfig,
117    parent_model: &str,
118    parent_provider: &str,
119    model_override: Option<&str>,
120    spec_model: Option<&str>,
121    agent_name: &str,
122) -> Result<ModelId> {
123    if let Some(requested) = model_override {
124        match resolve_subagent_model(
125            vt_cfg,
126            parent_model,
127            parent_provider,
128            Some(requested),
129            agent_name,
130        ) {
131            Ok(model) => return Ok(model),
132            Err(err) => {
133                return handle_model_override_failure(
134                    vt_cfg,
135                    parent_model,
136                    parent_provider,
137                    requested,
138                    spec_model,
139                    agent_name,
140                    err,
141                );
142            }
143        }
144    }
145
146    match resolve_subagent_model(
147        vt_cfg,
148        parent_model,
149        parent_provider,
150        spec_model,
151        agent_name,
152    ) {
153        Ok(model) => Ok(model),
154        Err(err)
155            if spec_model
156                .map(str::trim)
157                .is_some_and(|v| v.eq_ignore_ascii_case("small")) =>
158        {
159            tracing::warn!(
160                agent_name,
161                error = %err,
162                "Failed to resolve lightweight subagent model from spec; falling back to parent model"
163            );
164            resolve_subagent_model(
165                vt_cfg,
166                parent_model,
167                parent_provider,
168                Some("inherit"),
169                agent_name,
170            )
171        }
172        Err(err) => Err(err),
173    }
174}
175
176fn handle_model_override_failure(
177    vt_cfg: &VTCodeConfig,
178    parent_model: &str,
179    parent_provider: &str,
180    requested: &str,
181    spec_model: Option<&str>,
182    agent_name: &str,
183    err: anyhow::Error,
184) -> Result<ModelId> {
185    if requested.trim().eq_ignore_ascii_case("small") {
186        tracing::warn!(
187            agent_name,
188            requested_model = requested.trim(),
189            error = %err,
190            "Failed to bootstrap lightweight subagent model; falling back to parent model"
191        );
192        return resolve_subagent_model(
193            vt_cfg,
194            parent_model,
195            parent_provider,
196            Some("inherit"),
197            agent_name,
198        );
199    }
200    let fallback = spec_model
201        .map(str::trim)
202        .filter(|v| !v.is_empty())
203        .unwrap_or("inherit");
204    tracing::warn!(
205        agent_name,
206        requested_model = requested.trim(),
207        fallback_model = fallback,
208        error = %err,
209        "Failed to resolve subagent model override; falling back"
210    );
211    Ok(resolve_subagent_model(
212        vt_cfg,
213        parent_model,
214        parent_provider,
215        spec_model,
216        agent_name,
217    )
218    .unwrap_or_else(|_| {
219        resolve_subagent_model(
220            vt_cfg,
221            parent_model,
222            parent_provider,
223            Some("inherit"),
224            agent_name,
225        )
226        .expect("inherit fallback should succeed")
227    }))
228}
229
230fn normalize_subagent_model_alias(model: &str) -> &str {
231    match model.trim() {
232        "claude-haiku-4.5" => models::anthropic::CLAUDE_HAIKU_4_5,
233        "claude-sonnet-4.6" => models::anthropic::CLAUDE_SONNET_4_6,
234        "claude-opus-4.8" => models::anthropic::CLAUDE_OPUS_4_8,
235        other => other,
236    }
237}
238
239fn alias_model_for_provider(parent_provider: &str, alias: &str, parent_model: &str) -> String {
240    match infer_provider(Some(parent_provider), parent_model) {
241        Some(Provider::Anthropic) => match alias.to_ascii_lowercase().as_str() {
242            "haiku" => models::anthropic::CLAUDE_HAIKU_4_5.to_string(),
243            "opus" => models::anthropic::CLAUDE_OPUS_4_8.to_string(),
244            _ => models::anthropic::CLAUDE_SONNET_4_6.to_string(),
245        },
246        Some(Provider::OpenAI) => match alias.to_ascii_lowercase().as_str() {
247            "haiku" => models::openai::GPT_5_4_MINI.to_string(),
248            "opus" => models::openai::GPT_5_4.to_string(),
249            _ => models::openai::GPT_5_4.to_string(),
250        },
251        Some(Provider::Gemini) => match alias.to_ascii_lowercase().as_str() {
252            "haiku" => models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
253            _ => models::google::GEMINI_3_1_PRO_PREVIEW.to_string(),
254        },
255        _ => parent_model.to_string(),
256    }
257}
258
259pub fn agent_type_for_spec(spec: &SubagentSpec) -> AgentType {
260    match spec.name.as_str() {
261        "explorer" | "explore" => AgentType::Explore,
262        "plan" => AgentType::Plan,
263        "worker" | "general" | "general-purpose" | "default" => AgentType::General,
264        _ => AgentType::Custom(spec.name.clone()),
265    }
266}
267
268// ─── Memory Appendix ────────────────────────────────────────────────────────
269
270use super::constants::{
271    SUBAGENT_MEMORY_BYTES_LIMIT, SUBAGENT_MEMORY_HIGHLIGHT_LIMIT, SUBAGENT_MEMORY_LINE_LIMIT,
272};
273use crate::persistent_memory::extract_memory_highlights;
274use vtcode_config::SubagentMemoryScope;
275
276pub fn load_memory_appendix(
277    workspace_root: &Path,
278    agent_name: &str,
279    scope: Option<SubagentMemoryScope>,
280) -> Result<Option<String>> {
281    let Some(scope) = scope else {
282        return Ok(None);
283    };
284
285    let memory_dir = agent_memory_dir(workspace_root, agent_name, scope);
286    std::fs::create_dir_all(&memory_dir).with_context(|| {
287        format!(
288            "Failed to create subagent memory directory {}",
289            memory_dir.display()
290        )
291    })?;
292    let memory_file = memory_dir.join("MEMORY.md");
293    if !memory_file.exists() {
294        return Ok(Some(format!(
295            "Persistent memory file: {}. Create or update `MEMORY.md` with concise reusable notes when you discover stable repository conventions.",
296            memory_file.display()
297        )));
298    }
299
300    let content = std::fs::read_to_string(&memory_file)
301        .with_context(|| format!("Failed to read {}", memory_file.display()))?;
302    let (excerpt, truncated) = memory_excerpt(&content);
303    let highlights = extract_memory_highlights(&excerpt, SUBAGENT_MEMORY_HIGHLIGHT_LIMIT);
304    let mut appendix = String::new();
305    appendix.push_str(&format!(
306        "Persistent memory file: {}.\nRead and maintain `MEMORY.md` for durable learnings.",
307        memory_file.display()
308    ));
309
310    if !highlights.is_empty() {
311        appendix.push_str("\n\nKey points:\n");
312        for highlight in highlights {
313            appendix.push_str("- ");
314            appendix.push_str(&highlight);
315            appendix.push('\n');
316        }
317    }
318
319    appendix.push_str("\nOpen `MEMORY.md` when exact wording or more detail matters.");
320    if truncated {
321        appendix.push_str("\nMemory indexing stopped after the configured startup budget.");
322    }
323
324    Ok(Some(appendix))
325}
326
327pub fn load_primary_memory_appendix(
328    workspace_root: &Path,
329    agent_name: &str,
330    scope: Option<SubagentMemoryScope>,
331) -> Result<Option<String>> {
332    let Some(scope) = scope else {
333        return Ok(None);
334    };
335
336    let memory_file = agent_memory_dir(workspace_root, agent_name, scope).join("MEMORY.md");
337    if !memory_file.exists() {
338        return Ok(None);
339    }
340
341    let content = std::fs::read_to_string(&memory_file)
342        .with_context(|| format!("Failed to read {}", memory_file.display()))?;
343    let (excerpt, truncated) = memory_excerpt(&content);
344    let highlights = extract_memory_highlights(&excerpt, SUBAGENT_MEMORY_HIGHLIGHT_LIMIT);
345    let mut appendix = String::new();
346    appendix.push_str(&format!(
347        "Primary-agent memory file: {}.\nLoaded read-only for this request.",
348        memory_file.display()
349    ));
350
351    if !highlights.is_empty() {
352        appendix.push_str("\n\nKey points:\n");
353        for highlight in highlights {
354            appendix.push_str("- ");
355            appendix.push_str(&highlight);
356            appendix.push('\n');
357        }
358    }
359
360    if truncated {
361        appendix.push_str("\nMemory indexing stopped after the configured startup budget.");
362    }
363
364    Ok(Some(appendix))
365}
366
367fn agent_memory_dir(
368    workspace_root: &Path,
369    agent_name: &str,
370    scope: SubagentMemoryScope,
371) -> PathBuf {
372    match scope {
373        SubagentMemoryScope::Project => {
374            workspace_root.join(".vtcode/agent-memory").join(agent_name)
375        }
376        SubagentMemoryScope::Local => workspace_root
377            .join(".vtcode/agent-memory-local")
378            .join(agent_name),
379        SubagentMemoryScope::User => dirs::home_dir()
380            .unwrap_or_default()
381            .join(".vtcode/agent-memory")
382            .join(agent_name),
383    }
384}
385
386fn memory_excerpt(content: &str) -> (String, bool) {
387    let total_lines = content.lines().count();
388    let mut bytes = 0usize;
389    let mut excerpt_lines = Vec::new();
390    for line in content.lines().take(SUBAGENT_MEMORY_LINE_LIMIT) {
391        let next_bytes = bytes.saturating_add(line.len() + 1);
392        if next_bytes > SUBAGENT_MEMORY_BYTES_LIMIT {
393            break;
394        }
395        bytes = next_bytes;
396        excerpt_lines.push(line);
397    }
398
399    let truncated = excerpt_lines.len() < total_lines;
400    (excerpt_lines.join("\n"), truncated)
401}