a2a_agents/runtime/mod.rs
1//! Agent runtime — a place to *run* agents as managed, isolatable units.
2//!
3//! The [`AgentRuntime`] port is a **platform capability**: it provisions, starts,
4//! stops, health-checks, and serves back the output of agent *instances*,
5//! independently of how each agent serves requests once running
6//! ([`AgentServer`](crate::AgentServer) is that per-agent leaf). This is the keystone of Pillar 3 — a future control-plane
7//! and the Terraform provider drive a real backend through this port instead of
8//! a single in-process fan-out.
9//!
10//! Per the hexagonal rules this lives in the platform layer (never in the pure
11//! `a2a-rs` protocol crate): a capability port plus a first-class adapter.
12//! [`LocalProcessRuntime`] supervises agents as child `a2a run` OS processes
13//! (dev/test, no Docker); a `ContainerRuntime` (Docker/Podman) and the
14//! control-plane service are later drop-ins behind the same port — which is why
15//! every method returns [`Result`] even though the local adapter rarely fails.
16//!
17//! Identity is shared with the [`registry`](crate::registry): a runtime instance
18//! and its registry entry use the same [`AgentId`], so the two compose at the
19//! control-plane edge.
20//!
21//! A runtime also answers whether it outlives the supervisor
22//! ([`AgentRuntime::recover`] → [`Recovered`]). That is what separates a backend
23//! a control plane can be restarted over — containers, whose engine keeps the
24//! record — from one whose state dies with the process.
25
26mod container;
27mod local;
28mod memory;
29
30pub use container::{ContainerHardening, ContainerRuntime};
31pub use local::LocalProcessRuntime;
32pub use memory::InMemoryAgentRuntime;
33
34use std::collections::BTreeSet;
35use std::path::PathBuf;
36
37use async_trait::async_trait;
38use serde::{Deserialize, Serialize};
39use thiserror::Error;
40
41use crate::core::{AgentConfig, referenced_env_vars};
42use crate::registry::AgentId;
43
44/// The environment variables a deployed agent config is permitted to reference
45/// via `${VAR}` — least privilege over the *deploying process's* secrets.
46///
47/// Without this, any config the control plane accepts can name any variable the
48/// control-plane process holds (`description = "${AWS_SECRET_ACCESS_KEY}"`) and
49/// have it expanded into the agent's card, where anyone who can fetch the card
50/// reads it back. The allowlist is the operator's explicit statement of which
51/// secrets the platform is willing to hand to agents.
52///
53/// **Deny-by-default:** [`deny_all`](Self::deny_all) is the [`Default`], so a
54/// runtime built without an explicit allowlist rejects every config that
55/// references anything. Populate it at the composition edge (the `--allow-env`
56/// flag on `a2a control-plane`).
57#[derive(Debug, Clone, Default)]
58pub struct EnvAllowlist {
59 allowed: BTreeSet<String>,
60}
61
62impl EnvAllowlist {
63 /// Allow exactly these variable names.
64 pub fn new(vars: impl IntoIterator<Item = impl Into<String>>) -> Self {
65 Self {
66 allowed: vars.into_iter().map(Into::into).collect(),
67 }
68 }
69
70 /// Allow nothing — any `${VAR}` reference in a deployed config is rejected.
71 pub fn deny_all() -> Self {
72 Self::default()
73 }
74
75 /// Whether any variable is permitted.
76 pub fn is_empty(&self) -> bool {
77 self.allowed.is_empty()
78 }
79
80 /// Check a **raw, unexpanded** config against the allowlist, returning the
81 /// variables it legitimately references (in sorted order) for a runtime to
82 /// inject.
83 ///
84 /// Call this *before* parsing the config. Expansion resolves `${VAR}`
85 /// against the host environment and errors differently for a set vs. unset
86 /// variable, so parsing first would let a rejected config probe which
87 /// secrets the control-plane process holds.
88 ///
89 /// `HOST` is excluded from the result and exempt from the check: it is
90 /// adapter-owned (the container runtime sets `HOST=0.0.0.0` itself).
91 pub fn check(&self, raw_config: &str) -> Result<Vec<String>, RuntimeError> {
92 let referenced: Vec<String> = referenced_env_vars(raw_config)
93 .into_iter()
94 .filter(|var| var != "HOST")
95 .collect();
96
97 let disallowed: Vec<&str> = referenced
98 .iter()
99 .filter(|var| !self.allowed.contains(*var))
100 .map(String::as_str)
101 .collect();
102
103 if disallowed.is_empty() {
104 Ok(referenced)
105 } else {
106 Err(RuntimeError::DisallowedEnv(disallowed.join(", ")))
107 }
108 }
109}
110
111/// What to run and how to reach it — the unit of deployment a runtime manages.
112///
113/// Today an agent *is* a TOML config, so a spec pairs that config path with the
114/// derived [`AgentId`] and the endpoint the agent will serve on (used for health
115/// probing). Build one with [`from_config_path`](Self::from_config_path), or
116/// construct the fields directly.
117#[derive(Debug, Clone)]
118pub struct AgentSpec {
119 /// Stable id, derived from the agent's name (slug). Shared with the registry.
120 pub id: AgentId,
121 /// Path to the agent's TOML config, passed to `a2a run --config <path>`.
122 pub config_path: PathBuf,
123 /// Dialable base URL the agent serves on (from [`AgentConfig::agent_url`]),
124 /// probed to decide [`RuntimeHealth::Healthy`] vs [`RuntimeHealth::Unhealthy`].
125 pub endpoint: String,
126 /// The agent's own image (`[runtime] image`), where it brings one.
127 ///
128 /// This is how an agent whose handler no TOML can express reaches the
129 /// platform: as an image, rather than as a handler name this binary would
130 /// have to already know. `None` means the runtime's own default — its base
131 /// image, or a child `a2a run` process.
132 ///
133 /// A backend that cannot run an arbitrary image must reject a spec carrying
134 /// one with [`RuntimeError::Unsupported`]: running the config's *config* in
135 /// something other than its image gives an agent that starts, answers, and
136 /// is not the agent that was deployed.
137 pub image: Option<String>,
138}
139
140impl AgentSpec {
141 /// Derive a spec from a config file, reusing [`AgentConfig`] to read the
142 /// agent's name (→ [`AgentId`]), bound endpoint and image. Invalid configs
143 /// surface as [`RuntimeError::Config`].
144 pub fn from_config_path(path: impl Into<PathBuf>) -> Result<Self, RuntimeError> {
145 let config_path = path.into();
146 let config = AgentConfig::from_file(&config_path)
147 .map_err(|e| RuntimeError::Config(e.to_string()))?;
148 Ok(Self {
149 id: AgentId::from_name(&config.agent.name),
150 endpoint: config.agent_url(),
151 image: config.image().map(str::to_string),
152 config_path,
153 })
154 }
155}
156
157/// Liveness of a managed agent instance.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "lowercase")]
160pub enum RuntimeHealth {
161 /// Known to the runtime but not started.
162 Provisioned,
163 /// Process running **and** its agent card answered a probe.
164 Healthy,
165 /// Process running but the agent-card probe is failing (starting up or stuck).
166 Unhealthy,
167 /// The process has exited (or was stopped).
168 Stopped,
169}
170
171/// Matches the serialized form, so what an operator reads in `a2a ps` is what
172/// they would find in the API's JSON.
173impl std::fmt::Display for RuntimeHealth {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 // `pad`, not `write_str`: this is printed as a column in `a2a ps`, and
176 // `write_str` silently ignores the `{:<14}` that keeps it one.
177 f.pad(match self {
178 RuntimeHealth::Provisioned => "provisioned",
179 RuntimeHealth::Healthy => "healthy",
180 RuntimeHealth::Unhealthy => "unhealthy",
181 RuntimeHealth::Stopped => "stopped",
182 })
183 }
184}
185
186/// A managed agent's id, current [`RuntimeHealth`], and endpoint.
187#[derive(Debug, Clone)]
188pub struct RuntimeStatus {
189 /// The agent's id.
190 pub id: AgentId,
191 /// Liveness at the time [`list`](AgentRuntime::list) was called.
192 pub health: RuntimeHealth,
193 /// The endpoint the instance serves on.
194 pub endpoint: String,
195}
196
197/// The outcome of asking a backend what it is already running
198/// ([`AgentRuntime::recover`], [`ControlPlane::recover`](crate::ControlPlane::recover)).
199///
200/// The two cases are kept apart because "nothing to adopt" and "this backend
201/// cannot tell you" lead to opposite conclusions, and conflating them is exactly
202/// how a restarted control plane comes to report an empty fleet while the agents
203/// are still up. `T` is whatever the layer hands back — [`AgentId`]s from the
204/// port, richer status from the service.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum Recovered<T> {
207 /// The backend outlives this process and these are the instances it holds
208 /// (possibly none — that answer is trustworthy).
209 Adopted(Vec<T>),
210 /// The backend cannot outlive this process, so whatever it was managing is
211 /// gone and nothing could be adopted. Not an error, and not "none": an
212 /// operator seeing this knows the fleet is unmanaged, not empty.
213 Ephemeral,
214}
215
216impl<T> Recovered<T> {
217 /// The adopted instances, or an empty slice for an ephemeral backend.
218 ///
219 /// For callers that only need to iterate. Anything that *reports* to a human
220 /// should match instead, so the two cases stay distinguishable.
221 pub fn adopted(&self) -> &[T] {
222 match self {
223 Recovered::Adopted(items) => items,
224 Recovered::Ephemeral => &[],
225 }
226 }
227}
228
229/// Errors a runtime operation can return.
230#[derive(Debug, Error)]
231pub enum RuntimeError {
232 /// No agent with this id has been provisioned.
233 #[error("no agent provisioned with id '{0}'")]
234 NotFound(AgentId),
235
236 /// `start` was called on an agent that is already running.
237 #[error("agent '{0}' is already running")]
238 AlreadyRunning(AgentId),
239
240 /// The agent process could not be spawned.
241 #[error("failed to spawn agent '{id}': {source}")]
242 Spawn {
243 /// The agent that failed to start.
244 id: AgentId,
245 /// The underlying spawn error.
246 #[source]
247 source: std::io::Error,
248 },
249
250 /// The agent's config could not be loaded while building its spec.
251 #[error("invalid agent config: {0}")]
252 Config(String),
253
254 /// The runtime backend (container engine, etc.) reported a failure — a
255 /// non-zero `docker`/`podman` exit, or the engine binary being unavailable.
256 #[error("runtime backend error: {0}")]
257 Backend(String),
258
259 /// The config references environment variables the operator has not
260 /// permitted deployed agents to read. See [`EnvAllowlist`].
261 #[error(
262 "agent config references environment variables that are not allowed: {0} \
263 (permit them with `--allow-env`)"
264 )]
265 DisallowedEnv(String),
266
267 /// This backend cannot serve the operation at all — as distinct from trying
268 /// and failing ([`Backend`](Self::Backend)). Carries what would have to
269 /// change, because "not supported" on its own leaves the operator with
270 /// nowhere to go.
271 #[error("{operation} is not available on this runtime: {reason}")]
272 Unsupported {
273 /// The operation that was asked for, e.g. `"logs"`.
274 operation: &'static str,
275 /// Why this backend cannot serve it, and what would make it able to.
276 reason: String,
277 },
278}
279
280/// The capability the platform needs to *run* agents: provision, start, stop,
281/// health-check, and list managed instances. One trait per capability (hex rule
282/// 2); implemented by [`LocalProcessRuntime`] today, a container or
283/// control-plane adapter later.
284#[async_trait]
285pub trait AgentRuntime: Send + Sync {
286 /// Register an agent to be run, without starting it. Returns its [`AgentId`].
287 async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError>;
288
289 /// Adopt the instances this backend is already running, so a restarted
290 /// supervisor manages the fleet it left behind instead of a blank slate.
291 ///
292 /// Every adapter must answer, and the answer must be honest — there is
293 /// deliberately no default. A backend whose state dies with this process
294 /// returns [`Recovered::Ephemeral`] rather than an empty list, because a
295 /// caller cannot otherwise tell "nothing was running" from "I have no idea
296 /// what is running", and only the first of those is safe to report as an
297 /// empty fleet.
298 ///
299 /// Call it once at startup, before serving. Adoption is expected to be
300 /// idempotent: re-adopting an instance already in the map is not an error.
301 async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError>;
302
303 /// Start a provisioned agent. [`RuntimeError::AlreadyRunning`] if it is
304 /// already live, [`RuntimeError::NotFound`] if it was never provisioned.
305 async fn start(&self, id: &AgentId) -> Result<(), RuntimeError>;
306
307 /// Stop a running agent. Idempotent: stopping an already-stopped agent is
308 /// `Ok`. [`RuntimeError::NotFound`] if it was never provisioned.
309 async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError>;
310
311 /// Report an agent's current [`RuntimeHealth`].
312 async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError>;
313
314 /// List every provisioned agent with its current status.
315 async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError>;
316
317 /// The agent's captured output, oldest line first, limited to the last
318 /// `tail` lines when given.
319 ///
320 /// This is the one question an operator asks that health cannot answer:
321 /// [`Unhealthy`](RuntimeHealth::Unhealthy) says the card probe is failing,
322 /// not *why*, and the why is in the agent's own log. Supervising something
323 /// without being able to read what it printed makes every failure a matter
324 /// of guessing.
325 ///
326 /// Not every backend can capture output, so an adapter that cannot must say
327 /// so with [`RuntimeError::Unsupported`] rather than return an empty list —
328 /// "nothing was logged" and "I do not keep logs" send an operator to very
329 /// different places. Same reasoning as [`Recovered::Ephemeral`], and the
330 /// reason there is deliberately no default implementation.
331 async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError>;
332}
333
334/// Keep the last `tail` lines of `text`, oldest first.
335///
336/// Shared by the adapters that get a blob back (a file, an engine's `logs`
337/// output) rather than a line stream. `None` keeps everything.
338pub(crate) fn tail_lines(text: &str, tail: Option<usize>) -> Vec<String> {
339 let lines = text.lines();
340 match tail {
341 // `skip` rather than collect-then-truncate: the whole point is not to
342 // hand the caller more than it asked for.
343 Some(n) => {
344 let total = text.lines().count();
345 lines
346 .skip(total.saturating_sub(n))
347 .map(String::from)
348 .collect()
349 }
350 None => lines.map(String::from).collect(),
351 }
352}
353
354#[cfg(test)]
355mod tail_tests {
356 use super::tail_lines;
357
358 const LOG: &str = "first\nsecond\nthird\n";
359
360 #[test]
361 fn no_limit_keeps_every_line_oldest_first() {
362 assert_eq!(tail_lines(LOG, None), ["first", "second", "third"]);
363 }
364
365 #[test]
366 fn a_limit_keeps_the_newest_lines() {
367 assert_eq!(tail_lines(LOG, Some(2)), ["second", "third"]);
368 assert_eq!(tail_lines(LOG, Some(1)), ["third"]);
369 }
370
371 /// Asking for more than there is returns what there is — not an error, and
372 /// not padding.
373 #[test]
374 fn a_limit_larger_than_the_log_is_the_whole_log() {
375 assert_eq!(tail_lines(LOG, Some(99)), ["first", "second", "third"]);
376 }
377
378 #[test]
379 fn empty_input_and_zero_tail_are_both_empty() {
380 assert!(tail_lines("", None).is_empty());
381 assert!(tail_lines(LOG, Some(0)).is_empty());
382 }
383}
384
385#[cfg(test)]
386mod allowlist_tests {
387 use super::*;
388
389 const CONFIG: &str = r#"
390 [agent]
391 name = "Leaky"
392 description = "${AWS_SECRET_ACCESS_KEY}"
393
394 [handler.llm]
395 api_key = "${OPENROUTER_API_KEY}"
396 "#;
397
398 #[test]
399 fn deny_all_rejects_every_reference() {
400 let err = EnvAllowlist::deny_all().check(CONFIG).unwrap_err();
401 let msg = err.to_string();
402 assert!(msg.contains("AWS_SECRET_ACCESS_KEY"), "{msg}");
403 assert!(msg.contains("OPENROUTER_API_KEY"), "{msg}");
404 }
405
406 #[test]
407 fn partial_allowlist_still_rejects_and_names_only_the_offender() {
408 let err = EnvAllowlist::new(["OPENROUTER_API_KEY"])
409 .check(CONFIG)
410 .unwrap_err();
411 let msg = err.to_string();
412 assert!(msg.contains("AWS_SECRET_ACCESS_KEY"), "{msg}");
413 assert!(
414 !msg.contains("OPENROUTER_API_KEY"),
415 "an allowed var must not be reported as disallowed: {msg}"
416 );
417 }
418
419 #[test]
420 fn fully_allowed_config_yields_the_vars_to_inject() {
421 let allowed =
422 EnvAllowlist::new(["AWS_SECRET_ACCESS_KEY", "OPENROUTER_API_KEY", "UNUSED_VAR"]);
423 // Only what the config actually references — not the whole allowlist.
424 assert_eq!(
425 allowed.check(CONFIG).unwrap(),
426 ["AWS_SECRET_ACCESS_KEY", "OPENROUTER_API_KEY"]
427 );
428 }
429
430 #[test]
431 fn host_is_adapter_owned_and_neither_checked_nor_injected() {
432 // `HOST` passes deny-all *and* stays out of the injection list.
433 assert!(
434 EnvAllowlist::deny_all()
435 .check(r#"host = "${HOST}""#)
436 .unwrap()
437 .is_empty()
438 );
439 }
440
441 #[test]
442 fn config_with_no_references_passes_deny_all() {
443 assert!(
444 EnvAllowlist::deny_all()
445 .check(r#"name = "plain""#)
446 .unwrap()
447 .is_empty()
448 );
449 }
450}