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