foreach container arg to get the path treatment too!
This commit is contained in:
@@ -277,7 +277,7 @@ Runs a child body over items.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"foreach": {
|
"foreach": {
|
||||||
"over": "state.items",
|
"over": {"root": "state", "parts": ["items"]},
|
||||||
"as": "item",
|
"as": "item",
|
||||||
"mode": "serial",
|
"mode": "serial",
|
||||||
"on_item_error": "fail"
|
"on_item_error": "fail"
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ def build_demo_workflow() -> Workflow:
|
|||||||
{
|
{
|
||||||
"id": "summarize_each",
|
"id": "summarize_each",
|
||||||
"type": "foreach",
|
"type": "foreach",
|
||||||
"over": "state.documents",
|
"over": {"root": "state", "parts": ["documents"]},
|
||||||
"as": "document",
|
"as": "document",
|
||||||
"mode": "serial",
|
"mode": "serial",
|
||||||
"on_item_error": "fail",
|
"on_item_error": "fail",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||||||
|
|
||||||
from wf_core.models.conditions import Condition
|
from wf_core.models.conditions import Condition
|
||||||
from wf_core.models.steps import InputBinding, OutputBinding
|
from wf_core.models.steps import InputBinding, OutputBinding
|
||||||
|
from wf_core.paths import GraphSourcePath
|
||||||
|
|
||||||
JsonObject = dict[str, Any]
|
JsonObject = dict[str, Any]
|
||||||
STEP_KIND_KEYS = frozenset(
|
STEP_KIND_KEYS = frozenset(
|
||||||
@@ -94,7 +95,7 @@ class DraftForeachPayload(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
over: str
|
over: GraphSourcePath
|
||||||
as_: str = Field(alias="as")
|
as_: str = Field(alias="as")
|
||||||
mode: Literal["serial", "parallel"] = "serial"
|
mode: Literal["serial", "parallel"] = "serial"
|
||||||
on_item_error: Literal["fail", "collect", "skip"] = "fail"
|
on_item_error: Literal["fail", "collect", "skip"] = "fail"
|
||||||
|
|||||||
@@ -397,8 +397,6 @@ class WorkflowBuilder:
|
|||||||
mode: Literal["serial", "parallel"] = "serial",
|
mode: Literal["serial", "parallel"] = "serial",
|
||||||
on_item_error: Literal["fail", "collect", "skip"] = "fail",
|
on_item_error: Literal["fail", "collect", "skip"] = "fail",
|
||||||
) -> ForeachNode:
|
) -> ForeachNode:
|
||||||
# Core foreach still stores `over` as a string. Keep this compatibility
|
|
||||||
# path isolated until ForeachNode grows a typed GraphSourcePath field.
|
|
||||||
node = ForeachNode.model_validate(
|
node = ForeachNode.model_validate(
|
||||||
{
|
{
|
||||||
"id": id or self._next_step_id(f"foreach_{slug_id(as_)}"),
|
"id": id or self._next_step_id(f"foreach_{slug_id(as_)}"),
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class ForeachNode(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
type: Literal["foreach"]
|
type: Literal["foreach"]
|
||||||
over: str
|
over: GraphSourcePath
|
||||||
as_: str = Field(alias="as")
|
as_: str = Field(alias="as")
|
||||||
mode: Literal["serial", "parallel"] = "serial"
|
mode: Literal["serial", "parallel"] = "serial"
|
||||||
on_item_error: Literal["fail", "collect", "skip"] = "fail"
|
on_item_error: Literal["fail", "collect", "skip"] = "fail"
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ def step_foreach(
|
|||||||
progress = progress_map.setdefault(step.id, {"index": 0})
|
progress = progress_map.setdefault(step.id, {"index": 0})
|
||||||
|
|
||||||
iterable = safe_resolve_path(
|
iterable = safe_resolve_path(
|
||||||
step.over,
|
str(step.over),
|
||||||
state=run.state,
|
state=run.state,
|
||||||
workflow_input=run.workflow_input,
|
workflow_input=run.workflow_input,
|
||||||
context=frame_context_values(frame),
|
context=frame_context_values(frame),
|
||||||
)
|
)
|
||||||
if not isinstance(iterable, list):
|
if not isinstance(iterable, list):
|
||||||
raise WorkflowExecutionError(
|
raise WorkflowExecutionError(
|
||||||
f"foreach source {step.over!r} must resolve to a list"
|
f"foreach source {str(step.over)!r} must resolve to a list"
|
||||||
)
|
)
|
||||||
|
|
||||||
loop_index = progress["index"]
|
loop_index = progress["index"]
|
||||||
|
|||||||
@@ -162,6 +162,35 @@ def test_workflow_draft_accepts_choose_step() -> None:
|
|||||||
assert isinstance(draft.steps["choose_next"], DraftChooseStep)
|
assert isinstance(draft.steps["choose_next"], DraftChooseStep)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_draft_foreach_over_dumps_structural_path() -> None:
|
||||||
|
draft = WorkflowDraft.model_validate(
|
||||||
|
{
|
||||||
|
**_keyed_echo_draft(),
|
||||||
|
"start": "each_item",
|
||||||
|
"steps": {
|
||||||
|
**_keyed_echo_draft()["steps"],
|
||||||
|
"each_item": {
|
||||||
|
"foreach": {
|
||||||
|
"over": "state.items",
|
||||||
|
"as": "item",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"routes": {
|
||||||
|
"each_item": {"loop": "echo", "done": "__end__"},
|
||||||
|
"echo": {"ok": "__end__"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = draft.model_dump(mode="json")
|
||||||
|
|
||||||
|
assert dumped["steps"]["each_item"]["foreach"]["over"] == {
|
||||||
|
"root": "state",
|
||||||
|
"parts": ["items"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_draft_accepts_match_step() -> None:
|
def test_workflow_draft_accepts_match_step() -> None:
|
||||||
draft = WorkflowDraft.model_validate(
|
draft = WorkflowDraft.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pydantic import ValidationError
|
|||||||
from wf_core.models.steps import (
|
from wf_core.models.steps import (
|
||||||
InputPathBinding,
|
InputPathBinding,
|
||||||
InputValueBinding,
|
InputValueBinding,
|
||||||
|
ForeachNode,
|
||||||
InterruptNode,
|
InterruptNode,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
)
|
)
|
||||||
@@ -276,3 +277,20 @@ def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
|
|||||||
"request_map": {"input.other": "other"},
|
"request_map": {"input.other": "other"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreach_node_serializes_over_path_as_structural_json():
|
||||||
|
node = ForeachNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "each_item",
|
||||||
|
"type": "foreach",
|
||||||
|
"over": "state.items",
|
||||||
|
"as": "item",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert node.over == GraphSourcePath.state("items")
|
||||||
|
assert node.model_dump(mode="json")["over"] == {
|
||||||
|
"root": "state",
|
||||||
|
"parts": ["items"],
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user