fifth slice: Capability api!
This commit is contained in:
@@ -5,18 +5,8 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from wf_artifacts import (
|
||||
ArtifactKind,
|
||||
DependencyDiagnostic,
|
||||
DiagnosticSeverity,
|
||||
DraftWorkspaceStore,
|
||||
RequiredCapability,
|
||||
WorkflowArtifact,
|
||||
WorkflowCapabilityRef,
|
||||
)
|
||||
from wf_platform import (
|
||||
CapabilitySource,
|
||||
)
|
||||
from wf_authoring import build_async_registry
|
||||
from wf_core import RuntimeContext
|
||||
from wf_core.models.steps import (
|
||||
InputBinding,
|
||||
OutputBinding,
|
||||
@@ -24,22 +14,14 @@ from wf_core.models.steps import (
|
||||
from wf_core.paths import GraphSourcePath
|
||||
|
||||
from wf_api.artifacts import WorkflowArtifactApi
|
||||
from wf_api.capabilities import WorkflowCapabilityApi
|
||||
from wf_api.deployments import WorkflowDeploymentApi
|
||||
from wf_api.drafts import WorkflowDraftApi
|
||||
from wf_api.models import RawWorkflowPlan
|
||||
from wf_api.next_actions import NextActions
|
||||
from wf_api.refs import parse_workflow_surface_capability_id
|
||||
from wf_api.runs import WorkflowRunApi
|
||||
from wf_api.saved_subgraphs import (
|
||||
direct_wrapper_interrupt_diagnostic,
|
||||
)
|
||||
from wf_api.wrapper_hints import (
|
||||
workflow_output_schema_for_authoring,
|
||||
wrapper_hints_for_capability,
|
||||
)
|
||||
|
||||
from ..broker.service.workflow_operation_context import context_from_service
|
||||
from ..shared import matches_query, paged_list_payload
|
||||
from ..shared import paged_list_payload
|
||||
from .models import TraceRange
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -52,6 +34,7 @@ class WorkflowSurfaceHandlers:
|
||||
def __init__(self, service: WfMcpService) -> None:
|
||||
self.service = service
|
||||
context = context_from_service(service)
|
||||
self._capabilities = WorkflowCapabilityApi(context)
|
||||
self._drafts = WorkflowDraftApi(context)
|
||||
self._artifacts = WorkflowArtifactApi(context)
|
||||
self._deployments = WorkflowDeploymentApi(context)
|
||||
@@ -88,62 +71,18 @@ class WorkflowSurfaceHandlers:
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""Return compact paged planner-visible workflow capability summaries."""
|
||||
capabilities = [
|
||||
{
|
||||
"name": detail.name,
|
||||
"source_id": source.id,
|
||||
"kind": "node_spec",
|
||||
"description": detail.description,
|
||||
"outcomes": list(detail.outcomes),
|
||||
"is_async": detail.is_async,
|
||||
"input_fields": _schema_field_names(detail.input_schema),
|
||||
"output_fields": _schema_field_names(
|
||||
workflow_output_schema_for_authoring(detail.output_schema)
|
||||
),
|
||||
}
|
||||
for source in sorted(
|
||||
self.service.capability_sources.values(),
|
||||
key=lambda source: source.id,
|
||||
)
|
||||
if source.enabled and source.visibility.planner
|
||||
if source_id is None or source.id == source_id
|
||||
for detail in source.as_inventory().capabilities.node_spec_details
|
||||
if matches_query(
|
||||
detail.name,
|
||||
detail.description,
|
||||
query=query,
|
||||
)
|
||||
]
|
||||
capabilities.extend(
|
||||
self._wrapper_capability_summaries(query=query, source_id=source_id)
|
||||
)
|
||||
capabilities.sort(key=lambda capability: capability["name"])
|
||||
return paged_list_payload(
|
||||
"capabilities",
|
||||
capabilities,
|
||||
return await self._capabilities.list_capabilities(
|
||||
query=query,
|
||||
source_id=source_id,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]:
|
||||
"""Return one planner-visible workflow capability contract."""
|
||||
for source in self.service.capability_sources.values():
|
||||
if not source.enabled or not source.visibility.planner:
|
||||
continue
|
||||
for detail in source.as_inventory().capabilities.node_spec_details:
|
||||
if detail.name == qualified_name:
|
||||
detail_payload = detail.model_dump(mode="json")
|
||||
detail_payload["wrapper_hints"] = wrapper_hints_for_capability(
|
||||
capability_name=detail.name,
|
||||
input_schema=detail.input_schema,
|
||||
output_schema=detail.output_schema,
|
||||
outcomes=detail.outcomes,
|
||||
).model_dump(mode="json")
|
||||
return detail_payload
|
||||
wrapper_detail = self._wrapper_capability_detail(qualified_name)
|
||||
if wrapper_detail is not None:
|
||||
return wrapper_detail
|
||||
raise KeyError(f"unknown workflow capability {qualified_name!r}")
|
||||
return await self._capabilities.inspect_capability(
|
||||
qualified_name=qualified_name,
|
||||
)
|
||||
|
||||
async def call_capability(
|
||||
self,
|
||||
@@ -153,200 +92,11 @@ class WorkflowSurfaceHandlers:
|
||||
deployment_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute one planner-visible workflow capability for authoring tests."""
|
||||
wrapper_artifact = self._wrapper_artifact_for_capability_name(qualified_name)
|
||||
if wrapper_artifact is not None:
|
||||
return await self._call_wrapper_artifact(
|
||||
wrapper_artifact,
|
||||
payload,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
|
||||
spec = self.service._get_qualified_spec(qualified_name)
|
||||
handler = build_async_registry(spec)[spec.name]
|
||||
source_id = _source_id_for_capability(
|
||||
self.service.capability_sources,
|
||||
spec.name,
|
||||
return await self._capabilities.call_capability(
|
||||
qualified_name=qualified_name,
|
||||
payload=payload,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
try:
|
||||
result = await handler(payload, RuntimeContext(current_node_id=spec.name))
|
||||
except Exception as exc:
|
||||
return {
|
||||
"qualified_name": spec.name,
|
||||
"source_id": source_id,
|
||||
"kind": "node_spec",
|
||||
"deployment_id": None,
|
||||
"outcome": "runtime_error",
|
||||
"output": None,
|
||||
"diagnostics": [
|
||||
DependencyDiagnostic(
|
||||
severity=DiagnosticSeverity.ERROR,
|
||||
code="capability_call_failed",
|
||||
logical_ref=spec.name,
|
||||
bound_source=source_id,
|
||||
message=(
|
||||
f"Capability {spec.name!r} failed during test call: {exc}"
|
||||
),
|
||||
repair_hint=(
|
||||
"Check the source runtime, then retry the capability "
|
||||
"or inspect the deployment run if this happened inside "
|
||||
"a workflow."
|
||||
),
|
||||
).model_dump(mode="json")
|
||||
],
|
||||
}
|
||||
return {
|
||||
"qualified_name": spec.name,
|
||||
"source_id": source_id,
|
||||
"kind": "node_spec",
|
||||
"deployment_id": None,
|
||||
"outcome": result["outcome"],
|
||||
"output": result["output"],
|
||||
"diagnostics": [],
|
||||
}
|
||||
|
||||
def _wrapper_artifact_for_capability_name(
|
||||
self,
|
||||
qualified_name: str,
|
||||
) -> WorkflowArtifact | None:
|
||||
"""Resolve a saved node-like wrapper artifact from its stable capability name."""
|
||||
try:
|
||||
capability_id = parse_workflow_surface_capability_id(qualified_name)
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
not isinstance(capability_id, WorkflowCapabilityRef)
|
||||
or self.service.artifact_store is None
|
||||
):
|
||||
return None
|
||||
try:
|
||||
artifact = self.service.artifact_store.get_artifact(
|
||||
capability_id.artifact_id,
|
||||
capability_id.version,
|
||||
)
|
||||
except KeyError:
|
||||
return None
|
||||
if artifact.kind != "wrapper":
|
||||
return None
|
||||
return artifact
|
||||
|
||||
def _wrapper_capability_summaries(
|
||||
self,
|
||||
*,
|
||||
query: str | None,
|
||||
source_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Project saved wrappers into workflow capability discovery rows.
|
||||
|
||||
Wrapper artifacts are not live source NodeSpecs, but authors need to
|
||||
discover and test them through the same workflow-facing REPL surface.
|
||||
Full saved workflows stay out of this projection until graph-as-node is
|
||||
real in core.
|
||||
"""
|
||||
if source_id not in {None, "workflow"} or self.service.artifact_store is None:
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for artifact in self.service.artifact_store.list_artifacts():
|
||||
if artifact.kind != "wrapper":
|
||||
continue
|
||||
name = _artifact_capability_id(artifact)
|
||||
if not matches_query(
|
||||
name,
|
||||
artifact.description,
|
||||
query=query,
|
||||
):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"source_id": "workflow",
|
||||
"kind": "wrapper_artifact",
|
||||
"artifact_id": artifact.id,
|
||||
"version": artifact.version,
|
||||
"title": artifact.title,
|
||||
"description": artifact.description,
|
||||
"outcomes": list(artifact.outcomes),
|
||||
"is_async": True,
|
||||
"input_fields": _schema_field_names(artifact.input_schema),
|
||||
"output_fields": _schema_field_names(artifact.output_schema),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def _wrapper_capability_detail(
|
||||
self,
|
||||
qualified_name: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a NodeSpec-like contract for one saved wrapper artifact."""
|
||||
artifact = self._wrapper_artifact_for_capability_name(qualified_name)
|
||||
if artifact is None:
|
||||
return None
|
||||
return {
|
||||
"name": _artifact_capability_id(artifact),
|
||||
"source_id": "workflow",
|
||||
"kind": "wrapper_artifact",
|
||||
"artifact_id": artifact.id,
|
||||
"version": artifact.version,
|
||||
"title": artifact.title,
|
||||
"description": artifact.description,
|
||||
"outcomes": list(artifact.outcomes),
|
||||
"is_async": True,
|
||||
"input_schema": artifact.input_schema,
|
||||
"output_schema": artifact.output_schema,
|
||||
"required_capabilities": _required_capability_payloads(
|
||||
artifact.required_capability_map()
|
||||
),
|
||||
"wrapper_hints": wrapper_hints_for_capability(
|
||||
capability_name=_artifact_capability_id(artifact),
|
||||
input_schema=artifact.input_schema,
|
||||
output_schema=artifact.output_schema,
|
||||
outcomes=list(artifact.outcomes),
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
|
||||
async def _call_wrapper_artifact(
|
||||
self,
|
||||
artifact: WorkflowArtifact,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
deployment_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a saved wrapper artifact through the workflow runner."""
|
||||
unsupported = direct_wrapper_interrupt_diagnostic(artifact)
|
||||
if unsupported is not None:
|
||||
raise ValueError(unsupported.message)
|
||||
|
||||
# Direct capability calls remain wrapper-only. Full saved workflows run
|
||||
# through deployments, where native subgraph dependencies and bindings
|
||||
# are prepared before core execution.
|
||||
plan = _raw_plan_from_artifact(artifact)
|
||||
deployment = None
|
||||
if deployment_id is not None:
|
||||
if self.service.artifact_store is None:
|
||||
raise KeyError("workflow artifact store is not configured")
|
||||
deployment = self.service.artifact_store.get_deployment(deployment_id)
|
||||
if (
|
||||
deployment.artifact_id != artifact.id
|
||||
or deployment.artifact_version != artifact.version
|
||||
):
|
||||
raise ValueError(
|
||||
f"deployment {deployment_id!r} does not target "
|
||||
f"workflow.{artifact.id}.v{artifact.version}"
|
||||
)
|
||||
run = await self.service.run_workflow_from_plan(
|
||||
plan,
|
||||
payload,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
)
|
||||
return {
|
||||
"qualified_name": _artifact_capability_id(artifact),
|
||||
"source_id": "workflow",
|
||||
"kind": "wrapper_artifact",
|
||||
"deployment_id": deployment_id,
|
||||
"outcome": run.status.value,
|
||||
"output": run.output,
|
||||
"diagnostics": [],
|
||||
}
|
||||
|
||||
async def save_artifact(self, artifact: dict[str, Any]) -> dict[str, Any]:
|
||||
if self.service.artifact_store is None:
|
||||
@@ -587,33 +337,20 @@ class WorkflowSurfaceHandlers:
|
||||
error_message_source: str | GraphSourcePath | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a patchable draft workspace from inspect_capability hints."""
|
||||
capability = await self.inspect_capability(qualified_name=capability_name)
|
||||
hints = capability["wrapper_hints"]
|
||||
result = await self.create_minimal_draft_workspace(
|
||||
return await self._capabilities.create_draft_workspace_from_capability(
|
||||
workspace_id=workspace_id,
|
||||
name=name or _draft_name_from_capability(capability_name),
|
||||
capability_name=capability_name,
|
||||
input_schema=input_schema or hints["input_schema"],
|
||||
state_schema=state_schema or hints["state_schema"],
|
||||
output_schema=output_schema or hints["output_schema"],
|
||||
name=name,
|
||||
title=title,
|
||||
input_schema=input_schema,
|
||||
state_schema=state_schema,
|
||||
output_schema=output_schema,
|
||||
input=input,
|
||||
output=output,
|
||||
input_map=None if input is not None else (input_map or hints["input_map"]),
|
||||
output_map=None
|
||||
if output is not None
|
||||
else (output_map or hints["output_map"]),
|
||||
input_map=input_map,
|
||||
output_map=output_map,
|
||||
error_message_source=error_message_source,
|
||||
title=title,
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"wrapper_hints": hints,
|
||||
"next_actions": NextActions.from_wrapper_hints(
|
||||
workspace_id=workspace_id,
|
||||
revision=int(result["revision"]),
|
||||
hints=hints,
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
|
||||
async def create_artifact_from_workspace(
|
||||
self,
|
||||
@@ -764,84 +501,3 @@ class WorkflowSurfaceHandlers:
|
||||
run_id=run_id,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
|
||||
|
||||
def _required_capability_payloads(
|
||||
requirements: dict[str, RequiredCapability],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
name: capability.model_dump(mode="json")
|
||||
for name, capability in sorted(requirements.items())
|
||||
}
|
||||
|
||||
|
||||
def _schema_field_names(schema: dict[str, Any]) -> list[str]:
|
||||
"""Return top-level JSON object property names for compact discovery rows."""
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return []
|
||||
return sorted(str(name) for name in properties)
|
||||
|
||||
|
||||
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("-", "_")
|
||||
|
||||
|
||||
def _source_id_for_capability(
|
||||
sources: dict[str, CapabilitySource],
|
||||
qualified_name: str,
|
||||
) -> str | None:
|
||||
"""Return the source that currently owns one workflow capability."""
|
||||
for source in sources.values():
|
||||
if qualified_name in source.capabilities.node_specs:
|
||||
return source.id
|
||||
return None
|
||||
|
||||
|
||||
def _capability_name(qualified_name: str) -> str | None:
|
||||
"""Return the local name of one qualified capability ref if it is valid."""
|
||||
try:
|
||||
parsed = parse_workflow_surface_capability_id(qualified_name)
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(parsed, WorkflowCapabilityRef):
|
||||
return None
|
||||
return parsed.name
|
||||
|
||||
|
||||
def _artifact_capability_id(artifact: WorkflowArtifact) -> str:
|
||||
"""Use the same stable name shape as workflow artifact catalog entries."""
|
||||
return str(
|
||||
WorkflowCapabilityRef(
|
||||
artifact_id=artifact.id,
|
||||
version=artifact.version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan:
|
||||
"""Validate the stored plan shape expected by the broker workflow runner."""
|
||||
return RawWorkflowPlan.model_validate(
|
||||
{
|
||||
"name": _plan_field(artifact, "name"),
|
||||
"input_schema": _plan_field(artifact, "input_schema"),
|
||||
"state_schema": _plan_field(artifact, "state_schema"),
|
||||
"output_schema": _plan_field(artifact, "output_schema"),
|
||||
"outcomes": artifact.plan.get("outcomes", ["ok"]),
|
||||
"output": artifact.plan.get("output", []),
|
||||
"start": _plan_field(artifact, "start"),
|
||||
"nodes": _plan_field(artifact, "nodes"),
|
||||
"edges": _plan_field(artifact, "edges"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
|
||||
try:
|
||||
return artifact.plan[field_name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"workflow artifact {artifact.id}@{artifact.version} "
|
||||
f"is missing plan field {field_name!r}"
|
||||
) from exc
|
||||
|
||||
Reference in New Issue
Block a user