- 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
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:
vapora-capabilitiesexposes aCapabilitytrait,CapabilitySpecstruct,CapabilityRegistry, andCapabilityLoader— built-in implementations coverCodeReviewer,DocGenerator, andPRMonitor.AgentDefinitionis moved fromvapora-agents::configtovapora-shared;vapora-agents::configre-exports it for backward compatibility.AgentExecutorgains awith_router(Arc<LLMRouter>)builder method; the system prompt fromCapabilitySpecis forwarded as aRole::Systemmessage through the existingTypeDialogAdapter::build_messages(prompt, context)path.AgentCoordinatorgains in-process executor dispatch via aDashMap<String, Sender<TaskAssignment>>; the shard lock is released before.awaitby cloning theSenderout 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.
AgentDefinitioninvapora-sharedis architecturally correct: it is a DTO with no behavior, andvapora-sharedis the designated home for shared data types.- The
DashMaplock-before-await anti-pattern is eliminated fromAgentCoordinator.
Negative
vapora-agentsbinary depends onvapora-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
CodeReviewerprompt 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-capabilitieshas 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
AgentCoordinatorpatterns established in ADR-0024 and refined in ADR-0033; in-process dispatch viaDashMap<String, Sender>follows the same lock-discipline patterns applied to the workflow engine. AgentDefinitionrelocation is consistent with ADR-0001 (workspace crate responsibilities):vapora-sharedholds types depended on by multiple crates; orchestration crates hold behavior.- TOML override resolution follows the same
Option<T>-merge pattern used invapora-llm-router::configfor per-rule model overrides (ADR-0012).