scv_tools/delegate/
request.rs1use 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 #[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
31fn 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
40fn 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
49pub(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
61pub(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
86pub const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
89
90const MAX_EFFORT_BYTES: usize = 32;
92
93pub 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
104pub 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}