reducer as wf_mcp capability
This commit is contained in:
@@ -178,7 +178,9 @@ Existing built-in reducers remain distinct:
|
|||||||
|
|
||||||
- `wf.std.replace`
|
- `wf.std.replace`
|
||||||
- `wf.std.append`
|
- `wf.std.append`
|
||||||
|
- `wf.std.max`
|
||||||
- `wf.std.merge_object`
|
- `wf.std.merge_object`
|
||||||
|
- `wf.std.set_union`
|
||||||
|
|
||||||
`wf.std.merge_object` means shallow object merge at the exact destination path, similar
|
`wf.std.merge_object` means shallow object merge at the exact destination path, similar
|
||||||
to `dict.update` or `operator.or_`. It is not a recursive deep merge.
|
to `dict.update` or `operator.or_`. It is not a recursive deep merge.
|
||||||
@@ -211,7 +213,6 @@ and belongs in nodes or graph structure.
|
|||||||
Examples a future reducer library could support:
|
Examples a future reducer library could support:
|
||||||
|
|
||||||
- `max`
|
- `max`
|
||||||
- `set_union`
|
|
||||||
- `modulo_add` with configuration such as modulus `10`
|
- `modulo_add` with configuration such as modulus `10`
|
||||||
|
|
||||||
## Implementation Phases
|
## Implementation Phases
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ Expected capabilities:
|
|||||||
`wf.std.truthy`, `wf.std.first_item`, `wf.std.first_item_maybe`,
|
`wf.std.truthy`, `wf.std.first_item`, `wf.std.first_item_maybe`,
|
||||||
`wf.std.first_item_or_none`, `wf.std.last_item`, `wf.std.last_item_or_none`,
|
`wf.std.first_item_or_none`, `wf.std.last_item`, `wf.std.last_item_or_none`,
|
||||||
`wf.std.length`, `wf.std.is_empty`.
|
`wf.std.length`, `wf.std.is_empty`.
|
||||||
- `reducers`: `wf.std.replace`, `wf.std.append`, `wf.std.merge_object`.
|
- `reducers`: `wf.std.replace`, `wf.std.append`, `wf.std.max`,
|
||||||
|
`wf.std.merge_object`, `wf.std.set_union`.
|
||||||
- `prompts`: workflow authoring guide, error-handling guide, mapping guide.
|
- `prompts`: workflow authoring guide, error-handling guide, mapping guide.
|
||||||
- `resources`: reference docs for stdlib node behavior.
|
- `resources`: reference docs for stdlib node behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,8 @@ Saved workflow execution eventually needs first-class runtime support for:
|
|||||||
- resume into child run state
|
- resume into child run state
|
||||||
- child final outcome mapping to parent node outcome
|
- child final outcome mapping to parent node outcome
|
||||||
- dependency checks before execution
|
- dependency checks before execution
|
||||||
|
- reducer capabilities referenced by declared workflow state fields are saved as
|
||||||
|
direct artifact dependencies just like node specs or tools
|
||||||
|
|
||||||
The first implementation should prefer artifact validation and dependency
|
The first implementation should prefer artifact validation and dependency
|
||||||
diagnostics before attempting persistent nested resume.
|
diagnostics before attempting persistent nested resume.
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ def create_workflow_artifact_from_plan(
|
|||||||
output_schema=_required_object_field(plan, "output_schema"),
|
output_schema=_required_object_field(plan, "output_schema"),
|
||||||
outcomes=outcomes,
|
outcomes=outcomes,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
required_capabilities=dict(required_capabilities or {}),
|
required_capabilities={
|
||||||
|
**_required_reducers_from_plan(plan),
|
||||||
|
**dict(required_capabilities or {}),
|
||||||
|
},
|
||||||
created_from_catalog_version=created_from_catalog_version,
|
created_from_catalog_version=created_from_catalog_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,3 +71,28 @@ def _validate_workflow_plan(plan: JsonObject) -> None:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"invalid workflow plan: edge destination {edge.to!r} does not exist"
|
f"invalid workflow plan: edge destination {edge.to!r} does not exist"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapability]:
|
||||||
|
"""Infer reducer dependencies from declared state fields in one plan."""
|
||||||
|
state_schema = plan.get("state_schema")
|
||||||
|
if not isinstance(state_schema, dict):
|
||||||
|
return {}
|
||||||
|
fields = state_schema.get("fields")
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
requirements: dict[str, RequiredCapability] = {}
|
||||||
|
for field in fields.values():
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
continue
|
||||||
|
reducer = field.get("reducer", "wf.std.replace")
|
||||||
|
if not isinstance(reducer, str) or "." not in reducer:
|
||||||
|
continue
|
||||||
|
logical_source, _, capability_name = reducer.rpartition(".")
|
||||||
|
requirements[reducer] = RequiredCapability(
|
||||||
|
logical_source=logical_source,
|
||||||
|
capability_name=capability_name,
|
||||||
|
kind="reducer",
|
||||||
|
)
|
||||||
|
return requirements
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class RequiredCapability(BaseModel):
|
|||||||
|
|
||||||
logical_source: str
|
logical_source: str
|
||||||
capability_name: str
|
capability_name: str
|
||||||
kind: Literal["tool", "resource", "prompt", "node_spec", "workflow"]
|
kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"]
|
||||||
input_schema_hash: str | None = None
|
input_schema_hash: str | None = None
|
||||||
input_schema_snapshot: JsonObject | None = None
|
input_schema_snapshot: JsonObject | None = None
|
||||||
output_schema_hash: str | None = None
|
output_schema_hash: str | None = None
|
||||||
@@ -42,7 +42,7 @@ class AvailableCapability(BaseModel):
|
|||||||
"""Current contract for one capability exposed by a bound source."""
|
"""Current contract for one capability exposed by a bound source."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
kind: Literal["tool", "resource", "prompt", "node_spec", "workflow"]
|
kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"]
|
||||||
input_schema_hash: str | None = None
|
input_schema_hash: str | None = None
|
||||||
output_schema_hash: str | None = None
|
output_schema_hash: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ def _iter_model_metadata(
|
|||||||
yield from _iter_model_metadata(annotation, prefix=path)
|
yield from _iter_model_metadata(annotation, prefix=path)
|
||||||
|
|
||||||
|
|
||||||
def _flatten_state_properties(schema: SchemaRef) -> Iterator[tuple[str, dict[str, Any]]]:
|
def _flatten_state_properties(
|
||||||
|
schema: SchemaRef,
|
||||||
|
) -> Iterator[tuple[str, dict[str, Any]]]:
|
||||||
raw_schema = schema.model_dump(exclude_none=True)
|
raw_schema = schema.model_dump(exclude_none=True)
|
||||||
yield from _iter_state_properties(raw_schema.get("properties", {}), raw_schema)
|
yield from _iter_state_properties(raw_schema.get("properties", {}), raw_schema)
|
||||||
|
|
||||||
@@ -143,7 +145,11 @@ def _state_field_default(
|
|||||||
field_name: str,
|
field_name: str,
|
||||||
property_schema: object,
|
property_schema: object,
|
||||||
) -> object:
|
) -> object:
|
||||||
if "." not in field_name and isinstance(value, type) and issubclass(value, BaseModel):
|
if (
|
||||||
|
"." not in field_name
|
||||||
|
and isinstance(value, type)
|
||||||
|
and issubclass(value, BaseModel)
|
||||||
|
):
|
||||||
field_info = value.model_fields[field_name]
|
field_info = value.model_fields[field_name]
|
||||||
if not field_info.is_required():
|
if not field_info.is_required():
|
||||||
return field_info.get_default(call_default_factory=True)
|
return field_info.get_default(call_default_factory=True)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ def replace_reducer(_current_value: Any, incoming_value: Any) -> Any:
|
|||||||
def append_reducer(current_value: Any, incoming_value: Any) -> Any:
|
def append_reducer(current_value: Any, incoming_value: Any) -> Any:
|
||||||
"""Append one value or many values into a list-valued state path."""
|
"""Append one value or many values into a list-valued state path."""
|
||||||
if current_value is None:
|
if current_value is None:
|
||||||
return [incoming_value] if not isinstance(incoming_value, list) else incoming_value
|
return (
|
||||||
|
[incoming_value] if not isinstance(incoming_value, list) else incoming_value
|
||||||
|
)
|
||||||
if not isinstance(current_value, list):
|
if not isinstance(current_value, list):
|
||||||
raise TypeError("cannot append into non-list state value")
|
raise TypeError("cannot append into non-list state value")
|
||||||
return (
|
return (
|
||||||
@@ -37,10 +39,38 @@ def merge_object_reducer(current_value: Any, incoming_value: Any) -> Any:
|
|||||||
return current_value | incoming_value
|
return current_value | incoming_value
|
||||||
|
|
||||||
|
|
||||||
|
def set_union_reducer(current_value: Any, incoming_value: Any) -> Any:
|
||||||
|
"""Merge list values while preserving stable first-seen order."""
|
||||||
|
if current_value is None:
|
||||||
|
current_items: list[Any] = []
|
||||||
|
elif isinstance(current_value, list):
|
||||||
|
current_items = current_value
|
||||||
|
else:
|
||||||
|
raise TypeError("set_union requires list values")
|
||||||
|
|
||||||
|
if not isinstance(incoming_value, list):
|
||||||
|
raise TypeError("set_union requires list values")
|
||||||
|
|
||||||
|
merged: list[Any] = []
|
||||||
|
for item in [*current_items, *incoming_value]:
|
||||||
|
if item not in merged:
|
||||||
|
merged.append(item)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def max_reducer(current_value: Any, incoming_value: Any) -> Any:
|
||||||
|
"""Keep the larger of the current and incoming values."""
|
||||||
|
return (
|
||||||
|
incoming_value if current_value is None else max(current_value, incoming_value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_REDUCERS: Mapping[str, Reducer] = {
|
DEFAULT_REDUCERS: Mapping[str, Reducer] = {
|
||||||
"wf.std.replace": replace_reducer,
|
"wf.std.replace": replace_reducer,
|
||||||
"wf.std.append": append_reducer,
|
"wf.std.append": append_reducer,
|
||||||
"wf.std.merge_object": merge_object_reducer,
|
"wf.std.merge_object": merge_object_reducer,
|
||||||
|
"wf.std.set_union": set_union_reducer,
|
||||||
|
"wf.std.max": max_reducer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ def builtin_reducers() -> dict[str, ReducerSpec]:
|
|||||||
name="wf.std.merge_object",
|
name="wf.std.merge_object",
|
||||||
description="Shallow-merge object values at one exact state path.",
|
description="Shallow-merge object values at one exact state path.",
|
||||||
),
|
),
|
||||||
|
ReducerSpec(
|
||||||
|
name="wf.std.max",
|
||||||
|
description="Keep the larger of the current and incoming values.",
|
||||||
|
),
|
||||||
|
ReducerSpec(
|
||||||
|
name="wf.std.set_union",
|
||||||
|
description="Merge list values while preserving stable first-seen order.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return {spec.name: spec for spec in specs}
|
return {spec.name: spec for spec in specs}
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,15 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
|
|||||||
)
|
)
|
||||||
for spec in source.capabilities.node_specs.values()
|
for spec in source.capabilities.node_specs.values()
|
||||||
}
|
}
|
||||||
|
capabilities.update(
|
||||||
|
{
|
||||||
|
reducer.name.rsplit(".", maxsplit=1)[-1]: AvailableCapability(
|
||||||
|
name=reducer.name.rsplit(".", maxsplit=1)[-1],
|
||||||
|
kind="reducer",
|
||||||
|
)
|
||||||
|
for reducer in source.capabilities.reducers.values()
|
||||||
|
}
|
||||||
|
)
|
||||||
sources.append(
|
sources.append(
|
||||||
AvailableSource(
|
AvailableSource(
|
||||||
id=source.id,
|
id=source.id,
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ def test_create_workflow_artifact_from_plan_derives_boundary_schemas() -> None:
|
|||||||
assert artifact.created_from_catalog_version == "catalog-1"
|
assert artifact.created_from_catalog_version == "catalog-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_workflow_artifact_from_plan_adds_reducer_dependencies() -> None:
|
||||||
|
plan = _plan()
|
||||||
|
plan["state_schema"] = {
|
||||||
|
"fields": {"best_score": {"type": "integer", "reducer": "wf.std.max"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact = create_workflow_artifact_from_plan(
|
||||||
|
artifact_id="score",
|
||||||
|
version=1,
|
||||||
|
title="Score",
|
||||||
|
plan=plan,
|
||||||
|
outcomes=("done",),
|
||||||
|
)
|
||||||
|
|
||||||
|
reducer = artifact.required_capabilities["wf.std.max"]
|
||||||
|
assert reducer.logical_source == "wf.std"
|
||||||
|
assert reducer.capability_name == "max"
|
||||||
|
assert reducer.kind == "reducer"
|
||||||
|
|
||||||
|
|
||||||
def test_create_workflow_artifact_from_plan_accepts_wrapper_kind() -> None:
|
def test_create_workflow_artifact_from_plan_accepts_wrapper_kind() -> None:
|
||||||
artifact = create_workflow_artifact_from_plan(
|
artifact = create_workflow_artifact_from_plan(
|
||||||
artifact_id="normalize_status",
|
artifact_id="normalize_status",
|
||||||
|
|||||||
@@ -176,3 +176,28 @@ def test_validate_deployment_allows_changed_schema_when_policy_allows() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert diagnostics == []
|
assert diagnostics == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_deployment_accepts_reducer_capability() -> None:
|
||||||
|
reducer = RequiredCapability(
|
||||||
|
logical_source="wf.std",
|
||||||
|
capability_name="set_union",
|
||||||
|
kind="reducer",
|
||||||
|
)
|
||||||
|
diagnostics = validate_deployment_dependencies(
|
||||||
|
artifact=artifact_with(reducer),
|
||||||
|
deployment=deployment(bindings={"wf.std": "wf.std"}),
|
||||||
|
sources=[
|
||||||
|
AvailableSource(
|
||||||
|
id="wf.std",
|
||||||
|
capabilities={
|
||||||
|
"set_union": AvailableCapability(
|
||||||
|
name="set_union",
|
||||||
|
kind="reducer",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert diagnostics == []
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ def test_state_field_defaults_to_replace_reducer() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_state_reducer_fails_clearly() -> None:
|
def test_unknown_state_reducer_fails_clearly() -> None:
|
||||||
workflow = _workflow(fields={"person.tags": StateField(type="array", reducer="x.nope")})
|
workflow = _workflow(
|
||||||
|
fields={"person.tags": StateField(type="array", reducer="x.nope")}
|
||||||
|
)
|
||||||
state = {"person": {"tags": ["seed"]}}
|
state = {"person": {"tags": ["seed"]}}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -51,6 +53,28 @@ def test_unknown_state_reducer_fails_clearly() -> None:
|
|||||||
raise AssertionError("expected unknown reducer to fail")
|
raise AssertionError("expected unknown reducer to fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_union_reducer_preserves_first_seen_order() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
fields={"person.tags": StateField(type="array", reducer="wf.std.set_union")}
|
||||||
|
)
|
||||||
|
state = {"person": {"tags": ["alpha", "beta"]}}
|
||||||
|
|
||||||
|
write_state_value(workflow, state, "state.person.tags", ["beta", "gamma"])
|
||||||
|
|
||||||
|
assert state["person"]["tags"] == ["alpha", "beta", "gamma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_reducer_keeps_larger_value() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
fields={"best_score": StateField(type="integer", reducer="wf.std.max")}
|
||||||
|
)
|
||||||
|
state = {"best_score": 7}
|
||||||
|
|
||||||
|
write_state_value(workflow, state, "state.best_score", 9)
|
||||||
|
|
||||||
|
assert state["best_score"] == 9
|
||||||
|
|
||||||
|
|
||||||
def _workflow(*, fields: dict[str, StateField]) -> Workflow:
|
def _workflow(*, fields: dict[str, StateField]) -> Workflow:
|
||||||
return Workflow(
|
return Workflow(
|
||||||
name="nested_state_paths",
|
name="nested_state_paths",
|
||||||
|
|||||||
@@ -125,9 +125,7 @@ class PartialRates(SophisticatedRates, total=False):
|
|||||||
|
|
||||||
|
|
||||||
class Rates(BaseModel):
|
class Rates(BaseModel):
|
||||||
rates: Annotated[
|
rates: Annotated[PartialRates, state_field(reducer="wf.std.merge_object")] # or_!
|
||||||
PartialRates, state_field(reducer="wf.std.merge_object")
|
|
||||||
] # or_!
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentPools(BaseModel):
|
class CurrentPools(BaseModel):
|
||||||
|
|||||||
@@ -113,11 +113,13 @@ def test_service_lists_all_capability_sources_with_owned_capability_names() -> N
|
|||||||
assert "wf.std.runtime_error" in std_source["capabilities"]["node_specs"]
|
assert "wf.std.runtime_error" in std_source["capabilities"]["node_specs"]
|
||||||
assert std_source["capabilities"]["reducers"] == [
|
assert std_source["capabilities"]["reducers"] == [
|
||||||
"wf.std.append",
|
"wf.std.append",
|
||||||
|
"wf.std.max",
|
||||||
"wf.std.merge_object",
|
"wf.std.merge_object",
|
||||||
"wf.std.replace",
|
"wf.std.replace",
|
||||||
|
"wf.std.set_union",
|
||||||
]
|
]
|
||||||
assert std_source["capabilities"]["tools"] == []
|
assert std_source["capabilities"]["tools"] == []
|
||||||
assert std_source["reducer_count"] == 3
|
assert std_source["reducer_count"] == 5
|
||||||
|
|
||||||
mcp_source = sources_by_id["wf.mcp"]
|
mcp_source = sources_by_id["wf.mcp"]
|
||||||
assert mcp_source["capabilities"]["node_specs"] == ["wf.mcp.call_tool"]
|
assert mcp_source["capabilities"]["node_specs"] == ["wf.mcp.call_tool"]
|
||||||
@@ -159,7 +161,9 @@ def test_wf_std_source_contains_builtin_reducers() -> None:
|
|||||||
assert set(reducers) == {
|
assert set(reducers) == {
|
||||||
"wf.std.replace",
|
"wf.std.replace",
|
||||||
"wf.std.append",
|
"wf.std.append",
|
||||||
|
"wf.std.max",
|
||||||
"wf.std.merge_object",
|
"wf.std.merge_object",
|
||||||
|
"wf.std.set_union",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user