1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, num::NonZeroUsize, path::PathBuf};
4
5use super::helpers::is_false;
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, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
101pub struct TuiSettings {
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 #[schemars(range(min = 1, max = 50))]
104 pub(crate) subagent_card_rows: Option<usize>,
105 #[serde(default, flatten)]
106 pub(crate) extra: BTreeMap<String, serde_json::Value>,
107}
108
109impl TuiSettings {
110 pub(crate) fn is_default(&self) -> bool {
111 self == &Self::default()
112 }
113
114 pub(crate) fn subagent_card_rows(&self) -> usize {
115 self.subagent_card_rows
116 .unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
117 }
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121pub struct InstructionsSettings {
122 #[serde(default, skip_serializing_if = "is_false")]
123 pub subdir_discovery: bool,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub additional_markdown_paths: Vec<PathBuf>,
126 #[serde(default, flatten)]
127 pub(crate) extra: BTreeMap<String, serde_json::Value>,
128}
129
130impl InstructionsSettings {
131 pub(crate) fn is_default(&self) -> bool {
132 self == &Self::default()
133 }
134}
135
136#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
137pub struct SkillsSettings {
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub additional_paths: Vec<PathBuf>,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub disabled: Vec<String>,
142 #[serde(default, flatten)]
143 pub(crate) extra: BTreeMap<String, serde_json::Value>,
144}
145
146impl SkillsSettings {
147 pub(crate) fn is_default(&self) -> bool {
148 self == &Self::default()
149 }
150}
151
152pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub(crate) enum AutoCompactionLimit {
156 Limited(NonZeroUsize),
157 NoCountCap,
158}
159
160impl AutoCompactionLimit {
161 pub(crate) fn allows(self, completed_compactions: usize) -> bool {
162 match self {
163 Self::Limited(limit) => completed_compactions < limit.get(),
164 Self::NoCountCap => true,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
170#[serde(default)]
171pub struct AutoCompactionSettings {
172 pub enabled: bool,
173 pub threshold_percent: Option<u8>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub threshold_tokens: Option<u64>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 #[schemars(
178 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.",
179 extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
180 )]
181 pub max_compactions_per_run: Option<u8>,
182}
183
184impl Default for AutoCompactionSettings {
185 fn default() -> Self {
186 Self {
187 enabled: true,
188 threshold_percent: Some(80),
189 threshold_tokens: None,
190 max_compactions_per_run: None,
191 }
192 }
193}
194
195impl AutoCompactionSettings {
196 pub(crate) fn is_enabled(&self) -> bool {
197 self.enabled
198 }
199
200 pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
201 let configured = self
202 .max_compactions_per_run
203 .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
204 NonZeroUsize::new(usize::from(configured)).map_or(
205 AutoCompactionLimit::NoCountCap,
206 AutoCompactionLimit::Limited,
207 )
208 }
209
210 fn is_default(&self) -> bool {
211 self == &Self::default()
212 }
213
214 pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
215 if !self.enabled {
216 return false;
217 }
218 self.threshold_percent.is_some_and(|percent| {
219 u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
220 }) || self
221 .threshold_tokens
222 .is_some_and(|threshold| projected_tokens >= threshold)
223 }
224
225 pub(crate) fn threshold_display(&self) -> Option<String> {
226 match (self.threshold_percent, self.threshold_tokens) {
227 (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
228 (Some(percent), None) => Some(format!("{percent}%")),
229 (None, Some(tokens)) => Some(format!("{tokens} tokens")),
230 (None, None) => None,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
236pub struct CompactionSettings {
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub provider: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub model: Option<String>,
241 #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
242 pub auto: AutoCompactionSettings,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub(crate) struct CompactionConfig {
247 pub(crate) provider: String,
248 pub(crate) model: String,
249}
250
251impl CompactionSettings {
252 pub(crate) fn is_default(&self) -> bool {
253 self == &Self::default()
254 }
255
256 pub(crate) fn resolve_config(
257 &self,
258 active_provider: &str,
259 active_model: &str,
260 ) -> Result<CompactionConfig, String> {
261 let provider = self.provider.as_deref().map(str::trim);
262 let model = self.model.as_deref().map(str::trim);
263 match (provider, model) {
264 (None, None) => Ok(CompactionConfig {
265 provider: non_blank_active(active_provider, "provider")?.to_string(),
266 model: non_blank_active(active_model, "model")?.to_string(),
267 }),
268 (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
269 Ok(CompactionConfig {
270 provider: provider.to_string(),
271 model: model.to_string(),
272 })
273 }
274 _ => 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()),
275 }
276 }
277}
278
279fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
280 let trimmed = value.trim();
281 if trimmed.is_empty() {
282 Err(format!(
283 "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
284 ))
285 } else {
286 Ok(trimmed)
287 }
288}
289
290#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
291pub struct SessionTitleSettings {
292 #[serde(default)]
293 pub enabled: bool,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub provider: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub model: Option<String>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub(crate) struct SessionTitleConfig {
302 pub(crate) provider: String,
303 pub(crate) model: String,
304}
305
306impl SessionTitleSettings {
307 pub(crate) fn is_default(&self) -> bool {
308 self == &Self::default()
309 }
310
311 pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
312 if !self.enabled {
313 return Ok(None);
314 }
315 let provider = self
316 .provider
317 .as_deref()
318 .map(str::trim)
319 .filter(|value| !value.is_empty())
320 .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())?;
321 let model = self
322 .model
323 .as_deref()
324 .map(str::trim)
325 .filter(|value| !value.is_empty())
326 .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())?;
327 Ok(Some(SessionTitleConfig {
328 provider: provider.to_string(),
329 model: model.to_string(),
330 }))
331 }
332}
333
334#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
336pub struct SummarizerSettings {
337 #[serde(default)]
339 pub auto_start: bool,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub provider: Option<String>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub model: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub reasoning: Option<crate::thinking::ThinkingLevel>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub prompt: Option<String>,
349}
350
351impl SummarizerSettings {
352 pub(crate) fn is_default(&self) -> bool {
353 self == &Self::default()
354 }
355
356 pub(crate) fn validate(&self) -> anyhow::Result<()> {
357 for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
358 if let Some(value) = value {
359 super::tools::validate_model_identifier(
360 &format!("agent.summarizer.{field}"),
361 value,
362 )?;
363 }
364 }
365 if self
366 .prompt
367 .as_deref()
368 .is_some_and(|prompt| prompt.trim().is_empty())
369 {
370 anyhow::bail!(
371 "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
372 );
373 }
374 Ok(())
375 }
376}