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