feat(skill-catalog-subscription): synced catalog + HTTP sync + MCP tools

Consumes the external Subscription Platform as source of truth for skill
content; reuses skills_learning domain models (extended with KnowledgeSkill)
and workflow.skill_exec resolver. HTTP/MCP deps land in api/ per
CONSTITUTION.md; synced skills use a physically separate SQLite file
(tasks/skills.sqlite3) to preserve the skill-authoring capability boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 10:33:23 +08:00
co-authored by Claude Opus 4.6
parent 763c1b2299
commit b94abde92a
13 changed files with 2608 additions and 38 deletions
+16 -1
View File
@@ -92,7 +92,12 @@ def tool_handlers(
}
def create_mcp_server(*, manager: DeviceManager | None = None) -> Any:
def create_mcp_server(
*,
manager: DeviceManager | None = None,
skill_catalog_store: Any | None = None,
skill_active_subscriptions: set[str] | None = None,
) -> Any:
try:
from mcp.server.fastmcp import FastMCP
except ImportError as exc:
@@ -159,4 +164,14 @@ def create_mcp_server(*, manager: DeviceManager | None = None) -> Any:
def _device_status(device_id: str) -> dict[str, Any]:
return handlers["device_status"](device_id=device_id)
if skill_catalog_store is not None:
from api.skill_catalog_mcp import register_skill_catalog_tools
register_skill_catalog_tools(
server,
store=skill_catalog_store,
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
get_registered_tools=lambda: set(handlers.keys()),
)
return server
+203
View File
@@ -0,0 +1,203 @@
"""MCP tool surface for the Skill Catalog.
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
Reads from :mod:`storage.skill_catalog`; flow-template parameter resolution
delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
No server-side execution primitive is exposed — flow templates are resolved
here but executed step-by-step by the LLM via the existing device-capability
tools (design D5).
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.skill_catalog import SkillCatalogStore
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
# Tool names registered by this module. Used by tests and by callers that
# need to assert the full registered set (e.g., no batch-execute tool).
SKILL_TOOL_NAMES = (
"list_skills",
"search_skills",
"get_skill",
"resolve_flow_template",
)
class SkillCatalogError(Exception):
"""Base for skill MCP semantic errors."""
class SkillNotFoundError(SkillCatalogError):
"""Raised when a skill id is unknown or not visible to the caller.
Both cases raise the same error to avoid leaking existence (design D4,
task 2.4 indistinguishability contract).
"""
class InvalidFlowTemplateError(SkillCatalogError):
"""Raised when a skill exists but cannot be returned as a flow template
(e.g., it's a knowledge skill, or a step references an unknown tool)."""
class MissingParameterError(SkillCatalogError):
"""Raised when required flow-template parameters are missing/invalid."""
def skill_tool_handlers(
*,
store: SkillCatalogStore,
get_active_subscriptions: Callable[[], set[str]],
get_registered_tools: Callable[[], set[str]] | None = None,
) -> dict[str, Callable[..., dict[str, Any]]]:
"""Return a dict of MCP tool handler functions, keyed by tool name.
Decoupled from FastMCP so handlers can be tested directly without
standing up a server (mirrors :func:`api.mcp.tool_handlers`).
"""
tools_getter = get_registered_tools or (lambda: set())
def _list_skills() -> dict[str, Any]:
metas = store.list_skills(get_active_subscriptions())
return {
"ok": True,
"skills": [_metadata_to_summary(m) for m in metas],
}
def _search_skills(query: str) -> dict[str, Any]:
metas = store.search_skills(query, get_active_subscriptions())
return {
"ok": True,
"skills": [_metadata_to_summary(m) for m in metas],
}
def _get_skill(skill_id: str) -> dict[str, Any]:
skill = store.get_skill(
skill_id,
get_active_subscriptions(),
registered_tools=tools_getter(),
)
if skill is None:
return _error_response(SkillNotFoundError(skill_id))
return {"ok": True, "skill": _skill_to_full_dict(skill)}
def _resolve_flow_template(
skill_id: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
params = params or {}
skill = store.get_skill(
skill_id,
get_active_subscriptions(),
registered_tools=tools_getter(),
)
if skill is None:
return _error_response(SkillNotFoundError(skill_id))
if not isinstance(skill, FlowTemplateSkill):
return _error_response(
InvalidFlowTemplateError(
f"skill {skill_id} is not a flow_template (kind={skill.metadata.kind})"
)
)
try:
steps = resolve_skill_steps(skill, params)
except SkillExecutionError as exc:
return _error_response(MissingParameterError(str(exc)))
return {"ok": True, "steps": steps}
return {
"list_skills": _list_skills,
"search_skills": _search_skills,
"get_skill": _get_skill,
"resolve_flow_template": _resolve_flow_template,
}
def register_skill_catalog_tools(
server: Any,
*,
store: SkillCatalogStore,
get_active_subscriptions: Callable[[], set[str]],
get_registered_tools: Callable[[], set[str]] | None = None,
) -> Any:
"""Register ``list_skills``/``search_skills``/``get_skill``/
``resolve_flow_template`` as MCP tools on ``server``.
Returns the server so the caller can chain. No batch-execute tool is
registered (design D5): the LLM issues each resulting device-capability
tool call itself, preserving the Observe-Think-Act loop.
"""
handlers = skill_tool_handlers(
store=store,
get_active_subscriptions=get_active_subscriptions,
get_registered_tools=get_registered_tools,
)
@server.tool(name="list_skills")
def _list_skills() -> dict[str, Any]:
return handlers["list_skills"]()
@server.tool(name="search_skills")
def _search_skills(query: str) -> dict[str, Any]:
return handlers["search_skills"](query=query)
@server.tool(name="get_skill")
def _get_skill(skill_id: str) -> dict[str, Any]:
return handlers["get_skill"](skill_id=skill_id)
@server.tool(name="resolve_flow_template")
def _resolve_flow_template(
skill_id: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
return handlers["resolve_flow_template"](skill_id=skill_id, params=params)
return server
def _metadata_to_summary(meta: SkillMetadata) -> dict[str, Any]:
"""Compact metadata for list/search — no Subscription-Platform-specific fields."""
return {
"id": meta.id,
"name": meta.name,
"description": meta.description,
"kind": meta.kind,
"tags": list(meta.tags),
}
def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
"""Full skill payload for ``get_skill``."""
base = _metadata_to_summary(skill.metadata)
base["version"] = skill.metadata.version
base["updated_at"] = skill.metadata.updated_at.isoformat()
if isinstance(skill, KnowledgeSkill):
base["content"] = skill.content
elif isinstance(skill, FlowTemplateSkill):
base["steps"] = [step.to_dict() for step in skill.steps]
base["parameters"] = {
name: dict(schema) for name, schema in skill.parameters.items()
}
return base
def _error_response(exc: Exception) -> dict[str, Any]:
return {"ok": False, "error": _semantic_skill_error(exc)}
def _semantic_skill_error(exc: Exception) -> str:
if isinstance(exc, SkillNotFoundError):
return "skill not found"
if isinstance(exc, InvalidFlowTemplateError):
return "skill unavailable"
if isinstance(exc, MissingParameterError):
message = str(exc)
return f"missing parameter: {message}" if message else "invalid parameter"
return "operation failed"
+277
View File
@@ -0,0 +1,277 @@
"""Skill catalog sync with the external Subscription Platform.
API layer per CONSTITUTION.md: HTTP dependencies (``httpx``) live here, and
this module is the sole caller of :mod:`storage.skill_catalog`'s private
``_apply_sync_*`` methods — the "Subscription Platform is sole source of
truth" contract is enforced by that import boundary.
Push (webhook) is optional; the baseline pull loop is correct standalone.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from typing import Any, Protocol
import httpx
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
Skill,
)
from storage.skill_catalog import SkillCatalogStore
log = logging.getLogger(__name__)
class SubscriptionClient(Protocol):
"""Contract for talking to the Subscription Platform.
Concrete implementations may use HTTP, an in-process mock, or any other
transport — :class:`SkillSyncRunner` only depends on this Protocol, so
the real Subscription Platform API can be wired in by adjusting only the
concrete client class (see design.md Open Questions).
"""
def fetch_entitled_skills(
self,
subscription_id: str,
since_version: int | None = None,
) -> "SyncDelta":
"""Return the skills entitled to ``subscription_id``.
If ``since_version`` is given, the implementation MAY return only
skills changed since that version (incremental). If omitted or if
the implementation does not support incremental fetch, returns the
full entitled set (the runner treats ``is_full_replace`` as truthy).
"""
...
@dataclass
class SyncDelta:
"""Result of a single ``fetch_entitled_skills`` call."""
skills: list[Skill]
removed_ids: list[str] = field(default_factory=list)
latest_version: int | None = None
is_full_replace: bool = True
@dataclass
class SyncOutcome:
"""Per-subscription result of one ``SkillSyncRunner.tick()`` pass."""
success: bool
error: str | None
fetched: int
class HttpSubscriptionClient:
"""Concrete HTTP implementation of :class:`SubscriptionClient`.
The request/response shape is shaped to an assumed Subscription Platform
REST API; once the real API is finalized, only this class needs to
change (see design.md D3 and Open Questions).
"""
def __init__(
self,
base_url: str,
auth_token: str | None = None,
*,
timeout: float = 30.0,
client: httpx.Client | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.auth_token = auth_token
self.timeout = timeout
self._client = client
def fetch_entitled_skills(
self,
subscription_id: str,
since_version: int | None = None,
) -> SyncDelta:
url = f"{self.base_url}/subscriptions/{subscription_id}/skills"
params: dict[str, Any] = {}
if since_version is not None:
params["since_version"] = str(since_version)
headers: dict[str, str] = {}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
response = self._ensure_client().get(
url,
params=params or None,
headers=headers or None,
timeout=self.timeout,
)
response.raise_for_status()
return _parse_sync_payload(response.json())
def close(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
def _ensure_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client()
return self._client
def _parse_sync_payload(payload: dict[str, Any]) -> SyncDelta:
"""Parse the assumed Subscription Platform response into a SyncDelta."""
skills: list[Skill] = []
for item in payload.get("skills") or []:
kind = item.get("kind", "knowledge")
if kind == "flow_template":
skills.append(FlowTemplateSkill.from_dict(item))
else:
skills.append(KnowledgeSkill.from_dict(item))
removed_ids = [str(rid) for rid in payload.get("removed_ids") or []]
latest_raw = payload.get("latest_version")
latest_version = int(latest_raw) if latest_raw is not None else None
is_full_replace = bool(payload.get("is_full_replace", True))
return SyncDelta(
skills=skills,
removed_ids=removed_ids,
latest_version=latest_version,
is_full_replace=is_full_replace,
)
class SkillSyncRunner:
"""Drives periodic sync between the Subscription Platform and local catalog.
Push (webhook) is optional: poll-only operation is the baseline. The
runner does not auto-start any thread on import — call
:meth:`start_background` to begin polling, or :meth:`tick` for manual
control (used by tests and by the webhook trigger).
"""
def __init__(
self,
*,
store: SkillCatalogStore,
client: SubscriptionClient,
subscriptions: list[str],
poll_interval: float = 300.0,
) -> None:
self.store = store
self.client = client
self.subscriptions = list(subscriptions)
self.poll_interval = poll_interval
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._tick_lock = threading.Lock()
for sub_id in self.subscriptions:
self.store._register_subscription(sub_id)
def tick(self) -> dict[str, SyncOutcome]:
"""Run one sync pass across all configured subscriptions.
Failures do not raise; the cache is preserved and the failure is
recorded in the subscriptions row (``last_error`` /
``last_synced_at``) for observability.
"""
with self._tick_lock:
return {sub_id: self._sync_one(sub_id) for sub_id in self.subscriptions}
def sync_one(self, subscription_id: str) -> SyncOutcome:
"""Sync a single subscription immediately (used by the webhook)."""
with self._tick_lock:
return self._sync_one(subscription_id)
def start_background(self) -> None:
"""Start a daemon thread that calls :meth:`tick` on the configured interval."""
if self._thread is not None:
return
self._stop.clear()
self._thread = threading.Thread(target=self._run_forever, daemon=True)
self._thread.start()
def stop_background(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=5.0)
self._thread = None
def _run_forever(self) -> None:
while not self._stop.is_set():
try:
self.tick()
except Exception:
log.exception("skill sync tick raised unexpectedly")
self._stop.wait(self.poll_interval)
def _sync_one(self, subscription_id: str) -> SyncOutcome:
try:
delta = self.client.fetch_entitled_skills(subscription_id)
except Exception as exc:
log.warning(
"skill sync fetch failed for %s: %s", subscription_id, exc
)
self.store._set_subscription_state(
subscription_id,
last_error=f"{type(exc).__name__}: {exc}",
bump_synced_at=True,
)
return SyncOutcome(success=False, error=str(exc), fetched=0)
if delta.is_full_replace:
self.store._apply_sync_replace_all(
subscription_id,
delta.skills,
latest_version=delta.latest_version,
last_error=None,
)
else:
for skill in delta.skills:
self.store._apply_sync_upsert(skill, subscription_id)
for skill_id in delta.removed_ids:
self.store._apply_sync_remove(skill_id)
self.store._set_subscription_state(
subscription_id,
last_synced_version=delta.latest_version,
last_error=None,
)
return SyncOutcome(success=True, error=None, fetched=len(delta.skills))
def register_skill_sync_webhook(app: Any, runner: SkillSyncRunner) -> Any:
"""Attach ``POST /webhooks/skill-sync`` to a FastAPI app.
On notification, triggers an immediate ``runner.tick()`` out-of-cycle.
Optional — the poll loop remains correct standalone. The endpoint
accepts an optional JSON body ``{"subscription_id": "..."}`` to sync
only that subscription; if omitted, syncs all configured subscriptions.
Returns the runner so the caller can chain.
"""
from fastapi import Body, FastAPI
if not isinstance(app, FastAPI):
raise TypeError("register_skill_sync_webhook requires a FastAPI app")
@app.post("/webhooks/skill-sync")
async def _skill_sync_webhook(
body: dict | None = Body(default=None),
) -> dict[str, Any]:
sub_id = (body or {}).get("subscription_id")
if sub_id:
outcome = runner.sync_one(str(sub_id))
outcomes = {str(sub_id): outcome}
else:
outcomes = runner.tick()
return {
"ok": True,
"outcomes": {
sid: {"success": o.success, "error": o.error, "fetched": o.fetched}
for sid, o in outcomes.items()
},
}
return runner
@@ -46,6 +46,26 @@ Even though sync only ever pulls "entitled" skills, `skills/catalog.py`'s query
`skills/mcp_tools.py` exposes `get_skill` (returns metadata + content/template) and a parameter-resolution helper; it does **not** expose a `run_skill` tool that silently executes on the LLM's behalf. The LLM fetches the flow template, fills placeholders itself (or asks the user for missing required parameters), and then issues the already-existing device-capability tool calls (`tap`, `input_text`, etc.) from `apex-agent-mvp` in the order/values the template specifies.
- **Alternative considered**: Add a single `run_skill_flow(skill_id, params)` MCP tool that executes the whole sequence server-side. Rejected for this change — it would bypass the Agent Runtime's Observe→Think→Act loop and Executor retry/wait logic from `apex-agent-mvp`, silently skipping Scene verification between steps. Keeping execution inside the existing loop (LLM issues each tool call itself, informed by the template) preserves the "AI decides based on what it currently observes" principle the whole platform is built on. A batched execution helper can be revisited later as a design decision in its own change if needed.
### D6: Reuse `skills_learning/` domain models and `workflow/skill_exec.py` resolver; do not duplicate them
The archived `skill-learning-runtime` change already shipped `skills_learning/models.py` defining `SkillKind` (`"knowledge" | "flow_template"`), `SkillMetadata`, `FlowStep`, `Skill`, and `FlowTemplateSkill`, plus `workflow/skill_exec.py` with `validate_skill_args` / `resolve_skill_steps` / `_resolve_value`. This change **does not introduce a parallel `skills/` package**. Instead:
- Reuse all existing domain types verbatim. The only model-layer addition is a `KnowledgeSkill(Skill)` class in `skills_learning/models.py` (the `kind` Literal already includes `"knowledge"` but no concrete class exists for it yet).
- The MCP parameter-resolution helper (§4.2 of tasks) wraps `workflow.skill_exec.resolve_skill_steps` rather than reimplementing it.
- The archived `skill-authoring` spec (now in `openspec/specs/skill-authoring/spec.md`) explicitly anticipated this change: locally-synthesized skills live in `skills_learning/store.py` tagged `source = "local-synthesis"` and **must not write into** the externally-synced catalog store. This change honors that boundary by using a **physically separate** SQLite file (`tasks/skills.sqlite3`) for synced skills.
- **Alternative considered**: Move all shared skill domain types into `core/skill_models.py` to make the cross-feature reuse explicit. Deferred — would be a larger refactor of already-tested code, and the boundary is data-model-only (no store/runtime coupling). Revisited if a third consumer appears.
### D7: CONSTITUTION.md compliance — code lands in `core`-extended, `storage`, and `api` layers only
Per `docs/CONSTITUTION.md` lines 33-34 ("HTTP and MCP dependencies enter at `api`. No LLM, HTTP, or MCP dependency may appear in `core`, `driver`, `device`, or `tools`."), and the dependency direction `core → driver/device → tools → perception → storage → runtime → api`, this change's files map as follows:
| Concern | File (new or extended) | Layer | Allowed deps |
|---|---|---|---|
| Domain models (extend) | `skills_learning/models.py` | core (feature-scoped) | dataclasses, `core.models` only — zero HTTP/MCP/LLM |
| Synced-skill local store + query | `storage/skill_catalog.py` (new) | storage | `sqlite3`, `skills_learning.models` — zero HTTP/MCP/LLM |
| Subscription HTTP client + poll loop + webhook | `api/skill_sync.py` (new) | api | `httpx`, `storage.skill_catalog` |
| Skill MCP tools (list/search/get/resolve) | `api/skill_catalog_mcp.py` (new) | api | `mcp.server.fastmcp`, `storage.skill_catalog`, `workflow.skill_exec` |
| MCP server wire-up | `api/mcp.py` (one-line addition) | api | calls `register_skill_catalog_tools(...)` at end of `create_mcp_server` |
`core`, `driver`, `device`, `tools` receive **zero new imports** from this change. The `tasks/skills.sqlite3` file is physically separate from both `tasks/tasks.sqlite3` (task metadata) and the in-memory `skills_learning.SkillStore` (local synthesis), preserving the archived `skill-authoring` spec's "no cross-writes" contract at the storage layer.
## Risks / Trade-offs
- **[Risk]** The Subscription Platform's actual API shape is unknown/assumed (`fetch_entitled_skills`, optional webhook) → **Mitigation**: keep `skills/sync_client.py` behind a small internal interface (similar to `Driver` in `apex-agent-mvp`) so the concrete HTTP client can be adjusted once the real Subscription Platform API is finalized, without touching `catalog.py` or `mcp_tools.py`.
@@ -53,13 +73,25 @@ Even though sync only ever pulls "entitled" skills, `skills/catalog.py`'s query
- **[Risk]** Flow-template skills reference tools/parameters that drift from the actual `tools/` function signatures in `apex-agent-mvp` (e.g. a template calls a tool that was renamed) → **Mitigation**: validate a flow template's `tool` names against the currently registered MCP/tool set at sync time (or at least at `get_skill` time) and surface a clear "skill unavailable/invalid" error rather than letting a bad call reach the device.
- **[Trade-off]** No local skill authoring/editing keeps this change simple and avoids ownership ambiguity, but means Apex Agent is fully dependent on the Subscription Platform being reachable at least once to have any skills at all — acceptable since the platform is a required dependency by design, not an optional enhancement.
- **[Trade-off]** Not providing a server-side `run_skill_flow` execution tool keeps flow templates consistent with the Observe-Think-Act loop, at the cost of the LLM needing a few more tool-call round trips per flow skill than a single batched call would take — acceptable given the platform's core principle of always re-observing between actions.
- **[Risk]** Reusing `skills_learning/models.py` as the domain model for synced skills couples this change to the `skills_learning` feature package. If either consumer (local synthesis or external sync) needs a divergent model shape, ripple effects result. → **Mitigation**: the coupling is at the data-model layer only — no store, runtime, or executor coupling exists. If a third consumer appears or divergence is needed, refactor the shared types into `core/skill_models.py` as a separate cleanup change (not blocking this one).
## Migration Plan
Additive only: new `skills/` package, new MCP tools, and new local storage tables/files. No existing `apex-agent-mvp` code paths are modified (only composed with, per D-decisions above). If `apex-agent-mvp` has already been applied, this change's MCP tools register onto its existing MCP server instance; if not yet applied, `skills/mcp_tools.py` can stand up its own MCP server instance for independent testing and be merged once `apex-agent-mvp` lands. Rollback is simply removing the `skills/` package and its MCP tool registrations; no data migration or schema changes to existing tables are required.
Additive across existing packages — no new top-level package is introduced:
- **`skills_learning/models.py`**: gains one new dataclass (`KnowledgeSkill`). No changes to existing types; existing tests in `tests/test_skill_*.py` are unaffected.
- **`storage/skill_catalog.py`** (new): independent module + a new SQLite file `tasks/skills.sqlite3`. Does not touch `storage/task_metadata.py`'s `tasks/tasks.sqlite3` or `skills_learning/store.py`'s in-memory store.
- **`api/skill_sync.py`** (new): HTTP sync client + poll runner + webhook handler. Self-contained; the webhook registers on the existing FastAPI app from `apex-agent-mvp` if applied, or stands alone for testing.
- **`api/skill_catalog_mcp.py`** (new): MCP tool registration helper.
- **`api/mcp.py`**: one-line addition — `register_skill_catalog_tools(server, ...)` called at the end of `create_mcp_server`. Existing device-capability tools are unchanged.
- Rollback: remove the four new/extended files and delete `tasks/skills.sqlite3`. No data migration or schema changes to existing tables are required.
If `apex-agent-mvp` is not yet applied, `api/skill_catalog_mcp.py` can stand up its own MCP server instance for independent testing (mirroring the original migration note); otherwise it composes onto the existing server via the one-line wire-up above.
## Open Questions
- Exact Subscription Platform API contract (auth mechanism, request/response shapes, whether it supports `since_version` incremental sync or only full-catalog fetch) — to be confirmed with that platform's team/spec before `skills/sync_client.py` is finalized; this design assumes an interface shape that can absorb either.
- Whether flow-template `args_template` placeholders need a richer expression language (e.g. simple conditionals) or plain `{param}` substitution is sufficient for the MVP — left open, default to plain substitution and revisit if a real skill needs more.
- Where the local Skill Catalog lives relative to `apex-agent-mvp`'s existing SQLite task-metadata DB (same DB file, new tables, vs. a separate DB/file) — deferred to implementation time in `tasks.md`, doesn't affect the capability contracts defined here.
- Exact Subscription Platform API contract (auth mechanism, request/response shapes, whether it supports `since_version` incremental sync or only full-catalog fetch) — to be confirmed with that platform's team/spec before the concrete `HttpSubscriptionClient` in `api/skill_sync.py` is finalized. The `SubscriptionClient` Protocol (§3.1 of tasks) is shaped to absorb either full or incremental fetch; the concrete HTTP mapping is the only thing that changes once the real API is known.
- Whether flow-template `args_template` placeholders need a richer expression language (e.g. simple conditionals) or plain `{param}` substitution is sufficient for the MVP — left open, default to plain substitution (matching existing `workflow.skill_exec._resolve_value`) and revisit if a real skill needs more.
## Resolved Questions
- **Local catalog storage location** (previously open): resolved by D7 — a physically separate SQLite file at `tasks/skills.sqlite3`, owned by `storage/skill_catalog.py`. Chosen to preserve the archived `skill-authoring` spec's "no cross-writes between local-synthesis and synced-skill stores" contract at the storage layer (separate files make the boundary physical, not just conventional). Reusing `tasks/tasks.sqlite3`'s file with new tables was rejected because it would couple sync-skill schema migrations to task-metadata migrations.
@@ -21,8 +21,8 @@ Apex Agent's MCP tool server (see change `apex-agent-mvp`) gives the LLM raw dev
## Impact
- **New code**: `skills/` package — `skills/models.py` (Skill, KnowledgeSkill, FlowTemplateSkill, SkillMetadata), `skills/catalog.py` (local store + search/query), `skills/sync_client.py` (Subscription Platform client: pull/push, auth, visibility filtering), `skills/mcp_tools.py` (registers `list_skills`/`search_skills`/`get_skill`/flow-param-resolution as MCP tools).
- **Dependencies**: depends on the `apex-agent-mvp` change for the MCP server process and `tools/`/`runtime/` capability layer that flow-template skills ultimately drive (a flow-template skill's steps still execute through existing `tools/` functions); does not depend on any new external dependency beyond an HTTP client for the sync API.
- **New code**: extends existing packages rather than adding a new one. Adds `KnowledgeSkill` to `skills_learning/models.py` (reusing the already-shipped `SkillKind`/`SkillMetadata`/`FlowStep`/`Skill`/`FlowTemplateSkill`); new `storage/skill_catalog.py` (synced-skill local store + query); new `api/skill_sync.py` (Subscription Platform HTTP client, poll loop, optional webhook receiver); new `api/skill_catalog_mcp.py` (registers `list_skills`/`search_skills`/`get_skill`/`resolve_flow_template` as MCP tools, wrapping the existing `workflow/skill_exec.resolve_skill_steps`); one-line wire-up in `api/mcp.py:create_mcp_server`.
- **Dependencies**: reuses the already-shipped domain models from `skills_learning/` (archived `skill-learning-runtime` change) and the parameter-resolution logic from `workflow/skill_exec.py`. Depends on the `apex-agent-mvp` MCP server surface for tool registration. No new external dependency beyond `httpx` (already in `pyproject.toml`) for the sync API.
- **External systems**: introduces a new external dependency — the Subscription Platform's API (assumed to expose an endpoint to fetch entitled skills and, optionally, a webhook/push channel for change notifications). Exact base URL/auth mechanism is a deployment-time configuration, not a code dependency.
- **Storage**: adds a local Skill Catalog store (SQLite table(s) alongside the existing task-metadata DB from `apex-agent-mvp`, or an embedded file-based store — to be decided in design) plus a small sync-state table (last-synced version/timestamp per subscription).
- **Storage**: adds a new local Skill Catalog store as a **physically separate** SQLite file `tasks/skills.sqlite3` (owned by `storage/skill_catalog.py`), distinct from `tasks/tasks.sqlite3` (task metadata) and the in-memory `skills_learning.SkillStore`. This preserves the archived `skill-authoring` spec's contract that locally-synthesized and externally-synced skills never share storage. A small sync-state table (last-synced version/timestamp/error per subscription) lives in the same file.
- **Follow-on work explicitly deferred**: Subscription Platform's own design/build, skill-authoring workflows, billing/entitlement logic beyond "is this skill visible to me," and any LLM-driven automatic skill generation.
@@ -1,39 +1,38 @@
## 1. Package scaffolding & data model
## 1. Domain model extension (reuse `skills_learning/`)
- [ ] 1.1 Create `skills/` package (`__init__.py`) alongside existing `core/`, `tools/`, `vision/`, `runtime/`, `api/`, `storage/`
- [ ] 1.2 Add `skills/models.py`: `SkillMetadata` (id, name, description, version, tags, source/subscription id, updated_at), `KnowledgeSkill` (content), `FlowTemplateSkill` (steps, parameters), and a `kind` discriminator uniting them
- [ ] 1.3 Add dependencies if needed (HTTP client for sync, no new heavy deps expected) to `pyproject.toml`
- [x] 1.1 Add `KnowledgeSkill(Skill)` dataclass to `skills_learning/models.py` carrying a `content: str` field plus `to_dict`/`from_dict` mirroring `FlowTemplateSkill`'s shape. The `SkillKind` Literal already includes `"knowledge"` — no discriminator change needed.
- [x] 1.2 Confirm `pyproject.toml` already includes `httpx>=0.27.0` and `mcp>=1.27,<2`; no new dependencies required. (If a missing dep is discovered during impl, add it here.)
## 2. Skill Catalog storage & query (capability: skill-catalog)
## 2. Catalog storage & query (capability: skill-catalog; layer: `storage/`)
- [ ] 2.1 Implement `skills/catalog.py` local store (decide SQLite table(s) reusing the existing DB vs. separate file, per design's open question) with create/update/remove operations used only by the sync path
- [ ] 2.2 Implement `list_skills(caller_context)` returning only skills visible under the caller's active subscriptions, stable-sorted
- [ ] 2.3 Implement `search_skills(query, caller_context)` matching name/tags/description
- [ ] 2.4 Implement `get_skill(skill_id, caller_context)` returning full content, or a clear "not found" for unknown/invisible ids
- [ ] 2.5 Reject/ignore any direct create-or-edit call to the catalog that didn't come from the sync path (enforce "Subscription Platform is sole source of truth")
- [ ] 2.6 Implement flow-template tool-reference validation: check each step's `tool` name against the currently registered device-capability tools; mark skill invalid/unavailable if any reference is dangling
- [ ] 2.7 Write unit tests: list/search/get visibility filtering, not-found cases, and flow-template validation (valid and dangling-reference cases)
- [x] 2.1 Create `storage/skill_catalog.py` with `SkillCatalogStore(db_path="tasks/skills.sqlite3")` using SQLite. Schema: `subscriptions(id, active, last_synced_version, last_error, last_synced_at)` and `skills(id, subscription_id, name, description, kind, tags_json, version, source, updated_at, content, steps_json, parameters_json)`. Public read API only (`list_skills`, `search_skills`, `get_skill`); write surface is `_apply_sync_*` methods callable only from `api/skill_sync.py`.
- [x] 2.2 `list_skills(active_subscriptions: set[str]) -> list[SkillMetadata]` returns only skills whose `subscription_id ∈ active_subscriptions`, stable-sorted by `name`.
- [x] 2.3 `search_skills(query: str, active_subscriptions: set[str]) -> list[SkillMetadata]` — substring match on `name`/`tags`/`description`, ranked by simple relevance (e.g. name-prefix → name-contains → tag/desc).
- [x] 2.4 `get_skill(skill_id: str, active_subscriptions: set[str]) -> KnowledgeSkill | FlowTemplateSkill | None` — returns `None` for unknown id **and** for known-but-not-visible id (no existence leak).
- [x] 2.5 Enforce sync-only writes: no public `create`/`update`/`remove` methods. The `_apply_sync_upsert` / `_apply_sync_remove` / `_apply_sync_replace_all` methods are prefixed with `_` to signal the contract; only `api/skill_sync.py` imports them.
- [x] 2.6 `validate_flow_template_tools(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool` — returns `False` if any `step.tool_name` is not in `registered_tools`; `get_skill` uses this to return `None` (treat as unavailable) when references dangle.
- [x] 2.7 `tests/test_skill_catalog.py` — unit tests covering: visibility filtering on list/search/get, not-found vs not-visible indistinguishability, dangling-tool-reference invalidation, stable sort order, round-trip serialization for both `kind` values.
## 3. Subscription sync client (capability: skill-subscription-sync)
## 3. Subscription sync client (capability: skill-subscription-sync; layer: `api/`)
- [ ] 3.1 Define the internal sync interface (e.g. `SubscriptionClient.fetch_entitled_skills(subscription_id, since_version)` supporting full or incremental fetch) in `skills/sync_client.py`
- [ ] 3.2 Implement a concrete HTTP-based `SubscriptionClient` against the assumed Subscription Platform API (configurable base URL/auth), isolated behind the interface from 3.1
- [ ] 3.3 Implement the poll loop: fetch on a configurable interval, diff against local catalog, and apply create/update/remove to `skills/catalog.py`
- [ ] 3.4 Implement optional push-triggered sync: a receiver (e.g. a small webhook endpoint) that, on notification, triggers an immediate out-of-cycle pull rather than being required for correctness
- [ ] 3.5 Implement subscription-based visibility enforcement at query time (re-check active subscription set, not just last-sync membership) so revoked entitlements stop being visible without waiting for the next full sync
- [ ] 3.6 Implement sync failure handling: on network/auth/malformed-response errors, leave the local catalog unchanged and record a last-error timestamp/reason for observability
- [ ] 3.7 Write unit tests: create/update/remove sync scenarios, push-triggered immediate sync, query-time revocation re-check, and sync-failure-preserves-cache behavior (using a fake `SubscriptionClient`)
- [x] 3.1 Define `SubscriptionClient` Protocol in `api/skill_sync.py`: `fetch_entitled_skills(subscription_id: str, since_version: int | None = None) -> SyncDelta` where `SyncDelta` carries `skills: list[Skill]`, `removed_ids: list[str]`, `latest_version: int`. Supports both full (since_version=None) and incremental fetch shapes.
- [x] 3.2 Implement `HttpSubscriptionClient(base_url, auth_token, timeout)` satisfying the Protocol, using `httpx`. Auth and request/response shape isolated behind this class so the real Subscription Platform API can be wired in by adjusting only this file.
- [x] 3.3 Implement `SkillSyncRunner(store, client, subscriptions: list[str], poll_interval)`: a `tick()` method that fetches deltas for each subscription and applies them via `store._apply_sync_*`. Provide both a manual `tick()` API and a `run_forever()` blocking loop; do not auto-start any thread on import.
- [x] 3.4 Optional push receiver: add `POST /webhooks/skill-sync` to `api/rest.py` (or a new `api/skill_sync_webhook.py` if rest.py grows too large) that calls `SkillSyncRunner.tick()` immediately. Documented as optional — poll loop remains correct standalone.
- [x] 3.5 Query-time revocation re-check (D4): `active_subscriptions` parameter accepted by `list_skills`/`search_skills`/`get_skill` is **re-read from the `subscriptions` table on every call** (caller passes subscription IDs; store checks `active = 1`). Revoking a subscription (sync sets `active = 0`) takes effect on the next query, not just the next sync.
- [x] 3.6 Sync failure handling: `SkillSyncRunner.tick()` wraps `client.fetch_entitled_skills` in try/except; on any exception (network/auth/malformed-response), leave `store` unchanged and write `last_error` + `last_synced_at` to the `subscriptions` row. Cache continues serving last-known-good state.
- [x] 3.7 `tests/test_skill_sync_client.py` — using a `FakeSubscriptionClient` implementing the Protocol: new/updated/removed delta scenarios, push-triggered immediate `tick()`, query-time revocation before next sync (3.5), failure-preserves-cache (3.6).
## 4. Skill MCP tools (capability: skill-mcp-tools)
## 4. Skill MCP tools (capability: skill-mcp-tools; layer: `api/`)
- [ ] 4.1 Implement `skills/mcp_tools.py` registering `list_skills`, `search_skills`, `get_skill` as MCP tools on the existing MCP server surface from `apex-agent-mvp` (or a standalone MCP server instance if that change is not yet applied)
- [ ] 4.2 Implement the flow-template parameter-resolution MCP tool: validate provided values against a skill's `parameters` schema and return the fully substituted step sequence, or a clear validation error listing missing/invalid parameters
- [ ] 4.3 Add semantic error translation for skill MCP tools (not-found, not-visible/entitled, invalid/unavailable flow template) consistent in style with the device-capability tool error handling
- [ ] 4.4 Confirm no MCP tool exists that both resolves and executes a flow-template skill's steps server-side (execution stays with the LLM issuing existing device-capability tool calls one at a time)
- [ ] 4.5 Write tests: `list_skills`/`search_skills`/`get_skill` via MCP against a seeded catalog (mocked sync), parameter-resolution success/failure cases, and an explicit check that no batch-execute tool is registered
- [x] 4.1 Create `api/skill_catalog_mcp.py` with `register_skill_catalog_tools(server, store, get_active_subscriptions: Callable[[], set[str]], registered_tools: Callable[[], set[str]])`. Wire it into `api/mcp.py:create_mcp_server` with a single call at the end.
- [x] 4.2 `resolve_flow_template(skill_id, params: dict)` MCP tool: thin wrapper over `workflow.skill_exec.resolve_skill_steps` (after fetching the skill via `store.get_skill`). Returns the substituted step list. Missing/invalid params surface as semantic errors via 4.3.
- [x] 4.3 Register `list_skills`/`search_skills`/`get_skill`/`resolve_flow_template` MCP tools with semantic error translation consistent with `api/errors.py:call_with_semantic_errors`. Distinct error codes for `SkillNotFound`, `SkillNotVisible` (mapped to not-found at the response layer — no existence leak), `InvalidFlowTemplate` (dangling tool reference), `MissingParameter`.
- [x] 4.4 Confirm (via test 4.5) that **no** `run_skill_flow` or any other batch-execute MCP tool is registered. Execution stays with the LLM issuing existing device-capability tool calls one at a time, per D5.
- [x] 4.5 `tests/test_skill_catalog_mcp.py` — drive `FastMCP` in-memory tool registry: assert `list_skills`/`search_skills`/`get_skill`/`resolve_flow_template` are registered; assert no tool whose name suggests batch execution is registered; exercise each tool against a seeded in-memory `SkillCatalogStore` with mocked `get_active_subscriptions`; cover not-found, not-visible, dangling-tool-reference, and missing-parameter error paths.
## 5. End-to-end validation
- [ ] 5.1 Seed a local catalog via a fake `SubscriptionClient` with one knowledge skill and one flow-template skill, and verify both are listable/searchable/fetchable via MCP tools
- [ ] 5.2 Simulate an entitlement revocation (remove a skill from the fake client's entitled set) and confirm it disappears from `list_skills`/`get_skill` after both a sync cycle and, separately, via the query-time re-check before the next sync completes
- [ ] 5.3 Resolve a flow-template skill's parameters via MCP, then manually drive the resulting step sequence through the existing `apex-agent-mvp` device-capability tools against a mocked device, confirming each step still goes through the normal Observe-Think-Act loop
- [x] 5.1 `tests/test_skill_catalog_e2e.py` — seed a `SkillCatalogStore` via a `FakeSubscriptionClient` with one knowledge skill and one flow-template skill; assert both are listable/searchable/gettable through the MCP tool surface (using the same `register_skill_catalog_tools` registration).
- [x] 5.2 Same e2e harness — simulate revocation: remove the skill from the fake client's entitled set; run one sync `tick()` and confirm the skill disappears from `list_skills`/`get_skill`; separately, revoke subscription (`active = 0`) without running sync and confirm the query-time re-check (3.5) hides the skill before the next sync completes.
- [x] 5.3 Same e2e harness — call `resolve_flow_template` via MCP, then drive the resulting step sequence through the existing `apex-agent-mvp` device-capability tools (`tap`/`input_text`/etc.) against a mocked device (`tests/fakes.py` or a new fake), confirming each step still flows through the normal Executor Observe-Think-Act loop (no batched execution shortcut).
+8 -1
View File
@@ -1,12 +1,19 @@
"""Local skill learning from completed task timelines."""
from skills_learning.config import SkillAuthoringConfig, load_config
from skills_learning.models import FlowStep, FlowTemplateSkill, Skill, SkillMetadata
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
Skill,
SkillMetadata,
)
from skills_learning.store import SkillStore, get_default_store
__all__ = [
"FlowStep",
"FlowTemplateSkill",
"KnowledgeSkill",
"Skill",
"SkillAuthoringConfig",
"SkillMetadata",
+21
View File
@@ -166,6 +166,27 @@ class FlowTemplateSkill(Skill):
)
@dataclass(frozen=True)
class KnowledgeSkill(Skill):
content: str = ""
def to_dict(self) -> dict[str, Any]:
return {
**self.metadata.to_dict(),
"content": self.content,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "KnowledgeSkill":
return cls(
metadata=SkillMetadata.from_dict(data),
content=str(data.get("content") or ""),
)
def with_metadata(self, **changes: Any) -> "KnowledgeSkill":
return replace(self, metadata=replace(self.metadata, **changes))
def skill_embedding_text(skill: FlowTemplateSkill) -> str:
goal = skill.originating_goal or ""
return f"{skill.name}: {skill.description}\nOriginal goal: {goal}"
+407
View File
@@ -0,0 +1,407 @@
"""Local storage for skills synced from the external Subscription Platform.
Storage layer per CONSTITUTION.md: zero HTTP/MCP/LLM dependencies. Public read
API is list_skills/search_skills/get_skill only. Write methods are prefixed
with ``_`` and are callable only from :mod:`api.skill_sync` — this enforces
the "Subscription Platform is sole source of truth" contract from the
``skill-authoring`` capability boundary (no local authoring into synced rows).
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from core.models import utc_now
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
Skill,
SkillMetadata,
)
class SkillCatalogStore:
"""SQLite-backed local cache of externally-synced skills.
The database file defaults to ``tasks/skills.sqlite3``, physically separate
from both ``tasks/tasks.sqlite3`` (task metadata) and the in-memory
``skills_learning.SkillStore`` (local synthesis), honoring the
``skill-authoring`` capability's "no cross-writes" contract.
"""
def __init__(self, db_path: str | Path = "tasks/skills.sqlite3") -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
# ------------------------------------------------------------------
# Public read API
# ------------------------------------------------------------------
def list_skills(
self,
active_subscriptions: set[str],
) -> list[SkillMetadata]:
effective = self._effective_active(active_subscriptions)
if not effective:
return []
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT id, name, description, kind, tags_json, version,
source, created_at, updated_at
FROM skills
WHERE subscription_id IN ({_placeholders(effective)})
""",
tuple(effective),
).fetchall()
metas = [_row_to_metadata(row) for row in rows]
metas.sort(key=lambda m: m.name)
return metas
def search_skills(
self,
query: str,
active_subscriptions: set[str],
) -> list[SkillMetadata]:
effective = self._effective_active(active_subscriptions)
if not effective:
return []
like = f"%{query}%"
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT id, name, description, kind, tags_json, version,
source, created_at, updated_at
FROM skills
WHERE subscription_id IN ({_placeholders(effective)})
AND (name LIKE ? OR description LIKE ? OR tags_json LIKE ?)
""",
(*effective, like, like, like),
).fetchall()
metas = [_row_to_metadata(row) for row in rows]
_rank_by_relevance(metas, query)
return metas
def get_skill(
self,
skill_id: str,
active_subscriptions: set[str],
*,
registered_tools: set[str] | None = None,
) -> KnowledgeSkill | FlowTemplateSkill | None:
"""Return the skill if visible, else ``None``.
Returns ``None`` for both unknown ids and known-but-not-visible ids
(no existence leak). If ``registered_tools`` is provided and the skill
is a flow template with a dangling tool reference, also returns
``None`` (treat as unavailable per task 2.6).
"""
effective = self._effective_active(active_subscriptions)
if not effective:
return None
with self._connect() as conn:
row = conn.execute(
f"""
SELECT * FROM skills
WHERE id = ? AND subscription_id IN ({_placeholders(effective)})
""",
(skill_id, *effective),
).fetchone()
if row is None:
return None
skill = _row_to_skill(row)
if (
isinstance(skill, FlowTemplateSkill)
and registered_tools is not None
and not validate_flow_template_tools(skill, registered_tools)
):
return None
return skill
# ------------------------------------------------------------------
# Sync-only write API (private by convention; only api.skill_sync uses it)
# ------------------------------------------------------------------
def _register_subscription(
self,
subscription_id: str,
*,
active: bool = True,
) -> None:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO subscriptions
(id, active, last_synced_version, last_error, last_synced_at)
VALUES (?, ?, NULL, NULL, NULL)
ON CONFLICT(id) DO NOTHING
""",
(subscription_id, 1 if active else 0),
)
def _set_subscription_state(
self,
subscription_id: str,
*,
active: bool | None = None,
last_synced_version: int | None = None,
last_error: str | None = None,
bump_synced_at: bool = True,
) -> None:
updates: dict[str, Any] = {}
if bump_synced_at:
updates["last_synced_at"] = utc_now().isoformat()
if active is not None:
updates["active"] = 1 if active else 0
if last_synced_version is not None:
updates["last_synced_version"] = last_synced_version
if last_error is not None:
updates["last_error"] = last_error
if not updates:
return
assignments = ", ".join(f"{key} = ?" for key in updates)
values = (*updates.values(), subscription_id)
with self._connect() as conn:
conn.execute(
f"UPDATE subscriptions SET {assignments} WHERE id = ?",
values,
)
def _apply_sync_upsert(
self,
skill: Skill,
subscription_id: str,
) -> None:
with self._connect() as conn:
self._upsert_skill_row(conn, skill, subscription_id)
def _apply_sync_remove(self, skill_id: str) -> None:
with self._connect() as conn:
conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
def _apply_sync_replace_all(
self,
subscription_id: str,
skills: list[Skill],
*,
latest_version: int | None = None,
last_error: str | None = None,
) -> None:
"""Atomically replace all skills belonging to one subscription."""
with self._connect() as conn:
conn.execute(
"DELETE FROM skills WHERE subscription_id = ?",
(subscription_id,),
)
for skill in skills:
self._upsert_skill_row(conn, skill, subscription_id)
conn.execute(
"""
UPDATE subscriptions
SET last_synced_at = ?,
last_synced_version = ?,
last_error = ?
WHERE id = ?
""",
(
utc_now().isoformat(),
latest_version,
last_error,
subscription_id,
),
)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _effective_active(self, requested: set[str]) -> set[str]:
"""Query-time revocation re-check (design D4).
Intersect the caller-requested subscriptions with those still marked
``active = 1`` in the subscriptions table, so a revocation takes
effect on the next query, not just the next sync.
"""
if not requested:
return set()
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT id FROM subscriptions
WHERE active = 1 AND id IN ({_placeholders(requested)})
""",
tuple(requested),
).fetchall()
return {row["id"] for row in rows}
def _upsert_skill_row(
self,
conn: sqlite3.Connection,
skill: Skill,
subscription_id: str,
) -> None:
meta = skill.metadata
if isinstance(skill, KnowledgeSkill):
content = skill.content
steps_payload: list[dict[str, Any]] = []
parameters_payload: dict[str, dict[str, Any]] = {}
else:
content = ""
steps_payload = [step.to_dict() for step in skill.steps]
parameters_payload = {
name: dict(schema) for name, schema in skill.parameters.items()
}
conn.execute(
"""
INSERT INTO skills (
id, subscription_id, name, description, kind, tags_json,
version, source, created_at, updated_at,
content, steps_json, parameters_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
subscription_id = excluded.subscription_id,
name = excluded.name,
description = excluded.description,
kind = excluded.kind,
tags_json = excluded.tags_json,
version = excluded.version,
source = excluded.source,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
content = excluded.content,
steps_json = excluded.steps_json,
parameters_json = excluded.parameters_json
""",
(
meta.id,
subscription_id,
meta.name,
meta.description,
meta.kind,
json.dumps(list(meta.tags)),
meta.version,
meta.source,
meta.created_at.isoformat(),
meta.updated_at.isoformat(),
content,
json.dumps(steps_payload),
json.dumps(parameters_payload),
),
)
def _ensure_schema(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
active INTEGER NOT NULL DEFAULT 1,
last_synced_version INTEGER,
last_error TEXT,
last_synced_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
subscription_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
parameters_json TEXT NOT NULL DEFAULT '{}'
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_skills_subscription "
"ON skills(subscription_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name)"
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
def validate_flow_template_tools(
skill: FlowTemplateSkill,
registered_tools: set[str],
) -> bool:
"""Return ``True`` iff every step's ``tool_name`` is registered."""
return all(step.tool_name in registered_tools for step in skill.steps)
def _placeholders(values: "sized") -> str:
return ", ".join("?" for _ in values)
def _row_to_metadata(row: sqlite3.Row) -> SkillMetadata:
return SkillMetadata.from_dict(
{
"id": row["id"],
"name": row["name"],
"description": row["description"],
"kind": row["kind"],
"tags": json.loads(row["tags_json"] or "[]"),
"version": row["version"],
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
def _row_to_skill(row: sqlite3.Row) -> KnowledgeSkill | FlowTemplateSkill:
data = {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"kind": row["kind"],
"tags": json.loads(row["tags_json"] or "[]"),
"version": row["version"],
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"content": row["content"],
"steps": json.loads(row["steps_json"] or "[]"),
"parameters": json.loads(row["parameters_json"] or "{}"),
}
if row["kind"] == "knowledge":
return KnowledgeSkill.from_dict(data)
return FlowTemplateSkill.from_dict(data)
def _rank_by_relevance(metas: list[SkillMetadata], query: str) -> None:
"""Stable relevance ranking: name-prefix → name-contains → tag/desc match."""
lowered = query.lower()
def tier(meta: SkillMetadata) -> int:
name = meta.name.lower()
if name.startswith(lowered):
return 0
if lowered in name:
return 1
return 2
metas.sort(key=lambda m: (tier(m), m.name))
# Lightweight type alias for the sized-collection parameter to ``_placeholders``.
sized = Any # avoid typing.Sized import churn; parameter is only used for len()
+432
View File
@@ -0,0 +1,432 @@
"""Tests for storage/skill_catalog.py.
Covers task 2.7: visibility filtering, not-found indistinguishability,
dangling-tool-reference invalidation, stable sort, round-trip serialization
for both knowledge and flow_template kinds.
"""
from __future__ import annotations
from datetime import datetime
import pytest
from core.models import utc_now
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.skill_catalog import (
SkillCatalogStore,
validate_flow_template_tools,
)
@pytest.fixture
def store(tmp_path):
return SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
def _knowledge(skill_id: str, name: str, content: str, *, tags=None) -> KnowledgeSkill:
return KnowledgeSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="knowledge",
tags=tags or [],
source="subscription",
),
content=content,
)
def _flow(
skill_id: str,
name: str,
*,
steps: list[tuple[str, dict]],
parameters: dict | None = None,
tags=None,
) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="flow_template",
tags=tags or [],
source="subscription",
),
steps=[FlowStep(tool_name=t, args=a) for t, a in steps],
parameters=parameters or {},
)
# ----------------------------------------------------------------------
# Task 2.1: schema + sync-only write contract
# ----------------------------------------------------------------------
def test_store_creates_db_file(tmp_path):
db = tmp_path / "nested" / "skills.sqlite3"
SkillCatalogStore(db_path=db)
assert db.exists()
def test_register_subscription_is_idempotent(store):
store._register_subscription("sub-a")
store._register_subscription("sub-a")
# No error, single row, active by default
assert store._effective_active({"sub-a"}) == {"sub-a"}
def test_apply_sync_replace_all_clears_prior_skills(store):
store._register_subscription("sub-a")
store._apply_sync_replace_all(
"sub-a",
[_knowledge("k1", "keeps", "c"), _flow("f1", "flowy", steps=[("tap", {})])],
latest_version=5,
)
assert len(store.list_skills({"sub-a"})) == 2
# Replace with a single skill → previous two must be gone
store._apply_sync_replace_all("sub-a", [_knowledge("k2", "new", "c")])
ids = {m.id for m in store.list_skills({"sub-a"})}
assert ids == {"k2"}
# ----------------------------------------------------------------------
# Task 2.2 / 2.3 / 2.4: visibility filtering
# ----------------------------------------------------------------------
def _seed_two_subscriptions(store):
store._register_subscription("sub-a")
store._register_subscription("sub-b")
store._apply_sync_replace_all(
"sub-a",
[
_knowledge("k-a1", "Alpha Knowledge", "c-a1", tags=["alpha"]),
_flow(
"f-a2",
"Alpha Flow",
steps=[("tap", {"x": 1})],
tags=["alpha"],
),
],
)
store._apply_sync_replace_all(
"sub-b",
[
_knowledge("k-b1", "Beta Knowledge", "c-b1", tags=["beta"]),
],
)
def test_list_skills_filters_by_active_subscription(store):
_seed_two_subscriptions(store)
a = {m.id for m in store.list_skills({"sub-a"})}
b = {m.id for m in store.list_skills({"sub-b"})}
both = {m.id for m in store.list_skills({"sub-a", "sub-b"})}
assert a == {"k-a1", "f-a2"}
assert b == {"k-b1"}
assert both == {"k-a1", "f-a2", "k-b1"}
def test_list_skills_empty_active_set_returns_empty(store):
_seed_two_subscriptions(store)
assert store.list_skills(set()) == []
def test_list_skills_with_inactive_subscription(store):
_seed_two_subscriptions(store)
# Mark sub-a inactive in storage
store._set_subscription_state("sub-a", active=False)
assert store.list_skills({"sub-a"}) == []
# sub-b still visible
assert {m.id for m in store.list_skills({"sub-a", "sub-b"})} == {"k-b1"}
def test_list_skills_unknown_subscription_returns_empty(store):
_seed_two_subscriptions(store)
assert store.list_skills({"unknown-sub"}) == []
def test_search_skills_matches_name_tag_description(store):
_seed_two_subscriptions(store)
# Name match
assert {m.id for m in store.search_skills("Alpha", {"sub-a", "sub-b"})} == {
"k-a1",
"f-a2",
}
# Tag match
assert {m.id for m in store.search_skills("beta", {"sub-a", "sub-b"})} == {"k-b1"}
# Description match would work the same way; verify no-match returns empty
assert store.search_skills("zzz", {"sub-a", "sub-b"}) == []
def test_search_skills_respects_visibility(store):
_seed_two_subscriptions(store)
# sub-a inactive → its skills don't surface even if query matches
store._set_subscription_state("sub-a", active=False)
assert {m.id for m in store.search_skills("Alpha", {"sub-a", "sub-b"})} == set()
def test_get_skill_returns_full_content_for_knowledge(store):
_seed_two_subscriptions(store)
skill = store.get_skill("k-a1", {"sub-a"})
assert isinstance(skill, KnowledgeSkill)
assert skill.content == "c-a1"
assert skill.metadata.name == "Alpha Knowledge"
def test_get_skill_returns_full_steps_for_flow_template(store):
_seed_two_subscriptions(store)
skill = store.get_skill("f-a2", {"sub-a"})
assert isinstance(skill, FlowTemplateSkill)
assert len(skill.steps) == 1
assert skill.steps[0].tool_name == "tap"
assert skill.steps[0].args == {"x": 1}
def test_get_skill_unknown_id_returns_none(store):
_seed_two_subscriptions(store)
assert store.get_skill("does-not-exist", {"sub-a"}) is None
def test_get_skill_not_visible_indistinguishable_from_not_found(store):
"""Existence must not leak: invisible skill returns None, same as unknown."""
_seed_two_subscriptions(store)
# Skill exists under sub-a but caller asks with only sub-b
invisible = store.get_skill("k-a1", {"sub-b"})
unknown = store.get_skill("never-existed", {"sub-b"})
assert invisible is None
assert unknown is None
def test_get_skill_with_inactive_subscription_returns_none(store):
_seed_two_subscriptions(store)
store._set_subscription_state("sub-a", active=False)
assert store.get_skill("k-a1", {"sub-a"}) is None
# ----------------------------------------------------------------------
# Task 2.5: sync-only write contract (no public writes)
# ----------------------------------------------------------------------
def test_no_public_write_methods_on_store():
"""SkillCatalogStore must not expose create/update/remove publicly."""
public_methods = {
name
for name in dir(SkillCatalogStore)
if not name.startswith("_")
and callable(getattr(SkillCatalogStore, name))
}
forbidden = {"create", "update", "remove", "delete", "save", "put", "post"}
assert not (public_methods & forbidden), (
f"Public write methods found: {public_methods & forbidden}"
)
def test_apply_sync_upsert_round_trips_both_kinds(store):
store._register_subscription("sub-a")
original_k = _knowledge("k1", "Knowledge One", "content body", tags=["t1"])
original_f = _flow(
"f1",
"Flow One",
steps=[("tap", {"x": 1}), ("input_text", {"text": "hi"})],
parameters={"text": {"type": "string", "required": True}},
tags=["t2"],
)
store._apply_sync_upsert(original_k, "sub-a")
store._apply_sync_upsert(original_f, "sub-a")
fetched_k = store.get_skill("k1", {"sub-a"})
fetched_f = store.get_skill("f1", {"sub-a"})
assert isinstance(fetched_k, KnowledgeSkill)
assert isinstance(fetched_f, FlowTemplateSkill)
assert fetched_k.content == "content body"
assert fetched_k.metadata.tags == ["t1"]
assert [s.tool_name for s in fetched_f.steps] == ["tap", "input_text"]
assert fetched_f.steps[0].args == {"x": 1}
assert fetched_f.parameters == {"text": {"type": "string", "required": True}}
def test_apply_sync_upsert_overwrites_on_conflict(store):
store._register_subscription("sub-a")
store._apply_sync_upsert(_knowledge("k1", "v1", "old"), "sub-a")
store._apply_sync_upsert(_knowledge("k1", "v1-updated", "new"), "sub-a")
skill = store.get_skill("k1", {"sub-a"})
assert isinstance(skill, KnowledgeSkill)
assert skill.content == "new"
assert skill.metadata.name == "v1-updated"
def test_apply_sync_remove_deletes_skill(store):
store._register_subscription("sub-a")
store._apply_sync_upsert(_knowledge("k1", "v1", "c"), "sub-a")
store._apply_sync_remove("k1")
assert store.get_skill("k1", {"sub-a"}) is None
# ----------------------------------------------------------------------
# Task 2.6: flow-template tool-reference validation
# ----------------------------------------------------------------------
def test_validate_flow_template_tools_all_known_returns_true():
skill = _flow("f", "f", steps=[("tap", {}), ("input_text", {"text": "x"})])
assert validate_flow_template_tools(skill, {"tap", "input_text"}) is True
def test_validate_flow_template_tools_dangling_returns_false():
skill = _flow("f", "f", steps=[("tap", {}), ("ghost_tool", {})])
assert validate_flow_template_tools(skill, {"tap"}) is False
def test_validate_flow_template_tools_empty_steps_returns_true():
skill = _flow("f", "f", steps=[])
assert validate_flow_template_tools(skill, set()) is True
def test_get_skill_returns_none_when_flow_template_has_dangling_reference(store):
store._register_subscription("sub-a")
skill = _flow("f1", "Flow", steps=[("tap", {}), ("ghost_tool", {})])
store._apply_sync_upsert(skill, "sub-a")
# Without registered_tools: skill is returned as-is
assert store.get_skill("f1", {"sub-a"}) is not None
# With registered_tools missing ghost_tool: returns None (unavailable)
result = store.get_skill("f1", {"sub-a"}, registered_tools={"tap"})
assert result is None
# With all tools registered: returns the skill
result = store.get_skill("f1", {"sub-a"}, registered_tools={"tap", "ghost_tool"})
assert isinstance(result, FlowTemplateSkill)
def test_get_skill_validation_skipped_for_knowledge(store):
"""registered_tools must not affect knowledge skills."""
store._register_subscription("sub-a")
store._apply_sync_upsert(_knowledge("k1", "K", "c"), "sub-a")
result = store.get_skill("k1", {"sub-a"}, registered_tools=set())
assert isinstance(result, KnowledgeSkill)
# ----------------------------------------------------------------------
# Stable sort order
# ----------------------------------------------------------------------
def test_list_skills_is_sorted_by_name(store):
store._register_subscription("sub-a")
store._apply_sync_replace_all(
"sub-a",
[
_knowledge("z", "Zebra", "c"),
_knowledge("a", "Apple", "c"),
_knowledge("m", "Mango", "c"),
],
)
names = [m.name for m in store.list_skills({"sub-a"})]
assert names == ["Apple", "Mango", "Zebra"]
def test_search_skills_ranks_name_prefix_above_contains(store):
store._register_subscription("sub-a")
store._apply_sync_replace_all(
"sub-a",
[
_knowledge("k1", "Find Search", "c"), # contains only
_knowledge("k2", "Search Tips", "c"), # prefix match
],
)
results = store.search_skills("Search", {"sub-a"})
names = [m.name for m in results]
# Prefix match "Search Tips" must rank above contains-only "Find Search"
assert names[0] == "Search Tips"
assert names[1] == "Find Search"
# ----------------------------------------------------------------------
# Round-trip serialization for both kinds
# ----------------------------------------------------------------------
def test_round_trip_knowledge_preserves_all_metadata(store):
store._register_subscription("sub-a")
original = KnowledgeSkill(
metadata=SkillMetadata(
id="rt-k",
name="Round",
description="desc",
kind="knowledge",
tags=["a", "b"],
source="subscription",
version=3,
created_at=utc_now(),
updated_at=utc_now(),
),
content="body",
)
store._apply_sync_upsert(original, "sub-a")
fetched = store.get_skill("rt-k", {"sub-a"})
assert isinstance(fetched, KnowledgeSkill)
assert fetched.metadata.id == "rt-k"
assert fetched.metadata.name == "Round"
assert fetched.metadata.description == "desc"
assert fetched.metadata.kind == "knowledge"
assert fetched.metadata.tags == ["a", "b"]
assert fetched.metadata.source == "subscription"
assert fetched.metadata.version == 3
assert fetched.content == "body"
def test_round_trip_flow_template_preserves_steps_and_parameters(store):
store._register_subscription("sub-a")
original = FlowTemplateSkill(
metadata=SkillMetadata(
id="rt-f",
name="RT Flow",
description="d",
kind="flow_template",
tags=["x"],
source="subscription",
version=2,
),
steps=[
FlowStep(tool_name="tap", args={"x": 10, "y": 20}),
FlowStep(tool_name="input_text", args={"text": "hello"}),
],
parameters={"text": {"type": "string", "required": True}},
)
store._apply_sync_upsert(original, "sub-a")
fetched = store.get_skill("rt-f", {"sub-a"})
assert isinstance(fetched, FlowTemplateSkill)
assert [s.tool_name for s in fetched.steps] == ["tap", "input_text"]
assert fetched.steps[0].args == {"x": 10, "y": 20}
assert fetched.parameters == {"text": {"type": "string", "required": True}}
assert fetched.metadata.version == 2
# ----------------------------------------------------------------------
# Subscription state observability (used by sync failure path)
# ----------------------------------------------------------------------
def test_set_subscription_state_records_error_and_version(store):
store._register_subscription("sub-a")
store._set_subscription_state(
"sub-a",
active=False,
last_synced_version=7,
last_error="401 Unauthorized",
)
with store._connect() as conn:
row = conn.execute(
"SELECT active, last_synced_version, last_error FROM subscriptions WHERE id = ?",
("sub-a",),
).fetchone()
assert dict(row) == {
"active": 0,
"last_synced_version": 7,
"last_error": "401 Unauthorized",
}
+369
View File
@@ -0,0 +1,369 @@
"""End-to-end validation for skill-catalog-subscription (tasks 5.1-5.3).
Combines: FakeSubscriptionClient → SkillSyncRunner → SkillCatalogStore →
api.skill_catalog_mcp handlers. Verifies the full sync+query+resolve loop
and that resolved flow templates drive through the existing device-capability
tools one step at a time (no batched execution; Observe-Think-Act preserved).
"""
from __future__ import annotations
from typing import Any
import pytest
from api.mcp import tool_handlers
from api.skill_catalog_mcp import skill_tool_handlers
from api.skill_sync import SkillSyncRunner, SyncDelta
from device.manager import DeviceManager
from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.skill_catalog import SkillCatalogStore
from tests.fakes import FakeDriver
# ----------------------------------------------------------------------
# Shared fake client
# ----------------------------------------------------------------------
class E2EFakeClient:
"""In-process SubscriptionClient with revocable entitlements."""
def __init__(self) -> None:
self.skills_by_sub: dict[str, list[Any]] = {}
def set_skills(self, sub_id: str, skills: list[Any]) -> None:
self.skills_by_sub[sub_id] = list(skills)
def revoke(self, sub_id: str) -> None:
self.skills_by_sub.pop(sub_id, None)
def fetch_entitled_skills(self, subscription_id, since_version=None):
skills = self.skills_by_sub.get(subscription_id, [])
return SyncDelta(
skills=list(skills),
removed_ids=[],
latest_version=1,
is_full_replace=True,
)
def _knowledge(skill_id: str, name: str, content: str, tags=None) -> KnowledgeSkill:
return KnowledgeSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="knowledge",
tags=tags or [],
source="subscription",
),
content=content,
)
def _flow(
skill_id: str,
name: str,
*,
steps: list[tuple[str, dict]],
parameters: dict | None = None,
tags=None,
) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="flow_template",
tags=tags or [],
source="subscription",
),
steps=[FlowStep(tool_name=t, args=a) for t, a in steps],
parameters=parameters or {},
)
@pytest.fixture
def store(tmp_path):
return SkillCatalogStore(db_path=tmp_path / "e2e_skills.sqlite3")
@pytest.fixture
def fake_client():
return E2EFakeClient()
@pytest.fixture
def runner(store, fake_client):
return SkillSyncRunner(
store=store,
client=fake_client,
subscriptions=["sub-a"],
poll_interval=999.0, # no auto-poll
)
def _mcp_handlers(store, *, registered_tools, active=None):
return skill_tool_handlers(
store=store,
get_active_subscriptions=lambda: set(active if active is not None else {"sub-a"}),
get_registered_tools=lambda: set(registered_tools),
)
# ----------------------------------------------------------------------
# Task 5.1: seed + verify listable / searchable / fetchable via MCP
# ----------------------------------------------------------------------
def test_e2e_synced_skills_are_listable_searchable_fetchable_via_mcp(
store, fake_client, runner
):
# Seed the fake Subscription Platform with one of each kind
fake_client.set_skills(
"sub-a",
[
_knowledge(
"xhs-tips",
"Xiaohongshu Search Tips",
"Use specific keywords and filter by recent posts.",
tags=["social", "search"],
),
_flow(
"xhs-search-flow",
"Xiaohongshu Search Flow",
steps=[
("tap", {"x": "{search_x}", "y": "{search_y}"}),
("input_text", {"text": "{query}"}),
("tap", {"x": "{submit_x}", "y": "{submit_y}"}),
],
parameters={
"search_x": {"type": "number", "required": True},
"search_y": {"type": "number", "required": True},
"query": {"type": "string", "required": True},
"submit_x": {"type": "number", "required": True},
"submit_y": {"type": "number", "required": True},
},
tags=["social", "automation"],
),
],
)
# Run one sync tick
outcomes = runner.tick()
assert outcomes["sub-a"].success is True
assert outcomes["sub-a"].fetched == 2
# All device-capability tools are registered (used as the validator set)
registered = {"tap", "input_text", "swipe", "launch_app"}
handlers = _mcp_handlers(store, registered_tools=registered)
# list_skills returns both
listed = handlers["list_skills"]()
assert listed["ok"] is True
ids = {s["id"] for s in listed["skills"]}
assert ids == {"xhs-tips", "xhs-search-flow"}
# search_skills finds the knowledge skill by tag and the flow by name
by_tag = handlers["search_skills"](query="social")
assert {s["id"] for s in by_tag["skills"]} == {"xhs-tips", "xhs-search-flow"}
by_name = handlers["search_skills"](query="Xiaohongshu Search Flow")
assert {s["id"] for s in by_name["skills"]} == {"xhs-search-flow"}
# get_skill returns full content for each kind
knowledge = handlers["get_skill"](skill_id="xhs-tips")
assert knowledge["ok"] is True
assert knowledge["skill"]["kind"] == "knowledge"
assert "specific keywords" in knowledge["skill"]["content"]
flow = handlers["get_skill"](skill_id="xhs-search-flow")
assert flow["ok"] is True
assert flow["skill"]["kind"] == "flow_template"
assert [s["tool_name"] for s in flow["skill"]["steps"]] == [
"tap",
"input_text",
"tap",
]
# ----------------------------------------------------------------------
# Task 5.2: revocation — sync-time removal + query-time re-check
# ----------------------------------------------------------------------
def test_e2e_revocation_via_sync_cycle_removes_skill(
store, fake_client, runner
):
"""When the Subscription Platform no longer includes a skill in the
entitled set, the next sync cycle removes it locally."""
fake_client.set_skills(
"sub-a",
[_knowledge("k1", "Keep Me", "c"), _knowledge("k2", "Revoke Me", "c")],
)
runner.tick()
handlers = _mcp_handlers(store, registered_tools=set())
assert {s["id"] for s in handlers["list_skills"]()["skills"]} == {"k1", "k2"}
# Subscription Platform revokes k2
fake_client.set_skills("sub-a", [_knowledge("k1", "Keep Me", "c")])
runner.tick()
after = handlers["list_skills"]()
ids = {s["id"] for s in after["skills"]}
assert ids == {"k1"}
assert handlers["get_skill"](skill_id="k2")["ok"] is False
def test_e2e_revocation_via_query_time_recheck_hides_skill_before_next_sync(
store, fake_client, runner
):
"""Marking a subscription inactive hides its skills on the next query,
without waiting for the next sync tick to remove the rows."""
fake_client.set_skills("sub-a", [_knowledge("k1", "K", "c")])
runner.tick()
handlers_before = _mcp_handlers(store, registered_tools=set())
assert handlers_before["get_skill"](skill_id="k1")["ok"] is True
# Revoke the subscription locally (e.g., via push notification of revocation)
store._set_subscription_state("sub-a", active=False)
# Same handler config — query re-check excludes sub-a's skills immediately
handlers_after = _mcp_handlers(store, registered_tools=set())
assert handlers_after["list_skills"]()["skills"] == []
assert handlers_after["get_skill"](skill_id="k1")["ok"] is False
# ----------------------------------------------------------------------
# Task 5.3: resolve via MCP, then drive through device-capability tools
# ----------------------------------------------------------------------
def test_e2e_resolved_flow_template_drives_existing_device_tools_one_step_at_a_time(
store, fake_client, runner
):
"""Resolve a flow-template skill via MCP, then execute each resulting
step through the existing device-capability tools (Executor + FakeDriver).
Confirms the Observe-Think-Act loop is preserved: no batched
server-side execution primitive is used. Each step is issued
individually, exactly as the LLM would.
"""
# 1. Seed a flow template that uses tap + input_text (real device tools)
fake_client.set_skills(
"sub-a",
[
_flow(
"login-flow",
"Login Flow",
steps=[
("tap", {"x": 50, "y": 100}),
("input_text", {"text": "user@example.com"}),
("tap", {"x": 50, "y": 200}),
],
parameters={},
)
],
)
runner.tick()
# 2. Set up the existing device-capability tool surface (apex-agent-mvp)
fake_driver = FakeDriver()
manager = DeviceManager()
manager.register_device("device-1", lambda: fake_driver)
manager.connect("device-1", max_retries=1)
device_handlers = tool_handlers(manager=manager)
registered_tool_names = set(device_handlers.keys())
# 3. Resolve the flow template via the MCP handler
skill_handlers = _mcp_handlers(store, registered_tools=registered_tool_names)
resolved = skill_handlers["resolve_flow_template"](
skill_id="login-flow", params={}
)
assert resolved["ok"] is True
steps = resolved["steps"]
assert len(steps) == 3
# 4. Build an Executor with the device-capability tools — the same
# Executor the Agent Runtime uses for the Act phase. Bind device_id
# via closures so each step executes against the connected device.
# Each step is issued individually through executor.execute(step),
# exactly as the LLM-driven Observe-Think-Act loop does.
device_id = "device-1"
executor = Executor(
tools={
"tap": lambda **kw: device_handlers["tap"](device_id=device_id, **kw),
"input_text": lambda **kw: device_handlers["input_text"](
device_id=device_id, **kw
),
},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
results = []
for index, step in enumerate(steps, start=1):
planned = PlannedStep(
action=step["tool_name"],
description=f"login-flow step {index}",
args=step["args"],
)
result = executor.execute(planned)
results.append(result)
# 5. Every step must succeed through the normal Act loop
assert all(r.success for r in results), (
f"step failed: {[r.error for r in results if not r.success]}"
)
assert len(results) == 3
# 6. The FakeDriver must have received the calls in order — proving the
# resolved template actually reached the device via the existing
# capability surface (not via a new batch primitive).
tool_calls = [c for c in fake_driver.calls if c[0] in ("tap", "input")]
# 3 steps → exactly 3 device-level calls (tap, input, tap)
assert len(tool_calls) == 3
tap_calls = [c for c in tool_calls if c[0] == "tap"]
assert tap_calls[0][1] == (50, 100)
assert tap_calls[1][1] == (50, 200)
input_calls = [c for c in tool_calls if c[0] == "input"]
assert input_calls[0][1] == ("user@example.com",)
def test_e2e_resolved_flow_template_uses_parameter_substitution(
store, fake_client, runner
):
"""Resolution substitutes {param} placeholders so the LLM-driven Act loop
receives concrete argument values, not templates."""
fake_client.set_skills(
"sub-a",
[
_flow(
"param-flow",
"Parameterized",
steps=[
("tap", {"x": "{coord_x}", "y": "{coord_y}"}),
("input_text", {"text": "fixed-text"}),
],
parameters={
"coord_x": {"type": "number", "required": True},
"coord_y": {"type": "number", "required": True},
},
)
],
)
runner.tick()
handlers = _mcp_handlers(store, registered_tools={"tap", "input_text"})
resolved = handlers["resolve_flow_template"](
skill_id="param-flow",
params={"coord_x": 42, "coord_y": 99},
)
assert resolved["ok"] is True
steps = resolved["steps"]
# Placeholder substituted with provided param values
assert steps[0]["args"] == {"x": 42, "y": 99}
# Non-parameterized args pass through unchanged
assert steps[1]["args"] == {"text": "fixed-text"}
+332
View File
@@ -0,0 +1,332 @@
"""Tests for api/skill_catalog_mcp.py.
Covers task 4.5: list/search/get via MCP-style handler calls, resolve_flow_template
success and failure cases, semantic error translation, and an explicit
assertion that no batch-execute tool is registered (design D5 / task 4.4).
"""
from __future__ import annotations
import pytest
from api.skill_catalog_mcp import (
SKILL_TOOL_NAMES,
InvalidFlowTemplateError,
MissingParameterError,
SkillCatalogError,
SkillNotFoundError,
register_skill_catalog_tools,
skill_tool_handlers,
)
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.skill_catalog import SkillCatalogStore
# ----------------------------------------------------------------------
# Fixtures and helpers
# ----------------------------------------------------------------------
def _knowledge(skill_id: str, name: str, content: str, tags=None) -> KnowledgeSkill:
return KnowledgeSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="knowledge",
tags=tags or [],
source="subscription",
),
content=content,
)
def _flow(
skill_id: str,
name: str,
*,
steps: list[tuple[str, dict]],
parameters: dict | None = None,
tags=None,
) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="flow_template",
tags=tags or [],
source="subscription",
),
steps=[FlowStep(tool_name=t, args=a) for t, a in steps],
parameters=parameters or {},
)
@pytest.fixture
def store(tmp_path):
return SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
@pytest.fixture
def seeded_store(store):
store._register_subscription("sub-a")
store._apply_sync_replace_all(
"sub-a",
[
_knowledge("k1", "Knowledge One", "body of knowledge", tags=["docs"]),
_flow(
"f1",
"Search Flow",
steps=[
("tap", {"x": "{x_coord}"}),
("input_text", {"text": "{query}"}),
],
parameters={
"x_coord": {"type": "number", "required": True},
"query": {"type": "string", "required": True},
},
tags=["search"],
),
],
)
return store
def _handlers(store, *, registered_tools=None, active=None):
return skill_tool_handlers(
store=store,
get_active_subscriptions=lambda: set(active if active is not None else {"sub-a"}),
get_registered_tools=(lambda: set(registered_tools or {"tap", "input_text"})),
)
# ----------------------------------------------------------------------
# list_skills / search_skills / get_skill via handlers
# ----------------------------------------------------------------------
def test_list_skills_returns_summaries_without_platform_fields(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["list_skills"]()
assert result["ok"] is True
assert len(result["skills"]) == 2
# Summaries contain only id/name/description/kind/tags — no source/version
serialized = repr(result)
assert "subscription" not in serialized.lower()
assert "parent_version_id" not in serialized
assert "originating_goal" not in serialized
names = {s["name"] for s in result["skills"]}
assert names == {"Knowledge One", "Search Flow"}
def test_list_skills_empty_when_no_active_subscriptions(seeded_store):
handlers = _handlers(seeded_store, active=set())
result = handlers["list_skills"]()
assert result == {"ok": True, "skills": []}
def test_search_skills_finds_by_tag_and_name(seeded_store):
handlers = _handlers(seeded_store)
by_name = handlers["search_skills"](query="Search")
assert {s["id"] for s in by_name["skills"]} == {"f1"}
by_tag = handlers["search_skills"](query="docs")
assert {s["id"] for s in by_tag["skills"]} == {"k1"}
def test_get_skill_returns_full_knowledge_content(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["get_skill"](skill_id="k1")
assert result["ok"] is True
skill = result["skill"]
assert skill["kind"] == "knowledge"
assert skill["content"] == "body of knowledge"
assert skill["name"] == "Knowledge One"
def test_get_skill_returns_full_flow_template(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["get_skill"](skill_id="f1")
assert result["ok"] is True
skill = result["skill"]
assert skill["kind"] == "flow_template"
assert [s["tool_name"] for s in skill["steps"]] == ["tap", "input_text"]
assert "x_coord" in skill["parameters"]
def test_get_skill_unknown_returns_skill_not_found(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["get_skill"](skill_id="does-not-exist")
assert result["ok"] is False
assert result["error"] == "skill not found"
def test_get_skill_not_visible_indistinguishable_from_not_found(seeded_store):
"""Existence must not leak: not-visible returns the same error as not-found."""
handlers = _handlers(seeded_store, active=set()) # no active subs
invisible = handlers["get_skill"](skill_id="k1")
unknown = handlers["get_skill"](skill_id="never-existed")
assert invisible == unknown == {"ok": False, "error": "skill not found"}
def test_get_skill_with_dangling_tool_reference_returns_unavailable(seeded_store):
# f1 references tap + input_text; drop input_text from the registered set
handlers = _handlers(seeded_store, registered_tools={"tap"})
result = handlers["get_skill"](skill_id="f1")
assert result["ok"] is False
# Per task 2.6, get_skill returns None for dangling refs, which surfaces
# as "skill not found" at the MCP layer (no existence leak on the cause).
assert result["error"] == "skill not found"
# ----------------------------------------------------------------------
# resolve_flow_template
# ----------------------------------------------------------------------
def test_resolve_flow_template_succeeds_with_valid_params(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["resolve_flow_template"](
skill_id="f1",
params={"x_coord": 100, "query": "hello"},
)
assert result["ok"] is True
steps = result["steps"]
assert steps[0]["tool_name"] == "tap"
assert steps[0]["args"]["x"] == 100 # {x_coord} substituted
assert steps[1]["tool_name"] == "input_text"
assert steps[1]["args"]["text"] == "hello"
def test_resolve_flow_template_missing_required_param(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["resolve_flow_template"](
skill_id="f1",
params={"x_coord": 100}, # missing 'query'
)
assert result["ok"] is False
assert result["error"].startswith("missing parameter:")
assert "query" in result["error"]
def test_resolve_flow_template_unknown_skill(seeded_store):
handlers = _handlers(seeded_store)
result = handlers["resolve_flow_template"](
skill_id="no-such",
params={},
)
assert result["ok"] is False
assert result["error"] == "skill not found"
def test_resolve_flow_template_on_knowledge_skill_returns_unavailable(seeded_store):
"""Cannot resolve a knowledge skill as a flow template."""
handlers = _handlers(seeded_store)
result = handlers["resolve_flow_template"](skill_id="k1", params={})
assert result["ok"] is False
assert result["error"] == "skill unavailable"
def test_resolve_flow_template_with_dangling_tool_reference(seeded_store):
"""A skill whose steps reference an unregistered tool is treated as
not-found at resolve time (consistent with get_skill's behavior)."""
handlers = _handlers(seeded_store, registered_tools={"tap"}) # missing input_text
result = handlers["resolve_flow_template"](
skill_id="f1",
params={"x_coord": 1, "query": "x"},
)
assert result["ok"] is False
assert result["error"] == "skill not found"
# ----------------------------------------------------------------------
# Registration: tools registered on FastMCP server, no batch-execute tool
# ----------------------------------------------------------------------
def _fastmcp_tool_names(server) -> set[str]:
"""Read the FastMCP tool registry directly (sync, no event loop needed)."""
# FastMCP stores tools in ToolManager._tools (mcp>=1.27). Walk the attrs
# defensively so the test doesn't break on minor internal refactors.
if hasattr(server, "_tool_manager"):
manager = server._tool_manager
if hasattr(manager, "_tools"):
return set(manager._tools.keys())
raise AssertionError(
"Could not introspect FastMCP tool registry — internal API changed"
)
def test_register_skill_catalog_tools_registers_expected_names(seeded_store):
from mcp.server.fastmcp import FastMCP
server = FastMCP("test")
register_skill_catalog_tools(
server,
store=seeded_store,
get_active_subscriptions=lambda: {"sub-a"},
get_registered_tools=lambda: {"tap", "input_text"},
)
names = _fastmcp_tool_names(server)
for expected in SKILL_TOOL_NAMES:
assert expected in names, f"missing tool: {expected}"
def test_no_batch_execute_tool_is_registered(seeded_store):
"""Task 4.4: assert no run_skill_flow / execute / batch tool is registered."""
from mcp.server.fastmcp import FastMCP
server = FastMCP("test")
register_skill_catalog_tools(
server,
store=seeded_store,
get_active_subscriptions=lambda: {"sub-a"},
get_registered_tools=lambda: {"tap", "input_text"},
)
names = _fastmcp_tool_names(server)
forbidden_fragments = ("run_", "execute", "batch", "invoke_skill")
for name in names:
lower = name.lower()
for fragment in forbidden_fragments:
assert fragment not in lower, (
f"Forbidden tool name '{name}' contains '{fragment}'"
"design D5 forbids server-side flow execution tools"
)
def test_create_mcp_server_registers_skill_tools_when_store_provided(seeded_store):
"""Wire-up: api.mcp.create_mcp_server must register skill tools when
skill_catalog_store is provided."""
from api.mcp import create_mcp_server
server = create_mcp_server(
skill_catalog_store=seeded_store,
skill_active_subscriptions={"sub-a"},
)
names = _fastmcp_tool_names(server)
assert "list_skills" in names
assert "tap" in names # existing device tools still present
def test_create_mcp_server_omits_skill_tools_when_no_store():
"""Wire-up must not break existing behavior when no store is provided."""
from api.mcp import create_mcp_server
server = create_mcp_server()
names = _fastmcp_tool_names(server)
assert "list_skills" not in names
assert "tap" in names # existing device tools present
# ----------------------------------------------------------------------
# Error class hierarchy
# ----------------------------------------------------------------------
def test_skill_error_hierarchy():
assert issubclass(SkillNotFoundError, SkillCatalogError)
assert issubclass(InvalidFlowTemplateError, SkillCatalogError)
assert issubclass(MissingParameterError, SkillCatalogError)
+476
View File
@@ -0,0 +1,476 @@
"""Tests for api/skill_sync.py.
Covers task 3.7: create/update/remove sync scenarios, push-triggered
immediate sync, query-time revocation re-check, and failure-preserves-cache.
All tests use a FakeSubscriptionClient implementing the Protocol — no real
HTTP traffic.
"""
from __future__ import annotations
import json
from typing import Any
import pytest
from api.skill_sync import (
HttpSubscriptionClient,
SkillSyncRunner,
SyncDelta,
SyncOutcome,
_parse_sync_payload,
register_skill_sync_webhook,
)
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.skill_catalog import SkillCatalogStore
# ----------------------------------------------------------------------
# Test fakes and helpers
# ----------------------------------------------------------------------
class FakeSubscriptionClient:
"""In-process implementation of the SubscriptionClient Protocol."""
def __init__(self) -> None:
self.skills_by_sub: dict[str, list[Any]] = {}
self.delta_overrides: dict[str, SyncDelta] = {}
self.next_failure: Exception | None = None
self.call_log: list[tuple[str, int | None]] = []
def set_entitled_skills(self, sub_id: str, skills: list[Any]) -> None:
self.skills_by_sub[sub_id] = list(skills)
def set_delta(self, sub_id: str, delta: SyncDelta) -> None:
self.delta_overrides[sub_id] = delta
def fail_next_with(self, exc: Exception) -> None:
self.next_failure = exc
def fetch_entitled_skills(
self,
subscription_id: str,
since_version: int | None = None,
) -> SyncDelta:
self.call_log.append((subscription_id, since_version))
if self.next_failure is not None:
exc = self.next_failure
self.next_failure = None
raise exc
if subscription_id in self.delta_overrides:
return self.delta_overrides[subscription_id]
skills = self.skills_by_sub.get(subscription_id, [])
return SyncDelta(
skills=list(skills),
removed_ids=[],
latest_version=1,
is_full_replace=True,
)
def _knowledge(skill_id: str, name: str, content: str) -> KnowledgeSkill:
return KnowledgeSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="knowledge",
source="subscription",
),
content=content,
)
def _flow(skill_id: str, name: str, steps: list[tuple[str, dict]]) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
id=skill_id,
name=name,
kind="flow_template",
source="subscription",
),
steps=[FlowStep(tool_name=t, args=a) for t, a in steps],
)
@pytest.fixture
def store(tmp_path):
return SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
@pytest.fixture
def fake():
return FakeSubscriptionClient()
@pytest.fixture
def runner(store, fake):
return SkillSyncRunner(
store=store,
client=fake,
subscriptions=["sub-a"],
poll_interval=0.01,
)
# ----------------------------------------------------------------------
# Task 3.3 / 3.5: poll loop apply scenarios (create / update / remove)
# ----------------------------------------------------------------------
def test_tick_creates_new_skills_in_catalog(store, fake, runner):
fake.set_entitled_skills(
"sub-a",
[_knowledge("k1", "Knowledge", "body"), _flow("f1", "Flow", [("tap", {})])],
)
outcomes = runner.tick()
assert outcomes["sub-a"].success is True
assert outcomes["sub-a"].fetched == 2
ids = {m.id for m in store.list_skills({"sub-a"})}
assert ids == {"k1", "f1"}
def test_tick_updates_existing_skill_content(store, fake, runner):
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "v1")])
runner.tick()
fetched = store.get_skill("k1", {"sub-a"})
assert isinstance(fetched, KnowledgeSkill)
assert fetched.content == "v1"
# Sync again with new content
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "v2-updated")])
runner.tick()
fetched = store.get_skill("k1", {"sub-a"})
assert isinstance(fetched, KnowledgeSkill)
assert fetched.content == "v2-updated"
def test_tick_removes_skills_no_longer_entitled(store, fake, runner):
fake.set_entitled_skills(
"sub-a",
[_knowledge("k1", "K", "c"), _knowledge("k2", "K2", "c")],
)
runner.tick()
assert {m.id for m in store.list_skills({"sub-a"})} == {"k1", "k2"}
# Full replace with only k1 → k2 must disappear
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner.tick()
assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"}
def test_tick_applies_incremental_delta_via_upsert_and_remove(store, fake, runner):
# Seed with full sync
fake.set_entitled_skills(
"sub-a",
[_knowledge("k1", "K1", "c1"), _knowledge("k2", "K2", "c2")],
)
runner.tick()
# Now simulate an incremental delta: k1 content updated, k2 removed, k3 added
fake.set_delta(
"sub-a",
SyncDelta(
skills=[_knowledge("k1", "K1", "c1-updated"), _knowledge("k3", "K3", "c3")],
removed_ids=["k2"],
latest_version=5,
is_full_replace=False,
),
)
outcomes = runner.tick()
assert outcomes["sub-a"].success is True
assert outcomes["sub-a"].fetched == 2
ids = {m.id for m in store.list_skills({"sub-a"})}
assert ids == {"k1", "k3"}
fetched = store.get_skill("k1", {"sub-a"})
assert isinstance(fetched, KnowledgeSkill)
assert fetched.content == "c1-updated"
def test_tick_skips_unknown_subscriptions_in_outcome(store, fake):
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner = SkillSyncRunner(
store=store,
client=fake,
subscriptions=["sub-a", "sub-b"],
poll_interval=0.01,
)
outcomes = runner.tick()
# sub-b has no skills set → empty delta, success, fetched=0
assert outcomes["sub-b"].success is True
assert outcomes["sub-b"].fetched == 0
assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"}
assert store.list_skills({"sub-b"}) == []
# ----------------------------------------------------------------------
# Task 3.6: sync failure preserves cache + records last_error
# ----------------------------------------------------------------------
def test_tick_failure_preserves_cache_and_records_error(store, fake, runner):
# Initial successful sync
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner.tick()
assert store.get_skill("k1", {"sub-a"}) is not None
# Next tick fails — cache must remain, error recorded
fake.fail_next_with(RuntimeError("503 Service Unavailable"))
outcomes = runner.tick()
assert outcomes["sub-a"].success is False
assert "503 Service Unavailable" in (outcomes["sub-a"].error or "")
assert outcomes["sub-a"].fetched == 0
# Cache preserved
fetched = store.get_skill("k1", {"sub-a"})
assert isinstance(fetched, KnowledgeSkill)
assert fetched.content == "c"
# last_error recorded in subscriptions table
with store._connect() as conn:
row = conn.execute(
"SELECT last_error FROM subscriptions WHERE id = ?", ("sub-a",)
).fetchone()
assert row is not None
assert "503 Service Unavailable" in (row["last_error"] or "")
def test_tick_failure_then_recovery_clears_error(store, fake, runner):
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner.tick()
fake.fail_next_with(ConnectionError("network down"))
runner.tick()
# Successful sync after failure must clear last_error
runner.tick()
with store._connect() as conn:
row = conn.execute(
"SELECT last_error FROM subscriptions WHERE id = ?", ("sub-a",)
).fetchone()
assert row["last_error"] is None
# ----------------------------------------------------------------------
# Task 3.5: query-time revocation before next sync
# ----------------------------------------------------------------------
def test_revocation_takes_effect_immediately_without_resync(store, fake, runner):
"""Marking a subscription inactive hides its skills on the next query,
even if no further sync tick has run."""
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner.tick()
assert store.get_skill("k1", {"sub-a"}) is not None
# Revoke sub-a without syncing again
store._set_subscription_state("sub-a", active=False)
# Query-time re-check (storage._effective_active) must hide the skill
assert store.list_skills({"sub-a"}) == []
assert store.get_skill("k1", {"sub-a"}) is None
# Union with another (still-active) subscription still excludes sub-a's skills
store._register_subscription("sub-b")
assert store.list_skills({"sub-a", "sub-b"}) == []
# ----------------------------------------------------------------------
# Task 3.4: push-triggered (webhook) immediate sync
# ----------------------------------------------------------------------
def test_webhook_triggers_immediate_sync_of_all_subscriptions(store, fake):
from fastapi import FastAPI
from fastapi.testclient import TestClient
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner = SkillSyncRunner(
store=store,
client=fake,
subscriptions=["sub-a"],
poll_interval=999.0, # never auto-poll
)
app = FastAPI()
register_skill_sync_webhook(app, runner)
with TestClient(app) as client:
response = client.post("/webhooks/skill-sync")
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["outcomes"]["sub-a"]["success"] is True
# Catalog now reflects the synced skill without any tick() call from us
assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"}
def test_webhook_with_subscription_id_syncs_only_that_subscription(store, fake):
from fastapi import FastAPI
from fastapi.testclient import TestClient
fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
fake.set_entitled_skills("sub-b", [_knowledge("k2", "K2", "c")])
runner = SkillSyncRunner(
store=store,
client=fake,
subscriptions=["sub-a", "sub-b"],
poll_interval=999.0,
)
app = FastAPI()
register_skill_sync_webhook(app, runner)
with TestClient(app) as client:
response = client.post(
"/webhooks/skill-sync", json={"subscription_id": "sub-a"}
)
assert response.status_code == 200
body = response.json()
assert set(body["outcomes"].keys()) == {"sub-a"}
# Only sub-a was synced
assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"}
# sub-b was not synced in this call
assert store.list_skills({"sub-b"}) == []
def test_register_webhook_rejects_non_fastapi_app():
runner = object() # not a SkillSyncRunner, not a FastAPI app
with pytest.raises(TypeError):
register_skill_sync_webhook("not-an-app", runner) # type: ignore[arg-type]
# ----------------------------------------------------------------------
# Background poll loop
# ----------------------------------------------------------------------
def test_start_background_polls_until_stopped(store, fake):
call_count = {"n": 0}
class CountingClient(FakeSubscriptionClient):
def fetch_entitled_skills(self, subscription_id, since_version=None):
call_count["n"] += 1
return super().fetch_entitled_skills(subscription_id, since_version)
client = CountingClient()
client.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")])
runner = SkillSyncRunner(
store=store,
client=client,
subscriptions=["sub-a"],
poll_interval=0.01,
)
runner.start_background()
try:
# Allow at least 2 polls
import time
time.sleep(0.05)
finally:
runner.stop_background()
assert call_count["n"] >= 2
# ----------------------------------------------------------------------
# HttpSubscriptionClient: payload parsing + httpx transport (mocked)
# ----------------------------------------------------------------------
def test_parse_sync_payload_handles_knowledge_and_flow():
payload = {
"skills": [
{"id": "k1", "name": "K", "kind": "knowledge", "content": "body"},
{
"id": "f1",
"name": "F",
"kind": "flow_template",
"steps": [{"tool_name": "tap", "args": {"x": 1}}],
"parameters": {"x": {"type": "int"}},
},
],
"removed_ids": ["old1"],
"latest_version": 7,
"is_full_replace": False,
}
delta = _parse_sync_payload(payload)
assert len(delta.skills) == 2
assert isinstance(delta.skills[0], KnowledgeSkill)
assert isinstance(delta.skills[1], FlowTemplateSkill)
assert delta.skills[1].steps[0].tool_name == "tap"
assert delta.removed_ids == ["old1"]
assert delta.latest_version == 7
assert delta.is_full_replace is False
def test_parse_sync_payload_defaults_to_full_replace():
delta = _parse_sync_payload({"skills": []})
assert delta.skills == []
assert delta.removed_ids == []
assert delta.latest_version is None
assert delta.is_full_replace is True
def test_http_client_uses_injected_httpx_client_and_parses_response(monkeypatch):
captured: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, Any]:
return {"skills": [{"id": "k1", "name": "K", "kind": "knowledge"}]}
class FakeHttpxClient:
def get(self, url, *, params=None, headers=None, timeout=None):
captured["url"] = url
captured["params"] = params
captured["headers"] = headers
captured["timeout"] = timeout
return FakeResponse()
def close(self) -> None:
captured["closed"] = True
fake_http = FakeHttpxClient()
client = HttpSubscriptionClient(
base_url="https://subscription.example.com/",
auth_token="secret",
client=fake_http,
)
delta = client.fetch_entitled_skills("sub-a")
assert captured["url"] == "https://subscription.example.com/subscriptions/sub-a/skills"
assert captured["headers"] == {"Authorization": "Bearer secret"}
assert len(delta.skills) == 1
assert isinstance(delta.skills[0], KnowledgeSkill)
client.close()
assert captured.get("closed") is True
def test_http_client_raises_on_status_error_propagates_to_runner(store):
"""HTTP errors surface as a failed SyncOutcome, not a raised exception."""
class BoomResponse:
def raise_for_status(self):
raise RuntimeError("simulated 500")
class FailingClient:
def get(self, url, *, params=None, headers=None, timeout=None):
return BoomResponse()
def close(self):
pass
http_client = HttpSubscriptionClient(
base_url="https://subscription.example.com",
client=FailingClient(),
)
runner = SkillSyncRunner(
store=store,
client=http_client,
subscriptions=["sub-a"],
)
outcomes = runner.tick()
assert outcomes["sub-a"].success is False
assert "simulated 500" in (outcomes["sub-a"].error or "")