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