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        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}