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
8#[derive(Deserialize)]
9#[serde(deny_unknown_fields)]
10pub(crate) struct AgentArgs {
11    /// Which agent runs the call. The `agent` tool resolves it before a
12    /// backend sees the call, so backends ignore it.
13    #[serde(default, deserialize_with = "blank_as_none")]
14    pub(crate) agent: Option<String>,
15    pub(crate) prompt: String,
16    pub(crate) timeout_seconds: Option<u64>,
17    #[serde(default, deserialize_with = "blank_as_none")]
18    pub(crate) session: Option<String>,
19    #[serde(default, deserialize_with = "blank_as_none")]
20    pub(crate) cwd: Option<String>,
21    #[serde(default, deserialize_with = "blank_as_none")]
22    pub(crate) model: Option<String>,
23    #[serde(default, deserialize_with = "blank_as_none")]
24    pub(crate) effort: Option<String>,
25}
26
27/// Models often send an optional string they mean to leave unset as `""`, so
28/// a blank value selects the default rather than failing the call.
29fn blank_as_none<'de, D: serde::Deserializer<'de>>(
30    deserializer: D,
31) -> Result<Option<String>, D::Error> {
32    let value = Option::<String>::deserialize(deserializer)?;
33    Ok(value.filter(|value| !value.trim().is_empty()))
34}
35
36/// Longest `cwd` argument accepted, in bytes.
37pub(crate) const MAX_AGENT_CWD_BYTES: usize = 4096;
38
39pub(crate) fn validate_agent_cwd(cwd: &str) -> Result<(), ToolError> {
40    if cwd.trim().is_empty() || cwd.len() > MAX_AGENT_CWD_BYTES || cwd.contains('\0') {
41        return Err(ToolError::invalid_arguments(format!(
42            "cwd must be a non-empty directory path of at most {MAX_AGENT_CWD_BYTES} bytes"
43        )));
44    }
45    Ok(())
46}
47
48/// Resolve a requested agent directory against the workspace. Resolution
49/// follows symlinks, so a link pointing outside the workspace is refused
50/// rather than trusted by name.
51pub(crate) fn resolve_agent_cwd(workspace: &Path, cwd: Option<&str>) -> Result<PathBuf, ToolError> {
52    let root = std::fs::canonicalize(workspace)
53        .map_err(|error| ToolError::failed(format!("resolve workspace: {error}")))?;
54    let Some(cwd) = cwd else {
55        return Ok(root);
56    };
57    validate_agent_cwd(cwd)?;
58    let resolved = std::fs::canonicalize(root.join(cwd))
59        .map_err(|error| ToolError::invalid_arguments(format!("cwd {cwd:?}: {error}")))?;
60    if !resolved.starts_with(&root) {
61        return Err(ToolError::invalid_arguments(format!(
62            "cwd {cwd:?} is outside the workspace"
63        )));
64    }
65    if !resolved.is_dir() {
66        return Err(ToolError::invalid_arguments(format!(
67            "cwd {cwd:?} is not a directory"
68        )));
69    }
70    Ok(resolved)
71}
72
73/// Effort values the built-in adapters accept.
74pub const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
75
76/// Model names are passed as one argument, so only reject values that could
77/// read as a flag, name an `@file` argument, or carry unexpected characters.
78pub fn valid_model_name(value: &str) -> bool {
79    !value.is_empty()
80        && value.len() <= 128
81        && !value.starts_with(['-', '@'])
82        && value
83            .chars()
84            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
85}
86
87/// Whether `value` is one of [`AGENT_EFFORTS`].
88pub fn valid_effort(value: &str) -> bool {
89    AGENT_EFFORTS.contains(&value)
90}