664 lines
18 KiB
Markdown
664 lines
18 KiB
Markdown
# wf_api Slice 4E: Capabilities Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Move workflow capability discovery, inspection, direct capability calls, wrapper capability projection, and capability-backed draft bootstrap out of `WorkflowSurfaceHandlers` into `wf_api.capabilities.WorkflowCapabilityApi`.
|
||
|
||
**Architecture:** `WorkflowCapabilityApi` depends on `WorkflowOperationContext`, `WorkflowDraftApi`, and existing `wf_api` helper modules. It must not depend on `WfMcpService`, MCP events, MCP tools, or MCP request models. `WorkflowSurfaceHandlers` becomes a thin adapter/delegator for workflow operations.
|
||
|
||
**Tech Stack:** Python 3.14+, `wf_api.operation_context`, `wf_api.drafts`, `wf_api.runs`, `wf_api.wrapper_hints`, `wf_api.refs`, `wf_artifacts`, `wf_authoring`, `wf_core.RuntimeContext`, pytest, ruff, basedpyright.
|
||
|
||
---
|
||
|
||
## Scope
|
||
|
||
### Move In This Slice
|
||
|
||
Move these methods from `WorkflowSurfaceHandlers`:
|
||
|
||
```text
|
||
list_capabilities
|
||
inspect_capability
|
||
call_capability
|
||
create_draft_workspace_from_capability
|
||
```
|
||
|
||
Move these wrapper/capability private methods:
|
||
|
||
```text
|
||
_wrapper_artifact_for_capability_name
|
||
_wrapper_capability_summaries
|
||
_wrapper_capability_detail
|
||
_call_wrapper_artifact
|
||
```
|
||
|
||
Move or duplicate only the helpers needed by those methods:
|
||
|
||
```text
|
||
_schema_field_names
|
||
_source_id_for_capability
|
||
_artifact_capability_id
|
||
_raw_plan_from_artifact
|
||
_required_capability_payloads
|
||
_draft_name_from_capability
|
||
```
|
||
|
||
### Do Not Move In This Slice
|
||
|
||
Do not move MCP tool registration, MCP request/response Pydantic models, proxy/admin/broker runtime code, or CLI code.
|
||
|
||
### Invariants
|
||
|
||
- No public payload changes.
|
||
- No MCP tool schema changes.
|
||
- `WorkflowSurfaceHandlers` public capability method signatures stay unchanged.
|
||
- `wf_api` imports no `wf_mcp`.
|
||
- Direct raw NodeSpec calls still use `build_async_registry`.
|
||
- Direct wrapper calls still reject unsupported interrupting wrappers through `direct_wrapper_interrupt_diagnostic`.
|
||
- Full saved workflows still run through deployments, not direct capability calls.
|
||
- `create_draft_workspace_from_capability` keeps using inspect-capability wrapper hints.
|
||
|
||
---
|
||
|
||
## Design Notes
|
||
|
||
Capability extraction is the final domain split because it spans several concepts:
|
||
|
||
- live planner-visible source NodeSpecs
|
||
- saved wrapper artifacts projected as workflow-facing capabilities
|
||
- direct capability REPL calls
|
||
- wrapper calls through workflow execution
|
||
- wrapper-hint-driven draft bootstrap
|
||
|
||
Keep this in one `WorkflowCapabilityApi` for now. Do not create five tiny services unless tests prove the file is too large after extraction.
|
||
|
||
Temporary private helper duplication is allowed. A later cleanup can promote common helpers such as `artifact_capability_id`, `raw_plan_from_artifact`, and source snapshots into better shared modules. Do not widen this slice just to make helper names perfect.
|
||
|
||
---
|
||
|
||
## Task 1: Create `wf_api.capabilities`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/wf_api/capabilities.py`
|
||
- Modify: `src/wf_api/__init__.py`
|
||
- Test: `tests/wf_api/test_capability_api.py`
|
||
|
||
- [ ] **Step 1: Create service skeleton**
|
||
|
||
Create `src/wf_api/capabilities.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Sequence
|
||
from typing import Any
|
||
|
||
from wf_artifacts import (
|
||
DependencyDiagnostic,
|
||
DiagnosticSeverity,
|
||
WorkflowArtifact,
|
||
WorkflowCapabilityRef,
|
||
)
|
||
from wf_authoring import build_async_registry
|
||
from wf_core import RuntimeContext
|
||
from wf_core.models.steps import InputBinding, OutputBinding
|
||
from wf_core.paths import GraphSourcePath
|
||
from wf_platform import CapabilitySource, page_items
|
||
|
||
from .drafts import WorkflowDraftApi
|
||
from .models import RawWorkflowPlan
|
||
from .operation_context import WorkflowOperationContext
|
||
from .refs import parse_workflow_surface_capability_id
|
||
from .saved_subgraphs import direct_wrapper_interrupt_diagnostic
|
||
from .wrapper_hints import (
|
||
workflow_output_schema_for_authoring,
|
||
wrapper_hints_for_capability,
|
||
)
|
||
|
||
|
||
class WorkflowCapabilityApi:
|
||
"""Workflow-facing capability discovery, inspection, and REPL calls.
|
||
|
||
This service owns the source/wrapper projection, while adapter-specific MCP
|
||
tool schemas stay outside wf_api.
|
||
"""
|
||
|
||
def __init__(self, context: WorkflowOperationContext) -> None:
|
||
self.context = context
|
||
self.drafts = WorkflowDraftApi(context)
|
||
```
|
||
|
||
- [ ] **Step 2: Add local list helpers**
|
||
|
||
Add local helpers rather than importing `wf_mcp.shared`:
|
||
|
||
```python
|
||
def _matches_query(*values: object, query: str | None) -> bool:
|
||
if query is None:
|
||
return True
|
||
needle = query.strip().casefold()
|
||
if not needle:
|
||
return True
|
||
return any(needle in str(value).casefold() for value in values if value is not None)
|
||
|
||
|
||
def _paged_list_payload(
|
||
key: str,
|
||
items: Sequence[dict[str, Any]],
|
||
*,
|
||
cursor: str | None,
|
||
limit: int,
|
||
) -> dict[str, Any]:
|
||
page = page_items(items, cursor=cursor, limit=limit)
|
||
return {key: list(page.items), "next_cursor": page.next_cursor, "total": page.total}
|
||
```
|
||
|
||
This duplicates current list behavior without importing MCP shared helpers into
|
||
`wf_api`.
|
||
|
||
- [ ] **Step 3: Export capability service**
|
||
|
||
In `src/wf_api/__init__.py`:
|
||
|
||
```python
|
||
from .capabilities import WorkflowCapabilityApi
|
||
```
|
||
|
||
Add `"WorkflowCapabilityApi"` to `__all__`.
|
||
|
||
---
|
||
|
||
## Task 2: Move Discovery And Inspection
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_api/capabilities.py`
|
||
|
||
- [ ] **Step 1: Move `list_capabilities`**
|
||
|
||
Move the existing handler body into:
|
||
|
||
```python
|
||
async def list_capabilities(
|
||
self,
|
||
*,
|
||
query: str | None = None,
|
||
source_id: str | None = None,
|
||
cursor: str | None = None,
|
||
limit: int = 50,
|
||
) -> dict[str, Any]:
|
||
...
|
||
```
|
||
|
||
Required replacements:
|
||
|
||
```python
|
||
self.service.capability_sources -> self.context.capability_sources
|
||
self._wrapper_capability_summaries(...) -> self._wrapper_capability_summaries(...)
|
||
matches_query(...) -> _matches_query(...)
|
||
paged_list_payload(...) -> _paged_list_payload(...)
|
||
```
|
||
|
||
Preserve sorting and response shape.
|
||
|
||
- [ ] **Step 2: Move `inspect_capability`**
|
||
|
||
Move the existing handler body into:
|
||
|
||
```python
|
||
async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]:
|
||
...
|
||
```
|
||
|
||
Required replacements:
|
||
|
||
```python
|
||
self.service.capability_sources -> self.context.capability_sources
|
||
self._wrapper_capability_detail(...) -> self._wrapper_capability_detail(...)
|
||
```
|
||
|
||
Preserve:
|
||
|
||
- enabled/planner visibility filtering
|
||
- wrapper detail fallback
|
||
- `KeyError(f"unknown workflow capability {qualified_name!r}")`
|
||
- `wrapper_hints` payload
|
||
|
||
- [ ] **Step 3: Add helper functions**
|
||
|
||
Move or duplicate:
|
||
|
||
```text
|
||
_schema_field_names
|
||
_artifact_capability_id
|
||
_required_capability_payloads
|
||
```
|
||
|
||
Do not import private helpers from `wf_api.artifacts` or `wf_api.runs` in this slice unless the import is already public. Private duplication is acceptable here.
|
||
|
||
---
|
||
|
||
## Task 3: Move Wrapper Capability Projection
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_api/capabilities.py`
|
||
|
||
- [ ] **Step 1: Add artifact store helper**
|
||
|
||
Add:
|
||
|
||
```python
|
||
def _artifact_store(self):
|
||
return self.context.artifact_store
|
||
```
|
||
|
||
Do not raise from this helper. Existing wrapper projection returns no wrapper
|
||
rows/details when artifact store is absent.
|
||
|
||
- [ ] **Step 2: Move `_wrapper_artifact_for_capability_name`**
|
||
|
||
Move the existing method, replacing:
|
||
|
||
```python
|
||
self.service.artifact_store -> self.context.artifact_store
|
||
```
|
||
|
||
Preserve current behavior:
|
||
|
||
- invalid capability ids return `None`
|
||
- non-wrapper artifacts return `None`
|
||
- missing artifact store returns `None`
|
||
- missing artifact id/version returns `None`
|
||
|
||
- [ ] **Step 3: Move `_wrapper_capability_summaries`**
|
||
|
||
Move the method and replace:
|
||
|
||
```python
|
||
matches_query(...) -> _matches_query(...)
|
||
```
|
||
|
||
Preserve `source_id not in {None, "workflow"}` filtering and the existing row shape.
|
||
|
||
- [ ] **Step 4: Move `_wrapper_capability_detail`**
|
||
|
||
Move the method unchanged except helper references now point to local functions.
|
||
|
||
Preserve:
|
||
|
||
- `kind == "wrapper_artifact"`
|
||
- `required_capabilities`
|
||
- `wrapper_hints`
|
||
- output/input schema fields
|
||
|
||
---
|
||
|
||
## Task 4: Move Direct Capability Calls
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_api/capabilities.py`
|
||
|
||
- [ ] **Step 1: Move `call_capability`**
|
||
|
||
Move the existing handler body into:
|
||
|
||
```python
|
||
async def call_capability(
|
||
self,
|
||
*,
|
||
qualified_name: str,
|
||
payload: dict[str, Any],
|
||
deployment_id: str | None = None,
|
||
) -> dict[str, Any]:
|
||
...
|
||
```
|
||
|
||
Required replacements:
|
||
|
||
```python
|
||
self.service._get_qualified_spec(qualified_name) -> self.context.specs.get_qualified_spec(qualified_name)
|
||
self.service.capability_sources -> self.context.capability_sources
|
||
self._call_wrapper_artifact(...) -> self._call_wrapper_artifact(...)
|
||
```
|
||
|
||
Preserve direct NodeSpec call behavior:
|
||
|
||
- build handler with `build_async_registry(spec)[spec.name]`
|
||
- pass `RuntimeContext(current_node_id=spec.name)`
|
||
- catch `Exception` and return `capability_call_failed` diagnostic payload
|
||
- successful response returns `kind: "node_spec"` and empty diagnostics
|
||
|
||
- [ ] **Step 2: Move `_call_wrapper_artifact`**
|
||
|
||
Move the wrapper call method into `WorkflowCapabilityApi`.
|
||
|
||
Required replacements:
|
||
|
||
```python
|
||
self.service.artifact_store -> self.context.artifact_store
|
||
self.service.run_workflow_from_plan(...) -> self.context.runtime.run_workflow_from_plan(...)
|
||
```
|
||
|
||
Call runtime with the same deployment/artifact arguments:
|
||
|
||
```python
|
||
run = await self.context.runtime.run_workflow_from_plan(
|
||
plan,
|
||
payload,
|
||
deployment=deployment,
|
||
artifact=artifact,
|
||
)
|
||
```
|
||
|
||
Preserve:
|
||
|
||
- `direct_wrapper_interrupt_diagnostic` rejection
|
||
- deployment target validation
|
||
- `kind: "wrapper_artifact"`
|
||
- `outcome: run.status.value`
|
||
- `output: run.output`
|
||
|
||
- [ ] **Step 3: Move raw plan helper**
|
||
|
||
Move or duplicate:
|
||
|
||
```text
|
||
_raw_plan_from_artifact
|
||
_plan_field
|
||
```
|
||
|
||
Do not import private `_raw_plan_from_artifact` from `wf_api.runs` unless you
|
||
first make it public. Keeping a local copy is acceptable for this slice.
|
||
|
||
---
|
||
|
||
## Task 5: Move Capability-Backed Draft Bootstrap
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_api/capabilities.py`
|
||
|
||
- [ ] **Step 1: Move `create_draft_workspace_from_capability`**
|
||
|
||
Move the existing handler body into `WorkflowCapabilityApi`.
|
||
|
||
Required replacements:
|
||
|
||
```python
|
||
capability = await self.inspect_capability(...)
|
||
result = await self.drafts.create_minimal_draft_workspace(...)
|
||
```
|
||
|
||
Preserve:
|
||
|
||
- wrapper-hint defaults
|
||
- explicit `input` overrides `input_map`
|
||
- explicit `output` overrides `output_map`
|
||
- returned `wrapper_hints`
|
||
- returned `next_actions`
|
||
|
||
- [ ] **Step 2: Move `_draft_name_from_capability`**
|
||
|
||
Move or duplicate:
|
||
|
||
```python
|
||
def _draft_name_from_capability(capability_name: str) -> str:
|
||
"""Return a stable draft name when caller does not provide one."""
|
||
return capability_name.replace(".", "_").replace("-", "_")
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Wire `WorkflowSurfaceHandlers`
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
|
||
|
||
- [ ] **Step 1: Add import and instance**
|
||
|
||
Add:
|
||
|
||
```python
|
||
from wf_api.capabilities import WorkflowCapabilityApi
|
||
```
|
||
|
||
In `WorkflowSurfaceHandlers.__init__`, reuse the existing operation context:
|
||
|
||
```python
|
||
context = context_from_service(service)
|
||
self._capabilities = WorkflowCapabilityApi(context)
|
||
self._drafts = WorkflowDraftApi(context)
|
||
self._artifacts = WorkflowArtifactApi(context)
|
||
self._deployments = WorkflowDeploymentApi(context)
|
||
self._runs = WorkflowRunApi(context)
|
||
```
|
||
|
||
- [ ] **Step 2: Delegate moved methods**
|
||
|
||
Replace method bodies for:
|
||
|
||
```text
|
||
list_capabilities
|
||
inspect_capability
|
||
call_capability
|
||
create_draft_workspace_from_capability
|
||
```
|
||
|
||
Example:
|
||
|
||
```python
|
||
async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]:
|
||
"""Return one planner-visible workflow capability contract."""
|
||
return await self._capabilities.inspect_capability(qualified_name=qualified_name)
|
||
```
|
||
|
||
- [ ] **Step 3: Remove moved private methods**
|
||
|
||
Remove these from `handlers.py` after delegation:
|
||
|
||
```text
|
||
_wrapper_artifact_for_capability_name
|
||
_wrapper_capability_summaries
|
||
_wrapper_capability_detail
|
||
_call_wrapper_artifact
|
||
```
|
||
|
||
Then run:
|
||
|
||
```powershell
|
||
rg -n "_schema_field_names|_source_id_for_capability|_artifact_capability_id|_raw_plan_from_artifact|_plan_field|_draft_name_from_capability|_required_capability_payloads" src/wf_mcp/workflow_surface/handlers.py
|
||
```
|
||
|
||
Remove each helper only if it has no remaining handler caller. The target after
|
||
4E should be close to zero private workflow-domain helpers in `handlers.py`.
|
||
|
||
- [ ] **Step 4: Prune imports**
|
||
|
||
Use `ruff check` to remove unused imports. Likely candidates:
|
||
|
||
```text
|
||
DependencyDiagnostic
|
||
DiagnosticSeverity
|
||
WorkflowArtifact
|
||
WorkflowCapabilityRef
|
||
CapabilitySource
|
||
build_async_registry
|
||
RuntimeContext
|
||
direct_wrapper_interrupt_diagnostic
|
||
workflow_output_schema_for_authoring
|
||
wrapper_hints_for_capability
|
||
parse_workflow_surface_capability_id
|
||
matches_query
|
||
paged_list_payload
|
||
```
|
||
|
||
Do not remove imports still needed by method signatures such as `InputBinding`,
|
||
`OutputBinding`, `GraphSourcePath`, `TraceRange`, or `RawWorkflowPlan`.
|
||
|
||
---
|
||
|
||
## Task 7: Add Focused Capability API Tests
|
||
|
||
**Files:**
|
||
|
||
- Create: `tests/wf_api/test_capability_api.py`
|
||
|
||
- [ ] **Step 1: Cover live source capability listing and inspection**
|
||
|
||
Build a service with `echo_tool`, adapt with `context_from_service`, instantiate
|
||
`WorkflowCapabilityApi`, and assert:
|
||
|
||
```python
|
||
listed = asyncio.run(api.list_capabilities())
|
||
assert listed["total"] >= 1
|
||
assert any(item["name"] == "demo.personal.echo_tool" for item in listed["capabilities"])
|
||
|
||
detail = asyncio.run(api.inspect_capability(qualified_name="demo.personal.echo_tool"))
|
||
assert detail["name"] == "demo.personal.echo_tool"
|
||
assert "wrapper_hints" in detail
|
||
```
|
||
|
||
- [ ] **Step 2: Cover direct NodeSpec call**
|
||
|
||
Call:
|
||
|
||
```python
|
||
result = asyncio.run(
|
||
api.call_capability(
|
||
qualified_name="demo.personal.echo_tool",
|
||
payload={"text": "hello"},
|
||
)
|
||
)
|
||
```
|
||
|
||
Assert stable fields:
|
||
|
||
```python
|
||
assert result["kind"] == "node_spec"
|
||
assert result["outcome"] == "ok"
|
||
assert result["diagnostics"] == []
|
||
```
|
||
|
||
- [ ] **Step 3: Cover saved wrapper projection**
|
||
|
||
Save a wrapper artifact and assert:
|
||
|
||
- `list_capabilities(source_id="workflow")` includes `kind == "wrapper_artifact"`
|
||
- `inspect_capability(qualified_name="workflow.<id>.v<version>")` returns wrapper detail
|
||
- `call_capability(...)` executes the wrapper through runtime and returns `kind == "wrapper_artifact"`
|
||
|
||
Use existing artifact helpers where possible. Do not duplicate entire run tests.
|
||
|
||
- [ ] **Step 4: Cover capability-backed draft bootstrap**
|
||
|
||
Call `create_draft_workspace_from_capability(...)` and assert:
|
||
|
||
```python
|
||
assert result["workspace_id"] == "echo_ws"
|
||
assert result["revision"] == 1
|
||
assert "wrapper_hints" in result
|
||
assert "next_actions" in result
|
||
```
|
||
|
||
Fetch the workspace through `WorkflowDraftApi` and assert the draft uses the
|
||
expected capability name.
|
||
|
||
- [ ] **Step 5: Cover handler delegation smoke**
|
||
|
||
Compare stable fields from handler and direct API for one method:
|
||
|
||
```python
|
||
handler_result = asyncio.run(handlers.inspect_capability(qualified_name=name))
|
||
api_result = asyncio.run(api.inspect_capability(qualified_name=name))
|
||
assert handler_result["name"] == api_result["name"]
|
||
assert handler_result["kind"] == api_result["kind"]
|
||
```
|
||
|
||
Do not duplicate every capability behavior test in both layers.
|
||
|
||
---
|
||
|
||
## Task 8: Verification
|
||
|
||
- [ ] **Step 1: Run focused capability tests**
|
||
|
||
```powershell
|
||
uv run pytest tests/wf_api/test_capability_api.py tests/wf_mcp/workflow_surface/test_capabilities.py -q
|
||
```
|
||
|
||
If `tests/wf_mcp/workflow_surface/test_capabilities.py` does not exist, run the
|
||
closest existing workflow-surface capability tests discovered by `rg -n "call_capability|inspect_capability|list_capabilities" tests/wf_mcp`.
|
||
|
||
Expected: pass.
|
||
|
||
- [ ] **Step 2: Run adjacent API tests**
|
||
|
||
```powershell
|
||
uv run pytest tests/wf_api/test_drafts_service.py tests/wf_api/test_artifact_api.py tests/wf_api/test_deployment_api.py tests/wf_api/test_run_api.py -q
|
||
```
|
||
|
||
Expected: pass.
|
||
|
||
- [ ] **Step 3: Run import-direction test**
|
||
|
||
```powershell
|
||
uv run pytest tests/wf_api/test_import_direction.py -q
|
||
```
|
||
|
||
Expected: pass; `wf_api` has no `wf_mcp` imports.
|
||
|
||
- [ ] **Step 4: Run ruff on touched files**
|
||
|
||
```powershell
|
||
uv run ruff check src/wf_api/capabilities.py src/wf_api/__init__.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_capability_api.py
|
||
```
|
||
|
||
Expected: all checks pass.
|
||
|
||
- [ ] **Step 5: Run basedpyright on touched files**
|
||
|
||
```powershell
|
||
uv run basedpyright --level error src/wf_api/capabilities.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_capability_api.py
|
||
```
|
||
|
||
Expected: `0 errors`.
|
||
|
||
- [ ] **Step 6: Optional full suite**
|
||
|
||
```powershell
|
||
uv run pytest -q
|
||
```
|
||
|
||
Expected: full suite passes with the project’s existing skipped/xfailed counts.
|
||
|
||
---
|
||
|
||
## Self-Review Checklist
|
||
|
||
- `wf_api.capabilities` imports no `wf_mcp`.
|
||
- `WorkflowSurfaceHandlers` public capability signatures are unchanged.
|
||
- `create_draft_workspace_from_capability` moved with capability inspection.
|
||
- Direct NodeSpec calls still work.
|
||
- Saved wrapper discovery and direct wrapper calls still work.
|
||
- Full saved workflows still require deployments.
|
||
- No public payload shape changed.
|
||
- No MCP schema changed.
|
||
- Handler is now mostly a thin compatibility adapter over `wf_api` domain services.
|
||
|
||
## Follow-Up Cleanup After 4E
|
||
|
||
After this slice lands, consider a cleanup plan to promote shared helpers:
|
||
|
||
```text
|
||
wf_api.runs._raw_plan_from_artifact -> wf_api.artifact_plans.raw_plan_from_artifact
|
||
wf_api.capabilities._artifact_capability_id -> wf_api.artifact_refs.artifact_capability_id
|
||
wf_api.deployments._available_sources -> wf_api.source_snapshots.available_sources_from_capability_sources
|
||
```
|
||
|
||
Do not do that cleanup inside 4E unless it is required to remove circular imports
|
||
or duplicate behavior bugs.
|