Skip to main content

relay_knowledge/application/runtime/
agent.rs

1use std::{error::Error, fmt, time::Duration};
2
3use crate::{
4    api::{AgentAccessPolicy, AgentPolicyError},
5    env::EnvironmentConfig,
6};
7
8/// Resident agent protocol runtime configuration.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct AgentRuntimeConfig {
11    pub mcp_streamable_http_enabled: bool,
12    pub mcp_endpoint: String,
13    pub mcp_allowed_origins: Vec<String>,
14    pub access_policy: AgentAccessPolicy,
15    pub audit_sink_enabled: bool,
16    pub audit_queue_depth: usize,
17}
18
19impl AgentRuntimeConfig {
20    pub const DEFAULT_AUDIT_QUEUE_DEPTH: usize = 1024;
21
22    /// Builds agent protocol config from typed environment overrides.
23    pub fn from_environment(
24        environment: &EnvironmentConfig,
25        request_timeout: Duration,
26    ) -> Result<Self, AgentRuntimeConfigError> {
27        let max_runtime_ms = agent_runtime_budget_ms(request_timeout);
28        let access_policy = AgentAccessPolicy::new(
29            split_csv(environment.agent.mcp_allowed_scopes.as_deref())?,
30            environment
31                .agent
32                .mcp_allow_unspecified_scope
33                .unwrap_or(false),
34            environment
35                .agent
36                .mcp_max_limit
37                .unwrap_or(AgentAccessPolicy::DEFAULT_MAX_LIMIT),
38            environment
39                .agent
40                .mcp_max_context_bytes
41                .unwrap_or(AgentAccessPolicy::DEFAULT_MAX_CONTEXT_BYTES),
42            max_runtime_ms,
43            environment.agent.mcp_allow_remote_clients.unwrap_or(false),
44        )
45        .map_err(AgentRuntimeConfigError::Policy)?;
46
47        Ok(Self {
48            mcp_streamable_http_enabled: environment
49                .agent
50                .mcp_streamable_http_enabled
51                .unwrap_or(false),
52            mcp_endpoint: validate_endpoint(
53                environment.agent.mcp_endpoint.as_deref().unwrap_or("/mcp"),
54            )?,
55            mcp_allowed_origins: split_csv(environment.agent.mcp_allowed_origins.as_deref())?,
56            access_policy,
57            audit_sink_enabled: environment.agent.audit_sink_enabled.unwrap_or(false),
58            audit_queue_depth: environment
59                .agent
60                .audit_queue_depth
61                .unwrap_or(Self::DEFAULT_AUDIT_QUEUE_DEPTH),
62        })
63    }
64
65    /// Returns a copy with streamable HTTP forced on by a CLI command.
66    pub fn with_streamable_http_enabled(mut self) -> Self {
67        self.mcp_streamable_http_enabled = true;
68        self
69    }
70}
71
72/// Agent runtime configuration validation error.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum AgentRuntimeConfigError {
75    InvalidEndpoint(String),
76    EmptyListValue,
77    Policy(AgentPolicyError),
78}
79
80impl fmt::Display for AgentRuntimeConfigError {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::InvalidEndpoint(value) => {
84                write!(
85                    formatter,
86                    "MCP endpoint '{value}' must be an absolute HTTP path"
87                )
88            }
89            Self::EmptyListValue => {
90                write!(formatter, "MCP comma-separated values must not be empty")
91            }
92            Self::Policy(error) => write!(formatter, "{error}"),
93        }
94    }
95}
96
97impl Error for AgentRuntimeConfigError {}
98
99fn validate_endpoint(value: &str) -> Result<String, AgentRuntimeConfigError> {
100    let trimmed = value.trim();
101    if !trimmed.starts_with('/')
102        || trimmed.contains(char::is_whitespace)
103        || trimmed.contains('?')
104        || trimmed.contains('#')
105    {
106        return Err(AgentRuntimeConfigError::InvalidEndpoint(value.to_owned()));
107    }
108
109    Ok(trimmed.to_owned())
110}
111
112fn split_csv(value: Option<&str>) -> Result<Vec<String>, AgentRuntimeConfigError> {
113    value
114        .map(|items| {
115            items
116                .split(',')
117                .map(str::trim)
118                .map(|item| {
119                    if item.is_empty() {
120                        Err(AgentRuntimeConfigError::EmptyListValue)
121                    } else {
122                        Ok(item.to_owned())
123                    }
124                })
125                .collect()
126        })
127        .unwrap_or_else(|| Ok(Vec::new()))
128}
129
130fn duration_millis(duration: Duration) -> u64 {
131    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
132}
133
134fn agent_runtime_budget_ms(request_timeout: Duration) -> u64 {
135    let budget = request_timeout.saturating_sub(Duration::from_millis(1));
136    duration_millis(budget).max(1)
137}
138
139#[cfg(test)]
140#[path = "agent_tests.rs"]
141mod agent_tests;