let d = import "adr-defaults.ncl" in d.make_adr { id = "adr-014", title = "Capability Packages — vapora-capabilities Crate with In-Process Executor Dispatch", status = 'Accepted, date = "2026-02-26", context = "VAPORA agent roles (Developer, Reviewer, Architect) were enum variants with no attached system prompt or model preference. Every new deployment required manual agents.toml editing before agents produced domain-appropriate responses. AgentDefinition lived in vapora-agents::config; any capability crate that wanted to produce AgentDefinitions would have to import vapora-agents, and if vapora-agents also imported vapora-capabilities for built-in capability loading, a compile-time circular dependency would result. Additionally, AgentCoordinator dispatched tasks by serializing them to NATS JetStream and waiting for an external process — no path existed for in-process executor dispatch needed by capability built-ins.", decision = "Introduce vapora-capabilities crate exposing Capability trait, CapabilitySpec, CapabilityRegistry, and CapabilityLoader. Relocate AgentDefinition from vapora-agents::config to vapora-shared to break the circular dependency. Add AgentExecutor::with_router(Arc) builder. Add AgentCoordinator in-process executor dispatch via DashMap> — shard lock released before .await by cloning the Sender out of the map. Built-in implementations: CodeReviewer, DocGenerator, PRMonitor.", rationale = [ { claim = "AgentDefinition belongs in vapora-shared — it is a plain data-transfer type with no orchestration logic", detail = "AgentDefinition contains role, provider, model, system_prompt — no async traits, no runtime state, no I/O. Its presence in vapora-agents::config was an artifact of where it was first needed, not where it conceptually belongs. Moving it to vapora-shared eliminates the circular dependency without changing any observable behavior. vapora-agents re-exports it for backward compatibility.", }, { claim = "In-process executor dispatch requires releasing the DashMap shard lock before .await", detail = "DashMap shard entries hold a read/write guard. If the guard is held across an .await point, the executor that eventually processes the task cannot re-enter the same shard to update state — deadlock. The fix: clone the Sender out of the map (releases the guard), then call sender.send(assignment).await. This is a Rust async correctness constraint, not a design preference.", }, { claim = "Capability bundles ship a system prompt, not a crate — operators activate by name, not by file", detail = "Manual agents.toml prompt engineering was the alternative. It required locating the file, writing a semantically correct system prompt, choosing a model, and restarting the server. CapabilityLoader resolves built-ins by name ('code-reviewer') with TOML override support — zero-config activation, operator-override when needed.", }, ], consequences = { positive = [ "New agent type activation requires a single config entry; system prompt and model default ship with the capability", "AgentDefinition circular dependency eliminated at the Cargo level — cargo check catches any regression immediately", "In-process executor dispatch avoids NATS round-trip for capability-backed agents, reducing task latency", "reload_agents uses CapabilityRegistry to re-spawn built-in executors after hot-reload", ], negative = [ "AgentDefinition in vapora-shared means vapora-shared now has awareness of capability/agent concepts — previously it was pure data types", "In-process dispatch bypasses NATS audit trail for capability tasks — task events are not published to JetStream for capability-dispatched work", ], }, alternatives_considered = [ { option = "Keep AgentDefinition in vapora-agents, use trait objects to break the cycle", why_rejected = "Trait-object indirection to break a circular dependency adds abstraction without adding value. Moving the struct is simpler, traceable, and requires fewer files.", }, { option = "External NATS dispatch only — no in-process executor channel", why_rejected = "NATS dispatch requires a running NATS server. Capability built-ins need to function in environments without NATS (local dev, test). In-process dispatch with NATS graceful fallback is the correct model.", }, ], constraints = [ { id = "agent-definition-in-shared", claim = "AgentDefinition must live in vapora-shared, not vapora-agents::config — vapora-capabilities must not import vapora-agents", scope = "crates/vapora-shared/src/", severity = 'Hard, check = { tag = 'Grep, pattern = "AgentDefinition", paths = ["crates/vapora-shared/src/"], must_be_empty = false }, rationale = "Moving AgentDefinition back to vapora-agents recreates the circular dependency that this ADR was written to prevent.", }, { id = "dashmap-shard-released-before-await", claim = "DashMap shard guards must not be held across .await points in AgentCoordinator dispatch — clone the Sender before awaiting", scope = "crates/vapora-agents/src/coordinator.rs", severity = 'Hard, check = { tag = 'Grep, pattern = "executor_channels.*get\\|sender.*clone", paths = ["crates/vapora-agents/src/coordinator.rs"], must_be_empty = false }, rationale = "Holding a DashMap guard across .await deadlocks re-entrant shard access from the receiving executor.", }, ], related_adrs = ["adr-009", "adr-005"], ontology_check = { decision_string = "vapora-capabilities crate; AgentDefinition relocated to vapora-shared; in-process executor dispatch via DashMap; shard lock released before .await", invariants_at_risk = ["message-based-coordination"], verdict = 'Safe, }, }