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