scv_tools/delegate/
request.rs1use 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 #[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
27fn 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
36pub(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
48pub(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
73pub const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
76
77const MAX_EFFORT_BYTES: usize = 32;
79
80pub fn valid_model_name(value: &str) -> bool {
83 !value.is_empty()
84 && value.len() <= 128
85 && !value.starts_with(['-', '@'])
86 && value
87 .chars()
88 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
89}
90
91pub fn valid_effort(value: &str) -> bool {
96 value.len() <= MAX_EFFORT_BYTES
97 && value
98 .chars()
99 .next()
100 .is_some_and(|first| first.is_ascii_alphanumeric())
101 && value
102 .chars()
103 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
104}