Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to vouch are documented here. Format follows
New `vouch schema list` and `vouch schema sync` commands inspect declared
kinds and audit existing pages against them; `propose-page` gains `--kind`
and repeatable `--meta key=value`.
- `kb.capabilities` now reports a `host_compat` block mirroring the `openclaw.compat` constraints declared in `openclaw.plugin.json` (e.g. `pluginApi`), so non-OpenClaw clients can detect compat without parsing the manifest. A new test asserts the two stay in sync and fails CI on drift (#237).
- `kb.synthesize` — answer-mode retrieval over the review-gated KB. Answers a
query in prose from approved claims only, with an inline `[claim_id]`
citation behind every sentence, an explicit `gaps` block listing query
Expand Down
6 changes: 6 additions & 0 deletions schemas/capabilities.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"title": "Context Engines",
"type": "array"
},
"host_compat": {
"additionalProperties": true,
"description": "Per-host compatibility ranges (#237). Mirrors the `openclaw.compat` block in openclaw.plugin.json so non-OpenClaw clients can detect compat without parsing the manifest, e.g. {\"openclaw\": {\"pluginApi\": \">=2026.4.0\"}}.",
"title": "Host Compat",
"type": "object"
},
"knowledge_capability": {
"additionalProperties": true,
"title": "Knowledge Capability",
Expand Down
33 changes: 33 additions & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@

from __future__ import annotations

import json
import logging
from pathlib import Path

from . import __version__
from .models import Capabilities
from .openclaw.context_engine import describe_engine

_log = logging.getLogger(__name__)

# Path to the plugin manifest, relative to this module. capabilities.py lives
# at src/vouch/capabilities.py; openclaw.plugin.json lives at the repo root,
# three levels up (src/vouch/ -> src/ -> repo root).
_PLUGIN_MANIFEST_PATH = Path(__file__).resolve().parent.parent.parent / "openclaw.plugin.json"

# The full method surface this implementation exposes. Keep this list in
# sync with the MCP server + JSONL server registrations — `test_capabilities`
# asserts they match.
Expand Down Expand Up @@ -72,6 +83,27 @@
]


def _load_host_compat() -> dict[str, dict[str, str]]:
"""Read the `openclaw.compat` block from openclaw.plugin.json (#237).

Surfaced in `kb.capabilities` as `host_compat` so non-OpenClaw clients
can detect compat without parsing the manifest themselves. Returns an
empty dict (rather than raising) if the manifest is missing or
malformed — capabilities() must never fail to report basic info just
because the manifest moved or this is installed as a standalone wheel
without the manifest packaged alongside it.
"""
try:
manifest = json.loads(_PLUGIN_MANIFEST_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
_log.debug("openclaw.plugin.json unreadable, host_compat will be empty: %s", e)
return {}
compat = manifest.get("openclaw", {}).get("compat")
if not isinstance(compat, dict):
return {}
return {"openclaw": {k: str(v) for k, v in compat.items()}}


def capabilities() -> Capabilities:
retrieval = ["fts5", "substring"]
try:
Expand All @@ -94,4 +126,5 @@ def capabilities() -> Capabilities:
"config_path": "retrieval.scope",
},
context_engines=[describe_engine()],
host_compat=_load_host_compat(),
)
9 changes: 9 additions & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,12 @@ class Capabilities(BaseModel):
default_factory=list,
description="OpenClaw context engines exposed (see openclaw.plugin.json)",
)
host_compat: dict[str, Any] = Field(
default_factory=dict,
description=(
"Per-host compatibility ranges (#237). Mirrors the "
"`openclaw.compat` block in openclaw.plugin.json so non-OpenClaw "
"clients can detect compat without parsing the manifest, e.g. "
'{"openclaw": {"pluginApi": ">=2026.4.0"}}.'
),
)
70 changes: 70 additions & 0 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

from __future__ import annotations

import json
from pathlib import Path

import pytest

from vouch import capabilities
from vouch.jsonl_server import HANDLERS

Expand All @@ -15,3 +20,68 @@ def test_capabilities_matches_jsonl_handlers() -> None:
f"missing handlers={declared - implemented}, "
f"missing capabilities={implemented - declared}"
)


# --- host_compat drift detection (#237) -----------------------------------
#
# vouch declares openclaw.compat.pluginApi in openclaw.plugin.json. The same
# value must surface in kb.capabilities so non-OpenClaw clients can detect
# compat without parsing the manifest. These tests fail CI with a clear
# message if the two declarations drift apart.

_MANIFEST_PATH = (
Path(__file__).resolve().parent.parent / "openclaw.plugin.json"
)


def _manifest_plugin_api() -> str:
manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
return manifest["openclaw"]["compat"]["pluginApi"]


def test_capabilities_host_compat_matches_openclaw_manifest() -> None:
"""kb.capabilities.host_compat.openclaw.pluginApi must equal the
pluginApi range declared in openclaw.plugin.json. A bump in one file
without the other is exactly the "host compat drift" #237 asks CI to
catch."""
caps = capabilities.capabilities()
manifest_range = _manifest_plugin_api()
capabilities_range = caps.host_compat.get("openclaw", {}).get("pluginApi")
assert capabilities_range == manifest_range, (
f"host compat drift: openclaw.plugin.json declares pluginApi="
f"{manifest_range!r} but kb.capabilities.host_compat reports "
f"{capabilities_range!r}. Keep both in sync."
)


def test_capabilities_host_compat_present_and_nonempty() -> None:
"""host_compat must not silently degrade to {} when the manifest is
readable -- that would defeat the drift check above by making both
sides agree on "missing" rather than catching real drift."""
caps = capabilities.capabilities()
assert "openclaw" in caps.host_compat
assert "pluginApi" in caps.host_compat["openclaw"]
assert caps.host_compat["openclaw"]["pluginApi"].strip() != ""


def test_load_host_compat_returns_empty_on_missing_manifest(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
"""_load_host_compat must degrade gracefully (empty dict, no raise) if
the manifest is absent -- e.g. installed as a standalone wheel without
openclaw.plugin.json packaged alongside it."""
monkeypatch.setattr(
capabilities, "_PLUGIN_MANIFEST_PATH", tmp_path / "does-not-exist.json"
)
assert capabilities._load_host_compat() == {}


def test_load_host_compat_returns_empty_on_malformed_manifest(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
"""A malformed manifest must not crash capabilities() -- it's reporting
diagnostic info, not validating the install."""
bad = tmp_path / "openclaw.plugin.json"
bad.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(capabilities, "_PLUGIN_MANIFEST_PATH", bad)
assert capabilities._load_host_compat() == {}
Loading