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 SideAgentSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
impl SideAgentSettings {
pub(crate) fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.provider.is_some() == self.model.is_some(),
"agent.side.provider and agent.side.model must both be configured, or both omitted to inherit the primary provider/model"
);
for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
if let Some(value) = value {
super::tools::validate_model_identifier(&format!("agent.side.{field}"), value)?;
}
}
Ok(())
}
}
#[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, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HeaderPanel {
Summary,
ActivityTree,
SessionFiles,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(default)]
pub(crate) struct PanelLayoutSettings {
pub(crate) open: Vec<HeaderPanel>,
pub(crate) pinned: bool,
pub(crate) opened_by_pin: bool,
#[serde(flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl PanelLayoutSettings {
fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Default, Clone, 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, skip_serializing_if = "PanelLayoutSettings::is_default")]
pub(crate) panel_layout: PanelLayoutSettings,
#[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, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(default)]
pub struct AutoCompactionSettings {
pub enabled: bool,
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 Default for AutoCompactionSettings {
fn default() -> Self {
Self {
enabled: true,
threshold_percent: Some(80),
threshold_tokens: None,
max_compactions_per_run: None,
}
}
}
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, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(default)]
pub struct ClaudeSubscriptionCompactionSettings {
pub allow_compaction: bool,
pub reserve_tokens: usize,
pub threshold_percent: Option<u8>,
pub threshold_tokens: Option<u64>,
}
impl Default for ClaudeSubscriptionCompactionSettings {
fn default() -> Self {
Self {
allow_compaction: true,
reserve_tokens: 16_384,
threshold_percent: Some(100),
threshold_tokens: Some(crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS as u64),
}
}
}
impl ClaudeSubscriptionCompactionSettings {
fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[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,
#[serde(
default,
skip_serializing_if = "ClaudeSubscriptionCompactionSettings::is_default"
)]
pub claude_subscription: ClaudeSubscriptionCompactionSettings,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CompactionConfig {
pub(crate) provider: String,
pub(crate) model: String,
}
impl CompactionSettings {
fn claude_subscription_compaction_reserve(&self) -> usize {
let compacts_with_subscription = self.claude_subscription.allow_compaction
&& self
.provider
.as_deref()
.map(str::trim)
.unwrap_or(crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER)
== crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER;
if compacts_with_subscription {
self.claude_subscription.reserve_tokens
} else {
0
}
}
pub(crate) fn claude_subscription_context_limit(&self) -> usize {
crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS
.saturating_sub(self.claude_subscription_compaction_reserve())
}
pub(crate) fn apply_conversation_limit(
&self,
budget: &mut crate::context::ContextBudget,
provider: &str,
) {
if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
budget.max_tokens = budget
.max_tokens
.min(crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS)
.saturating_sub(self.claude_subscription_compaction_reserve());
}
}
pub(crate) fn auto_for_provider(
&self,
provider: &str,
max_tokens: usize,
) -> AutoCompactionSettings {
if provider != crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
return self.auto.clone();
}
let subscription = &self.claude_subscription;
let ceiling = self.claude_subscription_context_limit().min(max_tokens) as u64;
AutoCompactionSettings {
enabled: true,
threshold_percent: subscription.threshold_percent,
threshold_tokens: Some(
subscription
.threshold_tokens
.unwrap_or(ceiling)
.min(ceiling),
),
max_compactions_per_run: Some(0),
}
}
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> {
if !self.claude_subscription.allow_compaction
&& (self.provider.as_deref().is_none_or(|provider| {
provider.trim() == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER
}) || self
.model
.as_deref()
.is_none_or(|model| model.trim().is_empty()))
{
return Err("Claude subscription compaction is disabled; select a different provider's Compaction model in Settings → Agent settings".to_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(())
}
}