Skip to main content

vv_agent/
cli.rs

1mod args;
2mod logging;
3mod output;
4mod task;
5
6use std::collections::BTreeMap;
7use std::env;
8use std::fmt;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::ExitCode;
12use std::sync::Arc;
13use std::time::Duration;
14
15use crate::app_server::host::DefaultAppServerHost;
16use crate::app_server::processor::MessageProcessor;
17use crate::app_server::protocol::{
18    generate_app_server_json_schema_bundle, generate_app_server_typescript_bundle, AppClientInfo,
19    AppModelInfo, JsonRpcMessage, ThreadStartParams, TurnStartParams,
20};
21use crate::app_server::server::AppServer;
22use crate::app_server::test_support::{finish_response, scripted_app_server_client};
23use crate::app_server::thread_store::SqliteThreadStore;
24use crate::app_server::transport::stdio::StdioJsonlTransport;
25use crate::config::{build_vv_llm_from_local_settings, ResolvedModelConfig};
26use crate::llm::LlmClient;
27use crate::model::{ModelError, ModelProvider};
28use crate::runtime::AgentRuntime;
29use crate::workspace::LocalWorkspaceBackend;
30use crate::{Agent, ModelRef, RunConfig, Runner};
31
32pub use self::args::{
33    parse_cli_args_from, parse_cli_args_from_with_default_settings, parse_cli_command_from,
34    parse_cli_command_from_with_default_settings, AppServerCliCommand, CliArgs, CliCommand,
35    DebugCliCommand,
36};
37use self::logging::build_cli_log_handler;
38pub use self::output::result_payload;
39use self::task::generate_task_id;
40pub use self::task::{build_cli_task, build_cli_task_from_resolved};
41
42const APP_SERVER_DEFAULT_WORKSPACE: &str = "./workspace";
43const APP_SERVER_DEFAULT_MAX_CYCLES: u32 = 80;
44const APP_SERVER_APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
45
46#[derive(Debug)]
47enum CliError {
48    Usage(String),
49    Configuration(String),
50    Runtime(String),
51}
52
53impl CliError {
54    fn exit_code(&self) -> u8 {
55        match self {
56            Self::Usage(_) | Self::Configuration(_) => 2,
57            Self::Runtime(_) => 1,
58        }
59    }
60
61    fn runtime(error: impl ToString) -> Self {
62        Self::Runtime(error.to_string())
63    }
64}
65
66impl fmt::Display for CliError {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::Usage(message) | Self::Configuration(message) | Self::Runtime(message) => {
70                formatter.write_str(message)
71            }
72        }
73    }
74}
75
76#[derive(Clone)]
77struct FixedAppServerModelProvider {
78    client: Arc<dyn LlmClient>,
79    resolved: ResolvedModelConfig,
80}
81
82impl FixedAppServerModelProvider {
83    fn new(client: Arc<dyn LlmClient>, resolved: ResolvedModelConfig) -> Self {
84        Self { client, resolved }
85    }
86
87    fn matches(&self, model: &ModelRef) -> bool {
88        match model {
89            ModelRef::Named(model) => self.matches_model_name(model),
90            ModelRef::BackendModel { backend, model } => {
91                backend == &self.resolved.backend && self.matches_model_name(model)
92            }
93            ModelRef::Resolved(resolved) => {
94                resolved.backend == self.resolved.backend
95                    && resolved.model_id == self.resolved.model_id
96            }
97        }
98    }
99
100    fn matches_model_name(&self, model: &str) -> bool {
101        model == self.resolved.requested_model
102            || model == self.resolved.selected_model
103            || model == self.resolved.model_id
104    }
105}
106
107impl ModelProvider for FixedAppServerModelProvider {
108    fn resolve(&self, model: &ModelRef) -> Result<ResolvedModelConfig, ModelError> {
109        if !self.matches(model) {
110            return Err(ModelError::Config(format!(
111                "App Server fixed model provider cannot resolve `{}`",
112                model.model()
113            )));
114        }
115        Ok(self.resolved.clone())
116    }
117
118    fn client(&self, resolved: &ResolvedModelConfig) -> Result<Arc<dyn LlmClient>, ModelError> {
119        if resolved.backend != self.resolved.backend || resolved.model_id != self.resolved.model_id
120        {
121            return Err(ModelError::Config(format!(
122                "App Server fixed model provider has no client for `{}`",
123                resolved.model_id
124            )));
125        }
126        Ok(self.client.clone())
127    }
128
129    fn default_model_ref(&self) -> Option<ModelRef> {
130        Some(ModelRef::resolved(self.resolved.clone()))
131    }
132}
133
134fn run_main() -> Result<(), CliError> {
135    let raw_args = env::args().collect::<Vec<_>>();
136    if raw_args
137        .iter()
138        .skip(1)
139        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
140    {
141        println!("{}", args::help_text());
142        return Ok(());
143    }
144    let app_server_invocation = raw_args.get(1).map(String::as_str) == Some("app-server");
145    let command = parse_cli_command_from(raw_args).map_err(|error| {
146        if app_server_invocation {
147            CliError::Usage(error)
148        } else {
149            CliError::Runtime(error)
150        }
151    })?;
152    match command {
153        CliCommand::Run(args) => run_task(args).map_err(CliError::runtime),
154        CliCommand::AppServer(command) => run_app_server_command(command),
155        CliCommand::Debug(command) => run_debug_command(command).map_err(CliError::runtime),
156    }
157}
158
159pub fn main() -> Result<(), String> {
160    run_main().map_err(|error| error.to_string())
161}
162
163pub fn process_main() -> ExitCode {
164    match run_main() {
165        Ok(()) => ExitCode::SUCCESS,
166        Err(error) => {
167            eprintln!("{error}");
168            ExitCode::from(error.exit_code())
169        }
170    }
171}
172
173fn run_task(args: CliArgs) -> Result<(), String> {
174    let (llm, resolved) =
175        build_vv_llm_from_local_settings(&args.settings_file, &args.backend, &args.model, 90.0)
176            .map_err(|err| err.to_string())?;
177
178    let mut runtime = AgentRuntime::new(llm)
179        .with_settings_file(args.settings_file.clone())
180        .with_default_backend(args.backend.clone());
181    runtime.default_workspace = Some(args.workspace.clone());
182    runtime.workspace_backend = Arc::new(LocalWorkspaceBackend::new(args.workspace.clone()));
183    runtime.log_handler = build_cli_log_handler(args.verbose);
184
185    let task = build_cli_task_from_resolved(&args, &resolved, generate_task_id())?;
186    let result = runtime.run(task).map_err(|err| err.to_string())?;
187    let payload = result_payload(&result, &resolved);
188    let output = serde_json::to_string_pretty(&payload).map_err(|err| err.to_string())?;
189    println!("{output}");
190    Ok(())
191}
192
193fn run_app_server_command(command: AppServerCliCommand) -> Result<(), CliError> {
194    match command {
195        AppServerCliCommand::ListenStdio {
196            settings_file,
197            backend,
198            model,
199            timeout_seconds,
200        } => run_app_server_stdio(settings_file, backend, model, timeout_seconds),
201        AppServerCliCommand::GenerateTs { out } => write_schema_bundle(
202            &out,
203            generate_app_server_typescript_bundle().map_err(CliError::runtime)?,
204            None,
205        )
206        .map_err(CliError::runtime),
207        AppServerCliCommand::GenerateJsonSchema { out } => write_json_schema_bundle(
208            &out,
209            generate_app_server_json_schema_bundle().map_err(CliError::runtime)?,
210        )
211        .map_err(CliError::runtime),
212    }
213}
214
215fn run_debug_command(command: DebugCliCommand) -> Result<(), String> {
216    match command {
217        DebugCliCommand::AppServerSendMessage { message } => run_debug_app_server_message(message),
218    }
219}
220
221fn run_app_server_stdio(
222    settings_file: PathBuf,
223    backend: String,
224    model: String,
225    timeout_seconds: f64,
226) -> Result<(), CliError> {
227    let workspace = PathBuf::from(APP_SERVER_DEFAULT_WORKSPACE);
228    let (processor, outgoing) = production_app_server_processor(
229        &settings_file,
230        &backend,
231        &model,
232        &workspace,
233        APP_SERVER_DEFAULT_MAX_CYCLES,
234        timeout_seconds,
235    )?;
236    let runtime = tokio::runtime::Builder::new_current_thread()
237        .enable_time()
238        .build()
239        .map_err(CliError::runtime)?;
240    runtime
241        .block_on(async {
242            AppServer::new(StdioJsonlTransport::new(), processor, outgoing)
243                .run()
244                .await
245                .map_err(|error| error.message().to_string())
246        })
247        .map_err(CliError::runtime)
248}
249
250fn production_app_server_processor(
251    settings_file: &Path,
252    backend: &str,
253    model: &str,
254    workspace: &Path,
255    max_cycles: u32,
256    timeout_seconds: f64,
257) -> Result<
258    (
259        MessageProcessor,
260        tokio::sync::mpsc::Receiver<crate::app_server::outgoing::OutgoingEnvelope>,
261    ),
262    CliError,
263> {
264    let (llm, resolved) =
265        build_vv_llm_from_local_settings(settings_file, backend, model, timeout_seconds)
266            .map_err(|error| CliError::Configuration(error.to_string()))?;
267    let model_provider: Arc<dyn ModelProvider> = Arc::new(FixedAppServerModelProvider::new(
268        Arc::new(llm),
269        resolved.clone(),
270    ));
271    let run_config = RunConfig {
272        model: Some(ModelRef::resolved(resolved.clone())),
273        model_provider: Some(model_provider.clone()),
274        workspace: Some(workspace.to_path_buf()),
275        workspace_backend: Some(Arc::new(LocalWorkspaceBackend::new(
276            workspace.to_path_buf(),
277        ))),
278        max_cycles: Some(max_cycles),
279        approval_timeout: Some(APP_SERVER_APPROVAL_TIMEOUT),
280        ..RunConfig::default()
281    };
282    let runner = Runner::builder()
283        .model_provider_arc(model_provider)
284        .workspace(workspace)
285        .default_run_config(run_config.clone())
286        .build()
287        .map_err(CliError::runtime)?;
288    let agent = Agent::builder("assistant")
289        .instructions(
290            "You are the vv-agent App Server assistant. Complete user requests with available tools.",
291        )
292        .model(ModelRef::resolved(resolved.clone()))
293        .build()
294        .map_err(CliError::runtime)?;
295    let mut metadata = std::collections::BTreeMap::new();
296    metadata.insert(
297        "requestedModel".to_string(),
298        serde_json::json!(resolved.requested_model),
299    );
300    let host = DefaultAppServerHost::new()
301        .with_agent(agent)
302        .with_run_config(run_config)
303        .with_models(vec![AppModelInfo {
304            id: resolved.model_id,
305            provider: Some(resolved.backend),
306            display_name: Some(resolved.selected_model),
307            context_length: resolved.context_length,
308            supports_tools: resolved.function_call_available,
309            metadata,
310        }]);
311    let store = SqliteThreadStore::in_memory().map_err(CliError::runtime)?;
312    Ok(MessageProcessor::with_host(
313        128,
314        runner,
315        Arc::new(host),
316        store,
317    ))
318}
319
320fn run_debug_app_server_message(message: String) -> Result<(), String> {
321    let runtime = tokio::runtime::Builder::new_current_thread()
322        .enable_time()
323        .build()
324        .map_err(|error| error.to_string())?;
325    runtime.block_on(async {
326        let mut client = scripted_app_server_client(vec![finish_response(&message)]);
327        client
328            .initialize(AppClientInfo {
329                name: "debug-cli".to_string(),
330                title: Some("Debug CLI".to_string()),
331                version: Some(env!("CARGO_PKG_VERSION").to_string()),
332            })
333            .await
334            .map_err(|error| error.to_string())?;
335        let thread = client
336            .start_thread(ThreadStartParams {
337                agent_key: "default".to_string(),
338                cwd: None,
339                metadata: Default::default(),
340            })
341            .await
342            .map_err(|error| error.to_string())?
343            .thread_id;
344        client
345            .start_turn(TurnStartParams {
346                thread_id: thread,
347                input: vec![serde_json::json!({"type": "text", "text": message.clone()})],
348                metadata: Default::default(),
349            })
350            .await
351            .map_err(|error| error.to_string())?;
352        println!(
353            "{}",
354            serde_json::to_string(&serde_json::json!({
355                "method": "debug/input",
356                "params": { "message": message }
357            }))
358            .map_err(|error| error.to_string())?
359        );
360        while let Some(next) = client.next_message().await {
361            println!(
362                "{}",
363                serde_json::to_string(&next).map_err(|error| error.to_string())?
364            );
365            if matches!(
366                next,
367                JsonRpcMessage::Notification(notification)
368                    if notification.method == "turn/completed"
369            ) {
370                break;
371            }
372        }
373        Ok(())
374    })
375}
376
377fn write_schema_bundle(
378    out: &Path,
379    bundle: crate::app_server::protocol::SchemaBundle,
380    extension: Option<&str>,
381) -> Result<(), String> {
382    fs::create_dir_all(out).map_err(|error| error.to_string())?;
383    for (name, content) in bundle {
384        let file_name = match extension {
385            Some(extension) => format!("{name}.{extension}"),
386            None => name,
387        };
388        fs::write(out.join(file_name), content).map_err(|error| error.to_string())?;
389    }
390    Ok(())
391}
392
393fn write_json_schema_bundle(
394    out: &Path,
395    bundle: crate::app_server::protocol::SchemaBundle,
396) -> Result<(), String> {
397    let json_dir = out.join("json");
398    fs::create_dir_all(&json_dir).map_err(|error| error.to_string())?;
399    let mut aggregate = BTreeMap::new();
400    for (name, content) in bundle {
401        let schema = serde_json::from_str::<serde_json::Value>(&content)
402            .map_err(|error| format!("invalid committed JSON schema {name}: {error}"))?;
403        fs::write(json_dir.join(format!("{name}.json")), content)
404            .map_err(|error| error.to_string())?;
405        aggregate.insert(name, schema);
406    }
407    let aggregate = serde_json::to_string_pretty(&aggregate).map_err(|error| error.to_string())?;
408    fs::write(
409        json_dir.join("vv_agent_app_server.schemas.json"),
410        format!("{aggregate}\n"),
411    )
412    .map_err(|error| error.to_string())
413}