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