atomr_agents_coding_cli_core/
request.rs1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::PathBuf;
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::projection::ConceptProjection;
12use crate::vendor::CliVendorKind;
13
14macro_rules! id_newtype {
15 ($name:ident, $prefix:literal) => {
16 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17 #[serde(transparent)]
18 pub struct $name(String);
19
20 impl $name {
21 pub fn new() -> Self {
22 Self(format!("{}-{}", $prefix, Uuid::new_v4()))
23 }
24
25 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28 }
29
30 impl Default for $name {
31 fn default() -> Self {
32 Self::new()
33 }
34 }
35
36 impl fmt::Display for $name {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(&self.0)
39 }
40 }
41
42 impl From<String> for $name {
43 fn from(s: String) -> Self {
44 Self(s)
45 }
46 }
47 impl From<&str> for $name {
48 fn from(s: &str) -> Self {
49 Self(s.to_owned())
50 }
51 }
52 };
53}
54
55id_newtype!(CliRunId, "cli-run");
56id_newtype!(CliSessionId, "cli-sess");
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum RunMode {
63 Headless,
64 Interactive,
65}
66
67impl Default for RunMode {
68 fn default() -> Self {
69 RunMode::Headless
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum IsolationSpec {
77 Local,
79 Docker {
81 image: String,
83 #[serde(default)]
87 mounts: Vec<DockerMount>,
88 #[serde(default)]
90 env: BTreeMap<String, String>,
91 #[serde(default)]
93 network: Option<String>,
94 },
95}
96
97impl Default for IsolationSpec {
98 fn default() -> Self {
99 IsolationSpec::Local
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct DockerMount {
106 pub host_path: PathBuf,
107 pub container_path: PathBuf,
108 #[serde(default)]
109 pub read_only: bool,
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct BudgetSpec {
117 #[serde(default, with = "duration_secs_opt", skip_serializing_if = "Option::is_none")]
119 pub wall_clock: Option<Duration>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub max_tokens: Option<u64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub max_money_micro_usd: Option<u64>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct CliRequest {
131 pub vendor: CliVendorKind,
133
134 #[serde(default)]
136 pub mode: RunMode,
137
138 #[serde(default)]
141 pub prompt: String,
142
143 pub workdir: PathBuf,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub model: Option<String>,
151
152 #[serde(default)]
154 pub allowed_tools: Vec<String>,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub resume_session: Option<String>,
159
160 #[serde(default)]
163 pub project: ConceptProjection,
164
165 #[serde(default)]
167 pub isolation: IsolationSpec,
168
169 #[serde(default)]
171 pub budget: BudgetSpec,
172
173 #[serde(default)]
176 pub metadata: BTreeMap<String, serde_json::Value>,
177}
178
179impl CliRequest {
180 pub fn new(vendor: CliVendorKind, workdir: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
182 Self {
183 vendor,
184 mode: RunMode::Headless,
185 prompt: prompt.into(),
186 workdir: workdir.into(),
187 model: None,
188 allowed_tools: Vec::new(),
189 resume_session: None,
190 project: ConceptProjection::default(),
191 isolation: IsolationSpec::Local,
192 budget: BudgetSpec::default(),
193 metadata: BTreeMap::new(),
194 }
195 }
196
197 pub fn with_mode(mut self, mode: RunMode) -> Self {
198 self.mode = mode;
199 self
200 }
201
202 pub fn with_model(mut self, model: impl Into<String>) -> Self {
203 self.model = Some(model.into());
204 self
205 }
206
207 pub fn with_isolation(mut self, isolation: IsolationSpec) -> Self {
208 self.isolation = isolation;
209 self
210 }
211
212 pub fn with_project(mut self, project: ConceptProjection) -> Self {
213 self.project = project;
214 self
215 }
216}
217
218mod duration_secs_opt {
219 use serde::{Deserialize, Deserializer, Serializer};
220 use std::time::Duration;
221
222 pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
223 match d {
224 Some(d) => s.serialize_some(&d.as_secs()),
225 None => s.serialize_none(),
226 }
227 }
228 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
229 let v: Option<u64> = Option::deserialize(d)?;
230 Ok(v.map(Duration::from_secs))
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn request_round_trips_json() {
240 let r = CliRequest::new(CliVendorKind::Claude, "/tmp/workdir", "list files")
241 .with_model("claude-sonnet-4-6")
242 .with_mode(RunMode::Headless);
243 let j = serde_json::to_string(&r).unwrap();
244 let back: CliRequest = serde_json::from_str(&j).unwrap();
245 assert_eq!(back.vendor, CliVendorKind::Claude);
246 assert_eq!(back.model.as_deref(), Some("claude-sonnet-4-6"));
247 assert_eq!(back.mode, RunMode::Headless);
248 }
249
250 #[test]
251 fn isolation_default_is_local() {
252 assert!(matches!(IsolationSpec::default(), IsolationSpec::Local));
253 }
254
255 #[test]
256 fn run_id_is_unique() {
257 let a = CliRunId::new();
258 let b = CliRunId::new();
259 assert_ne!(a, b);
260 assert!(a.as_str().starts_with("cli-run-"));
261 }
262}