Skip to main content

agent_runtime_config/
lib.rs

1use std::{fmt, fs, path::Path};
2
3use serde::Deserialize;
4use thiserror::Error;
5
6#[derive(Clone, Default, Deserialize)]
7#[serde(transparent)]
8pub struct SecretString(String);
9
10impl SecretString {
11    pub fn expose(&self) -> &str {
12        &self.0
13    }
14    fn is_empty(&self) -> bool {
15        self.0.is_empty()
16    }
17}
18
19impl fmt::Debug for SecretString {
20    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21        formatter.write_str("[REDACTED]")
22    }
23}
24
25#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum RuntimeMode {
28    Local,
29    Production,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33#[serde(rename_all = "snake_case", deny_unknown_fields)]
34pub struct RuntimeConfig {
35    pub mode: RuntimeMode,
36    pub server: ServerConfig,
37    pub scheduler: SchedulerConfig,
38    #[serde(alias = "agentInfra")]
39    pub agent_infra: AgentInfraConfig,
40    pub journal: JournalConfig,
41    pub auth: AuthConfig,
42    pub binding: AgentBindingConfig,
43    #[serde(default)]
44    pub context: ContextConfig,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48#[serde(rename_all = "snake_case", deny_unknown_fields)]
49pub struct ServerConfig {
50    pub bind: String,
51    #[serde(alias = "shutdownGraceSeconds")]
52    pub shutdown_grace_seconds: u64,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56#[serde(rename_all = "snake_case", deny_unknown_fields)]
57pub struct SchedulerConfig {
58    #[serde(alias = "maxConcurrentExecutions")]
59    pub max_concurrent_executions: usize,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "snake_case", deny_unknown_fields)]
64pub struct AgentInfraConfig {
65    #[serde(alias = "gatewayUrl")]
66    pub gateway_url: String,
67    #[serde(alias = "requestTimeoutMs")]
68    pub request_timeout_ms: u64,
69    #[serde(alias = "maxResponseBytes")]
70    pub max_response_bytes: usize,
71    #[serde(alias = "trustedMeshHttp")]
72    pub trusted_mesh_http: bool,
73    #[serde(alias = "workloadSubject")]
74    pub workload_subject: String,
75    #[serde(default, alias = "workloadToken")]
76    pub workload_token: SecretString,
77    #[serde(alias = "delegationTtlSeconds")]
78    pub delegation_ttl_seconds: u64,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82#[serde(
83    tag = "type",
84    rename_all = "snake_case",
85    rename_all_fields = "snake_case",
86    deny_unknown_fields
87)]
88pub enum JournalConfig {
89    Memory {
90        #[serde(alias = "maxEventsPerExecution")]
91        max_events_per_execution: usize,
92    },
93}
94
95#[derive(Debug, Clone, Deserialize)]
96#[serde(
97    tag = "type",
98    rename_all = "snake_case",
99    rename_all_fields = "snake_case",
100    deny_unknown_fields
101)]
102pub enum AuthConfig {
103    LocalStatic {
104        #[serde(default)]
105        token: SecretString,
106        subject: String,
107        #[serde(alias = "tenantId")]
108        tenant_id: String,
109        #[serde(alias = "projectId")]
110        project_id: String,
111        #[serde(default)]
112        capabilities: Vec<String>,
113    },
114    TrustedGateway {
115        #[serde(default, alias = "sharedSecret")]
116        shared_secret: SecretString,
117    },
118}
119
120#[derive(Debug, Clone, Deserialize)]
121#[serde(rename_all = "snake_case", deny_unknown_fields)]
122pub struct AgentBindingConfig {
123    #[serde(alias = "runtimeInstanceId")]
124    pub runtime_instance_id: String,
125    #[serde(alias = "runtimeType")]
126    pub runtime_type: String,
127    pub model: String,
128    #[serde(alias = "workspaceId")]
129    pub workspace_id: String,
130    #[serde(alias = "packageId")]
131    pub package_id: String,
132    #[serde(alias = "packageVersion")]
133    pub package_version: String,
134    #[serde(default, alias = "packagePath")]
135    pub package_path: Option<String>,
136}
137
138#[derive(Debug, Clone, Deserialize)]
139#[serde(rename_all = "snake_case", deny_unknown_fields)]
140pub struct ContextConfig {
141    #[serde(alias = "maxMessages")]
142    pub max_messages: usize,
143    #[serde(alias = "maxInputTokens")]
144    pub max_input_tokens: u64,
145    #[serde(alias = "reservedOutputTokens")]
146    pub reserved_output_tokens: u32,
147    #[serde(alias = "protocolOverheadTokens")]
148    pub protocol_overhead_tokens: u64,
149    #[serde(alias = "autoCompact")]
150    pub auto_compact: bool,
151    pub compaction: ContextCompactionConfig,
152}
153
154impl Default for ContextConfig {
155    fn default() -> Self {
156        Self {
157            max_messages: 128,
158            max_input_tokens: 128 * 1024,
159            reserved_output_tokens: 8 * 1024,
160            protocol_overhead_tokens: 256,
161            auto_compact: true,
162            compaction: ContextCompactionConfig::default(),
163        }
164    }
165}
166
167#[derive(Debug, Clone, Deserialize)]
168#[serde(rename_all = "snake_case", deny_unknown_fields)]
169pub struct ContextCompactionConfig {
170    #[serde(alias = "triggerPercent")]
171    pub trigger_percent: u8,
172    #[serde(alias = "targetPercent")]
173    pub target_percent: u8,
174    #[serde(alias = "emergencyPercent")]
175    pub emergency_percent: u8,
176    #[serde(alias = "minReclaimPercent")]
177    pub min_reclaim_percent: u8,
178    #[serde(alias = "minTurnsBetween")]
179    pub min_turns_between: usize,
180    #[serde(alias = "checkpointMaxChars")]
181    pub checkpoint_max_chars: usize,
182}
183
184impl Default for ContextCompactionConfig {
185    fn default() -> Self {
186        Self {
187            trigger_percent: 82,
188            target_percent: 60,
189            emergency_percent: 95,
190            min_reclaim_percent: 10,
191            min_turns_between: 4,
192            checkpoint_max_chars: 8_000,
193        }
194    }
195}
196
197#[derive(Debug, Error)]
198pub enum ConfigError {
199    #[error("failed to read settings file: {0}")]
200    Read(#[from] std::io::Error),
201    #[error("config JSON is invalid: {0}")]
202    JsonDecode(#[from] serde_json::Error),
203    #[error("config TOML is invalid: {0}")]
204    TomlDecode(#[from] toml::de::Error),
205    #[error("settings are invalid: {0}")]
206    Invalid(String),
207}
208
209pub struct ConfigLoadReport {
210    pub config: RuntimeConfig,
211    pub warnings: Vec<String>,
212}
213
214impl RuntimeConfig {
215    pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
216        Ok(Self::load_report(path)?.config)
217    }
218
219    pub fn load_report(path: Option<&Path>) -> Result<ConfigLoadReport, ConfigError> {
220        let (mut config, warnings) = match path {
221            Some(path)
222                if path
223                    .extension()
224                    .is_some_and(|extension| extension == "json") =>
225            {
226                (
227                    serde_json::from_slice(&fs::read(path)?)?,
228                    vec!["JSON Runtime config is deprecated; migrate to TOML".into()],
229                )
230            }
231            Some(path) => (toml::from_str(&fs::read_to_string(path)?)?, Vec::new()),
232            None => (Self::local_defaults(), Vec::new()),
233        };
234        config.apply_environment()?;
235        config.validate()?;
236        Ok(ConfigLoadReport { config, warnings })
237    }
238
239    pub fn validate(&self) -> Result<(), ConfigError> {
240        self.server
241            .bind
242            .parse::<std::net::SocketAddr>()
243            .map_err(|_| ConfigError::Invalid("server.bind must be a socket address".into()))?;
244        if self.server.shutdown_grace_seconds == 0
245            || self.server.shutdown_grace_seconds > 300
246            || self.scheduler.max_concurrent_executions < 2
247            || self.scheduler.max_concurrent_executions > 10_000
248        {
249            return Err(ConfigError::Invalid(
250                "shutdown grace must be 1..=300s and execution concurrency 2..=10000".into(),
251            ));
252        }
253        let gateway = url::Url::parse(&self.agent_infra.gateway_url)
254            .map_err(|_| ConfigError::Invalid("Agent Infra Gateway URL is invalid".into()))?;
255        if gateway.host_str().is_none()
256            || !gateway.username().is_empty()
257            || gateway.password().is_some()
258        {
259            return Err(ConfigError::Invalid(
260                "Agent Infra Gateway URL requires a host and must not contain userinfo".into(),
261            ));
262        }
263        if self.agent_infra.request_timeout_ms == 0
264            || self.agent_infra.request_timeout_ms > 600_000
265            || !(1024..=1024 * 1024 * 1024).contains(&self.agent_infra.max_response_bytes)
266        {
267            return Err(ConfigError::Invalid(
268                "Agent Infra request/body limits are invalid".into(),
269            ));
270        }
271        if self.agent_infra.workload_subject.trim().is_empty()
272            || self.agent_infra.workload_token.is_empty()
273            || !(30..=7 * 24 * 60 * 60).contains(&self.agent_infra.delegation_ttl_seconds)
274        {
275            return Err(ConfigError::Invalid(
276                "Agent Infra workload identity/delegation settings are invalid".into(),
277            ));
278        }
279        let JournalConfig::Memory {
280            max_events_per_execution,
281        } = &self.journal;
282        if !(1..=1_000_000).contains(max_events_per_execution) {
283            return Err(ConfigError::Invalid(
284                "memory journal events must be 1..=1000000".into(),
285            ));
286        }
287        if let AuthConfig::LocalStatic { token, .. } = &self.auth
288            && token.is_empty()
289        {
290            return Err(ConfigError::Invalid("local auth token is empty".into()));
291        }
292        if let AuthConfig::TrustedGateway { shared_secret } = &self.auth
293            && !(32..=4096).contains(&shared_secret.expose().len())
294        {
295            return Err(ConfigError::Invalid(
296                "trusted gateway shared secret must contain 32..=4096 bytes".into(),
297            ));
298        }
299        if self.mode == RuntimeMode::Production {
300            if !matches!(self.auth, AuthConfig::TrustedGateway { .. }) {
301                return Err(ConfigError::Invalid(
302                    "production requires trusted gateway ingress authentication".into(),
303                ));
304            }
305            if gateway.scheme() != "https" || self.agent_infra.trusted_mesh_http {
306                return Err(ConfigError::Invalid(
307                    "production Agent Infra Gateway must use HTTPS".into(),
308                ));
309            }
310        }
311        let compaction = &self.context.compaction;
312        if self.context.max_messages < 2
313            || self.context.max_input_tokens < 1_024
314            || u64::from(self.context.reserved_output_tokens) >= self.context.max_input_tokens
315            || self.context.protocol_overhead_tokens >= self.context.max_input_tokens
316            || !(1..=99).contains(&compaction.target_percent)
317            || compaction.trigger_percent <= compaction.target_percent
318            || compaction.emergency_percent <= compaction.trigger_percent
319            || compaction.emergency_percent > 100
320            || compaction.min_reclaim_percent > 100
321            || !(1_024..=1_000_000).contains(&compaction.checkpoint_max_chars)
322        {
323            return Err(ConfigError::Invalid(
324                "context limits and compaction watermarks are invalid".into(),
325            ));
326        }
327        Ok(())
328    }
329
330    fn apply_environment(&mut self) -> Result<(), ConfigError> {
331        if let Ok(value) = std::env::var("RUNTIME_BIND") {
332            self.server.bind = value;
333        }
334        if let Ok(value) = std::env::var("AGENT_INFRA_GATEWAY_URL") {
335            self.agent_infra.gateway_url = value;
336        }
337        if let Ok(value) = std::env::var("AGENT_INFRA_WORKLOAD_TOKEN") {
338            self.agent_infra.workload_token = SecretString(value);
339        }
340        if let Ok(value) = std::env::var("AGENT_INFRA_WORKLOAD_SUBJECT") {
341            self.agent_infra.workload_subject = value;
342        }
343        if let Ok(value) = std::env::var("RUNTIME_INSTANCE_ID") {
344            self.binding.runtime_instance_id = value;
345        }
346        if let Ok(value) = std::env::var("RUNTIME_MODEL") {
347            self.binding.model = value;
348        }
349        if let Ok(value) = std::env::var("RUNTIME_WORKSPACE_ID") {
350            self.binding.workspace_id = value;
351        }
352        if let Ok(value) = std::env::var("RUNTIME_LOCAL_TOKEN") {
353            match &mut self.auth {
354                AuthConfig::LocalStatic { token, .. } => *token = SecretString(value),
355                AuthConfig::TrustedGateway { .. } => {
356                    return Err(ConfigError::Invalid(
357                        "RUNTIME_LOCAL_TOKEN cannot override trusted gateway auth".into(),
358                    ));
359                }
360            }
361        }
362        if let Ok(value) = std::env::var("RUNTIME_GATEWAY_SHARED_SECRET") {
363            match &mut self.auth {
364                AuthConfig::TrustedGateway { shared_secret } => {
365                    *shared_secret = SecretString(value)
366                }
367                AuthConfig::LocalStatic { .. } => {
368                    return Err(ConfigError::Invalid(
369                        "RUNTIME_GATEWAY_SHARED_SECRET requires trusted gateway auth".into(),
370                    ));
371                }
372            }
373        }
374        if self.mode == RuntimeMode::Local {
375            if self.agent_infra.workload_token.is_empty() {
376                self.agent_infra.workload_token =
377                    SecretString("local-runtime-workload-token".into());
378            }
379            if let AuthConfig::LocalStatic { token, .. } = &mut self.auth
380                && token.is_empty()
381            {
382                *token = SecretString("local-runtime-token".into());
383            }
384        }
385        Ok(())
386    }
387
388    fn local_defaults() -> Self {
389        Self {
390            mode: RuntimeMode::Local,
391            server: ServerConfig {
392                bind: "127.0.0.1:8080".into(),
393                shutdown_grace_seconds: 30,
394            },
395            scheduler: SchedulerConfig {
396                max_concurrent_executions: 16,
397            },
398            agent_infra: AgentInfraConfig {
399                gateway_url: "http://127.0.0.1:5100".into(),
400                request_timeout_ms: 30_000,
401                max_response_bytes: 16 * 1024 * 1024,
402                trusted_mesh_http: true,
403                workload_subject: "local-anonymous".into(),
404                workload_token: SecretString("local-runtime-workload-token".into()),
405                delegation_ttl_seconds: 300,
406            },
407            journal: JournalConfig::Memory {
408                max_events_per_execution: 10_000,
409            },
410            auth: AuthConfig::LocalStatic {
411                token: SecretString("local-runtime-token".into()),
412                subject: "local-user".into(),
413                tenant_id: "local-tenant".into(),
414                project_id: "local-project".into(),
415                capabilities: vec![
416                    "*".into(),
417                    "files:read".into(),
418                    "files:write".into(),
419                    "commands:execute".into(),
420                    "interaction:request".into(),
421                    "agent:delegate".into(),
422                ],
423            },
424            binding: AgentBindingConfig {
425                runtime_instance_id: "runtime-local".into(),
426                runtime_type: "code".into(),
427                model: "local-model".into(),
428                workspace_id: "workspace-local".into(),
429                package_id: "runtime-code-agent".into(),
430                package_version: env!("CARGO_PKG_VERSION").into(),
431                package_path: None,
432            },
433            context: ContextConfig::default(),
434        }
435    }
436}
437
438// Compatibility names for existing Runtime distributions. New code should use
439// the `*Config` names above.
440pub type RuntimeSettings = RuntimeConfig;
441pub type ServerSettings = ServerConfig;
442pub type SchedulerSettings = SchedulerConfig;
443pub type AgentInfraSettings = AgentInfraConfig;
444pub type JournalSettings = JournalConfig;
445pub type AuthSettings = AuthConfig;
446pub type RuntimeBindingSettings = AgentBindingConfig;
447pub type SettingsError = ConfigError;
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn rejects_unknown_fields_and_redacts_secrets() {
455        let error = serde_json::from_str::<RuntimeConfig>(r#"{"unknown":true}"#).unwrap_err();
456        assert!(error.to_string().contains("unknown field"));
457        assert_eq!(format!("{:?}", SecretString("secret".into())), "[REDACTED]");
458    }
459
460    #[test]
461    fn production_accepts_memory_but_rejects_local_auth() {
462        let mut settings = RuntimeConfig::local_defaults();
463        settings.mode = RuntimeMode::Production;
464        assert!(settings.validate().is_err());
465
466        settings.agent_infra.gateway_url = "https://agent-infra.example.internal".into();
467        settings.agent_infra.trusted_mesh_http = false;
468        settings.auth = AuthConfig::TrustedGateway {
469            shared_secret: SecretString("a-secure-gateway-secret-with-32-bytes".into()),
470        };
471        assert!(settings.validate().is_ok());
472    }
473
474    #[test]
475    fn preferred_toml_and_legacy_json_decode_to_the_same_context_policy() {
476        let toml: RuntimeConfig = toml::from_str(
477            r#"
478mode = "local"
479[server]
480bind = "127.0.0.1:8080"
481shutdown_grace_seconds = 30
482[scheduler]
483max_concurrent_executions = 16
484[agent_infra]
485gateway_url = "http://127.0.0.1:5100"
486request_timeout_ms = 30000
487max_response_bytes = 16777216
488trusted_mesh_http = true
489workload_subject = "local-anonymous"
490delegation_ttl_seconds = 300
491[journal]
492type = "memory"
493max_events_per_execution = 10000
494[auth]
495type = "local_static"
496subject = "local-user"
497tenant_id = "local-tenant"
498project_id = "local-project"
499[binding]
500runtime_instance_id = "runtime-local"
501runtime_type = "code"
502model = "local-model"
503workspace_id = "workspace-local"
504package_id = "runtime-code-agent"
505package_version = "0.1.0"
506[context]
507max_messages = 128
508max_input_tokens = 131072
509reserved_output_tokens = 8192
510protocol_overhead_tokens = 256
511auto_compact = true
512[context.compaction]
513trigger_percent = 82
514target_percent = 60
515emergency_percent = 95
516min_reclaim_percent = 10
517min_turns_between = 4
518checkpoint_max_chars = 8000
519"#,
520        )
521        .unwrap();
522        let json: RuntimeConfig = serde_json::from_str(
523            r#"{
524  "mode":"local",
525  "server":{"bind":"127.0.0.1:8080","shutdownGraceSeconds":30},
526  "scheduler":{"maxConcurrentExecutions":16},
527  "agentInfra":{"gatewayUrl":"http://127.0.0.1:5100","requestTimeoutMs":30000,"maxResponseBytes":16777216,"trustedMeshHttp":true,"workloadSubject":"local-anonymous","delegationTtlSeconds":300},
528  "journal":{"type":"memory","maxEventsPerExecution":10000},
529  "auth":{"type":"local_static","subject":"local-user","tenantId":"local-tenant","projectId":"local-project"},
530  "binding":{"runtimeInstanceId":"runtime-local","runtimeType":"code","model":"local-model","workspaceId":"workspace-local","packageId":"runtime-code-agent","packageVersion":"0.1.0"},
531  "context":{"maxMessages":128,"maxInputTokens":131072,"reservedOutputTokens":8192,"protocolOverheadTokens":256,"autoCompact":true,"compaction":{"triggerPercent":82,"targetPercent":60,"emergencyPercent":95,"minReclaimPercent":10,"minTurnsBetween":4,"checkpointMaxChars":8000}}
532}"#,
533        )
534        .unwrap();
535        assert_eq!(toml.context.max_input_tokens, 128 * 1024);
536        assert_eq!(toml.context.max_input_tokens, json.context.max_input_tokens);
537        assert_eq!(toml.binding.package_id, json.binding.package_id);
538    }
539}