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;