leonard_cli

Command-line entrypoint for Leonard. Connects to a running Flutter VM service, drives the perception-action loop, and streams a trajectory file to disk.

Model providers

The --model flag selects a ModelTier which the provider_factory maps to a concrete ModelProvider:

Tier Provider Required env
claude AnthropicModelProvider ANTHROPIC_API_KEY (default)
qwen-mlx SwiftInferModelProvider (local swift-infer gateway) SWIFT_INFER_AGENT_TOKEN (when the gateway requires auth), SWIFT_INFER_ENDPOINT (optional, defaults to http://localhost:8080), SWIFT_INFER_MODEL (optional, defaults to qwen3.6-35b-a3b-8bit), SWIFT_INFER_REASONING_EFFORT (optional), SWIFT_INFER_MAX_TOKENS (optional, defaults to 16384)
openai OpenAiModelProvider OPENAI_API_KEY

swift-infer gateway (qwen-mlx)

When --model qwen-mlx is selected, the CLI talks to the user's local swift-infer gateway. The wire contract is intentionally identical to fs agent (factoryskills' agent implementation in factoryskills/internal/agent/agent.go) so the same gateway and inspection tooling work for both clients.

Environment variables

  • SWIFT_INFER_AGENT_TOKEN — forwarded as Authorization: Bearer <token>. Same name fs agent uses; share one shell export for both. When unset (or empty), the CLI sends an unauthenticated request — useful when pointing at a gateway that has auth disabled, but the production gateway requires this.
  • SWIFT_INFER_ENDPOINT — base URL of the gateway. Defaults to http://localhost:8080 when unset or empty.
  • SWIFT_INFER_MODEL — model id requested from the gateway. Defaults to qwen3.6-35b-a3b-8bit when unset or empty. Set it to any node the gateway serves (curl "$SWIFT_INFER_ENDPOINT/v1/models" lists them), e.g. qwen3.8-40b-a3b-8bit. The --model-id flag outranks this variable, which outranks the default; capabilities resolve from the id itself, so any qwen3.* node keeps vision + preserved thinking.
  • SWIFT_INFER_REASONING_EFFORTreasoning_effort sent on every /v1/messages request when resolved: one of none, low, medium, high, xhigh. Unset (or empty) means the field is omitted and the node's model-card default applies — except for a qwen3.8* model id, which defaults to medium because that template otherwise runs xhigh. The --reasoning-effort flag outranks this variable; an unparseable value fails the run loudly.
  • SWIFT_INFER_MAX_TOKENSmax_tokens for a single response. Defaults to 16384 (thinking plus answer share this budget). The --max-tokens flag outranks this variable; a non-positive or non-numeric value fails the run loudly.

Every other sampling knob (temperature, top_p, top_k, presence_penalty, repetition_penalty) is deliberately NOT sent by the CLI, so the swift-infer node's per-model card values apply. preserve_thinking is always sent — it is a reasoning-replay contract, not a sampling knob.

Per-run conversation tracing

Every run mints a stable sessionId (cli-<utc-iso8601> slugged to header-safe characters) and stamps every request with:

  • X-Session-Id: <sessionId>
  • X-Conversation-Id: leonard-<sessionId>-<unixMs> — one conversation per run, groups every turn for inspection. Mirrors fs agent's fsagent-<beadID>-<unixtime> convention.
  • X-Swift-Infer-Capture-Bodies: truecaptureBodies is on by default for dev/PoC. The gateway captures both the request and response bodies so GET $SWIFT_INFER_ENDPOINT/v1/conversations/<id> returns the captured turn for inspection without re-running the agent.
  • Accept: text/event-stream — SSE streaming for live <think>…</think> surfaces.

Example (default — cloud backend):

export ANTHROPIC_API_KEY=sk-ant-…
dart run leonard_cli \
  --vm-uri ws://127.0.0.1:54321/abc=/ws \
  --goal "open settings"

Example (opt-in — local swift-infer):

export SWIFT_INFER_AGENT_TOKEN=sk-…
export SWIFT_INFER_ENDPOINT=http://localhost:8080     # optional
export SWIFT_INFER_MODEL=qwen3.8-40b-a3b-8bit         # optional
export SWIFT_INFER_REASONING_EFFORT=medium            # optional
export SWIFT_INFER_MAX_TOKENS=16384                   # optional
dart run leonard_cli \
  --model qwen-mlx \
  --vm-uri ws://127.0.0.1:54321/abc=/ws \
  --goal "open settings"
# Inspect captured turn:
curl "$SWIFT_INFER_ENDPOINT/v1/conversations/leonard-cli-…"

Captured frames and image goldens

Every live-driving run writes PNG frames already carried by its trajectory turns. By default, the frame directory is derived from the trajectory path:

trajectories/
├── 20260507T141503Z.jsonl
└── 20260507T141503Z.frames/
    ├── turn-0000.png
    └── turn-0001.png

The same rule keeps frames inside the station's existing per-run directory:

panel-selfdrive-20260507T141503Z/
├── outer.jsonl
├── outer.frames/
│   ├── turn-0000.png
│   └── turn-0001.png
├── driver.log
└── driver.status

--frames-dir <dir> overrides only frame placement. The CLI never deletes or prunes frame or run directories; the caller owns retention. With no --goldens-dir, the run is capture-only.

To compare the current run against PNG baselines, supply their directory:

dart run leonard_cli \
  --vm-uri ws://127.0.0.1:54321/abc=/ws \
  --goal "open settings" \
  --goldens-dir image_goldens

Frames are paired with goldens by filename. Image dimensions must match. A pixel differs when any RGBA channel delta exceeds --golden-channel-tolerance (default 8), and the frame fails when the differing-pixel ratio exceeds --golden-max-diff-ratio (default 0.0).

To establish or replace baselines, run:

dart run leonard_cli \
  --vm-uri ws://127.0.0.1:54321/abc=/ws \
  --goal "open settings" \
  --goldens-dir image_goldens --update-goldens

Update mode creates the baseline directory, overwrites same-named files, and prints every baseline written. It never compares in the same invocation. The repository's comparator fixtures are PNGs under packages/leonard_cli/test/image_goldens/; they are distinct from the JSON observation goldens used by the Flutter perception-equivalence tests.

Nightly dogfood

The nightly e2e test (packages/leonard_agent/integration_test/dogfood/dogfood_e2e_test.dart) is self-pinned to local inference via its own SwiftInferConfig construction and does not depend on leonard_cli's --model default. The launchagent that runs the nightly test (scripts/launchd/run-dogfood.sh) invokes dart test directly on the e2e test file, bypassing the CLI entirely, so the default change has no effect on nightly behavior.

See also

  • factoryskills/internal/agent/agent.go — the reference implementation of this wire contract. lenny's swift-infer client is intentionally header-for-header symmetric with fs agent; if you add a new header to one, add it to the other.
  • lib/src/provider/swift_infer/swift_infer_config.dart (in leonard_agent) — provider-side config surface, including the extraHeaders forward-compat bag.

Libraries

leonard_cli