a2a_agents/runtime/local.rs
1//! [`LocalProcessRuntime`] — supervise agents as child `a2a run` processes.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use tokio::process::{Child, Command};
10use tokio::sync::Mutex;
11use tracing::warn;
12
13use super::{
14 AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth, RuntimeStatus,
15 tail_lines,
16};
17use crate::registry::AgentId;
18
19/// One supervised agent: its spec and where it is in its lifecycle.
20struct Supervised {
21 spec: AgentSpec,
22 state: ProcState,
23}
24
25/// Lifecycle state of a supervised agent process.
26enum ProcState {
27 /// Provisioned but never started.
28 Provisioned,
29 /// A live child process serving the agent. Boxed to keep the enum small —
30 /// `Child` dwarfs the unit variants.
31 Running(Box<Child>),
32 /// The process has been stopped (or exited on its own).
33 Stopped,
34}
35
36/// An [`AgentRuntime`] that runs each agent as a child `a2a run --config <path>`
37/// OS process.
38///
39/// A first-class adapter (hex rule 6 — not test-only): it gives real process
40/// boundaries on a dev box without Docker, and naturally contains an agent's
41/// `mcp_client` arbitrary-`command` child exec inside that agent's own process
42/// tree. The supervised binary defaults to the current executable
43/// ([`new`](Self::new)) so the supervisor runs copies of itself; point it
44/// elsewhere with [`with_exe`](Self::with_exe).
45///
46/// Cheap to `clone` (shares one map). Children are spawned with `kill_on_drop`,
47/// so dropping the runtime tears down everything it started.
48///
49/// An agent whose config names its own image (`[runtime] image`) is refused with
50/// [`RuntimeError::Unsupported`]: there is no image to run here, and starting
51/// the config under this binary instead would serve a different agent than the
52/// one that was deployed. Those belong on
53/// [`ContainerRuntime`](super::ContainerRuntime).
54///
55/// # Logs
56///
57/// By default a child **inherits** this process's stdout/stderr, so its output
58/// lands in the supervisor's terminal and [`logs`](AgentRuntime::logs) has
59/// nothing to read — it reports [`RuntimeError::Unsupported`] rather than
60/// pretending the agent was silent. Give it a
61/// [`log_dir`](Self::with_log_dir) to capture each agent's output into its own
62/// file instead; that is what makes `a2a logs <id>` work over this backend, and
63/// it is also what stops a multi-agent control plane from interleaving every
64/// agent's output into one unlabelled stream.
65///
66/// # Secrets — dev-only isolation
67///
68/// [`provision`](AgentRuntime::provision) enforces the same [`EnvAllowlist`] as
69/// [`ContainerRuntime`](super::ContainerRuntime), so a deployed config cannot
70/// template an un-permitted secret into itself (and thence into its agent card).
71///
72/// But a child process **inherits this process's entire environment**, and there
73/// is no allowlist that changes that: an agent whose config declares an
74/// `mcp_client` with an arbitrary `command` can read every variable the control
75/// plane holds. That is inherent to process inheritance and is why this adapter
76/// is dev/test-only — deploy untrusted or third-party configs on
77/// [`ContainerRuntime`](super::ContainerRuntime), where only the allowlisted
78/// variables cross the boundary.
79#[derive(Clone)]
80pub struct LocalProcessRuntime {
81 exe: PathBuf,
82 /// Which host env vars a deployed config may reference. Deny-all by default.
83 allowed_env: EnvAllowlist,
84 /// Where each agent's captured output goes. `None` ⇒ children inherit this
85 /// process's streams and there is nothing to serve back.
86 log_dir: Option<PathBuf>,
87 agents: Arc<Mutex<HashMap<AgentId, Supervised>>>,
88}
89
90impl LocalProcessRuntime {
91 /// Supervise copies of the current executable (the `a2a` binary). Falls back
92 /// to `"a2a"` on `PATH` if the current exe path can't be resolved.
93 pub fn new() -> Self {
94 let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("a2a"));
95 Self::with_exe(exe)
96 }
97
98 /// Supervise a specific `a2a` binary (e.g. `env!("CARGO_BIN_EXE_a2a")` in
99 /// tests, or a pinned install path).
100 pub fn with_exe(exe: impl Into<PathBuf>) -> Self {
101 Self {
102 exe: exe.into(),
103 allowed_env: EnvAllowlist::deny_all(),
104 log_dir: None,
105 agents: Arc::new(Mutex::new(HashMap::new())),
106 }
107 }
108
109 /// Capture each agent's stdout and stderr into `<dir>/<id>.log`, making
110 /// [`logs`](AgentRuntime::logs) answerable over this backend.
111 ///
112 /// Appends rather than truncates, so restarting an agent keeps the output
113 /// that explains why it needed restarting.
114 pub fn with_log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
115 self.log_dir = Some(dir.into());
116 self
117 }
118
119 /// Permit deployed configs to reference these host environment variables.
120 /// Anything not listed is rejected at [`provision`](AgentRuntime::provision).
121 ///
122 /// Note the caveat in the type docs: this bounds what a *config* may name,
123 /// not what a child process can read.
124 pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
125 self.allowed_env = allowed;
126 self
127 }
128}
129
130impl Default for LocalProcessRuntime {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136/// Where an agent's captured output lives under a log directory.
137fn log_path(dir: &Path, id: &AgentId) -> PathBuf {
138 // `AgentId` is a slug, so it is already a safe filename component.
139 dir.join(format!("{id}.log"))
140}
141
142/// Open an agent's log file as a pair of child stdio handles (one per stream).
143///
144/// Both handles are the *same* appended file, so the child's stdout and stderr
145/// interleave the way they would on a terminal — which matters because an agent
146/// prints its banner on stdout and everything `tracing` emits on stderr, and
147/// reading only one of those is how you end up looking at an empty log for a
148/// crashing agent.
149async fn log_stdio(dir: &Path, id: &AgentId) -> Result<(Stdio, Stdio), std::io::Error> {
150 tokio::fs::create_dir_all(dir).await?;
151 let file = tokio::fs::OpenOptions::new()
152 .create(true)
153 .append(true)
154 .open(log_path(dir, id))
155 .await?
156 // Back to a std handle: `Stdio` is what `Command` takes, and the child
157 // owns the descriptor from here on.
158 .into_std()
159 .await;
160 let dup = file.try_clone()?;
161 Ok((Stdio::from(file), Stdio::from(dup)))
162}
163
164#[async_trait]
165impl AgentRuntime for LocalProcessRuntime {
166 async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError> {
167 // An agent that named an image is not this binary, and running the
168 // config here anyway would start something that answers, passes its
169 // health probe, and is not the agent that was deployed.
170 if let Some(image) = &spec.image {
171 return Err(RuntimeError::Unsupported {
172 operation: "custom image",
173 reason: format!(
174 "agent '{}' runs from image '{image}', which this runtime cannot pull \
175 — deploy it on a container runtime (`a2a control-plane --runtime container`)",
176 spec.id
177 ),
178 });
179 }
180
181 // Vet the raw config before it is ever expanded — see `EnvAllowlist::check`.
182 let content = tokio::fs::read_to_string(&spec.config_path)
183 .await
184 .map_err(|e| RuntimeError::Config(e.to_string()))?;
185 self.allowed_env.check(&content)?;
186
187 let id = spec.id.clone();
188 self.agents.lock().await.insert(
189 id.clone(),
190 Supervised {
191 spec,
192 state: ProcState::Provisioned,
193 },
194 );
195 Ok(id)
196 }
197
198 /// Always [`Recovered::Ephemeral`]: nothing here survives the supervisor.
199 ///
200 /// Children are spawned with `kill_on_drop`, so an orderly exit takes them
201 /// with it, and the only record that they existed was the in-memory table
202 /// that died with the process. Even after a `SIGKILL` leaves orphans behind,
203 /// there is nothing durable tying a stray `a2a run` to an [`AgentId`] — so
204 /// the honest answer is "I cannot tell you", not an empty list. This is the
205 /// concrete reason the container runtime is the supported control-plane
206 /// backend and this one is for dev loops.
207 async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError> {
208 Ok(Recovered::Ephemeral)
209 }
210
211 async fn start(&self, id: &AgentId) -> Result<(), RuntimeError> {
212 // The lock is held across the log-file open below. That serializes
213 // concurrent starts, which is the point: the not-already-running check
214 // and the spawn have to be one step, or two callers both pass it.
215 let mut guard = self.agents.lock().await;
216 let entry = guard
217 .get_mut(id)
218 .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
219 if matches!(entry.state, ProcState::Running(_)) {
220 return Err(RuntimeError::AlreadyRunning(id.clone()));
221 }
222
223 let mut command = Command::new(&self.exe);
224 command
225 .arg("run")
226 .arg("--config")
227 .arg(&entry.spec.config_path)
228 .kill_on_drop(true);
229 // Without a log dir the child inherits our streams — the dev-loop
230 // default, where the operator is watching the terminal anyway.
231 if let Some(dir) = &self.log_dir {
232 let (out, err) = log_stdio(dir, id)
233 .await
234 .map_err(|source| RuntimeError::Spawn {
235 id: id.clone(),
236 source,
237 })?;
238 command.stdout(out).stderr(err);
239 }
240
241 let child = command.spawn().map_err(|source| RuntimeError::Spawn {
242 id: id.clone(),
243 source,
244 })?;
245
246 entry.state = ProcState::Running(Box::new(child));
247 Ok(())
248 }
249
250 async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError> {
251 let mut guard = self.agents.lock().await;
252 let entry = guard
253 .get_mut(id)
254 .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
255 if let ProcState::Running(child) = &mut entry.state
256 && let Err(e) = child.kill().await
257 {
258 warn!("error killing agent '{}': {}", id, e);
259 }
260 entry.state = ProcState::Stopped;
261 Ok(())
262 }
263
264 async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError> {
265 // Resolve process state under the lock, then probe the card *outside* it
266 // so a slow network probe never serializes other lifecycle ops.
267 let endpoint = {
268 let mut guard = self.agents.lock().await;
269 let entry = guard
270 .get_mut(id)
271 .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
272 match &mut entry.state {
273 ProcState::Provisioned => return Ok(RuntimeHealth::Provisioned),
274 ProcState::Stopped => return Ok(RuntimeHealth::Stopped),
275 ProcState::Running(child) => match child.try_wait() {
276 // The process exited on its own — record and report it.
277 Ok(Some(_)) => {
278 entry.state = ProcState::Stopped;
279 return Ok(RuntimeHealth::Stopped);
280 }
281 // Still running (or status unknown) — probe the card below.
282 Ok(None) | Err(_) => entry.spec.endpoint.clone(),
283 },
284 }
285 };
286
287 match a2a_rs::fetch_agent_card(&endpoint).await {
288 Ok(_) => Ok(RuntimeHealth::Healthy),
289 Err(_) => Ok(RuntimeHealth::Unhealthy),
290 }
291 }
292
293 async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError> {
294 // Snapshot ids under the lock, then resolve health per-id (which re-locks
295 // briefly and probes outside the lock) so we never hold it across awaits.
296 let ids: Vec<AgentId> = self.agents.lock().await.keys().cloned().collect();
297 let mut statuses = Vec::with_capacity(ids.len());
298 for id in ids {
299 let health = self.health(&id).await?;
300 let endpoint = match self.agents.lock().await.get(&id) {
301 Some(entry) => entry.spec.endpoint.clone(),
302 None => continue, // deprovisioned between snapshot and probe
303 };
304 statuses.push(RuntimeStatus {
305 id,
306 health,
307 endpoint,
308 });
309 }
310 Ok(statuses)
311 }
312
313 /// Read back what the child wrote to its captured log file.
314 ///
315 /// Only answerable when this runtime was given a
316 /// [`log_dir`](Self::with_log_dir); otherwise the output went to the
317 /// supervisor's own streams and is not ours to serve.
318 async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError> {
319 if !self.agents.lock().await.contains_key(id) {
320 return Err(RuntimeError::NotFound(id.clone()));
321 }
322 let Some(dir) = &self.log_dir else {
323 return Err(RuntimeError::Unsupported {
324 operation: "logs",
325 reason: "this local runtime does not capture agent output — its children \
326 inherit the supervisor's stdout/stderr. Start the control plane \
327 with --log-dir to capture it per agent."
328 .to_string(),
329 });
330 };
331 match tokio::fs::read_to_string(log_path(dir, id)).await {
332 Ok(text) => Ok(tail_lines(&text, tail)),
333 // Provisioned but never started, so no file exists yet. That is an
334 // empty log, not a missing agent — `NotFound` above already ruled
335 // the latter out.
336 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
337 Err(e) => Err(RuntimeError::Backend(format!(
338 "could not read the log for '{id}': {e}"
339 ))),
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn log_path_is_the_slug_under_the_log_dir() {
350 assert_eq!(
351 log_path(
352 Path::new("/var/log/a2a"),
353 &AgentId::from_name("Weather Agent")
354 ),
355 Path::new("/var/log/a2a/weather-agent.log")
356 );
357 }
358
359 /// Without a log dir the runtime must say it cannot serve logs, not report
360 /// the agent as silent — an operator debugging a crash needs to know which.
361 #[tokio::test]
362 async fn logs_without_a_log_dir_are_unsupported_not_empty() {
363 let runtime = LocalProcessRuntime::with_exe("a2a");
364 let spec = AgentSpec {
365 id: AgentId::from_name("Quiet"),
366 config_path: PathBuf::from("quiet.toml"),
367 endpoint: "http://127.0.0.1:8080".to_string(),
368 image: None,
369 };
370 // Provision directly: the file-reading `provision` would need a config
371 // on disk, and this is about the log seam.
372 runtime.agents.lock().await.insert(
373 spec.id.clone(),
374 Supervised {
375 spec,
376 state: ProcState::Provisioned,
377 },
378 );
379
380 let err = runtime
381 .logs(&AgentId::from("quiet"), None)
382 .await
383 .expect_err("a runtime with no log dir cannot serve logs");
384 assert!(
385 matches!(&err, RuntimeError::Unsupported { operation, .. } if *operation == "logs"),
386 "got: {err}"
387 );
388 // The message has to point somewhere.
389 assert!(err.to_string().contains("--log-dir"), "{err}");
390 }
391
392 /// An agent that brings its own image cannot run here, and saying so is the
393 /// whole value: provisioning it as a child `a2a run` would start the config
394 /// under the *wrong* binary — an agent that comes up healthy and is not the
395 /// one that was deployed.
396 #[tokio::test]
397 async fn an_agent_with_its_own_image_is_refused() {
398 let runtime = LocalProcessRuntime::with_exe("a2a");
399 let err = runtime
400 .provision(AgentSpec {
401 id: AgentId::from_name("Billing"),
402 config_path: PathBuf::from("billing.toml"),
403 endpoint: "http://127.0.0.1:8080".to_string(),
404 image: Some("ghcr.io/acme/billing:1.4".to_string()),
405 })
406 .await
407 .expect_err("a local process cannot be someone else's image");
408
409 assert!(
410 matches!(&err, RuntimeError::Unsupported { operation, .. } if *operation == "custom image"),
411 "got: {err}"
412 );
413 // Names the image it could not run, and where it can be run instead.
414 let message = err.to_string();
415 assert!(message.contains("ghcr.io/acme/billing:1.4"), "{message}");
416 assert!(message.contains("container"), "{message}");
417 }
418
419 #[tokio::test]
420 async fn logs_for_an_unprovisioned_agent_are_not_found() {
421 let runtime = LocalProcessRuntime::with_exe("a2a").with_log_dir(std::env::temp_dir());
422 assert!(matches!(
423 runtime.logs(&AgentId::from("ghost"), None).await,
424 Err(RuntimeError::NotFound(_))
425 ));
426 }
427}