Skip to main content

scv_server/config/
schema.rs

1//! The configuration schema: every table of `config.toml`, with defaults.
2
3use std::{
4    collections::{BTreeMap, HashMap},
5    path::PathBuf,
6};
7
8use scv_channels::state::AccountSettings;
9use scv_client::Secret;
10use scv_core::ContextConfig;
11use scv_provider_openai::ProviderLimits;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15#[serde(default, deny_unknown_fields)]
16pub struct Config {
17    pub provider: ProviderConfig,
18    /// Named provider profiles. When non-empty, `provider.active` selects one.
19    pub(crate) providers: HashMap<String, ProviderConfig>,
20    pub(crate) provider_active: Option<String>,
21    pub(crate) agent: AgentConfig,
22    pub(crate) session: SessionConfig,
23    pub(crate) context: ContextConfigFile,
24    pub(crate) tools: ToolConfig,
25    pub(crate) protocol: ProtocolConfig,
26    pub(crate) tui: TuiConfig,
27    pub update: UpdateConfig,
28    pub(crate) notify: NotifyConfig,
29    pub(crate) provider_limits: ProviderLimitsFile,
30    pub(crate) skills: SkillsConfig,
31    pub(crate) agents: AgentsConfig,
32    pub(crate) web: WebConfig,
33    /// `[channels.<channel>.<account>]`: each chat account's settings. SCV's
34    /// channel store reads and edits them in the instance's `config.toml`;
35    /// here they are only validated.
36    pub(crate) channels: BTreeMap<String, BTreeMap<String, AccountSettings>>,
37    /// The process-owned root: see [`Layout`] for what it holds.
38    #[serde(skip)]
39    pub(crate) instance_home: PathBuf,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct ProviderConfig {
45    pub(crate) active: Option<String>,
46    pub kind: String,
47    pub wire_api: String,
48    pub model: String,
49    pub base_url: String,
50    pub api_key: Option<Secret>,
51    pub api_key_env: Option<String>,
52    pub timeout_seconds: u64,
53    /// Extra request headers; their values may carry credentials.
54    pub headers: HashMap<String, Secret>,
55    /// Show images users attach to the model as image input. Turn it off
56    /// for a model without vision; SCV also stops for the session after the
57    /// provider rejects an image.
58    pub(crate) image_input: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62#[serde(default, deny_unknown_fields)]
63pub struct UpdateConfig {
64    /// Optional Cargo registry index URL used by `scv update`.
65    pub index_url: Option<String>,
66}
67
68/// Where SCV sends notices nobody asked for: an update started from a
69/// terminal, a rollback, a restart after a crash, or a disconnected account.
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71#[serde(default, deny_unknown_fields)]
72pub(crate) struct NotifyConfig {
73    /// Accounts as `<channel>:<account>`, such as `feishu:default`. A notice
74    /// goes to the owner of the first one that is connected, on that one
75    /// account only. Empty: the chat the owner last wrote from.
76    pub(crate) owner: Vec<String>,
77}
78
79impl Default for ProviderConfig {
80    fn default() -> Self {
81        Self {
82            active: None,
83            kind: "openai-compatible".into(),
84            wire_api: "responses".into(),
85            model: "gpt-4.1-mini".into(),
86            base_url: "https://api.openai.com/v1".into(),
87            api_key: None,
88            api_key_env: Some("OPENAI_API_KEY".into()),
89            timeout_seconds: 600,
90            headers: HashMap::new(),
91            image_input: true,
92        }
93    }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(default, deny_unknown_fields)]
98pub(crate) struct AgentConfig {
99    pub(crate) max_steps: usize,
100    pub(crate) system_prompt: String,
101    /// The `agent` tool is offered only while this SCV's own delegation depth
102    /// is below this, so delegation chains stay bounded. 0 disables it.
103    pub(crate) max_delegation_depth: u32,
104    /// Delegated conversations a session remembers; starting another forgets
105    /// the least recently used idle one.
106    pub(crate) max_conversations: usize,
107    /// A delegated conversation unused this long is forgotten.
108    pub(crate) conversation_idle_seconds: u64,
109    /// Background agent jobs (`background: true`) a session may run at once;
110    /// 0 turns background delegation off.
111    pub(crate) max_background: usize,
112    /// Agents the user prefers, in order (such as `["codex", "claude"]`);
113    /// the system prompt names the offered ones, and the first of them runs
114    /// an `agent` call that names none. Empty states no preference.
115    pub(crate) prefer: Vec<String>,
116}
117
118impl Default for AgentConfig {
119    fn default() -> Self {
120        Self {
121            max_steps: 128,
122            max_delegation_depth: 2,
123            max_conversations: 8,
124            conversation_idle_seconds: 86400,
125            // The main agent hands most work to background jobs and stays
126            // available, so a few may run at once.
127            max_background: 4,
128            prefer: Vec::new(),
129            system_prompt: "You are SCV, a concise and careful agent. Use tools to inspect, change, and verify.".into(),
130        }
131    }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(default, deny_unknown_fields)]
136pub(crate) struct SessionConfig {
137    pub(crate) max_history_bytes: usize,
138    pub(crate) max_messages: usize,
139}
140
141impl Default for SessionConfig {
142    fn default() -> Self {
143        Self {
144            max_history_bytes: 16 * 1024 * 1024,
145            max_messages: 10_000,
146        }
147    }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(default, deny_unknown_fields)]
152pub(crate) struct ContextConfigFile {
153    pub(crate) max_tokens: usize,
154    pub(crate) reserve_output_tokens: usize,
155    pub(crate) safety_margin_tokens: usize,
156    pub(crate) bytes_per_token: usize,
157    pub(crate) summary_max_chars: usize,
158}
159
160impl Default for ContextConfigFile {
161    fn default() -> Self {
162        let value = ContextConfig::default();
163        Self {
164            max_tokens: value.max_tokens,
165            reserve_output_tokens: value.reserve_output_tokens,
166            safety_margin_tokens: value.safety_margin_tokens,
167            bytes_per_token: value.bytes_per_token,
168            summary_max_chars: value.summary_max_chars,
169        }
170    }
171}
172
173impl From<&ContextConfigFile> for ContextConfig {
174    fn from(value: &ContextConfigFile) -> Self {
175        Self {
176            max_tokens: value.max_tokens,
177            reserve_output_tokens: value.reserve_output_tokens,
178            safety_margin_tokens: value.safety_margin_tokens,
179            bytes_per_token: value.bytes_per_token,
180            summary_max_chars: value.summary_max_chars,
181        }
182    }
183}
184
185#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(rename_all = "kebab-case")]
187pub enum ApprovalPolicy {
188    OnRisk,
189    Always,
190    Never,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(default, deny_unknown_fields)]
195pub(crate) struct ToolConfig {
196    pub(crate) approval_policy: ApprovalPolicy,
197    /// `bash` timeout when a call does not choose one.
198    pub(crate) command_timeout_seconds: u64,
199    /// Native-agent timeout when a call does not choose one.
200    pub(crate) agent_timeout_seconds: u64,
201    /// The longest timeout a single tool call may request.
202    pub(crate) max_timeout_seconds: u64,
203    pub(crate) output_limit_bytes: usize,
204    pub(crate) max_read_bytes: usize,
205    pub(crate) max_write_bytes: usize,
206}
207
208impl Default for ToolConfig {
209    fn default() -> Self {
210        Self {
211            approval_policy: ApprovalPolicy::OnRisk,
212            command_timeout_seconds: 600,
213            agent_timeout_seconds: 3600,
214            max_timeout_seconds: 14400,
215            output_limit_bytes: 64 * 1024,
216            max_read_bytes: 256 * 1024,
217            max_write_bytes: 1024 * 1024,
218        }
219    }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default, deny_unknown_fields)]
224pub(crate) struct ProtocolConfig {
225    pub(crate) max_client_frame_bytes: usize,
226    pub(crate) max_server_frame_bytes: usize,
227}
228
229impl Default for ProtocolConfig {
230    fn default() -> Self {
231        Self {
232            max_client_frame_bytes: 1024 * 1024,
233            max_server_frame_bytes: 8 * 1024 * 1024,
234        }
235    }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(default, deny_unknown_fields)]
240pub(crate) struct TuiConfig {
241    pub(crate) max_transcript_bytes: usize,
242    pub(crate) max_transcript_items: usize,
243    pub(crate) max_prompt_history_bytes: usize,
244    pub(crate) max_prompt_history_items: usize,
245}
246
247impl Default for TuiConfig {
248    fn default() -> Self {
249        Self {
250            max_transcript_bytes: 8 * 1024 * 1024,
251            max_transcript_items: 10_000,
252            max_prompt_history_bytes: 1024 * 1024,
253            max_prompt_history_items: 200,
254        }
255    }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(default, deny_unknown_fields)]
260pub(crate) struct ProviderLimitsFile {
261    pub(crate) max_sse_event_bytes: usize,
262    pub(crate) max_response_bytes: usize,
263    pub(crate) max_assistant_bytes: usize,
264    pub(crate) max_tool_calls: usize,
265    pub(crate) max_tool_arguments_bytes: usize,
266    pub(crate) max_retries: usize,
267}
268
269impl Default for ProviderLimitsFile {
270    fn default() -> Self {
271        let value = ProviderLimits::default();
272        Self {
273            max_sse_event_bytes: value.max_sse_event_bytes,
274            max_response_bytes: value.max_response_bytes,
275            max_assistant_bytes: value.max_assistant_bytes,
276            max_tool_calls: value.max_tool_calls,
277            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
278            max_retries: value.max_retries,
279        }
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(default, deny_unknown_fields)]
285pub(crate) struct SkillsConfig {
286    pub(crate) user_dir: PathBuf,
287    pub(crate) project_dir: PathBuf,
288    /// List the agent skills (`.agents/skills`, `.claude/skills`) of the
289    /// workspace and its immediate child projects in tool-enabled sessions.
290    pub(crate) scan_projects: bool,
291    pub(crate) max_skills: usize,
292    pub(crate) max_skill_bytes: usize,
293}
294
295impl Default for SkillsConfig {
296    fn default() -> Self {
297        Self {
298            user_dir: PathBuf::from("~/.scv/skills"),
299            project_dir: PathBuf::from(".scv/skills"),
300            scan_projects: true,
301            max_skills: 128,
302            max_skill_bytes: 256 * 1024,
303        }
304    }
305}
306
307/// Where `web_search` results come from.
308#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
309#[serde(rename_all = "lowercase")]
310pub(crate) enum WebSearchMode {
311    Off,
312    /// The provider endpoint's hosted Responses `web_search` tool.
313    Provider,
314    Searxng,
315    Brave,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(default, deny_unknown_fields)]
320pub(crate) struct WebConfig {
321    /// Offer `web_fetch` (and search, when configured) to tool-enabled sessions.
322    pub(crate) enabled: bool,
323    pub(crate) fetch_max_bytes: usize,
324    pub(crate) fetch_timeout_seconds: u64,
325    pub(crate) max_redirects: usize,
326    /// HTTPS hosts `web_fetch` may read without approval.
327    pub(crate) auto_approve_domains: Vec<String>,
328    /// Let `web_fetch` reach loopback, private, and link-local addresses.
329    pub(crate) allow_private_addresses: bool,
330    pub(crate) search: WebSearchMode,
331    pub(crate) searxng_url: Option<String>,
332    pub(crate) brave_url: String,
333    pub(crate) brave_api_key: Option<Secret>,
334    pub(crate) brave_api_key_env: Option<String>,
335    pub(crate) max_search_results: usize,
336}
337
338impl Default for WebConfig {
339    fn default() -> Self {
340        Self {
341            enabled: true,
342            fetch_max_bytes: 2 * 1024 * 1024,
343            fetch_timeout_seconds: 30,
344            max_redirects: 5,
345            auto_approve_domains: [
346                "docs.rs",
347                "crates.io",
348                "doc.rust-lang.org",
349                "docs.python.org",
350                "pypi.org",
351                "developer.mozilla.org",
352            ]
353            .map(String::from)
354            .to_vec(),
355            allow_private_addresses: false,
356            search: WebSearchMode::Off,
357            searxng_url: None,
358            brave_url: "https://api.search.brave.com/res/v1/web/search".into(),
359            brave_api_key: None,
360            brave_api_key_env: Some("BRAVE_SEARCH_API_KEY".into()),
361            max_search_results: 8,
362        }
363    }
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, Default)]
367#[serde(default, deny_unknown_fields)]
368pub(crate) struct AdapterConfig {
369    pub(crate) command: String,
370    pub(crate) args: Vec<String>,
371    /// `full` adds the CLI's own switches for unprompted, unsandboxed work.
372    pub(crate) permissions: AgentPermissions,
373    /// Placed immediately before the prompt (`grok -p <prompt>`).
374    pub(crate) prompt_args: Vec<String>,
375    /// Appended when a call selects a model; `{model}` is substituted.
376    pub(crate) model_args: Vec<String>,
377    /// Appended when a call selects an effort; `{effort}` is substituted.
378    pub(crate) effort_args: Vec<String>,
379    /// How SCV talks to the agent: its ACP server or one process per turn.
380    pub(crate) transport: AgentTransport,
381    /// When to choose this agent, in the user's words; added to its tool
382    /// description so the model can pick between agents.
383    pub(crate) use_for: Option<String>,
384    /// Model to pass when the work matches `use_for`. Without `use_for`, pass
385    /// it whenever this agent is called, unless the user asks for another.
386    pub(crate) model: Option<String>,
387    /// Effort to pass the same way as `model`.
388    pub(crate) effort: Option<String>,
389}
390
391/// How SCV talks to a delegated agent that has an ACP server.
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
393#[serde(rename_all = "lowercase")]
394pub(crate) enum AgentTransport {
395    /// The agent's ACP server when it is installed, else one process per turn.
396    #[default]
397    Auto,
398    /// Only its ACP server; the agent is not offered while it is missing.
399    Acp,
400    /// One CLI process per turn, continued through the CLI's own resume.
401    Resume,
402}
403
404/// How much a delegated CLI may do without its own prompts.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
406#[serde(rename_all = "lowercase")]
407pub(crate) enum AgentPermissions {
408    /// Add nothing: the CLI's own configuration decides.
409    #[default]
410    Default,
411    /// Add the CLI's full-autonomy switches: no approval prompts, no sandbox,
412    /// and web search where the CLI gates it. An explicit user opt-in.
413    Full,
414}
415
416/// `[agents.<name>]` for every adapter in [`scv_tools::adapters::ADAPTERS`].
417#[derive(Debug, Clone, Serialize, Deserialize)]
418#[serde(transparent)]
419pub(crate) struct AgentsConfig(pub(crate) BTreeMap<String, AdapterConfig>);
420
421impl Default for AgentsConfig {
422    fn default() -> Self {
423        let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
424        Self(
425            scv_tools::adapters::ADAPTERS
426                .iter()
427                .map(|adapter| {
428                    (
429                        adapter.name.to_owned(),
430                        AdapterConfig {
431                            command: adapter.command.into(),
432                            args: strings(adapter.args),
433                            permissions: AgentPermissions::Default,
434                            prompt_args: strings(adapter.prompt_args),
435                            model_args: strings(adapter.model_args),
436                            effort_args: strings(adapter.effort_args),
437                            transport: AgentTransport::Auto,
438                            use_for: None,
439                            model: None,
440                            effort: None,
441                        },
442                    )
443                })
444                .collect(),
445        )
446    }
447}
448
449/// What the command line, or a client's `session.start`, changes on top of
450/// the configuration files.
451#[derive(Debug, Clone, Default)]
452pub struct ConfigOverrides {
453    pub provider: Option<String>,
454    pub model: Option<String>,
455    pub base_url: Option<String>,
456    pub approval_policy: Option<ApprovalPolicy>,
457    pub no_tools: bool,
458    /// An explicit configuration layer (`--config`, or `SCV_CONFIG` as the
459    /// process received it), applied after the user and project files.
460    pub config_file: Option<PathBuf>,
461}