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