1use std::io::{self, Write};
4use std::path::PathBuf;
5
6use anyhow::{Result, bail};
7use zagens_core::approval::ApprovalMode;
8use zagens_core::chat::LlmClient;
9
10use crate::agent_surface::AppMode;
11use crate::cli::auto_route_cli::resolve_cli_auto_route;
12use crate::cli::context::CliContext;
13use crate::compaction::CompactionConfig;
14use crate::config::{Config, MAX_SUBAGENTS};
15use crate::core::engine::turn_loop::host_impl::app_mode_to_turn_loop;
16use crate::core::engine::{EngineConfig, spawn_engine};
17use crate::core::events::Event;
18use crate::core::events::TurnOutcomeStatus;
19use crate::core::ops::Op;
20use crate::models::compaction_threshold_for_model;
21use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
22use crate::tools::plan::new_shared_plan_state;
23use crate::tools::todo::new_shared_todo_list;
24
25pub struct ExecOptions {
26 pub prompt: String,
27 pub model: Option<String>,
28 pub auto_mode: bool,
29 pub json_output: bool,
30 pub max_subagents: Option<usize>,
31}
32
33pub async fn run_exec(ctx: &CliContext, opts: ExecOptions) -> Result<()> {
34 let model = opts
35 .model
36 .or_else(|| ctx.config.default_text_model.clone())
37 .unwrap_or_else(|| ctx.config.default_model());
38
39 if opts.auto_mode {
40 let max_subagents = opts.max_subagents.map_or_else(
41 || ctx.config.max_subagents(),
42 |value| value.clamp(1, MAX_SUBAGENTS),
43 );
44 run_exec_agent(
45 &ctx.config,
46 &model,
47 &opts.prompt,
48 ctx.workspace.clone(),
49 max_subagents,
50 ExecAgentRunOptions {
51 auto_approve: true,
52 trust_mode: true,
53 json_output: opts.json_output,
54 llm_client_override: None,
55 },
56 )
57 .await
58 } else if opts.json_output {
59 run_one_shot_json(&ctx.config, &model, &opts.prompt).await
60 } else {
61 run_one_shot(&ctx.config, &model, &opts.prompt).await
62 }
63}
64
65async fn run_one_shot(config: &Config, model: &str, prompt: &str) -> Result<()> {
66 let client = crate::client::DeepSeekClient::new(config)?;
67 let route = resolve_cli_auto_route(config, model, prompt).await;
68 let reasoning_effort = route
69 .reasoning_effort
70 .map(|effort| effort.as_setting().to_string());
71
72 let request = MessageRequest {
73 model: route.model,
74 messages: vec![Message {
75 role: "user".to_string(),
76 content: vec![ContentBlock::Text {
77 text: prompt.to_string(),
78 cache_control: None,
79 }],
80 }],
81 max_tokens: 4096,
82 system: None,
83 tools: None,
84 tool_choice: None,
85 metadata: None,
86 thinking: None,
87 reasoning_effort,
88 stream: Some(false),
89 temperature: None,
90 top_p: None,
91 };
92
93 let response = client.create_message(request).await?;
94 for block in response.content {
95 if let ContentBlock::Text { text, .. } = block {
96 println!("{text}");
97 }
98 }
99 Ok(())
100}
101
102async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result<()> {
103 let client = crate::client::DeepSeekClient::new(config)?;
104 let route = resolve_cli_auto_route(config, model, prompt).await;
105 let model = route.model;
106 let reasoning_effort = route
107 .reasoning_effort
108 .map(|effort| effort.as_setting().to_string());
109 let request = MessageRequest {
110 model: model.clone(),
111 messages: vec![Message {
112 role: "user".to_string(),
113 content: vec![ContentBlock::Text {
114 text: prompt.to_string(),
115 cache_control: None,
116 }],
117 }],
118 max_tokens: 4096,
119 system: Some(SystemPrompt::Text(
120 "You are a coding assistant. Give concise, actionable responses.".to_string(),
121 )),
122 tools: None,
123 tool_choice: None,
124 metadata: None,
125 thinking: None,
126 reasoning_effort,
127 stream: Some(false),
128 temperature: Some(0.2),
129 top_p: Some(0.9),
130 };
131
132 let response = client.create_message(request).await?;
133 let mut output = String::new();
134 for block in response.content {
135 if let ContentBlock::Text { text, .. } = block {
136 output.push_str(&text);
137 }
138 }
139 println!(
140 "{}",
141 serde_json::to_string_pretty(&serde_json::json!({
142 "mode": "one-shot",
143 "model": model,
144 "success": true,
145 "content": output
146 }))?
147 );
148 Ok(())
149}
150
151struct ExecAgentRunOptions {
152 auto_approve: bool,
153 trust_mode: bool,
154 json_output: bool,
155 llm_client_override: Option<std::sync::Arc<dyn LlmClient>>,
156}
157
158async fn run_exec_agent(
159 config: &Config,
160 model: &str,
161 prompt: &str,
162 workspace: PathBuf,
163 max_subagents: usize,
164 run: ExecAgentRunOptions,
165) -> Result<()> {
166 let ExecAgentRunOptions {
167 auto_approve,
168 trust_mode,
169 json_output,
170 llm_client_override,
171 } = run;
172 let route = resolve_cli_auto_route(config, model, prompt).await;
173 let auto_model = route.auto_model;
174 let effective_model = route.model;
175 let effective_reasoning_effort = route
176 .reasoning_effort
177 .map(|effort| effort.as_setting().to_string());
178
179 let compaction = CompactionConfig {
180 enabled: false,
181 model: effective_model.clone(),
182 token_threshold: compaction_threshold_for_model(&effective_model),
183 ..Default::default()
184 };
185
186 let network_policy = config.network.clone().map(|toml_cfg| {
187 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
188 });
189 let lsp_config = config
190 .lsp
191 .clone()
192 .map(crate::config::LspConfigToml::into_runtime);
193 let search = config.search_config();
194
195 let engine_config = EngineConfig {
196 model: effective_model.clone(),
197 workspace: workspace.clone(),
198 allow_shell: auto_approve || config.allow_shell(),
199 trust_mode,
200 notes_path: config.notes_path(),
201 mcp_config_path: config.mcp_config_path(),
202 skills_dir: config.skills_dir(),
203 instructions: crate::prompts::merge_instruction_paths_with_pick_rules(
204 &workspace,
205 config.instructions_paths(&workspace),
206 ),
207 max_steps: 100,
208 max_subagents,
209 subagent_step_timeout: config.subagent_step_timeout(),
210 features: config.features(),
211 compaction,
212 cycle: config.cycle_runtime_config(&effective_model),
213 capacity: crate::core::capacity::capacity_config_from_app(config),
214 todos: new_shared_todo_list(),
215 plan_state: new_shared_plan_state(),
216 max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
217 network_policy,
218 snapshots_enabled: config.snapshots_config().enabled,
219 snapshots_max_workspace_gb: config.snapshots_config().max_workspace_gb,
220 lsp_config,
221 runtime_services: crate::tools::spec::RuntimeToolServices::default(),
222 subagent_model_overrides: config.subagent_model_overrides(),
223 memory_enabled: config.memory_enabled(),
224 memory_path: config.memory_path(),
225 topic_memory: crate::topic_memory::settings_from_config(config),
226 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
227 goal_objective: None,
228 locale_tag: crate::localization::resolve_locale(
229 &crate::settings::Settings::load().unwrap_or_default().locale,
230 )
231 .tag()
232 .to_string(),
233 task_type: crate::task_type::TaskType::Code,
234 workshop: config.workshop.clone(),
235 scratchpad: config.scratchpad_config(),
236 long_horizon: config.long_horizon_config(),
237 llm_client_override,
238 search_provider: search.provider.unwrap_or_default(),
239 search_api_key: search.api_key,
240 };
241
242 let engine_handle = spawn_engine(engine_config, config);
243 let mode = if auto_approve {
244 AppMode::Yolo
245 } else {
246 AppMode::Agent
247 };
248
249 engine_handle
250 .send(Op::SendMessage {
251 content: prompt.to_string(),
252 mode: app_mode_to_turn_loop(mode),
253 model: effective_model.clone(),
254 goal_objective: None,
255 reasoning_effort: effective_reasoning_effort,
256 reasoning_effort_auto: auto_model,
257 auto_model,
258 allow_shell: auto_approve || config.allow_shell(),
259 trust_mode,
260 auto_approve,
261 approval_mode: if auto_approve {
262 ApprovalMode::Auto
263 } else {
264 config
265 .approval_policy
266 .as_deref()
267 .and_then(ApprovalMode::from_config_value)
268 .unwrap_or_default()
269 },
270 temperature: None,
271 top_p: None,
272 max_output_tokens: None,
273 })
274 .await?;
275
276 #[derive(serde::Serialize)]
277 struct ExecToolEntry {
278 name: String,
279 success: bool,
280 output: String,
281 }
282 #[derive(serde::Serialize, Default)]
283 struct ExecSummary {
284 mode: String,
285 model: String,
286 prompt: String,
287 output: String,
288 tools: Vec<ExecToolEntry>,
289 status: Option<String>,
290 error: Option<String>,
291 }
292
293 let mut summary = ExecSummary {
294 mode: "agent".to_string(),
295 model: effective_model,
296 prompt: prompt.to_string(),
297 ..ExecSummary::default()
298 };
299
300 let mut stdout = io::stdout();
301 let mut ends_with_newline = false;
302 let mut failed = false;
303
304 loop {
305 let event = {
306 let mut rx = engine_handle.rx_event.write().await;
307 rx.recv().await
308 };
309
310 let Some(event) = event else {
311 break;
312 };
313
314 match event {
315 Event::MessageDelta { content, .. } => {
316 summary.output.push_str(&content);
317 if !json_output {
318 print!("{content}");
319 stdout.flush()?;
320 }
321 ends_with_newline = content.ends_with('\n');
322 }
323 Event::MessageComplete { .. } if !json_output && !ends_with_newline => {
324 println!();
325 }
326 Event::ToolCallStarted { name, .. } if !json_output => {
327 eprintln!("tool: {name}");
328 }
329 Event::ToolCallComplete { name, result, .. } => match result {
330 Ok(output) => {
331 summary.tools.push(ExecToolEntry {
332 name: name.clone(),
333 success: output.success,
334 output: truncate_for_log(&output.content, 500),
335 });
336 if !json_output {
337 eprintln!("tool {name} completed");
338 }
339 }
340 Err(err) => {
341 summary.tools.push(ExecToolEntry {
342 name: name.clone(),
343 success: false,
344 output: err.to_string(),
345 });
346 if !json_output {
347 eprintln!("tool {name} failed: {err}");
348 }
349 }
350 },
351 Event::ApprovalRequired { id, tool_name, .. } => {
352 if auto_approve {
353 let _ = engine_handle.approve_tool_call(id).await;
354 } else {
355 failed = true;
356 if !json_output {
357 eprintln!(
358 "approval required for `{tool_name}` — re-run with `--auto` to allow tools"
359 );
360 }
361 let _ = engine_handle.deny_tool_call(id).await;
362 }
363 }
364 Event::UserInputRequired { id, .. } => {
365 failed = true;
366 if !json_output {
367 eprintln!("interactive user input requested — not supported in headless mode");
368 }
369 let _ = engine_handle.cancel_user_input(id).await;
370 }
371 Event::ElevationRequired {
372 tool_id,
373 tool_name,
374 denial_reason,
375 ..
376 } => {
377 if auto_approve {
378 eprintln!("sandbox denied {tool_name}: {denial_reason} (auto-elevating)");
379 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
380 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
381 } else {
382 failed = true;
383 eprintln!("sandbox denied {tool_name}: {denial_reason}");
384 let _ = engine_handle.deny_tool_call(tool_id).await;
385 }
386 }
387 Event::Error { envelope, .. } => {
388 failed = true;
389 summary.error = Some(envelope.message.clone());
390 if !json_output {
391 eprintln!("error: {}", envelope.message);
392 }
393 }
394 Event::TurnComplete { status, error, .. } => {
395 summary.status = Some(format!("{status:?}").to_lowercase());
396 summary.error = error.clone();
397 if matches!(status, TurnOutcomeStatus::Failed) || error.is_some() {
398 failed = true;
399 }
400 let _ = engine_handle.send(Op::Shutdown).await;
401 break;
402 }
403 _ => {}
404 }
405 }
406
407 if json_output {
408 println!("{}", serde_json::to_string_pretty(&summary)?);
409 }
410
411 if failed {
412 bail!("exec finished with errors");
413 }
414 Ok(())
415}
416
417fn truncate_for_log(text: &str, max: usize) -> String {
418 if text.chars().count() <= max {
419 return text.to_string();
420 }
421 let cut: String = text.chars().take(max).collect();
422 format!("{cut}…")
423}
424
425#[cfg(test)]
426mod tests {
427 use std::sync::Arc;
428
429 use crate::llm_client::mock::{MockLlmClient, canned};
430 use crate::models::Usage;
431 use tempfile::tempdir;
432
433 use super::*;
434
435 #[test]
436 fn exec_agent_json_summary_has_stable_top_level_keys() {
437 let summary = serde_json::json!({
438 "mode": "agent",
439 "model": "deepseek-v4-pro",
440 "prompt": "hello",
441 "output": "world",
442 "tools": [],
443 "status": "completed",
444 "error": null
445 });
446 for key in [
447 "mode", "model", "prompt", "output", "tools", "status", "error",
448 ] {
449 assert!(
450 summary.get(key).is_some(),
451 "exec --json summary missing key: {key}"
452 );
453 }
454 }
455
456 #[tokio::test]
457 async fn exec_agent_json_e2e_with_mock_llm() {
458 let tmp = tempdir().expect("tempdir");
459 let workspace = tmp.path().to_path_buf();
460 let config = Config::default();
461 let turn = vec![
462 canned::message_start("msg_1"),
463 canned::text_block_start(0),
464 canned::text_delta(0, "mock-cli-agent-reply"),
465 canned::block_stop(0),
466 canned::message_delta("end_turn", Some(Usage::default())),
467 canned::message_stop(),
468 ];
469 let mock = Arc::new(MockLlmClient::new(vec![turn]).with_model("deepseek-v4-pro"));
470
471 run_exec_agent(
472 &config,
473 "deepseek-v4-pro",
474 "hello mock",
475 workspace,
476 1,
477 ExecAgentRunOptions {
478 auto_approve: true,
479 trust_mode: true,
480 json_output: true,
481 llm_client_override: Some(mock.clone()),
482 },
483 )
484 .await
485 .expect("exec agent with mock LLM");
486
487 assert_eq!(mock.call_count(), 1, "mock should receive one stream call");
488 }
489}