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 config_check(config: &Config, registry: &ProviderRegistry) -> Check {
219 let mut names = registry.provider_names();
220 names.sort_unstable();
221 let registered = match names.is_empty() {
222 true => "none".to_string(),
223 false => names.join(", "),
224 };
225 Check::ok(
226 "config",
227 format!(
228 "default_provider={}; registered: {} (script providers resolve by name)",
229 config.default_provider, registered
230 ),
231 )
232}
233
234struct Resolved {
240 provider_name: String,
241 model: String,
242 provider: Arc<dyn Provider>,
243}
244
245fn resolve_check(
256 config: &Config,
257 model_override: Option<&str>,
258 registry: &ProviderRegistry,
259) -> (Check, Option<Resolved>) {
260 let empty = ModelConfig {
261 models: Vec::new(),
262 allow_user_default: true,
263 parameters: std::collections::HashMap::new(),
264 request_timeout_secs: None,
265 };
266 let defaults = model_defaults(config);
267 let (provider_name, model) = resolve_stage_model(&empty, model_override, &defaults, registry);
268
269 match registry.get(&provider_name) {
270 Some(provider) => (
271 Check::ok("resolve", format!("{provider_name} / {model}")),
272 Some(Resolved {
273 provider_name,
274 model,
275 provider,
276 }),
277 ),
278 None => (
279 Check::fail(
280 "resolve",
281 format!(
282 "resolved to '{provider_name}', which is not configured (tried: {}). \
283 Configure it with `lev setup`, or add it to config.toml.",
284 providers_tried(&empty, model_override, &defaults)
285 ),
286 ),
287 None,
288 ),
289 }
290}
291
292async fn inference_check(provider: &dyn Provider, model: &str) -> Check {
298 let caps = provider.capabilities(model);
299 let request = InferenceRequest {
300 system: Vec::new(),
301 messages: vec![Message {
302 role: "user".to_string(),
303 content: PROBE_PROMPT.into(),
304 cache_breakpoint: false,
305 }],
306 model: model.to_string(),
307 max_tokens: PROBE_MAX_TOKENS.min(caps.max_output_tokens),
308 temperature: 0.0,
311 tools: Vec::new(),
312 extra: serde_json::Value::Null,
313 request_timeout_secs: Some(60),
314 };
315
316 let started = Instant::now();
317 match provider.infer(request).await {
318 Ok(response) => {
319 let usage = response.tokens_used;
320 let echo = match response.content.contains(PROBE_EXPECTED) {
321 true => format!("replied {PROBE_EXPECTED}"),
322 false => format!("no {PROBE_EXPECTED} in the reply"),
325 };
326 Check::ok(
327 "inference",
328 format!(
329 "{} in / {} out / {} total, {echo}",
330 usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
331 ),
332 )
333 .timed(started.elapsed())
334 }
335 Err(e) => Check::fail("inference", e.to_string()).timed(started.elapsed()),
340 }
341}
342
343fn canary_manifest(provider: &str, model: &str) -> String {
357 let provider = serde_json::to_string(provider).expect("a str always serializes to JSON");
358 let model = serde_json::to_string(model).expect("a str always serializes to JSON");
359 format!(
360 r#"[agent]
361name = "doctor"
362version = "0.0.1"
363description = "One-turn provider probe spawned by `lev doctor`, deleted when it finishes."
364entry_stage = "ping"
365
366[stages.ping]
367mode = "autonomous"
368model = {{ models = [{{ provider = {provider}, model = {model} }}] }}
369description = "Answer once, in text."
370available_tools = []
371max_iterations = 1
372system_prompt = "Reply with exactly: {PROBE_EXPECTED}. Call no tools."
373
374[context.regions]
375task = {{ kind = "pinned", max_tokens = 1000, seed = "task" }}
376conversation = {{ kind = "sliding_window", max_items = 4, max_tokens = 2000 }}
377"#
378 )
379}
380
381fn cleanup_run(run_id: &str) {
388 let _ = crate::runstate::force_cancel(run_id);
389 let _ = std::fs::remove_dir_all(crate::runstate::run_dir(run_id));
390 let _ = leviath_core::paths::data_dir().map(|d| {
391 let _ = std::fs::remove_dir_all(d.join("state").join(run_id));
392 });
393}
394
395enum DaemonOutcome {
398 Complete(String),
400 Failed(String),
402}
403
404fn stage_canary(
409 root: &std::path::Path,
410 provider: &str,
411 model: &str,
412) -> std::io::Result<std::path::PathBuf> {
413 let agent_dir = root.join("doctor");
414 std::fs::create_dir_all(&agent_dir)?;
415 let manifest = agent_dir.join("agent.leviath");
416 std::fs::write(&manifest, canary_manifest(provider, model))?;
417 Ok(manifest)
418}
419
420async fn daemon_check(
423 client: &ControlClient,
424 provider_name: &str,
425 model: &str,
426 timeout: Duration,
427 poll: Duration,
428 root: &std::path::Path,
429) -> Check {
430 let started = Instant::now();
431 let manifest = match stage_canary(root, provider_name, model) {
432 Ok(manifest) => manifest,
433 Err(e) => return Check::fail("daemon", format!("could not stage a probe agent: {e}")),
434 };
435
436 match spawn_and_wait(client, &manifest, root, timeout, poll).await {
437 DaemonOutcome::Complete(detail) => Check::ok("daemon", detail).timed(started.elapsed()),
438 DaemonOutcome::Failed(detail) => Check::fail("daemon", detail).timed(started.elapsed()),
439 }
440}
441
442async fn spawn_and_wait(
444 client: &ControlClient,
445 manifest: &std::path::Path,
446 workdir: &std::path::Path,
447 timeout: Duration,
448 poll: Duration,
449) -> DaemonOutcome {
450 let args = crate::daemon::client::resolve_spawn_args(
454 &manifest.to_string_lossy(),
455 Some(PROBE_PROMPT),
456 &|| false,
460 None,
461 &workdir.to_string_lossy(),
462 true,
463 Vec::new(),
464 None,
465 std::collections::HashMap::new(),
466 false,
467 );
468 let args = match args {
469 Ok(args) => args,
470 Err(e) => return DaemonOutcome::Failed(format!("could not build the spawn request: {e}")),
471 };
472 let run_id = args.run_id.clone();
473
474 let spawned = match client.spawn(args).await {
475 Ok(ControlResponse::Spawned { run_id }) => Ok(run_id),
476 Ok(ControlResponse::Error { message }) => {
477 Err(format!("the daemon refused the spawn: {message}"))
478 }
479 Ok(other) => Err(format!("unexpected daemon response to spawn: {other:?}")),
480 Err(e) => Err(format!(
481 "the daemon is not reachable ({e}); start it with `lev daemon`"
482 )),
483 };
484 let run_id = match spawned {
485 Ok(id) => id,
486 Err(detail) => {
487 cleanup_run(&run_id);
489 return DaemonOutcome::Failed(detail);
490 }
491 };
492
493 let outcome = wait_for_run(client, &run_id, timeout, poll).await;
494 cleanup_run(&run_id);
495 outcome
496}
497
498async fn wait_for_run(
500 client: &ControlClient,
501 run_id: &str,
502 timeout: Duration,
503 poll: Duration,
504) -> DaemonOutcome {
505 let started = Instant::now();
506 loop {
507 let still = match client.status(run_id).await {
508 Ok(ControlResponse::Status {
509 status: Some(status),
510 }) => {
511 if leviath_runtime::pipeline::is_terminal_status(&status) {
512 return finished(run_id, &status);
513 }
514 status.label()
515 }
516 Ok(ControlResponse::Status { status: None }) => return reaped(run_id),
519 Ok(other) => {
520 return DaemonOutcome::Failed(format!("unexpected daemon response: {other:?}"));
521 }
522 Err(e) => {
523 return DaemonOutcome::Failed(format!("lost contact with the daemon: {e}"));
524 }
525 };
526 if started.elapsed() >= timeout {
527 return DaemonOutcome::Failed(format!(
528 "the run was still '{still}' after {}s - the daemon took the spawn but is not \
529 getting anywhere. Check `lev ps` for the lane footer.",
530 timeout.as_secs()
531 ));
532 }
533 tokio::time::sleep(poll).await;
534 }
535}
536
537fn finished(run_id: &str, status: &leviath_runtime::components::AgentStatus) -> DaemonOutcome {
540 use leviath_runtime::components::AgentStatus;
541 let iterations = crate::runstate::read_meta(run_id)
542 .map(|m| m.iteration)
543 .unwrap_or(0);
544 match status {
545 AgentStatus::Complete => DaemonOutcome::Complete(format!(
546 "run {run_id} complete after {iterations} iteration(s)"
547 )),
548 AgentStatus::Error { message } => {
549 DaemonOutcome::Failed(format!("run {run_id} ended in error: {message}"))
550 }
551 other => DaemonOutcome::Failed(format!("run {run_id} ended {}", other.label())),
552 }
553}
554
555fn reaped(run_id: &str) -> DaemonOutcome {
557 match crate::runstate::read_meta(run_id) {
558 Ok(meta) if crate::runstate::is_terminal_status(&meta.status) => match meta.error {
559 Some(err) => DaemonOutcome::Failed(format!("run {run_id} ended in error: {err}")),
560 None => DaemonOutcome::Complete(format!(
561 "run {run_id} {} after {} iteration(s)",
562 meta.status, meta.iteration
563 )),
564 },
565 _ => DaemonOutcome::Failed(format!(
568 "run {run_id} vanished before it finished; the daemon accepted the spawn but \
569 never completed it"
570 )),
571 }
572}
573
574pub enum DaemonTarget<'a> {
584 Skip,
586 Client(&'a ControlClient),
588 Unavailable(String),
590}
591
592pub async fn run_checks(
594 args: &DoctorArgs,
595 build_registry: &dyn Fn(&Config) -> ProviderRegistry,
596 daemon: DaemonTarget<'_>,
597) -> Vec<Check> {
598 let mut checks = Vec::new();
599
600 let config = match Config::load() {
603 Ok(config) => config,
604 Err(e) => {
605 checks.push(Check::fail("config", e.to_string()));
606 return checks;
607 }
608 };
609 for warning in config.validate_keys() {
610 eprintln!("Warning: {warning}");
611 }
612
613 let registry = build_registry(&config);
614 checks.push(config_check(&config, ®istry));
615
616 let (check, resolved) = resolve_check(&config, args.model.as_deref(), ®istry);
617 checks.push(check);
618 let Some(resolved) = resolved else {
619 return checks;
620 };
621
622 let check = inference_check(resolved.provider.as_ref(), &resolved.model).await;
623 let inference_failed = check.status == CheckStatus::Fail;
624 checks.push(check);
625 if inference_failed {
626 return checks;
627 }
628
629 match daemon {
630 DaemonTarget::Skip => {}
631 DaemonTarget::Unavailable(reason) => checks.push(Check::fail("daemon", reason)),
632 DaemonTarget::Client(client) => {
633 let stage = tempfile::tempdir().expect("the system temp directory is writable");
638 checks.push(
639 daemon_check(
640 client,
641 &resolved.provider_name,
642 &resolved.model,
643 DAEMON_TIMEOUT,
644 DAEMON_POLL,
645 stage.path(),
646 )
647 .await,
648 );
649 }
650 }
651 checks
652}
653
654async fn execute_with_registry(
657 args: DoctorArgs,
658 build_registry: &dyn Fn(&Config) -> ProviderRegistry,
659 daemon: DaemonTarget<'_>,
660) -> anyhow::Result<()> {
661 let checks = run_checks(&args, build_registry, daemon).await;
662 let failed = checks.iter().find(|c| c.status == CheckStatus::Fail);
663
664 if args.json {
665 let report = serde_json::json!({
666 "checks": checks,
667 "passed": failed.is_none(),
668 });
669 println!(
670 "{}",
671 serde_json::to_string_pretty(&report).expect("a Check report always serializes")
672 );
673 } else {
674 print!("{}", format_report(&checks));
675 }
676
677 match failed {
678 Some(check) => bail!("doctor failed at: {}", check.name),
679 None => Ok(()),
680 }
681}
682
683pub async fn execute(args: DoctorArgs, daemon: DaemonTarget<'_>) -> anyhow::Result<()> {
686 execute_with_registry(args, &build_provider_registry_from_config, daemon).await
687}
688
689#[cfg(test)]
690mod tests;