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