Skip to main content

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}