Skip to main content

scv_tools/delegate/
request.rs

1//! The arguments an `agent` call takes, and their validation.
2
3use std::path::{Path, PathBuf};
4
5use scv_core::ToolError;
6use serde::Deserialize;
7
8use crate::BusyBehavior;
9
10#[derive(Deserialize)]
11#[serde(deny_unknown_fields)]
12pub(crate) struct AgentArgs {
13    /// Which agent runs the call. The `agent` tool resolves it before a
14    /// backend sees the call, so backends ignore it.
15    #[serde(default, deserialize_with = "blank_as_none")]
16    pub(crate) agent: Option<String>,
17    pub(crate) prompt: String,
18    pub(crate) timeout_seconds: Option<u64>,
19    #[serde(default)]
20    pub(crate) no_timeout: bool,
21    #[serde(default, deserialize_with = "blank_as_none")]
22    pub(crate) session: Option<String>,
23    #[serde(default, deserialize_with = "blank_as_none")]
24    pub(crate) cwd: Option<String>,
25    #[serde(default, deserialize_with = "blank_as_none")]
26    pub(crate) model: Option<String>,
27    #[serde(default, deserialize_with = "blank_as_none")]
28    pub(crate) effort: Option<String>,
29    #[serde(default, deserialize_with = "blank_busy_as_none")]
30    pub(crate) on_busy: Option<BusyBehavior>,
31}
32
33/// Models often send an optional string they mean to leave unset as `""`, so
34/// a blank value selects the default rather than failing the call.
35pub(crate) fn blank_as_none<'de, D: serde::Deserializer<'de>>(
36    deserializer: D,
37) -> Result<Option<String>, D::Error> {
38    let value = Option::<String>::deserialize(deserializer)?;
39    Ok(value.filter(|value| !value.trim().is_empty()))
40}
41
42/// `on_busy`, where blank also selects the default.
43fn blank_busy_as_none<'de, D: serde::Deserializer<'de>>(
44    deserializer: D,
45) -> Result<Option<BusyBehavior>, D::Error> {
46    blank_as_none(deserializer)?
47        .map(|value| BusyBehavior::parse(&value).map_err(serde::de::Error::custom))
48        .transpose()
49}
50
51/// Longest `cwd` argument accepted, in bytes.
52pub(crate) const MAX_AGENT_CWD_BYTES: usize = 4096;
53
54pub(crate) fn validate_agent_cwd(cwd: &str) -> Result<(), ToolError> {
55    if cwd.trim().is_empty() || cwd.len() > MAX_AGENT_CWD_BYTES || cwd.contains('\0') {
56        return Err(ToolError::invalid_arguments(format!(
57            "cwd must be a non-empty directory path of at most {MAX_AGENT_CWD_BYTES} bytes"
58        )));
59    }
60    Ok(())
61}
62
63/// Resolve a requested agent directory against the workspace. Resolution
64/// follows symlinks, so a link pointing outside the workspace is refused
65/// rather than trusted by name.
66pub(crate) fn resolve_agent_cwd(workspace: &Path, cwd: Option<&str>) -> Result<PathBuf, ToolError> {
67    let root = std::fs::canonicalize(workspace)
68        .map_err(|error| ToolError::failed(format!("resolve workspace: {error}")))?;
69    let Some(cwd) = cwd else {
70        return Ok(root);
71    };
72    validate_agent_cwd(cwd)?;
73    let resolved = std::fs::canonicalize(root.join(cwd))
74        .map_err(|error| ToolError::invalid_arguments(format!("cwd {cwd:?}: {error}")))?;
75    if !resolved.starts_with(&root) {
76        return Err(ToolError::invalid_arguments(format!(
77            "cwd {cwd:?} is outside the workspace"
78        )));
79    }
80    if !resolved.is_dir() {
81        return Err(ToolError::invalid_arguments(format!(
82            "cwd {cwd:?} is not a directory"
83        )));
84    }
85    Ok(resolved)
86}
87
88/// Effort values the `agent` tool's schema always lists; an ACP agent's own
89/// values are added to them.
90pub const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
91
92/// Longest effort value accepted, in bytes.
93const MAX_EFFORT_BYTES: usize = 32;
94
95/// Model names are passed as one argument, so only reject values that could
96/// read as a flag, name an `@file` argument, or carry unexpected characters.
97pub fn valid_model_name(value: &str) -> bool {
98    !value.is_empty()
99        && value.len() <= 128
100        && !value.starts_with(['-', '@'])
101        && value
102            .chars()
103            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
104}
105
106/// Whether `value` can be passed as an effort: 1-32 ASCII letters, digits,
107/// `-`, or `_`, starting with a letter or digit so it never reads as a flag.
108/// Which values an agent supports is the agent's to check, since its levels
109/// change with its releases.
110pub fn valid_effort(value: &str) -> bool {
111    value.len() <= MAX_EFFORT_BYTES
112        && value
113            .chars()
114            .next()
115            .is_some_and(|first| first.is_ascii_alphanumeric())
116        && value
117            .chars()
118            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
119}