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