Skip to main content

robonix_pilot/
config.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Pilot config: how a launched pilot process figures out where atlas is,
5// what address to bind, and how to reach the LLM upstream.
6//
7// Three sources, from lowest to highest priority:
8//   1. compiled defaults (atlas endpoint, listen address, id, …)
9//   2. optional YAML at `$ROBONIX_CONFIG_PATH` or `--config <path>`
10//      (used by `rbnx boot` to write a slice of `system.pilot` from
11//      `robonix_manifest.yaml`)
12//   3. CLI flags / per-field env vars
13//
14// Higher-priority source overrides lower. Manual launch only needs the
15// minimum: an atlas endpoint, a VLM upstream URL, and an API key.
16
17use anyhow::{Context, Result, bail};
18use clap::Parser;
19use serde::Deserialize;
20use std::path::{Path, PathBuf};
21
22pub const DEFAULT_PILOT_PROVIDER_ID: &str = "pilot";
23pub const PILOT_NAMESPACE: &str = "robonix/system/pilot";
24pub const DEFAULT_ATLAS_ENDPOINT: &str = "127.0.0.1:50051";
25pub const DEFAULT_LISTEN: &str = "127.0.0.1:50071";
26pub const DEFAULT_VLM_FORMAT: &str = "openai";
27
28/// Fully-resolved settings the pilot binary runs against.
29#[derive(Debug, Clone)]
30pub struct PilotConfig {
31    pub atlas_endpoint: String,
32    pub listen: String,
33    pub id: String,
34    pub vlm: VlmConfig,
35}
36
37#[derive(Debug, Clone)]
38pub struct VlmConfig {
39    pub upstream: String,
40    pub api_key: String,
41    pub model: String,
42    /// Wire dialect. Currently only "openai" is implemented; checked at
43    /// `resolve` time, kept on the struct for diagnostics / future routing.
44    #[allow(dead_code)]
45    pub api_format: String,
46}
47
48/// CLI surface; every field is optional so config-file mode stays usable
49/// without spelling out flags. clap also reads the listed env vars.
50#[derive(Parser, Debug)]
51#[command(name = "robonix-pilot", about = "Robonix Pilot — VLM planner")]
52pub struct Args {
53    /// Atlas control-plane endpoint. Also reads `ROBONIX_ATLAS` (the var rbnx /
54    /// the Python API / liaison use) as an alias; see `env_atlas`.
55    #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")]
56    pub atlas: Option<String>,
57
58    /// Address pilot's SystemPilot gRPC binds to.
59    #[arg(long, env = "ROBONIX_PILOT_LISTEN")]
60    pub listen: Option<String>,
61
62    /// Override pilot's id (singleton, rarely needed).
63    #[arg(long, env = "ROBONIX_PILOT_PROVIDER_ID")]
64    pub id: Option<String>,
65
66    /// LLM API base URL (e.g. <https://api.openai.com/v1>).
67    #[arg(long, env = "ROBONIX_VLM_UPSTREAM")]
68    pub vlm_upstream: Option<String>,
69
70    /// LLM API key.
71    #[arg(long, env = "ROBONIX_VLM_API_KEY")]
72    pub vlm_api_key: Option<String>,
73
74    /// LLM model identifier.
75    #[arg(long, env = "ROBONIX_VLM_MODEL")]
76    pub vlm_model: Option<String>,
77
78    /// LLM API dialect ("openai" only for now).
79    #[arg(long, env = "ROBONIX_VLM_FORMAT")]
80    pub vlm_format: Option<String>,
81
82    /// YAML config file (rbnx writes this; CLI/env still override individual fields).
83    #[arg(long, env = "ROBONIX_CONFIG_PATH")]
84    pub config: Option<PathBuf>,
85
86    /// Log level for this component (`debug`/`info`/`warn`/`error`). Sets the
87    /// scribe log-file floor; falls back to `SCRIBE_FILE_LEVEL` / `info`.
88    /// Normally arrives inside `--config-json`, not as a standalone flag.
89    #[arg(long)]
90    pub log: Option<String>,
91
92    /// The component's `system.pilot` manifest block, serialized to JSON by
93    /// rbnx and passed as one arg (`--config-json '{…}'`). Parsed by the
94    /// binary itself — see `robonix_scribe::init_from_config`, which reads the
95    /// `log` key from it so the manifest's per-component level reaches the log.
96    #[arg(long)]
97    pub config_json: Option<String>,
98}
99
100/// Optional YAML schema. Field names match `PilotConfig` (flat) so a
101/// hand-written file looks like the manifest's `system.pilot` block.
102#[derive(Default, Deserialize)]
103struct FileConfig {
104    #[serde(default)]
105    atlas_endpoint: Option<String>,
106    #[serde(default)]
107    listen: Option<String>,
108    #[serde(default)]
109    id: Option<String>,
110    #[serde(default)]
111    vlm: Option<FileVlmConfig>,
112}
113
114#[derive(Default, Deserialize)]
115struct FileVlmConfig {
116    #[serde(default)]
117    upstream: Option<String>,
118    #[serde(default)]
119    api_key: Option<String>,
120    #[serde(default)]
121    model: Option<String>,
122    #[serde(default)]
123    api_format: Option<String>,
124}
125
126impl PilotConfig {
127    /// Build the resolved config from CLI args (which already pulled env
128    /// vars). Reads optional YAML; CLI/env still override file fields.
129    pub fn resolve(args: Args) -> Result<Self> {
130        let file_cfg: FileConfig = match &args.config {
131            Some(path) => load_yaml(path)?,
132            None => FileConfig::default(),
133        };
134        let file_vlm = file_cfg.vlm.unwrap_or_default();
135
136        let atlas_endpoint = args
137            .atlas
138            .or_else(env_atlas)
139            .or(file_cfg.atlas_endpoint)
140            .unwrap_or_else(|| DEFAULT_ATLAS_ENDPOINT.to_string());
141        let listen = args
142            .listen
143            .or(file_cfg.listen)
144            .unwrap_or_else(|| DEFAULT_LISTEN.to_string());
145        let id = args
146            .id
147            .or(file_cfg.id)
148            .unwrap_or_else(|| DEFAULT_PILOT_PROVIDER_ID.to_string());
149        let api_format = args
150            .vlm_format
151            .or(file_vlm.api_format)
152            .unwrap_or_else(|| DEFAULT_VLM_FORMAT.to_string());
153        if api_format != "openai" {
154            bail!("vlm api_format='{api_format}' not supported (only 'openai')");
155        }
156
157        let upstream = args
158            .vlm_upstream
159            .or(file_vlm.upstream)
160            .filter(|s| !s.trim().is_empty())
161            .ok_or_else(|| {
162                missing_field("vlm.upstream", "ROBONIX_VLM_UPSTREAM", "--vlm-upstream")
163            })?;
164        let api_key = args
165            .vlm_api_key
166            .or(file_vlm.api_key)
167            .filter(|s| !s.trim().is_empty())
168            .ok_or_else(|| missing_field("vlm.api_key", "ROBONIX_VLM_API_KEY", "--vlm-api-key"))?;
169        let model = args
170            .vlm_model
171            .or(file_vlm.model)
172            .filter(|s| !s.trim().is_empty())
173            .ok_or_else(|| missing_field("vlm.model", "ROBONIX_VLM_MODEL", "--vlm-model"))?;
174
175        Ok(Self {
176            atlas_endpoint,
177            listen,
178            id,
179            vlm: VlmConfig {
180                upstream,
181                api_key,
182                model,
183                api_format,
184            },
185        })
186    }
187}
188
189/// Read the `ROBONIX_ATLAS` env var as an atlas-endpoint alias.
190///
191/// rbnx, the Python API, and liaison all configure the atlas endpoint via
192/// `ROBONIX_ATLAS`, while executor/pilot historically only honored
193/// `ROBONIX_ATLAS_ENDPOINT` (the clap `env`). Accepting `ROBONIX_ATLAS` here as
194/// well means a single env var configures every component. Without it, setting
195/// only `ROBONIX_ATLAS` left pilot silently falling back to
196/// `DEFAULT_ATLAS_ENDPOINT` (127.0.0.1:50051) — it would then dial the wrong
197/// atlas and log 127.0.0.1 even after the operator "changed" the endpoint.
198/// Empty values are ignored so an exported-but-blank var doesn't shadow later
199/// sources.
200fn env_atlas() -> Option<String> {
201    std::env::var("ROBONIX_ATLAS")
202        .ok()
203        .filter(|v| !v.is_empty())
204}
205
206fn load_yaml(path: &Path) -> Result<FileConfig> {
207    let raw = std::fs::read_to_string(path)
208        .with_context(|| format!("read pilot config '{}'", path.display()))?;
209    serde_yaml::from_str(&raw).with_context(|| format!("parse pilot config '{}'", path.display()))
210}
211
212fn missing_field(yaml_path: &str, env_var: &str, flag: &str) -> anyhow::Error {
213    anyhow::anyhow!(
214        "missing required field '{yaml_path}': set it in --config YAML, env {env_var}, or pass {flag}"
215    )
216}