feat: add draft step capability helper
This commit is contained in:
@@ -389,6 +389,96 @@ class WorkflowDraftApi:
|
||||
],
|
||||
)
|
||||
|
||||
async def add_step_from_capability(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
capability_name: str,
|
||||
route_from_step: str | None = None,
|
||||
route_from_outcome: str = DEFAULT_OK_OUTCOME,
|
||||
route_outcome: str = DEFAULT_OK_OUTCOME,
|
||||
route_to: str = "__end__",
|
||||
input_map: dict[str, str] | None = None,
|
||||
bind_outputs: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add one capability step plus explicit route/map/schema wiring.
|
||||
|
||||
This is a composed authoring helper for agents. It edits the draft in
|
||||
one revision so callers do not have to interleave add-step, route,
|
||||
input-map, state-schema, and output-map operations by hand.
|
||||
"""
|
||||
workspace = self._draft_store().get_workspace(workspace_id)
|
||||
steps = workspace.draft.get("steps")
|
||||
if not isinstance(steps, dict):
|
||||
raise ValueError("draft steps must be an object")
|
||||
if step_id in steps:
|
||||
raise ValueError(f"draft step {step_id!r} already exists")
|
||||
|
||||
spec = self.context.specs.get_qualified_spec(capability_name)
|
||||
output_schema = (
|
||||
spec.output_schema_contract or spec.output_model.model_json_schema()
|
||||
)
|
||||
state_schema = workspace.draft.get("state_schema", {})
|
||||
if not isinstance(state_schema, dict):
|
||||
raise ValueError("draft state_schema must be an object")
|
||||
|
||||
input_map = input_map or {}
|
||||
bind_outputs = bind_outputs or {}
|
||||
projected_state_schema = state_schema
|
||||
for output_field, state_path in bind_outputs.items():
|
||||
state_field = _state_root_field(state_path)
|
||||
projected_state_schema = project_output_property_to_state_schema(
|
||||
state_schema=projected_state_schema,
|
||||
output_schema=output_schema,
|
||||
output_field=output_field,
|
||||
state_field=state_field,
|
||||
)
|
||||
|
||||
patch: list[dict[str, Any]] = [
|
||||
{
|
||||
"op": "add",
|
||||
"path": f"/steps/{_escape_json_pointer(step_id)}",
|
||||
"value": {
|
||||
"use": capability_name,
|
||||
"input": _draft_input_bindings_payload(input_map, {}),
|
||||
"output": _draft_output_bindings_payload(bind_outputs),
|
||||
},
|
||||
},
|
||||
{
|
||||
"op": "add",
|
||||
"path": f"/routes/{_escape_json_pointer(step_id)}",
|
||||
"value": {route_outcome: route_to},
|
||||
},
|
||||
]
|
||||
if projected_state_schema != state_schema:
|
||||
patch.insert(
|
||||
0,
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/state_schema",
|
||||
"value": projected_state_schema,
|
||||
},
|
||||
)
|
||||
if route_from_step is not None:
|
||||
patch.append(
|
||||
{
|
||||
"op": "add",
|
||||
"path": (
|
||||
f"/routes/{_escape_json_pointer(route_from_step)}/"
|
||||
f"{_escape_json_pointer(route_from_outcome)}"
|
||||
),
|
||||
"value": step_id,
|
||||
}
|
||||
)
|
||||
|
||||
return await self.patch_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
patch=patch,
|
||||
)
|
||||
|
||||
def _step_input_maps(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -396,6 +396,33 @@ class WorkflowApi:
|
||||
state_path=state_path,
|
||||
)
|
||||
|
||||
async def add_step_from_capability(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
capability_name: str,
|
||||
route_from_step: str | None = None,
|
||||
route_from_outcome: str = "ok",
|
||||
route_outcome: str = "ok",
|
||||
route_to: str = "__end__",
|
||||
input_map: dict[str, str] | None = None,
|
||||
bind_outputs: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.drafts.add_step_from_capability(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
capability_name=capability_name,
|
||||
route_from_step=route_from_step,
|
||||
route_from_outcome=route_from_outcome,
|
||||
route_outcome=route_outcome,
|
||||
route_to=route_to,
|
||||
input_map=input_map,
|
||||
bind_outputs=bind_outputs,
|
||||
)
|
||||
|
||||
async def create_minimal_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -134,6 +134,21 @@ class WorkflowDraftSurface(Protocol):
|
||||
state_path: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def add_step_from_capability(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
capability_name: str,
|
||||
route_from_step: str | None = None,
|
||||
route_from_outcome: str = "ok",
|
||||
route_outcome: str = "ok",
|
||||
route_to: str = "__end__",
|
||||
input_map: dict[str, str] | None = None,
|
||||
bind_outputs: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def validate_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -359,6 +359,81 @@ def bind_output_to_state(
|
||||
)
|
||||
|
||||
|
||||
@app.command("add-step-from-capability")
|
||||
def add_step_from_capability(
|
||||
ctx: typer.Context,
|
||||
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
|
||||
revision: Annotated[
|
||||
int, typer.Option("--revision", min=1, help="Expected workspace revision.")
|
||||
],
|
||||
step_id: Annotated[str, typer.Option("--step", help="New draft step id.")],
|
||||
capability_name: Annotated[
|
||||
str, typer.Option("--capability", help="Qualified capability name.")
|
||||
],
|
||||
route_from_step: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--from-step",
|
||||
help="Optional existing step whose outcome should route to this step.",
|
||||
),
|
||||
] = None,
|
||||
route_from_outcome: Annotated[
|
||||
str,
|
||||
typer.Option("--from-outcome", help="Outcome on --from-step."),
|
||||
] = "ok",
|
||||
route_outcome: Annotated[
|
||||
str,
|
||||
typer.Option("--outcome", help="Outcome emitted by the new step."),
|
||||
] = "ok",
|
||||
route_to: Annotated[
|
||||
str,
|
||||
typer.Option("--to", help="Target step id or __end__ for the new step."),
|
||||
] = "__end__",
|
||||
input_mapping: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--input",
|
||||
help="Input binding SOURCE=LOCAL_TARGET. Repeat for multiple inputs.",
|
||||
),
|
||||
] = None,
|
||||
output_mapping: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--bind-output",
|
||||
help=(
|
||||
"Output binding LOCAL_OUTPUT=STATE_TARGET with state schema "
|
||||
"projection. Repeat for multiple outputs."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Add one capability step with explicit route, input, and output wiring.
|
||||
|
||||
This command does not guess missing maps. Pass the route and bindings you
|
||||
want, then run `wf draft validate <workspace_id>`.
|
||||
"""
|
||||
input_map = _parse_map_flags(input_mapping)
|
||||
bind_outputs = _parse_map_flags(output_mapping)
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
run_cli_operation(
|
||||
context,
|
||||
context.handlers.add_step_from_capability(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
capability_name=capability_name,
|
||||
route_from_step=route_from_step,
|
||||
route_from_outcome=route_from_outcome,
|
||||
route_outcome=route_outcome,
|
||||
route_to=route_to,
|
||||
input_map=input_map,
|
||||
bind_outputs=bind_outputs,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command("validate")
|
||||
def validate_draft(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -265,6 +265,39 @@ class BindOutputToStateRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class AddStepFromCapabilityRequest(BaseModel):
|
||||
"""Typed MCP request for adding one capability-backed draft step with wiring."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
revision: int = Field(ge=1, description="Expected workspace revision.")
|
||||
step_id: str = Field(description="New draft step id.")
|
||||
capability_name: str = Field(description="Qualified capability name.")
|
||||
route_from_step: str | None = Field(
|
||||
default=None,
|
||||
description="Optional existing step whose outcome should route to the new step.",
|
||||
)
|
||||
route_from_outcome: str = Field(
|
||||
default="ok",
|
||||
description="Outcome on route_from_step that should route to the new step.",
|
||||
)
|
||||
route_outcome: str = Field(
|
||||
default="ok",
|
||||
description="Outcome emitted by the new step.",
|
||||
)
|
||||
route_to: str = Field(
|
||||
default="__end__",
|
||||
description="Target step id or __end__ for the new step outcome.",
|
||||
)
|
||||
input_map: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Graph source path to node-local target field.",
|
||||
)
|
||||
bind_outputs: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Node-local output field to state path with schema projection.",
|
||||
)
|
||||
|
||||
|
||||
class DeleteDraftWorkspaceRequest(BaseModel):
|
||||
"""Typed MCP request payload for deleting one draft workspace."""
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from wf_mcp.broker.service.workflow_operation_context import context_from_servic
|
||||
|
||||
from .models import (
|
||||
AddStateFromOutputRequest,
|
||||
AddStepFromCapabilityRequest,
|
||||
BindOutputToStateRequest,
|
||||
CallCapabilityResult,
|
||||
CreateArtifactFromWorkspaceRequest,
|
||||
@@ -483,6 +484,32 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.add_step_from_capability",
|
||||
title="Add Step From Capability",
|
||||
description=(
|
||||
"Add one capability-backed draft step with explicit route, input, "
|
||||
"and output-to-state binding hints."
|
||||
),
|
||||
)
|
||||
async def add_step_from_capability(
|
||||
request: AddStepFromCapabilityRequest,
|
||||
) -> DraftWorkspaceResult:
|
||||
return DraftWorkspaceResult.model_validate(
|
||||
await handlers.add_step_from_capability(
|
||||
workspace_id=request.workspace_id,
|
||||
revision=request.revision,
|
||||
step_id=request.step_id,
|
||||
capability_name=request.capability_name,
|
||||
route_from_step=request.route_from_step,
|
||||
route_from_outcome=request.route_from_outcome,
|
||||
route_outcome=request.route_outcome,
|
||||
route_to=request.route_to,
|
||||
input_map=request.input_map,
|
||||
bind_outputs=request.bind_outputs,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.create_minimal_draft_workspace",
|
||||
title="Create Minimal Draft Workspace",
|
||||
|
||||
@@ -4,6 +4,7 @@ from .app import create_rpc_app
|
||||
from .client import RpcWorkflowApiClient
|
||||
from .errors import WorkflowRpcError
|
||||
from .models import (
|
||||
AddStepFromCapabilityParams,
|
||||
AdminEmptyParams,
|
||||
CallCapabilityParams,
|
||||
CreateArtifactFromPlanParams,
|
||||
@@ -43,6 +44,7 @@ from .models import (
|
||||
__all__ = [
|
||||
"CreateArtifactFromPlanParams",
|
||||
"CreateArtifactFromWorkspaceParams",
|
||||
"AddStepFromCapabilityParams",
|
||||
"AdminEmptyParams",
|
||||
"CallCapabilityParams",
|
||||
"CreateDraftFromCapabilityParams",
|
||||
|
||||
@@ -181,6 +181,36 @@ class RpcDraftClientMixin:
|
||||
},
|
||||
)
|
||||
|
||||
async def add_step_from_capability(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
capability_name: str,
|
||||
route_from_step: str | None = None,
|
||||
route_from_outcome: str = "ok",
|
||||
route_outcome: str = "ok",
|
||||
route_to: str = "__end__",
|
||||
input_map: dict[str, str] | None = None,
|
||||
bind_outputs: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.draft_workspaces.add_step_from_capability",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"revision": revision,
|
||||
"step_id": step_id,
|
||||
"capability_name": capability_name,
|
||||
"route_from_step": route_from_step,
|
||||
"route_from_outcome": route_from_outcome,
|
||||
"route_outcome": route_outcome,
|
||||
"route_to": route_to,
|
||||
"input_map": input_map or {},
|
||||
"bind_outputs": bind_outputs or {},
|
||||
},
|
||||
)
|
||||
|
||||
async def validate_draft_workspace(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
|
||||
@@ -9,6 +9,7 @@ from wf_server import WorkflowServer
|
||||
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||
from ..models import (
|
||||
AddStateFromOutputParams,
|
||||
AddStepFromCapabilityParams,
|
||||
BindOutputToStateParams,
|
||||
CreateArtifactFromWorkspaceParams,
|
||||
CreateDraftFromCapabilityParams,
|
||||
@@ -215,6 +216,29 @@ def register_methods(
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.draft_workspaces.add_step_from_capability",
|
||||
errors=[WorkflowRpcError],
|
||||
)
|
||||
async def workflow_draft_workspaces_add_step_from_capability(
|
||||
params: AddStepFromCapabilityParams = RpcParams(),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await server.api.add_step_from_capability(
|
||||
workspace_id=params.workspace_id,
|
||||
revision=params.revision,
|
||||
step_id=params.step_id,
|
||||
capability_name=params.capability_name,
|
||||
route_from_step=params.route_from_step,
|
||||
route_from_outcome=params.route_from_outcome,
|
||||
route_outcome=params.route_outcome,
|
||||
route_to=params.route_to,
|
||||
input_map=params.input_map,
|
||||
bind_outputs=params.bind_outputs,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.draft_workspaces.validate", errors=[WorkflowRpcError]
|
||||
)
|
||||
|
||||
@@ -157,6 +157,19 @@ class BindOutputToStateParams(RpcParamsModel):
|
||||
state_path: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AddStepFromCapabilityParams(RpcParamsModel):
|
||||
workspace_id: str = Field(min_length=1)
|
||||
revision: int = Field(ge=1)
|
||||
step_id: str = Field(min_length=1)
|
||||
capability_name: str = Field(min_length=1)
|
||||
route_from_step: str | None = None
|
||||
route_from_outcome: str = Field(default="ok", min_length=1)
|
||||
route_outcome: str = Field(default="ok", min_length=1)
|
||||
route_to: str = Field(default="__end__", min_length=1)
|
||||
input_map: dict[str, str] = Field(default_factory=dict)
|
||||
bind_outputs: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ValidateDraftWorkspaceParams(RpcParamsModel):
|
||||
workspace_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user