Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:14:52.836Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

main.rs

1021 lines · 37.4 KB · rust
1//! Headless CLI binary for running Omega Agent in evaluation/benchmark environments.
2//!
3//! Designed to work inside containerized environments (like Harbor/termbench) where:
4//! - The repository is already checked out at the working directory
5//! - The model API key is provided via environment variables
6//! - Results are written to an output directory (default: `/logs/agent/`)
7//!
8//! ## Usage
9//!
10//! ```text
11//! eval-cli --workdir /testbed --model anthropic/claude-sonnet-4-6-latest \
12//!          --instruction "Fix the bug described in..." --timeout 600
13//! ```
14//!
15//! ## Output
16//!
17//! Writes to `--output-dir` (default `/logs/agent/`):
18//!   - `result.json`  — structured result with status, timing, token usage,
19//!     step count, and tool-call counts (total and per tool)
20//!   - `thread.md`    — full conversation as markdown
21//!   - `thread.json`  — raw thread state as JSON
22//!
23//! ## Exit codes
24//!
25//! | Code | Meaning |
26//! |------|---------|
27//! | 0    | Agent finished |
28//! | 1    | Error (model/auth/runtime failure) |
29//! | 2    | Timeout |
30//! | 3    | Interrupted (SIGTERM/SIGINT) |
31
32mod headless;
33
34use std::path::PathBuf;
35use std::process;
36use std::rc::Rc;
37use std::str::FromStr;
38use std::sync::Arc;
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::time::{Duration, Instant};
41
42use acp_thread::AgentConnection as _;
43use agent::{NativeAgent, NativeAgentConnection, Templates, ThreadStore};
44use agent_client_protocol::schema::v1 as acp;
45use anyhow::{Context, Result};
46use clap::Parser;
47use feature_flags::FeatureFlagAppExt as _;
48
49use futures::{FutureExt, select_biased};
50use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal};
51use language_model::{
52    ANTHROPIC_PROVIDER_ID, LanguageModel, LanguageModelId, LanguageModelProviderId,
53    LanguageModelRegistry, SelectedModel,
54};
55use project::Project;
56use settings::SettingsStore;
57use util::path_list::PathList;
58
59use crate::headless::AgentCliAppState;
60
61#[derive(Parser, Debug)]
62#[command(
63    name = "eval-cli",
64    about = "Run Omega Agent headlessly in evaluation/benchmark environments"
65)]
66struct Args {
67    /// Output current environment variables as JSON to stdout.
68    /// Used internally by Omega's shell environment capture.
69    #[arg(long, hide = true)]
70    printenv: bool,
71
72    /// Path to the repository working directory. Defaults to the current directory.
73    #[arg(long, default_value = ".")]
74    workdir: PathBuf,
75
76    /// Instruction/prompt text. If omitted, read from stdin.
77    #[arg(long, allow_hyphen_values = true)]
78    instruction: Option<String>,
79
80    /// File containing additional instruction text appended after the task prompt.
81    #[arg(long)]
82    instruction_suffix_file: Option<PathBuf>,
83
84    /// Language model to use, in `provider/model` format.
85    #[arg(long, default_value = "anthropic/claude-sonnet-4-6-latest")]
86    model: String,
87
88    /// Maximum wall-clock time in seconds for the agent run.
89    #[arg(long)]
90    timeout: Option<u64>,
91
92    /// Directory for output artifacts (result.json, thread.md, thread.json).
93    #[arg(long, default_value = ".")]
94    output_dir: PathBuf,
95
96    /// Disable staff mode (staff mode is enabled by default).
97    #[arg(long)]
98    no_staff: bool,
99
100    /// Reasoning effort level for models that support thinking (low, medium, high).
101    /// Defaults to "high" for thinking-capable models.
102    #[arg(long)]
103    reasoning_effort: Option<String>,
104
105    /// Enable or disable extended thinking. Defaults to model auto-detection if omitted.
106    #[arg(long)]
107    thinking: Option<bool>,
108}
109
110enum AgentOutcome {
111    Completed,
112    Timeout { seconds: u64 },
113    Interrupted,
114}
115
116#[derive(serde::Serialize)]
117struct EvalResult {
118    status: String,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    error: Option<String>,
121    duration_secs: f64,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    timeout_secs: Option<u64>,
124    model: String,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    input_tokens: Option<u64>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    output_tokens: Option<u64>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    cache_creation_input_tokens: Option<u64>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    cache_read_input_tokens: Option<u64>,
133    /// Number of agent (assistant) turns, i.e. model round-trips in the agentic
134    /// loop. Reported as "steps" by the eval harness.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    step_count: Option<u64>,
137    /// Total number of tool calls across all steps.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    tool_call_count: Option<u64>,
140    /// Tool calls broken down by tool name.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    tool_calls: Option<std::collections::BTreeMap<String, u64>>,
143}
144
145/// Per-run statistics collected from the finished thread, written into
146/// `result.json` so the post-hoc report can compute success-conditioned metrics.
147#[derive(Default)]
148struct RunStats {
149    token_usage: Option<language_model::TokenUsage>,
150    step_count: Option<u64>,
151    tool_call_count: Option<u64>,
152    tool_calls: Option<std::collections::BTreeMap<String, u64>>,
153}
154
155const EXIT_OK: i32 = 0;
156const EXIT_ERROR: i32 = 1;
157const EXIT_TIMEOUT: i32 = 2;
158const EXIT_INTERRUPTED: i32 = 3;
159const MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
160const MODEL_DISCOVERY_POLL_INTERVAL: Duration = Duration::from_millis(100);
161
162static TERMINATED: AtomicBool = AtomicBool::new(false);
163
164fn main() {
165    let args = Args::parse();
166
167    if args.printenv {
168        util::shell_env::print_env();
169        return;
170    }
171
172    env_logger::init();
173
174    ctrlc::set_handler(|| {
175        TERMINATED.store(true, Ordering::SeqCst);
176    })
177    .expect("failed to set signal handler");
178
179    let instruction = read_instruction(&args).unwrap_or_else(|e| {
180        eprintln!("Error reading instruction: {e}");
181        process::exit(EXIT_ERROR);
182    });
183
184    let workdir = args.workdir.canonicalize().unwrap_or_else(|e| {
185        eprintln!("Invalid --workdir {:?}: {e}", args.workdir);
186        process::exit(EXIT_ERROR);
187    });
188
189    let output_dir = args.output_dir.clone();
190    if let Err(e) = std::fs::create_dir_all(&output_dir) {
191        eprintln!("Error creating output dir {}: {e}", output_dir.display());
192        process::exit(EXIT_ERROR);
193    }
194    let output_dir = output_dir.canonicalize().unwrap_or_else(|e| {
195        eprintln!("Invalid --output-dir {:?}: {e}", output_dir);
196        process::exit(EXIT_ERROR);
197    });
198
199    let http_client = Arc::new(reqwest_client::ReqwestClient::new());
200    let app = gpui_platform::headless().with_http_client(http_client);
201
202    app.run(move |cx| {
203        let app_state = headless::init(cx);
204        cx.set_staff(!args.no_staff);
205
206        // Eval hook: enable additional feature-flag-gated tools (e.g. the LSP
207        // navigation tools behind `lsp-tool` / `rename-tool`) so experiments can
208        // measure the agent with tools that aren't yet GA. Comma-separated flag
209        // names; unset in production.
210        if let Ok(raw_flags) = std::env::var("ZED_EVAL_ENABLE_FLAGS") {
211            let flags: Vec<String> = raw_flags
212                .split(',')
213                .map(|flag| flag.trim().to_string())
214                .filter(|flag| !flag.is_empty())
215                .collect();
216            if !flags.is_empty() {
217                cx.update_flags(!args.no_staff, flags);
218            }
219        }
220
221        let openai_compatible_providers_json = openai_compatible_providers_override();
222        let anthropic_available_models_json = anthropic_available_models_override();
223
224        let model_name = args.model.clone();
225        let timeout = args.timeout;
226        let thinking_override = args.thinking;
227        let reasoning_effort = args.reasoning_effort.clone();
228
229        cx.spawn(async move |cx| {
230            // Each settings change below is applied in its own `cx.update` call (rather than
231            // inline in the synchronous body of `app.run`) so that GPUI flushes the resulting
232            // `NotifyGlobalObservers` effect before we move on. Without this, the
233            // openai_compatible/anthropic provider registration (driven by an
234            // `observe_global::<SettingsStore>` callback in language_models::init) wouldn't
235            // have run yet by the time we collect `auth_tasks`, so a newly-added provider's
236            // `authenticate()` would never get called and it would be permanently stuck
237            // unauthenticated.
238            if let Some(providers_json) = &openai_compatible_providers_json {
239                let result = cx.update(|cx| apply_openai_compatible_providers(providers_json, cx));
240                if let Err(e) = result {
241                    eprintln!("Error applying {OPENAI_COMPATIBLE_PROVIDERS_ENV}: {e:#}");
242                    process::exit(EXIT_ERROR);
243                }
244            }
245
246            if let Some(models_json) = &anthropic_available_models_json {
247                let result = cx.update(|cx| apply_anthropic_available_models(models_json, cx));
248                if let Err(e) = result {
249                    eprintln!("Error applying {ANTHROPIC_AVAILABLE_MODELS_ENV}: {e:#}");
250                    process::exit(EXIT_ERROR);
251                }
252            }
253
254            let auth_tasks = cx.update(|cx| {
255                LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
256                    registry
257                        .providers()
258                        .iter()
259                        .map(|p| p.authenticate(cx))
260                        .collect::<Vec<_>>()
261                })
262            });
263            futures::future::join_all(auth_tasks).await;
264
265            let start = Instant::now();
266
267            let (outcome, stats) = run_agent(
268                &app_state,
269                &workdir,
270                &instruction,
271                &model_name,
272                timeout,
273                thinking_override,
274                reasoning_effort.as_deref(),
275                Some(&output_dir),
276                openai_compatible_providers_json.as_deref(),
277                anthropic_available_models_json.as_deref(),
278                cx,
279            )
280            .await;
281
282            let duration = start.elapsed();
283
284            let (status, error, exit_code) = match &outcome {
285                Ok(AgentOutcome::Completed) => ("completed".to_string(), None, EXIT_OK),
286                Ok(AgentOutcome::Timeout { seconds }) => {
287                    eprintln!("Timeout: agent exceeded {seconds}s time limit");
288                    ("timeout".to_string(), None, EXIT_TIMEOUT)
289                }
290                Ok(AgentOutcome::Interrupted) => {
291                    eprintln!("Interrupted: received SIGTERM, saved partial output");
292                    ("interrupted".to_string(), None, EXIT_INTERRUPTED)
293                }
294                Err(e) => {
295                    eprintln!("Error: {e:#}");
296                    ("error".to_string(), Some(format!("{e:#}")), EXIT_ERROR)
297                }
298            };
299
300            let token_usage = stats.token_usage;
301            let result = EvalResult {
302                status,
303                error,
304                duration_secs: duration.as_secs_f64(),
305                timeout_secs: timeout,
306                model: model_name.clone(),
307                input_tokens: token_usage.as_ref().map(|u| u.input_tokens),
308                output_tokens: token_usage.as_ref().map(|u| u.output_tokens),
309                cache_creation_input_tokens: token_usage
310                    .as_ref()
311                    .filter(|u| u.cache_creation_input_tokens > 0)
312                    .map(|u| u.cache_creation_input_tokens),
313                cache_read_input_tokens: token_usage
314                    .as_ref()
315                    .filter(|u| u.cache_read_input_tokens > 0)
316                    .map(|u| u.cache_read_input_tokens),
317                step_count: stats.step_count,
318                tool_call_count: stats.tool_call_count,
319                tool_calls: stats.tool_calls,
320            };
321
322            match serde_json::to_string_pretty(&result) {
323                Ok(json) => {
324                    if let Err(e) = std::fs::write(output_dir.join("result.json"), &json) {
325                        eprintln!("Error writing result.json: {e:#}");
326                    }
327                    eprintln!("[eval-cli] result: {json}");
328                }
329                Err(e) => eprintln!("Error serializing result: {e:#}"),
330            }
331
332            cx.update(|cx| cx.quit());
333            process::exit(exit_code);
334        })
335        .detach();
336    });
337}
338
339/// Name of the env var carrying a JSON object to merge into
340/// `language_models.openai_compatible` user settings before model discovery, in
341/// the same shape as Omega's `openai_compatible` settings key (provider id ->
342/// `{ "api_url": ..., "available_models": [...] }`). Lets eval-cli route the
343/// agent itself through an OpenAI-compatible endpoint (e.g. Baseten) that isn't
344/// one of Omega's built-in providers, without hardcoding it into eval-cli.
345const OPENAI_COMPATIBLE_PROVIDERS_ENV: &str = "ZED_OPENAI_COMPATIBLE_PROVIDERS";
346
347fn openai_compatible_providers_override() -> Option<String> {
348    let raw = std::env::var(OPENAI_COMPATIBLE_PROVIDERS_ENV).ok()?;
349    if raw.trim().is_empty() {
350        return None;
351    }
352    Some(raw)
353}
354
355fn apply_openai_compatible_providers(providers_json: &str, cx: &mut gpui::App) -> Result<()> {
356    let settings = format!(r#"{{"language_models": {{"openai_compatible": {providers_json}}}}}"#);
357    SettingsStore::update_global(cx, |store, cx| {
358        store.set_user_settings(&settings, cx).result()
359    })
360    .context("applying openai_compatible provider settings")?;
361    Ok(())
362}
363
364/// Name of the env var carrying a JSON array to merge into
365/// `language_models.anthropic.available_models` user settings before model
366/// discovery, in the same shape as Omega's `anthropic.available_models` settings
367/// key (a list of `{ "name": ..., "max_tokens": ..., ... }` entries). Lets
368/// eval-cli run models that exist on the Anthropic API for the configured
369/// key (e.g. early-access-program models) but aren't returned by the live
370/// `/v1/models` listing, without hardcoding them into eval-cli.
371const ANTHROPIC_AVAILABLE_MODELS_ENV: &str = "ZED_ANTHROPIC_AVAILABLE_MODELS";
372
373fn anthropic_available_models_override() -> Option<String> {
374    let raw = std::env::var(ANTHROPIC_AVAILABLE_MODELS_ENV).ok()?;
375    if raw.trim().is_empty() {
376        return None;
377    }
378    Some(raw)
379}
380
381fn apply_anthropic_available_models(models_json: &str, cx: &mut gpui::App) -> Result<()> {
382    let settings =
383        format!(r#"{{"language_models": {{"anthropic": {{"available_models": {models_json}}}}}}}"#);
384    SettingsStore::update_global(cx, |store, cx| {
385        store.set_user_settings(&settings, cx).result()
386    })
387    .context("applying anthropic available_models settings")?;
388    Ok(())
389}
390
391fn read_instruction(args: &Args) -> Result<String> {
392    let mut text = if let Some(text) = &args.instruction {
393        text.clone()
394    } else {
395        use std::io::Read;
396        let mut buf = String::new();
397        std::io::stdin()
398            .read_to_string(&mut buf)
399            .context("reading instruction from stdin")?;
400        buf
401    };
402    anyhow::ensure!(!text.trim().is_empty(), "instruction is empty");
403
404    if let Some(path) = &args.instruction_suffix_file {
405        let suffix = read_instruction_suffix_file(path)?;
406        text.push_str("\n\n");
407        text.push_str(&suffix);
408    }
409    Ok(text)
410}
411
412fn read_instruction_suffix_file(path: &PathBuf) -> Result<String> {
413    let suffix = std::fs::read_to_string(path)
414        .with_context(|| format!("reading instruction suffix file {}", path.display()))?;
415    let suffix = suffix.trim().to_string();
416    anyhow::ensure!(!suffix.is_empty(), "instruction suffix file is empty");
417    Ok(suffix)
418}
419
420async fn wait_for_model(selected: &SelectedModel, cx: &mut AsyncApp) -> Result<()> {
421    let started_at = Instant::now();
422
423    loop {
424        let found = cx.update(|cx| find_available_model(selected, cx).is_some());
425        if found {
426            return Ok(());
427        }
428
429        cx.update(|cx| ensure_provider_authenticated(selected, cx))?;
430
431        let selected_provider_has_models = cx.update(|cx| {
432            LanguageModelRegistry::global(cx)
433                .read(cx)
434                .available_models(cx)
435                .any(|model| model.provider_id() == selected.provider)
436        });
437        let should_wait_for_discovery =
438            selected.provider == ANTHROPIC_PROVIDER_ID || !selected_provider_has_models;
439
440        if !should_wait_for_discovery || started_at.elapsed() >= MODEL_DISCOVERY_TIMEOUT {
441            return Err(cx.update(|cx| model_not_found_error(&selected_model_name(selected), cx)));
442        }
443
444        cx.background_executor()
445            .timer(MODEL_DISCOVERY_POLL_INTERVAL)
446            .await;
447    }
448}
449
450fn ensure_provider_authenticated(selected: &SelectedModel, cx: &gpui::App) -> Result<()> {
451    let registry = LanguageModelRegistry::global(cx);
452    let provider = registry
453        .read(cx)
454        .provider(&selected.provider)
455        .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected.provider.0))?;
456
457    anyhow::ensure!(
458        provider.is_authenticated(cx),
459        "Provider {} is not authenticated",
460        selected.provider.0
461    );
462
463    Ok(())
464}
465
466fn find_available_model(
467    selected: &SelectedModel,
468    cx: &gpui::App,
469) -> Option<Arc<dyn LanguageModel>> {
470    let registry = LanguageModelRegistry::global(cx);
471    let models = registry.read(cx).available_models(cx).collect::<Vec<_>>();
472
473    if let Some(model) = models
474        .iter()
475        .find(|model| model.provider_id() == selected.provider && model.id() == selected.model)
476    {
477        return Some(model.clone());
478    }
479
480    models
481        .into_iter()
482        .filter(|model| {
483            model.provider_id() == selected.provider
484                && model_id_matches_selected(&model.provider_id(), &model.id(), &selected.model)
485        })
486        .max_by(|left, right| left.id().0.to_string().cmp(&right.id().0.to_string()))
487}
488
489fn model_id_matches_selected(
490    provider_id: &LanguageModelProviderId,
491    available: &LanguageModelId,
492    selected: &LanguageModelId,
493) -> bool {
494    if available == selected {
495        return true;
496    }
497
498    if provider_id != &ANTHROPIC_PROVIDER_ID {
499        return false;
500    }
501
502    anthropic_model_ids_match(available.0.as_ref(), selected.0.as_ref())
503}
504
505fn anthropic_model_ids_match(available: &str, selected: &str) -> bool {
506    let available = anthropic_model_alias_base(available);
507    let selected = anthropic_model_alias_base(selected);
508
509    available == selected || anthropic_dated_model_id_matches_base(available, selected)
510}
511
512fn anthropic_model_alias_base(mut model_id: &str) -> &str {
513    if let Some(stripped) = model_id.strip_suffix("-latest") {
514        model_id = stripped;
515    }
516    if let Some(stripped) = model_id.strip_suffix("-thinking") {
517        model_id = stripped;
518    }
519    if let Some(stripped) = model_id.strip_suffix("-1m-context") {
520        model_id = stripped;
521    }
522    model_id
523}
524
525fn anthropic_dated_model_id_matches_base(available: &str, selected: &str) -> bool {
526    let Some(suffix) = available.strip_prefix(selected) else {
527        return false;
528    };
529    let Some(date) = suffix.strip_prefix('-') else {
530        return false;
531    };
532
533    date.len() == 8 && date.chars().all(|character| character.is_ascii_digit())
534}
535
536fn selected_model_name(selected: &SelectedModel) -> String {
537    format!("{}/{}", selected.provider.0, selected.model.0)
538}
539
540fn model_not_found_error(model_name: &str, cx: &gpui::App) -> anyhow::Error {
541    let available = LanguageModelRegistry::global(cx)
542        .read(cx)
543        .available_models(cx)
544        .map(|model| format!("{}/{}", model.provider_id().0, model.id().0))
545        .collect::<Vec<_>>();
546    let available = if available.is_empty() {
547        "(none)".to_string()
548    } else {
549        available.join(", ")
550    };
551
552    anyhow::anyhow!("Model {model_name} not found. Available: {available}")
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn anthropic_latest_alias_matches_listed_base_model() {
561        assert!(model_id_matches_selected(
562            &ANTHROPIC_PROVIDER_ID,
563            &LanguageModelId("claude-sonnet-4-6".into()),
564            &LanguageModelId("claude-sonnet-4-6-latest".into()),
565        ));
566    }
567
568    #[test]
569    fn anthropic_thinking_alias_matches_listed_base_model() {
570        assert!(model_id_matches_selected(
571            &ANTHROPIC_PROVIDER_ID,
572            &LanguageModelId("claude-sonnet-4-6".into()),
573            &LanguageModelId("claude-sonnet-4-6-1m-context-thinking-latest".into()),
574        ));
575    }
576
577    #[test]
578    fn anthropic_latest_alias_matches_listed_dated_model() {
579        assert!(model_id_matches_selected(
580            &ANTHROPIC_PROVIDER_ID,
581            &LanguageModelId("claude-sonnet-4-6-20260518".into()),
582            &LanguageModelId("claude-sonnet-4-6-latest".into()),
583        ));
584    }
585
586    #[test]
587    fn non_anthropic_models_require_exact_ids() {
588        assert!(!model_id_matches_selected(
589            &LanguageModelProviderId("other".into()),
590            &LanguageModelId("claude-sonnet-4-6".into()),
591            &LanguageModelId("claude-sonnet-4-6-latest".into()),
592        ));
593    }
594}
595
596async fn run_agent(
597    app_state: &Arc<AgentCliAppState>,
598    workdir: &std::path::Path,
599    instruction: &str,
600    model_name: &str,
601    timeout: Option<u64>,
602    thinking_override: Option<bool>,
603    reasoning_effort: Option<&str>,
604    output_dir: Option<&std::path::Path>,
605    openai_compatible_providers_json: Option<&str>,
606    anthropic_available_models_json: Option<&str>,
607    cx: &mut AsyncApp,
608) -> (Result<AgentOutcome>, RunStats) {
609    let selected = match SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!("{e}")) {
610        Ok(selected) => selected,
611        Err(e) => return (Err(e), RunStats::default()),
612    };
613
614    if let Err(e) = wait_for_model(&selected, cx).await {
615        return (Err(e), RunStats::default());
616    }
617
618    let setup_result: Result<()> = cx.update(|cx| {
619        let registry = LanguageModelRegistry::global(cx);
620        let model = find_available_model(&selected, cx)
621            .ok_or_else(|| model_not_found_error(model_name, cx))?;
622        let provider = registry
623            .read(cx)
624            .provider(&model.provider_id())
625            .context("Provider not found")?;
626
627        let supports_thinking = model.supports_thinking();
628        let model_id = model.id().0.to_string();
629
630        registry.update(cx, |registry, cx| {
631            registry.set_default_model(
632                Some(language_model::ConfiguredModel { provider, model }),
633                cx,
634            );
635        });
636
637        let enable_thinking = thinking_override.unwrap_or(supports_thinking);
638        let effort = if enable_thinking {
639            match reasoning_effort {
640                Some(level) => format!("\"{level}\""),
641                None => "\"high\"".to_string(),
642            }
643        } else {
644            "null".to_string()
645        };
646        let provider_id = selected.provider.0.to_string();
647        // set_user_settings replaces the whole user settings buffer, so the
648        // openai_compatible/anthropic blocks applied earlier (before model
649        // discovery) have to be folded back in here, or they would be dropped
650        // by this call.
651        let mut language_models_fields = Vec::new();
652        if let Some(providers_json) = openai_compatible_providers_json {
653            language_models_fields.push(format!(r#""openai_compatible": {providers_json}"#));
654        }
655        if let Some(models_json) = anthropic_available_models_json {
656            language_models_fields.push(format!(
657                r#""anthropic": {{"available_models": {models_json}}}"#
658            ));
659        }
660        let language_models_settings = format!("{{{}}}", language_models_fields.join(","));
661        // Disable specific tools (e.g. `fetch`/`search_web` on air-gapped
662        // benchmarks) the canonical way: via the agent profile. The model only
663        // sees a built-in tool when the active profile enables it (see
664        // Thread::enabled_tools), so we define a dedicated "eval" profile that
665        // mirrors the built-in "write" profile minus the disabled tools, and make
666        // it the default profile. A fresh profile key is NOT deep-merged against
667        // the defaults, so it can't inherit "write"'s tools — the full set is
668        // listed explicitly here. Keep WRITE_TOOLS in sync with the "write"
669        // profile in assets/settings/default.json.
670        let profile_field = {
671            let raw = std::env::var("ZED_EVAL_DISABLE_TOOLS").unwrap_or_default();
672            let disabled = raw
673                .split(',')
674                .map(|name| name.trim().to_string())
675                .filter(|name| !name.is_empty())
676                .collect::<std::collections::HashSet<String>>();
677            if disabled.is_empty() {
678                String::new()
679            } else {
680                const WRITE_TOOLS: &[&str] = &[
681                    "copy_path",
682                    "create_directory",
683                    "create_thread",
684                    "delete_path",
685                    "diagnostics",
686                    "apply_code_action",
687                    "edit_file",
688                    "write_file",
689                    "fetch",
690                    "find_path",
691                    "find_references",
692                    "get_code_actions",
693                    "go_to_definition",
694                    "list_agents_and_models",
695                    "list_directory",
696                    "move_path",
697                    "rename_symbol",
698                    "read_file",
699                    "grep",
700                    "skill",
701                    "spawn_agent",
702                    "terminal",
703                    "search_web",
704                ];
705                let tools = WRITE_TOOLS
706                    .iter()
707                    .filter(|tool| !disabled.contains(**tool))
708                    .map(|tool| format!(r#""{tool}": true"#))
709                    .collect::<Vec<_>>()
710                    .join(", ");
711                format!(
712                    r#","default_profile": "eval", "profiles": {{"eval": {{"name": "Eval", "enable_all_context_servers": true, "tools": {{{tools}}}}}}}"#
713                )
714            }
715        };
716        SettingsStore::update_global(cx, |store, cx| {
717            let settings = format!(
718                r#"{{
719                    "language_models": {language_models_settings},
720                    "agent": {{
721                        "tool_permissions": {{"default": "allow"}},
722                        "default_model": {{
723                            "provider": "{provider_id}",
724                            "model": "{model_id}",
725                            "enable_thinking": {enable_thinking},
726                            "effort": {effort}
727                        }}{profile_field}
728                    }},
729                    "autosave": "off",
730                    "format_on_save": "off"
731                }}"
732                "#
733            );
734            store.set_user_settings(&settings, cx).result()
735        })
736        .context("updating agent settings")?;
737
738        anyhow::Ok(())
739    });
740
741    if let Err(e) = setup_result {
742        return (Err(e), RunStats::default());
743    }
744
745    let project = cx.update(|cx| {
746        Project::local(
747            app_state.client.clone(),
748            app_state.node_runtime.clone(),
749            app_state.user_store.clone(),
750            app_state.languages.clone(),
751            app_state.fs.clone(),
752            None,
753            project::LocalProjectFlags {
754                init_worktree_trust: false,
755                ..Default::default()
756            },
757            cx,
758        )
759    });
760
761    let worktree = project.update(cx, |project, cx| project.create_worktree(workdir, true, cx));
762    let worktree = match worktree.await {
763        Ok(w) => w,
764        Err(e) => return (Err(e).context("creating worktree"), RunStats::default()),
765    };
766
767    let scan_result = worktree.update(cx, |tree, _cx| {
768        tree.as_local()
769            .context("expected local worktree")
770            .map(|local| local.scan_complete())
771    });
772    match scan_result {
773        Ok(future) => future.await,
774        Err(e) => return (Err(e), RunStats::default()),
775    };
776
777    let output_worktree = match output_dir {
778        Some(output_dir) if !output_dir.starts_with(workdir) => {
779            let output_worktree = project.update(cx, |project, cx| {
780                project.create_worktree(output_dir, true, cx)
781            });
782            match output_worktree.await {
783                Ok(worktree) => Some(worktree),
784                Err(e) => {
785                    return (
786                        Err(e).context("creating output worktree"),
787                        RunStats::default(),
788                    );
789                }
790            }
791        }
792        _ => None,
793    };
794
795    if let Some(output_worktree) = output_worktree {
796        let scan_result = output_worktree.update(cx, |tree, _cx| {
797            tree.as_local()
798                .context("expected local output worktree")
799                .map(|local| local.scan_complete())
800        });
801        match scan_result {
802            Ok(future) => future.await,
803            Err(e) => return (Err(e), RunStats::default()),
804        };
805    }
806
807    let agent = cx.update(|cx| {
808        let thread_store = cx.new(|cx| ThreadStore::new(cx));
809        NativeAgent::new(thread_store, Templates::new(), app_state.fs.clone(), cx)
810    });
811
812    let connection = Rc::new(NativeAgentConnection(agent.clone()));
813    let acp_thread = match cx
814        .update(|cx| {
815            connection
816                .clone()
817                .new_session(project, PathList::new(&[workdir]), cx)
818        })
819        .await
820    {
821        Ok(t) => t,
822        Err(e) => return (Err(e).context("creating ACP session"), RunStats::default()),
823    };
824
825    let _subscription = cx.subscribe(&acp_thread, |acp_thread, event, cx| {
826        log_acp_thread_event(&acp_thread, event, cx);
827    });
828
829    let message = vec![acp::ContentBlock::Text(acp::TextContent::new(
830        instruction.to_string(),
831    ))];
832
833    let send_future = acp_thread.update(cx, |acp_thread: &mut acp_thread::AcpThread, cx| {
834        acp_thread.send(message, cx)
835    });
836
837    let timeout_future = if let Some(timeout_secs) = timeout {
838        futures::future::Either::Left(
839            cx.background_executor()
840                .timer(Duration::from_secs(timeout_secs)),
841        )
842    } else {
843        futures::future::Either::Right(futures::future::pending::<()>())
844    };
845
846    let sigterm_future = {
847        let executor = cx.background_executor().clone();
848        async move {
849            while !TERMINATED.load(Ordering::Relaxed) {
850                executor.timer(Duration::from_millis(100)).await;
851            }
852        }
853    };
854
855    let outcome = select_biased! {
856        result = send_future.fuse() => match result {
857            Ok(Some(response)) => {
858                eprintln!("[eval-cli] stopped: {:?}", response.stop_reason);
859                if response.stop_reason == acp::StopReason::MaxTokens {
860                    Err(anyhow::anyhow!("Model hit maximum token limit"))
861                } else {
862                    Ok(AgentOutcome::Completed)
863                }
864            }
865            Ok(None) => {
866                eprintln!("[eval-cli] completed (no response)");
867                Ok(AgentOutcome::Completed)
868            }
869            Err(e) => Err(e).context("agent run failed"),
870        },
871        _ = sigterm_future.fuse() => {
872            eprintln!("[eval-cli] received SIGTERM, cancelling...");
873            acp_thread.update(cx, |t: &mut acp_thread::AcpThread, cx| t.cancel(cx)).await;
874            Ok(AgentOutcome::Interrupted)
875        },
876        _ = timeout_future.fuse() => {
877            acp_thread.update(cx, |t: &mut acp_thread::AcpThread, cx| t.cancel(cx)).await;
878            Ok(AgentOutcome::Timeout { seconds: timeout.unwrap_or(0) })
879        }
880    };
881
882    let thread = cx.update(|cx| {
883        let session_id = acp_thread.read(cx).session_id().clone();
884        connection.thread(&session_id, cx)
885    });
886
887    let mut step_count = None;
888    let mut tool_call_count = None;
889    let mut tool_calls = None;
890    let cumulative_usage = if let Some(thread) = &thread {
891        let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx));
892        let db_thread = db_thread.await;
893        let mut counts = std::collections::BTreeMap::<String, u64>::new();
894        let mut agent_turn_count = 0;
895        for message in &db_thread.messages {
896            let Some(agent_message) = message.as_agent_message() else {
897                continue;
898            };
899            agent_turn_count += 1;
900            for request_message in agent_message.to_request() {
901                for content in request_message.content {
902                    if let language_model::MessageContent::ToolUse(tool_use) = content {
903                        *counts.entry(tool_use.name.to_string()).or_default() += 1;
904                    }
905                }
906            }
907        }
908        step_count = Some(agent_turn_count);
909        tool_call_count = Some(counts.values().sum());
910        tool_calls = Some(counts);
911        let usage = db_thread.cumulative_token_usage;
912        if usage.input_tokens > 0 || usage.output_tokens > 0 {
913            Some(usage)
914        } else {
915            None
916        }
917    } else {
918        None
919    };
920
921    let acp_usage = cx.update(|cx| {
922        acp_thread
923            .read(cx)
924            .token_usage()
925            .map(|usage| language_model::TokenUsage {
926                input_tokens: usage.input_tokens,
927                output_tokens: usage.output_tokens,
928                ..Default::default()
929            })
930    });
931
932    let final_usage = cumulative_usage.or(acp_usage);
933
934    if let (Some(thread), Some(dir)) = (&thread, output_dir) {
935        let markdown = thread.read_with(cx, |thread, _cx| thread.to_markdown());
936        if let Err(e) = std::fs::write(dir.join("thread.md"), markdown) {
937            eprintln!("Error writing thread.md: {e:#}");
938        }
939
940        let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx));
941        let db_thread = db_thread.await;
942        match serde_json::to_string_pretty(&db_thread) {
943            Ok(json) => {
944                if let Err(e) = std::fs::write(dir.join("thread.json"), json) {
945                    eprintln!("Error writing thread.json: {e:#}");
946                }
947            }
948            Err(e) => eprintln!("Error serializing thread.json: {e:#}"),
949        }
950    }
951
952    (
953        outcome,
954        RunStats {
955            token_usage: final_usage,
956            step_count,
957            tool_call_count,
958            tool_calls,
959        },
960    )
961}
962
963fn log_acp_thread_event(
964    acp_thread: &Entity<acp_thread::AcpThread>,
965    event: &acp_thread::AcpThreadEvent,
966    cx: &mut gpui::App,
967) {
968    match event {
969        acp_thread::AcpThreadEvent::NewEntry => {
970            let entries = acp_thread.read(cx).entries();
971            if let Some(acp_thread::AgentThreadEntry::AssistantMessage(message)) = entries.last() {
972                for chunk in &message.chunks {
973                    if let acp_thread::AssistantMessageChunk::Message { id: _, block } = chunk {
974                        if let acp_thread::ContentBlock::Markdown { markdown } = block {
975                            let text = markdown.read(cx).source().to_string();
976                            if !text.is_empty() {
977                                eprint!("{text}");
978                            }
979                        }
980                    }
981                }
982            }
983        }
984        acp_thread::AcpThreadEvent::EntryUpdated(index) => {
985            let entries = acp_thread.read(cx).entries();
986            if let Some(acp_thread::AgentThreadEntry::ToolCall(tool_call)) = entries.get(*index) {
987                if let Some(name) = &tool_call.tool_name {
988                    match &tool_call.status {
989                        acp_thread::ToolCallStatus::Completed => {
990                            eprintln!("[tool] {name} ✓");
991                        }
992                        acp_thread::ToolCallStatus::Failed => {
993                            eprintln!("[tool] {name} ✗");
994                        }
995                        acp_thread::ToolCallStatus::Rejected => {
996                            eprintln!("[tool] {name} rejected");
997                        }
998                        acp_thread::ToolCallStatus::Canceled => {
999                            eprintln!("[tool] {name} canceled");
1000                        }
1001                        _ => {}
1002                    }
1003                }
1004            }
1005        }
1006        acp_thread::AcpThreadEvent::Stopped(reason) => {
1007            eprintln!("\n[eval-cli] stopped: {reason:?}");
1008        }
1009        acp_thread::AcpThreadEvent::Error => {
1010            eprintln!("[eval-cli] error event");
1011        }
1012        acp_thread::AcpThreadEvent::Retry(status) => {
1013            eprintln!("[eval-cli] retry: {status:?}");
1014        }
1015        acp_thread::AcpThreadEvent::SubagentSpawned(session_id) => {
1016            eprintln!("[eval-cli] subagent spawned: {session_id}");
1017        }
1018        _ => {}
1019    }
1020}
1021
Served at tenant.openagents/omega Member data and write actions are omitted.