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