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    for env in restored.of(Kind::Run) {
439        match serde_json::from_value::<crate::engine::RunState>(env.state.clone()) {
440            Ok(mut r) => {
441                r.dirty = false;
442                if !r.status.is_terminal() {
443                    // Replay policy (RFC 0027 §7): a `running` step is re-executed
444                    // (same idempotency key); a suspended step keeps its wait.
445                    for (id, st) in r.steps.iter_mut() {
446                        if st.status == StepStatus::Running {
447                            log.info(
448                                "restore.step.replay",
449                                json!({"run": r.id, "step": id, "attempt": st.attempt}),
450                            );
451                            st.status = StepStatus::Pending;
452                            st.worker = None;
453                        }
454                    }
455                    r.status = crate::engine::RunStatus::Running;
456                    r.dirty = true;
457                }
458                rt.runs.insert(r.id.clone(), r);
459            }
460            Err(e) => log.warn(
461                "restore.run.corrupt",
462                json!({"id": env.id, "err": e.to_string()}),
463            ),
464        }
465    }
466    for env in restored.of(Kind::Subagent) {
467        match serde_json::from_value::<reactor::SubagentRecord>(env.state.clone()) {
468            Ok(s) => {
469                rt.subagents.insert(s.handle.clone(), s);
470            }
471            Err(e) => log.warn(
472                "restore.subagent.corrupt",
473                json!({"id": env.id, "err": e.to_string()}),
474            ),
475        }
476    }
477    #[cfg(feature = "a2a")]
478    rt.restore_a2a_tasks(restored.of(Kind::Task));
479    if let Some(m) = &restored.manifest {
480        rt.governor.restore(&m.budget, now_ms());
481    }
482    for ev in restored.inbox_pending() {
483        rt.inbox_queue.push_back(ev);
484    }
485    if restored.manifest.is_some() {
486        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()}));
487        // Audit the restore — a durable-state generation adoption (plan §3.11:
488        // restore is audited; `lost` entities are recorded).
489        rt.audit(audit::AuditEvent {
490            action: "restore",
491            target: json!({"runs": rt.runs.len(), "subagents": rt.subagents.len(), "inbox_pending": rt.inbox_queue.len(), "lost": restored.lost.len()}),
492            outcome: if restored.lost.is_empty() { "restored" } else { "restored_with_loss" },
493            principal: Some("system"),
494            role: Some("system"),
495            request_id: None,
496        });
497    }
498
499    // The instruction (RFC 0028 §3): static text or a resource (read + subscribe).
500    if let Some(text) = rt.settings.agent.instruction.clone() {
501        if crate::config::v2::looks_like_resource_uri(&text) {
502            match rt.subscribe_instruction(&text) {
503                Ok(()) => {}
504                Err(e) => {
505                    log.error("proc.exit", json!({"code": crate::exit::MCP_REQUIRED_DOWN, "err": format!("agent.instruction {text}: {e}")}));
506                    return crate::exit::MCP_REQUIRED_DOWN;
507                }
508            }
509        } else {
510            rt.instruction.text = text;
511        }
512    }
513
514    // Workflows (RFC 0027) — refused definitions are a config error.
515    if let Err(errs) = rt.load_workflows() {
516        for e in &errs {
517            log.error("config.invalid", json!({"error": e}));
518        }
519        log.error(
520            "proc.exit",
521            json!({"code": crate::exit::USAGE, "err": "workflow definitions"}),
522        );
523        return crate::exit::USAGE;
524    }
525    // RFC 0026 §8: `auto` ⇒ the job shape when there is no A2A listener and no
526    // long-lived start node; `idle` ⇒ job shape; `drained` ⇒ a daemon.
527    rt.job_shape = match rt.settings.lifecycle.run_until {
528        crate::config::v2::RunUntil::Drained => false,
529        crate::config::v2::RunUntil::Idle => true,
530        crate::config::v2::RunUntil::Auto => {
531            rt.settings.a2a.listen.is_none() && !rt.workflows.values().any(|w| w.is_long_lived())
532        }
533    };
534    // Restored `once` runs of a job count toward its exit code.
535    for r in rt.runs.values() {
536        if rt.job_shape
537            && rt
538                .workflows
539                .get(&r.workflow)
540                .and_then(|w| w.step(&r.start.node))
541                .is_some_and(|s| s.kind == "once")
542        {
543            rt.job_runs.push(r.id.clone());
544        }
545    }
546    // Skill references in the instruction preload into the root context.
547    let refs = rt.skills.references(&rt.instruction.text.clone());
548    if !refs.is_empty() {
549        let unknown = rt.preload_skills(crate::context::ROOT, &refs, None);
550        for u in unknown {
551            rt.note_root(format!(
552                "skill.unknown: {u:?} referenced by the instruction is not in the catalogue"
553            ));
554        }
555    }
556    // `lifecycle.watch_config`: a file change reloads like SIGHUP (RFC 0017 §5.2).
557    #[cfg(all(unix, feature = "config-watch"))]
558    if rt.settings.lifecycle.watch_config {
559        for (path, _) in &loaded.files {
560            crate::config::watch::spawn_config_watcher(std::path::Path::new(path), &log);
561        }
562    }
563    rt.arm_workflows();
564    rt.arm_long_lived_starts();
565    rt.arm_goal();
566    rt.respawn_restored_subagents();
567    // The A2A v2 transport (RFC 0029): the HTTPS listener for conversations,
568    // command DataParts, and durable tasks. A bind/TLS/principals failure at
569    // startup is fatal — the daemon cannot serve its only external channel.
570    #[cfg(feature = "a2a")]
571    if rt.settings.a2a.listen.is_some() {
572        let resolver = match crate::a2a::Resolver::build(&rt.settings.a2a, &envmap) {
573            Ok(r) => r,
574            Err(e) => {
575                log.error(
576                    "proc.exit",
577                    json!({"code": crate::exit::USAGE, "err": format!("a2a principals: {e}")}),
578                );
579                return crate::exit::USAGE;
580            }
581        };
582        let write_timeout = rt.settings.lifecycle.drain_timeout();
583        match a2a_server::spawn_a2a_listener(
584            &rt.settings.a2a,
585            &rt.settings.interface,
586            rt.events_tx.clone(),
587            resolver,
588            &envmap,
589            write_timeout,
590            log.clone(),
591        ) {
592            Ok(serving) => {
593                rt.a2a_feed = serving.feed;
594                rt.a2a_pairing = serving.pairing;
595                rt.a2a_sink = Some(std::sync::Arc::clone(&serving.listener.sink));
596                // The listener stops the moment it is dropped, so the runtime
597                // holds it for as long as it is serving.
598                rt.a2a_listener = Some(serving.listener);
599                // The interface debug reads tail the live log ring (RFC 0016
600                // §7.2 / RFC 0032 §5) — install it only when debug is on, so
601                // the default build keeps its zero-cost logging hot path.
602                if rt.settings.interface.enabled && rt.settings.interface.debug {
603                    let cap = rt
604                        .settings
605                        .observability
606                        .events_ring
607                        .map(|n| n as usize)
608                        .unwrap_or(crate::obs::log::EVENTS_RING_DEFAULT);
609                    crate::obs::log::install_event_ring(cap);
610                    log.info("interface.debug", json!({"events_ring": cap}));
611                }
612                // Publish restored tasks now that the shared view exists.
613                for id in rt.tasks.keys().cloned().collect::<Vec<_>>() {
614                    rt.task_sync(&id);
615                }
616            }
617            Err(e) => {
618                log.error(
619                    "proc.exit",
620                    json!({"code": crate::exit::USAGE, "err": format!("a2a listen: {e}")}),
621                );
622                return crate::exit::USAGE;
623            }
624        }
625    }
626    // The inbound webhook surface (RFC 0027): a dedicated HTTP listener that turns
627    // signed requests into workflow runs. A bind/TLS failure at startup is fatal —
628    // a daemon that can't serve its declared webhooks is misconfigured.
629    #[cfg(feature = "a2a")]
630    if rt.settings.webhooks.listen.is_some() {
631        let nodes: Vec<(String, String, serde_json::Map<String, serde_json::Value>)> = rt
632            .workflows
633            .values()
634            .flat_map(|wf| {
635                wf.steps
636                    .values()
637                    .filter(|s| s.kind == "webhook")
638                    .map(|s| (wf.name.clone(), s.id.clone(), s.spec.clone()))
639                    .collect::<Vec<_>>()
640            })
641            .collect();
642        let write_timeout = rt.settings.lifecycle.drain_timeout();
643        if let Err(e) = webhooks::spawn_webhook_listener(
644            &rt.settings.webhooks,
645            nodes,
646            rt.webhook_callbacks.clone(),
647            rt.events_tx.clone(),
648            &envmap,
649            write_timeout,
650            log.clone(),
651        ) {
652            log.error(
653                "proc.exit",
654                json!({"code": crate::exit::USAGE, "err": format!("webhooks listen: {e}")}),
655            );
656            return crate::exit::USAGE;
657        }
658    }
659    // Observability serving (plan §3.11): the Prometheus `/metrics` surface and
660    // the health-file heartbeat (RFC 0016 §10 fleet liveness), when configured.
661    #[cfg(feature = "metrics")]
662    if let Some(addr) = rt.settings.observability.metrics_addr.clone()
663        && let Err(e) = crate::obs::serve::spawn(&addr, log.clone())
664    {
665        log.warn(
666            "metrics.serve.fail",
667            json!({"addr": addr, "err": e.to_string()}),
668        );
669    }
670    if let Some(path) = rt.settings.observability.health_file.clone() {
671        crate::obs::health::spawn_writer(
672            std::path::PathBuf::from(path),
673            run_id.clone(),
674            "2.0".into(),
675            std::time::Duration::from_secs(10),
676        );
677    }
678    // OTLP logs export (plan §3.11, optional): mirror the JSON-lines log surface
679    // to `<endpoint>/v1/logs` when `observability.otel.logs` is on.
680    #[cfg(feature = "otel")]
681    if rt.settings.observability.otel.logs == Some(true)
682        && let Some(ep) = rt
683            .settings
684            .observability
685            .otel
686            .endpoint
687            .clone()
688            .or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
689    {
690        crate::obs::otel::arm_logs(&ep, "agentd", crate::VERSION);
691        log.info("otel.logs.armed", json!({"endpoint": ep}));
692    }
693    // `--prompt`: the task, delivered as a MESSAGE into the agent's root
694    // context — the same path an A2A message takes. Root scope is the point:
695    // the agent answers with its full tool surface, so a prompt may set the
696    // instance up (`workflow.create` a loop/schedule/subscribe) instead of
697    // only answering once. Whether the process then exits is the ordinary
698    // lifecycle question: `auto` stays up iff something long-lived is armed.
699    if let Some(prompt) = rt.settings.agent.prompt.clone()
700        && !prompt.trim().is_empty()
701        && let Err(err) = rt.accept_event(
702            events::kinds::A2A_MESSAGE,
703            Some("operator".into()),
704            json!({"text": prompt, "context_id": crate::context::ROOT}),
705        )
706    {
707        log.warn("prompt.reject", json!({"err": err}));
708    }
709    // A debug-only seam (`AGENTD_TEST_INBOX_FILE`): inject inbox events from a
710    // JSON file — the e2e suite's stand-in for the A2A server until P5.
711    #[cfg(any(feature = "internal-mocks", debug_assertions))]
712    if let Ok(path) = std::env::var("AGENTD_TEST_INBOX_FILE") {
713        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())) {
714            Ok(Value::Array(events)) => {
715                for e in events {
716                    let kind = e["kind"].as_str().unwrap_or(events::kinds::A2A_MESSAGE).to_string();
717                    let principal = e["principal"].as_str().map(str::to_string);
718                    let payload = e.get("payload").cloned().unwrap_or(Value::Null);
719                    if let Err(err) = rt.accept_event(&kind, principal, payload) {
720                        log.warn("test.inbox.reject", json!({"err": err}));
721                    }
722                }
723                let _ = std::fs::remove_file(&path);
724            }
725            other => log.warn("test.inbox.bad_file", json!({"path": path, "err": format!("{other:?}").chars().take(200).collect::<String>()})),
726        }
727    }
728    rt.checkpoint(true);
729    let code = rt.run_loop();
730    let _ = &rt.last_manifest_flush;
731    // A job-shaped run prints its result on stdout (the 1.x `once` contract).
732    if rt.job_shape
733        && let Some(out) = rt.job_output()
734    {
735        match out {
736            Value::String(s) => println!("{s}"),
737            Value::Null => {}
738            other => println!(
739                "{}",
740                serde_json::to_string_pretty(&other).unwrap_or_default()
741            ),
742        }
743    }
744    code
745}
746
747/// A static **capability document** for `--capabilities` (RFC 0015 §5.2):
748/// describes the configured 2.0 surface with **no side effects** — it does not
749/// connect to MCP servers, read secrets, or start the loop. It reflects the
750/// configuration (what the agent is set up to do), not live state.
751pub fn capabilities(loaded: &Loaded) -> Value {
752    const START_KINDS: &[&str] = &[
753        "once",
754        "manual",
755        "loop",
756        "schedule",
757        "subscribe",
758        "signal",
759        "event",
760        "a2a",
761    ];
762    let s = &loaded.settings;
763    let workflows: Vec<Value> = s
764        .workflows
765        .iter()
766        .map(|w| {
767            let starts: Vec<String> = w["steps"]
768                .as_object()
769                .map(|steps| steps.values().filter_map(|st| st["kind"].as_str()).filter(|k| START_KINDS.contains(k)).map(str::to_string).collect())
770                .unwrap_or_default();
771            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()})
772        })
773        .collect();
774    let a2a = s.a2a.listen.as_ref().map(|listen| {
775        let principals: Vec<Value> = s
776            .a2a
777            .principals
778            .iter()
779            .map(|p| json!({"role": format!("{:?}", p.role).to_lowercase(), "match": principal_match_desc(&p.matcher), "grants": p.grants}))
780            .collect();
781        let mut methods = vec![
782            "SendMessage",
783            "SendStreamingMessage",
784            "GetTask",
785            "CancelTask",
786            "ListTasks",
787            "SubscribeToTask",
788            "GetAgentCard",
789        ];
790        let mut command_ops = vec![
791            "status",
792            "config",
793            "workflow.run",
794            "workflow.status",
795            "workflow.cancel",
796            "workflow.signal",
797            "subagent.send",
798            "subagent.kill",
799            "subagent.status",
800            "plan.get",
801        ];
802        if s.interface.enabled {
803            methods.push("SubscribeToEvents");
804            command_ops.push("interface.info");
805            if s.interface.debug {
806                command_ops.extend(["conversation.get", "run.get", "debug.events"]);
807            }
808        }
809        json!({
810            "listen": listen,
811            "tls": s.a2a.tls.cert.is_some(),
812            "mtls": s.a2a.tls.client_ca.is_some(),
813            "bearer": s.a2a.bearer.is_some(),
814            "methods": methods,
815            "admin": ["a2a.drain", "a2a.lameduck", "a2a.cancel", "a2a.pause", "a2a.resume"],
816            "command_ops": command_ops,
817            "principals": principals,
818            "loopback_operator": s.a2a.principals.is_empty(),
819        })
820    });
821    json!({
822        "runtime": "2.0",
823        "version": crate::VERSION,
824        "agent": {"name": s.instance_name(), "instruction": s.agent.instruction.is_some(), "preflight": format!("{:?}", s.agent.preflight).to_lowercase()},
825        "intelligence": {"model": s.intelligence.model, "endpoints": s.intelligence.endpoints.len()},
826        "mcp_servers": s.mcp.servers.iter().map(|m| m.name.clone()).collect::<Vec<_>>(),
827        "internal_tools": crate::registry::internal::names(),
828        "tools": {"overrides": s.tools.overrides.keys().cloned().collect::<Vec<_>>(), "disabled": s.tools.disabled},
829        "workflows": workflows,
830        "knowledge": {"server": s.knowledge.server},
831        "search": {"server": s.search.server},
832        "skills": {"sources": s.skills.sources.len()},
833        "a2a": a2a,
834        "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}},
835        "store": format!("{:?}", s.store.kind).to_lowercase(),
836        // For the file adapter the kind alone under-reports: what an operator
837        // actually gets depends on the directory it lands in, and on whether
838        // they chose it or the long-lived default did (RFC 0033 §5.1). Additive
839        // and `null` for every other adapter, so the `store` string above stays
840        // the stable answer to "which adapter".
841        "store_file": (s.store.kind == StoreKind::File).then(|| json!({
842            "path": crate::config::v2::file_store_root(&s.store).display().to_string(),
843            "defaulted": loaded.doc.pointer("/store/kind").is_none(),
844        })),
845        "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")))))},
846    })
847}
848
849/// A redacted description of a principal matcher (secrets never leak here).
850fn principal_match_desc(m: &crate::config::v2::PrincipalMatch) -> Value {
851    if m.any {
852        json!({"any": true})
853    } else if let Some(s) = &m.san {
854        json!({"san": s})
855    } else if let Some(s) = &m.sub {
856        json!({"sub": s})
857    } else if m.bearer_ref.is_some() {
858        json!({"bearer_ref": "***"})
859    } else if let Some(a) = &m.aauth_agent {
860        json!({"aauth_agent": a})
861    } else {
862        json!({})
863    }
864}
865
866/// Build the intelligence OAuth credential provider (RFC 0031 §7): a closure
867/// returning the current bearer (refreshing from the `agentd login intelligence`
868/// device-login cache). `None` when no oauth2 `intelligence.auth` is set (or
869/// without `--features oauth`), so the static `intelligence.token` path is
870/// byte-identical.
871fn intel_bearer_provider(
872    settings: &crate::config::v2::Settings,
873) -> Option<std::sync::Arc<dyn Fn() -> Option<String> + Send + Sync>> {
874    #[cfg(feature = "oauth")]
875    {
876        let auth = settings.intelligence.auth.as_ref()?;
877        let spec = auth.to_spec();
878        // SigV4 (`kind: aws`) is a per-request signature, not a bearer — the intel
879        // path has no generic signer hook yet, so it is a follow-up for LLM auth.
880        if spec.kind == "aws" {
881            return None;
882        }
883        // Build the provider's signer once (preserving the oauth2 in-memory
884        // refresh) and extract the bearer per LLM dial. Covers static / oauth2
885        // device-login / spiffe jwt — all bearer-style for intelligence.
886        let signer = crate::auth::device::signer_for(
887            &spec,
888            "intelligence",
889            std::time::Duration::from_secs(30),
890        )
891        .ok()??;
892        Some(std::sync::Arc::new(move || {
893            signer
894                .sign("POST", "", "", &[])
895                .into_iter()
896                .find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
897                .map(|(_, v)| v.strip_prefix("Bearer ").unwrap_or(&v).to_string())
898        }))
899    }
900    #[cfg(not(feature = "oauth"))]
901    {
902        let _ = settings;
903        None
904    }
905}
906
907impl Runtime {
908    /// The current intelligence bearer (RFC 0031): the OAuth provider's token
909    /// (refreshing) when an `intelligence.auth` oauth2 block is configured, else
910    /// the static `intelligence.token`. Resolved fresh at each subagent spawn so
911    /// a child rides a live token without its own refresh machinery.
912    pub(crate) fn current_intel_bearer(&self) -> Option<String> {
913        self.intel_bearer
914            .as_ref()
915            .and_then(|f| f())
916            .or_else(|| self.intel_token.clone())
917    }
918
919    /// The AWS SigV4 intelligence-auth spec (RFC 0031), when `intelligence.auth`
920    /// selects `kind: aws`. Threaded to subagents (which build the signer) and
921    /// used by the goal judge to SigV4-sign the LLM dial.
922    pub(crate) fn intel_aws_auth(&self) -> Option<crate::config::AuthSpec> {
923        let a = self.settings.intelligence.auth.as_ref()?;
924        (a.kind == crate::config::v2::AuthKind::Aws).then(|| a.to_spec())
925    }
926
927    /// The configured `intelligence.dialect` (RFC 0031 §8), threaded into a
928    /// child's spawn payload so it selects the same wire adapter. `None` ⇒
929    /// OpenAI-compatible.
930    pub(crate) fn intel_dialect(&self) -> Option<String> {
931        self.settings.intelligence.dialect.clone()
932    }
933}
934
935/// Resolve `intelligence.token` / `token_file` (secret refs, files).
936fn resolve_intel_token(
937    settings: &crate::config::v2::Settings,
938    env: &dyn Fn(&str) -> Option<String>,
939) -> Result<Option<String>, String> {
940    if let Some(t) = &settings.intelligence.token {
941        let resolved = crate::sec::secret::resolve(&t.0, env)
942            .map_err(|e| format!("intelligence.token: {e}"))?;
943        return Ok(Some(resolved));
944    }
945    if let Some(p) = &settings.intelligence.token_file {
946        return crate::sec::secret::read_token_file(p)
947            .map(Some)
948            .map_err(|e| format!("intelligence.token_file: {e}"));
949    }
950    // The v1 env conventions still apply inside the intel client (AGENT_INTELLIGENCE_TOKEN…).
951    Ok(None)
952}
953
954impl Runtime {
955    /// Read + subscribe the instruction resource (RFC 0028 §3).
956    pub(crate) fn subscribe_instruction(&mut self, uri: &str) -> Result<(), String> {
957        let (server, res) = match uri.strip_prefix("mcp://").and_then(|r| r.split_once('/')) {
958            Some((s, r)) => (Some(s.to_string()), r.to_string()),
959            None => (None, uri.to_string()),
960        };
961        // Find the serving client.
962        let candidates: Vec<(String, Arc<McpClient>)> = match &server {
963            Some(s) => self
964                .mcp
965                .get(s)
966                .map(|c| vec![(s.clone(), c.clone())])
967                .unwrap_or_default(),
968            None => self
969                .mcp
970                .iter()
971                .map(|(n, c)| (n.clone(), c.clone()))
972                .collect(),
973        };
974        let mut last_err = String::from("no connected MCP server serves it");
975        for (name, c) in candidates {
976            match c.read_resource(&res) {
977                Ok(r) => {
978                    let text = r.text();
979                    if c.capabilities().supports_resources()
980                        && let Err(e) = c.subscribe(&res)
981                    {
982                        self.log.warn(
983                            "instruction.subscribe.fail",
984                            json!({"server": name, "uri": res, "err": e.to_string()}),
985                        );
986                    }
987                    let changed = self.instruction.text != text;
988                    self.instruction = reactor::Instruction {
989                        text,
990                        source: "resource",
991                        uri: Some(res.clone()),
992                        server: Some(name.clone()),
993                        version: self.instruction.version + u64::from(changed),
994                    };
995                    self.log.info("instruction.loaded", json!({"server": name, "uri": res, "bytes": self.instruction.text.len(), "version": self.instruction.version}));
996                    return Ok(());
997                }
998                Err(e) => last_err = e.to_string(),
999            }
1000        }
1001        Err(last_err)
1002    }
1003
1004    /// Drain MCP notifications: an updated instruction resource re-reads it
1005    /// and wakes the root (`instruction_updated`); `tools/list_changed` is
1006    /// noted (registry rebuild lands with the P5 reload choreography).
1007    pub(crate) fn poll_mcp_notifications(&mut self) {
1008        let mut updated_instruction = false;
1009        let mut tools_changed = Vec::new();
1010        let mut resource_updates: Vec<(String, String)> = Vec::new();
1011        for (name, c) in &self.mcp {
1012            for n in c.drain_notifications() {
1013                match n.method.as_str() {
1014                    ::mcp::wire::method::NOTIFY_RESOURCES_UPDATED => {
1015                        let uri = n
1016                            .params
1017                            .as_ref()
1018                            .and_then(|p| p.get("uri"))
1019                            .and_then(Value::as_str)
1020                            .unwrap_or("");
1021                        if self.instruction.uri.as_deref() == Some(uri)
1022                            && self.instruction.server.as_deref() == Some(name.as_str())
1023                        {
1024                            updated_instruction = true;
1025                        }
1026                        resource_updates.push((name.clone(), uri.to_string()));
1027                    }
1028                    ::mcp::wire::method::NOTIFY_TOOLS_LIST_CHANGED => {
1029                        tools_changed.push(name.clone())
1030                    }
1031                    _ => {}
1032                }
1033            }
1034        }
1035        if updated_instruction && let Some(uri) = self.instruction.uri.clone() {
1036            let full = match &self.instruction.server {
1037                Some(s) => format!("mcp://{s}/{uri}"),
1038                None => uri,
1039            };
1040            let before = self.instruction.version;
1041            if self.subscribe_instruction(&full).is_ok() && self.instruction.version != before {
1042                self.log.info(
1043                    "instruction.updated",
1044                    json!({"version": self.instruction.version}),
1045                );
1046                if self
1047                    .settings
1048                    .agent
1049                    .wake_on()
1050                    .contains(&crate::config::v2::WakeEvent::InstructionUpdated)
1051                {
1052                    self.note_root("instruction.updated: the instruction resource changed; re-read it with instruction.read".into());
1053                }
1054            }
1055        }
1056        for (server, uri) in resource_updates {
1057            self.on_resource_updated(&server, &uri); // `wait` steps
1058            self.on_subscribe_resource(&server, &uri); // `subscribe` start nodes
1059        }
1060        for s in tools_changed {
1061            self.log.info("mcp.tools_changed", json!({"server": s, "note": "registry rebuild lands with the P5 reload choreography"}));
1062        }
1063    }
1064}