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