use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, num::NonZeroUsize, path::PathBuf};
use super::helpers::is_false;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SubagentsSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_validation_max_retries: Option<u64>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl SubagentsSettings {
pub(crate) fn schema_validation_max_retries(&self) -> u64 {
self.schema_validation_max_retries
.unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ModelsSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl ModelsSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
}
pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IntegrationsSettings {
#[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
pub herdr: HerdrSettings,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl IntegrationsSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct HerdrSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl HerdrSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
}
pub const DEFAULT_TUI_SUBAGENT_CARD_ROWS: usize = 16;
pub const MIN_TUI_SUBAGENT_CARD_ROWS: usize = 1;
pub const MAX_TUI_SUBAGENT_CARD_ROWS: usize = 50;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct TuiSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 50))]
pub(crate) subagent_card_rows: Option<usize>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl TuiSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn subagent_card_rows(&self) -> usize {
self.subagent_card_rows
.unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct InstructionsSettings {
#[serde(default, skip_serializing_if = "is_false")]
pub subdir_discovery: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_markdown_paths: Vec<PathBuf>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl InstructionsSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SkillsSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_paths: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl SkillsSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
}
pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoCompactionLimit {
Limited(NonZeroUsize),
NoCountCap,
}
impl AutoCompactionLimit {
pub(crate) fn allows(self, completed_compactions: usize) -> bool {
match self {
Self::Limited(limit) => completed_compactions < limit.get(),
Self::NoCountCap => true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct AutoCompactionSettings {
#[serde(default, skip_serializing_if = "is_false")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_percent: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(
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.",
extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
)]
pub max_compactions_per_run: Option<u8>,
}
impl AutoCompactionSettings {
pub(crate) fn is_enabled(&self) -> bool {
self.enabled
}
pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
let configured = self
.max_compactions_per_run
.unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
NonZeroUsize::new(usize::from(configured)).map_or(
AutoCompactionLimit::NoCountCap,
AutoCompactionLimit::Limited,
)
}
fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
if !self.enabled {
return false;
}
self.threshold_percent.is_some_and(|percent| {
u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
}) || self
.threshold_tokens
.is_some_and(|threshold| projected_tokens >= threshold)
}
pub(crate) fn threshold_display(&self) -> Option<String> {
match (self.threshold_percent, self.threshold_tokens) {
(Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
(Some(percent), None) => Some(format!("{percent}%")),
(None, Some(tokens)) => Some(format!("{tokens} tokens")),
(None, None) => None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct CompactionSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
pub auto: AutoCompactionSettings,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CompactionConfig {
pub(crate) provider: String,
pub(crate) model: String,
}
impl CompactionSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn resolve_config(
&self,
active_provider: &str,
active_model: &str,
) -> Result<CompactionConfig, String> {
let provider = self.provider.as_deref().map(str::trim);
let model = self.model.as_deref().map(str::trim);
match (provider, model) {
(None, None) => Ok(CompactionConfig {
provider: non_blank_active(active_provider, "provider")?.to_string(),
model: non_blank_active(active_model, "model")?.to_string(),
}),
(Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
Ok(CompactionConfig {
provider: provider.to_string(),
model: model.to_string(),
})
}
_ => 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()),
}
}
}
fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(format!(
"active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
))
} else {
Ok(trimmed)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SessionTitleSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionTitleConfig {
pub(crate) provider: String,
pub(crate) model: String,
}
impl SessionTitleSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
if !self.enabled {
return Ok(None);
}
let provider = self
.provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.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())?;
let model = self
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.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())?;
Ok(Some(SessionTitleConfig {
provider: provider.to_string(),
model: model.to_string(),
}))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SummarizerSettings {
#[serde(default)]
pub auto_start: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<crate::thinking::ThinkingLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
}
impl SummarizerSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn validate(&self) -> anyhow::Result<()> {
for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
if let Some(value) = value {
super::tools::validate_model_identifier(
&format!("agent.summarizer.{field}"),
value,
)?;
}
}
if self
.prompt
.as_deref()
.is_some_and(|prompt| prompt.trim().is_empty())
{
anyhow::bail!(
"agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
);
}
Ok(())
}
}