alright uhh
This commit is contained in:
@@ -409,6 +409,60 @@ The practical split:
|
||||
- migrate: changed workflow behavior, incompatible schema, or different
|
||||
provider semantics
|
||||
|
||||
## Artifact References
|
||||
|
||||
Reusable saved artifacts should prefer logical capability references, not
|
||||
account-specific concrete references.
|
||||
|
||||
For example, if discovery shows a concrete node spec:
|
||||
|
||||
```text
|
||||
demo.personal.echo_tool
|
||||
```
|
||||
|
||||
and the author wants the artifact to depend on the logical source `demo`, the
|
||||
saved plan should use:
|
||||
|
||||
```text
|
||||
demo.echo_tool
|
||||
```
|
||||
|
||||
and the artifact should declare a matching required capability:
|
||||
|
||||
```text
|
||||
RequiredCapability(
|
||||
logical_source="demo",
|
||||
capability_name="echo_tool",
|
||||
kind="node_spec"
|
||||
)
|
||||
```
|
||||
|
||||
The deployment then chooses the concrete account or connection profile:
|
||||
|
||||
```text
|
||||
bindings:
|
||||
demo: demo.personal
|
||||
```
|
||||
|
||||
This keeps reusable artifacts portable across accounts while still letting
|
||||
runtime validation prove that the concrete deployment can actually supply the
|
||||
required capability.
|
||||
|
||||
Concrete references such as `demo.personal.echo_tool` remain useful for raw
|
||||
local plans, tests, direct calls, and backward compatibility. Artifact creation
|
||||
tools should avoid saving those concrete names by default when the user is
|
||||
creating a reusable workflow or wrapper.
|
||||
|
||||
Planned authoring helper:
|
||||
|
||||
```text
|
||||
concrete discovered ref + logical source alias -> artifact ref
|
||||
demo.personal.echo_tool + demo -> demo.echo_tool
|
||||
```
|
||||
|
||||
That helper should also populate `required_capabilities` so LLM clients do not
|
||||
have to reverse-engineer dependency metadata from formatted names.
|
||||
|
||||
Bindings should live outside the immutable artifact, preferably on a deployment
|
||||
or run configuration:
|
||||
|
||||
|
||||
@@ -227,6 +227,18 @@ Saved wrapper artifacts can be called with a deployment id when they use logical
|
||||
source names. The deployment supplies the concrete source bindings for that
|
||||
test call, matching the way `run_deployment` resolves a full saved workflow.
|
||||
|
||||
Reusable wrapper and workflow artifacts should be authored against logical
|
||||
source names by default. If an author discovers a concrete capability such as
|
||||
`everything.default.echo`, the saved artifact should normally depend on a
|
||||
logical reference such as `everything.echo` plus a required capability entry.
|
||||
The deployment is responsible for binding `everything` to `everything.default`,
|
||||
`everything.work`, or any other compatible concrete source.
|
||||
|
||||
This is especially important for LLM-authored workflows. The LLM should not
|
||||
need to infer dependency metadata by parsing formatted names, and a saved
|
||||
artifact should not accidentally become tied to the first account used during
|
||||
exploration.
|
||||
|
||||
That direct-call surface is different from:
|
||||
|
||||
- calling the raw upstream MCP tool
|
||||
|
||||
@@ -17,6 +17,7 @@ from .models import (
|
||||
)
|
||||
from .store import FileWorkflowArtifactStore, WorkflowArtifactStore
|
||||
from .validation import validate_deployment_dependencies
|
||||
from .references import logical_ref_for_concrete_ref, normalize_plan_node_refs
|
||||
|
||||
__all__ = [
|
||||
"AvailableCapability",
|
||||
@@ -34,5 +35,7 @@ __all__ = [
|
||||
"artifact_catalog_entry",
|
||||
"artifact_node_name",
|
||||
"create_workflow_artifact_from_plan",
|
||||
"logical_ref_for_concrete_ref",
|
||||
"normalize_plan_node_refs",
|
||||
"validate_deployment_dependencies",
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections.abc import Mapping
|
||||
from wf_core import ReducerRef, Workflow
|
||||
|
||||
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
|
||||
from .references import normalize_plan_node_refs
|
||||
|
||||
|
||||
def create_workflow_artifact_from_plan(
|
||||
@@ -17,22 +18,28 @@ def create_workflow_artifact_from_plan(
|
||||
kind: ArtifactKind = "workflow",
|
||||
description: str | None = None,
|
||||
required_capabilities: Mapping[str, RequiredCapability] | None = None,
|
||||
source_bindings: Mapping[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> WorkflowArtifact:
|
||||
"""Create an immutable artifact from a declarative workflow plan."""
|
||||
_validate_workflow_plan(plan)
|
||||
normalized_plan, node_requirements = normalize_plan_node_refs(
|
||||
plan,
|
||||
source_bindings or {},
|
||||
)
|
||||
_validate_workflow_plan(normalized_plan)
|
||||
return WorkflowArtifact(
|
||||
id=artifact_id,
|
||||
version=version,
|
||||
title=title,
|
||||
kind=kind,
|
||||
description=description,
|
||||
input_schema=_required_object_field(plan, "input_schema"),
|
||||
output_schema=_required_object_field(plan, "output_schema"),
|
||||
input_schema=_required_object_field(normalized_plan, "input_schema"),
|
||||
output_schema=_required_object_field(normalized_plan, "output_schema"),
|
||||
outcomes=outcomes,
|
||||
plan=plan,
|
||||
plan=normalized_plan,
|
||||
required_capabilities={
|
||||
**_required_reducers_from_plan(plan),
|
||||
**_required_reducers_from_plan(normalized_plan),
|
||||
**node_requirements,
|
||||
**dict(required_capabilities or {}),
|
||||
},
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
|
||||
from .models import JsonObject, RequiredCapability
|
||||
|
||||
|
||||
def normalize_plan_node_refs(
|
||||
plan: JsonObject,
|
||||
source_bindings: Mapping[str, str],
|
||||
) -> tuple[JsonObject, dict[str, RequiredCapability]]:
|
||||
"""Rewrite concrete node refs in a plan to deployment-bound logical refs.
|
||||
|
||||
`source_bindings` uses the same direction as `WorkflowDeployment.bindings`:
|
||||
logical source -> concrete source. This lets artifact creation accept the
|
||||
source mapping authors already need at deployment time while saving portable
|
||||
plan refs such as `demo.echo_tool` instead of `demo.personal.echo_tool`.
|
||||
"""
|
||||
if not source_bindings:
|
||||
return deepcopy(plan), {}
|
||||
|
||||
normalized = deepcopy(plan)
|
||||
requirements: dict[str, RequiredCapability] = {}
|
||||
nodes = normalized.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return normalized, requirements
|
||||
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
raw_node_ref = node.get("node")
|
||||
if not isinstance(raw_node_ref, str):
|
||||
continue
|
||||
replacement = logical_ref_for_concrete_ref(raw_node_ref, source_bindings)
|
||||
if replacement is None:
|
||||
continue
|
||||
logical_ref, logical_source, capability_name, concrete_source = replacement
|
||||
node["node"] = logical_ref
|
||||
requirements[logical_ref] = RequiredCapability(
|
||||
logical_source=logical_source,
|
||||
capability_name=capability_name,
|
||||
kind="node_spec",
|
||||
observed_concrete_source=concrete_source,
|
||||
)
|
||||
|
||||
return normalized, requirements
|
||||
|
||||
|
||||
def logical_ref_for_concrete_ref(
|
||||
concrete_ref: str,
|
||||
source_bindings: Mapping[str, str],
|
||||
) -> tuple[str, str, str, str] | None:
|
||||
"""Return a logical ref for one concrete capability ref when bound.
|
||||
|
||||
The longest concrete source match wins so `demo.personal.pro.echo` can be
|
||||
handled safely when both `demo.personal` and `demo.personal.pro` exist.
|
||||
"""
|
||||
matches = sorted(
|
||||
source_bindings.items(),
|
||||
key=lambda item: len(item[1]),
|
||||
reverse=True,
|
||||
)
|
||||
for logical_source, concrete_source in matches:
|
||||
prefix = f"{concrete_source}."
|
||||
if not concrete_ref.startswith(prefix):
|
||||
continue
|
||||
capability_name = concrete_ref[len(prefix) :]
|
||||
if not capability_name:
|
||||
continue
|
||||
logical_ref = f"{logical_source}.{capability_name}"
|
||||
return logical_ref, logical_source, capability_name, concrete_source
|
||||
return None
|
||||
@@ -30,6 +30,7 @@ def register_artifact_tools(server: FastMCP, service: WfMcpService) -> None:
|
||||
outcomes: list[str],
|
||||
description: str | None = None,
|
||||
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
source_bindings: dict[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await handlers.create_artifact_from_plan(
|
||||
@@ -43,6 +44,7 @@ def register_artifact_tools(server: FastMCP, service: WfMcpService) -> None:
|
||||
name: capability
|
||||
for name, capability in (required_capabilities or {}).items()
|
||||
},
|
||||
source_bindings=source_bindings,
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ class WorkflowSurfaceHandlers:
|
||||
kind: ArtifactKind = "workflow",
|
||||
description: str | None = None,
|
||||
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
source_bindings: dict[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.service.artifact_store is None:
|
||||
@@ -178,6 +179,7 @@ class WorkflowSurfaceHandlers:
|
||||
name: RequiredCapability.model_validate(capability)
|
||||
for name, capability in (required_capabilities or {}).items()
|
||||
},
|
||||
source_bindings=source_bindings,
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
self.service.artifact_store.save_artifact(workflow_artifact)
|
||||
|
||||
@@ -68,6 +68,7 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
required_capabilities: (
|
||||
Mapping[str, RequiredCapability | dict[str, Any]] | None
|
||||
) = None,
|
||||
source_bindings: Mapping[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await handlers.create_artifact_from_plan(
|
||||
@@ -87,6 +88,7 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
for name, capability in (required_capabilities or {}).items()
|
||||
}
|
||||
or None,
|
||||
source_bindings=dict(source_bindings or {}),
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from wf_artifacts import RequiredCapability, create_workflow_artifact_from_plan
|
||||
|
||||
|
||||
@@ -53,6 +55,55 @@ def test_create_workflow_artifact_from_plan_adds_reducer_dependencies() -> None:
|
||||
assert reducer.kind == "reducer"
|
||||
|
||||
|
||||
def test_create_workflow_artifact_from_plan_rewrites_bound_node_specs() -> None:
|
||||
plan = _plan()
|
||||
_set_first_node_ref(plan, "demo.personal.echo_tool")
|
||||
|
||||
artifact = create_workflow_artifact_from_plan(
|
||||
artifact_id="echo",
|
||||
version=1,
|
||||
title="Echo",
|
||||
plan=plan,
|
||||
outcomes=("done",),
|
||||
source_bindings={"demo": "demo.personal"},
|
||||
)
|
||||
|
||||
node = artifact.plan["nodes"][0]
|
||||
required = artifact.required_capabilities["demo.echo_tool"]
|
||||
assert node["node"] == "demo.echo_tool"
|
||||
assert required.logical_source == "demo"
|
||||
assert required.capability_name == "echo_tool"
|
||||
assert required.kind == "node_spec"
|
||||
assert required.observed_concrete_source == "demo.personal"
|
||||
|
||||
|
||||
def test_create_workflow_artifact_from_plan_keeps_explicit_capability_metadata() -> (
|
||||
None
|
||||
):
|
||||
plan = _plan()
|
||||
_set_first_node_ref(plan, "demo.personal.echo_tool")
|
||||
|
||||
artifact = create_workflow_artifact_from_plan(
|
||||
artifact_id="echo",
|
||||
version=1,
|
||||
title="Echo",
|
||||
plan=plan,
|
||||
outcomes=("done",),
|
||||
source_bindings={"demo": "demo.personal"},
|
||||
required_capabilities={
|
||||
"demo.echo_tool": RequiredCapability(
|
||||
logical_source="demo",
|
||||
capability_name="echo_tool",
|
||||
kind="node_spec",
|
||||
input_schema_hash="sha256:explicit",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
required = artifact.required_capabilities["demo.echo_tool"]
|
||||
assert required.input_schema_hash == "sha256:explicit"
|
||||
|
||||
|
||||
def test_create_workflow_artifact_from_plan_accepts_wrapper_kind() -> None:
|
||||
artifact = create_workflow_artifact_from_plan(
|
||||
artifact_id="normalize_status",
|
||||
@@ -147,3 +198,9 @@ def _plan() -> dict[str, object]:
|
||||
],
|
||||
"edges": [{"from": "echo", "outcome": "ok", "to": "__end__"}],
|
||||
}
|
||||
|
||||
|
||||
def _set_first_node_ref(plan: dict[str, object], node_ref: str) -> None:
|
||||
"""Set the first node ref in a loosely typed raw plan test fixture."""
|
||||
nodes = cast("list[dict[str, Any]]", plan["nodes"])
|
||||
nodes[0]["node"] = node_ref
|
||||
|
||||
@@ -134,6 +134,30 @@ def test_workflow_surface_creates_wrapper_artifact_from_plan() -> None:
|
||||
assert artifact.kind == "wrapper"
|
||||
|
||||
|
||||
def test_workflow_surface_creates_artifact_with_logical_node_refs() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_logical_refs"
|
||||
)
|
||||
handlers = _handlers(artifact_store)
|
||||
plan = _echo_artifact().plan
|
||||
plan["nodes"][0]["node"] = "demo.personal.echo_tool"
|
||||
|
||||
asyncio.run(
|
||||
handlers.create_artifact_from_plan(
|
||||
artifact_id="echo_logical",
|
||||
version=1,
|
||||
title="Echo Logical",
|
||||
plan=plan,
|
||||
outcomes=("completed",),
|
||||
source_bindings={"demo": "demo.personal"},
|
||||
)
|
||||
)
|
||||
artifact = artifact_store.get_artifact("echo_logical", 1)
|
||||
|
||||
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
|
||||
assert artifact.required_capabilities["demo.echo_tool"].logical_source == "demo"
|
||||
|
||||
|
||||
def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None:
|
||||
plan = RawWorkflowPlan.model_validate(_echo_artifact().plan)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user