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, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
170pub struct AutoCompactionSettings {
171 #[serde(default, skip_serializing_if = "is_false")]
172 pub enabled: bool,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub threshold_percent: Option<u8>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub threshold_tokens: Option<u64>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 #[schemars(
179 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.",
180 extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
181 )]
182 pub max_compactions_per_run: Option<u8>,
183}
184
185impl AutoCompactionSettings {
186 pub(crate) fn is_enabled(&self) -> bool {
187 self.enabled
188 }
189
190 pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
191 let configured = self
192 .max_compactions_per_run
193 .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
194 NonZeroUsize::new(usize::from(configured)).map_or(
195 AutoCompactionLimit::NoCountCap,
196 AutoCompactionLimit::Limited,
197 )
198 }
199
200 fn is_default(&self) -> bool {
201 self == &Self::default()
202 }
203
204 pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
205 if !self.enabled {
206 return false;
207 }
208 self.threshold_percent.is_some_and(|percent| {
209 u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
210 }) || self
211 .threshold_tokens
212 .is_some_and(|threshold| projected_tokens >= threshold)
213 }
214
215 pub(crate) fn threshold_display(&self) -> Option<String> {
216 match (self.threshold_percent, self.threshold_tokens) {
217 (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
218 (Some(percent), None) => Some(format!("{percent}%")),
219 (None, Some(tokens)) => Some(format!("{tokens} tokens")),
220 (None, None) => None,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
226pub struct CompactionSettings {
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub provider: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub model: Option<String>,
231 #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
232 pub auto: AutoCompactionSettings,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub(crate) struct CompactionConfig {
237 pub(crate) provider: String,
238 pub(crate) model: String,
239}
240
241impl CompactionSettings {
242 pub(crate) fn is_default(&self) -> bool {
243 self == &Self::default()
244 }
245
246 pub(crate) fn resolve_config(
247 &self,
248 active_provider: &str,
249 active_model: &str,
250 ) -> Result<CompactionConfig, String> {
251 let provider = self.provider.as_deref().map(str::trim);
252 let model = self.model.as_deref().map(str::trim);
253 match (provider, model) {
254 (None, None) => Ok(CompactionConfig {
255 provider: non_blank_active(active_provider, "provider")?.to_string(),
256 model: non_blank_active(active_model, "model")?.to_string(),
257 }),
258 (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
259 Ok(CompactionConfig {
260 provider: provider.to_string(),
261 model: model.to_string(),
262 })
263 }
264 _ => 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()),
265 }
266 }
267}
268
269fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
270 let trimmed = value.trim();
271 if trimmed.is_empty() {
272 Err(format!(
273 "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
274 ))
275 } else {
276 Ok(trimmed)
277 }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
281pub struct SessionTitleSettings {
282 #[serde(default)]
283 pub enabled: bool,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub provider: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub model: Option<String>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub(crate) struct SessionTitleConfig {
292 pub(crate) provider: String,
293 pub(crate) model: String,
294}
295
296impl SessionTitleSettings {
297 pub(crate) fn is_default(&self) -> bool {
298 self == &Self::default()
299 }
300
301 pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
302 if !self.enabled {
303 return Ok(None);
304 }
305 let provider = self
306 .provider
307 .as_deref()
308 .map(str::trim)
309 .filter(|value| !value.is_empty())
310 .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())?;
311 let model = self
312 .model
313 .as_deref()
314 .map(str::trim)
315 .filter(|value| !value.is_empty())
316 .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())?;
317 Ok(Some(SessionTitleConfig {
318 provider: provider.to_string(),
319 model: model.to_string(),
320 }))
321 }
322}
323
324#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
326pub struct SummarizerSettings {
327 #[serde(default)]
329 pub auto_start: bool,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub provider: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub model: Option<String>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub reasoning: Option<crate::thinking::ThinkingLevel>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub prompt: Option<String>,
339}
340
341impl SummarizerSettings {
342 pub(crate) fn is_default(&self) -> bool {
343 self == &Self::default()
344 }
345
346 pub(crate) fn validate(&self) -> anyhow::Result<()> {
347 for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
348 if let Some(value) = value {
349 super::tools::validate_model_identifier(
350 &format!("agent.summarizer.{field}"),
351 value,
352 )?;
353 }
354 }
355 if self
356 .prompt
357 .as_deref()
358 .is_some_and(|prompt| prompt.trim().is_empty())
359 {
360 anyhow::bail!(
361 "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
362 );
363 }
364 Ok(())
365 }
366}