a2a-agents 0.7.0

Framework for building A2A Protocol agents with declarative configuration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! [`LocalProcessRuntime`] — supervise agents as child `a2a run` processes.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;

use async_trait::async_trait;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::warn;

use super::{
    AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth, RuntimeStatus,
    tail_lines,
};
use crate::registry::AgentId;

/// One supervised agent: its spec and where it is in its lifecycle.
struct Supervised {
    spec: AgentSpec,
    state: ProcState,
}

/// Lifecycle state of a supervised agent process.
enum ProcState {
    /// Provisioned but never started.
    Provisioned,
    /// A live child process serving the agent. Boxed to keep the enum small —
    /// `Child` dwarfs the unit variants.
    Running(Box<Child>),
    /// The process has been stopped (or exited on its own).
    Stopped,
}

/// An [`AgentRuntime`] that runs each agent as a child `a2a run --config <path>`
/// OS process.
///
/// A first-class adapter (hex rule 6 — not test-only): it gives real process
/// boundaries on a dev box without Docker, and naturally contains an agent's
/// `mcp_client` arbitrary-`command` child exec inside that agent's own process
/// tree. The supervised binary defaults to the current executable
/// ([`new`](Self::new)) so the supervisor runs copies of itself; point it
/// elsewhere with [`with_exe`](Self::with_exe).
///
/// Cheap to `clone` (shares one map). Children are spawned with `kill_on_drop`,
/// so dropping the runtime tears down everything it started.
///
/// An agent whose config names its own image (`[runtime] image`) is refused with
/// [`RuntimeError::Unsupported`]: there is no image to run here, and starting
/// the config under this binary instead would serve a different agent than the
/// one that was deployed. Those belong on
/// [`ContainerRuntime`](super::ContainerRuntime).
///
/// # Logs
///
/// By default a child **inherits** this process's stdout/stderr, so its output
/// lands in the supervisor's terminal and [`logs`](AgentRuntime::logs) has
/// nothing to read — it reports [`RuntimeError::Unsupported`] rather than
/// pretending the agent was silent. Give it a
/// [`log_dir`](Self::with_log_dir) to capture each agent's output into its own
/// file instead; that is what makes `a2a logs <id>` work over this backend, and
/// it is also what stops a multi-agent control plane from interleaving every
/// agent's output into one unlabelled stream.
///
/// # Secrets — dev-only isolation
///
/// [`provision`](AgentRuntime::provision) enforces the same [`EnvAllowlist`] as
/// [`ContainerRuntime`](super::ContainerRuntime), so a deployed config cannot
/// template an un-permitted secret into itself (and thence into its agent card).
///
/// But a child process **inherits this process's entire environment**, and there
/// is no allowlist that changes that: an agent whose config declares an
/// `mcp_client` with an arbitrary `command` can read every variable the control
/// plane holds. That is inherent to process inheritance and is why this adapter
/// is dev/test-only — deploy untrusted or third-party configs on
/// [`ContainerRuntime`](super::ContainerRuntime), where only the allowlisted
/// variables cross the boundary.
#[derive(Clone)]
pub struct LocalProcessRuntime {
    exe: PathBuf,
    /// Which host env vars a deployed config may reference. Deny-all by default.
    allowed_env: EnvAllowlist,
    /// Where each agent's captured output goes. `None` ⇒ children inherit this
    /// process's streams and there is nothing to serve back.
    log_dir: Option<PathBuf>,
    agents: Arc<Mutex<HashMap<AgentId, Supervised>>>,
}

impl LocalProcessRuntime {
    /// Supervise copies of the current executable (the `a2a` binary). Falls back
    /// to `"a2a"` on `PATH` if the current exe path can't be resolved.
    pub fn new() -> Self {
        let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("a2a"));
        Self::with_exe(exe)
    }

    /// Supervise a specific `a2a` binary (e.g. `env!("CARGO_BIN_EXE_a2a")` in
    /// tests, or a pinned install path).
    pub fn with_exe(exe: impl Into<PathBuf>) -> Self {
        Self {
            exe: exe.into(),
            allowed_env: EnvAllowlist::deny_all(),
            log_dir: None,
            agents: Arc::new(Mutex::new(HashMap::new())),
        }
    }

    /// Capture each agent's stdout and stderr into `<dir>/<id>.log`, making
    /// [`logs`](AgentRuntime::logs) answerable over this backend.
    ///
    /// Appends rather than truncates, so restarting an agent keeps the output
    /// that explains why it needed restarting.
    pub fn with_log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.log_dir = Some(dir.into());
        self
    }

    /// Permit deployed configs to reference these host environment variables.
    /// Anything not listed is rejected at [`provision`](AgentRuntime::provision).
    ///
    /// Note the caveat in the type docs: this bounds what a *config* may name,
    /// not what a child process can read.
    pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
        self.allowed_env = allowed;
        self
    }
}

impl Default for LocalProcessRuntime {
    fn default() -> Self {
        Self::new()
    }
}

/// Where an agent's captured output lives under a log directory.
fn log_path(dir: &Path, id: &AgentId) -> PathBuf {
    // `AgentId` is a slug, so it is already a safe filename component.
    dir.join(format!("{id}.log"))
}

/// Open an agent's log file as a pair of child stdio handles (one per stream).
///
/// Both handles are the *same* appended file, so the child's stdout and stderr
/// interleave the way they would on a terminal — which matters because an agent
/// prints its banner on stdout and everything `tracing` emits on stderr, and
/// reading only one of those is how you end up looking at an empty log for a
/// crashing agent.
async fn log_stdio(dir: &Path, id: &AgentId) -> Result<(Stdio, Stdio), std::io::Error> {
    tokio::fs::create_dir_all(dir).await?;
    let file = tokio::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_path(dir, id))
        .await?
        // Back to a std handle: `Stdio` is what `Command` takes, and the child
        // owns the descriptor from here on.
        .into_std()
        .await;
    let dup = file.try_clone()?;
    Ok((Stdio::from(file), Stdio::from(dup)))
}

#[async_trait]
impl AgentRuntime for LocalProcessRuntime {
    async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError> {
        // An agent that named an image is not this binary, and running the
        // config here anyway would start something that answers, passes its
        // health probe, and is not the agent that was deployed.
        if let Some(image) = &spec.image {
            return Err(RuntimeError::Unsupported {
                operation: "custom image",
                reason: format!(
                    "agent '{}' runs from image '{image}', which this runtime cannot pull \
                     — deploy it on a container runtime (`a2a control-plane --runtime container`)",
                    spec.id
                ),
            });
        }

        // Vet the raw config before it is ever expanded — see `EnvAllowlist::check`.
        let content = tokio::fs::read_to_string(&spec.config_path)
            .await
            .map_err(|e| RuntimeError::Config(e.to_string()))?;
        self.allowed_env.check(&content)?;

        let id = spec.id.clone();
        self.agents.lock().await.insert(
            id.clone(),
            Supervised {
                spec,
                state: ProcState::Provisioned,
            },
        );
        Ok(id)
    }

    /// Always [`Recovered::Ephemeral`]: nothing here survives the supervisor.
    ///
    /// Children are spawned with `kill_on_drop`, so an orderly exit takes them
    /// with it, and the only record that they existed was the in-memory table
    /// that died with the process. Even after a `SIGKILL` leaves orphans behind,
    /// there is nothing durable tying a stray `a2a run` to an [`AgentId`] — so
    /// the honest answer is "I cannot tell you", not an empty list. This is the
    /// concrete reason the container runtime is the supported control-plane
    /// backend and this one is for dev loops.
    async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError> {
        Ok(Recovered::Ephemeral)
    }

    async fn start(&self, id: &AgentId) -> Result<(), RuntimeError> {
        // The lock is held across the log-file open below. That serializes
        // concurrent starts, which is the point: the not-already-running check
        // and the spawn have to be one step, or two callers both pass it.
        let mut guard = self.agents.lock().await;
        let entry = guard
            .get_mut(id)
            .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
        if matches!(entry.state, ProcState::Running(_)) {
            return Err(RuntimeError::AlreadyRunning(id.clone()));
        }

        let mut command = Command::new(&self.exe);
        command
            .arg("run")
            .arg("--config")
            .arg(&entry.spec.config_path)
            .kill_on_drop(true);
        // Without a log dir the child inherits our streams — the dev-loop
        // default, where the operator is watching the terminal anyway.
        if let Some(dir) = &self.log_dir {
            let (out, err) = log_stdio(dir, id)
                .await
                .map_err(|source| RuntimeError::Spawn {
                    id: id.clone(),
                    source,
                })?;
            command.stdout(out).stderr(err);
        }

        let child = command.spawn().map_err(|source| RuntimeError::Spawn {
            id: id.clone(),
            source,
        })?;

        entry.state = ProcState::Running(Box::new(child));
        Ok(())
    }

    async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError> {
        let mut guard = self.agents.lock().await;
        let entry = guard
            .get_mut(id)
            .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
        if let ProcState::Running(child) = &mut entry.state
            && let Err(e) = child.kill().await
        {
            warn!("error killing agent '{}': {}", id, e);
        }
        entry.state = ProcState::Stopped;
        Ok(())
    }

    async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError> {
        // Resolve process state under the lock, then probe the card *outside* it
        // so a slow network probe never serializes other lifecycle ops.
        let endpoint = {
            let mut guard = self.agents.lock().await;
            let entry = guard
                .get_mut(id)
                .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
            match &mut entry.state {
                ProcState::Provisioned => return Ok(RuntimeHealth::Provisioned),
                ProcState::Stopped => return Ok(RuntimeHealth::Stopped),
                ProcState::Running(child) => match child.try_wait() {
                    // The process exited on its own — record and report it.
                    Ok(Some(_)) => {
                        entry.state = ProcState::Stopped;
                        return Ok(RuntimeHealth::Stopped);
                    }
                    // Still running (or status unknown) — probe the card below.
                    Ok(None) | Err(_) => entry.spec.endpoint.clone(),
                },
            }
        };

        match a2a_rs::fetch_agent_card(&endpoint).await {
            Ok(_) => Ok(RuntimeHealth::Healthy),
            Err(_) => Ok(RuntimeHealth::Unhealthy),
        }
    }

    async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError> {
        // Snapshot ids under the lock, then resolve health per-id (which re-locks
        // briefly and probes outside the lock) so we never hold it across awaits.
        let ids: Vec<AgentId> = self.agents.lock().await.keys().cloned().collect();
        let mut statuses = Vec::with_capacity(ids.len());
        for id in ids {
            let health = self.health(&id).await?;
            let endpoint = match self.agents.lock().await.get(&id) {
                Some(entry) => entry.spec.endpoint.clone(),
                None => continue, // deprovisioned between snapshot and probe
            };
            statuses.push(RuntimeStatus {
                id,
                health,
                endpoint,
            });
        }
        Ok(statuses)
    }

    /// Read back what the child wrote to its captured log file.
    ///
    /// Only answerable when this runtime was given a
    /// [`log_dir`](Self::with_log_dir); otherwise the output went to the
    /// supervisor's own streams and is not ours to serve.
    async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError> {
        if !self.agents.lock().await.contains_key(id) {
            return Err(RuntimeError::NotFound(id.clone()));
        }
        let Some(dir) = &self.log_dir else {
            return Err(RuntimeError::Unsupported {
                operation: "logs",
                reason: "this local runtime does not capture agent output — its children \
                         inherit the supervisor's stdout/stderr. Start the control plane \
                         with --log-dir to capture it per agent."
                    .to_string(),
            });
        };
        match tokio::fs::read_to_string(log_path(dir, id)).await {
            Ok(text) => Ok(tail_lines(&text, tail)),
            // Provisioned but never started, so no file exists yet. That is an
            // empty log, not a missing agent — `NotFound` above already ruled
            // the latter out.
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
            Err(e) => Err(RuntimeError::Backend(format!(
                "could not read the log for '{id}': {e}"
            ))),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn log_path_is_the_slug_under_the_log_dir() {
        assert_eq!(
            log_path(
                Path::new("/var/log/a2a"),
                &AgentId::from_name("Weather Agent")
            ),
            Path::new("/var/log/a2a/weather-agent.log")
        );
    }

    /// Without a log dir the runtime must say it cannot serve logs, not report
    /// the agent as silent — an operator debugging a crash needs to know which.
    #[tokio::test]
    async fn logs_without_a_log_dir_are_unsupported_not_empty() {
        let runtime = LocalProcessRuntime::with_exe("a2a");
        let spec = AgentSpec {
            id: AgentId::from_name("Quiet"),
            config_path: PathBuf::from("quiet.toml"),
            endpoint: "http://127.0.0.1:8080".to_string(),
            image: None,
        };
        // Provision directly: the file-reading `provision` would need a config
        // on disk, and this is about the log seam.
        runtime.agents.lock().await.insert(
            spec.id.clone(),
            Supervised {
                spec,
                state: ProcState::Provisioned,
            },
        );

        let err = runtime
            .logs(&AgentId::from("quiet"), None)
            .await
            .expect_err("a runtime with no log dir cannot serve logs");
        assert!(
            matches!(&err, RuntimeError::Unsupported { operation, .. } if *operation == "logs"),
            "got: {err}"
        );
        // The message has to point somewhere.
        assert!(err.to_string().contains("--log-dir"), "{err}");
    }

    /// An agent that brings its own image cannot run here, and saying so is the
    /// whole value: provisioning it as a child `a2a run` would start the config
    /// under the *wrong* binary — an agent that comes up healthy and is not the
    /// one that was deployed.
    #[tokio::test]
    async fn an_agent_with_its_own_image_is_refused() {
        let runtime = LocalProcessRuntime::with_exe("a2a");
        let err = runtime
            .provision(AgentSpec {
                id: AgentId::from_name("Billing"),
                config_path: PathBuf::from("billing.toml"),
                endpoint: "http://127.0.0.1:8080".to_string(),
                image: Some("ghcr.io/acme/billing:1.4".to_string()),
            })
            .await
            .expect_err("a local process cannot be someone else's image");

        assert!(
            matches!(&err, RuntimeError::Unsupported { operation, .. } if *operation == "custom image"),
            "got: {err}"
        );
        // Names the image it could not run, and where it can be run instead.
        let message = err.to_string();
        assert!(message.contains("ghcr.io/acme/billing:1.4"), "{message}");
        assert!(message.contains("container"), "{message}");
    }

    #[tokio::test]
    async fn logs_for_an_unprovisioned_agent_are_not_found() {
        let runtime = LocalProcessRuntime::with_exe("a2a").with_log_dir(std::env::temp_dir());
        assert!(matches!(
            runtime.logs(&AgentId::from("ghost"), None).await,
            Err(RuntimeError::NotFound(_))
        ));
    }
}