Skip to main content

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}