Skip to main content

zagens_runtime/cli/
runner.rs

1//! Process-in Engine one-shot runner for `zagens exec` (shared with future TUI).
2
3use 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}