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 sandbox_mode: config.sandbox_mode.clone(),
200 trust_mode,
201 notes_path: config.notes_path(),
202 mcp_config_path: config.mcp_config_path(),
203 skills_dir: config.skills_dir(),
204 instructions: crate::prompts::merge_instruction_paths_with_pick_rules(
205 &workspace,
206 config.instructions_paths(&workspace),
207 ),
208 max_steps: 100,
209 max_subagents,
210 subagent_step_timeout: config.subagent_step_timeout(),
211 features: config.features(),
212 compaction,
213 cycle: config.cycle_runtime_config(&effective_model),
214 capacity: crate::core::capacity::capacity_config_from_app(config),
215 todos: new_shared_todo_list(),
216 plan_state: new_shared_plan_state(),
217 max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
218 network_policy,
219 snapshots_enabled: config.snapshots_config().enabled,
220 snapshots_max_workspace_gb: config.snapshots_config().max_workspace_gb,
221 lsp_config,
222 runtime_services: crate::tools::spec::RuntimeToolServices::default(),
223 subagent_model_overrides: config.subagent_model_overrides(),
224 memory_enabled: config.memory_enabled(),
225 memory_path: config.memory_path(),
226 topic_memory: crate::topic_memory::settings_from_config(config),
227 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
228 goal_objective: None,
229 locale_tag: crate::localization::resolve_locale(
230 &crate::settings::Settings::load().unwrap_or_default().locale,
231 )
232 .tag()
233 .to_string(),
234 task_type: crate::task_type::TaskType::Code,
235 workshop: config.workshop.clone(),
236 scratchpad: config.scratchpad_config(),
237 long_horizon: config.long_horizon_config(),
238 llm_client_override,
239 search_provider: search.provider.unwrap_or_default(),
240 search_api_key: search.api_key,
241 };
242
243 let engine_handle = spawn_engine(engine_config, config);
244 let mode = if auto_approve {
245 AppMode::Yolo
246 } else {
247 AppMode::Agent
248 };
249
250 engine_handle
251 .send(Op::SendMessage {
252 content: prompt.to_string(),
253 mode: app_mode_to_turn_loop(mode),
254 model: effective_model.clone(),
255 goal_objective: None,
256 reasoning_effort: effective_reasoning_effort,
257 reasoning_effort_auto: auto_model,
258 auto_model,
259 allow_shell: auto_approve || config.allow_shell(),
260 trust_mode,
261 auto_approve,
262 approval_mode: if auto_approve {
263 ApprovalMode::Auto
264 } else {
265 config
266 .approval_policy
267 .as_deref()
268 .and_then(ApprovalMode::from_config_value)
269 .unwrap_or_default()
270 },
271 temperature: None,
272 top_p: None,
273 max_output_tokens: None,
274 })
275 .await?;
276
277 #[derive(serde::Serialize)]
278 struct ExecToolEntry {
279 name: String,
280 success: bool,
281 output: String,
282 }
283 #[derive(serde::Serialize, Default)]
284 struct ExecSummary {
285 mode: String,
286 model: String,
287 prompt: String,
288 output: String,
289 tools: Vec<ExecToolEntry>,
290 status: Option<String>,
291 error: Option<String>,
292 }
293
294 let mut summary = ExecSummary {
295 mode: "agent".to_string(),
296 model: effective_model,
297 prompt: prompt.to_string(),
298 ..ExecSummary::default()
299 };
300
301 let mut stdout = io::stdout();
302 let mut ends_with_newline = false;
303 let mut failed = false;
304
305 loop {
306 let event = {
307 let mut rx = engine_handle.rx_event.write().await;
308 rx.recv().await
309 };
310
311 let Some(event) = event else {
312 break;
313 };
314
315 match event {
316 Event::MessageDelta { content, .. } => {
317 summary.output.push_str(&content);
318 if !json_output {
319 print!("{content}");
320 stdout.flush()?;
321 }
322 ends_with_newline = content.ends_with('\n');
323 }
324 Event::MessageComplete { .. } if !json_output && !ends_with_newline => {
325 println!();
326 }
327 Event::ToolCallStarted { name, .. } if !json_output => {
328 eprintln!("tool: {name}");
329 }
330 Event::ToolCallComplete { name, result, .. } => match result {
331 Ok(output) => {
332 summary.tools.push(ExecToolEntry {
333 name: name.clone(),
334 success: output.success,
335 output: truncate_for_log(&output.content, 500),
336 });
337 if !json_output {
338 eprintln!("tool {name} completed");
339 }
340 }
341 Err(err) => {
342 summary.tools.push(ExecToolEntry {
343 name: name.clone(),
344 success: false,
345 output: err.to_string(),
346 });
347 if !json_output {
348 eprintln!("tool {name} failed: {err}");
349 }
350 }
351 },
352 Event::ApprovalRequired { id, tool_name, .. } => {
353 if auto_approve {
354 let _ = engine_handle.approve_tool_call(id).await;
355 } else {
356 failed = true;
357 if !json_output {
358 eprintln!(
359 "approval required for `{tool_name}` — re-run with `--auto` to allow tools"
360 );
361 }
362 let _ = engine_handle.deny_tool_call(id).await;
363 }
364 }
365 Event::UserInputRequired { id, .. } => {
366 failed = true;
367 if !json_output {
368 eprintln!("interactive user input requested — not supported in headless mode");
369 }
370 let _ = engine_handle.cancel_user_input(id).await;
371 }
372 Event::ElevationRequired {
373 tool_id,
374 tool_name,
375 denial_reason,
376 ..
377 } => {
378 if auto_approve {
379 eprintln!("sandbox denied {tool_name}: {denial_reason} (auto-elevating)");
380 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
381 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
382 } else {
383 failed = true;
384 eprintln!("sandbox denied {tool_name}: {denial_reason}");
385 let _ = engine_handle.deny_tool_call(tool_id).await;
386 }
387 }
388 Event::Error { envelope, .. } => {
389 failed = true;
390 summary.error = Some(envelope.message.clone());
391 if !json_output {
392 eprintln!("error: {}", envelope.message);
393 }
394 }
395 Event::TurnComplete { status, error, .. } => {
396 summary.status = Some(format!("{status:?}").to_lowercase());
397 summary.error = error.clone();
398 if matches!(status, TurnOutcomeStatus::Failed) || error.is_some() {
399 failed = true;
400 }
401 let _ = engine_handle.send(Op::Shutdown).await;
402 break;
403 }
404 _ => {}
405 }
406 }
407
408 if json_output {
409 println!("{}", serde_json::to_string_pretty(&summary)?);
410 }
411
412 if failed {
413 bail!("exec finished with errors");
414 }
415 Ok(())
416}
417
418fn truncate_for_log(text: &str, max: usize) -> String {
419 if text.chars().count() <= max {
420 return text.to_string();
421 }
422 let cut: String = text.chars().take(max).collect();
423 format!("{cut}…")
424}
425
426#[cfg(test)]
427mod tests {
428 use std::sync::Arc;
429
430 use crate::llm_client::mock::{MockLlmClient, canned};
431 use crate::models::Usage;
432 use tempfile::tempdir;
433
434 use super::*;
435
436 #[test]
437 fn exec_agent_json_summary_has_stable_top_level_keys() {
438 let summary = serde_json::json!({
439 "mode": "agent",
440 "model": "deepseek-v4-pro",
441 "prompt": "hello",
442 "output": "world",
443 "tools": [],
444 "status": "completed",
445 "error": null
446 });
447 for key in [
448 "mode", "model", "prompt", "output", "tools", "status", "error",
449 ] {
450 assert!(
451 summary.get(key).is_some(),
452 "exec --json summary missing key: {key}"
453 );
454 }
455 }
456
457 #[tokio::test]
458 async fn exec_agent_json_e2e_with_mock_llm() {
459 let tmp = tempdir().expect("tempdir");
460 let workspace = tmp.path().to_path_buf();
461 let config = Config::default();
462 let turn = vec![
463 canned::message_start("msg_1"),
464 canned::text_block_start(0),
465 canned::text_delta(0, "mock-cli-agent-reply"),
466 canned::block_stop(0),
467 canned::message_delta("end_turn", Some(Usage::default())),
468 canned::message_stop(),
469 ];
470 let mock = Arc::new(MockLlmClient::new(vec![turn]).with_model("deepseek-v4-pro"));
471
472 run_exec_agent(
473 &config,
474 "deepseek-v4-pro",
475 "hello mock",
476 workspace,
477 1,
478 ExecAgentRunOptions {
479 auto_approve: true,
480 trust_mode: true,
481 json_output: true,
482 llm_client_override: Some(mock.clone()),
483 },
484 )
485 .await
486 .expect("exec agent with mock LLM");
487
488 assert_eq!(mock.call_count(), 1, "mock should receive one stream call");
489 }
490}