Skip to main content

agent_runtime_config/
lib.rs

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