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
13pub 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
268use 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}