Skip to main content

vtcode_core/subagents/
model.rs

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