Skip to main content

vv_agent/cli/
args.rs

1use std::env;
2use std::path::PathBuf;
3
4use crate::model_settings::ModelSettings;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct CliArgs {
8    pub prompt: String,
9    pub backend: String,
10    pub model: String,
11    pub settings_file: PathBuf,
12    pub workspace: PathBuf,
13    pub max_cycles: u32,
14    pub language: String,
15    pub agent_type: Option<String>,
16    pub model_settings: Option<ModelSettings>,
17    pub verbose: bool,
18}
19
20#[derive(Debug, Clone, PartialEq)]
21#[allow(clippy::large_enum_variant)] // Preserve the public command shape without boxed variants.
22pub enum CliCommand {
23    Run(CliArgs),
24    AppServer(AppServerCliCommand),
25    Debug(DebugCliCommand),
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum AppServerCliCommand {
30    ListenStdio {
31        settings_file: PathBuf,
32        backend: String,
33        model: String,
34        timeout_seconds: f64,
35    },
36    GenerateTs {
37        out: PathBuf,
38    },
39    GenerateJsonSchema {
40        out: PathBuf,
41    },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum DebugCliCommand {
46    AppServerSendMessage { message: String },
47}
48
49pub fn parse_cli_args_from<I, S>(args: I) -> Result<CliArgs, String>
50where
51    I: IntoIterator<Item = S>,
52    S: Into<String>,
53{
54    let default_settings = default_settings_file_from_environment();
55    parse_cli_args_from_with_default_settings(args, default_settings)
56}
57
58pub fn parse_cli_command_from<I, S>(args: I) -> Result<CliCommand, String>
59where
60    I: IntoIterator<Item = S>,
61    S: Into<String>,
62{
63    let default_settings = default_settings_file_from_environment();
64    parse_cli_command_from_with_default_settings(args, default_settings)
65}
66
67fn default_settings_file_from_environment() -> String {
68    non_blank_environment_value("VV_AGENT_LOCAL_SETTINGS")
69        .or_else(|| non_blank_environment_value("V_AGENT_LOCAL_SETTINGS"))
70        .unwrap_or_else(|| "local_settings.json".to_string())
71}
72
73fn non_blank_environment_value(name: &str) -> Option<String> {
74    env::var(name).ok().filter(|value| !value.trim().is_empty())
75}
76
77pub fn parse_cli_command_from_with_default_settings<I, S>(
78    args: I,
79    default_settings: impl Into<String>,
80) -> Result<CliCommand, String>
81where
82    I: IntoIterator<Item = S>,
83    S: Into<String>,
84{
85    let mut values = args.into_iter().map(Into::into).collect::<Vec<_>>();
86    if !values.is_empty() {
87        values.remove(0);
88    }
89    match values.first().map(String::as_str) {
90        Some("app-server") => parse_app_server_command(&values[1..]).map(CliCommand::AppServer),
91        Some("debug") => parse_debug_command(&values[1..]).map(CliCommand::Debug),
92        _ => parse_cli_args_from_with_default_settings(
93            std::iter::once("vv-agent".to_string()).chain(values),
94            default_settings,
95        )
96        .map(CliCommand::Run),
97    }
98}
99
100pub fn parse_cli_args_from_with_default_settings<I, S>(
101    args: I,
102    default_settings: impl Into<String>,
103) -> Result<CliArgs, String>
104where
105    I: IntoIterator<Item = S>,
106    S: Into<String>,
107{
108    let mut values = args.into_iter().map(Into::into).collect::<Vec<_>>();
109    if !values.is_empty() {
110        values.remove(0);
111    }
112
113    let mut parsed = ParsedCliArgs::with_default_settings(default_settings.into());
114    parsed.consume(values)?;
115    parsed.finish()
116}
117
118fn parse_app_server_command(values: &[String]) -> Result<AppServerCliCommand, String> {
119    match values.first().map(String::as_str) {
120        Some("generate-ts") => Ok(AppServerCliCommand::GenerateTs {
121            out: parse_out_dir(&values[1..], "app-server generate-ts")?,
122        }),
123        Some("generate-json-schema" | "schema") => Ok(AppServerCliCommand::GenerateJsonSchema {
124            out: parse_out_dir(&values[1..], "app-server generate-json-schema")?,
125        }),
126        _ => parse_app_server_listener(values),
127    }
128}
129
130fn parse_app_server_listener(values: &[String]) -> Result<AppServerCliCommand, String> {
131    let values = normalize_equals_arguments(values.to_vec());
132    let mut parsed = ParsedAppServerListener::default();
133    parsed.consume(&values)?;
134    parsed.finish()
135}
136
137#[derive(Default)]
138struct ParsedAppServerListener {
139    listen: Option<String>,
140    settings_file: Option<PathBuf>,
141    backend: Option<String>,
142    model: Option<String>,
143    timeout_seconds: Option<f64>,
144}
145
146impl ParsedAppServerListener {
147    fn consume(&mut self, values: &[String]) -> Result<(), String> {
148        let mut index = 0;
149        while index < values.len() {
150            let flag = &values[index];
151            index += 1;
152            match flag.as_str() {
153                "--listen" => {
154                    reject_duplicate(self.listen.is_some(), flag)?;
155                    let listen = next_non_blank_value(values, &mut index, flag)?;
156                    if listen != "stdio" {
157                        return Err("only app-server --listen stdio is supported".to_string());
158                    }
159                    self.listen = Some(listen);
160                }
161                "--settings" => {
162                    reject_duplicate(self.settings_file.is_some(), flag)?;
163                    self.settings_file = Some(PathBuf::from(next_non_blank_value(
164                        values, &mut index, flag,
165                    )?));
166                }
167                "--backend" => {
168                    reject_duplicate(self.backend.is_some(), flag)?;
169                    self.backend = Some(next_non_blank_value(values, &mut index, flag)?);
170                }
171                "--model" => {
172                    reject_duplicate(self.model.is_some(), flag)?;
173                    self.model = Some(next_non_blank_value(values, &mut index, flag)?);
174                }
175                "--timeout-seconds" => {
176                    reject_duplicate(self.timeout_seconds.is_some(), flag)?;
177                    let raw = next_non_blank_value(values, &mut index, flag)?;
178                    self.timeout_seconds = Some(parse_positive_f64(&raw, flag)?);
179                }
180                other => return Err(format!("unknown app-server argument: {other}")),
181            }
182        }
183        Ok(())
184    }
185
186    fn finish(self) -> Result<AppServerCliCommand, String> {
187        let mut missing = Vec::new();
188        if self.listen.is_none() {
189            missing.push("--listen");
190        }
191        if self.settings_file.is_none() {
192            missing.push("--settings");
193        }
194        if self.backend.is_none() {
195            missing.push("--backend");
196        }
197        if self.model.is_none() {
198            missing.push("--model");
199        }
200        if !missing.is_empty() {
201            return Err(format!("app-server requires {}", missing.join(", ")));
202        }
203
204        Ok(AppServerCliCommand::ListenStdio {
205            settings_file: self.settings_file.expect("checked above"),
206            backend: self.backend.expect("checked above"),
207            model: self.model.expect("checked above"),
208            timeout_seconds: self.timeout_seconds.unwrap_or(90.0),
209        })
210    }
211}
212
213fn reject_duplicate(seen: bool, flag: &str) -> Result<(), String> {
214    if seen {
215        return Err(format!("duplicate app-server argument: {flag}"));
216    }
217    Ok(())
218}
219
220fn next_non_blank_value(
221    values: &[String],
222    index: &mut usize,
223    flag: &str,
224) -> Result<String, String> {
225    let value = next_value(values, index, flag)?;
226    if value.trim().is_empty() {
227        return Err(format!("{flag} requires a value"));
228    }
229    Ok(value)
230}
231
232fn parse_debug_command(values: &[String]) -> Result<DebugCliCommand, String> {
233    if values.first().map(String::as_str) == Some("app-server")
234        && values.get(1).map(String::as_str) == Some("send-message")
235        && values.len() >= 3
236    {
237        return Ok(DebugCliCommand::AppServerSendMessage {
238            message: values[2..].join(" "),
239        });
240    }
241    Err(format!("unknown debug command\n\n{}", help_text()))
242}
243
244fn parse_out_dir(values: &[String], command: &str) -> Result<PathBuf, String> {
245    let values = normalize_equals_arguments(values.to_vec());
246    if values.first().map(String::as_str) != Some("--out") || values.len() != 2 {
247        return Err(format!("{command} requires --out <dir>"));
248    }
249    let out = values.get(1).expect("length checked above");
250    if out.trim().is_empty() || cli_flag(out) {
251        return Err(format!("{command} requires --out <dir>"));
252    }
253    Ok(PathBuf::from(out))
254}
255
256struct ParsedCliArgs {
257    prompt: Option<String>,
258    backend: String,
259    model: String,
260    settings_file: PathBuf,
261    workspace: PathBuf,
262    max_cycles: u32,
263    language: String,
264    agent_type: Option<String>,
265    temperature: Option<f64>,
266    top_p: Option<f64>,
267    max_tokens: Option<u32>,
268    verbose: bool,
269}
270
271impl ParsedCliArgs {
272    fn with_default_settings(default_settings: String) -> Self {
273        Self {
274            prompt: None,
275            backend: "moonshot".to_string(),
276            model: "kimi-k2.6".to_string(),
277            settings_file: PathBuf::from(default_settings),
278            workspace: PathBuf::from("./workspace"),
279            max_cycles: 80,
280            language: "zh-CN".to_string(),
281            agent_type: None,
282            temperature: None,
283            top_p: None,
284            max_tokens: None,
285            verbose: false,
286        }
287    }
288
289    fn consume(&mut self, values: Vec<String>) -> Result<(), String> {
290        let values = normalize_equals_arguments(values);
291        let mut index = 0;
292        while index < values.len() {
293            let flag = &values[index];
294            index += 1;
295            match flag.as_str() {
296                "--prompt" => self.prompt = Some(next_prompt(&values, &mut index)?),
297                "--backend" => self.backend = next_value(&values, &mut index, "--backend")?,
298                "--model" => self.model = next_value(&values, &mut index, "--model")?,
299                "--settings-file" => {
300                    self.settings_file =
301                        PathBuf::from(next_value(&values, &mut index, "--settings-file")?)
302                }
303                "--workspace" => {
304                    self.workspace = PathBuf::from(next_value(&values, &mut index, "--workspace")?)
305                }
306                "--max-cycles" => {
307                    let raw = next_value(&values, &mut index, "--max-cycles")?;
308                    self.max_cycles = raw
309                        .parse::<u32>()
310                        .map_err(|_| "--max-cycles must be an integer".to_string())?
311                        .max(1);
312                }
313                "--language" => self.language = next_value(&values, &mut index, "--language")?,
314                "--agent-type" => {
315                    self.agent_type = Some(next_value(&values, &mut index, "--agent-type")?)
316                        .filter(|value| !value.trim().is_empty())
317                }
318                "--temperature" => {
319                    let raw = next_value(&values, &mut index, "--temperature")?;
320                    self.temperature = Some(parse_temperature(&raw)?);
321                }
322                "--top-p" => {
323                    let raw = next_value(&values, &mut index, "--top-p")?;
324                    self.top_p = Some(parse_top_p(&raw)?);
325                }
326                "--max-tokens" => {
327                    let raw = next_value(&values, &mut index, "--max-tokens")?;
328                    self.max_tokens = Some(parse_positive_u32(&raw, "--max-tokens")?);
329                }
330                "--verbose" => self.verbose = true,
331                "--help" | "-h" => return Err(help_text()),
332                other => return Err(format!("unknown argument: {other}\n\n{}", help_text())),
333            }
334        }
335        Ok(())
336    }
337
338    fn finish(self) -> Result<CliArgs, String> {
339        let Some(prompt) = self.prompt.filter(|value| !value.trim().is_empty()) else {
340            return Err(format!("--prompt is required\n\n{}", help_text()));
341        };
342        let model_settings =
343            if self.temperature.is_some() || self.top_p.is_some() || self.max_tokens.is_some() {
344                let settings = ModelSettings {
345                    temperature: self.temperature,
346                    top_p: self.top_p,
347                    max_tokens: self.max_tokens,
348                    ..ModelSettings::default()
349                };
350                settings.validate()?;
351                Some(settings)
352            } else {
353                None
354            };
355
356        Ok(CliArgs {
357            prompt,
358            backend: self.backend,
359            model: self.model,
360            settings_file: self.settings_file,
361            workspace: self.workspace,
362            max_cycles: self.max_cycles,
363            language: self.language,
364            agent_type: self.agent_type,
365            model_settings,
366            verbose: self.verbose,
367        })
368    }
369}
370
371fn normalize_equals_arguments(values: Vec<String>) -> Vec<String> {
372    let mut normalized = Vec::with_capacity(values.len());
373    for value in values {
374        let Some((flag, argument)) = value.split_once('=') else {
375            normalized.push(value);
376            continue;
377        };
378        if value_flag(flag) {
379            normalized.push(flag.to_string());
380            normalized.push(argument.to_string());
381        } else {
382            normalized.push(value);
383        }
384    }
385    normalized
386}
387
388fn next_prompt(values: &[String], index: &mut usize) -> Result<String, String> {
389    let start = *index;
390    while *index < values.len() && !cli_flag(&values[*index]) {
391        *index += 1;
392    }
393    if *index == start {
394        return Err("--prompt requires a value".to_string());
395    }
396    Ok(values[start..*index].join(" "))
397}
398
399fn cli_flag(value: &str) -> bool {
400    matches!(value, "--verbose" | "--help" | "-h") || value_flag(value) || value.starts_with("--")
401}
402
403fn value_flag(value: &str) -> bool {
404    matches!(
405        value,
406        "--listen"
407            | "--settings"
408            | "--timeout-seconds"
409            | "--prompt"
410            | "--backend"
411            | "--model"
412            | "--settings-file"
413            | "--workspace"
414            | "--max-cycles"
415            | "--language"
416            | "--agent-type"
417            | "--temperature"
418            | "--top-p"
419            | "--max-tokens"
420    )
421}
422
423fn parse_temperature(value: &str) -> Result<f64, String> {
424    let parsed = parse_f64(value, "--temperature")?;
425    if parsed < 0.0 {
426        return Err("--temperature must be a finite number at least 0".to_string());
427    }
428    Ok(parsed)
429}
430
431fn parse_top_p(value: &str) -> Result<f64, String> {
432    let parsed = parse_f64(value, "--top-p")?;
433    if !(0.0..=1.0).contains(&parsed) {
434        return Err("--top-p must be a finite number between 0 and 1".to_string());
435    }
436    Ok(parsed)
437}
438
439fn parse_f64(value: &str, flag: &str) -> Result<f64, String> {
440    let parsed = value
441        .parse::<f64>()
442        .map_err(|_| format!("{flag} must be a finite number"))?;
443    if !parsed.is_finite() {
444        return Err(format!("{flag} must be a finite number"));
445    }
446    Ok(parsed)
447}
448
449fn parse_positive_f64(value: &str, flag: &str) -> Result<f64, String> {
450    let parsed = value
451        .parse::<f64>()
452        .map_err(|_| format!("{flag} must be a finite positive number"))?;
453    if !parsed.is_finite() || parsed <= 0.0 {
454        return Err(format!("{flag} must be a finite positive number"));
455    }
456    Ok(parsed)
457}
458
459fn parse_positive_u32(value: &str, flag: &str) -> Result<u32, String> {
460    let parsed = value
461        .parse::<i128>()
462        .map_err(|_| format!("{flag} must be an integer"))?;
463    if !(1..=i128::from(u32::MAX)).contains(&parsed) {
464        return Err(format!("{flag} must be between 1 and {}", u32::MAX));
465    }
466    Ok(parsed as u32)
467}
468
469fn next_value(values: &[String], index: &mut usize, flag: &str) -> Result<String, String> {
470    let Some(value) = values.get(*index) else {
471        return Err(format!("{flag} requires a value"));
472    };
473    if cli_flag(value) {
474        return Err(format!("{flag} requires a value"));
475    }
476    *index += 1;
477    Ok(value.clone())
478}
479
480pub(crate) fn help_text() -> String {
481    [
482        "Run a vv-agent task against a configured LLM endpoint.",
483        "",
484        "Required:",
485        "  --prompt <text>",
486        "",
487        "Options:",
488        "  --backend <key>        Provider backend key in LLM_SETTINGS (default: moonshot)",
489        "  --model <key>          Model key in provider models (default: kimi-k2.6)",
490        "  --settings-file <path> Path to local settings (default: VV_AGENT_LOCAL_SETTINGS, V_AGENT_LOCAL_SETTINGS, or local_settings.json)",
491        "  --workspace <path>     Workspace directory (default: ./workspace)",
492        "  --max-cycles <n>       Max runtime cycles (default: 80)",
493        "  --language <locale>    System prompt language (default: zh-CN)",
494        "  --agent-type <type>    Agent type, e.g. computer",
495        "  --temperature <n>      Model sampling temperature",
496        "  --top-p <n>            Model nucleus sampling threshold",
497        "  --max-tokens <n>       Maximum generated tokens",
498        "  --verbose             Show per-cycle runtime logs",
499        "",
500        "App Server:",
501        "  app-server --listen stdio --settings <path> --backend <key> --model <key>",
502        "    [--timeout-seconds <seconds>]",
503        "  app-server generate-json-schema --out <dir>",
504        "  app-server schema --out <dir>",
505        "  app-server generate-ts --out <dir>",
506    ]
507    .join("\n")
508}