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