Skip to main content

aion_integrations/
workspace.rs

1//! Where one attempt's working directory comes from, and how it is obtained.
2//!
3//! # Why this is a building block and not part of the seam
4//!
5//! [`crate::contract::AgentHarness`] is harness-blind: `start` takes only neutral run
6//! identity and the input [`Payload`], never harness configuration. The directory an agent
7//! stands in IS harness configuration — it is declared in the worker document's `harness`
8//! section — so it stays on the adapter's own config type and never grows a field on
9//! [`crate::AgentRunSpec`].
10//!
11//! What it cannot stay is a plain path. A worker serves many jobs, and which tree a job
12//! concerns is a property of the JOB: a document written before any work exists can only
13//! name one tree, and one tree is only ever right for a worker that serves one tree. So
14//! the configuration holds a SOURCE, and the source is resolved once per attempt, against
15//! that attempt's input.
16//!
17//! # Why the resolution lives here rather than in each adapter
18//!
19//! Two adapters need the same answer from the same bytes, and two implementations of
20//! "read this parameter out of the input" would be two chances to disagree about what an
21//! absent parameter means — which is the one case that must never resolve into the
22//! launching process's own directory. One function, one refusal set, both adapters.
23
24use std::path::{Path, PathBuf};
25
26use aion_core::Payload;
27
28use crate::error::HarnessError;
29
30/// Where each attempt's working directory comes from.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum HarnessWorkspace {
33    /// Every attempt stands in this one directory, resolved and absolute. The form for a
34    /// worker that serves exactly one tree.
35    Fixed(PathBuf),
36    /// Each attempt's directory arrives in that attempt's input, under this parameter
37    /// name. The form for a worker whose jobs each name their own tree.
38    PerRun(String),
39}
40
41impl HarnessWorkspace {
42    /// This attempt's working directory.
43    ///
44    /// The fixed form ignores the input entirely. The per-run form reads the named
45    /// parameter out of it and refuses — TERMINALLY, never falling back — when the input
46    /// is not a JSON object, when the parameter is absent, when it is not a string, or
47    /// when it is blank. Every one of those is a job that did not say where to work, and
48    /// an agent that guesses stands in the launching process's directory: invisible in the
49    /// document, in the argv and in the log, which is the whole hazard the setting exists
50    /// to close.
51    ///
52    /// The path is returned as WRITTEN. Whether it is absolute, exists, and is a directory
53    /// is the launching adapter's check, kept there so one adapter's refusal wording and
54    /// one adapter's spawn stay in the same place.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`HarnessError::Configuration`] for every case above. That variant is the
59    /// right one for the same reason it is right for a malformed `env_pass`: nothing was
60    /// spawned, no frame was exchanged, and what is wrong is a value somebody WROTE — here
61    /// in the workflow that dispatched the job rather than in the worker document. The next
62    /// attempt reads the same input and meets the same wall, so the worker's mapping of
63    /// this variant to a TERMINAL failure is exactly what should happen.
64    pub fn for_attempt(&self, input: &Payload) -> Result<PathBuf, HarnessError> {
65        match self {
66            Self::Fixed(directory) => Ok(directory.clone()),
67            Self::PerRun(parameter) => Self::read(parameter, input),
68        }
69    }
70
71    /// The fixed directory, when this workspace is the fixed form.
72    ///
73    /// For the callers that report a launch before any attempt exists — a worker's startup
74    /// narration has no input to resolve against, and saying "the directory this worker
75    /// uses" of a per-run workspace would be stating a fact that does not exist.
76    #[must_use]
77    pub fn fixed(&self) -> Option<&Path> {
78        match self {
79            Self::Fixed(directory) => Some(directory.as_path()),
80            Self::PerRun(_) => None,
81        }
82    }
83
84    /// The parameter name, when the directory arrives with each job.
85    #[must_use]
86    pub fn per_run(&self) -> Option<&str> {
87        match self {
88            Self::PerRun(parameter) => Some(parameter.as_str()),
89            Self::Fixed(_) => None,
90        }
91    }
92
93    /// The prompt text this attempt's input carries.
94    ///
95    /// The activity input is a serialized [`Payload`], not raw prompt text, so the decoding
96    /// is deliberate:
97    ///
98    /// - Bytes that are not valid JSON → the raw UTF-8 text, unchanged ([`Payload`] is a
99    ///   dumb carrier that does not validate on construction).
100    /// - JSON **string** → the inner string, so the agent receives the exact text a caller
101    ///   passed and a multi-line prompt survives verbatim.
102    /// - JSON **object** → the ONE field that is not this workspace's directory parameter
103    ///   and not one of the caller's `reserved` parameter names, read by name. This is the
104    ///   shape an authored action produces: its input is an object keyed by parameter name
105    ///   even when it declares a single parameter, so an adapter that passed the object
106    ///   through handed the agent `{"prompt":"…"}` as its literal instructions.
107    /// - Any other JSON value (array, number, boolean, null) → the raw JSON text: there is
108    ///   no field to read by, and inventing a projection would lose information.
109    ///
110    /// `reserved` names every further input parameter the caller's adapter reads for
111    /// itself — a session parameter, say — so the prompt stays defined by SUBTRACTION: the
112    /// prompt is whatever remains once every parameter the adapter already knows by name is
113    /// removed. An adapter with no such parameters passes `&[]` and the rule is unchanged.
114    ///
115    /// The object arm is read by NAME rather than by position because position is not
116    /// carried on the wire — a JSON object has no order that survives serialization — and
117    /// because the subtracted fields are exactly the fields whose names the adapter already
118    /// knows. Subtracting them leaves exactly one field for every shape the checker admits,
119    /// which is what makes this a total function rather than a guess.
120    ///
121    /// # Errors
122    ///
123    /// [`HarnessError::Protocol`] when the bytes are not UTF-8 at all. Otherwise
124    /// [`HarnessError::Configuration`] — terminal, never retried — when an object carries
125    /// no prompt field, carries more than one, or carries one that is not a string. Each is
126    /// a call that did not state what to ask the agent, and an adapter that guessed would
127    /// send an agent instructions nobody wrote.
128    pub fn prompt_for_attempt(
129        &self,
130        input: &Payload,
131        reserved: &[&str],
132    ) -> Result<String, HarnessError> {
133        let text = std::str::from_utf8(input.bytes())
134            .map(str::to_owned)
135            .map_err(|source| {
136                HarnessError::protocol(format!("the run input is not valid UTF-8: {source}"))
137            })?;
138        // Matched exhaustively rather than guarded with `!matches!(…, Json)`: a payload
139        // carries exactly one content type today, so a guard would be an arm that cannot
140        // run and a claim about behaviour nobody can observe. A second content type added
141        // later stops compiling here, which is where the decision about it belongs.
142        match input.content_type() {
143            aion_core::ContentType::Json => {
144                match serde_json::from_str::<serde_json::Value>(&text) {
145                    Ok(serde_json::Value::String(inner)) => Ok(inner),
146                    Ok(serde_json::Value::Object(fields)) => self.prompt_field(&fields, reserved),
147                    _ => Ok(text),
148                }
149            }
150        }
151    }
152
153    /// Reads the prompt out of an input object: the one field that is neither the
154    /// directory nor a reserved adapter parameter.
155    fn prompt_field(
156        &self,
157        fields: &serde_json::Map<String, serde_json::Value>,
158        reserved: &[&str],
159    ) -> Result<String, HarnessError> {
160        let directory = self.per_run();
161        let carried = fields
162            .iter()
163            .filter(|(name, _)| {
164                Some(name.as_str()) != directory && !reserved.contains(&name.as_str())
165            })
166            .collect::<Vec<_>>();
167        let [(name, value)] = carried.as_slice() else {
168            return Err(HarnessError::configuration(format!(
169                "an agent is asked one thing, and this job's input carries {carried} \
170                 {besides} to ask it with{named}. The agent is not started rather than \
171                 started on instructions nobody wrote.",
172                carried = carried.len(),
173                besides = match (directory, reserved.is_empty()) {
174                    (Some(_), _) | (None, false) =>
175                        "fields besides the parameters this worker's harness reads itself",
176                    (None, true) => "fields",
177                },
178                named = list(&carried)
179            )));
180        };
181        let Some(prompt) = value.as_str() else {
182            return Err(HarnessError::configuration(format!(
183                "an agent is asked in words, and this job carries its `{name}` as {kind} \
184                 rather than text",
185                kind = describe(value)
186            )));
187        };
188        Ok(prompt.to_owned())
189    }
190
191    /// Reads one named string parameter out of an attempt's input.
192    fn read(parameter: &str, input: &Payload) -> Result<PathBuf, HarnessError> {
193        let value: serde_json::Value = serde_json::from_slice(input.bytes()).map_err(|error| {
194            HarnessError::configuration(format!(
195                "this worker takes each agent's working directory from the `{parameter}` \
196                     parameter of the job, and this job's input is not readable as JSON: {error}"
197            ))
198        })?;
199        let serde_json::Value::Object(fields) = value else {
200            return Err(HarnessError::configuration(format!(
201                "this worker takes each agent's working directory from the `{parameter}` \
202                 parameter of the job, and this job's input is not an object, so it carries no \
203                 parameters at all"
204            )));
205        };
206        let Some(field) = fields.get(parameter) else {
207            return Err(HarnessError::configuration(format!(
208                "this worker takes each agent's working directory from the `{parameter}` \
209                 parameter of the job, and this job's input does not carry it. The agent is \
210                 not started rather than started in whichever directory this worker process \
211                 happens to be in."
212            )));
213        };
214        let Some(directory) = field.as_str() else {
215            return Err(HarnessError::configuration(format!(
216                "this worker takes each agent's working directory from the `{parameter}` \
217                 parameter of the job, and this job carries `{parameter}` as {kind} rather \
218                 than a path",
219                kind = describe(field)
220            )));
221        };
222        if directory.trim().is_empty() {
223            return Err(HarnessError::configuration(format!(
224                "this worker takes each agent's working directory from the `{parameter}` \
225                 parameter of the job, and this job carries it empty. An empty directory is \
226                 not the current one; it is a job that did not say where to work."
227            )));
228        }
229        Ok(PathBuf::from(directory))
230    }
231}
232
233/// The field names a refusal reports, so an author can see what arrived.
234///
235/// Empty for an empty set rather than an empty list, because " (): " reads as a fault in
236/// the message itself.
237fn list(fields: &[(&String, &serde_json::Value)]) -> String {
238    if fields.is_empty() {
239        return String::new();
240    }
241    format!(
242        " ({})",
243        fields
244            .iter()
245            .map(|(name, _)| name.as_str())
246            .collect::<Vec<_>>()
247            .join(", ")
248    )
249}
250
251/// What a JSON value is, for a refusal that has to say what arrived instead of a path.
252fn describe(value: &serde_json::Value) -> &'static str {
253    match value {
254        serde_json::Value::Null => "null",
255        serde_json::Value::Bool(_) => "a boolean",
256        serde_json::Value::Number(_) => "a number",
257        serde_json::Value::String(_) => "a string",
258        serde_json::Value::Array(_) => "a list",
259        serde_json::Value::Object(_) => "an object",
260    }
261}
262
263#[cfg(test)]
264mod tests;