1 Capability Contract
jfabian edited this page 2026-08-13 11:15:47 -03:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Capability Contract

Every VOX capability must satisfy a minimal contract so the framework can load, configure, bind, and run it without crashing. This document defines that contract and the guarantees the framework makes in return.


0. Agent Contract (Cross-Reference)

This document covers the capability contract. Every capability is consumed by an agent. For the agent-side contract — including agent.yml schema, role loading, and lifecycle — see VOXAgent and VOXRole. Key points:

  • agent.yml must contain name (str) and id (str). Wrong types are rejected at boot; unknown fields are warned.
  • Roles declare capability dependencies via the REQUIRES class variable (see §1.5 below), discovered by static AST analysis.
  • Missing capabilities degrade, not crash — a role whose required capability is unavailable is disabled and the agent is flagged DEGRADED.

1. The Mandatories

1.1 Inherit from VOXCapability

from vox.capabilities.base import VOXCapability

class MyCapability(VOXCapability):
    ...

All lifecycle, parameter extraction, and mount logic live on the base class. Skipping it breaks the entire binding mechanism.

1.2 Declare CAPABILITY_NAME

A globally unique dotted identifier used for lookup and role declarations.

CAPABILITY_NAME = "ai.llm"

Roles request capabilities via REQUIRES:

REQUIRES = {"ai.llm", "comm.gateway"}

After mounting, the agent stores the bound proxy in self.agent.capabilities[ "ai.llm"] — not as a cap_llm attribute.

1.3 Declare PARAMS

Every configuration parameter the capability needs must be listed so mount() can validate and extract it before the bound proxy is created.

PARAMS = {
    "API_URL":    ["Service endpoint URL", "http://localhost:8080"],
    "API_KEY":    ["Authentication token", None],           # None = REQUIRED
    "TIMEOUT":    ["Request timeout (seconds)", 30.0],
}

Format: {param_name: [human_description, default_or_None]}.

  • A None default makes the parameter required — the agent is flagged DEGRADED if it is missing at mount time.
  • An empty string "" default means unconfigured / feature inactive.
  • Optional credentials MUST default to "", never None — a None default on a system capability mounted on every agent (e.g. comm.gateway) degrades every agent that does not use the feature. See Capabilities Reference.

Mutable class-level PARAMS dicts are an intentional pattern — keep # noqa: RUF012 on them.

1.4 Declare SENSITIVE_PARAMS

Secrets are injected from the per-agent vault, never from source or logs.

SENSITIVE_PARAMS = {"API_KEY"}

At boot, CapabilityBinder.inject_vault_secrets() replaces each sensitive param with its vault-stored value. If a sensitive param cannot be resolved and the vault is unavailable (VOX_MASTER_KEY not set), boot fails fast with VaultAccessError. When the vault exists but a value is missing, the capability's roles are disabled and the agent is flagged DEGRADED.

1.5 Declare EXPOSED_COMMANDS (optional)

Capabilities can expose commands callable through the agent's command registry:

EXPOSED_COMMANDS = [
    {"name": "send_email", "description": "Send an automated plain-text email.", "method": "send_email"},
]

Each exposed command is registered on the agent and routed through the capability's bound method.

1.6 Implement Public Methods

Agents call capability methods on the bound proxy:

result = await self.agent.capabilities["ai.llm"].generate(system, prompt)

All public methods must:

  • Be async — the event loop must never block.
  • Accept self as the bound proxy instance (the __getattr__ wrapper injects it automatically).
  • Accept keyword arguments rather than opaque positional blobs.
  • Return a serializable value (str, dict, list, None, etc.).

No generic run("command") dispatch. Each capability exposes its own named methods with explicit signatures.

1.7 Declare Role Dependencies via REQUIRES

Every role must declare which capabilities it needs via the REQUIRES class variable. The ASTAgentAnalyzer scans role files statically (top-level REQUIRES = {...} sets) so capabilities are mounted before roles run.

from vox.roles import VOXRole, command

class MyRole(VOXRole):
    REQUIRES = {"ai.llm", "comm.gateway"}   # <-- capability IDs

    @command("do_thing", description="Does a thing")
    async def do_thing(self, ...):
        ...

Rules:

  • REQUIRES is a set[str] of fully-qualified capability IDs.
  • A role that needs no capabilities leaves REQUIRES = set() (the default).
  • If a required capability is missing, the role is disabled and the agent is flagged DEGRADED (no crash).

1.8 NLU Guardrails via sample_prompts

Every @command can include sample_prompts — example phrases that help the NLU classifier distinguish user intent:

from vox.roles import VOXRole, command

class MyRole(VOXRole):
    REQUIRES = {"ai.llm"}

    @command(
        "search_products",
        description="Search for products in the catalog",
        sample_prompts=[
            "search for blue shoes",
            "find me a laptop under 1000",
            "show me red dresses",
        ],
    )
    async def search_products(self, ...):
        ...

The NLU prompt rendered to the LLM becomes:

Available commands:
- search_products: Search for products in the catalog
  e.g. "search for blue shoes"
  e.g. "find me a laptop under 1000"
  e.g. "show me red dresses"
- general_chat: casual conversation, no specific command.
  e.g. "hello"
  e.g. "how are you"

These few-shot examples are the strongest tool for guiding LLM classification. Without them, the LLM must infer intent from the command name alone.

The chat role also defines counter-examples for the general_chat fallback — phrases that look like commands but should always be treated as conversation.

Rules:

  • Prompts should match the language the agent actually processes.
  • Provide 3–5 diverse examples per command.
  • Use general_chat examples to cover common conversational openers.
  • The CommandInfo dataclass stores these; they are rendered automatically by the chat role's NLU prompt builder.

2. The Lifecycle Hooks

The framework calls these at specific points. All are optional — the base class provides safe no-op defaults.

Method When Purpose
health_check() At discovery, orchestrator startup Verify the external service is reachable; return True/False
boot() After mount, before first use Start background tasks, open connections, begin polling loops
shutdown() On agent teardown Close connections, cancel tasks, release resources

health_check (classmethod)

@classmethod
async def health_check(cls) -> bool:
    ...

Called at discovery. Unhealthy capabilities are flagged but still registered.

boot()

async def boot(self) -> None:
    self._client = await create_client(...)
    self._poll_task = asyncio.create_task(self._poll_loop())

Called after the bound proxy is created (see §3.2). At this point self.PARAM_NAME works because self is the bound proxy. Use it to initialize state that needs configuration values.

shutdown()

async def shutdown(self) -> None:
    self._poll_task.cancel()
    await self._client.close()

Called during agent teardown. Must be idempotent — the framework may call it more than once in error paths.


3. What the Framework Guarantees

3.1 Parameter Resolution

When mount() runs, it:

  1. Reads the agent's config dict.
  2. Extracts every param listed in PARAMS.
  3. Fills missing optional params with their declared defaults.
  4. Leaves required params (None default) unresolved if absent.
  5. Stores the resolved dict on the bound proxy.

Missing required params are reported by bound.validate_params(); the CapabilityBinder flags the agent DEGRADED. bound.initialize() raises ValueError listing the missing params.

3.2 Automatic self Injection

The VOXBoundCapability.__getattr__ wrapper ensures that when an agent calls:

await self.agent.capabilities["ai.llm"].generate(system, prompt)

It is rewritten to:

await LLMCapability.generate(bound_proxy, system, prompt)

So the method's self is the bound proxy, not the stateless capability class. This gives your method access to:

  • self.PARAM_NAME — resolved configuration values
  • self.log() / self.warning() / self.error() / self.ok() — logging
  • self._agent — the agent that owns this capability
  • self._capability — the stateless capability singleton
  • self.get_capability(cap_id) — sibling capability lookup
  • self.get_safe_path(sub_dir, filename) — sandboxed asset path

3.3 Freeze Protection

After mounting, bound.freeze() makes the proxy immutable — any attempt to set a public attribute raises AttributeError. Private _-prefixed attributes and _instance_attrs remain mutable for storing runtime state.

3.4 Vault Secret Injection

Sensitive params are populated from the agent's encrypted vault at boot. Never fall back to embedding secrets in PARAMS defaults, source, tests, or logs.


4. Contract Violations (What Must Never Happen)

Violation Consequence
Blocking the event loop (time.sleep(), sync HTTP, etc.) Freezes the entire agent. Use asyncio.sleep(), aiohttp/httpx.AsyncClient, etc.
Raising unhandled exceptions in public methods Propagates to the agent, may crash the role. Catch and log instead.
Accessing the filesystem, network, or OS without going through a capability Breaks the OCAP security model. All I/O must be capability-gated.
Mutating the bound proxy's params at runtime The proxy is frozen. Use _instance_attrs (private) for mutable state.
Holding a reference to the raw VOXCapability singleton and calling methods on it directly Bypasses param injection. Always call through the bound proxy (self inside a method, or self.agent.capabilities[cap_id] outside).
Defaulting optional credentials to None Degrades agents that do not use the feature (config sentinel violation). Default to "".
Logging or committing secret values Breaks the vault model. Secrets live in the vault only.

5. Best Practices

  • Log everything. Use self.log(message), self.warning(message), self.error(message), self.ok(message) — they prefix the capability name automatically.
  • Design for idempotency. shutdown() may be called multiple times. generate() should be safe to retry.
  • Keep methods focused. A capability is a gateway — one method per operation.
  • Use type hints. The contract doesn't enforce them, but they enable static analysis in agents.
  • Test health_check() first. If it fails, the capability won't be healthy at discovery.

6. Checklists

For a New Capability

  • Subclass VOXCapability
  • Set CAPABILITY_NAME (dotted, unique)
  • Declare all params in PARAMS (required params have None default; optional credentials default to "")
  • Declare secrets in SENSITIVE_PARAMS (vault-injected)
  • Implement async public methods with explicit signatures
  • Optionally declare EXPOSED_COMMANDS
  • Optionally override health_check(), boot(), shutdown()
  • Create capability.yml alongside capability.py
  • Never block
  • Never mutate the proxy
  • Never raise unhandled exceptions from public methods

For a New Role

  • Subclass VOXRole
  • Set REQUIRES to the set of capability IDs the role needs
  • Decorate command handlers with @command(name, description)
  • Register event handlers with self.on(event_name)
  • Use self.agent.capabilities["comm.gateway"].send_text(...) for outbound messages (or emit send_message to the chat role)
  • Guard optional capability access with self.agent.capabilities.get("cap_id")