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