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    /// `agent_*` tools are offered only while this SCV's own delegation depth
102    /// is below this, so delegation chains stay bounded. 0 disables them.
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 installed ones. Empty states no preference.
114    pub(crate) prefer: Vec<String>,
115}
116
117impl Default for AgentConfig {
118    fn default() -> Self {
119        Self {
120            max_steps: 128,
121            max_delegation_depth: 2,
122            max_conversations: 8,
123            conversation_idle_seconds: 86400,
124            // The main agent hands most work to background jobs and stays
125            // available, so a few may run at once.
126            max_background: 4,
127            prefer: Vec::new(),
128            system_prompt: "You are SCV, a concise and careful agent. Use tools to inspect, change, and verify.".into(),
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(default, deny_unknown_fields)]
135pub(crate) struct SessionConfig {
136    pub(crate) max_history_bytes: usize,
137    pub(crate) max_messages: usize,
138}
139
140impl Default for SessionConfig {
141    fn default() -> Self {
142        Self {
143            max_history_bytes: 16 * 1024 * 1024,
144            max_messages: 10_000,
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub(crate) struct ContextConfigFile {
152    pub(crate) max_tokens: usize,
153    pub(crate) reserve_output_tokens: usize,
154    pub(crate) safety_margin_tokens: usize,
155    pub(crate) bytes_per_token: usize,
156    pub(crate) summary_max_chars: usize,
157}
158
159impl Default for ContextConfigFile {
160    fn default() -> Self {
161        let value = ContextConfig::default();
162        Self {
163            max_tokens: value.max_tokens,
164            reserve_output_tokens: value.reserve_output_tokens,
165            safety_margin_tokens: value.safety_margin_tokens,
166            bytes_per_token: value.bytes_per_token,
167            summary_max_chars: value.summary_max_chars,
168        }
169    }
170}
171
172impl From<&ContextConfigFile> for ContextConfig {
173    fn from(value: &ContextConfigFile) -> Self {
174        Self {
175            max_tokens: value.max_tokens,
176            reserve_output_tokens: value.reserve_output_tokens,
177            safety_margin_tokens: value.safety_margin_tokens,
178            bytes_per_token: value.bytes_per_token,
179            summary_max_chars: value.summary_max_chars,
180        }
181    }
182}
183
184#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(rename_all = "kebab-case")]
186pub enum ApprovalPolicy {
187    OnRisk,
188    Always,
189    Never,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default, deny_unknown_fields)]
194pub(crate) struct ToolConfig {
195    pub(crate) approval_policy: ApprovalPolicy,
196    /// `bash` timeout when a call does not choose one.
197    pub(crate) command_timeout_seconds: u64,
198    /// Native-agent timeout when a call does not choose one.
199    pub(crate) agent_timeout_seconds: u64,
200    /// The longest timeout a single tool call may request.
201    pub(crate) max_timeout_seconds: u64,
202    pub(crate) output_limit_bytes: usize,
203    pub(crate) max_read_bytes: usize,
204    pub(crate) max_write_bytes: usize,
205}
206
207impl Default for ToolConfig {
208    fn default() -> Self {
209        Self {
210            approval_policy: ApprovalPolicy::OnRisk,
211            command_timeout_seconds: 600,
212            agent_timeout_seconds: 3600,
213            max_timeout_seconds: 14400,
214            output_limit_bytes: 64 * 1024,
215            max_read_bytes: 256 * 1024,
216            max_write_bytes: 1024 * 1024,
217        }
218    }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub(crate) struct ProtocolConfig {
224    pub(crate) max_client_frame_bytes: usize,
225    pub(crate) max_server_frame_bytes: usize,
226}
227
228impl Default for ProtocolConfig {
229    fn default() -> Self {
230        Self {
231            max_client_frame_bytes: 1024 * 1024,
232            max_server_frame_bytes: 8 * 1024 * 1024,
233        }
234    }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(default, deny_unknown_fields)]
239pub(crate) struct TuiConfig {
240    pub(crate) max_transcript_bytes: usize,
241    pub(crate) max_transcript_items: usize,
242    pub(crate) max_prompt_history_bytes: usize,
243    pub(crate) max_prompt_history_items: usize,
244}
245
246impl Default for TuiConfig {
247    fn default() -> Self {
248        Self {
249            max_transcript_bytes: 8 * 1024 * 1024,
250            max_transcript_items: 10_000,
251            max_prompt_history_bytes: 1024 * 1024,
252            max_prompt_history_items: 200,
253        }
254    }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[serde(default, deny_unknown_fields)]
259pub(crate) struct ProviderLimitsFile {
260    pub(crate) max_sse_event_bytes: usize,
261    pub(crate) max_response_bytes: usize,
262    pub(crate) max_assistant_bytes: usize,
263    pub(crate) max_tool_calls: usize,
264    pub(crate) max_tool_arguments_bytes: usize,
265    pub(crate) max_retries: usize,
266}
267
268impl Default for ProviderLimitsFile {
269    fn default() -> Self {
270        let value = ProviderLimits::default();
271        Self {
272            max_sse_event_bytes: value.max_sse_event_bytes,
273            max_response_bytes: value.max_response_bytes,
274            max_assistant_bytes: value.max_assistant_bytes,
275            max_tool_calls: value.max_tool_calls,
276            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
277            max_retries: value.max_retries,
278        }
279    }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(default, deny_unknown_fields)]
284pub(crate) struct SkillsConfig {
285    pub(crate) user_dir: PathBuf,
286    pub(crate) project_dir: PathBuf,
287    /// List the agent skills (`.agents/skills`, `.claude/skills`) of the
288    /// workspace and its immediate child projects in tool-enabled sessions.
289    pub(crate) scan_projects: bool,
290    pub(crate) max_skills: usize,
291    pub(crate) max_skill_bytes: usize,
292}
293
294impl Default for SkillsConfig {
295    fn default() -> Self {
296        Self {
297            user_dir: PathBuf::from("~/.scv/skills"),
298            project_dir: PathBuf::from(".scv/skills"),
299            scan_projects: true,
300            max_skills: 128,
301            max_skill_bytes: 256 * 1024,
302        }
303    }
304}
305
306/// Where `web_search` results come from.
307#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(rename_all = "lowercase")]
309pub(crate) enum WebSearchMode {
310    Off,
311    /// The provider endpoint's hosted Responses `web_search` tool.
312    Provider,
313    Searxng,
314    Brave,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318#[serde(default, deny_unknown_fields)]
319pub(crate) struct WebConfig {
320    /// Offer `web_fetch` (and search, when configured) to tool-enabled sessions.
321    pub(crate) enabled: bool,
322    pub(crate) fetch_max_bytes: usize,
323    pub(crate) fetch_timeout_seconds: u64,
324    pub(crate) max_redirects: usize,
325    /// HTTPS hosts `web_fetch` may read without approval.
326    pub(crate) auto_approve_domains: Vec<String>,
327    /// Let `web_fetch` reach loopback, private, and link-local addresses.
328    pub(crate) allow_private_addresses: bool,
329    pub(crate) search: WebSearchMode,
330    pub(crate) searxng_url: Option<String>,
331    pub(crate) brave_url: String,
332    pub(crate) brave_api_key: Option<Secret>,
333    pub(crate) brave_api_key_env: Option<String>,
334    pub(crate) max_search_results: usize,
335}
336
337impl Default for WebConfig {
338    fn default() -> Self {
339        Self {
340            enabled: true,
341            fetch_max_bytes: 2 * 1024 * 1024,
342            fetch_timeout_seconds: 30,
343            max_redirects: 5,
344            auto_approve_domains: [
345                "docs.rs",
346                "crates.io",
347                "doc.rust-lang.org",
348                "docs.python.org",
349                "pypi.org",
350                "developer.mozilla.org",
351            ]
352            .map(String::from)
353            .to_vec(),
354            allow_private_addresses: false,
355            search: WebSearchMode::Off,
356            searxng_url: None,
357            brave_url: "https://api.search.brave.com/res/v1/web/search".into(),
358            brave_api_key: None,
359            brave_api_key_env: Some("BRAVE_SEARCH_API_KEY".into()),
360            max_search_results: 8,
361        }
362    }
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, Default)]
366#[serde(default, deny_unknown_fields)]
367pub(crate) struct AdapterConfig {
368    pub(crate) command: String,
369    pub(crate) args: Vec<String>,
370    /// `full` adds the CLI's own switches for unprompted, unsandboxed work.
371    pub(crate) permissions: AgentPermissions,
372    /// Placed immediately before the prompt (`grok -p <prompt>`).
373    pub(crate) prompt_args: Vec<String>,
374    /// Appended when a call selects a model; `{model}` is substituted.
375    pub(crate) model_args: Vec<String>,
376    /// Appended when a call selects an effort; `{effort}` is substituted.
377    pub(crate) effort_args: Vec<String>,
378    /// How SCV talks to the agent: its ACP server or one process per turn.
379    pub(crate) transport: AgentTransport,
380    /// When to choose this agent, in the user's words; added to its tool
381    /// description so the model can pick between agents.
382    pub(crate) use_for: Option<String>,
383    /// Model to pass when the work matches `use_for`. Without `use_for`, pass
384    /// it whenever this agent is called, unless the user asks for another.
385    pub(crate) model: Option<String>,
386    /// Effort to pass the same way as `model`.
387    pub(crate) effort: Option<String>,
388}
389
390/// How SCV talks to a delegated agent that has an ACP server.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
392#[serde(rename_all = "lowercase")]
393pub(crate) enum AgentTransport {
394    /// The agent's ACP server when it is installed, else one process per turn.
395    #[default]
396    Auto,
397    /// Only its ACP server; the agent is not offered while it is missing.
398    Acp,
399    /// One CLI process per turn, continued through the CLI's own resume.
400    Resume,
401}
402
403/// How much a delegated CLI may do without its own prompts.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
405#[serde(rename_all = "lowercase")]
406pub(crate) enum AgentPermissions {
407    /// Add nothing: the CLI's own configuration decides.
408    #[default]
409    Default,
410    /// Add the CLI's full-autonomy switches: no approval prompts, no sandbox,
411    /// and web search where the CLI gates it. An explicit user opt-in.
412    Full,
413}
414
415/// `[agents.<name>]` for every adapter in [`scv_tools::adapters::ADAPTERS`].
416#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(transparent)]
418pub(crate) struct AgentsConfig(pub(crate) BTreeMap<String, AdapterConfig>);
419
420impl Default for AgentsConfig {
421    fn default() -> Self {
422        let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
423        Self(
424            scv_tools::adapters::ADAPTERS
425                .iter()
426                .map(|adapter| {
427                    (
428                        adapter.name.to_owned(),
429                        AdapterConfig {
430                            command: adapter.command.into(),
431                            args: strings(adapter.args),
432                            permissions: AgentPermissions::Default,
433                            prompt_args: strings(adapter.prompt_args),
434                            model_args: strings(adapter.model_args),
435                            effort_args: strings(adapter.effort_args),
436                            transport: AgentTransport::Auto,
437                            use_for: None,
438                            model: None,
439                            effort: None,
440                        },
441                    )
442                })
443                .collect(),
444        )
445    }
446}
447
448/// What the command line, or a client's `session.start`, changes on top of
449/// the configuration files.
450#[derive(Debug, Clone, Default)]
451pub struct ConfigOverrides {
452    pub provider: Option<String>,
453    pub model: Option<String>,
454    pub base_url: Option<String>,
455    pub approval_policy: Option<ApprovalPolicy>,
456    pub no_tools: bool,
457    /// An explicit configuration layer (`--config`, or `SCV_CONFIG` as the
458    /// process received it), applied after the user and project files.
459    pub config_file: Option<PathBuf>,
460}