relay_knowledge/application/runtime/
agent.rs1use std::{error::Error, fmt, time::Duration};
2
3use crate::{
4 api::{AgentAccessPolicy, AgentPolicyError},
5 env::EnvironmentConfig,
6};
7
8#[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 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 pub fn with_streamable_http_enabled(mut self) -> Self {
67 self.mcp_streamable_http_enabled = true;
68 self
69 }
70}
71
72#[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;