Skip to main content

scv_server/config/
runtime.rs

1//! Turning configuration into what the runtime, tools, and provider take.
2
3use std::{collections::HashMap, ffi::OsString, time::Duration};
4
5use anyhow::{Context, Result};
6use scv_client::Layout;
7use scv_core::{AgentConfig as CoreAgentConfig, HistoryLimits};
8use scv_provider_openai::ProviderLimits;
9use scv_tools::{
10    AgentAdapterConfig, ToolsConfig,
11    conversation::ConversationLimits,
12    web::{SearchBackend, WebToolsConfig},
13};
14
15use super::{AgentPermissions, AgentTransport, Config, WebSearchMode, load::ensure_private_dir};
16
17impl Config {
18    pub(crate) fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
19        CoreAgentConfig {
20            system_prompt,
21            max_steps: self.agent.max_steps,
22            history_limits: HistoryLimits {
23                max_bytes: self.session.max_history_bytes,
24                max_messages: self.session.max_messages,
25                note_max_chars: self.context.summary_max_chars,
26            },
27        }
28    }
29
30    pub(crate) fn tools(&self) -> ToolsConfig {
31        ToolsConfig {
32            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
33            agent_timeout: Duration::from_secs(self.tools.agent_timeout_seconds),
34            max_timeout: Duration::from_secs(self.tools.max_timeout_seconds),
35            output_limit_bytes: self.tools.output_limit_bytes,
36            max_read_bytes: self.tools.max_read_bytes,
37            max_write_bytes: self.tools.max_write_bytes,
38            max_delegation_depth: self.agent.max_delegation_depth,
39            conversations: ConversationLimits {
40                max: self.agent.max_conversations,
41                idle: Duration::from_secs(self.agent.conversation_idle_seconds),
42            },
43            delegation: None,
44            max_background: self.agent.max_background,
45            background: None,
46            chat_attach: None,
47        }
48    }
49
50    /// Web tool settings for a tool-enabled session, or `None` when disabled.
51    /// A Brave backend without a key is left out rather than failing the session.
52    pub(crate) fn web_tools(&self) -> Option<WebToolsConfig> {
53        if !self.web.enabled {
54            return None;
55        }
56        let search = match self.web.search {
57            WebSearchMode::Off | WebSearchMode::Provider => None,
58            WebSearchMode::Searxng => self
59                .web
60                .searxng_url
61                .clone()
62                .map(|url| SearchBackend::Searxng { url }),
63            WebSearchMode::Brave => {
64                let api_key = self
65                    .web
66                    .brave_api_key
67                    .clone()
68                    .or_else(|| {
69                        self.web
70                            .brave_api_key_env
71                            .as_deref()
72                            .and_then(|name| std::env::var(name).ok())
73                            .map(scv_client::Secret::from)
74                    })
75                    .filter(|key| !key.trim().is_empty());
76                if api_key.is_none() {
77                    tracing::warn!(
78                        "web.search is \"brave\" but no Brave API key is configured; web_search is unavailable"
79                    );
80                }
81                api_key.map(|api_key| SearchBackend::Brave {
82                    url: self.web.brave_url.clone(),
83                    api_key: api_key.into_inner(),
84                })
85            }
86        };
87        Some(WebToolsConfig {
88            fetch_max_bytes: self.web.fetch_max_bytes,
89            fetch_timeout: Duration::from_secs(self.web.fetch_timeout_seconds),
90            max_redirects: self.web.max_redirects,
91            auto_approve_domains: self.web.auto_approve_domains.clone(),
92            allow_private_addresses: self.web.allow_private_addresses,
93            search,
94            max_search_results: self.web.max_search_results,
95            output_limit: self.tools.output_limit_bytes,
96        })
97    }
98
99    /// Whether to offer the provider's hosted web search to tool-enabled sessions.
100    pub fn hosted_web_search(&self) -> bool {
101        self.web.enabled && self.web.search == WebSearchMode::Provider
102    }
103
104    pub(crate) fn provider_limits(&self) -> ProviderLimits {
105        ProviderLimits {
106            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
107            max_response_bytes: self.provider_limits.max_response_bytes,
108            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
109            max_tool_calls: self.provider_limits.max_tool_calls,
110            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
111            max_retries: self.provider_limits.max_retries,
112            ..ProviderLimits::default()
113        }
114    }
115
116    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
117        let user_home = dirs::home_dir();
118        self.agents
119            .0
120            .iter()
121            .filter_map(|(name, config)| {
122                let descriptor = scv_tools::adapters::adapter(name)?;
123                let adapter_home = self.layout().agent_home(name);
124                let mut environment = vec![
125                    (OsString::from("SCV_HOME"), adapter_home.clone().into()),
126                    (OsString::from("HOME"), adapter_home.clone().into()),
127                    (
128                        OsString::from("XDG_CONFIG_HOME"),
129                        adapter_home.join("config").into(),
130                    ),
131                    (
132                        OsString::from("XDG_DATA_HOME"),
133                        adapter_home.join("data").into(),
134                    ),
135                    (
136                        OsString::from("XDG_STATE_HOME"),
137                        adapter_home.join("state").into(),
138                    ),
139                ];
140                for (variable, relative) in descriptor.home_environment {
141                    let path = if relative.is_empty() {
142                        adapter_home.clone()
143                    } else {
144                        adapter_home.join(relative)
145                    };
146                    environment.push((OsString::from(variable), path.into()));
147                }
148                let full = config.permissions == AgentPermissions::Full;
149                environment.extend(
150                    descriptor
151                        .fixed_environment
152                        .iter()
153                        .chain(
154                            descriptor
155                                .full_permission_environment
156                                .iter()
157                                .filter(|_| full),
158                        )
159                        .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
160                );
161                Some((
162                    format!("agent_{name}"),
163                    AgentAdapterConfig {
164                        command: config.command.clone(),
165                        args: config.args.clone(),
166                        prompt_args: config.prompt_args.clone(),
167                        full_permission_args: full.then(|| {
168                            descriptor
169                                .full_permission_args
170                                .iter()
171                                .map(|arg| (*arg).to_owned())
172                                .collect()
173                        }),
174                        model_args: config.model_args.clone(),
175                        effort_args: config.effort_args.clone(),
176                        model_hint: descriptor.model_hint.into(),
177                        environment,
178                        search_dirs: user_home
179                            .as_deref()
180                            .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
181                            .unwrap_or_default(),
182                        output: descriptor.output,
183                        resume: descriptor.resume,
184                        home: Some(adapter_home),
185                        transport: descriptor.transport,
186                        acp: descriptor
187                            .acp
188                            .filter(|_| match config.transport {
189                                AgentTransport::Acp => true,
190                                AgentTransport::Resume => false,
191                                // A custom `command` points SCV at a specific
192                                // CLI, which the ACP server would not run.
193                                AgentTransport::Auto => config.command == descriptor.command,
194                            })
195                            .map(|launch| scv_tools::AcpAgentLaunch {
196                                command: launch.command.to_owned(),
197                                args: scv_tools::adapters::acp_args(&launch, full),
198                                full_mode: launch.full_mode.filter(|_| full).map(str::to_owned),
199                                environment: launch
200                                    .full_environment
201                                    .iter()
202                                    .filter(|_| full)
203                                    .map(|(variable, value)| {
204                                        (OsString::from(variable), OsString::from(value))
205                                    })
206                                    .collect(),
207                                required: config.transport == AgentTransport::Acp,
208                            }),
209                        use_for: config.use_for.clone(),
210                        model: config.model.clone(),
211                        effort: config.effort.clone(),
212                    },
213                ))
214            })
215            .collect()
216    }
217
218    /// The paths under this instance's home. It does not know whether the
219    /// instance is the default one, so take the service unit name from the
220    /// `Layout` the process resolved instead.
221    pub fn layout(&self) -> Layout {
222        Layout::new(&self.instance_home)
223    }
224
225    pub fn prepare_adapter_homes(&self) -> Result<()> {
226        let layout = self.layout();
227        std::fs::create_dir_all(layout.agents()).context("create the agent homes directory")?;
228        ensure_private_dir(&layout.agents())?;
229        for name in self.agents.0.keys() {
230            let path = layout.agent_home(name);
231            std::fs::create_dir_all(&path)
232                .with_context(|| format!("create isolated {name} agent home"))?;
233            ensure_private_dir(&path)
234                .with_context(|| format!("secure isolated {name} agent home"))?;
235        }
236        Ok(())
237    }
238}