Skip to main content

scv_tools/delegate/
options.rs

1//! The `model` and `effort` values an agent's ACP server offers, as SCV last
2//! saw them.
3//!
4//! These values belong to the agent and change when it updates, so SCV reads
5//! them from the agent instead of shipping a list. Every ACP session SCV
6//! opens reports its `configOptions`, and SCV saves them per agent in
7//! `state/agent-options/<name>.json`. A session built within [`MAX_AGE`] of
8//! that save, with the same ACP server installed, lists the values in the
9//! `agent` tool's description and refuses a model the list lacks before a
10//! run starts. `scv agents check` refreshes the file on demand.
11//!
12//! A select option lists its values flat or in groups. SCV shows each value
13//! as the agent spells it, except one that is a JSON array of strings, such
14//! as DeepSeek Harness's `["provider","model"]` pairs, which it shows and
15//! takes as those strings joined by `/` ([`select_values`]).
16
17use std::{
18    path::{Path, PathBuf},
19    time::{Duration, SystemTime, UNIX_EPOCH},
20};
21
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use crate::delegate::{
26    records::write_private_json,
27    request::{valid_effort, valid_model_name},
28};
29
30/// How long saved values are trusted without a new ACP session to confirm
31/// them. Agents also change their lists without a new install, such as when
32/// an account gains a model.
33pub(crate) const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
34
35/// Config option IDs that agents use for reasoning effort.
36pub(crate) const EFFORT_OPTIONS: [&str; 3] = ["effort", "reasoning_effort", "thought_level"];
37
38/// The ACP value that selects the agent's own default. SCV's description
39/// leaves it out, since omitting the argument does the same.
40const DEFAULT_VALUE: &str = "default";
41
42const FORMAT_VERSION: u32 = 1;
43const MAX_FILE_BYTES: u64 = 64 * 1024;
44const MAX_VALUES: usize = 64;
45
46/// The values of one option and the one currently selected.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Choice {
49    pub values: Vec<String>,
50    /// The session's selection when it opened: the agent's default.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub current: Option<String>,
53}
54
55impl Choice {
56    /// The values to show the model, without `default`.
57    pub fn shown(&self) -> Vec<&str> {
58        self.values
59            .iter()
60            .map(String::as_str)
61            .filter(|value| *value != DEFAULT_VALUE)
62            .collect()
63    }
64
65    /// The agent's default, when it names a real value.
66    pub fn named_default(&self) -> Option<&str> {
67        self.current
68            .as_deref()
69            .filter(|value| *value != DEFAULT_VALUE)
70    }
71
72    pub fn offers(&self, value: &str) -> bool {
73        self.values.iter().any(|offered| offered == value)
74    }
75}
76
77/// What an agent's ACP session offers for `model` and `effort`. The effort
78/// values are those of the agent's default model.
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct AgentOptions {
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub model: Option<Choice>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub effort: Option<Choice>,
85}
86
87impl AgentOptions {
88    /// The options in an ACP `session/new` result, or `None` when it lists
89    /// neither a model nor an effort choice. Values that could not be passed
90    /// as an argument are left out.
91    pub(crate) fn from_acp(result: &Value) -> Option<Self> {
92        let options = result.get("configOptions")?.as_array()?;
93        let find = |ids: &[&str], valid: fn(&str) -> bool| {
94            options
95                .iter()
96                .find(|option| {
97                    option
98                        .get("id")
99                        .and_then(Value::as_str)
100                        .is_some_and(|id| ids.contains(&id))
101                })
102                .and_then(|option| choice(option, valid))
103        };
104        let found = Self {
105            model: find(&["model"], valid_model_name),
106            effort: find(&EFFORT_OPTIONS, valid_effort),
107        };
108        (found.model.is_some() || found.effort.is_some()).then_some(found)
109    }
110
111    fn sanitized(self) -> Self {
112        let clean = |choice: Option<Choice>, valid: fn(&str) -> bool| {
113            choice.and_then(|choice| {
114                let values: Vec<String> = choice
115                    .values
116                    .into_iter()
117                    .filter(|value| valid(value))
118                    .take(MAX_VALUES)
119                    .collect();
120                (!values.is_empty()).then(|| Choice {
121                    current: choice.current.filter(|value| valid(value)),
122                    values,
123                })
124            })
125        };
126        Self {
127            model: clean(self.model, valid_model_name),
128            effort: clean(self.effort, valid_effort),
129        }
130    }
131}
132
133fn choice(option: &Value, valid: fn(&str) -> bool) -> Option<Choice> {
134    let values: Vec<String> = select_values(option)
135        .into_iter()
136        .map(|value| value.shown)
137        .filter(|shown| valid(shown))
138        .take(MAX_VALUES)
139        .collect();
140    if values.is_empty() {
141        return None;
142    }
143    let current = option
144        .get("currentValue")
145        .and_then(Value::as_str)
146        .map(shown)
147        .filter(|value| valid(value));
148    Some(Choice { values, current })
149}
150
151/// One value of an ACP select config option.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub(crate) struct SelectValue {
154    /// How SCV shows it, and takes it as `model` or `effort`.
155    pub(crate) shown: String,
156    /// The agent's own value, which `session/set_config_option` sends.
157    pub(crate) value: String,
158}
159
160/// The values of an ACP select config option, listed flat or in groups
161/// (each group's own `options` hold its values).
162pub(crate) fn select_values(option: &Value) -> Vec<SelectValue> {
163    let entries = option.get("options").and_then(Value::as_array);
164    let mut values = Vec::new();
165    for entry in entries.into_iter().flatten() {
166        match entry.get("options").and_then(Value::as_array) {
167            Some(group) => values.extend(group.iter().filter_map(select_value)),
168            None => values.extend(select_value(entry)),
169        }
170    }
171    values
172}
173
174fn select_value(entry: &Value) -> Option<SelectValue> {
175    let value = entry.get("value")?.as_str()?;
176    Some(SelectValue {
177        shown: shown(value),
178        value: value.to_owned(),
179    })
180}
181
182/// How SCV shows an agent's select value: a JSON array of strings, such as
183/// DeepSeek Harness's `["provider","model"]`, as the strings joined by `/`,
184/// since a model name cannot hold quotes or commas; any other value as it
185/// is.
186fn shown(value: &str) -> String {
187    match serde_json::from_str::<Vec<String>>(value) {
188        Ok(parts) if !parts.is_empty() && parts.iter().all(|part| !part.is_empty()) => {
189            parts.join("/")
190        }
191        _ => value.to_owned(),
192    }
193}
194
195/// Which ACP server the values came from. A different install, or the same
196/// file changed, means they may be out of date.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198struct Installed {
199    path: PathBuf,
200    size: u64,
201    modified_unix_ms: u64,
202}
203
204impl Installed {
205    fn of(executable: &Path) -> Option<Self> {
206        let path = std::fs::canonicalize(executable).ok()?;
207        let metadata = std::fs::metadata(&path).ok()?;
208        let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
209        Some(Self {
210            path,
211            size: metadata.len(),
212            modified_unix_ms: u64::try_from(modified.as_millis()).unwrap_or(u64::MAX),
213        })
214    }
215}
216
217/// The file SCV keeps for one agent.
218#[derive(Debug, Serialize, Deserialize)]
219struct Saved {
220    version: u32,
221    agent: String,
222    executable: Installed,
223    seen_unix: u64,
224    #[serde(flatten)]
225    options: AgentOptions,
226}
227
228/// Options an agent listed, and when.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub(crate) struct Listed {
231    pub(crate) options: AgentOptions,
232    pub(crate) seen: SystemTime,
233}
234
235impl Listed {
236    /// Whether they are recent enough to go by at `now`.
237    pub(crate) fn fresh(&self, now: SystemTime) -> bool {
238        now.duration_since(self.seen)
239            .map_or(true, |age| age <= MAX_AGE)
240    }
241}
242
243/// Save `options`, just reported by `agent`'s ACP server at `executable`,
244/// as `file`.
245pub(crate) fn save(
246    file: &Path,
247    agent: &str,
248    executable: &Path,
249    options: &AgentOptions,
250) -> std::io::Result<()> {
251    let (Some(dir), Some(name)) = (
252        file.parent(),
253        file.file_name().and_then(|name| name.to_str()),
254    ) else {
255        return Err(std::io::Error::other("agent options file has no directory"));
256    };
257    let executable = Installed::of(executable)
258        .ok_or_else(|| std::io::Error::other("the ACP server's file could not be read"))?;
259    write_private_json(
260        dir,
261        name,
262        &Saved {
263            version: FORMAT_VERSION,
264            agent: agent.to_owned(),
265            executable,
266            seen_unix: unix_seconds(SystemTime::now()),
267            options: options.clone(),
268        },
269    )
270}
271
272/// The options saved for `agent` in `file`, when they came from the ACP
273/// server now installed at `executable` no longer than [`MAX_AGE`] before
274/// `now`.
275pub(crate) fn load(file: &Path, agent: &str, executable: &Path, now: SystemTime) -> Option<Listed> {
276    let saved = read(file)?;
277    let listed = Listed {
278        options: saved.options,
279        seen: UNIX_EPOCH + Duration::from_secs(saved.seen_unix),
280    };
281    (saved.agent == agent
282        && listed.fresh(now)
283        && Installed::of(executable).as_ref() == Some(&saved.executable))
284    .then_some(listed)
285}
286
287/// The options saved in `file`, however old, and when they were seen (Unix
288/// seconds): for reports such as `scv agents check`.
289pub fn read_saved(file: &Path, agent: &str) -> Option<(AgentOptions, u64)> {
290    read(file)
291        .filter(|saved| saved.agent == agent)
292        .map(|saved| (saved.options, saved.seen_unix))
293}
294
295fn read(file: &Path) -> Option<Saved> {
296    let mut bytes = Vec::new();
297    let opened = std::fs::File::open(file).ok()?;
298    std::io::Read::read_to_end(
299        &mut std::io::Read::take(opened, MAX_FILE_BYTES + 1),
300        &mut bytes,
301    )
302    .ok()?;
303    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
304        return None;
305    }
306    let saved: Saved = serde_json::from_slice(&bytes).ok()?;
307    (saved.version == FORMAT_VERSION).then(|| Saved {
308        options: saved.options.sanitized(),
309        ..saved
310    })
311}
312
313fn unix_seconds(time: SystemTime) -> u64 {
314    time.duration_since(UNIX_EPOCH)
315        .map_or(0, |elapsed| elapsed.as_secs())
316}
317
318#[cfg(test)]
319mod tests;