Skip to main content

atomr_agents_coding_cli_core/
request.rs

1//! Inputs accepted by the coding-cli harness.
2
3use 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/// Whether the harness should run the CLI headlessly (parse structured
59/// events) or interactively (bridge a tmux session to a browser).
60#[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/// Where the CLI process runs.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum IsolationSpec {
77    /// Spawn the CLI directly on the host.
78    Local,
79    /// Spawn the CLI inside a Docker container.
80    Docker {
81        /// Image reference (e.g. `atomr-agents/coding-cli-claude:latest`).
82        image: String,
83        /// Host→container bind mounts. The harness always mounts the
84        /// project workdir; additional mounts (e.g. credential files)
85        /// go here.
86        #[serde(default)]
87        mounts: Vec<DockerMount>,
88        /// Environment variables to set inside the container.
89        #[serde(default)]
90        env: BTreeMap<String, String>,
91        /// Network mode (default: `bridge`). Use `none` to fully isolate.
92        #[serde(default)]
93        network: Option<String>,
94    },
95}
96
97impl Default for IsolationSpec {
98    fn default() -> Self {
99        IsolationSpec::Local
100    }
101}
102
103/// One bind mount entry for `IsolationSpec::Docker`.
104#[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/// Budgets shared across the run. Mirrors the budget plumbing in the
113/// rest of the framework but stays decoupled from
114/// `atomr-agents-core::TokenBudget` so this crate remains lightweight.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct BudgetSpec {
117    /// Wall-clock cap for the run.
118    #[serde(default, with = "duration_secs_opt", skip_serializing_if = "Option::is_none")]
119    pub wall_clock: Option<Duration>,
120    /// Total token cap across all CLI calls.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub max_tokens: Option<u64>,
123    /// Optional money cap in micro-USD.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub max_money_micro_usd: Option<u64>,
126}
127
128/// Uniform request the harness accepts.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct CliRequest {
131    /// Which adapter to dispatch to.
132    pub vendor: CliVendorKind,
133
134    /// Headless or interactive.
135    #[serde(default)]
136    pub mode: RunMode,
137
138    /// Free-text prompt fed to the CLI. For interactive runs this may
139    /// be empty (the operator drives the session).
140    #[serde(default)]
141    pub prompt: String,
142
143    /// Working directory the CLI executes against. The harness sets
144    /// this as `cwd` (for `Local`) or bind-mounts it (for `Docker`).
145    pub workdir: PathBuf,
146
147    /// Model id override (vendor-specific string). `None` lets the CLI
148    /// pick its own default.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub model: Option<String>,
151
152    /// Vendor-specific allow-list of tool names. Empty = no restriction.
153    #[serde(default)]
154    pub allowed_tools: Vec<String>,
155
156    /// Resume an existing CLI session id if the vendor supports it.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub resume_session: Option<String>,
159
160    /// Concept projection — atomr Skills/Persona/Policy/toolsets the
161    /// vendor adapter materializes to on-disk config before the run.
162    #[serde(default)]
163    pub project: ConceptProjection,
164
165    /// Where the CLI runs.
166    #[serde(default)]
167    pub isolation: IsolationSpec,
168
169    /// Run budget caps.
170    #[serde(default)]
171    pub budget: BudgetSpec,
172
173    /// Free-form metadata the caller can stash on the request. Echoed
174    /// back on the `CliResult`.
175    #[serde(default)]
176    pub metadata: BTreeMap<String, serde_json::Value>,
177}
178
179impl CliRequest {
180    /// Shortcut for the common case.
181    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}