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::{AgentTransport, Config, PolicyMode, 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            worktrees: None,
53            workbench: None,
54            label: String::new(),
55            jobs: None,
56            workspace: std::path::PathBuf::new(),
57        }
58    }
59
60    /// How long a chat may be quiet before its next message starts a new
61    /// episode of its log.
62    pub(crate) fn episode_gap(&self) -> Duration {
63        Duration::from_secs(self.history.episode_gap_minutes.saturating_mul(60))
64    }
65
66    /// Where files the owner keeps from chat go: `history.archive_dir`, or
67    /// the history directory.
68    pub(crate) fn archive_dir(&self) -> std::path::PathBuf {
69        self.history
70            .archive_dir
71            .clone()
72            .unwrap_or_else(|| self.layout().history())
73    }
74
75    /// Web tool settings for a tool-enabled session, or `None` when disabled.
76    /// A Brave backend without a key is left out rather than failing the session.
77    pub(crate) fn web_tools(&self) -> Option<WebToolsConfig> {
78        if !self.web.enabled {
79            return None;
80        }
81        let search = match self.web.search {
82            WebSearchMode::Off | WebSearchMode::Provider => None,
83            WebSearchMode::Searxng => self
84                .web
85                .searxng_url
86                .clone()
87                .map(|url| SearchBackend::Searxng { url }),
88            WebSearchMode::Brave => {
89                let api_key = self
90                    .web
91                    .brave_api_key
92                    .clone()
93                    .or_else(|| {
94                        self.web
95                            .brave_api_key_env
96                            .as_deref()
97                            .and_then(|name| std::env::var(name).ok())
98                            .map(scv_client::Secret::from)
99                    })
100                    .filter(|key| !key.trim().is_empty());
101                if api_key.is_none() {
102                    tracing::warn!(
103                        "web.search is \"brave\" but no Brave API key is configured; web_search is unavailable"
104                    );
105                }
106                api_key.map(|api_key| SearchBackend::Brave {
107                    url: self.web.brave_url.clone(),
108                    api_key: api_key.into_inner(),
109                })
110            }
111        };
112        Some(WebToolsConfig {
113            fetch_max_bytes: self.web.fetch_max_bytes,
114            fetch_timeout: Duration::from_secs(self.web.fetch_timeout_seconds),
115            max_redirects: self.web.max_redirects,
116            auto_approve_domains: self.web.auto_approve_domains.clone(),
117            allow_private_addresses: self.web.allow_private_addresses,
118            search,
119            max_search_results: self.web.max_search_results,
120            output_limit: self.tools.output_limit_bytes,
121        })
122    }
123
124    /// Whether to offer the provider's hosted web search to tool-enabled sessions.
125    pub fn hosted_web_search(&self) -> bool {
126        self.web.enabled && self.web.search == WebSearchMode::Provider
127    }
128
129    pub(crate) fn provider_limits(&self) -> ProviderLimits {
130        ProviderLimits {
131            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
132            max_response_bytes: self.provider_limits.max_response_bytes,
133            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
134            max_tool_calls: self.provider_limits.max_tool_calls,
135            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
136            max_retries: self.provider_limits.max_retries,
137            ..ProviderLimits::default()
138        }
139    }
140
141    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
142        let user_home = dirs::home_dir();
143        self.agents
144            .0
145            .iter()
146            .filter_map(|(name, config)| {
147                let descriptor = scv_tools::adapters::adapter(name)?;
148                let adapter_home = self.layout().agent_home(name);
149                let mut environment = vec![
150                    (OsString::from("SCV_HOME"), adapter_home.clone().into()),
151                    (OsString::from("HOME"), adapter_home.clone().into()),
152                    (
153                        OsString::from("XDG_CONFIG_HOME"),
154                        adapter_home.join("config").into(),
155                    ),
156                    (
157                        OsString::from("XDG_DATA_HOME"),
158                        adapter_home.join("data").into(),
159                    ),
160                    (
161                        OsString::from("XDG_STATE_HOME"),
162                        adapter_home.join("state").into(),
163                    ),
164                ];
165                for (variable, relative) in descriptor.home_environment {
166                    let path = if relative.is_empty() {
167                        adapter_home.clone()
168                    } else {
169                        adapter_home.join(relative)
170                    };
171                    environment.push((OsString::from(variable), path.into()));
172                }
173                let mode = self.agent_mode(name);
174                let full = mode == PolicyMode::Auto;
175                environment.extend(
176                    descriptor
177                        .fixed_environment
178                        .iter()
179                        .chain(
180                            descriptor
181                                .full_permission_environment
182                                .iter()
183                                .filter(|_| full),
184                        )
185                        .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
186                );
187                Some((
188                    name.clone(),
189                    AgentAdapterConfig {
190                        command: config.command.clone(),
191                        args: if descriptor.transport == scv_tools::adapters::Transport::ScvProtocol
192                        {
193                            nested_scv_args(&config.args)
194                        } else {
195                            config.args.clone()
196                        },
197                        prompt_args: config.prompt_args.clone(),
198                        full_permission_args: full.then(|| {
199                            descriptor
200                                .full_permission_args
201                                .iter()
202                                .map(|arg| (*arg).to_owned())
203                                .collect()
204                        }),
205                        policy: mode.as_str().into(),
206                        mode_report: descriptor.mode_report,
207                        mode_switch: descriptor.mode_switch(),
208                        model_args: config.model_args.clone(),
209                        effort_args: config.effort_args.clone(),
210                        model_hint: descriptor.model_hint.into(),
211                        environment,
212                        search_dirs: user_home
213                            .as_deref()
214                            .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
215                            .unwrap_or_default(),
216                        output: descriptor.output,
217                        resume: descriptor.resume,
218                        home: Some(adapter_home),
219                        auth: match descriptor.auth {
220                            scv_tools::adapters::AgentAuth::None => AgentAuthPlan::None,
221                            mode => {
222                                let settings = match scv_tools::claude_auth::claude_settings(
223                                    mode,
224                                    &self.layout().agent_home(name),
225                                ) {
226                                    Ok(Some(settings)) => settings,
227                                    Ok(None) => return None,
228                                    Err(error) => {
229                                        tracing::error!(
230                                            agent = %name,
231                                            %error,
232                                            "cannot build pinned Claude Code settings"
233                                        );
234                                        return None;
235                                    }
236                                };
237                                AgentAuthPlan::Claude {
238                                    mode,
239                                    settings,
240                                    home: self.layout().agent_home(name),
241                                }
242                            }
243                        },
244                        transport: descriptor.transport,
245                        acp: config.acp_launch(descriptor).map(|launch| {
246                            scv_tools::AcpAgentLaunch {
247                                command: launch.command.to_owned(),
248                                args: scv_tools::adapters::acp_args(&launch, full),
249                                full_mode: launch.full_mode.map(str::to_owned),
250                                environment: launch
251                                    .full_environment
252                                    .iter()
253                                    .filter(|_| full)
254                                    .map(|(variable, value)| {
255                                        (OsString::from(variable), OsString::from(value))
256                                    })
257                                    .collect(),
258                                // An agent without a verified one-prompt
259                                // mode runs over ACP only.
260                                required: config.transport == AgentTransport::Acp
261                                    || !descriptor.cli_turns,
262                                session_options: launch.session_options,
263                            }
264                        }),
265                        use_for: config.use_for.clone(),
266                        defaults: config.defaults(),
267                        options_file: Some(self.layout().agent_options(name)),
268                        busy: BusyConfig {
269                            behavior: config.on_busy.unwrap_or(self.agent.on_busy),
270                            steer_fallback: config
271                                .steer_fallback
272                                .unwrap_or(self.agent.steer_fallback),
273                            max_queued_turns: config
274                                .max_queued_turns
275                                .unwrap_or(self.agent.max_queued_turns),
276                        },
277                    },
278                ))
279            })
280            .collect()
281    }
282
283    /// The policy SCV's own tools and the requests relayed to it are
284    /// decided by.
285    pub(crate) fn policy(&self) -> crate::approval::Policy {
286        crate::approval::Policy {
287            mode: self.policy.mode,
288        }
289    }
290
291    /// `[policy] mode` in effect.
292    pub fn policy_mode(&self) -> PolicyMode {
293        self.policy.mode
294    }
295
296    /// The mode delegated agent `name` starts in: its own `[agents.<name>]
297    /// mode`, else `[policy] mode`.
298    pub fn agent_mode(&self, name: &str) -> PolicyMode {
299        self.agents
300            .0
301            .get(name)
302            .and_then(|agent| agent.mode)
303            .unwrap_or(self.policy.mode)
304    }
305
306    /// The agents with a mode of their own, and that mode.
307    pub fn agent_modes(&self) -> Vec<(String, PolicyMode)> {
308        self.agents
309            .0
310            .iter()
311            .filter_map(|(name, agent)| Some((name.clone(), agent.mode?)))
312            .collect()
313    }
314
315    /// `[policy] publish_crate_versions_per_day`: versions of one crate that
316    /// may reach crates.io in any 24 hours before publishing asks the owner.
317    pub fn publish_crate_versions_per_day(&self) -> u64 {
318        self.policy.publish_crate_versions_per_day
319    }
320
321    /// The deprecated keys the layers set, each read as its replacement.
322    pub fn deprecated(&self) -> &[super::Deprecated] {
323        &self.deprecated
324    }
325
326    /// The paths under this instance's home. It does not know whether the
327    /// instance is the default one, so take the service unit name from the
328    /// `Layout` the process resolved instead.
329    pub fn layout(&self) -> Layout {
330        Layout::new(&self.instance_home)
331    }
332
333    pub fn prepare_adapter_homes(&self) -> Result<()> {
334        let layout = self.layout();
335        std::fs::create_dir_all(layout.agents()).context("create the agent homes directory")?;
336        ensure_private_dir(&layout.agents())?;
337        for name in self.agents.0.keys() {
338            let path = layout.agent_home(name);
339            std::fs::create_dir_all(&path)
340                .with_context(|| format!("create isolated {name} agent home"))?;
341            ensure_private_dir(&path)
342                .with_context(|| format!("secure isolated {name} agent home"))?;
343        }
344        Ok(())
345    }
346}
347
348/// A nested SCV's `args` with its policy at least `on-risk`, so every request
349/// that is not read-only is relayed to the parent's gate, whatever `[agents.scv]
350/// args` say: any `--policy` (or `--approval-policy`) is dropped, and the
351/// stricter of `on-risk` and the one dropped is appended.
352fn nested_scv_args(args: &[String]) -> Vec<String> {
353    let mut kept = Vec::with_capacity(args.len() + 2);
354    let mut mode = PolicyMode::OnRisk;
355    let mut args = args.iter();
356    while let Some(arg) = args.next() {
357        let value = match arg.split_once('=') {
358            Some(("--policy" | "--approval-policy", value)) => value,
359            _ if matches!(arg.as_str(), "--policy" | "--approval-policy") => {
360                args.next().map_or("", String::as_str)
361            }
362            _ => {
363                kept.push(arg.clone());
364                continue;
365            }
366        };
367        let named = match value {
368            "always" => PolicyMode::Always,
369            "read-only" | "never" => PolicyMode::ReadOnly,
370            _ => PolicyMode::OnRisk,
371        };
372        if named.strictness() > mode.strictness() {
373            mode = named;
374        }
375    }
376    kept.extend(["--policy".to_owned(), mode.as_str().to_owned()]);
377    kept
378}