1use std::sync::Arc;
29use std::time::{Duration, Instant};
30
31use anyhow::bail;
32use leviath_core::blueprint::ModelConfig;
33use leviath_providers::{InferenceRequest, Message, Provider};
34use leviath_runtime::ProviderRegistry;
35use leviath_runtime::control_socket::{ControlClient, ControlResponse};
36use leviath_runtime::pipeline::{providers_tried, resolve_stage_model};
37
38use crate::commands::run::session::build_provider_registry_from_config;
39use crate::config::Config;
40use crate::daemon::spawn::model_defaults;
41
42pub const DOCTOR_LONG_ABOUT: &str = "\
44Check that provider wiring works, end to end.
45
46Four checks run in order, and the first failure stops the rest. The check that
47fails is the diagnosis:
48
49 config the config file parses and a provider registry can be built.
50 Fails on a malformed config.toml.
51 resolve your default provider/model picks a provider that is actually
52 registered. Fails when a key is missing or misspelled - and
53 catches the case where a blueprint with no model falls back to
54 anthropic on a machine that has no Anthropic key.
55 inference one real call to that provider. Fails on a bad key, an unknown
56 model id, or a billing problem; the provider's own error is
57 printed verbatim, status line and response body included.
58 daemon a one-stage agent spawned over the control socket, waited on,
59 then deleted. Fails when the handoff is broken even though the
60 credentials are fine.
61
62So config/resolve/inference OK with daemon FAIL means the daemon is the
63problem, not your keys - the distinction this command exists to make.
64
65`--model` takes the same forms `lev run --model` does: `provider/model` picks
66both (the way to reach a Rhai script provider, which cannot be listed), and a
67bare model id pairs with your default_provider. Use it to try a model string
68before wiring it into a blueprint.
69
70Two inferences are billed per run, capped at 64 output tokens each.
71`--no-daemon` stops after the third check and bills one.
72
73Exits non-zero on failure, so it works as a CI gate. --json prints the same
74checks as {\"checks\": [...], \"passed\": bool}.";
75
76#[derive(clap::Args, Debug, Clone, Default)]
78pub struct DoctorArgs {
79 #[arg(short, long)]
82 pub model: Option<String>,
83
84 #[arg(long)]
87 pub no_daemon: bool,
88
89 #[arg(long)]
91 pub json: bool,
92}
93
94const DAEMON_TIMEOUT: Duration = Duration::from_secs(90);
97
98const DAEMON_POLL: Duration = Duration::from_millis(250);
100
101const PROBE_MAX_TOKENS: usize = 64;
104
105const PROBE_PROMPT: &str = "Reply with exactly: PONG";
107
108const PROBE_EXPECTED: &str = "PONG";
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
117#[serde(rename_all = "lowercase")]
118pub enum CheckStatus {
119 Ok,
121 Fail,
123}
124
125impl CheckStatus {
126 fn label(&self) -> &'static str {
128 match self {
129 Self::Ok => "OK",
130 Self::Fail => "FAIL",
131 }
132 }
133}
134
135#[derive(Debug, Clone, serde::Serialize)]
137pub struct Check {
138 pub name: &'static str,
140 pub status: CheckStatus,
142 pub detail: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub elapsed_ms: Option<u64>,
147}
148
149impl Check {
150 fn ok(name: &'static str, detail: impl Into<String>) -> Self {
152 Self {
153 name,
154 status: CheckStatus::Ok,
155 detail: detail.into(),
156 elapsed_ms: None,
157 }
158 }
159
160 fn fail(name: &'static str, detail: impl Into<String>) -> Self {
162 Self {
163 name,
164 status: CheckStatus::Fail,
165 detail: detail.into(),
166 elapsed_ms: None,
167 }
168 }
169
170 fn timed(mut self, elapsed: Duration) -> Self {
172 self.elapsed_ms = Some(elapsed.as_millis() as u64);
173 self
174 }
175}
176
177pub fn format_report(checks: &[Check]) -> String {
185 let name_width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
186 let status_width = checks
187 .iter()
188 .map(|c| c.status.label().len())
189 .max()
190 .unwrap_or(0);
191
192 let mut out = String::from("\n");
193 for check in checks {
194 out.push_str(&format!(
195 " {:<name_width$} {:<status_width$} {}",
196 check.name,
197 check.status.label(),
198 check.detail,
199 ));
200 if let Some(ms) = check.elapsed_ms {
201 out.push_str(&format!(" ({:.1}s)", ms as f64 / 1000.0));
202 }
203 out.push('\n');
204 }
205 if checks.iter().all(|c| c.status == CheckStatus::Ok) {
206 out.push_str("\ndoctor passed\n");
207 }
208 out
209}
210
211fn misdirected_rate_limits(config: &Config) -> Vec<String> {
222 let known: Vec<&str> = crate::commands::setup::catalog::providers()
223 .iter()
224 .map(|p| p.id)
225 .collect();
226 let mut misdirected: Vec<String> = config
227 .rate_limits
228 .keys()
229 .filter(|name| !known.contains(&name.as_str()))
230 .map(|name| format!("rate_limits.{name}"))
231 .collect();
232 misdirected.sort_unstable();
234 misdirected
235}
236
237fn config_check(config: &Config, registry: &ProviderRegistry) -> Check {
243 let mut names = registry.provider_names();
244 names.sort_unstable();
245 let registered = match names.is_empty() {
246 true => "none".to_string(),
247 false => names.join(", "),
248 };
249 let detail = format!(
250 "default_provider={}; registered: {} (script providers resolve by name)",
251 config.default_provider, registered
252 );
253
254 let mut unread = Config::unread_keys_at(&Config::config_path());
260 unread.extend(misdirected_rate_limits(config));
261 if unread.is_empty() {
262 return Check::ok("config", detail);
263 }
264 let subject = match unread.len() {
265 1 => "1 key in config.toml is",
266 n => &format!("{n} keys in config.toml are"),
267 };
268 Check::ok(
269 "config",
270 format!(
271 "{detail} (note: {subject} read by nothing - check the spelling: {})",
272 unread.join(", ")
273 ),
274 )
275}
276
277struct Resolved {
283 provider_name: String,
284 model: String,
285 provider: Arc<dyn Provider>,
286}
287
288fn resolve_check(
299 config: &Config,
300 model_override: Option<&str>,
301 registry: &ProviderRegistry,
302) -> (Check, Option<Resolved>) {
303 let empty = ModelConfig {
304 models: Vec::new(),
305 allow_user_default: true,
306 parameters: std::collections::HashMap::new(),
307 request_timeout_secs: None,
308 };
309 let defaults = model_defaults(config);
310 let (provider_name, model) = resolve_stage_model(&empty, model_override, &defaults, registry);
311
312 match registry.get(&provider_name) {
313 Some(provider) => (
314 Check::ok(
315 "resolve",
316 format!(
317 "{provider_name} / {model}{}",
318 default_provider_note(config, &provider_name, model_override, registry)
319 ),
320 ),
321 Some(Resolved {
322 provider_name,
323 model,
324 provider,
325 }),
326 ),
327 None => (
328 Check::fail(
329 "resolve",
330 format!(
331 "resolved to '{provider_name}', which is not configured (tried: {}). \
332 Configure it with `lev setup`, or add it to config.toml.",
333 providers_tried(&empty, model_override, &defaults)
334 ),
335 ),
336 None,
337 ),
338 }
339}
340
341fn default_provider_note(
356 config: &Config,
357 resolved: &str,
358 model_override: Option<&str>,
359 registry: &ProviderRegistry,
360) -> String {
361 if model_override.is_some() || resolved == config.default_provider {
362 return String::new();
363 }
364 if config.default_model.is_some() || !registry.has(&config.default_provider) {
370 return String::new();
371 }
372 let named = &config.default_provider;
373 format!(
374 " (note: default_provider is '{named}' but no default_model is set, \
375 so it is never chosen - add `default_model` to config.toml)"
376 )
377}
378
379async fn inference_check(provider: &dyn Provider, model: &str) -> Check {
385 let caps = provider.capabilities(model);
386 let request = InferenceRequest {
387 system: Vec::new(),
388 messages: vec![Message {
389 role: "user".to_string(),
390 content: PROBE_PROMPT.into(),
391 cache_breakpoint: false,
392 }],
393 model: model.to_string(),
394 max_tokens: PROBE_MAX_TOKENS.min(caps.max_output_tokens),
395 temperature: 0.0,
398 tools: Vec::new(),
399 extra: serde_json::Value::Null,
400 request_timeout_secs: Some(60),
401 };
402
403 let started = Instant::now();
404 match provider.infer(&request).await {
405 Ok(response) => {
406 let usage = response.tokens_used;
407 let echo = match response.content.contains(PROBE_EXPECTED) {
408 true => format!("replied {PROBE_EXPECTED}"),
409 false => format!("no {PROBE_EXPECTED} in the reply"),
412 };
413 Check::ok(
414 "inference",
415 format!(
416 "{} in / {} out / {} total, {echo}",
417 usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
418 ),
419 )
420 .timed(started.elapsed())
421 }
422 Err(e) => Check::fail("inference", e.to_string()).timed(started.elapsed()),
427 }
428}
429
430fn canary_manifest(provider: &str, model: &str) -> String {
444 let provider = serde_json::to_string(provider).expect("a str always serializes to JSON");
445 let model = serde_json::to_string(model).expect("a str always serializes to JSON");
446 format!(
447 r#"[agent]
448name = "doctor"
449version = "0.0.1"
450description = "One-turn provider probe spawned by `lev doctor`, deleted when it finishes."
451entry_stage = "ping"
452
453[stages.ping]
454mode = "autonomous"
455model = {{ models = [{{ provider = {provider}, model = {model} }}] }}
456description = "Answer once, in text."
457available_tools = []
458max_iterations = 1
459system_prompt = "Reply with exactly: {PROBE_EXPECTED}. Call no tools."
460
461[context.regions]
462task = {{ kind = "pinned", max_tokens = 1000, seed = "task" }}
463conversation = {{ kind = "sliding_window", max_items = 4, max_tokens = 2000 }}
464"#
465 )
466}
467
468fn cleanup_run(run_id: &str) {
475 let _ = crate::runstate::force_cancel(run_id);
476 let _ = std::fs::remove_dir_all(crate::runstate::run_dir(run_id));
477 let _ = leviath_core::paths::data_dir().map(|d| {
478 let _ = std::fs::remove_dir_all(d.join("state").join(run_id));
479 });
480}
481
482enum DaemonOutcome {
485 Complete(String),
487 Failed(String),
489}
490
491fn stage_canary(
496 root: &std::path::Path,
497 provider: &str,
498 model: &str,
499) -> std::io::Result<std::path::PathBuf> {
500 let agent_dir = root.join("doctor");
501 std::fs::create_dir_all(&agent_dir)?;
502 let manifest = agent_dir.join("agent.leviath");
503 std::fs::write(&manifest, canary_manifest(provider, model))?;
504 Ok(manifest)
505}
506
507async fn daemon_check(
510 client: &ControlClient,
511 provider_name: &str,
512 model: &str,
513 timeout: Duration,
514 poll: Duration,
515 root: &std::path::Path,
516) -> Check {
517 let started = Instant::now();
518 let manifest = match stage_canary(root, provider_name, model) {
519 Ok(manifest) => manifest,
520 Err(e) => return Check::fail("daemon", format!("could not stage a probe agent: {e}")),
521 };
522
523 match spawn_and_wait(client, &manifest, root, timeout, poll).await {
524 DaemonOutcome::Complete(detail) => Check::ok("daemon", detail).timed(started.elapsed()),
525 DaemonOutcome::Failed(detail) => Check::fail("daemon", detail).timed(started.elapsed()),
526 }
527}
528
529async fn spawn_and_wait(
531 client: &ControlClient,
532 manifest: &std::path::Path,
533 workdir: &std::path::Path,
534 timeout: Duration,
535 poll: Duration,
536) -> DaemonOutcome {
537 let args = crate::daemon::client::resolve_spawn_args(crate::daemon::client::LaunchRequest {
541 path: &manifest.to_string_lossy(),
542 task: Some(PROBE_PROMPT),
543 stdin_is_terminal: &|| false,
547 model: None,
548 workdir: &workdir.to_string_lossy(),
549 yolo: true,
550 allow: Vec::new(),
551 max_depth: None,
552 regions: std::collections::HashMap::new(),
553 no_seed_commands: false,
554 output_request: None,
555 });
556 let args = match args {
557 Ok(args) => args,
558 Err(e) => return DaemonOutcome::Failed(format!("could not build the spawn request: {e}")),
559 };
560 let run_id = args.run_id.clone();
561
562 let spawned = match client.spawn(args).await {
563 Ok(ControlResponse::Spawned { run_id }) => Ok(run_id),
564 Ok(ControlResponse::Error { message }) => {
565 Err(format!("the daemon refused the spawn: {message}"))
566 }
567 Ok(other) => Err(format!("unexpected daemon response to spawn: {other:?}")),
568 Err(e) => Err(format!(
569 "the daemon is not reachable ({e}); start it with `lev daemon`"
570 )),
571 };
572 let run_id = match spawned {
573 Ok(id) => id,
574 Err(detail) => {
575 cleanup_run(&run_id);
577 return DaemonOutcome::Failed(detail);
578 }
579 };
580
581 let outcome = wait_for_run(client, &run_id, timeout, poll).await;
582 cleanup_run(&run_id);
583 outcome
584}
585
586async fn wait_for_run(
588 client: &ControlClient,
589 run_id: &str,
590 timeout: Duration,
591 poll: Duration,
592) -> DaemonOutcome {
593 let started = Instant::now();
594 loop {
595 let still = match client.status(run_id).await {
596 Ok(ControlResponse::Status {
597 status: Some(status),
598 }) => {
599 if leviath_runtime::pipeline::is_terminal_status(&status) {
600 return finished(run_id, &status);
601 }
602 status.label()
603 }
604 Ok(ControlResponse::Status { status: None }) => return reaped(run_id),
607 Ok(other) => {
608 return DaemonOutcome::Failed(format!("unexpected daemon response: {other:?}"));
609 }
610 Err(e) => {
611 return DaemonOutcome::Failed(format!("lost contact with the daemon: {e}"));
612 }
613 };
614 if started.elapsed() >= timeout {
615 return DaemonOutcome::Failed(format!(
616 "the run was still '{still}' after {}s - the daemon took the spawn but is not \
617 getting anywhere. Check `lev ps` for the lane footer.",
618 timeout.as_secs()
619 ));
620 }
621 tokio::time::sleep(poll).await;
622 }
623}
624
625fn finished(run_id: &str, status: &leviath_runtime::components::AgentStatus) -> DaemonOutcome {
628 use leviath_runtime::components::AgentStatus;
629 let iterations = crate::runstate::read_meta(run_id)
630 .map(|m| m.iteration)
631 .unwrap_or(0);
632 match status {
633 AgentStatus::Complete => DaemonOutcome::Complete(format!(
634 "run {run_id} complete after {iterations} iteration(s)"
635 )),
636 AgentStatus::Error { message } => {
637 DaemonOutcome::Failed(format!("run {run_id} ended in error: {message}"))
638 }
639 other => DaemonOutcome::Failed(format!("run {run_id} ended {}", other.label())),
640 }
641}
642
643fn reaped(run_id: &str) -> DaemonOutcome {
645 match crate::runstate::read_meta(run_id) {
646 Ok(meta) if crate::runstate::is_terminal_status(&meta.status) => match meta.error {
647 Some(err) => DaemonOutcome::Failed(format!("run {run_id} ended in error: {err}")),
648 None => DaemonOutcome::Complete(format!(
649 "run {run_id} {} after {} iteration(s)",
650 meta.status, meta.iteration
651 )),
652 },
653 _ => DaemonOutcome::Failed(format!(
656 "run {run_id} vanished before it finished; the daemon accepted the spawn but \
657 never completed it"
658 )),
659 }
660}
661
662pub enum DaemonTarget<'a> {
672 Skip,
674 Client(&'a ControlClient),
676 Unavailable(String),
678}
679
680pub async fn run_checks(
686 args: &DoctorArgs,
687 build_registry: &(
688 dyn Fn(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError> + Sync
689 ),
690 daemon: DaemonTarget<'_>,
691) -> Vec<Check> {
692 let mut checks = Vec::new();
693
694 let config = match Config::load() {
697 Ok(config) => config,
698 Err(e) => {
699 checks.push(Check::fail("config", e.to_string()));
700 return checks;
701 }
702 };
703 for warning in config.validate_keys() {
704 eprintln!("Warning: {warning}");
705 }
706
707 let registry = match build_registry(&config) {
710 Ok(registry) => registry,
711 Err(e) => {
712 checks.push(Check::fail(
713 "providers",
714 format!("could not build any provider client: {e}"),
715 ));
716 return checks;
717 }
718 };
719 checks.push(config_check(&config, ®istry));
720
721 let (check, resolved) = resolve_check(&config, args.model.as_deref(), ®istry);
722 checks.push(check);
723 let Some(resolved) = resolved else {
724 return checks;
725 };
726
727 let check = inference_check(resolved.provider.as_ref(), &resolved.model).await;
728 let inference_failed = check.status == CheckStatus::Fail;
729 checks.push(check);
730 if inference_failed {
731 return checks;
732 }
733
734 match daemon {
735 DaemonTarget::Skip => {}
736 DaemonTarget::Unavailable(reason) => checks.push(Check::fail("daemon", reason)),
737 DaemonTarget::Client(client) => {
738 let stage = tempfile::tempdir().expect("the system temp directory is writable");
743 checks.push(
744 daemon_check(
745 client,
746 &resolved.provider_name,
747 &resolved.model,
748 DAEMON_TIMEOUT,
749 DAEMON_POLL,
750 stage.path(),
751 )
752 .await,
753 );
754 }
755 }
756 checks
757}
758
759async fn execute_with_registry(
762 args: DoctorArgs,
763 build_registry: &(
764 dyn Fn(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError> + Sync
765 ),
766 daemon: DaemonTarget<'_>,
767) -> anyhow::Result<()> {
768 let checks = run_checks(&args, build_registry, daemon).await;
769 let failed = checks.iter().find(|c| c.status == CheckStatus::Fail);
770
771 if args.json {
772 let report = serde_json::json!({
773 "checks": checks,
774 "passed": failed.is_none(),
775 });
776 println!(
777 "{}",
778 serde_json::to_string_pretty(&report).expect("a Check report always serializes")
779 );
780 } else {
781 print!("{}", format_report(&checks));
782 }
783
784 match failed {
785 Some(check) => bail!("doctor failed at: {}", check.name),
786 None => Ok(()),
787 }
788}
789
790pub async fn execute(args: DoctorArgs, daemon: DaemonTarget<'_>) -> anyhow::Result<()> {
793 execute_with_registry(args, &build_provider_registry_from_config, daemon).await
794}
795
796#[cfg(test)]
797mod tests;