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