Files
agentic-mobile-control/openspec/changes/skill-catalog-subscription/tasks.md
T
q792602257andClaude Opus 4.6 b94abde92a 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>
2026-07-07 10:33:23 +08:00

7.1 KiB

1. Domain model extension (reuse skills_learning/)

  • 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.
  • 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. Catalog storage & query (capability: skill-catalog; layer: storage/)

  • 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.
  • 2.2 list_skills(active_subscriptions: set[str]) -> list[SkillMetadata] — returns only skills whose subscription_id ∈ active_subscriptions, stable-sorted by name.
  • 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).
  • 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).
  • 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.
  • 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.
  • 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; layer: api/)

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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; layer: api/)

  • 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.
  • 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.
  • 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.
  • 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.
  • 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 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).
  • 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.
  • 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).