Skip to main content

smooth_operator_server/
config.rs

1//! Server configuration, read entirely from the environment.
2//!
3//! No secret is ever hardcoded. The gateway key is optional at *startup* — the
4//! server still binds and answers protocol-only actions (`ping`,
5//! `create_conversation_session`) without it — but `send_message` returns a
6//! clean `error` event when the key is absent, so protocol conformance can be
7//! tested with zero credentials.
8//!
9//! ## Environment variables (the contract every language E2E harness reuses)
10//!
11//! | var | default | meaning |
12//! | --- | --- | --- |
13//! | `SMOOTH_AGENT_BIND` | `127.0.0.1` | IP address to bind. Set `0.0.0.0` in k8s/containers so the Service/Ingress can reach the pod. |
14//! | `SMOOTH_AGENT_PORT` | `8787` | TCP port to bind. |
15//! | `SMOOAI_GATEWAY_URL` | `https://llm.smoo.ai/v1` | OpenAI-compatible LLM gateway base URL. |
16//! | `SMOOAI_GATEWAY_KEY` | *(unset)* | Gateway API key. When unset, `send_message` errors cleanly. |
17//! | `SMOOTH_AGENT_MODEL` | `claude-haiku-4-5` | Model id requested from the gateway. |
18//! | `SMOOTH_AGENT_PREAMBLE_MODEL` | *(unset → off)* | When set to a fast model id (e.g. `groq-gpt-oss-20b`), a small model runs in parallel with each streaming turn and emits ONE ephemeral `stream_preamble` sentence ("what I'm about to do") to cover the main model's time-to-first-token. Uses the same gateway/key as `SMOOTH_AGENT_MODEL`. Unset ⇒ no extra call, behavior unchanged. |
19//! | `SMOOTH_AGENT_SEED_KB` | *(unset)* | When `1`, seed a couple of distinctive demo docs on startup. |
20//! | `SMOOTH_AGENT_MAX_ITERATIONS` | `6` | Agent-loop iteration cap per turn. |
21//! | `SMOOTH_AGENT_MAX_TOKENS` | `512` | `max_tokens` sent to the gateway (kept low — paid endpoint). |
22//! | `SMOOTH_AGENT_STORAGE` | `memory` | Storage backend: `memory` \| `postgres` \| `dynamodb`. |
23//! | `SMOOTH_AGENT_BACKPLANE` | `memory` | Connection backplane: `memory` (single-process) \| `redis`/`valkey` \| `nats`. A distributed backend is required for >1 replica and to let non-AI publishers push events via `Backplane::publish`. |
24//! | `SMOOTH_AGENT_BACKPLANE_URL` | *(unset)* | Bus URL for `redis`/`nats` (e.g. `redis://valkey:6379`, `nats://nats:4222`); falls back to `SMOOTH_AGENT_REDIS_URL` / `SMOOTH_AGENT_NATS_URL`. |
25//! | `WIDGET_AUTH_STRICT` | *(unset → `false`)* | Fail-closed embeddable-widget auth: when `1`/`true`, a session for an agent the [`WidgetAuthProvider`](smooth_operator::widget_auth::WidgetAuthProvider) has no policy for is rejected. Origin + `authContext` are always enforced for policied agents. |
26//! | `SMOOTH_AGENT_CONFIRM_TOOLS` | *(unset → off)* | Comma-separated tool-name substrings that require **human confirmation** before the agent may run them (write-confirmation HITL). A turn that calls a matching tool parks and emits a `write_confirmation_required` event; the client resumes it with `confirm_tool_action` (`{sessionId, requestId, approved}`). Empty = no tool ever requires confirmation (byte-for-byte unchanged). |
27//! | `WIDGET_AUTH_URL` | *(unset → permissive)* | When set, install an [`HttpWidgetAuth`](smooth_operator::widget_auth::HttpWidgetAuth) provider resolving each agent's embed policy from `{url}/{agentId}` — enforce widget auth against a host policy service with no custom binary. |
28//! | `WIDGET_AUTH_BEARER` | *(unset)* | Optional bearer token sent to `WIDGET_AUTH_URL` (e.g. an M2M token). |
29//! | `WIDGET_AUTH_TTL_SECS` | `60` | Policy cache TTL for `WIDGET_AUTH_URL` (incl. cached 404 no-policy results). |
30//!
31//! ### Auth (load-bearing — the admin API's `require_role` reads these)
32//!
33//! Parsed by [`smooth_operator::auth::AuthConfig::from_env`], not [`ServerConfig`],
34//! but documented here because they gate `/admin` and the binary refuses to start
35//! when they're misconfigured. See [`smooth_operator::auth`] for the full contract.
36//!
37//! | var | default | meaning |
38//! | --- | --- | --- |
39//! | `AUTH_MODE` | *(unset → admin disabled, 401)* | `jwt` (BYO) \| `smoo` (hosted) \| `none` (dev only). Unset boots `/ws` but `/admin` returns 401 until configured. |
40//! | `AUTH_JWT_HS256_SECRET` | — | HS256 shared secret (for `jwt`/`smoo`). |
41//! | `AUTH_JWT_RS256_PUBLIC_KEY` | — | RS256 PEM public key (takes precedence over HS256). |
42//! | `AUTH_JWT_ISSUER` | — | Required `iss` claim (required for `smoo`; optional for `jwt`). |
43//! | `AUTH_JWT_AUDIENCE` | — | Required `aud` claim (optional). |
44//!
45//! ### Embedding (the retrieval/index path)
46//!
47//! The `/index` path (and the `dev-support` example) select the embedder from the
48//! gateway config above: with `SMOOAI_GATEWAY_KEY` set, the real **`GatewayEmbedder`**
49//! (`text-embedding-3-small`, 1536-d) is used for semantic retrieval; without it,
50//! the network-free **`DeterministicEmbedder`** (FNV-1a hash, 1024-d) is used and a
51//! warning is logged. See [`crate::embedder`].
52
53use smooth_operator_core::llm::{ApiFormat, RetryPolicy};
54use smooth_operator_core::LlmConfig;
55
56/// Default bind address (loopback; override with `0.0.0.0` in containers).
57pub const DEFAULT_BIND: &str = "127.0.0.1";
58/// Default WebSocket bind port.
59pub const DEFAULT_PORT: u16 = 8787;
60/// Default OpenAI-compatible LLM gateway.
61pub const DEFAULT_GATEWAY_URL: &str = "https://llm.smoo.ai/v1";
62/// Default (cheap) model.
63pub const DEFAULT_MODEL: &str = "claude-haiku-4-5";
64/// Default agent-loop iteration cap. Was 6 (chat-widget sizing) — too tight for
65/// any multi-step turn. Raised to 20 for agentic use (EPIC th-1cc9fa).
66pub const DEFAULT_MAX_ITERATIONS: u32 = 20;
67/// Default `max_tokens` per LLM call. Was 512 (chat-widget sizing), which
68/// STARVES reasoning models — they spend it all on `reasoning_content` and
69/// return empty `content`. Raised to 8192 (EPIC th-1cc9fa). A cap only bounds
70/// runaway output; concise answers stay concise, and the per-model output
71/// ceiling clamp (`AgentConfig::with_model_ceiling`) keeps it under whatever the
72/// model can physically emit.
73pub const DEFAULT_MAX_TOKENS: u32 = 8192;
74
75/// Which storage backend the server runs on. Selected via `SMOOTH_AGENT_STORAGE`
76/// (`memory` / `postgres` / `dynamodb`); the **admin stores** (connector configs,
77/// settings, indexing runs) follow the same backend so they're durable wherever
78/// the conversations / knowledge live.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum StorageBackend {
81    /// Process-local in-memory (the default — local dev / tests). Admin stores
82    /// are the in-memory impls (lost on restart).
83    Memory,
84    /// Postgres + pgvector. Admin stores persist to the same database.
85    Postgres,
86    /// DynamoDB single-table (AWS-serverless). Admin stores persist to the same
87    /// table.
88    Dynamodb,
89}
90
91impl StorageBackend {
92    /// Parse from the `SMOOTH_AGENT_STORAGE` wire value (case-insensitive).
93    /// Unknown / empty falls back to [`StorageBackend::Memory`].
94    #[must_use]
95    pub fn parse(value: &str) -> Self {
96        match value.trim().to_ascii_lowercase().as_str() {
97            "postgres" | "pg" | "postgresql" => Self::Postgres,
98            "dynamodb" | "ddb" | "dynamo" => Self::Dynamodb,
99            _ => Self::Memory,
100        }
101    }
102}
103
104/// Fully-resolved server configuration.
105#[derive(Debug, Clone)]
106pub struct ServerConfig {
107    /// IP address to bind (`127.0.0.1` for local dev, `0.0.0.0` in containers).
108    pub bind: String,
109    /// Port to bind.
110    pub port: u16,
111    /// LLM gateway base URL.
112    pub gateway_url: String,
113    /// Optional gateway API key. `None` means LLM turns are unavailable and
114    /// `send_message` returns a clean error.
115    pub gateway_key: Option<String>,
116    /// Model id.
117    pub model: String,
118    /// Whether to seed the knowledge base with demo docs on startup.
119    pub seed_kb: bool,
120    /// Agent-loop iteration cap per turn.
121    pub max_iterations: u32,
122    /// `max_tokens` per LLM call.
123    pub max_tokens: u32,
124    /// Storage backend (drives both the storage adapter and the matching durable
125    /// admin stores). Defaults to [`StorageBackend::Memory`].
126    pub storage: StorageBackend,
127    /// Fail-closed embeddable-widget auth: when `true`, a session for an agent
128    /// the [`WidgetAuthProvider`](smooth_operator::widget_auth::WidgetAuthProvider)
129    /// has **no** policy for is **rejected** (unknown/unregistered agents can't be
130    /// embedded). When `false` (default), an absent policy is allowed — so the
131    /// permissive default provider leaves `/ws` open. Set `WIDGET_AUTH_STRICT=1`
132    /// in front of a real provider. Origin + `authContext` are always enforced
133    /// for agents that *do* have a policy, regardless of this flag.
134    pub widget_auth_strict: bool,
135    /// **Write-confirmation HITL**: tool-name substrings that require human
136    /// approval before the agent may run them. When non-empty, a turn that calls
137    /// a matching tool **parks** and emits a `confirm_tool_action_required` event;
138    /// the client resumes it with `confirm_tool_action`. Read from
139    /// `SMOOTH_AGENT_CONFIRM_TOOLS` (comma-separated). Empty (the default) means
140    /// no tool ever requires confirmation — no turn parks, byte-for-byte
141    /// unchanged from before HITL. Matched by core's `ConfirmationHook` (`contains`).
142    pub confirm_tools: Vec<String>,
143    /// Cheap fast-tier model for the post-turn conversation-workflow judge
144    /// (SMOODEV-590). Independent of [`model`](Self::model) so the judge stays
145    /// cheap even when a turn runs on a bigger model. Read from
146    /// `SMOOTH_AGENT_JUDGE_MODEL`; defaults to [`DEFAULT_MODEL`] (haiku-tier).
147    pub judge_model: String,
148}
149
150impl ServerConfig {
151    /// Read configuration from the environment, applying documented defaults.
152    #[must_use]
153    pub fn from_env() -> Self {
154        let bind = std::env::var("SMOOTH_AGENT_BIND")
155            .ok()
156            .map(|s| s.trim().to_string())
157            .filter(|s| !s.is_empty())
158            .unwrap_or_else(|| DEFAULT_BIND.to_string());
159
160        let port = std::env::var("SMOOTH_AGENT_PORT")
161            .ok()
162            .and_then(|s| s.trim().parse::<u16>().ok())
163            .unwrap_or(DEFAULT_PORT);
164
165        let gateway_url = std::env::var("SMOOAI_GATEWAY_URL")
166            .ok()
167            .map(|s| s.trim().to_string())
168            .filter(|s| !s.is_empty())
169            .unwrap_or_else(|| DEFAULT_GATEWAY_URL.to_string());
170
171        let gateway_key = std::env::var("SMOOAI_GATEWAY_KEY")
172            .ok()
173            .map(|s| s.trim().to_string())
174            .filter(|s| !s.is_empty());
175
176        let model = std::env::var("SMOOTH_AGENT_MODEL")
177            .ok()
178            .map(|s| s.trim().to_string())
179            .filter(|s| !s.is_empty())
180            .unwrap_or_else(|| DEFAULT_MODEL.to_string());
181
182        let seed_kb = std::env::var("SMOOTH_AGENT_SEED_KB").as_deref() == Ok("1");
183
184        let max_iterations = std::env::var("SMOOTH_AGENT_MAX_ITERATIONS")
185            .ok()
186            .and_then(|s| s.trim().parse::<u32>().ok())
187            .filter(|n| *n > 0)
188            .unwrap_or(DEFAULT_MAX_ITERATIONS);
189
190        let max_tokens = std::env::var("SMOOTH_AGENT_MAX_TOKENS")
191            .ok()
192            .and_then(|s| s.trim().parse::<u32>().ok())
193            .filter(|n| *n > 0)
194            .unwrap_or(DEFAULT_MAX_TOKENS);
195
196        let storage = std::env::var("SMOOTH_AGENT_STORAGE")
197            .ok()
198            .map(|s| StorageBackend::parse(&s))
199            .unwrap_or(StorageBackend::Memory);
200
201        let widget_auth_strict = std::env::var("WIDGET_AUTH_STRICT")
202            .ok()
203            .map(|s| {
204                let s = s.trim().to_ascii_lowercase();
205                s == "1" || s == "true" || s == "yes"
206            })
207            .unwrap_or(false);
208
209        let confirm_tools = std::env::var("SMOOTH_AGENT_CONFIRM_TOOLS")
210            .ok()
211            .map(|s| parse_confirm_tools(&s))
212            .unwrap_or_default();
213
214        let judge_model = std::env::var("SMOOTH_AGENT_JUDGE_MODEL")
215            .ok()
216            .map(|s| s.trim().to_string())
217            .filter(|s| !s.is_empty())
218            .unwrap_or_else(|| DEFAULT_MODEL.to_string());
219
220        Self {
221            bind,
222            port,
223            gateway_url,
224            gateway_key,
225            model,
226            seed_kb,
227            max_iterations,
228            max_tokens,
229            storage,
230            widget_auth_strict,
231            confirm_tools,
232            judge_model,
233        }
234    }
235
236    /// The configured write-confirmation tool patterns, or `None` when none are
237    /// configured (so the runner installs no `ConfirmationHook` and the turn
238    /// behaves exactly as before HITL). `Some` only when at least one non-empty
239    /// pattern is set.
240    #[must_use]
241    pub fn confirmation_tool_patterns(&self) -> Option<Vec<String>> {
242        if self.confirm_tools.is_empty() {
243            None
244        } else {
245            Some(self.confirm_tools.clone())
246        }
247    }
248
249    /// `true` when a gateway key is present, so LLM turns can actually run.
250    #[must_use]
251    pub fn has_llm(&self) -> bool {
252        self.gateway_key.is_some()
253    }
254
255    /// Build the smooth-operator [`LlmConfig`] for live turns using the server's
256    /// configured (env) gateway key.
257    ///
258    /// Returns `None` when no gateway key is configured (callers should emit a
259    /// clean protocol `error` rather than attempting a turn).
260    ///
261    /// In a multi-tenant flavor the per-turn key comes from a
262    /// [`GatewayKeyResolver`](smooth_operator::gateway_key::GatewayKeyResolver)
263    /// instead; use [`llm_config_with_key`](Self::llm_config_with_key) once the
264    /// per-org key is resolved.
265    #[must_use]
266    pub fn llm_config(&self) -> Option<LlmConfig> {
267        let key = self.gateway_key.clone()?;
268        Some(self.llm_config_with_key(key))
269    }
270
271    /// Build the smooth-operator [`LlmConfig`] for live turns with an explicit
272    /// gateway key (gateway URL, model, and limits still come from this config).
273    ///
274    /// This is the per-org seam's entry point: a
275    /// [`GatewayKeyResolver`](smooth_operator::gateway_key::GatewayKeyResolver)
276    /// resolves the key for the turn's org (falling back to the env key), and the
277    /// resolved key is threaded through here. With the default env resolver this
278    /// produces exactly the same config as [`llm_config`](Self::llm_config).
279    #[must_use]
280    pub fn llm_config_with_key(&self, key: String) -> LlmConfig {
281        LlmConfig {
282            api_url: self.gateway_url.clone(),
283            api_key: key,
284            model: self.model.clone(),
285            max_tokens: self.max_tokens,
286            temperature: 0.0,
287            retry_policy: RetryPolicy::default(),
288            api_format: ApiFormat::OpenAiCompat,
289        }
290    }
291
292    /// Build an [`LlmConfig`] **without** requiring a gateway key, for the
293    /// test-only path where a [`MockLlmClient`](smooth_operator_core::llm_provider::MockLlmClient)
294    /// is injected (the scenario-parity corpus). The mock replaces the client
295    /// built from this config, so its url/key/model are never used to make a
296    /// network call — this just satisfies the engine's `LlmConfig` argument so a
297    /// keyless deterministic turn can run. Not reachable on the production path
298    /// (only consulted when `chat_provider` is `Some`).
299    #[must_use]
300    pub fn placeholder_llm_config(&self) -> LlmConfig {
301        LlmConfig {
302            api_url: self.gateway_url.clone(),
303            api_key: "mock-no-network".to_string(),
304            model: self.model.clone(),
305            max_tokens: self.max_tokens,
306            temperature: 0.0,
307            retry_policy: RetryPolicy::default(),
308            api_format: ApiFormat::OpenAiCompat,
309        }
310    }
311}
312
313/// Parse the comma-separated `SMOOTH_AGENT_CONFIRM_TOOLS` value into trimmed,
314/// non-empty tool-name patterns. Whitespace-only / empty entries are dropped so
315/// `","` or `" "` yields no patterns (HITL stays off).
316fn parse_confirm_tools(raw: &str) -> Vec<String> {
317    raw.split(',')
318        .map(str::trim)
319        .filter(|s| !s.is_empty())
320        .map(str::to_string)
321        .collect()
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn defaults_apply_when_env_absent() {
330        // Build a config directly (env-independent) to assert default constants
331        // line up with the documented contract.
332        let cfg = ServerConfig {
333            bind: DEFAULT_BIND.to_string(),
334            port: DEFAULT_PORT,
335            gateway_url: DEFAULT_GATEWAY_URL.to_string(),
336            gateway_key: None,
337            model: DEFAULT_MODEL.to_string(),
338            seed_kb: false,
339            max_iterations: DEFAULT_MAX_ITERATIONS,
340            max_tokens: DEFAULT_MAX_TOKENS,
341            storage: StorageBackend::Memory,
342            widget_auth_strict: false,
343            confirm_tools: Vec::new(),
344            judge_model: DEFAULT_MODEL.to_string(),
345        };
346        assert_eq!(cfg.port, 8787);
347        assert_eq!(cfg.storage, StorageBackend::Memory);
348        assert_eq!(cfg.gateway_url, "https://llm.smoo.ai/v1");
349        assert_eq!(cfg.model, "claude-haiku-4-5");
350        assert!(!cfg.has_llm());
351        assert!(cfg.llm_config().is_none());
352    }
353
354    #[test]
355    fn llm_config_built_when_key_present() {
356        let cfg = ServerConfig {
357            bind: DEFAULT_BIND.to_string(),
358            port: 1,
359            gateway_url: "https://example.test/v1".into(),
360            gateway_key: Some("sk-test".into()),
361            model: "m".into(),
362            seed_kb: false,
363            max_iterations: 4,
364            max_tokens: 128,
365            storage: StorageBackend::Memory,
366            widget_auth_strict: false,
367            confirm_tools: Vec::new(),
368            judge_model: DEFAULT_MODEL.to_string(),
369        };
370        assert!(cfg.has_llm());
371        let llm = cfg.llm_config().expect("llm config");
372        assert_eq!(llm.api_url, "https://example.test/v1");
373        assert_eq!(llm.model, "m");
374        assert_eq!(llm.max_tokens, 128);
375        assert!(matches!(llm.api_format, ApiFormat::OpenAiCompat));
376    }
377
378    #[test]
379    fn storage_backend_parse_maps_aliases_and_defaults_memory() {
380        assert_eq!(StorageBackend::parse("postgres"), StorageBackend::Postgres);
381        assert_eq!(StorageBackend::parse("  PG "), StorageBackend::Postgres);
382        assert_eq!(
383            StorageBackend::parse("PostgreSQL"),
384            StorageBackend::Postgres
385        );
386        assert_eq!(StorageBackend::parse("dynamodb"), StorageBackend::Dynamodb);
387        assert_eq!(StorageBackend::parse("ddb"), StorageBackend::Dynamodb);
388        assert_eq!(StorageBackend::parse("Dynamo"), StorageBackend::Dynamodb);
389        // Memory is the default for the explicit value, unknown values, and empty.
390        assert_eq!(StorageBackend::parse("memory"), StorageBackend::Memory);
391        assert_eq!(StorageBackend::parse("sqlite"), StorageBackend::Memory);
392        assert_eq!(StorageBackend::parse(""), StorageBackend::Memory);
393    }
394}