leviath-cli 0.3.8

Command-line interface for Leviath agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
//! The checks that read a manifest as a shape: does every stage it names exist,
//! can the run reach an output, does a tool it advertises actually resolve.
//!
//! Split from the security checks next door because these answer "will this
//! agent work" and those answer "should this agent be allowed to".

use super::*;

/// Fields the stage left to a default: `mode`, `model`, and `max_iterations`.
pub(super) fn lint_declarations(stage: &leviath_core::Stage, keys: StageKeys) -> Vec<LintFinding> {
    let mut findings = Vec::new();

    if !keys.mode {
        findings.push(
            LintFinding::new(
                LintSeverity::Warning,
                "stage-missing-mode",
                "no mode is set, so the stage runs as autonomous".to_string(),
            )
            .in_stage(&stage.name)
            .with_fix("write mode = \"autonomous\" if that is what you meant"),
        );
    }

    if !keys.model {
        findings.push(
            LintFinding::new(
                LintSeverity::Warning,
                "stage-missing-model",
                format!(
                    "no [stages.{}.model] block, so the stage runs on your \
                     configured default_provider, whatever that is",
                    stage.name
                ),
            )
            .in_stage(&stage.name)
            .with_fix(format!(
                "add model = {{ models = [{{ provider = \"...\", model = \"...\" }}] }} \
                 to [stages.{}]",
                stage.name
            )),
        );
    }

    // A fan_out stage does not run inference itself - it splits work and waits
    // on its workers - so it has no iteration count to cap.
    let counts_iterations = !matches!(stage.mode, StageMode::FanOut { .. });
    if counts_iterations && stage.max_iterations.is_none() {
        findings.push(
            LintFinding::new(
                LintSeverity::Warning,
                "stage-missing-max-iterations",
                "no max_iterations, so the stage is unbounded unless your config \
                 sets [limits] default_max_iterations"
                    .to_string(),
            )
            .in_stage(&stage.name)
            .with_fix("give the stage a max_iterations it should never reach"),
        );
    }

    findings
}

/// Tool names that resolve to nothing, and permissions for tools the stage
/// never granted.
pub(super) fn lint_tools(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
    let mut findings = Vec::new();

    if !env.known_tools.is_empty() {
        for tool in &stage.available_tools {
            // `server__tool` is an MCP name. It resolves only once that server
            // is installed and connected, which is not a property of the
            // manifest, so it is never this check's business.
            if tool.contains("__") || env.known_tools.contains(tool) {
                continue;
            }
            findings.push(
                LintFinding::new(
                    LintSeverity::Error,
                    "unknown-tool",
                    format!(
                        "grants '{tool}', which is not a built-in, a sub-agent \
                         tool, or one of this agent's own tools/*.rhai"
                    ),
                )
                .in_stage(&stage.name)
                .with_fix("check the spelling, or drop the entry"),
            );
        }
    }

    let granted: HashSet<&str> = stage.available_tools.iter().map(String::as_str).collect();
    for tool in stage.tool_permissions.keys() {
        if granted.contains(tool.as_str()) {
            continue;
        }
        findings.push(
            LintFinding::new(
                LintSeverity::Error,
                "orphan-stage-permission",
                format!(
                    "sets a permission for '{tool}', which it does not grant in \
                     available_tools - it reads as a grant and is not one"
                ),
            )
            .in_stage(&stage.name)
            .with_fix(format!(
                "add '{tool}' to available_tools, or drop the permission"
            )),
        );
    }

    findings
}

/// Human-in-the-loop tools offered by a stage that runs with nobody attached.
pub(super) fn lint_blocking_tools(stage: &leviath_core::Stage) -> Vec<LintFinding> {
    // Only autonomous stages are a problem: the interactive modes are where a
    // person is expected, and a fan_out stage runs no tools of its own.
    if !matches!(stage.mode, StageMode::Autonomous) || stage.allow_blocking_tools {
        return Vec::new();
    }
    stage
        .available_tools
        .iter()
        .filter(|t| BLOCKING_INTERACTION_TOOLS.contains(&canonical_tool_name(t)))
        // A tool kept in `required_tools` is the same statement of intent
        // `allow_blocking_tools` makes, made one tool at a time - and it is the
        // one that also survives an unattended run, so it is worth more.
        //
        // Canonicalised on both sides, as the runtime does: a stage granting
        // `bash` and keeping `shell` is one decision, not two.
        .filter(|t| {
            !stage
                .required_tools
                .iter()
                .any(|r| canonical_tool_name(r) == canonical_tool_name(t))
        })
        .map(|tool| {
            LintFinding::new(
                LintSeverity::Warning,
                "blocking-tool-in-autonomous-stage",
                format!(
                    "is autonomous but grants '{tool}', which suspends the run \
                     until a person answers"
                ),
            )
            .in_stage(&stage.name)
            .with_fix(
                "drop the tool, switch the stage to an interactive mode, list it in \
                 required_tools so it survives an unattended run too, or set \
                 allow_blocking_tools = true to say you meant it",
            )
        })
        .collect()
}

/// A stage's own output declarations: a demand it cannot meet, a shape nothing
/// will read, or a reporting stage that can also change the workspace.
pub(super) fn lint_output_stage(stage: &leviath_core::Stage) -> Vec<LintFinding> {
    let mut findings = Vec::new();
    let grants_submit = stage
        .available_tools
        .iter()
        .any(|t| canonical_tool_name(t) == leviath_core::blueprint::SUBMIT_OUTPUT_TOOL);

    // `Stage::validate` already refuses this outright, so reaching it here means
    // the manifest never loaded. Reported anyway because `lev validate` runs the
    // linter over a blueprint it *did* load, and a future path that relaxes the
    // hard error should still surface it.
    if stage.require_output && !grants_submit {
        findings.push(
            LintFinding::new(
                LintSeverity::Error,
                "output-missing-submit-tool",
                format!(
                    "must produce a final output but does not grant '{}'",
                    leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
                ),
            )
            .in_stage(&stage.name)
            .with_fix(format!(
                "add '{}' to available_tools, or use mode = \"output\", which grants it",
                leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
            )),
        );
    }

    // A declared shape nobody is obliged to produce is a wish, not a contract:
    // the tool description carries it, and the agent may still finish without
    // calling the tool at all.
    if stage.output.is_some() && !stage.require_output {
        findings.push(
            LintFinding::new(
                LintSeverity::Warning,
                "output-shape-not-required",
                "declares an output shape but is not required to produce one, so the run may \
                 finish with nothing"
                    .to_string(),
            )
            .in_stage(&stage.name)
            .with_fix("set require_output = true, or move the shape to the stage that submits"),
        );
    }

    // An output stage summarizes work; one that can also change files invites
    // the model to keep working where it was meant to report.
    if stage.mode == StageMode::Output {
        let modifying: Vec<&String> = stage
            .available_tools
            .iter()
            .filter(|t| leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical_tool_name(t)))
            .collect();
        for tool in modifying {
            findings.push(
                LintFinding::new(
                    LintSeverity::Warning,
                    "output-stage-can-modify",
                    format!("is an output stage but grants '{tool}', which changes the workspace"),
                )
                .in_stage(&stage.name)
                .with_fix(
                    "drop the tool: an output stage reports what happened, and work done here \
                     lands after the review that was meant to check it",
                ),
            );
        }
    }
    findings
}

/// Output stages nothing can reach, and the upstream `allow_complete` that is
/// the usual reason.
///
/// The second half is the one that fails quietly. `allow_complete` offers the
/// model a "DONE" it may pick instead of routing onward, and it is appended even
/// to a stage's custom `transition_prompt` - so a stage can offer an exit its own
/// prompt never mentions. A run that takes it ends with no answer and looks
/// exactly like success.
/// Stages whose every normal exit can run out of `max_revisits` budget.
///
/// A stage transitions along its `Always`/`LlmChoice` edges; an edge whose
/// target has `max_revisits` stops being followable once the budget is spent.
/// When EVERY normal edge is like that, a long enough run strands the stage
/// with nowhere to go - which the engine now reports as a dead-end *error*
/// (it used to read as `complete`, from the middle of the graph, with the
/// output stage still pending). Observed live: a wide-researcher bounced
/// deep_dive → compare until compare's budget ran out, then "completed" with
/// nothing produced.
///
/// The fix is one un-exhaustible way forward: an edge to a stage without
/// `max_revisits` (an output/terminal stage usually), or a
/// `condition = "max_iterations"` escape.
pub(super) fn lint_dead_end_possible(blueprint: &Blueprint) -> Vec<LintFinding> {
    let mut findings = Vec::new();
    for stage in &blueprint.stages {
        let Some(transitions) = &stage.transitions else {
            continue;
        };
        let normal: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
            .values()
            .filter(|e| {
                matches!(
                    e.condition,
                    leviath_core::blueprint::TransitionCondition::Always
                        | leviath_core::blueprint::TransitionCondition::LlmChoice
                )
            })
            .collect();
        if normal.is_empty() {
            continue; // terminal (or conditioned-only) stage: nothing to strand
        }
        let all_exhaustible = normal.iter().all(|e| {
            blueprint
                .find_stage(&e.target)
                .is_none_or(|t| t.max_revisits.is_some())
        });
        // An escape the runtime actually consults on this path. `resolve_transition`
        // resolves a dead end down a `dead_end` edge, then an `error` edge, so
        // either satisfies the check - provided its own target can still be
        // entered, or it is no escape at all.
        let has_escape = transitions.values().any(|e| {
            matches!(
                e.condition,
                leviath_core::blueprint::TransitionCondition::DeadEnd
                    | leviath_core::blueprint::TransitionCondition::Error
            ) && blueprint
                .find_stage(&e.target)
                .is_some_and(|t| t.max_revisits.is_none())
        });

        if all_exhaustible && !has_escape {
            findings.push(
                LintFinding::new(
                    LintSeverity::Warning,
                    "dead-end-possible",
                    "can strand the run: every normal transition's target has a max_revisits \
                     budget, and once they are all spent the run errors as dead-ended"
                        .to_string(),
                )
                .in_stage(&stage.name)
                .with_fix(
                    "add a condition = \"dead_end\" edge to a stage without max_revisits \
                     (the output stage, usually). It is taken only when the graph would \
                     otherwise strand, so it is not a route the model can choose early - \
                     unlike a plain edge to the same stage, which is offered on every visit",
                ),
            );
        }
    }
    findings
}

pub(super) fn lint_output_reachable(blueprint: &Blueprint) -> Vec<LintFinding> {
    let outputs: Vec<&leviath_core::Stage> = blueprint
        .stages
        .iter()
        .filter(|s| s.mode == StageMode::Output)
        .collect();
    if outputs.is_empty() {
        return Vec::new();
    }
    let mut findings = Vec::new();

    for output in &outputs {
        let reached = blueprint.stages.iter().any(|s| {
            s.name != output.name
                && s.transitions
                    .iter()
                    .flat_map(|edges| edges.values())
                    .any(|e| e.target == output.name)
        });
        let is_entry = blueprint.entry_stage.as_deref() == Some(output.name.as_str())
            || blueprint.stages.first().map(|s| s.name.as_str()) == Some(output.name.as_str());
        if !reached && !is_entry {
            findings.push(
                LintFinding::new(
                    LintSeverity::Error,
                    "output-unreachable",
                    "is an output stage no edge routes to, so the run can never produce one"
                        .to_string(),
                )
                .in_stage(&output.name)
                .with_fix(format!(
                    "add a transition to '{}' from whichever stage finishes the work",
                    output.name
                )),
            );
        }
    }

    for stage in &blueprint.stages {
        if stage.allow_complete && stage.mode != StageMode::Output {
            findings.push(
                LintFinding::new(
                    LintSeverity::Warning,
                    "allow-complete-skips-output",
                    "may end the run itself, so the model can finish here and never reach the \
                     output stage"
                        .to_string(),
                )
                .in_stage(&stage.name)
                .with_fix(
                    "drop allow_complete and route to the output stage instead - the run then \
                     still explains what it did",
                ),
            );
        }
    }
    findings
}

/// Graph shape: stages the entry can never reach, and cycles with no revisit
/// cap. Both only mean anything for a blueprint that declares transitions at
/// all - a linear one has no graph to walk.
pub(super) fn lint_graph(blueprint: &Blueprint) -> Vec<LintFinding> {
    if !blueprint.stages.iter().any(|s| s.transitions.is_some()) {
        return Vec::new();
    }
    let stage_names: HashSet<&str> = blueprint.stages.iter().map(|s| s.name.as_str()).collect();
    let entry = blueprint.resolve_entry_stage_name();

    // Breadth-first from the entry stage; whatever is left over is orphaned.
    let mut reachable = HashSet::new();
    let mut queue = std::collections::VecDeque::from([entry.clone()]);
    while let Some(name) = queue.pop_front() {
        if !reachable.insert(name.clone()) {
            continue;
        }
        let Some(stage) = blueprint.find_stage(&name) else {
            continue;
        };
        // A fan_out stage reaches its worker and merge stages through its own
        // config rather than a transition edge, so following only `transitions`
        // would report a perfectly wired worker as an orphan.
        let fan_out = match &stage.mode {
            StageMode::FanOut { config } => [
                config.worker_stage.as_deref(),
                config.merge_stage.as_deref(),
            ],
            _ => [None, None],
        };
        let edges = stage
            .transitions
            .iter()
            .flat_map(|t| t.keys().map(String::as_str))
            .chain(fan_out.into_iter().flatten());
        for target in edges {
            if !reachable.contains(target) && stage_names.contains(target) {
                queue.push_back(target.to_string());
            }
        }
    }

    let mut findings: Vec<LintFinding> = blueprint
        .stages
        .iter()
        .filter(|s| !reachable.contains(s.name.as_str()))
        .map(|s| {
            LintFinding::new(
                LintSeverity::Warning,
                "unreachable-stage",
                format!("cannot be reached from entry stage '{entry}'"),
            )
            .in_stage(&s.name)
            .with_fix("give some stage a transition to it, or delete it")
        })
        .collect();

    // A pair of stages that each transition to the other, where the one being
    // returned to has no revisit cap, can bounce forever.
    for stage in &blueprint.stages {
        let Some(transitions) = &stage.transitions else {
            continue;
        };
        for target in transitions.keys().filter(|t| **t != stage.name) {
            let Some(target_stage) = blueprint.find_stage(target) else {
                continue;
            };
            let Some(t2) = &target_stage.transitions else {
                continue;
            };
            if t2.contains_key(&stage.name) && target_stage.max_revisits.is_none() {
                findings.push(
                    LintFinding::new(
                        LintSeverity::Warning,
                        "cycle-without-max-revisits",
                        format!(
                            "is in a cycle with '{}' and has no max_revisits",
                            stage.name
                        ),
                    )
                    .in_stage(target)
                    .with_fix("set max_revisits so the loop has to end"),
                );
            }
        }
    }

    findings
}

/// Models and providers the install cannot resolve.
pub(super) fn lint_models(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
    let mut findings = Vec::new();

    for entry in &stage.model.models {
        // A provider with no catalog here is open-ended (Ollama serves whatever
        // is pulled, OpenRouter's list runs to hundreds, a script provider
        // defines its own). Checking a model against a catalog that does not
        // claim to be complete would only produce false alarms.
        let catalog_known = env.known_models.iter().any(|(p, _)| *p == entry.provider);
        let listed = env
            .known_models
            .iter()
            .any(|(p, m)| *p == entry.provider && *m == entry.model);
        if catalog_known && !listed {
            findings.push(
                LintFinding::new(
                    LintSeverity::Warning,
                    "unknown-model",
                    format!(
                        "names {}/{}, which is not a model this build knows about",
                        entry.provider, entry.model
                    ),
                )
                .in_stage(&stage.name)
                .with_fix(
                    "check `lev models list`, or `lev models list --remote` \
                           if it is newer than this build",
                ),
            );
        }
    }

    // Reported per stage, not per entry: the models list is an ordered set of
    // fallbacks, so naming a provider this install cannot reach is normal and
    // expected as long as something later in the list answers. What is worth
    // saying is that *nothing* in the list does, which is the shape that
    // reaches the runtime as "no usable provider" at spawn.
    if let Some(available) = &env.available_providers
        && !stage.model.models.is_empty()
        && !stage
            .model
            .models
            .iter()
            .any(|e| available.contains(&e.provider))
    {
        let tried: Vec<&str> = stage
            .model
            .models
            .iter()
            .map(|e| e.provider.as_str())
            .collect();
        findings.push(
            LintFinding::new(
                LintSeverity::Warning,
                "no-reachable-provider",
                format!(
                    "names no provider this install can reach (tried {}), so it \
                     falls back to your default model",
                    tried.join(", ")
                ),
            )
            .in_stage(&stage.name)
            .with_fix("run `lev setup` to configure one of them, or add a provider you have"),
        );
    }

    findings
}

/// A bare `compact` edge that would summarize a region holding a deliverable.
///
/// `transform = "compact"` reads as "summarize the transcript on the way out"
/// and means "summarize every region that is not pinned", which includes the
/// ones holding the run's results. Figures that survive a paraphrase are no
/// longer figures, and nothing about the blueprint is malformed, so the only
/// place to say so is here (#369).
///
/// Scoped to regions declared `required` rather than every region a bare
/// compact touches. `required` is the author saying "a stage must populate
/// this", which is the closest thing a blueprint has to "this is a
/// deliverable" - warning on all of them would fire on every agent that ever
/// wrote `transform = "compact"` and teach people to ignore it.
pub(super) fn lint_compacted_deliverables(blueprint: &Blueprint) -> Vec<LintFinding> {
    use leviath_core::blueprint::EdgeTransform;

    // Named once per region, however many edges would summarize it: the fix is
    // on the region, so repeating it per edge is noise.
    let mut at_risk: Vec<&str> = Vec::new();
    for stage in &blueprint.stages {
        let layout = stage
            .context_layout
            .as_ref()
            .unwrap_or(&blueprint.context_layout);
        let bare_compact = stage
            .transitions
            .iter()
            .flat_map(|edges| edges.values())
            .any(|e| matches!(e.transform, EdgeTransform::Compact { .. }));
        if !bare_compact {
            continue;
        }
        for region in &layout.regions {
            if region.required
                && region.summarizable
                && leviath_runtime::is_stage_specific(&region.kind)
                && !at_risk.contains(&region.name.as_str())
            {
                at_risk.push(region.name.as_str());
            }
        }
    }

    at_risk
        .into_iter()
        .map(|region| {
            LintFinding::new(
                LintSeverity::Warning,
                "compact-summarizes-deliverable",
                format!(
                    "region '{region}' is declared required - a stage must populate it - \
                     and a `transform = \"compact\"` edge would hand it to the summarizer \
                     on the way out, so whatever the stage wrote reaches later stages \
                     paraphrased"
                ),
            )
            .with_fix(format!(
                "add summarizable = false to [context.regions] {region} if its content \
                 does not survive a rewrite, or name the regions to summarize with \
                 transform = \"custom\""
            ))
        })
        .collect()
}