1use clap::Parser;
4use indexmap::IndexMap;
5
6use super::models_common::{
7 load_merged_aliases, load_project_config_layers_optional, models_cache_ttl_hours,
8};
9use crate::build::policy::{PolicyInput, resolve_policy};
10use crate::compiler::agents::{AgentProfile, parse_agent_content};
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::models::{self, ModelAlias};
14use crate::types::MarsContext;
15
16#[derive(Debug, Parser)]
17#[command(
18 after_help = "Examples:\n mars models prompting @explorer\n mars models prompting gpt55"
19)]
20pub struct PromptingArgs {
21 pub reference: String,
23 #[arg(long, conflicts_with = "no_refresh_models")]
25 refresh_models: bool,
26 #[arg(long, conflicts_with = "refresh_models")]
28 no_refresh_models: bool,
29}
30
31pub fn run(args: &PromptingArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
32 let project_config = load_project_config_layers_optional(&ctx.project_root)?;
33 let merged = load_merged_aliases(&ctx.project_root, project_config.as_ref())?;
34 let refresh =
35 models::resolve_models_refresh_control(args.refresh_models, args.no_refresh_models)?;
36
37 let target = resolve_prompt_ref(
38 &args.reference,
39 ctx,
40 &merged,
41 project_config.as_ref(),
42 refresh,
43 )?;
44
45 if json {
46 let out = target.to_json(&args.reference);
47 println!("{}", serde_json::to_string_pretty(&out).unwrap());
48 } else if target.found {
49 print_prompt_target(&target);
50 } else {
51 eprintln!(
52 "Unknown agent or model ref `{}`. Run `mars agents` or `mars models list` to see available refs.",
53 args.reference
54 );
55 eprintln!("Examples:");
56 eprintln!(" mars models prompting @explorer");
57 eprintln!(" mars models prompting gpt55");
58 return Ok(1);
59 }
60
61 Ok(if target.found { 0 } else { 1 })
62}
63
64#[derive(Debug)]
65struct PromptTarget {
66 found: bool,
67 ref_kind: Option<PromptRefKind>,
68 agent_name: Option<String>,
69 model_alias: Option<String>,
70 model_name: Option<String>,
71 prompting: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy)]
75enum PromptRefKind {
76 Agent,
77 Model,
78}
79
80impl PromptRefKind {
81 fn as_str(self) -> &'static str {
82 match self {
83 Self::Agent => "agent",
84 Self::Model => "model",
85 }
86 }
87}
88
89impl PromptTarget {
90 fn unknown() -> Self {
91 Self {
92 found: false,
93 ref_kind: None,
94 agent_name: None,
95 model_alias: None,
96 model_name: None,
97 prompting: None,
98 }
99 }
100
101 fn to_json(&self, input_ref: &str) -> serde_json::Value {
102 serde_json::json!({
103 "ref": input_ref,
104 "ref_kind": self.ref_kind.map(PromptRefKind::as_str),
105 "agent_name": self.agent_name,
106 "model_alias": self.model_alias,
107 "model_name": self.model_name,
108 "found": self.found,
109 "prompting": self.prompting,
110 })
111 }
112}
113
114fn resolve_prompt_ref(
115 input_ref: &str,
116 ctx: &MarsContext,
117 aliases: &IndexMap<String, ModelAlias>,
118 project_config: Option<&crate::config::LoadedProjectConfig>,
119 refresh: models::ModelsRefreshControl,
120) -> Result<PromptTarget, MarsError> {
121 if let Some(agent) = resolve_prompt_agent(input_ref, ctx)? {
122 return prompt_target_for_agent(agent, ctx, aliases, project_config, refresh);
123 }
124
125 if input_ref.starts_with('@') {
126 return Ok(PromptTarget::unknown());
127 }
128
129 Ok(aliases
130 .get(input_ref)
131 .map(|alias| prompt_target_for_model(input_ref, alias, ctx, project_config, refresh))
132 .unwrap_or_else(PromptTarget::unknown))
133}
134
135struct PromptAgent {
136 name: String,
137 file_stem: String,
138 profile: AgentProfile,
139}
140
141#[derive(Debug, Clone, Copy)]
142enum PromptAgentMatch {
143 FileStem,
144 ProfileName,
145}
146
147fn resolve_prompt_agent(
148 input_ref: &str,
149 ctx: &MarsContext,
150) -> Result<Option<PromptAgent>, MarsError> {
151 let lookup_name = agent_ref_lookup_name(input_ref);
152 let mut agents = load_prompt_agents(ctx)?;
153
154 for match_kind in [PromptAgentMatch::FileStem, PromptAgentMatch::ProfileName] {
155 if let Some(index) = agents
156 .iter()
157 .position(|agent| prompt_agent_matches(agent, lookup_name, match_kind))
158 {
159 return Ok(Some(agents.remove(index)));
160 }
161 }
162
163 Ok(None)
164}
165
166fn load_prompt_agents(ctx: &MarsContext) -> Result<Vec<PromptAgent>, MarsError> {
167 let lock = crate::lock::load(&ctx.project_root)?;
168 let mars_dir = ctx.project_root.join(".mars");
169 let mut agents = Vec::new();
170
171 for (dest_path, item) in lock.canonical_flat_items() {
172 if item.kind != ItemKind::Agent {
173 continue;
174 }
175
176 let disk_path = dest_path.resolve(&mars_dir);
177 let content = match std::fs::read_to_string(&disk_path) {
178 Ok(content) => content,
179 Err(err) => {
180 eprintln!("warning: skipping {}: {err}", disk_path.display());
181 continue;
182 }
183 };
184
185 let mut diags = Vec::new();
186 let (profile, _fm) = match parse_agent_content(&content, &mut diags) {
187 Ok(parsed) => parsed,
188 Err(err) => {
189 eprintln!("warning: skipping {}: {err}", disk_path.display());
190 continue;
191 }
192 };
193 if let Some(fatal) = diags.iter().find(|diag| diag.is_error()) {
194 eprintln!(
195 "warning: skipping {}: {}",
196 disk_path.display(),
197 fatal.message()
198 );
199 continue;
200 }
201
202 let stem = prompt_path_stem(&disk_path);
203 let agent_name = profile.name.as_deref().unwrap_or(stem.as_str());
204 agents.push(PromptAgent {
205 name: agent_name.to_string(),
206 file_stem: stem,
207 profile,
208 });
209 }
210
211 Ok(agents)
212}
213
214fn prompt_agent_matches(
215 agent: &PromptAgent,
216 lookup_name: &str,
217 match_kind: PromptAgentMatch,
218) -> bool {
219 match match_kind {
220 PromptAgentMatch::FileStem => agent.file_stem.eq_ignore_ascii_case(lookup_name),
221 PromptAgentMatch::ProfileName => agent.name.eq_ignore_ascii_case(lookup_name),
222 }
223}
224
225fn agent_ref_lookup_name(input_ref: &str) -> &str {
226 input_ref.strip_prefix('@').unwrap_or(input_ref)
227}
228
229fn prompt_path_stem(path: &std::path::Path) -> String {
230 path.file_stem()
231 .and_then(|s| s.to_str())
232 .unwrap_or("unknown")
233 .to_string()
234}
235
236fn prompt_target_for_agent(
237 agent: PromptAgent,
238 ctx: &MarsContext,
239 aliases: &IndexMap<String, ModelAlias>,
240 project_config: Option<&crate::config::LoadedProjectConfig>,
241 refresh: models::ModelsRefreshControl,
242) -> Result<PromptTarget, MarsError> {
243 let effective_config = project_config
244 .map(|loaded| loaded.effective.clone())
245 .unwrap_or_default();
246 let policy = resolve_policy(
247 &effective_config,
248 PolicyInput {
249 project_root: &ctx.project_root,
250 runtime_aliases: aliases,
251 agent: Some(&agent.name),
252 profile: &agent.profile,
253 model_override: None,
254 harness_override: None,
255 effort_override: None,
256 approval_override: None,
257 sandbox_override: None,
258 models_refresh: refresh,
259 },
260 )?;
261
262 Ok(prompt_target_for_routing(
263 Some(agent.name),
264 PromptRefKind::Agent,
265 &policy.routing,
266 aliases,
267 ))
268}
269
270fn prompt_target_for_routing(
271 agent_name: Option<String>,
272 ref_kind: PromptRefKind,
273 routing: &crate::build::bundle::Routing,
274 aliases: &IndexMap<String, ModelAlias>,
275) -> PromptTarget {
276 let token = routing.model_token.trim();
277 let model_alias = (!token.is_empty() && aliases.contains_key(token)).then(|| token.to_string());
278 let prompting = model_alias
279 .as_deref()
280 .and_then(|alias| aliases.get(alias))
281 .and_then(|alias| alias.prompting.clone());
282 let model_name = runnable_model_name(routing);
283
284 PromptTarget {
285 found: true,
286 ref_kind: Some(ref_kind),
287 agent_name,
288 model_alias,
289 model_name,
290 prompting,
291 }
292}
293
294fn runnable_model_name(routing: &crate::build::bundle::Routing) -> Option<String> {
295 let harness_model = routing.harness_model.trim();
296 if !harness_model.is_empty() {
297 return Some(harness_model.to_string());
298 }
299
300 let model = routing.model.trim();
301 (!model.is_empty()).then(|| model.to_string())
302}
303
304fn prompt_target_for_model(
305 alias_name: &str,
306 alias: &ModelAlias,
307 ctx: &MarsContext,
308 project_config: Option<&crate::config::LoadedProjectConfig>,
309 refresh: models::ModelsRefreshControl,
310) -> PromptTarget {
311 let cache = prompt_model_cache(ctx, project_config, refresh);
312 PromptTarget {
313 found: true,
314 ref_kind: Some(PromptRefKind::Model),
315 agent_name: None,
316 model_alias: Some(alias_name.to_string()),
317 model_name: Some(model_name_for_alias(alias_name, alias, &cache)),
318 prompting: alias.prompting.clone(),
319 }
320}
321
322fn prompt_model_cache(
323 ctx: &MarsContext,
324 project_config: Option<&crate::config::LoadedProjectConfig>,
325 refresh: models::ModelsRefreshControl,
326) -> models::ModelsCache {
327 let mars_dir = ctx.project_root.join(".mars");
328 let ttl = models_cache_ttl_hours(project_config);
329 models::ensure_fresh(&mars_dir, ttl, refresh.catalog_mode)
330 .map(|(cache, _)| cache)
331 .or_else(|_| models::read_cache(&mars_dir))
332 .unwrap_or(models::ModelsCache {
333 models: Vec::new(),
334 fetched_at: None,
335 })
336}
337
338fn model_name_for_alias(
339 alias_name: &str,
340 alias: &ModelAlias,
341 cache: &models::ModelsCache,
342) -> String {
343 models::resolve_model_id_for_alias(alias, cache)
344 .unwrap_or_else(|| alias.pinned_model_id().unwrap_or(alias_name).to_string())
345}
346
347fn print_prompt_target(target: &PromptTarget) {
348 if let Some(text) = target.prompting.as_deref() {
349 println!("{text}");
350 return;
351 }
352
353 match target.ref_kind {
354 Some(PromptRefKind::Agent) => {
355 let agent_name = target.agent_name.as_deref().unwrap_or("unknown");
356 match target.model_alias.as_deref() {
357 Some(model_alias) => {
358 println!(
359 "No prompting guidance defined for agent `{agent_name}` (model alias `{model_alias}`)."
360 );
361 print_prompting_field_hint(model_alias);
362 }
363 None => {
364 let model = target.model_name.as_deref().unwrap_or("no model");
365 println!(
366 "No prompting guidance defined for agent `{agent_name}` (model `{model}`)."
367 );
368 println!("Prompting guidance is read from a known model alias.");
369 }
370 }
371 }
372 Some(PromptRefKind::Model) => {
373 let model_alias = target.model_alias.as_deref().unwrap_or("unknown");
374 println!("No prompting guidance defined for model alias `{model_alias}`.");
375 print_prompting_field_hint(model_alias);
376 }
377 None => {}
378 }
379
380 println!();
381 println!("Examples:");
382 println!(" mars models prompting @explorer");
383 println!(" mars models prompting gpt55");
384}
385
386fn print_prompting_field_hint(model_alias: &str) {
387 println!("Add a `prompting` field to the alias in mars.toml:");
388 println!();
389 println!(" [models.{model_alias}]");
390 println!(" prompting = \"Prompting tips for this model.\"");
391}