Skip to main content

zeroclaw_runtime/doctor/
mod.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::io::Write;
4use std::path::Path;
5use zeroclaw_config::schema::Config;
6
7const DAEMON_STALE_SECONDS: i64 = 30;
8const SCHEDULER_STALE_SECONDS: i64 = 120;
9const CHANNEL_STALE_SECONDS: i64 = 300;
10const COMMAND_VERSION_PREVIEW_CHARS: usize = 60;
11
12// ── Diagnostic item ──────────────────────────────────────────────
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17    Ok,
18    Warn,
19    Error,
20}
21
22/// Structured diagnostic result for programmatic consumption (web dashboard, API).
23#[derive(Debug, Clone, serde::Serialize)]
24pub struct DiagResult {
25    pub severity: Severity,
26    pub category: String,
27    pub message: String,
28}
29
30struct DiagItem {
31    severity: Severity,
32    category: &'static str,
33    message: String,
34}
35
36impl DiagItem {
37    fn ok(category: &'static str, msg: impl Into<String>) -> Self {
38        Self {
39            severity: Severity::Ok,
40            category,
41            message: msg.into(),
42        }
43    }
44    fn warn(category: &'static str, msg: impl Into<String>) -> Self {
45        Self {
46            severity: Severity::Warn,
47            category,
48            message: msg.into(),
49        }
50    }
51    fn error(category: &'static str, msg: impl Into<String>) -> Self {
52        Self {
53            severity: Severity::Error,
54            category,
55            message: msg.into(),
56        }
57    }
58
59    #[cfg(test)]
60    fn icon(&self) -> &'static str {
61        match self.severity {
62            Severity::Ok => "✅",
63            Severity::Warn => "⚠️ ",
64            Severity::Error => "❌",
65        }
66    }
67
68    fn into_result(self) -> DiagResult {
69        DiagResult {
70            severity: self.severity,
71            category: self.category.to_string(),
72            message: self.message,
73        }
74    }
75}
76
77// ── Public entry points ──────────────────────────────────────────
78
79/// Run diagnostics and return structured results (for API/web dashboard).
80pub fn diagnose(config: &Config) -> Vec<DiagResult> {
81    let mut items: Vec<DiagItem> = Vec::new();
82
83    check_config_semantics(config, &mut items);
84    check_workspace(config, &mut items);
85    check_daemon_state(config, &mut items);
86    check_environment(&mut items);
87    check_cli_tools(&mut items);
88
89    items.into_iter().map(DiagItem::into_result).collect()
90}
91
92/// Run diagnostics and print human-readable report to stdout.
93async fn probe_models(config: &Config) -> Vec<DiagResult> {
94    let targets = doctor_model_targets(config, None);
95    let mut out = Vec::new();
96
97    for provider_name in &targets {
98        let result = match create_doctor_model_provider(config, provider_name) {
99            Ok(handle) => handle.list_models().await,
100            Err(e) => Err(e),
101        };
102        match result {
103            Ok(models) => out.push(DiagResult {
104                severity: Severity::Ok,
105                category: "providers.models".to_string(),
106                message: format!("{}: {} models", provider_name, models.len()),
107            }),
108            Err(e) => {
109                let text = format_error_chain(&e);
110                let severity = match classify_model_probe_error(&text) {
111                    ModelProbeOutcome::Skipped => Severity::Warn,
112                    ModelProbeOutcome::AuthOrAccess => Severity::Warn,
113                    ModelProbeOutcome::Ok | ModelProbeOutcome::Error => Severity::Error,
114                };
115                out.push(DiagResult {
116                    severity,
117                    category: "providers.models".to_string(),
118                    message: format!("{}: {}", provider_name, truncate_for_display(&text, 120)),
119                });
120            }
121        }
122    }
123
124    out
125}
126
127pub async fn run(config: &Config) -> Result<()> {
128    let mut results = diagnose(config);
129    results.extend(probe_models(config).await);
130
131    println!("🩺 ZeroClaw Doctor (enhanced)");
132    println!();
133
134    let mut current_cat = String::new();
135    for item in &results {
136        if item.category != current_cat {
137            current_cat = item.category.clone();
138            println!("  [{current_cat}]");
139        }
140        let icon = match item.severity {
141            Severity::Ok => "✅",
142            Severity::Warn => "⚠️ ",
143            Severity::Error => "❌",
144        };
145        println!("    {} {}", icon, item.message);
146    }
147
148    let errors = results
149        .iter()
150        .filter(|i| i.severity == Severity::Error)
151        .count();
152    let warns = results
153        .iter()
154        .filter(|i| i.severity == Severity::Warn)
155        .count();
156    let oks = results
157        .iter()
158        .filter(|i| i.severity == Severity::Ok)
159        .count();
160
161    println!();
162    println!("  Summary: {oks} ok, {warns} warnings, {errors} errors");
163
164    if errors > 0 {
165        println!("  💡 Fix the errors above, then run `zeroclaw doctor` again.");
166    }
167
168    Ok(())
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum ModelProbeOutcome {
173    Ok,
174    Skipped,
175    AuthOrAccess,
176    Error,
177}
178
179fn model_probe_status_label(outcome: ModelProbeOutcome) -> &'static str {
180    match outcome {
181        ModelProbeOutcome::Ok => "ok",
182        ModelProbeOutcome::Skipped => "skipped",
183        ModelProbeOutcome::AuthOrAccess => "auth/access",
184        ModelProbeOutcome::Error => "error",
185    }
186}
187
188fn classify_model_probe_error(err_message: &str) -> ModelProbeOutcome {
189    let lower = err_message.to_lowercase();
190
191    if lower.contains("does not support live model discovery") {
192        return ModelProbeOutcome::Skipped;
193    }
194
195    if [
196        "401",
197        "403",
198        "429",
199        "unauthorized",
200        "forbidden",
201        "api key",
202        "token",
203        "insufficient balance",
204        "insufficient quota",
205        "plan does not include",
206        "rate limit",
207    ]
208    .iter()
209    .any(|hint| lower.contains(hint))
210    {
211        return ModelProbeOutcome::AuthOrAccess;
212    }
213
214    ModelProbeOutcome::Error
215}
216
217fn doctor_model_targets(config: &Config, provider_override: Option<&str>) -> Vec<String> {
218    if let Some(model_provider) = provider_override.map(str::trim).filter(|p| !p.is_empty()) {
219        return vec![model_provider.to_string()];
220    }
221
222    config
223        .providers
224        .models
225        .iter_entries()
226        .map(|(type_k, alias_k, _)| format!("{type_k}.{alias_k}"))
227        .collect()
228}
229
230fn configured_model_provider_api_key<'a>(
231    config: &'a Config,
232    provider_name: &str,
233) -> Option<&'a str> {
234    let (family, alias) = provider_name
235        .split_once('.')
236        .unwrap_or((provider_name, "default"));
237
238    config
239        .providers
240        .models
241        .find(family, alias)
242        .and_then(|entry| entry.api_key.as_deref())
243}
244
245fn create_doctor_model_provider(
246    config: &Config,
247    provider_name: &str,
248) -> anyhow::Result<Box<dyn zeroclaw_api::model_provider::ModelProvider>> {
249    let api_key = configured_model_provider_api_key(config, provider_name);
250    let options = zeroclaw_providers::options_for_provider_ref(
251        config,
252        provider_name,
253        &zeroclaw_providers::ModelProviderRuntimeOptions::default(),
254    );
255
256    match provider_name.split_once('.') {
257        Some((family, alias)) => zeroclaw_providers::create_model_provider_for_alias(
258            config, family, alias, api_key, &options,
259        ),
260        None => {
261            zeroclaw_providers::create_model_provider_with_options(provider_name, api_key, &options)
262        }
263    }
264}
265
266pub async fn run_models(
267    config: &Config,
268    provider_override: Option<&str>,
269    _use_cache: bool,
270    show_model_names: bool,
271) -> Result<()> {
272    let targets = doctor_model_targets(config, provider_override);
273
274    if targets.is_empty() {
275        anyhow::bail!(
276            "No configured model_providers to probe — run `zeroclaw quickstart` to set one up first"
277        );
278    }
279
280    println!("🩺 ZeroClaw Doctor — Model Catalog Probe");
281    println!("  Providers to probe: {}", targets.len());
282    println!();
283
284    let mut ok_count = 0usize;
285    let mut skipped_count = 0usize;
286    let mut auth_count = 0usize;
287    let mut error_count = 0usize;
288    let mut matrix_rows: Vec<(String, ModelProbeOutcome, Option<usize>, String)> = Vec::new();
289
290    for provider_name in &targets {
291        println!("  [{}]", provider_name);
292
293        let outcome = match create_doctor_model_provider(config, provider_name) {
294            Ok(handle) => handle.list_models().await,
295            Err(e) => Err(e),
296        };
297
298        match outcome {
299            Ok(models) => {
300                ok_count += 1;
301                println!("    ✅ {} models", models.len());
302                if show_model_names && !models.is_empty() {
303                    for m in &models {
304                        println!("      • {}", m);
305                    }
306                }
307                matrix_rows.push((
308                    provider_name.clone(),
309                    ModelProbeOutcome::Ok,
310                    Some(models.len()),
311                    "catalog fetched".to_string(),
312                ));
313            }
314            Err(error) => {
315                let error_text = format_error_chain(&error);
316                match classify_model_probe_error(&error_text) {
317                    ModelProbeOutcome::Skipped => {
318                        skipped_count += 1;
319                        println!("    ⚪ skipped: {}", truncate_for_display(&error_text, 160));
320                        matrix_rows.push((
321                            provider_name.clone(),
322                            ModelProbeOutcome::Skipped,
323                            None,
324                            truncate_for_display(&error_text, 120),
325                        ));
326                    }
327                    ModelProbeOutcome::AuthOrAccess => {
328                        auth_count += 1;
329                        println!(
330                            "    ⚠️  auth/access: {}",
331                            truncate_for_display(&error_text, 160)
332                        );
333                        matrix_rows.push((
334                            provider_name.clone(),
335                            ModelProbeOutcome::AuthOrAccess,
336                            None,
337                            truncate_for_display(&error_text, 120),
338                        ));
339                    }
340                    ModelProbeOutcome::Error | ModelProbeOutcome::Ok => {
341                        error_count += 1;
342                        println!("    ❌ error: {}", truncate_for_display(&error_text, 160));
343                        matrix_rows.push((
344                            provider_name.clone(),
345                            ModelProbeOutcome::Error,
346                            None,
347                            truncate_for_display(&error_text, 120),
348                        ));
349                    }
350                }
351            }
352        }
353
354        println!();
355    }
356
357    println!(
358        "  Summary: {} ok, {} skipped, {} auth/access, {} errors",
359        ok_count, skipped_count, auth_count, error_count
360    );
361
362    if !matrix_rows.is_empty() {
363        println!();
364        println!("  Connectivity matrix:");
365        println!(
366            "  {:<18} {:<12} {:<8} detail",
367            "model_provider", "status", "models"
368        );
369        println!(
370            "  {:<18} {:<12} {:<8} ------",
371            "------------------", "------------", "--------"
372        );
373        for (model_provider, outcome, models_count, detail) in matrix_rows {
374            let models_text = models_count
375                .map(|count| count.to_string())
376                .unwrap_or_else(|| "-".to_string());
377            println!(
378                "  {:<18} {:<12} {:<8} {}",
379                model_provider,
380                model_probe_status_label(outcome),
381                models_text,
382                detail
383            );
384        }
385    }
386
387    if auth_count > 0 {
388        println!(
389            "  💡 Some model_providers need valid API keys/plan access before `/models` can be fetched."
390        );
391    }
392
393    if provider_override.is_some() && ok_count == 0 {
394        anyhow::bail!("Model probe failed for target model_provider")
395    }
396
397    Ok(())
398}
399
400pub fn run_traces(
401    config: &Config,
402    id: Option<&str>,
403    event_filter: Option<&str>,
404    contains: Option<&str>,
405    limit: usize,
406) -> Result<()> {
407    let path = crate::observability::runtime_trace::resolve_trace_path(
408        &config.observability,
409        &config.data_dir,
410    );
411
412    if let Some(target_id) = id.map(str::trim).filter(|value| !value.is_empty()) {
413        match crate::observability::runtime_trace::find_event_by_id(&path, target_id)? {
414            Some(event) => {
415                println!("{}", serde_json::to_string_pretty(&event)?);
416            }
417            None => {
418                println!(
419                    "No runtime trace event found for id '{}' (path: {}).",
420                    target_id,
421                    path.display()
422                );
423            }
424        }
425        return Ok(());
426    }
427
428    if !path.exists() {
429        println!(
430            "Runtime trace file not found: {}.\n\
431             Enable [observability] log_persistence = \"rolling\" or \"full\", then reproduce the issue.",
432            path.display()
433        );
434        return Ok(());
435    }
436
437    let safe_limit = limit.max(1);
438    let events = crate::observability::runtime_trace::load_events(
439        &path,
440        safe_limit,
441        event_filter,
442        contains,
443    )?;
444
445    if events.is_empty() {
446        println!(
447            "No runtime trace events matched query (path: {}).",
448            path.display()
449        );
450        return Ok(());
451    }
452
453    println!("Runtime traces (newest first)");
454    println!("Path: {}", path.display().to_string());
455    println!(
456        "Filters: event={} contains={} limit={}",
457        event_filter.unwrap_or("*"),
458        contains.unwrap_or("*"),
459        safe_limit
460    );
461    println!();
462
463    for event in events {
464        let outcome = match event.event.outcome.as_str() {
465            "success" => "ok",
466            "failure" => "fail",
467            _ => "-",
468        };
469        let message = event.message.unwrap_or_default();
470        let preview = truncate_for_display(&message, 80);
471        println!(
472            "- {} | {} | {} | {} | {}",
473            event.timestamp, event.id, event.event.action, outcome, preview
474        );
475    }
476
477    println!();
478    println!("Use `zeroclaw doctor traces --id <trace-id>` to inspect a full event payload.");
479    Ok(())
480}
481
482// ── Config semantic validation ───────────────────────────────────
483
484fn check_config_semantics(config: &Config, items: &mut Vec<DiagItem>) {
485    let cat = "config";
486
487    // Config file exists
488    if config.config_path.exists() {
489        items.push(DiagItem::ok(
490            cat,
491            format!("config file: {}", config.config_path.display().to_string()),
492        ));
493    } else {
494        items.push(DiagItem::error(
495            cat,
496            format!(
497                "config file not found: {}",
498                config.config_path.display().to_string()
499            ),
500        ));
501    }
502
503    // ModelProvider validity — check each configured provider entry
504    {
505        let mut found_any = false;
506        for (family, alias, entry) in config.providers.models.iter_entries() {
507            found_any = true;
508            let label = format!("{family}.{alias}");
509            if let Some(reason) = provider_validation_error(family) {
510                items.push(DiagItem::error(
511                    cat,
512                    format!("model_provider \"{label}\" is invalid: {reason}"),
513                ));
514            } else {
515                items.push(DiagItem::ok(
516                    cat,
517                    format!("model_provider \"{label}\" is valid"),
518                ));
519            }
520
521            // API key presence
522            if family != "ollama" {
523                if entry.api_key.as_deref().is_some() {
524                    items.push(DiagItem::ok(cat, format!("{label}: API key configured")));
525                } else {
526                    items.push(DiagItem::warn(
527                        cat,
528                        format!("{label}: no api_key set (may rely on env vars or model_provider defaults)"),
529                    ));
530                }
531            }
532
533            // Model configured
534            if let Some(model) = entry.model.as_deref() {
535                items.push(DiagItem::ok(cat, format!("{label}: model: {model}")));
536            } else {
537                items.push(DiagItem::warn(cat, format!("{label}: no model configured")));
538            }
539
540            // Temperature range
541            match entry.temperature {
542                Some(temperature) if (0.0..=2.0).contains(&temperature) => {
543                    items.push(DiagItem::ok(
544                        cat,
545                        format!(
546                            "{label}: temperature {temperature:.1} (valid range 0.0\u{2013}2.0)"
547                        ),
548                    ));
549                }
550                Some(temperature) => {
551                    items.push(DiagItem::error(
552                        cat,
553                        format!(
554                            "{label}: temperature {temperature:.1} is out of range (expected 0.0\u{2013}2.0)"
555                        ),
556                    ));
557                }
558                None => {
559                    items.push(DiagItem::ok(
560                        cat,
561                        format!("{label}: temperature unset (provider default)"),
562                    ));
563                }
564            }
565        }
566        if !found_any {
567            items.push(DiagItem::error(cat, "no model providers configured"));
568        }
569    }
570
571    // Gateway port range
572    let port = config.gateway.port;
573    if port > 0 {
574        items.push(DiagItem::ok(cat, format!("gateway port: {port}")));
575    } else {
576        items.push(DiagItem::error(cat, "gateway port is 0 (invalid)"));
577    }
578
579    // Model routes validation
580    for route in &config.model_routes {
581        if route.hint.is_empty() {
582            items.push(DiagItem::warn(cat, "model route with empty hint"));
583        }
584        if let Some(reason) = provider_validation_error(&route.model_provider) {
585            items.push(DiagItem::warn(
586                cat,
587                format!(
588                    "model route \"{}\" uses invalid model_provider \"{}\": {}",
589                    route.hint, route.model_provider, reason
590                ),
591            ));
592        }
593        if route.model.is_empty() {
594            items.push(DiagItem::warn(
595                cat,
596                format!("model route \"{}\" has empty model", route.hint),
597            ));
598        }
599    }
600
601    // Embedding routes validation
602    for route in &config.embedding_routes {
603        if route.hint.trim().is_empty() {
604            items.push(DiagItem::warn(cat, "embedding route with empty hint"));
605        }
606        if let Some(reason) = embedding_provider_validation_error(&route.model_provider) {
607            items.push(DiagItem::warn(
608                cat,
609                format!(
610                    "embedding route \"{}\" uses invalid model_provider \"{}\": {}",
611                    route.hint, route.model_provider, reason
612                ),
613            ));
614        }
615        if route.model.trim().is_empty() {
616            items.push(DiagItem::warn(
617                cat,
618                format!("embedding route \"{}\" has empty model", route.hint),
619            ));
620        }
621        if route.dimensions.is_some_and(|value| value == 0) {
622            items.push(DiagItem::warn(
623                cat,
624                format!(
625                    "embedding route \"{}\" has invalid dimensions=0",
626                    route.hint
627                ),
628            ));
629        }
630    }
631
632    if let Some(hint) = config
633        .memory
634        .embedding_model
635        .strip_prefix("hint:")
636        .map(str::trim)
637        .filter(|value| !value.is_empty())
638        && !config
639            .embedding_routes
640            .iter()
641            .any(|route| route.hint.trim() == hint)
642    {
643        items.push(DiagItem::warn(
644                cat,
645                format!(
646                    "memory.embedding_model uses hint \"{hint}\" but no matching [[embedding_routes]] entry exists"
647                ),
648            ));
649    }
650
651    // gateway.web_dist_dir: flag values that rely on shell expansion the
652    // gateway does not perform. Parallel check lives in
653    // `src/commands/self_test.rs::check_web_dist_dir`; keep the wording
654    // and predicate in sync.
655    check_web_dist_dir(config, items);
656
657    // Channel: at least one configured
658    let cc = &config.channels;
659    let has_channel = cc.channels().iter().any(|info| info.configured);
660
661    if has_channel {
662        items.push(DiagItem::ok(cat, "at least one channel configured"));
663    } else {
664        items.push(DiagItem::warn(
665            cat,
666            "no channels configured — run `zeroclaw quickstart` to set one up",
667        ));
668    }
669
670    // Delegate agents: model_provider validity (resolved from model_provider alias)
671    let mut agent_names: Vec<_> = config.agents.keys().collect();
672    agent_names.sort();
673    for name in agent_names {
674        let agent = config.agents.get(name).unwrap();
675        let provider_type = agent
676            .model_provider
677            .split_once('.')
678            .map_or(agent.model_provider.as_str(), |(t, _)| t);
679        if provider_type.is_empty() {
680            continue;
681        }
682        if let Some(reason) = provider_validation_error(provider_type) {
683            items.push(DiagItem::warn(
684                cat,
685                format!(
686                    "agent \"{name}\" uses invalid model_provider \"{provider_type}\": {reason}",
687                ),
688            ));
689        }
690    }
691}
692
693/// Flag `gateway.web_dist_dir` values that rely on shell-style expansion
694/// (a leading `~` or any `$VAR` / `${VAR}`). The gateway reads this field
695/// verbatim and never invokes a shell, so values like `~/web-dist` or
696/// `$HOME/web-dist` resolve to literal on-disk paths and silently fail to
697/// find the bundled assets — surface that here at `zeroclaw doctor` time
698/// instead of at runtime. Parallel check lives in
699/// `src/commands/self_test.rs::check_web_dist_dir`.
700///
701/// User-facing message goes through Fluent
702/// (`cli-doctor-web-dist-dir-expansion-warning`) per AGENTS.md §
703/// Localization — no bare Rust literals for CLI output. Reason phrases
704/// are Fluent keys too (`cli-web-dist-dir-reason-{tilde,dollar}`).
705fn check_web_dist_dir(config: &Config, items: &mut Vec<DiagItem>) {
706    let cat = "config";
707    match config.gateway.web_dist_dir.as_deref() {
708        None => {}
709        Some(value) => match web_dist_dir_expansion_reason_key(value) {
710            None => {}
711            Some(reason_key) => {
712                let reason = crate::i18n::get_required_cli_string(reason_key);
713                let message = crate::i18n::get_required_cli_string_with_args(
714                    "cli-doctor-web-dist-dir-expansion-warning",
715                    &[("path", value), ("reason", reason.as_str())],
716                );
717                items.push(DiagItem::warn(cat, message));
718            }
719        },
720    }
721}
722
723/// Return the Fluent reason key when `value` looks like it expects
724/// shell expansion the gateway will not perform. `None` means the value
725/// is a literal path that the gateway can resolve as-is.
726fn web_dist_dir_expansion_reason_key(value: &str) -> Option<&'static str> {
727    if value.starts_with('~') {
728        Some("cli-web-dist-dir-reason-tilde")
729    } else if value.contains('$') {
730        Some("cli-web-dist-dir-reason-dollar")
731    } else {
732        None
733    }
734}
735
736fn provider_validation_error(name: &str) -> Option<String> {
737    match zeroclaw_providers::create_model_provider(name, None) {
738        Ok(_) => None,
739        Err(err) => Some(
740            err.to_string()
741                .lines()
742                .next()
743                .unwrap_or("invalid model_provider")
744                .into(),
745        ),
746    }
747}
748
749fn embedding_provider_validation_error(name: &str) -> Option<String> {
750    let normalized = name.trim();
751    if normalized.eq_ignore_ascii_case("none") || normalized.eq_ignore_ascii_case("openai") {
752        return None;
753    }
754
755    let Some(url) = normalized.strip_prefix("custom:") else {
756        return Some("supported values: none, openai, custom:<url>".into());
757    };
758
759    let url = url.trim();
760    if url.is_empty() {
761        return Some("custom model_provider requires a non-empty URL after 'custom:'".into());
762    }
763
764    match reqwest::Url::parse(url) {
765        Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => None,
766        Ok(parsed) => Some(format!(
767            "custom model_provider URL must use http/https, got '{}'",
768            parsed.scheme()
769        )),
770        Err(err) => Some(format!("invalid custom model_provider URL: {err}")),
771    }
772}
773
774// ── Workspace integrity ──────────────────────────────────────────
775
776fn check_workspace(config: &Config, items: &mut Vec<DiagItem>) {
777    let cat = "workspace";
778    let ws = &config.data_dir;
779
780    if ws.exists() {
781        items.push(DiagItem::ok(
782            cat,
783            format!("directory exists: {}", ws.display().to_string()),
784        ));
785    } else {
786        items.push(DiagItem::error(
787            cat,
788            format!("directory missing: {}", ws.display().to_string()),
789        ));
790        return;
791    }
792
793    // Writable check
794    let probe = workspace_probe_path(ws);
795    match std::fs::OpenOptions::new()
796        .write(true)
797        .create_new(true)
798        .open(&probe)
799    {
800        Ok(mut probe_file) => {
801            let write_result = probe_file.write_all(b"probe");
802            drop(probe_file);
803            let _ = std::fs::remove_file(&probe);
804            match write_result {
805                Ok(()) => items.push(DiagItem::ok(cat, "directory is writable")),
806                Err(e) => items.push(DiagItem::error(
807                    cat,
808                    format!("directory write probe failed: {e}"),
809                )),
810            }
811        }
812        Err(e) => {
813            items.push(DiagItem::error(
814                cat,
815                format!("directory is not writable: {e}"),
816            ));
817        }
818    }
819
820    // Disk space (best-effort via `df`)
821    if let Some(avail_mb) = disk_available_mb(ws) {
822        if avail_mb >= 100 {
823            items.push(DiagItem::ok(
824                cat,
825                format!("disk space: {avail_mb} MB available"),
826            ));
827        } else {
828            items.push(DiagItem::warn(
829                cat,
830                format!("low disk space: only {avail_mb} MB available"),
831            ));
832        }
833    }
834
835    // Key workspace files
836    check_file_exists(ws, "SOUL.md", false, cat, items);
837    check_file_exists(ws, "AGENTS.md", false, cat, items);
838}
839
840fn check_file_exists(
841    base: &Path,
842    name: &str,
843    required: bool,
844    cat: &'static str,
845    items: &mut Vec<DiagItem>,
846) {
847    let path = base.join(name);
848    if path.is_file() {
849        items.push(DiagItem::ok(cat, format!("{name} present")));
850    } else if required {
851        items.push(DiagItem::error(cat, format!("{name} missing")));
852    } else {
853        items.push(DiagItem::warn(cat, format!("{name} not found (optional)")));
854    }
855}
856
857fn disk_available_mb(path: &Path) -> Option<u64> {
858    let output = std::process::Command::new("df")
859        .arg("-m")
860        .arg(path)
861        .output()
862        .ok()?;
863    if !output.status.success() {
864        return None;
865    }
866    let stdout = String::from_utf8_lossy(&output.stdout);
867    parse_df_available_mb(&stdout)
868}
869
870fn parse_df_available_mb(stdout: &str) -> Option<u64> {
871    let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
872    let avail = line.split_whitespace().nth(3)?;
873    avail.parse::<u64>().ok()
874}
875
876fn workspace_probe_path(workspace_dir: &Path) -> std::path::PathBuf {
877    let nanos = std::time::SystemTime::now()
878        .duration_since(std::time::UNIX_EPOCH)
879        .map_or(0, |duration| duration.as_nanos());
880    workspace_dir.join(format!(
881        ".zeroclaw_doctor_probe_{}_{}",
882        std::process::id(),
883        nanos
884    ))
885}
886
887// ── Daemon state (original logic, preserved) ─────────────────────
888
889fn check_daemon_state(config: &Config, items: &mut Vec<DiagItem>) {
890    let cat = "daemon";
891    let state_file = crate::daemon::state_file_path(config);
892
893    if !state_file.exists() {
894        items.push(DiagItem::error(
895            cat,
896            format!(
897                "state file not found: {} — is the daemon running?",
898                state_file.display()
899            ),
900        ));
901        return;
902    }
903
904    let raw = match std::fs::read_to_string(&state_file) {
905        Ok(r) => r,
906        Err(e) => {
907            items.push(DiagItem::error(cat, format!("cannot read state file: {e}")));
908            return;
909        }
910    };
911
912    let snapshot: serde_json::Value = match serde_json::from_str(&raw) {
913        Ok(v) => v,
914        Err(e) => {
915            items.push(DiagItem::error(cat, format!("invalid state JSON: {e}")));
916            return;
917        }
918    };
919
920    // Daemon heartbeat freshness
921    let updated_at = snapshot
922        .get("updated_at")
923        .and_then(serde_json::Value::as_str)
924        .unwrap_or("");
925
926    if let Ok(ts) = DateTime::parse_from_rfc3339(updated_at) {
927        let age = Utc::now()
928            .signed_duration_since(ts.with_timezone(&Utc))
929            .num_seconds();
930        if age <= DAEMON_STALE_SECONDS {
931            items.push(DiagItem::ok(cat, format!("heartbeat fresh ({age}s ago)")));
932        } else {
933            items.push(DiagItem::error(
934                cat,
935                format!("heartbeat stale ({age}s ago)"),
936            ));
937        }
938    } else {
939        items.push(DiagItem::error(
940            cat,
941            format!("invalid daemon timestamp: {updated_at}"),
942        ));
943    }
944
945    // Components
946    if let Some(components) = snapshot
947        .get("components")
948        .and_then(serde_json::Value::as_object)
949    {
950        // Scheduler
951        if let Some(scheduler) = components.get("scheduler") {
952            let scheduler_ok = scheduler
953                .get("status")
954                .and_then(serde_json::Value::as_str)
955                .is_some_and(|s| s == "ok");
956            let scheduler_age = scheduler
957                .get("last_ok")
958                .and_then(serde_json::Value::as_str)
959                .and_then(parse_rfc3339)
960                .map_or(i64::MAX, |dt| {
961                    Utc::now().signed_duration_since(dt).num_seconds()
962                });
963
964            if scheduler_ok && scheduler_age <= SCHEDULER_STALE_SECONDS {
965                items.push(DiagItem::ok(
966                    cat,
967                    format!("scheduler healthy (last ok {scheduler_age}s ago)"),
968                ));
969            } else {
970                items.push(DiagItem::error(
971                    cat,
972                    format!("scheduler unhealthy (ok={scheduler_ok}, age={scheduler_age}s)"),
973                ));
974            }
975        } else {
976            items.push(DiagItem::warn(cat, "scheduler component not tracked yet"));
977        }
978
979        // Channels
980        let mut channel_count = 0u32;
981        let mut stale = 0u32;
982        for (name, component) in components {
983            if !name.starts_with("channel:") {
984                continue;
985            }
986            channel_count += 1;
987            let status_ok = component
988                .get("status")
989                .and_then(serde_json::Value::as_str)
990                .is_some_and(|s| s == "ok");
991            let age = component
992                .get("last_ok")
993                .and_then(serde_json::Value::as_str)
994                .and_then(parse_rfc3339)
995                .map_or(i64::MAX, |dt| {
996                    Utc::now().signed_duration_since(dt).num_seconds()
997                });
998
999            if status_ok && age <= CHANNEL_STALE_SECONDS {
1000                items.push(DiagItem::ok(cat, format!("{name} fresh ({age}s ago)")));
1001            } else {
1002                stale += 1;
1003                items.push(DiagItem::error(
1004                    cat,
1005                    format!("{name} stale (ok={status_ok}, age={age}s)"),
1006                ));
1007            }
1008        }
1009
1010        if channel_count == 0 {
1011            items.push(DiagItem::warn(cat, "no channel components tracked yet"));
1012        } else if stale > 0 {
1013            items.push(DiagItem::warn(
1014                cat,
1015                format!("{channel_count} channels, {stale} stale"),
1016            ));
1017        }
1018    }
1019}
1020
1021// ── Environment checks ───────────────────────────────────────────
1022
1023fn check_environment(items: &mut Vec<DiagItem>) {
1024    let cat = "environment";
1025
1026    // git
1027    check_command_available("git", &["--version"], cat, items);
1028
1029    // Shell — Unix uses $SHELL, Windows uses %ComSpec% (path to cmd.exe).
1030    let shell = std::env::var("SHELL")
1031        .ok()
1032        .filter(|s| !s.is_empty())
1033        .or_else(|| std::env::var("ComSpec").ok().filter(|s| !s.is_empty()));
1034    match shell {
1035        Some(s) => items.push(DiagItem::ok(cat, format!("shell: {s}"))),
1036        None => items.push(DiagItem::warn(cat, "neither $SHELL nor %ComSpec% is set")),
1037    }
1038
1039    // HOME
1040    if std::env::var("HOME").is_ok() || std::env::var("USERPROFILE").is_ok() {
1041        items.push(DiagItem::ok(cat, "home directory env set"));
1042    } else {
1043        items.push(DiagItem::error(
1044            cat,
1045            "neither $HOME nor $USERPROFILE is set",
1046        ));
1047    }
1048
1049    // Optional tools
1050    check_command_available("curl", &["--version"], cat, items);
1051}
1052
1053fn check_cli_tools(items: &mut Vec<DiagItem>) {
1054    let cat = "cli-tools";
1055
1056    let discovered = crate::tools::discover_cli_tools(&[], &[]);
1057
1058    if discovered.is_empty() {
1059        items.push(DiagItem::warn(cat, "No CLI tools found in PATH"));
1060    } else {
1061        for cli in &discovered {
1062            let version_info = cli
1063                .version
1064                .as_deref()
1065                .map(|v| truncate_for_display(v, COMMAND_VERSION_PREVIEW_CHARS))
1066                .unwrap_or_else(|| "unknown version".to_string());
1067            items.push(DiagItem::ok(
1068                cat,
1069                format!("{} ({}) — {}", cli.name, cli.category, version_info),
1070            ));
1071        }
1072        items.push(DiagItem::ok(
1073            cat,
1074            format!("{} CLI tools discovered", discovered.len()),
1075        ));
1076    }
1077}
1078
1079fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &mut Vec<DiagItem>) {
1080    match std::process::Command::new(cmd)
1081        .args(args)
1082        .stdout(std::process::Stdio::piped())
1083        .stderr(std::process::Stdio::piped())
1084        .output()
1085    {
1086        Ok(output) if output.status.success() => {
1087            let ver = String::from_utf8_lossy(&output.stdout);
1088            let first_line = ver.lines().next().unwrap_or("").trim();
1089            let display = truncate_for_display(first_line, COMMAND_VERSION_PREVIEW_CHARS);
1090            items.push(DiagItem::ok(cat, format!("{cmd}: {display}")));
1091        }
1092        Ok(_) => {
1093            items.push(DiagItem::warn(
1094                cat,
1095                format!("{cmd} found but returned non-zero"),
1096            ));
1097        }
1098        Err(_) => {
1099            items.push(DiagItem::warn(cat, format!("{cmd} not found in PATH")));
1100        }
1101    }
1102}
1103
1104fn format_error_chain(error: &anyhow::Error) -> String {
1105    let mut parts = Vec::new();
1106    for cause in error.chain() {
1107        let message = cause.to_string();
1108        if !message.is_empty() {
1109            parts.push(message);
1110        }
1111    }
1112
1113    if parts.is_empty() {
1114        return String::new();
1115    }
1116
1117    parts.join(": ")
1118}
1119
1120fn truncate_for_display(input: &str, max_chars: usize) -> String {
1121    let mut chars = input.chars();
1122    let preview: String = chars.by_ref().take(max_chars).collect();
1123    if chars.next().is_some() {
1124        format!("{preview}…")
1125    } else {
1126        preview
1127    }
1128}
1129
1130// ── Helpers ──────────────────────────────────────────────────────
1131
1132fn parse_rfc3339(raw: &str) -> Option<DateTime<Utc>> {
1133    DateTime::parse_from_rfc3339(raw)
1134        .ok()
1135        .map(|dt| dt.with_timezone(&Utc))
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use tempfile::TempDir;
1142
1143    #[test]
1144    fn provider_validation_checks_custom_url_shape() {
1145        assert!(provider_validation_error("openrouter").is_none());
1146        assert!(provider_validation_error("custom:https://example.com").is_none());
1147        assert!(provider_validation_error("anthropic-custom:https://example.com").is_none());
1148
1149        let invalid_custom = provider_validation_error("custom:").unwrap_or_default();
1150        assert!(invalid_custom.contains("requires a URL"));
1151
1152        let invalid_unknown = provider_validation_error("totally-fake").unwrap_or_default();
1153        assert!(invalid_unknown.contains("Unknown model_provider"));
1154    }
1155
1156    #[test]
1157    fn diag_item_icons() {
1158        assert_eq!(DiagItem::ok("t", "m").icon(), "✅");
1159        assert_eq!(DiagItem::warn("t", "m").icon(), "⚠️ ");
1160        assert_eq!(DiagItem::error("t", "m").icon(), "❌");
1161    }
1162
1163    #[test]
1164    fn config_validation_catches_bad_temperature() {
1165        // Single model_provider entry with an out-of-range temperature so the
1166        // doctor's `iter_entries()` walk deterministically finds it
1167        // (HashMap iteration order is unspecified — multiple entries
1168        // produce a coin-flip iteration order).
1169        let mut config = Config::default();
1170        config
1171            .providers
1172            .models
1173            .ensure("openrouter", "default")
1174            .expect("known model_provider type")
1175            .temperature = Some(5.0);
1176        let mut items = Vec::new();
1177        check_config_semantics(&config, &mut items);
1178        let temp_item = items.iter().find(|i| i.message.contains("temperature"));
1179        assert!(temp_item.is_some());
1180        assert_eq!(temp_item.unwrap().severity, Severity::Error);
1181    }
1182
1183    #[test]
1184    fn config_validation_accepts_valid_temperature() {
1185        let mut config = Config::default();
1186        config
1187            .providers
1188            .models
1189            .ensure("openrouter", "default")
1190            .expect("known model_provider type")
1191            .temperature = Some(0.7);
1192        let mut items = Vec::new();
1193        check_config_semantics(&config, &mut items);
1194        let temp_item = items.iter().find(|i| i.message.contains("temperature"));
1195        assert!(temp_item.is_some());
1196        assert_eq!(temp_item.unwrap().severity, Severity::Ok);
1197    }
1198
1199    #[test]
1200    fn config_validation_warns_no_channels() {
1201        let config = Config::default();
1202        let mut items = Vec::new();
1203        check_config_semantics(&config, &mut items);
1204        let ch_item = items.iter().find(|i| i.message.contains("channel"));
1205        assert!(ch_item.is_some());
1206        assert_eq!(ch_item.unwrap().severity, Severity::Warn);
1207    }
1208
1209    #[test]
1210    fn configured_model_provider_api_key_uses_alias_profile() {
1211        let mut config = Config::default();
1212        config
1213            .providers
1214            .models
1215            .ensure("custom", "local")
1216            .expect("known model_provider type")
1217            .api_key = Some("redacted-test-key".to_string());
1218
1219        assert_eq!(
1220            configured_model_provider_api_key(&config, "custom.local"),
1221            Some("redacted-test-key")
1222        );
1223        assert_eq!(configured_model_provider_api_key(&config, "custom"), None);
1224    }
1225
1226    #[test]
1227    fn doctor_model_provider_uses_alias_profile() {
1228        let mut config = Config::default();
1229        let profile = config
1230            .providers
1231            .models
1232            .ensure("custom", "local")
1233            .expect("known model_provider type");
1234        profile.api_key = Some("redacted-test-key".to_string());
1235        profile.uri = Some("https://models.example.test/v1".to_string());
1236
1237        if let Err(error) = create_doctor_model_provider(&config, "custom.local") {
1238            panic!("doctor model probe should build custom providers from alias config: {error}");
1239        }
1240    }
1241
1242    #[test]
1243    fn config_validation_catches_unknown_provider() {
1244        // Typed slots can only hold canonical family names, so an unknown
1245        // family can no longer reach `iter_entries()`. The
1246        // remaining reachable path is `agent.model_provider`, which is a
1247        // free-form `String` an operator can set to any dotted ref.
1248        let mut config = Config::default();
1249        config.agents.insert(
1250            "broken".to_string(),
1251            zeroclaw_config::schema::AliasedAgentConfig {
1252                model_provider: "totally-fake.default".into(),
1253                risk_profile: "default".to_string(),
1254                ..Default::default()
1255            },
1256        );
1257        let mut items = Vec::new();
1258        check_config_semantics(&config, &mut items);
1259        let prov_item = items.iter().find(|i| {
1260            i.message
1261                .contains("agent \"broken\" uses invalid model_provider \"totally-fake\"")
1262        });
1263        assert!(
1264            prov_item.is_some(),
1265            "doctor should flag unknown agent model_provider"
1266        );
1267        assert_eq!(prov_item.unwrap().severity, Severity::Warn);
1268    }
1269
1270    // The pre-Phase-6 tests `config_validation_catches_malformed_custom_provider`
1271    // and `config_validation_accepts_custom_provider` are obsolete: the typed
1272    // ModelProviders container can't represent malformed `custom:` outer keys at
1273    // all. Custom-URL model_providers now live under the `custom` typed slot with the
1274    // operator-supplied URL in `base.uri`. The malformed-custom-key validator
1275    // path is unreachable.
1276
1277    #[test]
1278    fn config_validation_warns_empty_model_route() {
1279        let config = Config {
1280            model_routes: vec![zeroclaw_config::schema::ModelRouteConfig {
1281                hint: "fast".into(),
1282                model_provider: "groq".into(),
1283                model: String::new(),
1284                api_key: None,
1285            }],
1286            ..Config::default()
1287        };
1288        let mut items = Vec::new();
1289        check_config_semantics(&config, &mut items);
1290        let route_item = items.iter().find(|i| i.message.contains("empty model"));
1291        assert!(route_item.is_some());
1292        assert_eq!(route_item.unwrap().severity, Severity::Warn);
1293    }
1294
1295    #[test]
1296    fn config_validation_warns_empty_embedding_route_model() {
1297        let config = Config {
1298            embedding_routes: vec![zeroclaw_config::schema::EmbeddingRouteConfig {
1299                hint: "semantic".into(),
1300                model_provider: "openai".into(),
1301                model: String::new(),
1302                dimensions: Some(1536),
1303                api_key: None,
1304            }],
1305            ..Config::default()
1306        };
1307
1308        let mut items = Vec::new();
1309        check_config_semantics(&config, &mut items);
1310        let route_item = items.iter().find(|item| {
1311            item.message
1312                .contains("embedding route \"semantic\" has empty model")
1313        });
1314        assert!(route_item.is_some());
1315        assert_eq!(route_item.unwrap().severity, Severity::Warn);
1316    }
1317
1318    #[test]
1319    fn config_validation_warns_invalid_embedding_route_provider() {
1320        let config = Config {
1321            embedding_routes: vec![zeroclaw_config::schema::EmbeddingRouteConfig {
1322                hint: "semantic".into(),
1323                model_provider: "groq".into(),
1324                model: "text-embedding-3-small".into(),
1325                dimensions: None,
1326                api_key: None,
1327            }],
1328            ..Config::default()
1329        };
1330
1331        let mut items = Vec::new();
1332        check_config_semantics(&config, &mut items);
1333        let route_item = items.iter().find(|item| {
1334            item.message
1335                .contains("uses invalid model_provider \"groq\"")
1336        });
1337        assert!(route_item.is_some());
1338        assert_eq!(route_item.unwrap().severity, Severity::Warn);
1339    }
1340
1341    #[test]
1342    fn config_validation_warns_missing_embedding_hint_target() {
1343        let mut config = Config::default();
1344        config.memory.embedding_model = "hint:semantic".into();
1345
1346        let mut items = Vec::new();
1347        check_config_semantics(&config, &mut items);
1348        let route_item = items.iter().find(|item| {
1349            item.message
1350                .contains("no matching [[embedding_routes]] entry exists")
1351        });
1352        assert!(route_item.is_some());
1353        assert_eq!(route_item.unwrap().severity, Severity::Warn);
1354    }
1355
1356    #[test]
1357    fn environment_check_finds_git() {
1358        let mut items = Vec::new();
1359        check_environment(&mut items);
1360        let git_item = items.iter().find(|i| i.message.starts_with("git:"));
1361        // git should be available in any CI/dev environment
1362        assert!(git_item.is_some());
1363        assert_eq!(git_item.unwrap().severity, Severity::Ok);
1364    }
1365
1366    #[test]
1367    fn parse_df_available_mb_uses_last_data_line() {
1368        let stdout =
1369            "Filesystem 1M-blocks Used Available Use% Mounted on\n/dev/sda1 1000 500 500 50% /\n";
1370        assert_eq!(parse_df_available_mb(stdout), Some(500));
1371    }
1372
1373    #[test]
1374    fn truncate_for_display_preserves_utf8_boundaries() {
1375        let preview = truncate_for_display("🙂example-alpha-build", 3);
1376        assert_eq!(preview, "🙂ex…");
1377    }
1378
1379    #[test]
1380    fn workspace_probe_path_is_hidden_and_unique() {
1381        let tmp = TempDir::new().unwrap();
1382        let first = workspace_probe_path(tmp.path());
1383        let second = workspace_probe_path(tmp.path());
1384
1385        assert_ne!(first, second);
1386        assert!(
1387            first
1388                .file_name()
1389                .and_then(|name| name.to_str())
1390                .is_some_and(|name| name.starts_with(".zeroclaw_doctor_probe_"))
1391        );
1392    }
1393
1394    #[test]
1395    fn diagnose_flags_web_dist_dir_with_tilde() {
1396        // Asserts the localized Fluent message resolves and inlines the path +
1397        // the tilde reason — the diagnostic now goes through Fluent per
1398        // AGENTS.md (#6961 Round 3).
1399        let mut config = Config::default();
1400        config.gateway.web_dist_dir = Some("~/web-dist".to_string());
1401
1402        let expected_reason = crate::i18n::get_required_cli_string("cli-web-dist-dir-reason-tilde");
1403        let expected_message = crate::i18n::get_required_cli_string_with_args(
1404            "cli-doctor-web-dist-dir-expansion-warning",
1405            &[("path", "~/web-dist"), ("reason", expected_reason.as_str())],
1406        );
1407
1408        let results = diagnose(&config);
1409        let hit = results
1410            .iter()
1411            .find(|item| item.category == "config" && item.message == expected_message);
1412        assert!(
1413            hit.is_some(),
1414            "doctor should flag web_dist_dir = \"~/web-dist\" with the localized warning; \
1415             expected message: {expected_message:?}; got: {results:?}"
1416        );
1417        assert_eq!(hit.unwrap().severity, Severity::Warn);
1418    }
1419
1420    #[test]
1421    fn diagnose_flags_web_dist_dir_with_env_var() {
1422        let mut config = Config::default();
1423        config.gateway.web_dist_dir = Some("$HOME/web-dist".to_string());
1424
1425        let expected_reason =
1426            crate::i18n::get_required_cli_string("cli-web-dist-dir-reason-dollar");
1427        let expected_message = crate::i18n::get_required_cli_string_with_args(
1428            "cli-doctor-web-dist-dir-expansion-warning",
1429            &[
1430                ("path", "$HOME/web-dist"),
1431                ("reason", expected_reason.as_str()),
1432            ],
1433        );
1434
1435        let results = diagnose(&config);
1436        let hit = results
1437            .iter()
1438            .find(|item| item.category == "config" && item.message == expected_message);
1439        assert!(hit.is_some());
1440        assert_eq!(hit.unwrap().severity, Severity::Warn);
1441    }
1442
1443    #[test]
1444    fn diagnose_accepts_literal_web_dist_dir() {
1445        let mut config = Config::default();
1446        config.gateway.web_dist_dir = Some("/srv/zeroclaw/web-dist".to_string());
1447
1448        let results = diagnose(&config);
1449        assert!(
1450            !results
1451                .iter()
1452                .any(|item| item.message.contains("gateway.web_dist_dir")),
1453            "literal web_dist_dir paths should produce no doctor diagnostic"
1454        );
1455    }
1456
1457    #[test]
1458    fn web_dist_dir_expansion_reason_key_detects_tilde_and_env() {
1459        assert_eq!(
1460            web_dist_dir_expansion_reason_key("~/web-dist"),
1461            Some("cli-web-dist-dir-reason-tilde")
1462        );
1463        assert_eq!(
1464            web_dist_dir_expansion_reason_key("$HOME/web-dist"),
1465            Some("cli-web-dist-dir-reason-dollar")
1466        );
1467        assert_eq!(
1468            web_dist_dir_expansion_reason_key("${HOME}/web-dist"),
1469            Some("cli-web-dist-dir-reason-dollar")
1470        );
1471        assert!(web_dist_dir_expansion_reason_key("/srv/zeroclaw/web-dist").is_none());
1472        assert!(web_dist_dir_expansion_reason_key("./dist").is_none());
1473    }
1474
1475    #[test]
1476    fn config_validation_reports_delegate_agents_in_sorted_order() {
1477        let mut config = Config::default();
1478        config.agents.insert(
1479            "zeta".into(),
1480            zeroclaw_config::schema::AliasedAgentConfig {
1481                model_provider: "totally-fake.default".into(),
1482                ..Default::default()
1483            },
1484        );
1485        config.agents.insert(
1486            "alpha".into(),
1487            zeroclaw_config::schema::AliasedAgentConfig {
1488                model_provider: "totally-fake.default".into(),
1489                ..Default::default()
1490            },
1491        );
1492
1493        let mut items = Vec::new();
1494        check_config_semantics(&config, &mut items);
1495
1496        let agent_messages: Vec<_> = items
1497            .iter()
1498            .filter(|item| item.message.starts_with("agent \""))
1499            .map(|item| item.message.as_str())
1500            .collect();
1501
1502        assert_eq!(agent_messages.len(), 2);
1503        assert!(agent_messages[0].contains("agent \"alpha\""));
1504        assert!(agent_messages[1].contains("agent \"zeta\""));
1505    }
1506}