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