186 lines
9.9 KiB
Markdown
186 lines
9.9 KiB
Markdown
|
|
# 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`)
|
||
|
|
|
||
|
|
```text
|
||
|
|
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`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
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:
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[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
|
||
|
|
|
||
|
|
```text
|
||
|
|
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:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
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
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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).
|