Skip to main content

thndrs_lib/server/
config_options.rs

1//! ACP session config option primitives.
2
3use crate::cli::{ReasoningEffort, ReasoningSummary, WebSearchMode};
4use agent_client_protocol::schema::v1::{SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption};
5
6/// ACP config option id for model selection.
7pub const MODEL_CONFIG_OPTION_ID: &str = "model";
8
9/// ACP config option id for web-search mode selection.
10pub const WEBSEARCH_CONFIG_OPTION_ID: &str = "websearch";
11/// ACP config option id for reasoning effort.
12pub const REASONING_EFFORT_CONFIG_OPTION_ID: &str = "reasoning_effort";
13/// ACP config option id for reasoning-summary display.
14pub const REASONING_SUMMARY_CONFIG_OPTION_ID: &str = "reasoning_summary";
15
16/// Stable ACP option specification shape.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct ConfigOptionSpec {
19    /// Wire identifier.
20    pub id: &'static str,
21    /// Human-readable option label.
22    pub label: &'static str,
23    /// Supported value domain.
24    pub mode: &'static str,
25}
26
27/// Validated config option value.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum ConfigOptionValue {
30    /// ACP `model` value.
31    Model(String),
32    /// ACP `websearch` value.
33    WebSearch(WebSearchMode),
34    /// ACP `reasoning_effort` value.
35    ReasoningEffort(ReasoningEffort),
36    /// ACP `reasoning_summary` value.
37    ReasoningSummary(ReasoningSummary),
38}
39
40impl ConfigOptionValue {
41    /// Return the persisted string representation for session metadata.
42    pub fn as_persisted_value(&self) -> String {
43        match self {
44            Self::Model(model) => model.clone(),
45            Self::WebSearch(mode) => mode.label().to_string(),
46            Self::ReasoningEffort(effort) => effort.label().to_string(),
47            Self::ReasoningSummary(summary) => summary.label().to_string(),
48        }
49    }
50}
51
52/// Errors produced by config-option validation.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum ConfigOptionError {
55    /// Unknown option id.
56    UnknownOption { id: String },
57    /// Value failed option-specific validation.
58    InvalidValue { id: String, value: String, reason: String },
59}
60
61impl core::fmt::Display for ConfigOptionError {
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        match self {
64            Self::UnknownOption { id } => write!(f, "unknown config option `{id}`"),
65            Self::InvalidValue { id, value, reason } => {
66                write!(f, "invalid value `{value}` for config option `{id}`: {reason}")
67            }
68        }
69    }
70}
71
72impl std::error::Error for ConfigOptionError {}
73
74/// Return the initial ACP config option IDs supported by the server.
75///
76/// Kept in stable order for protocol responses and tests.
77pub fn initial_config_option_ids() -> &'static [&'static str; 4] {
78    &[
79        MODEL_CONFIG_OPTION_ID,
80        WEBSEARCH_CONFIG_OPTION_ID,
81        REASONING_EFFORT_CONFIG_OPTION_ID,
82        REASONING_SUMMARY_CONFIG_OPTION_ID,
83    ]
84}
85
86/// Return the initial server config option specs.
87pub fn initial_config_options() -> &'static [ConfigOptionSpec] {
88    const OPTIONS: [ConfigOptionSpec; 4] = [
89        ConfigOptionSpec { id: MODEL_CONFIG_OPTION_ID, label: "Model", mode: "string" },
90        ConfigOptionSpec { id: WEBSEARCH_CONFIG_OPTION_ID, label: "Web Search", mode: "enum" },
91        ConfigOptionSpec { id: REASONING_EFFORT_CONFIG_OPTION_ID, label: "Reasoning Effort", mode: "enum" },
92        ConfigOptionSpec { id: REASONING_SUMMARY_CONFIG_OPTION_ID, label: "Reasoning Summary", mode: "enum" },
93    ];
94    &OPTIONS
95}
96
97/// Return ACP session config options for the current session state.
98pub fn acp_config_options(
99    model: &str, websearch: WebSearchMode, effort: ReasoningEffort, summary: ReasoningSummary,
100) -> Vec<SessionConfigOption> {
101    vec![
102        model_config_option(model),
103        websearch_config_option(websearch),
104        reasoning_effort_config_option(model, effort),
105        reasoning_summary_config_option(summary),
106    ]
107}
108
109/// Validate one config option id/value pair.
110pub fn validate_config_option(option_id: &str, value: &str) -> Result<ConfigOptionValue, ConfigOptionError> {
111    match option_id {
112        MODEL_CONFIG_OPTION_ID => validate_model(value),
113        WEBSEARCH_CONFIG_OPTION_ID => validate_websearch(value),
114        REASONING_EFFORT_CONFIG_OPTION_ID => validate_reasoning_effort(value),
115        REASONING_SUMMARY_CONFIG_OPTION_ID => validate_reasoning_summary(value),
116        _ => Err(ConfigOptionError::UnknownOption { id: option_id.to_string() }),
117    }
118}
119
120fn validate_model(value: &str) -> Result<ConfigOptionValue, ConfigOptionError> {
121    let trimmed = value.trim();
122    if trimmed.is_empty() {
123        return Err(ConfigOptionError::InvalidValue {
124            id: MODEL_CONFIG_OPTION_ID.to_string(),
125            value: value.to_string(),
126            reason: "model may not be empty".to_string(),
127        });
128    }
129    Ok(ConfigOptionValue::Model(trimmed.to_string()))
130}
131
132fn validate_websearch(value: &str) -> Result<ConfigOptionValue, ConfigOptionError> {
133    let mode = match value.to_lowercase().as_str() {
134        "duckduckgo" => WebSearchMode::DuckDuckGo,
135        "searxng" => WebSearchMode::Searxng,
136        "none" => WebSearchMode::None,
137        other => {
138            return Err(ConfigOptionError::InvalidValue {
139                id: WEBSEARCH_CONFIG_OPTION_ID.to_string(),
140                value: other.to_string(),
141                reason: "must be one of duckduckgo/searxng/none".to_string(),
142            });
143        }
144    };
145    Ok(ConfigOptionValue::WebSearch(mode))
146}
147
148fn validate_reasoning_effort(value: &str) -> Result<ConfigOptionValue, ConfigOptionError> {
149    ReasoningEffort::parse(value)
150        .map(ConfigOptionValue::ReasoningEffort)
151        .ok_or_else(|| ConfigOptionError::InvalidValue {
152            id: REASONING_EFFORT_CONFIG_OPTION_ID.to_string(),
153            value: value.to_string(),
154            reason: "must be one of auto/on/none/minimal/low/medium/high/xhigh/max".to_string(),
155        })
156}
157
158fn validate_reasoning_summary(value: &str) -> Result<ConfigOptionValue, ConfigOptionError> {
159    ReasoningSummary::parse(value)
160        .map(ConfigOptionValue::ReasoningSummary)
161        .ok_or_else(|| ConfigOptionError::InvalidValue {
162            id: REASONING_SUMMARY_CONFIG_OPTION_ID.to_string(),
163            value: value.to_string(),
164            reason: "must be one of off/auto".to_string(),
165        })
166}
167
168fn model_config_option(model: &str) -> SessionConfigOption {
169    SessionConfigOption::select(
170        MODEL_CONFIG_OPTION_ID,
171        "Model",
172        model.to_string(),
173        vec![SessionConfigSelectOption::new(model.to_string(), model.to_string())],
174    )
175    .description("Provider model for future prompt turns")
176    .category(SessionConfigOptionCategory::Model)
177}
178
179fn websearch_config_option(websearch: WebSearchMode) -> SessionConfigOption {
180    let current = websearch.label();
181    SessionConfigOption::select(
182        WEBSEARCH_CONFIG_OPTION_ID,
183        "Web Search",
184        current,
185        vec![
186            SessionConfigSelectOption::new("duckduckgo", "DuckDuckGo"),
187            SessionConfigSelectOption::new("searxng", "SearXNG"),
188            SessionConfigSelectOption::new("none", "None"),
189        ],
190    )
191    .description("Web search mode for future prompt turns")
192    .category(SessionConfigOptionCategory::ModelConfig)
193}
194
195fn reasoning_effort_config_option(model: &str, effort: ReasoningEffort) -> SessionConfigOption {
196    let options: Vec<SessionConfigSelectOption> = crate::providers::reasoning_options(model)
197        .into_iter()
198        .map(|option| SessionConfigSelectOption::new(option.label(), option.display_label()))
199        .collect();
200    SessionConfigOption::select(
201        REASONING_EFFORT_CONFIG_OPTION_ID,
202        "Reasoning Effort",
203        effort.label(),
204        options,
205    )
206    .description("Reasoning control supported by the selected model for future prompt turns")
207    .category(SessionConfigOptionCategory::ModelConfig)
208}
209
210fn reasoning_summary_config_option(summary: ReasoningSummary) -> SessionConfigOption {
211    SessionConfigOption::select(
212        REASONING_SUMMARY_CONFIG_OPTION_ID,
213        "Reasoning Summary",
214        summary.label(),
215        vec![
216            SessionConfigSelectOption::new("off", "Off"),
217            SessionConfigSelectOption::new("auto", "Auto"),
218        ],
219    )
220    .description("Whether ChatGPT Codex reasoning summaries are shown for future prompt turns")
221    .category(SessionConfigOptionCategory::ModelConfig)
222}