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