project workflow state -> output

This commit is contained in:
lda
2026-05-26 00:43:23 +07:00 Verified
parent fad915a186
commit 6846c649b6
16 changed files with 307 additions and 23 deletions
+9
View File
@@ -30,6 +30,15 @@ This means schema fields are contracts for authoring, planning, documentation,
and mapping validation. Runtime state merge behavior is separate metadata on and mapping validation. Runtime state merge behavior is separate metadata on
declared exact state paths; undeclared paths still use `replace`. declared exact state paths; undeclared paths still use `replace`.
Final workflow output has two projection modes:
- If `Workflow.output` contains bindings, those bindings build the public output
payload from graph paths such as `state.result.message`.
- If `Workflow.output` is empty, legacy projection copies same-named top-level
state keys listed in `workflow.output_schema.properties`.
The projected payload is then validated against `workflow.output_schema`.
## Why This Matters ## Why This Matters
Node and workflow boundaries can now reject wrong primitive/container types when Node and workflow boundaries can now reject wrong primitive/container types when
+3 -1
View File
@@ -41,7 +41,9 @@ flat modules.
8. `runtime.ops.nodes` handles `NodeUse` input projection, handler invocation, 8. `runtime.ops.nodes` handles `NodeUse` input projection, handler invocation,
output validation, and state writes. output validation, and state writes.
9. `runtime.ops.flow` records trace entries and advances frames. 9. `runtime.ops.flow` records trace entries and advances frames.
10. Completion projects workflow output from state and validates it. 10. Completion projects workflow output and validates it. Explicit
`Workflow.output` bindings are used when present; older workflows fall back
to same-name top-level state projection from `output_schema.properties`.
Async execution shares the same runtime model. The async seam is handler Async execution shares the same runtime model. The async seam is handler
invocation; control-flow steps are still synchronous state transitions. invocation; control-flow steps are still synchronous state transitions.
+4 -4
View File
@@ -21,9 +21,8 @@ from .models import (
def build_workflow_from_draft(draft: WorkflowDraft) -> Workflow: def build_workflow_from_draft(draft: WorkflowDraft) -> Workflow:
"""Adapt one typed draft through `WorkflowBuilder` into a core workflow. """Adapt one typed draft through `WorkflowBuilder` into a core workflow.
Draft step `output` bindings become node-output-to-state writes. Final Draft step `output` bindings become node-output-to-state writes. Draft
workflow output projection stays in core runtime and uses output schema top-level `output` bindings become core root workflow output projection.
property names as state keys.
""" """
builder = WorkflowBuilder( builder = WorkflowBuilder(
name=draft.name, name=draft.name,
@@ -39,7 +38,8 @@ def build_workflow_from_draft(draft: WorkflowDraft) -> Workflow:
for source_id, routes in draft.routes.items(): for source_id, routes in draft.routes.items():
for outcome, target in routes.items(): for outcome, target in routes.items():
builder.connect(step_refs[source_id], outcome, target) builder.connect(step_refs[source_id], outcome, target)
return builder.compile() workflow = builder.compile()
return workflow.model_copy(update={"output": list(draft.output)})
def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep): def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
+6 -7
View File
@@ -30,9 +30,8 @@ STEP_KIND_KEYS = frozenset(
class DraftUseStep(BaseModel): class DraftUseStep(BaseModel):
"""Draft step that calls one externally resolvable workflow capability. """Draft step that calls one externally resolvable workflow capability.
`output` writes node-local output fields into workflow state. It does not `output` writes node-local output fields into workflow state. Root
define final workflow output; core currently projects final output from workflow output bindings live on `WorkflowDraft.output`.
state keys whose names match `WorkflowDraft.output_schema.properties`.
""" """
model_config = ConfigDict(extra="forbid", populate_by_name=True) model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -277,16 +276,16 @@ DraftStep = (
class WorkflowDraft(BaseModel): class WorkflowDraft(BaseModel):
"""Patch-friendly JSON authoring document for one workflow graph. """Patch-friendly JSON authoring document for one workflow graph.
There is intentionally no top-level output map in this draft shape. Final Step `output` writes node results into state. Top-level `output` projects
workflow output is projected from state by matching output-schema property final workflow output from graph paths. If top-level `output` is empty, core
names, so terminal steps should write required output fields to same-named falls back to same-name top-level state projection.
state paths.
""" """
name: str name: str
input_schema: JsonObject input_schema: JsonObject
state_schema: JsonObject state_schema: JsonObject
output_schema: JsonObject output_schema: JsonObject
output: list[InputBinding] = Field(default_factory=list)
start: str start: str
steps: dict[str, DraftStep] steps: dict[str, DraftStep]
routes: dict[str, dict[str, str]] = Field(default_factory=dict) routes: dict[str, dict[str, str]] = Field(default_factory=dict)
+10 -1
View File
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, cast
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from wf_core.models.schemas import NodeDef, SchemaRef, StateSchema from wf_core.models.schemas import NodeDef, SchemaRef, StateSchema
from wf_core.models.steps import Step from wf_core.models.steps import InputBinding, Step
if TYPE_CHECKING: if TYPE_CHECKING:
from wf_core.validation.issues import ValidationReport from wf_core.validation.issues import ValidationReport
@@ -27,6 +27,15 @@ class Workflow(BaseModel):
input_schema: SchemaRef input_schema: SchemaRef
state_schema: StateSchema state_schema: StateSchema
output_schema: SchemaRef output_schema: SchemaRef
output: list[InputBinding] = Field(
default_factory=list,
description=(
"Optional final output projection bindings. Sources read from graph "
"paths such as state.result, and targets write into the workflow "
"output payload. When omitted, legacy same-name top-level state "
"projection is used."
),
)
node_defs: list[NodeDef] = Field(default_factory=list) node_defs: list[NodeDef] = Field(default_factory=list)
outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1) outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1)
start: str start: str
+5 -1
View File
@@ -96,7 +96,11 @@ def advance_frame(
def finalize_run(workflow: Workflow, run: RunState) -> RunState: def finalize_run(workflow: Workflow, run: RunState) -> RunState:
if run.outcome is None: if run.outcome is None:
run.outcome = "ok" run.outcome = "ok"
run.output = project_output(workflow, run.state) run.output = project_output(
workflow,
run.state,
workflow_input=run.workflow_input,
)
validate_payload_against_schema( validate_payload_against_schema(
workflow.output_schema, run.output, "workflow output" workflow.output_schema, run.output, "workflow output"
) )
+43 -6
View File
@@ -5,11 +5,22 @@ from copy import deepcopy
from dataclasses import dataclass, field as dataclass_field from dataclasses import dataclass, field as dataclass_field
from typing import Any from typing import Any
from wf_core.conditions import safe_resolve_path
from wf_core.errors import WorkflowExecutionError from wf_core.errors import WorkflowExecutionError
from wf_core.local_paths import LocalPathError, get_local_value, has_overlapping_paths from wf_core.local_paths import (
LocalPathError,
get_local_value,
has_overlapping_paths,
set_local_value,
)
from wf_core.models.reducers import ReducerRef from wf_core.models.reducers import ReducerRef
from wf_core.models.schemas import StateFieldDecl from wf_core.models.schemas import StateFieldDecl
from wf_core.models.steps import NodeUse, OutputBinding from wf_core.models.steps import (
InputPathBinding,
InputValueBinding,
NodeUse,
OutputBinding,
)
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.paths import ( from wf_core.paths import (
PathResolutionError, PathResolutionError,
@@ -437,12 +448,38 @@ def reducer_for_state_path(
) )
def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]: def project_output(
"""Project final workflow output from same-named state fields. workflow: Workflow,
state: dict[str, Any],
*,
workflow_input: Mapping[str, Any] | None = None,
context: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Project final workflow output from explicit bindings or state fields.
Node output bindings write into state during execution. At END, the runtime `workflow.output` is the canonical root-output mapping for workflows whose
exposes only state keys declared by `workflow.output_schema.properties`. public output shape does not mirror top-level state keys. Older workflows
without explicit output bindings keep the same-name state projection.
""" """
if workflow.output:
output: dict[str, Any] = {}
for binding in workflow.output:
if isinstance(binding, InputValueBinding):
value = binding.value
elif isinstance(binding, InputPathBinding):
value = safe_resolve_path(
str(binding.path),
state=state,
workflow_input=workflow_input or {},
context=context or {},
)
else:
raise WorkflowExecutionError("unsupported workflow output binding")
try:
set_local_value(output, binding.target, value)
except LocalPathError as exc:
raise WorkflowExecutionError(str(exc)) from exc
return output
return { return {
key: state[key] for key in workflow.output_schema.properties if key in state key: state[key] for key in workflow.output_schema.properties if key in state
} }
+5 -1
View File
@@ -249,7 +249,11 @@ def _finish_subgraph(
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"subgraph step {step.id!r} child completed without a workflow outcome" f"subgraph step {step.id!r} child completed without a workflow outcome"
) )
child_output = project_output(prepared.workflow, child_scope.committed_state) child_output = project_output(
prepared.workflow,
child_scope.committed_state,
workflow_input=child_scope.workflow_input,
)
validate_payload_against_schema( validate_payload_against_schema(
prepared.workflow.output_schema, prepared.workflow.output_schema,
child_output, child_output,
+2
View File
@@ -20,6 +20,7 @@ from wf_core.validation.steps import (
validate_interrupt_node, validate_interrupt_node,
validate_node_use, validate_node_use,
validate_subgraph_node, validate_subgraph_node,
validate_workflow_output_bindings,
) )
@@ -27,6 +28,7 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
report = ValidationReport() report = ValidationReport()
node_defs = _collect_node_defs(workflow, report) node_defs = _collect_node_defs(workflow, report)
validate_workflow_output_bindings(workflow.output, workflow, report)
nodes_by_id = _validate_nodes(workflow, node_defs, report) nodes_by_id = _validate_nodes(workflow, node_defs, report)
_validate_start(workflow, nodes_by_id, report) _validate_start(workflow, nodes_by_id, report)
outgoing = _validate_edges(workflow, nodes_by_id, node_defs, report) outgoing = _validate_edges(workflow, nodes_by_id, node_defs, report)
+1
View File
@@ -18,6 +18,7 @@ class ValidationIssueCode(StrEnum):
INVALID_NODE_INPUT_FIELD = "invalid_node_input_field" INVALID_NODE_INPUT_FIELD = "invalid_node_input_field"
INVALID_SOURCE_PATH = "invalid_source_path" INVALID_SOURCE_PATH = "invalid_source_path"
INVALID_NODE_OUTPUT_FIELD = "invalid_node_output_field" INVALID_NODE_OUTPUT_FIELD = "invalid_node_output_field"
INVALID_WORKFLOW_OUTPUT_FIELD = "invalid_workflow_output_field"
INVALID_DESTINATION_PATH = "invalid_destination_path" INVALID_DESTINATION_PATH = "invalid_destination_path"
EMPTY_CONDITION_ARGS = "empty_condition_args" EMPTY_CONDITION_ARGS = "empty_condition_args"
INVALID_CONDITION_PATH = "invalid_condition_path" INVALID_CONDITION_PATH = "invalid_condition_path"
+51
View File
@@ -16,6 +16,7 @@ from wf_core.models.steps import (
ForeachNode, ForeachNode,
InputBinding, InputBinding,
InputPathBinding, InputPathBinding,
InputValueBinding,
InterruptNode, InterruptNode,
NodeUse, NodeUse,
OutputBinding, OutputBinding,
@@ -153,6 +154,56 @@ def _validate_boundary_bindings(
) )
def validate_workflow_output_bindings(
bindings: list[InputBinding],
workflow: Workflow,
report: ValidationReport,
) -> None:
"""Validate final workflow output projection bindings.
Root output bindings reuse input-binding shape: graph paths flow into a
local output payload that is later validated against `output_schema`.
"""
output_fields = set(workflow.output_schema.properties)
state_root_fields = workflow.state_schema.root_fields()
input_root_fields = set(workflow.input_schema.properties)
output_targets = []
for output_index, binding in enumerate(bindings):
output_targets.append(binding.target)
destination_root = _local_root(binding.target)
if destination_root is None or (
destination_root != "." and destination_root not in output_fields
):
report.add(
ValidationIssueCode.INVALID_WORKFLOW_OUTPUT_FIELD,
f"output[{output_index}].target",
"destination field is not declared in workflow output schema",
)
if isinstance(binding, InputPathBinding) and not is_valid_source_path(
binding.path,
state_root_fields,
input_root_fields,
allow_context=True,
):
report.add(
ValidationIssueCode.INVALID_SOURCE_PATH,
f"output[{output_index}].path",
"source path must start with input., state., or context. and reference a declared root field when applicable",
)
elif not isinstance(binding, (InputPathBinding, InputValueBinding)):
report.add(
ValidationIssueCode.INVALID_WORKFLOW_OUTPUT_FIELD,
f"output[{output_index}]",
"unsupported workflow output binding",
)
if has_overlapping_paths(output_targets):
report.add(
ValidationIssueCode.INVALID_WORKFLOW_OUTPUT_FIELD,
"output",
"workflow output has overlapping output payload paths",
)
def _local_root(path: str | LocalPath) -> str | None: def _local_root(path: str | LocalPath) -> str | None:
try: try:
parts = split_local_path(path) parts = split_local_path(path)
+1
View File
@@ -663,6 +663,7 @@ class WfMcpService:
"input_schema": plan.input_schema, "input_schema": plan.input_schema,
"state_schema": plan.state_schema, "state_schema": plan.state_schema,
"output_schema": plan.output_schema, "output_schema": plan.output_schema,
"output": [binding.model_dump(mode="json") for binding in plan.output],
"start": plan.start, "start": plan.start,
"node_defs": [node.model_dump() for node in node_defs.values()], "node_defs": [node.model_dump() for node in node_defs.values()],
"nodes": nodes, "nodes": nodes,
+9 -2
View File
@@ -4,9 +4,9 @@ from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel, Field
from wf_core import Edge from wf_core import Edge
from wf_core.models.steps import Step from wf_core.models.steps import InputBinding, Step
from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry
@@ -49,6 +49,13 @@ class RawWorkflowPlan(BaseModel):
input_schema: dict[str, Any] input_schema: dict[str, Any]
state_schema: dict[str, Any] state_schema: dict[str, Any]
output_schema: dict[str, Any] output_schema: dict[str, Any]
output: list[InputBinding] = Field(
default_factory=list,
description=(
"Optional root workflow output bindings. Sources read graph paths "
"such as state.result and targets write the public output payload."
),
)
start: str start: str
nodes: list[Step] nodes: list[Step]
edges: list[Edge] edges: list[Edge]
+32
View File
@@ -75,6 +75,38 @@ def test_adapter_lowers_use_steps_to_canonical_bindings() -> None:
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]} assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]}
def test_adapter_lowers_root_workflow_output_bindings() -> None:
draft = WorkflowDraft.model_validate(
{
"name": "echo",
"input_schema": {},
"state_schema": {
"type": "object",
"properties": {
"raw": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
}
},
},
"output_schema": {
"type": "object",
"properties": {"message": {"type": "string"}},
},
"output": [{"target": "message", "path": "state.raw.echoed"}],
"start": "echo",
"steps": {"echo": {"use": "demo.echo"}},
"routes": {"echo": {"ok": "__end__"}},
}
)
workflow = build_workflow_from_draft(draft)
dumped = workflow.model_dump(mode="json")
assert dumped["output"][0]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["output"][0]["path"] == {"root": "state", "parts": ["raw", "echoed"]}
def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None: def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate(
{ {
+77
View File
@@ -33,6 +33,81 @@ def test_explicit_end_node_sets_workflow_outcome() -> None:
assert run.outcome == "error" assert run.outcome == "error"
def test_root_workflow_output_bindings_project_final_output() -> None:
workflow = Workflow.model_validate(
{
"name": "root_output_bindings",
"input_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
"state_schema": {
"type": "object",
"properties": {
"result": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
}
},
},
"output_schema": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
"required": ["echoed"],
},
"output": [{"target": "echoed", "path": "state.result.echoed"}],
"node_defs": [
{
"name": "finish",
"input_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
"output_schema": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
"required": ["echoed"],
},
"outcomes": ["done"],
}
],
"start": "finish",
"nodes": [
{
"id": "finish",
"type": "node",
"node": "finish",
"input": [{"target": "text", "path": "input.text"}],
"output": [{"source": "echoed", "target": "state.result.echoed"}],
}
],
"edges": [{"from": "finish", "outcome": "done", "to": END}],
}
)
run = execute_workflow(workflow, {"text": "hello"}, {"finish": _finish})
assert run.output["echoed"] == "hello"
assert run.state["result"]["echoed"] == "hello"
def test_validation_rejects_root_workflow_output_target_not_declared() -> None:
workflow = _workflow(
edges=[{"from": "finish", "outcome": "done", "to": END}],
output=[{"target": "missing", "path": "state.echoed"}],
)
report = workflow.validate_structure()
assert any(
issue.code == ValidationIssueCode.INVALID_WORKFLOW_OUTPUT_FIELD
and issue.path == "output[0].target"
for issue in report.errors
)
def test_validation_rejects_end_node_outcome_not_declared_by_workflow() -> None: def test_validation_rejects_end_node_outcome_not_declared_by_workflow() -> None:
workflow = _workflow( workflow = _workflow(
nodes=[ nodes=[
@@ -80,6 +155,7 @@ def _workflow(
outcomes: list[str] | None = None, outcomes: list[str] | None = None,
nodes: list[dict[str, object]] | None = None, nodes: list[dict[str, object]] | None = None,
edges: list[dict[str, object]], edges: list[dict[str, object]],
output: list[dict[str, object]] | None = None,
) -> Workflow: ) -> Workflow:
return Workflow.model_validate( return Workflow.model_validate(
{ {
@@ -115,6 +191,7 @@ def _workflow(
} }
], ],
"outcomes": outcomes or ["ok"], "outcomes": outcomes or ["ok"],
"output": [] if output is None else output,
"start": "finish", "start": "finish",
"nodes": [_finish_node_data()] if nodes is None else nodes, "nodes": [_finish_node_data()] if nodes is None else nodes,
"edges": edges, "edges": edges,
+49
View File
@@ -388,6 +388,55 @@ def test_service_compiles_and_runs_raw_plan() -> None:
assert "workflow_run_completed" in event_kinds assert "workflow_run_completed" in event_kinds
def test_service_preserves_raw_plan_root_output_bindings() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "root_output_store"))
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
plan = _raw_plan(
name="demo_root_output_plan",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
state_schema={
"type": "object",
"properties": {
"raw": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
}
},
},
output_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output=[{"target": "message", "path": "state.raw.echoed"}],
start="echo",
nodes=[
{
"id": "echo",
"type": "node",
"node": "demo.personal.echo_tool",
"input": [input_binding("input.text", "text")],
"output": [output_binding("echoed", "state.raw.echoed")],
}
],
edges=[{"from": "echo", "outcome": "ok", "to": END}],
)
run = asyncio.run(service.run_workflow_from_plan(plan, {"text": "hello"}))
assert run.status == RunStatus.COMPLETED
assert run.output["message"] == "hello"
assert run.state["raw"]["echoed"] == "hello"
def test_service_resolves_registered_spec_with_dotted_local_name() -> None: def test_service_resolves_registered_spec_with_dotted_local_name() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "dotted_spec_store")) service = WfMcpService(store=FileStore(local_temp_root() / "dotted_spec_store"))
service.register_connection( service.register_connection(