Some checks failed
Documentation Lint & Validation / Markdown Linting (push) Has been cancelled
Documentation Lint & Validation / Validate mdBook Configuration (push) Has been cancelled
Documentation Lint & Validation / Content & Structure Validation (push) Has been cancelled
mdBook Build & Deploy / Build mdBook (push) Has been cancelled
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
Documentation Lint & Validation / Lint & Validation Summary (push) Has been cancelled
mdBook Build & Deploy / Documentation Quality Check (push) Has been cancelled
mdBook Build & Deploy / Deploy to GitHub Pages (push) Has been cancelled
mdBook Build & Deploy / Notification (push) Has been cancelled
on+re:
- core.ncl: 5 new Practice nodes (notification-channels,
vapora-capabilities, agent-hot-reload-stable-identity,
merkle-audit-trail, notification-channels) + 5 new edges;
knowledge-graph-execution-history updated with HNSW+BM25+RRF
- state.ncl: production-readiness blocker/catalyst updated (hot-reload
complete, BudgetManager/LLMRouter still require restart);
ontoref-integration catalyst updated (vapora-ontology/reflection
crates, api-catalog.json, nickel contracts)
ADRs (NCL):
- adr-013: KG hybrid search — HNSW+BM25+RRF, rejected in-process scan
- adr-014: capability packages — AgentDefinition→vapora-shared,
DashMap shard-before-await constraint
- adr-015: Merkle audit trail — SHA-256 hash chain, rejected HMAC
- adr-016: agent hot-reload — stable_id=role, learning_profiles survive
drain, BudgetManager excluded from reload scope
landing page:
- 2 new feature boxes: VCS-Agnostic Worktree (jj/git), Ontology Protocol
- KG box: 20→28 tests, HNSW+BM25+RRF description
- Agents box: 71→82 tests, hot-reload + stable_id
- tech stack: Rust 21→23 crates, added jj, Radicle, ontoref badges
- status badge: 620→691 tests
78 lines
5.9 KiB
Text
78 lines
5.9 KiB
Text
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<LLMRouter>) builder. Add AgentCoordinator in-process executor dispatch via DashMap<String, Sender<TaskAssignment>> — 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<TaskAssignment> 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<String, Sender>; shard lock released before .await",
|
|
invariants_at_risk = ["message-based-coordination"],
|
|
verdict = 'Safe,
|
|
},
|
|
}
|