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}