Skip to main content

mars_agents/cli/
models_prompting.rs

1//! CLI handler for `mars models prompting`.
2
3use 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    /// Agent name/ref or model alias to look up prompting guidance for.
22    pub reference: String,
23    /// Refresh models.dev catalog and harness probes synchronously before resolving an agent.
24    #[arg(long, conflicts_with = "no_refresh_models")]
25    refresh_models: bool,
26    /// Skip automatic models-cache refresh; use whatever is on disk.
27    #[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}