Skip to main content

agentd/runtime/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **agentd 2.0 runtime** (RFC 0026): the supervisor's event loop over
3//! durable state, the turn workers it spawns, and the lifecycle policy. Built
4//! beside the 1.x mode drivers and selected by a v2 configuration document;
5//! the 1.x drivers are removed at the P5 cut-over.
6//!
7//! Startup (RFC 0026 §8): parse+validate config → connect MCP servers
8//! (contained failures) → connect the store or refuse → restore → build the
9//! registry (validate overrides) → discover skills → resolve the instruction
10//! → load workflows → arm start nodes (`once` fires unless a live run was
11//! restored) → re-spawn pending subagents → `proc.ready` → the loop.
12
13#[cfg(feature = "a2a")]
14pub mod a2a_server;
15pub mod activity; // live per-turn activity for the display clients (RFC 0032 §17)
16pub mod artifacts;
17pub mod audit;
18pub mod children;
19pub mod events;
20#[cfg(feature = "exec")]
21pub mod exec; // guarded local command runner behind the `exec` tool (RFC 0028; default-OFF)
22pub mod goal;
23pub mod http_node;
24pub mod human; // human-in-the-loop: ask_human gates + fallbacks (RFC 0032 §16)
25pub mod nested;
26pub mod reactor;
27pub mod reload;
28pub mod starts;
29pub mod steps;
30pub mod subagents;
31pub mod timers;
32pub mod tools;
33pub mod turns;
34pub mod waits;
35#[cfg(feature = "a2a")]
36pub mod webhooks;
37pub mod worker;
38
39pub use reactor::Runtime;
40
41use crate::config::v2::{Loaded, StoreKind};
42use crate::context::memory::Memory;
43use crate::context::{Contexts, skills, tokens};
44use crate::engine::run::StepStatus;
45use crate::governor::Governor;
46use crate::mcp::client::McpClient;
47use crate::obs::log::{Comp, Level, LogCtx, Logger};
48use crate::registry::{Registry, ServerTools};
49use crate::state::{Durable, Kind, Policy, now_ms};
50use serde_json::{Value, json};
51use std::collections::BTreeMap;
52use std::sync::Arc;
53use std::time::{Duration, Instant};
54
55/// Run the 2.0 runtime for a loaded v2 configuration. Returns the exit code.
56pub fn run(loaded: &Loaded, args: &[String], env: &[(String, String)]) -> i32 {
57    let settings = loaded.settings.clone();
58    let instance = settings.instance_name();
59    let run_id = match settings.lifecycle.run_id.clone() {
60        Some(r) => r,
61        None => crate::state::ulid::new(),
62    };
63    let trace = crate::obs::trace::resolve(&run_id, settings.observability.traceparent.as_deref());
64    let level = settings
65        .observability
66        .log_level
67        .as_deref()
68        .and_then(Level::parse)
69        .unwrap_or(Level::Info);
70    let log = Logger::new(
71        LogCtx {
72            run_id: run_id.clone(),
73            agent_id: "sup".into(),
74            agent_path: "0".into(),
75            comp: Comp::Supervisor,
76            pid: std::process::id(),
77            trace_id: Some(trace.trace_id.clone()),
78        },
79        level,
80    )
81    .with_content(settings.observability.log_content);
82    log.info("proc.start", json!({"version": crate::VERSION, "runtime": "2.0", "instance": instance, "config_files": loaded.files.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>()}));
83    for w in &loaded.warnings {
84        log.warn("config.warning", json!({"warning": w}));
85    }
86    crate::signals::install();
87    crate::supervisor::reap::set_child_subreaper();
88    let envmap = |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
89
90    // Outbound trust anchor.
91    #[cfg(feature = "tls")]
92    if let Some(path) = settings.security.tls_ca.as_deref()
93        && let Err(e) = std::fs::read(path).and_then(|pem| crate::net::tls::install_extra_ca(&pem))
94    {
95        log.error(
96            "proc.exit",
97            json!({"code": crate::exit::USAGE, "err": format!("security.tls_ca {path}: {e}")}),
98        );
99        return crate::exit::USAGE;
100    }
101    // AAuth identity (RFC 0023) — signs outbound MCP requests tree-wide.
102    #[cfg(feature = "aauth")]
103    if let Some(a) = &settings.security.aauth {
104        let v1 = crate::config::AAuthSettings {
105            provider: a.provider.clone(),
106            key_file: a
107                .key_file
108                .clone()
109                .unwrap_or_else(|| "/var/lib/agentd/aauth-key".into()),
110            enrollment_token: a.enroll_token.as_ref().map(|s| s.0.clone()),
111            enroll_assertion_file: a.enroll_assertion_file.clone(),
112            person_server: a.person_server.clone(),
113        };
114        if let Err(e) = crate::aauth::setup(&v1, Duration::from_secs(30)) {
115            log.error(
116                "proc.exit",
117                json!({"code": crate::exit::USAGE, "err": format!("aauth: {e}")}),
118            );
119            return crate::exit::USAGE;
120        }
121    }
122
123    // Resource containment (RFC 0009 §cgroup): arm the process-tree cgroup so
124    // each spawned child (turn workers + subagents) is placed in its own leaf
125    // with the configured `memory.max`/`pids.max`, and gets `cgroup.kill` atomic
126    // teardown. A no-op unless `security.cgroup.spec` is set.
127    #[cfg(unix)]
128    if settings.security.cgroup.spec.is_some() {
129        let c = &settings.security.cgroup;
130        if let Some(configured) = crate::supervisor::cgroup::configure(
131            c.spec.as_deref(),
132            c.memory_max.as_deref(),
133            c.pids_max.as_deref(),
134        ) {
135            log.info("cgroup.armed", json!({"parent": configured.parent.display().to_string(), "limits_unavailable": configured.limits_unavailable}));
136        }
137    }
138
139    // Intelligence.
140    let intel_uri = settings.intelligence.endpoint_list().unwrap_or_default();
141    let intel_token = match resolve_intel_token(&settings, &envmap) {
142        Ok(t) => t,
143        Err(e) => {
144            log.error("proc.exit", json!({"code": crate::exit::USAGE, "err": e}));
145            return crate::exit::USAGE;
146        }
147    };
148    let model = settings.intelligence.model.clone().unwrap_or_default();
149    // RFC 0031: resolved `intelligence.headers` (per-dial) + an optional OAuth
150    // credential provider (device-login bearer, refreshing).
151    let intel_headers: Vec<(String, String)> = settings
152        .intelligence
153        .headers
154        .iter()
155        .filter_map(|(k, v)| {
156            crate::sec::secret::resolve(v, &envmap)
157                .ok()
158                .map(|r| (k.clone(), r))
159        })
160        .collect();
161    let intel_bearer = intel_bearer_provider(&settings);
162
163    // MCP servers (contained failures: a down server is logged; tools that need
164    // it are unavailable; the store server must be up).
165    let mut mcp: BTreeMap<String, Arc<McpClient>> = BTreeMap::new();
166    let mut mcp_specs = BTreeMap::new();
167    let mut server_tools: Vec<ServerTools> = Vec::new();
168    let mcp_timeout = settings
169        .mcp
170        .default_timeout
171        .map(|d| d.0)
172        .unwrap_or(Duration::from_secs(60));
173    for s in &settings.mcp.servers {
174        let spec = match s.to_spec() {
175            Ok(sp) => sp,
176            Err(e) => {
177                log.error("proc.exit", json!({"code": crate::exit::USAGE, "err": e}));
178                return crate::exit::USAGE;
179            }
180        };
181        let per_timeout = s.timeout.map(|d| d.0).unwrap_or(mcp_timeout);
182        match crate::mcp::from_spec(&spec, per_timeout).and_then(|mut c| c.initialize().map(|()| c))
183        {
184            Ok(mut c) => {
185                let mut meta = json!({"agent/run_id": run_id, "agent/instance": instance});
186                meta["traceparent"] =
187                    crate::obs::trace::outbound_traceparent(&trace.trace_id).into();
188                c.set_tool_meta(meta);
189                let tools = c.list_tools().unwrap_or_default();
190                log.info(
191                    "mcp.connect",
192                    json!({"server": s.name, "tools": tools.len()}),
193                );
194                server_tools.push(ServerTools {
195                    name: s.name.clone(),
196                    ns: s.ns.clone(),
197                    tags: spec.tags.clone(),
198                    tools,
199                });
200                mcp.insert(s.name.clone(), Arc::new(c));
201            }
202            Err(e) => {
203                log.warn(
204                    "mcp.connect.fail",
205                    json!({"server": s.name, "err": e.to_string()}),
206                );
207                crate::obs::metrics::record_mcp_connect_failure(&s.name);
208            }
209        }
210        mcp_specs.insert(s.name.clone(), spec);
211    }
212
213    // The store (RFC 0025). `none` ⇒ an in-process store for a job-shaped
214    // instance: a long-lived one either defaulted to `file` (RFC 0033 §5) or
215    // asked for `none` in writing, which validation already refused.
216    let store = match settings.store.kind {
217        StoreKind::None => {
218            log.warn(
219                "store.none",
220                json!({"note": "no durable store: state lives in this process only (job shape)"}),
221            );
222            Arc::new(crate::store::memory::MemoryStore::new()) as crate::store::SharedStore
223        }
224        _ => {
225            let mcp_ref = mcp.clone();
226            match crate::store::open(&settings.store, &|name: &str| {
227                mcp_ref
228                    .get(name)
229                    .map(|c| c.clone() as Arc<dyn crate::store::mcp::McpCall>)
230            }) {
231                Ok(Some(s)) => s,
232                Ok(None) => Arc::new(crate::store::memory::MemoryStore::new()),
233                Err(e) => {
234                    log.error("proc.exit", json!({"code": crate::exit::MCP_REQUIRED_DOWN, "err": format!("store: {e}")}));
235                    return crate::exit::MCP_REQUIRED_DOWN;
236                }
237            }
238        }
239    };
240    let durable = Durable::new(
241        store,
242        settings.store.prefix(),
243        &instance,
244        Policy::from_settings(&settings.store),
245        Some(log.clone()),
246    );
247
248    // Restore (RFC 0025 §6).
249    let restored = match durable.restore() {
250        Ok(r) => r,
251        Err(e) => {
252            log.error(
253                "proc.exit",
254                json!({"code": crate::exit::MCP_REQUIRED_DOWN, "err": format!("restore: {e}")}),
255            );
256            return crate::exit::MCP_REQUIRED_DOWN;
257        }
258    };
259    // The file store, named out loud (RFC 0033 §5.1). Durability is a property
260    // of the DIRECTORY, not of agentd: on a mounted volume this survives
261    // anything, on a container's writable layer it survives a restart of this
262    // process and not a reschedule. A store that implies more than it delivers
263    // is worse than the exit 2 it replaced, so the path, the life we are in and
264    // whether it was chosen or defaulted are all on one line. Logged after
265    // `restore` because that is where the manifest's `generation` becomes known
266    // (RFC 0025 §6) — a fresh instance has no manifest and is generation 1.
267    if settings.store.kind == StoreKind::File {
268        let root = crate::config::v2::file_store_root(&settings.store);
269        log.info(
270            "store.file",
271            json!({
272                "path": root.display().to_string(),
273                "generation": restored.manifest.as_ref().map(|m| m.generation).unwrap_or(1),
274                // `store.kind` absent from the effective document (files ← env ←
275                // flags) is exactly what `load` defaulted to `file`.
276                "defaulted": loaded.doc.pointer("/store/kind").is_none(),
277                "msg": "durable state is on the local filesystem; it survives a restart of this process but not a move to another host — use store.kind mcp|http for a fleet",
278            }),
279        );
280    }
281
282    // Registry (RFC 0028): overrides validated against the connected servers.
283    let registry = match Registry::build(&settings, &server_tools) {
284        Ok(r) => r,
285        Err(errs) => {
286            for e in &errs {
287                log.error("config.invalid", json!({"error": e}));
288            }
289            log.error(
290                "proc.exit",
291                json!({"code": crate::exit::USAGE, "err": "tool registry"}),
292            );
293            return crate::exit::USAGE;
294        }
295    };
296    for w in &registry.warnings {
297        log.warn("registry.warning", json!({"warning": w}));
298    }
299
300    // Skills (RFC 0028 §7).
301    let mut catalogue = skills::Catalogue::new(
302        settings
303            .skills
304            .reference_prefix
305            .as_deref()
306            .unwrap_or(skills::DEFAULT_PREFIX),
307        settings.skills.max_bytes.unwrap_or(32_768) as usize,
308    );
309    for src in &settings.skills.sources {
310        match mcp.get(&src.server) {
311            Some(c) => {
312                let mode = match src.discover {
313                    crate::config::v2::Discover::Prompts => skills::Discover::Prompts,
314                    crate::config::v2::Discover::Resources => skills::Discover::Resources,
315                    crate::config::v2::Discover::Auto => skills::Discover::Auto,
316                };
317                let found = catalogue.discover(&**c, mode, src.filter.as_deref());
318                log.info(
319                    "skills.discovered",
320                    json!({"server": src.server, "count": found.len(), "skills": found}),
321                );
322            }
323            None => log.warn("skills.source.unavailable", json!({"server": src.server})),
324        }
325    }
326
327    // Channels.
328    let (events_tx, events_rx) = std::sync::mpsc::channel();
329    let (child_tx, child_rx) = std::sync::mpsc::channel();
330    let (reap_tx, reap_rx) = std::sync::mpsc::channel();
331    let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("agentd"));
332
333    let model_window = settings.context.model_window.unwrap_or_else(|| {
334        if model.is_empty() {
335            tokens::DEFAULT_MODEL_WINDOW
336        } else {
337            tokens::window_for_model(&model)
338        }
339    });
340    let mut rt = Runtime {
341        instance: instance.clone(),
342        run_id: run_id.clone(),
343        durable,
344        mcp,
345        mcp_specs,
346        registry,
347        contexts: Contexts::new(model_window),
348        memory: Memory::new(
349            settings.memory.max_value_bytes.unwrap_or(65_536) as usize,
350            settings.memory.list_default_limit.unwrap_or(100) as usize,
351        ),
352        artifacts: artifacts::Artifacts::new(),
353        skills: catalogue,
354        governor: Governor::new(&settings.intelligence.budget),
355        workflows: BTreeMap::new(),
356        runs: BTreeMap::new(),
357        children: children::Children::new(exe, child_tx, reap_tx),
358        timers: timers::Timers::new(),
359        events_rx,
360        events_tx,
361        child_rx,
362        reap_rx,
363        pending: Vec::new(),
364        turn_queue: Default::default(),
365        staged_turns: BTreeMap::new(),
366        inbox_queue: Default::default(),
367        subagents: BTreeMap::new(),
368        instruction: reactor::Instruction {
369            text: String::new(),
370            source: "static",
371            uri: None,
372            server: None,
373            version: 1,
374        },
375        job_shape: false,
376        exit: None,
377        draining: false,
378        paused: false,
379        drain_started: None,
380        drain_reason: String::new(),
381        idle_since: None,
382        intel_uri,
383        intel_token,
384        intel_headers,
385        intel_bearer,
386        model,
387        trace_id: Some(trace.trace_id.clone()),
388        started: Instant::now(),
389        seq: 0,
390        counters: Default::default(),
391        job_runs: Vec::new(),
392        executing: BTreeMap::new(),
393        last_manifest_flush: Instant::now(),
394        goal_judge_at: None,
395        #[cfg(feature = "a2a")]
396        tasks: BTreeMap::new(),
397        #[cfg(feature = "a2a")]
398        event_to_task: BTreeMap::new(),
399        #[cfg(feature = "a2a")]
400        #[cfg(feature = "a2a")]
401        a2a_feed: None,
402        #[cfg(feature = "a2a")]
403        a2a_pairing: None,
404        #[cfg(feature = "a2a")]
405        reserved_task_id: None,
406        #[cfg(feature = "a2a")]
407        a2a_sink: None,
408        #[cfg(feature = "a2a")]
409        a2a_listener: None,
410        activity: BTreeMap::new(),
411        last_root_reply: None,
412        #[cfg(feature = "a2a")]
413        feed_marks: BTreeMap::new(),
414        #[cfg(feature = "a2a")]
415        feed_last: Instant::now(),
416        #[cfg(feature = "a2a")]
417        webhook_callbacks: std::sync::Arc::new(std::sync::Mutex::new(
418            std::collections::HashMap::new(),
419        )),
420        #[cfg(feature = "a2a")]
421        webhook_sync: std::collections::HashMap::new(),
422        settings_doc: loaded.doc.clone(),
423        args: args.to_vec(),
424        env: env.to_vec(),
425        pinned: BTreeMap::new(),
426        recent_signals: BTreeMap::new(),
427        settings,
428        log: log.clone(),
429    };
430
431    // Adopt the restored state.
432    let lost_ctx = rt.contexts.restore(restored.of(Kind::Context));
433    if !lost_ctx.is_empty() {
434        log.warn("restore.context.lost", json!({"ids": lost_ctx}));
435    }
436    rt.timers.restore(restored.timers());
437    rt.artifacts.restore(restored.of(Kind::Artifact));
438    let mut replayed: Vec<(String, String)> = Vec::new();
439    for env in restored.of(Kind::Run) {
440        match serde_json::from_value::<crate::engine::RunState>(env.state.clone()) {
441            Ok(mut r) => {
442                r.dirty = false;
443                if !r.status.is_terminal() {
444                    // Replay policy (RFC 0027 §7): a `running` step is re-executed
445                    // (same idempotency key); a suspended step keeps its wait.
446                    for (id, st) in r.steps.iter_mut() {
447                        if st.status == StepStatus::Running {
448                            log.info(
449                                "restore.step.replay",
450                                json!({"run": r.id, "step": id, "attempt": st.attempt}),
451                            );
452                            st.status = StepStatus::Pending;
453                            st.worker = None;
454                            // The step's `on_replay` policy is applied in a
455                            // second pass: the definitions are not loaded yet
456                            // here, and the policy lives in the definition.
457                            replayed.push((r.id.clone(), id.clone()));
458                        }
459                    }
460                    r.status = crate::engine::RunStatus::Running;
461                    r.dirty = true;
462                }
463                rt.runs.insert(r.id.clone(), r);
464            }
465            Err(e) => log.warn(
466                "restore.run.corrupt",
467                json!({"id": env.id, "err": e.to_string()}),
468            ),
469        }
470    }
471    for env in restored.of(Kind::Subagent) {
472        match serde_json::from_value::<reactor::SubagentRecord>(env.state.clone()) {
473            Ok(s) => {
474                rt.subagents.insert(s.handle.clone(), s);
475            }
476            Err(e) => log.warn(
477                "restore.subagent.corrupt",
478                json!({"id": env.id, "err": e.to_string()}),
479            ),
480        }
481    }
482    #[cfg(feature = "a2a")]
483    rt.restore_a2a_tasks(restored.of(Kind::Task));
484    if let Some(m) = &restored.manifest {
485        rt.governor.restore(&m.budget, now_ms());
486    }
487    for ev in restored.inbox_pending() {
488        rt.inbox_queue.push_back(ev);
489    }
490    if restored.manifest.is_some() {
491        log.info("restore.adopted", json!({"runs": rt.runs.len(), "contexts": rt.contexts.len(), "subagents": rt.subagents.len(), "timers": rt.timers.len(), "artifacts": rt.artifacts.len(), "inbox_pending": rt.inbox_queue.len(), "lost": restored.lost.len()}));
492        // Audit the restore — a durable-state generation adoption (plan §3.11:
493        // restore is audited; `lost` entities are recorded).
494        rt.audit(audit::AuditEvent {
495            action: "restore",
496            target: json!({"runs": rt.runs.len(), "subagents": rt.subagents.len(), "inbox_pending": rt.inbox_queue.len(), "lost": restored.lost.len()}),
497            outcome: if restored.lost.is_empty() { "restored" } else { "restored_with_loss" },
498            principal: Some("system"),
499            role: Some("system"),
500            request_id: None,
501        });
502    }
503
504    // The instruction (RFC 0028 §3): static text or a resource (read + subscribe).
505    if let Some(text) = rt.settings.agent.instruction.clone() {
506        if crate::config::v2::looks_like_resource_uri(&text) {
507            match rt.subscribe_instruction(&text) {
508                Ok(()) => {}
509                Err(e) => {
510                    log.error("proc.exit", json!({"code": crate::exit::MCP_REQUIRED_DOWN, "err": format!("agent.instruction {text}: {e}")}));
511                    return crate::exit::MCP_REQUIRED_DOWN;
512                }
513            }
514        } else {
515            rt.instruction.text = text;
516        }
517    }
518
519    if let Err(errs) = rt.load_workflows() {
520        for e in &errs {
521            log.error("config.invalid", json!({"error": e}));
522        }
523        log.error(
524            "proc.exit",
525            json!({"code": crate::exit::USAGE, "err": "workflow definitions"}),
526        );
527        return crate::exit::USAGE;
528    }
529    // Workflows (RFC 0027) — refused definitions are a config error.
530    // `on_replay` was published in the JSON Schema, documented, and read by
531    // nothing: every in-flight step was re-executed on restore regardless. Now
532    // the declared policy decides. `retry` (the default) keeps the old
533    // behaviour, so this only changes runs that asked for something else.
534    if !replayed.is_empty() {
535        let policies: Vec<(String, String, crate::engine::model::OnReplay)> = replayed
536            .iter()
537            .filter_map(|(rid, sid)| {
538                let wf_name = rt.runs.get(rid)?.workflow.clone();
539                let step = rt.workflows.get(&wf_name)?.steps.get(sid)?;
540                Some((rid.clone(), sid.clone(), step.on_replay))
541            })
542            .collect();
543        for (rid, sid, policy) in policies {
544            match policy {
545                crate::engine::model::OnReplay::Retry => {}
546                crate::engine::model::OnReplay::Skip => {
547                    if let Some(r) = rt.runs.get_mut(&rid) {
548                        r.end_step(&sid, StepStatus::Skipped, None, None);
549                    }
550                    rt.log.info(
551                        "restore.step.skipped",
552                        json!({"run": rid, "step": sid, "on_replay": "skip"}),
553                    );
554                }
555                crate::engine::model::OnReplay::Fail => {
556                    if let Some(r) = rt.runs.get_mut(&rid) {
557                        r.end_step(
558                            &sid,
559                            StepStatus::Failed,
560                            None,
561                            Some(
562                                "step was in flight when the process died and its \
563                                 on_replay policy is `fail`"
564                                    .into(),
565                            ),
566                        );
567                    }
568                    rt.log.warn(
569                        "restore.step.failed",
570                        json!({"run": rid, "step": sid, "on_replay": "fail"}),
571                    );
572                }
573            }
574        }
575    }
576    // RFC 0026 §8: `auto` ⇒ the job shape when there is no A2A listener and no
577    // long-lived start node; `idle` ⇒ job shape; `drained` ⇒ a daemon.
578    rt.job_shape = match rt.settings.lifecycle.run_until {
579        crate::config::v2::RunUntil::Drained => false,
580        crate::config::v2::RunUntil::Idle => true,
581        crate::config::v2::RunUntil::Auto => {
582            rt.settings.a2a.listen.is_none() && !rt.workflows.values().any(|w| w.is_long_lived())
583        }
584    };
585    // Restored `once` runs of a job count toward its exit code.
586    for r in rt.runs.values() {
587        if rt.job_shape
588            && rt
589                .workflows
590                .get(&r.workflow)
591                .and_then(|w| w.step(&r.start.node))
592                .is_some_and(|s| s.kind == "once")
593        {
594            rt.job_runs.push(r.id.clone());
595        }
596    }
597    // Skill references in the instruction preload into the root context.
598    let refs = rt.skills.references(&rt.instruction.text.clone());
599    if !refs.is_empty() {
600        let unknown = rt.preload_skills(crate::context::ROOT, &refs, None);
601        for u in unknown {
602            rt.note_root(format!(
603                "skill.unknown: {u:?} referenced by the instruction is not in the catalogue"
604            ));
605        }
606    }
607    // `lifecycle.watch_config`: a file change reloads like SIGHUP (RFC 0017 §5.2).
608    #[cfg(all(unix, feature = "config-watch"))]
609    if rt.settings.lifecycle.watch_config {
610        for (path, _) in &loaded.files {
611            crate::config::watch::spawn_config_watcher(std::path::Path::new(path), &log);
612        }
613    }
614    rt.arm_workflows();
615    rt.arm_long_lived_starts();
616    rt.arm_goal();
617    rt.respawn_restored_subagents();
618    // The A2A v2 transport (RFC 0029): the HTTPS listener for conversations,
619    // command DataParts, and durable tasks. A bind/TLS/principals failure at
620    // startup is fatal — the daemon cannot serve its only external channel.
621    #[cfg(feature = "a2a")]
622    if rt.settings.a2a.listen.is_some() {
623        let resolver = match crate::a2a::Resolver::build(&rt.settings.a2a, &envmap) {
624            Ok(r) => r,
625            Err(e) => {
626                log.error(
627                    "proc.exit",
628                    json!({"code": crate::exit::USAGE, "err": format!("a2a principals: {e}")}),
629                );
630                return crate::exit::USAGE;
631            }
632        };
633        let write_timeout = rt.settings.lifecycle.drain_timeout();
634        match a2a_server::spawn_a2a_listener(
635            &rt.settings.a2a,
636            &rt.settings.interface,
637            rt.events_tx.clone(),
638            resolver,
639            &envmap,
640            write_timeout,
641            log.clone(),
642        ) {
643            Ok(serving) => {
644                rt.a2a_feed = serving.feed;
645                rt.a2a_pairing = serving.pairing;
646                rt.a2a_sink = Some(std::sync::Arc::clone(&serving.listener.sink));
647                // The listener stops the moment it is dropped, so the runtime
648                // holds it for as long as it is serving.
649                rt.a2a_listener = Some(serving.listener);
650                // The interface debug reads tail the live log ring (RFC 0016
651                // §7.2 / RFC 0032 §5) — install it only when debug is on, so
652                // the default build keeps its zero-cost logging hot path.
653                if rt.settings.interface.enabled && rt.settings.interface.debug {
654                    let cap = rt
655                        .settings
656                        .observability
657                        .events_ring
658                        .map(|n| n as usize)
659                        .unwrap_or(crate::obs::log::EVENTS_RING_DEFAULT);
660                    crate::obs::log::install_event_ring(cap);
661                    log.info("interface.debug", json!({"events_ring": cap}));
662                }
663                // Publish restored tasks now that the shared view exists.
664                for id in rt.tasks.keys().cloned().collect::<Vec<_>>() {
665                    rt.task_sync(&id);
666                }
667            }
668            Err(e) => {
669                log.error(
670                    "proc.exit",
671                    json!({"code": crate::exit::USAGE, "err": format!("a2a listen: {e}")}),
672                );
673                return crate::exit::USAGE;
674            }
675        }
676    }
677    // The inbound webhook surface (RFC 0027): a dedicated HTTP listener that turns
678    // signed requests into workflow runs. A bind/TLS failure at startup is fatal —
679    // a daemon that can't serve its declared webhooks is misconfigured.
680    #[cfg(feature = "a2a")]
681    if rt.settings.webhooks.listen.is_some() {
682        let nodes: Vec<(String, String, serde_json::Map<String, serde_json::Value>)> = rt
683            .workflows
684            .values()
685            .flat_map(|wf| {
686                wf.steps
687                    .values()
688                    .filter(|s| s.kind == "webhook")
689                    .map(|s| (wf.name.clone(), s.id.clone(), s.spec.clone()))
690                    .collect::<Vec<_>>()
691            })
692            .collect();
693        let write_timeout = rt.settings.lifecycle.drain_timeout();
694        if let Err(e) = webhooks::spawn_webhook_listener(
695            &rt.settings.webhooks,
696            nodes,
697            rt.webhook_callbacks.clone(),
698            rt.events_tx.clone(),
699            &envmap,
700            write_timeout,
701            log.clone(),
702        ) {
703            log.error(
704                "proc.exit",
705                json!({"code": crate::exit::USAGE, "err": format!("webhooks listen: {e}")}),
706            );
707            return crate::exit::USAGE;
708        }
709    }
710    // Observability serving (plan §3.11): the Prometheus `/metrics` surface and
711    // the health-file heartbeat (RFC 0016 §10 fleet liveness), when configured.
712    #[cfg(feature = "metrics")]
713    if let Some(addr) = rt.settings.observability.metrics_addr.clone()
714        && let Err(e) = crate::obs::serve::spawn(&addr, log.clone())
715    {
716        log.warn(
717            "metrics.serve.fail",
718            json!({"addr": addr, "err": e.to_string()}),
719        );
720    }
721    if let Some(path) = rt.settings.observability.health_file.clone() {
722        crate::obs::health::spawn_writer(
723            std::path::PathBuf::from(path),
724            run_id.clone(),
725            "2.0".into(),
726            std::time::Duration::from_secs(10),
727        );
728    }
729    // OTLP logs export (plan §3.11, optional): mirror the JSON-lines log surface
730    // to `<endpoint>/v1/logs` when `observability.otel.logs` is on.
731    #[cfg(feature = "otel")]
732    if rt.settings.observability.otel.logs == Some(true)
733        && let Some(ep) = rt
734            .settings
735            .observability
736            .otel
737            .endpoint
738            .clone()
739            .or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
740    {
741        crate::obs::otel::arm_logs(&ep, "agentd", crate::VERSION);
742        log.info("otel.logs.armed", json!({"endpoint": ep}));
743    }
744    // `--prompt`: the task, delivered as a MESSAGE into the agent's root
745    // context — the same path an A2A message takes. Root scope is the point:
746    // the agent answers with its full tool surface, so a prompt may set the
747    // instance up (`workflow.create` a loop/schedule/subscribe) instead of
748    // only answering once. Whether the process then exits is the ordinary
749    // lifecycle question: `auto` stays up iff something long-lived is armed.
750    if let Some(prompt) = rt.settings.agent.prompt.clone()
751        && !prompt.trim().is_empty()
752        && let Err(err) = rt.accept_event(
753            events::kinds::A2A_MESSAGE,
754            Some("operator".into()),
755            json!({"text": prompt, "context_id": crate::context::ROOT}),
756        )
757    {
758        log.warn("prompt.reject", json!({"err": err}));
759    }
760    // A debug-only seam (`AGENTD_TEST_INBOX_FILE`): inject inbox events from a
761    // JSON file — the e2e suite's stand-in for the A2A server until P5.
762    #[cfg(any(feature = "internal-mocks", debug_assertions))]
763    if let Ok(path) = std::env::var("AGENTD_TEST_INBOX_FILE") {
764        match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string())) {
765            Ok(Value::Array(events)) => {
766                for e in events {
767                    let kind = e["kind"].as_str().unwrap_or(events::kinds::A2A_MESSAGE).to_string();
768                    let principal = e["principal"].as_str().map(str::to_string);
769                    let payload = e.get("payload").cloned().unwrap_or(Value::Null);
770                    if let Err(err) = rt.accept_event(&kind, principal, payload) {
771                        log.warn("test.inbox.reject", json!({"err": err}));
772                    }
773                }
774                let _ = std::fs::remove_file(&path);
775            }
776            other => log.warn("test.inbox.bad_file", json!({"path": path, "err": format!("{other:?}").chars().take(200).collect::<String>()})),
777        }
778    }
779    rt.checkpoint(true);
780    let code = rt.run_loop();
781    let _ = &rt.last_manifest_flush;
782    // A job-shaped run prints its result on stdout (the 1.x `once` contract).
783    if rt.job_shape
784        && let Some(out) = rt.job_output()
785    {
786        match out {
787            Value::String(s) => println!("{s}"),
788            Value::Null => {}
789            other => println!(
790                "{}",
791                serde_json::to_string_pretty(&other).unwrap_or_default()
792            ),
793        }
794    }
795    code
796}
797
798/// A static **capability document** for `--capabilities` (RFC 0015 §5.2):
799/// describes the configured 2.0 surface with **no side effects** — it does not
800/// connect to MCP servers, read secrets, or start the loop. It reflects the
801/// configuration (what the agent is set up to do), not live state.
802pub fn capabilities(loaded: &Loaded) -> Value {
803    const START_KINDS: &[&str] = &[
804        "once",
805        "manual",
806        "loop",
807        "schedule",
808        "subscribe",
809        "signal",
810        "event",
811        "a2a",
812    ];
813    let s = &loaded.settings;
814    let workflows: Vec<Value> = s
815        .workflows
816        .iter()
817        .map(|w| {
818            let starts: Vec<String> = w["steps"]
819                .as_object()
820                .map(|steps| steps.values().filter_map(|st| st["kind"].as_str()).filter(|k| START_KINDS.contains(k)).map(str::to_string).collect())
821                .unwrap_or_default();
822            json!({"name": w["name"].as_str().unwrap_or(""), "description": w.get("description").and_then(Value::as_str), "start_kinds": starts, "inputs_schema": w.get("inputs").is_some()})
823        })
824        .collect();
825    let a2a = s.a2a.listen.as_ref().map(|listen| {
826        let principals: Vec<Value> = s
827            .a2a
828            .principals
829            .iter()
830            .map(|p| json!({"role": format!("{:?}", p.role).to_lowercase(), "match": principal_match_desc(&p.matcher), "grants": p.grants}))
831            .collect();
832        let mut methods = vec![
833            "SendMessage",
834            "SendStreamingMessage",
835            "GetTask",
836            "CancelTask",
837            "ListTasks",
838            "SubscribeToTask",
839            "GetAgentCard",
840        ];
841        let mut command_ops = vec![
842            "status",
843            "config",
844            "workflow.run",
845            "workflow.status",
846            "workflow.cancel",
847            "workflow.signal",
848            "subagent.send",
849            "subagent.kill",
850            "subagent.status",
851            "plan.get",
852        ];
853        if s.interface.enabled {
854            methods.push("SubscribeToEvents");
855            command_ops.push("interface.info");
856            if s.interface.debug {
857                command_ops.extend(["conversation.get", "run.get", "debug.events"]);
858            }
859        }
860        json!({
861            "listen": listen,
862            "tls": s.a2a.tls.cert.is_some(),
863            "mtls": s.a2a.tls.client_ca.is_some(),
864            "bearer": s.a2a.bearer.is_some(),
865            "methods": methods,
866            "admin": ["a2a.drain", "a2a.lameduck", "a2a.cancel", "a2a.pause", "a2a.resume"],
867            "command_ops": command_ops,
868            "principals": principals,
869            "loopback_operator": s.a2a.principals.is_empty(),
870        })
871    });
872    json!({
873        "runtime": "2.0",
874        "version": crate::VERSION,
875        "agent": {"name": s.instance_name(), "instruction": s.agent.instruction.is_some(), "preflight": format!("{:?}", s.agent.preflight).to_lowercase()},
876        "intelligence": {"model": s.intelligence.model, "endpoints": s.intelligence.endpoints.len()},
877        "mcp_servers": s.mcp.servers.iter().map(|m| m.name.clone()).collect::<Vec<_>>(),
878        "internal_tools": crate::registry::internal::names(),
879        "tools": {"overrides": s.tools.overrides.keys().cloned().collect::<Vec<_>>(), "disabled": s.tools.disabled},
880        "workflows": workflows,
881        "knowledge": {"server": s.knowledge.server},
882        "search": {"server": s.search.server},
883        "skills": {"sources": s.skills.sources.len()},
884        "a2a": a2a,
885        "interface": {"enabled": s.interface.enabled, "debug": s.interface.debug, "origins": s.interface.origins.len(), "pairing": s.interface.pairing.enabled, "display": {"top": s.interface.display.top, "bottom": s.interface.display.bottom}},
886        "store": format!("{:?}", s.store.kind).to_lowercase(),
887        // For the file adapter the kind alone under-reports: what an operator
888        // actually gets depends on the directory it lands in, and on whether
889        // they chose it or the long-lived default did (RFC 0033 §5.1). Additive
890        // and `null` for every other adapter, so the `store` string above stays
891        // the stable answer to "which adapter".
892        "store_file": (s.store.kind == StoreKind::File).then(|| json!({
893            "path": crate::config::v2::file_store_root(&s.store).display().to_string(),
894            "defaulted": loaded.doc.pointer("/store/kind").is_none(),
895        })),
896        "lifecycle": {"run_until": format!("{:?}", s.lifecycle.run_until).to_lowercase(), "daemon": s.a2a.listen.is_some() || s.workflows.iter().any(|w| w["steps"].as_object().is_some_and(|st| st.values().any(|n| n["kind"].as_str().is_some_and(|k| matches!(k, "loop" | "schedule" | "subscribe" | "signal" | "event")))))},
897    })
898}
899
900/// A redacted description of a principal matcher (secrets never leak here).
901fn principal_match_desc(m: &crate::config::v2::PrincipalMatch) -> Value {
902    if m.any {
903        json!({"any": true})
904    } else if let Some(s) = &m.san {
905        json!({"san": s})
906    } else if let Some(s) = &m.sub {
907        json!({"sub": s})
908    } else if m.bearer_ref.is_some() {
909        json!({"bearer_ref": "***"})
910    } else if let Some(a) = &m.aauth_agent {
911        json!({"aauth_agent": a})
912    } else {
913        json!({})
914    }
915}
916
917/// Build the intelligence OAuth credential provider (RFC 0031 §7): a closure
918/// returning the current bearer (refreshing from the `agentd login intelligence`
919/// device-login cache). `None` when no oauth2 `intelligence.auth` is set (or
920/// without `--features oauth`), so the static `intelligence.token` path is
921/// byte-identical.
922fn intel_bearer_provider(
923    settings: &crate::config::v2::Settings,
924) -> Option<std::sync::Arc<dyn Fn() -> Option<String> + Send + Sync>> {
925    #[cfg(feature = "oauth")]
926    {
927        let auth = settings.intelligence.auth.as_ref()?;
928        let spec = auth.to_spec();
929        // SigV4 (`kind: aws`) is a per-request signature, not a bearer — the intel
930        // path has no generic signer hook yet, so it is a follow-up for LLM auth.
931        if spec.kind == "aws" {
932            return None;
933        }
934        // Build the provider's signer once (preserving the oauth2 in-memory
935        // refresh) and extract the bearer per LLM dial. Covers static / oauth2
936        // device-login / spiffe jwt — all bearer-style for intelligence.
937        let signer = crate::auth::device::signer_for(
938            &spec,
939            "intelligence",
940            std::time::Duration::from_secs(30),
941        )
942        .ok()??;
943        Some(std::sync::Arc::new(move || {
944            signer
945                .sign("POST", "", "", &[])
946                .into_iter()
947                .find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
948                .map(|(_, v)| v.strip_prefix("Bearer ").unwrap_or(&v).to_string())
949        }))
950    }
951    #[cfg(not(feature = "oauth"))]
952    {
953        let _ = settings;
954        None
955    }
956}
957
958impl Runtime {
959    /// The current intelligence bearer (RFC 0031): the OAuth provider's token
960    /// (refreshing) when an `intelligence.auth` oauth2 block is configured, else
961    /// the static `intelligence.token`. Resolved fresh at each subagent spawn so
962    /// a child rides a live token without its own refresh machinery.
963    pub(crate) fn current_intel_bearer(&self) -> Option<String> {
964        self.intel_bearer
965            .as_ref()
966            .and_then(|f| f())
967            .or_else(|| self.intel_token.clone())
968    }
969
970    /// The AWS SigV4 intelligence-auth spec (RFC 0031), when `intelligence.auth`
971    /// selects `kind: aws`. Threaded to subagents (which build the signer) and
972    /// used by the goal judge to SigV4-sign the LLM dial.
973    pub(crate) fn intel_aws_auth(&self) -> Option<crate::config::AuthSpec> {
974        let a = self.settings.intelligence.auth.as_ref()?;
975        (a.kind == crate::config::v2::AuthKind::Aws).then(|| a.to_spec())
976    }
977
978    /// The configured `intelligence.dialect` (RFC 0031 §8), threaded into a
979    /// child's spawn payload so it selects the same wire adapter. `None` ⇒
980    /// OpenAI-compatible.
981    pub(crate) fn intel_dialect(&self) -> Option<String> {
982        self.settings.intelligence.dialect.clone()
983    }
984}
985
986/// Resolve `intelligence.token` / `token_file` (secret refs, files).
987fn resolve_intel_token(
988    settings: &crate::config::v2::Settings,
989    env: &dyn Fn(&str) -> Option<String>,
990) -> Result<Option<String>, String> {
991    if let Some(t) = &settings.intelligence.token {
992        let resolved = crate::sec::secret::resolve(&t.0, env)
993            .map_err(|e| format!("intelligence.token: {e}"))?;
994        return Ok(Some(resolved));
995    }
996    if let Some(p) = &settings.intelligence.token_file {
997        return crate::sec::secret::read_token_file(p)
998            .map(Some)
999            .map_err(|e| format!("intelligence.token_file: {e}"));
1000    }
1001    // The v1 env conventions still apply inside the intel client (AGENT_INTELLIGENCE_TOKEN…).
1002    Ok(None)
1003}
1004
1005impl Runtime {
1006    /// Read + subscribe the instruction resource (RFC 0028 §3).
1007    pub(crate) fn subscribe_instruction(&mut self, uri: &str) -> Result<(), String> {
1008        let (server, res) = match uri.strip_prefix("mcp://").and_then(|r| r.split_once('/')) {
1009            Some((s, r)) => (Some(s.to_string()), r.to_string()),
1010            None => (None, uri.to_string()),
1011        };
1012        // Find the serving client.
1013        let candidates: Vec<(String, Arc<McpClient>)> = match &server {
1014            Some(s) => self
1015                .mcp
1016                .get(s)
1017                .map(|c| vec![(s.clone(), c.clone())])
1018                .unwrap_or_default(),
1019            None => self
1020                .mcp
1021                .iter()
1022                .map(|(n, c)| (n.clone(), c.clone()))
1023                .collect(),
1024        };
1025        let mut last_err = String::from("no connected MCP server serves it");
1026        for (name, c) in candidates {
1027            match c.read_resource(&res) {
1028                Ok(r) => {
1029                    let text = r.text();
1030                    if c.capabilities().supports_resources()
1031                        && let Err(e) = c.subscribe(&res)
1032                    {
1033                        self.log.warn(
1034                            "instruction.subscribe.fail",
1035                            json!({"server": name, "uri": res, "err": e.to_string()}),
1036                        );
1037                    }
1038                    let changed = self.instruction.text != text;
1039                    self.instruction = reactor::Instruction {
1040                        text,
1041                        source: "resource",
1042                        uri: Some(res.clone()),
1043                        server: Some(name.clone()),
1044                        version: self.instruction.version + u64::from(changed),
1045                    };
1046                    self.log.info("instruction.loaded", json!({"server": name, "uri": res, "bytes": self.instruction.text.len(), "version": self.instruction.version}));
1047                    return Ok(());
1048                }
1049                Err(e) => last_err = e.to_string(),
1050            }
1051        }
1052        Err(last_err)
1053    }
1054
1055    /// Drain MCP notifications: an updated instruction resource re-reads it
1056    /// and wakes the root (`instruction_updated`); `tools/list_changed` is
1057    /// noted (registry rebuild lands with the P5 reload choreography).
1058    pub(crate) fn poll_mcp_notifications(&mut self) {
1059        let mut updated_instruction = false;
1060        let mut tools_changed = Vec::new();
1061        let mut resource_updates: Vec<(String, String)> = Vec::new();
1062        for (name, c) in &self.mcp {
1063            for n in c.drain_notifications() {
1064                match n.method.as_str() {
1065                    ::mcp::wire::method::NOTIFY_RESOURCES_UPDATED => {
1066                        let uri = n
1067                            .params
1068                            .as_ref()
1069                            .and_then(|p| p.get("uri"))
1070                            .and_then(Value::as_str)
1071                            .unwrap_or("");
1072                        if self.instruction.uri.as_deref() == Some(uri)
1073                            && self.instruction.server.as_deref() == Some(name.as_str())
1074                        {
1075                            updated_instruction = true;
1076                        }
1077                        resource_updates.push((name.clone(), uri.to_string()));
1078                    }
1079                    ::mcp::wire::method::NOTIFY_TOOLS_LIST_CHANGED => {
1080                        tools_changed.push(name.clone())
1081                    }
1082                    _ => {}
1083                }
1084            }
1085        }
1086        if updated_instruction && let Some(uri) = self.instruction.uri.clone() {
1087            let full = match &self.instruction.server {
1088                Some(s) => format!("mcp://{s}/{uri}"),
1089                None => uri,
1090            };
1091            let before = self.instruction.version;
1092            if self.subscribe_instruction(&full).is_ok() && self.instruction.version != before {
1093                self.log.info(
1094                    "instruction.updated",
1095                    json!({"version": self.instruction.version}),
1096                );
1097                if self
1098                    .settings
1099                    .agent
1100                    .wake_on()
1101                    .contains(&crate::config::v2::WakeEvent::InstructionUpdated)
1102                {
1103                    self.note_root("instruction.updated: the instruction resource changed; re-read it with instruction.read".into());
1104                }
1105            }
1106        }
1107        for (server, uri) in resource_updates {
1108            self.on_resource_updated(&server, &uri); // `wait` steps
1109            self.on_subscribe_resource(&server, &uri); // `subscribe` start nodes
1110        }
1111        for s in tools_changed {
1112            self.log.info("mcp.tools_changed", json!({"server": s, "note": "registry rebuild lands with the P5 reload choreography"}));
1113        }
1114    }
1115}