1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, num::NonZeroUsize, path::PathBuf};
4
5use super::helpers::{default_true, is_false, is_true};
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
8pub struct SideAgentSettings {
9 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub provider: Option<String>,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub model: Option<String>,
13}
14
15impl SideAgentSettings {
16 pub(crate) fn is_default(&self) -> bool {
17 self == &Self::default()
18 }
19
20 pub(crate) fn validate(&self) -> anyhow::Result<()> {
21 anyhow::ensure!(
22 self.provider.is_some() == self.model.is_some(),
23 "agent.side.provider and agent.side.model must both be configured, or both omitted to inherit the primary provider/model"
24 );
25 for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
26 if let Some(value) = value {
27 super::tools::validate_model_identifier(&format!("agent.side.{field}"), value)?;
28 }
29 }
30 Ok(())
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
35pub struct SubagentsSettings {
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub disabled: Vec<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub schema_validation_max_retries: Option<u64>,
40 #[serde(default, flatten)]
41 pub(crate) extra: BTreeMap<String, serde_json::Value>,
42}
43
44impl SubagentsSettings {
45 pub(crate) fn schema_validation_max_retries(&self) -> u64 {
46 self.schema_validation_max_retries
47 .unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
48 }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
52pub struct ModelsSettings {
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub disabled: Vec<String>,
55 #[serde(default, flatten)]
56 pub(crate) extra: BTreeMap<String, serde_json::Value>,
57}
58
59impl ModelsSettings {
60 pub(crate) fn is_default(&self) -> bool {
61 self == &Self::default()
62 }
63}
64
65pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
66pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
69pub struct IntegrationsSettings {
70 #[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
71 pub herdr: HerdrSettings,
72 #[serde(default, flatten)]
73 pub(crate) extra: BTreeMap<String, serde_json::Value>,
74}
75
76impl IntegrationsSettings {
77 pub(crate) fn is_default(&self) -> bool {
78 self == &Self::default()
79 }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
83pub struct HerdrSettings {
84 #[serde(default)]
85 pub enabled: bool,
86 #[serde(default, flatten)]
87 pub(crate) extra: BTreeMap<String, serde_json::Value>,
88}
89
90impl HerdrSettings {
91 pub(crate) fn is_default(&self) -> bool {
92 self == &Self::default()
93 }
94}
95
96pub const DEFAULT_TUI_SUBAGENT_CARD_ROWS: usize = 16;
97pub const MIN_TUI_SUBAGENT_CARD_ROWS: usize = 1;
98pub const MAX_TUI_SUBAGENT_CARD_ROWS: usize = 50;
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
101pub struct TuiSettings {
102 #[serde(default = "default_true", skip_serializing_if = "is_true")]
103 #[schemars(!skip_serializing_if)]
104 pub(crate) show_panel_on_startup: bool,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 #[schemars(range(min = 1, max = 50))]
107 pub(crate) subagent_card_rows: Option<usize>,
108 #[serde(default, flatten)]
109 pub(crate) extra: BTreeMap<String, serde_json::Value>,
110}
111
112impl Default for TuiSettings {
113 fn default() -> Self {
114 Self {
115 show_panel_on_startup: true,
116 subagent_card_rows: None,
117 extra: BTreeMap::new(),
118 }
119 }
120}
121
122impl TuiSettings {
123 pub(crate) fn is_default(&self) -> bool {
124 self == &Self::default()
125 }
126
127 pub(crate) fn subagent_card_rows(&self) -> usize {
128 self.subagent_card_rows
129 .unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
130 }
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
134pub struct InstructionsSettings {
135 #[serde(default, skip_serializing_if = "is_false")]
136 pub subdir_discovery: bool,
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub additional_markdown_paths: Vec<PathBuf>,
139 #[serde(default, flatten)]
140 pub(crate) extra: BTreeMap<String, serde_json::Value>,
141}
142
143impl InstructionsSettings {
144 pub(crate) fn is_default(&self) -> bool {
145 self == &Self::default()
146 }
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
150pub struct SkillsSettings {
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub additional_paths: Vec<PathBuf>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub disabled: Vec<String>,
155 #[serde(default, flatten)]
156 pub(crate) extra: BTreeMap<String, serde_json::Value>,
157}
158
159impl SkillsSettings {
160 pub(crate) fn is_default(&self) -> bool {
161 self == &Self::default()
162 }
163}
164
165pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub(crate) enum AutoCompactionLimit {
169 Limited(NonZeroUsize),
170 NoCountCap,
171}
172
173impl AutoCompactionLimit {
174 pub(crate) fn allows(self, completed_compactions: usize) -> bool {
175 match self {
176 Self::Limited(limit) => completed_compactions < limit.get(),
177 Self::NoCountCap => true,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
183#[serde(default)]
184pub struct AutoCompactionSettings {
185 pub enabled: bool,
186 pub threshold_percent: Option<u8>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub threshold_tokens: Option<u64>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 #[schemars(
191 description = "Maximum automatic compactions per run. When omitted, the effective runtime default is 4. Set to 0 to remove only this per-run compaction-count cap.",
192 extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
193 )]
194 pub max_compactions_per_run: Option<u8>,
195}
196
197impl Default for AutoCompactionSettings {
198 fn default() -> Self {
199 Self {
200 enabled: true,
201 threshold_percent: Some(80),
202 threshold_tokens: None,
203 max_compactions_per_run: None,
204 }
205 }
206}
207
208impl AutoCompactionSettings {
209 pub(crate) fn is_enabled(&self) -> bool {
210 self.enabled
211 }
212
213 pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
214 let configured = self
215 .max_compactions_per_run
216 .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
217 NonZeroUsize::new(usize::from(configured)).map_or(
218 AutoCompactionLimit::NoCountCap,
219 AutoCompactionLimit::Limited,
220 )
221 }
222
223 fn is_default(&self) -> bool {
224 self == &Self::default()
225 }
226
227 pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
228 if !self.enabled {
229 return false;
230 }
231 self.threshold_percent.is_some_and(|percent| {
232 u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
233 }) || self
234 .threshold_tokens
235 .is_some_and(|threshold| projected_tokens >= threshold)
236 }
237
238 pub(crate) fn threshold_display(&self) -> Option<String> {
239 match (self.threshold_percent, self.threshold_tokens) {
240 (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
241 (Some(percent), None) => Some(format!("{percent}%")),
242 (None, Some(tokens)) => Some(format!("{tokens} tokens")),
243 (None, None) => None,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
249pub struct CompactionSettings {
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub provider: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub model: Option<String>,
254 #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
255 pub auto: AutoCompactionSettings,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub(crate) struct CompactionConfig {
260 pub(crate) provider: String,
261 pub(crate) model: String,
262}
263
264impl CompactionSettings {
265 pub(crate) fn is_default(&self) -> bool {
266 self == &Self::default()
267 }
268
269 pub(crate) fn resolve_config(
270 &self,
271 active_provider: &str,
272 active_model: &str,
273 ) -> Result<CompactionConfig, String> {
274 let provider = self.provider.as_deref().map(str::trim);
275 let model = self.model.as_deref().map(str::trim);
276 match (provider, model) {
277 (None, None) => Ok(CompactionConfig {
278 provider: non_blank_active(active_provider, "provider")?.to_string(),
279 model: non_blank_active(active_model, "model")?.to_string(),
280 }),
281 (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
282 Ok(CompactionConfig {
283 provider: provider.to_string(),
284 model: model.to_string(),
285 })
286 }
287 _ => Err("compaction.provider and compaction.model must either both be configured and non-blank, or both be omitted to inherit the active provider/model".to_string()),
288 }
289 }
290}
291
292fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
293 let trimmed = value.trim();
294 if trimmed.is_empty() {
295 Err(format!(
296 "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
297 ))
298 } else {
299 Ok(trimmed)
300 }
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
304pub struct SessionTitleSettings {
305 #[serde(default)]
306 pub enabled: bool,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub provider: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub model: Option<String>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub(crate) struct SessionTitleConfig {
315 pub(crate) provider: String,
316 pub(crate) model: String,
317}
318
319impl SessionTitleSettings {
320 pub(crate) fn is_default(&self) -> bool {
321 self == &Self::default()
322 }
323
324 pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
325 if !self.enabled {
326 return Ok(None);
327 }
328 let provider = self
329 .provider
330 .as_deref()
331 .map(str::trim)
332 .filter(|value| !value.is_empty())
333 .ok_or_else(|| "session_titles.enabled is true but session_titles.provider is missing or blank; set an explicit title provider or disable session_titles".to_string())?;
334 let model = self
335 .model
336 .as_deref()
337 .map(str::trim)
338 .filter(|value| !value.is_empty())
339 .ok_or_else(|| "session_titles.enabled is true but session_titles.model is missing or blank; set an explicit title model or disable session_titles".to_string())?;
340 Ok(Some(SessionTitleConfig {
341 provider: provider.to_string(),
342 model: model.to_string(),
343 }))
344 }
345}
346
347#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
349pub struct SummarizerSettings {
350 #[serde(default)]
352 pub auto_start: bool,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub provider: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub model: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub reasoning: Option<crate::thinking::ThinkingLevel>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub prompt: Option<String>,
362}
363
364impl SummarizerSettings {
365 pub(crate) fn is_default(&self) -> bool {
366 self == &Self::default()
367 }
368
369 pub(crate) fn validate(&self) -> anyhow::Result<()> {
370 for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
371 if let Some(value) = value {
372 super::tools::validate_model_identifier(
373 &format!("agent.summarizer.{field}"),
374 value,
375 )?;
376 }
377 }
378 if self
379 .prompt
380 .as_deref()
381 .is_some_and(|prompt| prompt.trim().is_empty())
382 {
383 anyhow::bail!(
384 "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
385 );
386 }
387 Ok(())
388 }
389}