Vapora/docs/adrs/0037-capability-packages.md
Jesús Pérez 765841b18f
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
Documentation Lint & Validation / Lint & Validation Summary (push) Has been cancelled
mdBook Build & Deploy / Build mdBook (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
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
feat(capabilities): add vapora-capabilities crate with in-process executor dispatch
- New vapora-capabilities crate: CapabilitySpec, Capability trait, CapabilityRegistry
     (parking_lot RwLock), CapabilityLoader (TOML overrides), 3 built-ins
     (code-reviewer, doc-generator, pr-monitor), 22 tests
   - Move AgentDefinition to vapora-shared to break capabilities↔agents circular dep
   - Wire system_prompt into AgentExecutor via LLMRouter.complete_with_budget
   - AgentCoordinator: in-process task dispatch via DashMap<String, Sender<TaskAssignment>>
   - server.rs: bootstrap CapabilityRegistry + LLMRouter from env, spawn executors per capability
   - Landing page: 620 tests, 21 crates, Capability Packages feature box
   - docs: capability-packages feature guide, ADR-0037, CHANGELOG, SUMMARY
   EOF
2026-02-26 16:43:28 +00:00

9.9 KiB

ADR-0037: Capability Packages — vapora-capabilities Crate

Status: Accepted Date: 2026-02-26 Deciders: VAPORA Team Technical Story: VAPORA agents carried generic roles (developer, reviewer, architect) but had no domain-specific system prompts or model defaults — every new deployment required manual prompt engineering per agent before first use.


Decision

Introduce a dedicated vapora-capabilities crate that provides zero-config capability bundles, and relocate AgentDefinition to vapora-shared to eliminate a circular dependency:

  1. vapora-capabilities exposes a Capability trait, CapabilitySpec struct, CapabilityRegistry, and CapabilityLoader — built-in implementations cover CodeReviewer, DocGenerator, and PRMonitor.
  2. AgentDefinition is moved from vapora-agents::config to vapora-shared; vapora-agents::config re-exports it for backward compatibility.
  3. AgentExecutor gains a with_router(Arc<LLMRouter>) builder method; the system prompt from CapabilitySpec is forwarded as a Role::System message through the existing TypeDialogAdapter::build_messages(prompt, context) path.
  4. AgentCoordinator gains in-process executor dispatch via a DashMap<String, Sender<TaskAssignment>>; the shard lock is released before .await by cloning the Sender out of the map.

Context

Gap: Generic Roles Require Manual Configuration

VAPORA's agent roles were defined as enum variants (AgentRole::Developer, AgentRole::Reviewer, etc.) with no attached system prompt or model preference. A freshly deployed instance had functionally identical agents regardless of the task domain. Operators had to locate agents.toml, write a system prompt, select a model, and restart the agents server before agents produced domain-appropriate responses. This repeated for every deployment.

Competitive Signal: OpenFang "Hands"

OpenFang's "Hands" concept ships pre-configured agent personas as first-class artifacts: a code-review Hand knows which model to use, what temperature to set, and what system prompt to send — activated with a single config entry. VAPORA's equivalent required three files and a server restart. The gap was not capability but packaging.

Circular Dependency Risk

AgentDefinition was the struct that capability specs would need to produce in order to register a new agent. It lived in vapora-agents::config. If vapora-capabilities imported vapora-agents to get AgentDefinition, and vapora-agents imported vapora-capabilities to load built-in capabilities at startup, the workspace would have a compile-time cycle. Cargo does not resolve intra-workspace circular dependencies.

AgentDefinition is a plain data-transfer struct — no orchestration logic, no I/O, no runtime state. Its correct home is vapora-shared.


Implementation

Crate Structure (vapora-capabilities)

vapora-capabilities/
├── src/
│   ├── lib.rs          — pub re-exports (Capability, CapabilitySpec, CapabilityRegistry, CapabilityLoader)
│   ├── capability.rs   — Capability trait + CapabilitySpec struct
│   ├── registry.rs     — CapabilityRegistry: name → Arc<dyn Capability>
│   ├── loader.rs       — CapabilityLoader: TOML file + env override resolution
│   └── builtins/
│       ├── mod.rs      — register_defaults(registry: &mut CapabilityRegistry)
│       ├── code_reviewer.rs  — CodeReviewer (Opus 4.6, temp 0.1)
│       ├── doc_generator.rs  — DocGenerator (Sonnet 4.6, temp 0.3)
│       └── pr_monitor.rs     — PRMonitor (Sonnet 4.6, temp 0.1)

Capability Trait and CapabilitySpec

pub trait Capability: Send + Sync {
    fn name(&self) -> &str;
    fn spec(&self) -> CapabilitySpec;
}

pub struct CapabilitySpec {
    pub system_prompt: String,
    pub model: String,
    pub temperature: f32,
    pub agent_definition: AgentDefinition,  // from vapora-shared
}

CapabilitySpec is intentionally flat — no nested Option hierarchies. TOML overrides are applied by CapabilityLoader before the spec reaches AgentExecutor.

Built-in Capabilities

Name Model Temperature Purpose
code-reviewer claude-opus-4-6 0.1 Structured code review with severity classification
doc-generator claude-sonnet-4-6 0.3 Module and API documentation generation
pr-monitor claude-sonnet-4-6 0.1 PR diff analysis and merge-readiness assessment

Temperature 0.1 for review tasks enforces determinism; 0.3 for generation allows phrasing variation without hallucination risk at this task type.

TOML Override System

Operators can override any CapabilitySpec field without touching source code:

[capabilities.code-reviewer]
model = "claude-sonnet-4-6"
temperature = 0.2

[capabilities.doc-generator]
system_prompt = "You are a documentation expert specializing in Rust crate APIs."

Unset fields retain the built-in default. CapabilityLoader::load merges partial TOML structs using Option<T> fields internally — a None after deserialization means "keep built-in value", not "clear to empty".

AgentDefinition Relocation

Before: vapora-agents::config::AgentDefinition
After:  vapora-shared::models::AgentDefinition

vapora-agents::config:
  pub use vapora_shared::models::AgentDefinition;  // backward compat

All existing call sites in vapora-agents, vapora-backend, and vapora-a2a continue to compile without change. The re-export is the only required modification in vapora-agents.

AgentExecutor — System Prompt Forwarding

AgentExecutor gains one builder method:

impl AgentExecutor {
    pub fn with_router(mut self, router: Arc<LLMRouter>) -> Self {
        self.router = Some(router);
        self
    }
}

The capability's system_prompt is passed as context to TypeDialogAdapter::build_messages(prompt, context), which already mapped context to a leading Role::System message before this change. No new message-construction logic is needed.

AgentCoordinator — In-Process Executor Dispatch

// DashMap shard released before .await by cloning the Sender
let tx: Sender<TaskAssignment> = {
    self.executor_channels
        .get(&agent_id)
        .map(|entry| entry.value().clone())
        .ok_or(CoordinatorError::AgentNotFound(agent_id.clone()))?
};
tx.send(assignment).await?;

The DashMap::get guard (which holds a shard read lock) is dropped at the end of the block. The .await on tx.send occurs after the lock is released, eliminating the risk of a Tokio task yielding while holding a DashMap shard lock.


Consequences

Positive

  • Deploy VAPORA, activate a capability by name, and it works — no manual system prompt engineering required.
  • TOML overrides allow per-deployment model preferences without code changes or recompilation of the capabilities crate.
  • AgentDefinition in vapora-shared is architecturally correct: it is a DTO with no behavior, and vapora-shared is the designated home for shared data types.
  • The DashMap lock-before-await anti-pattern is eliminated from AgentCoordinator.

Negative

  • vapora-agents binary depends on vapora-capabilities. Adding a new built-in capability requires recompiling and redeploying the agents server. Capabilities are not dynamically loaded.
  • Built-in system prompts are opinionated. An operator who disagrees with the CodeReviewer prompt must override it explicitly; there is no mechanism to reset to "no prompt" after a capability is activated short of removing the config entry.

Neutral

  • Partial TOML override via Option<T> fields means unset override fields silently preserve the built-in value. This is the intended behavior but requires operators to understand that omitting a field is not the same as clearing it.
  • vapora-capabilities has no runtime dependency on SurrealDB or NATS — it is a pure configuration and trait-definition crate. It can be unit-tested without any external services.

Alternatives Considered

CapabilityRegistry Initialization in vapora-backend

Register agents via HTTP call to the agents server at backend startup. Rejected: it adds a temporal dependency (agents server must be up before backend can finish booting), introduces a network round-trip on startup, and couples the backend's readiness to the agents server's availability — three failure modes for what is a configuration operation.

Keep AgentDefinition in vapora-agents, Expose Capabilities via a Plugin Trait

Define a CapabilityPlugin trait in a thin interface crate; vapora-agents depends on the interface, vapora-capabilities implements it. Rejected: this adds a third crate (vapora-capability-api) to resolve what is fundamentally a DTO placement error. AgentDefinition has no behavior to isolate behind a trait — moving it to vapora-shared is the minimal correct fix.

Static TOML Baked into agents.toml at CLI Time

Generate capability configuration as a static TOML block via vapora-cli and write it into agents.toml. Rejected: this loses runtime composability (capabilities cannot be activated or deactivated without regenerating the file), makes the override system asymmetric (CLI generates, operator edits by hand), and provides no registry abstraction for future dynamic activation.


Relation to Prior Decisions

  • Builds on AgentCoordinator patterns established in ADR-0024 and refined in ADR-0033; in-process dispatch via DashMap<String, Sender> follows the same lock-discipline patterns applied to the workflow engine.
  • AgentDefinition relocation is consistent with ADR-0001 (workspace crate responsibilities): vapora-shared holds types depended on by multiple crates; orchestration crates hold behavior.
  • TOML override resolution follows the same Option<T>-merge pattern used in vapora-llm-router::config for per-rule model overrides (ADR-0012).