misc changes: returns None, root as ., add
This commit is contained in:
@@ -219,6 +219,20 @@ They should not receive node ids, frame ids, loop indexes, timestamps, or other
|
||||
runtime context. If behavior depends on workflow context, that is business logic
|
||||
and belongs in nodes or graph structure.
|
||||
|
||||
Reducers are meant to remove write boilerplate, not absorb domain decisions.
|
||||
If a node needs to decide whether to increment two counters, reset both
|
||||
counters, reset one counter, or preserve one counter, that decision belongs in
|
||||
the node or in an explicit graph branch. A reducer should only describe how a
|
||||
declared state path combines the node's write with the current state value.
|
||||
|
||||
This distinction matters for fragmented outputs. It is valid for a node to emit
|
||||
a delta if the node is explicitly a delta-producing node, such as
|
||||
`countdown_delta = -1` written through `wf.std.add`. It is a smell if a node
|
||||
returns artificial fragments only to trigger reducer behavior while hiding the
|
||||
actual domain operation. In that case prefer a clearer node output, an explicit
|
||||
shaping node, or a default map on the node spec that is still validated at the
|
||||
use site.
|
||||
|
||||
Examples a future reducer library could support:
|
||||
|
||||
- `max`
|
||||
|
||||
@@ -51,6 +51,7 @@ from .ops import (
|
||||
)
|
||||
from .nodes import (
|
||||
AsyncRegistryHandler,
|
||||
NoOutput,
|
||||
NodeReturn,
|
||||
NodeSpec,
|
||||
Nothing,
|
||||
@@ -82,6 +83,7 @@ __all__ = [
|
||||
"ReducerCatalog",
|
||||
"RenameFieldsInput",
|
||||
"RuntimeErrorInput",
|
||||
"NoOutput",
|
||||
"NodeReturn",
|
||||
"NodeSpec",
|
||||
"Nothing",
|
||||
|
||||
@@ -13,7 +13,7 @@ from .callables import (
|
||||
from .inference import accepts_context, infer_models, is_basemodel_subclass
|
||||
from .decorator import node
|
||||
from .registry import build_async_registry, build_registry
|
||||
from .result import NodeReturn, Nothing, outcome
|
||||
from .result import NoOutput, NodeReturn, Nothing, outcome
|
||||
from .schema import schema_ref_for
|
||||
from .spec import NodeSpec
|
||||
|
||||
@@ -25,6 +25,7 @@ __all__ = [
|
||||
"ContextNodeCallable",
|
||||
"InputT",
|
||||
"NodeCallable",
|
||||
"NoOutput",
|
||||
"NodeReturn",
|
||||
"NodeSpec",
|
||||
"Nothing",
|
||||
|
||||
@@ -22,7 +22,7 @@ class ContextNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||
payload: InputT_contra,
|
||||
/,
|
||||
ctx: RuntimeContext,
|
||||
) -> NodeReturn[OutputT_co] | OutputT_co: ...
|
||||
) -> NodeReturn[OutputT_co] | OutputT_co | None: ...
|
||||
|
||||
|
||||
class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||
@@ -30,7 +30,7 @@ class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||
self,
|
||||
payload: InputT_contra,
|
||||
/,
|
||||
) -> NodeReturn[OutputT_co] | OutputT_co: ...
|
||||
) -> NodeReturn[OutputT_co] | OutputT_co | None: ...
|
||||
|
||||
|
||||
NodeCallable = ContextNodeCallable[InputT, OutputT] | PlainNodeCallable[InputT, OutputT]
|
||||
@@ -42,7 +42,7 @@ class AsyncContextNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||
payload: InputT_contra,
|
||||
/,
|
||||
ctx: RuntimeContext,
|
||||
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co]: ...
|
||||
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co | None]: ...
|
||||
|
||||
|
||||
class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||
@@ -50,7 +50,7 @@ class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||
self,
|
||||
payload: InputT_contra,
|
||||
/,
|
||||
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co]: ...
|
||||
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co | None]: ...
|
||||
|
||||
|
||||
AsyncNodeCallable = (
|
||||
|
||||
@@ -6,7 +6,12 @@ from typing import Any, cast, overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .callables import AsyncNodeCallable, InputT, NodeCallable, OutputT
|
||||
from .callables import (
|
||||
AsyncNodeCallable,
|
||||
InputT,
|
||||
NodeCallable,
|
||||
OutputT,
|
||||
)
|
||||
from .inference import accepts_context, infer_models
|
||||
from .spec import NodeSpec
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
|
||||
from wf_core import RuntimeContext
|
||||
|
||||
from .result import NodeReturn
|
||||
from .result import NodeReturn, Nothing
|
||||
|
||||
|
||||
def is_basemodel_subclass(value: object) -> bool:
|
||||
@@ -47,10 +47,15 @@ def infer_models(fn: Callable[..., object]) -> tuple[type[BaseModel], type[BaseM
|
||||
return_type = hints.get("return")
|
||||
if return_type is None:
|
||||
raise TypeError("node function must declare a return annotation")
|
||||
if return_type is type(None):
|
||||
return cast(type[BaseModel], input_model), Nothing
|
||||
|
||||
if is_basemodel_subclass(return_type):
|
||||
return cast(type[BaseModel], input_model), cast(type[BaseModel], return_type)
|
||||
|
||||
if return_type is NodeReturn:
|
||||
return cast(type[BaseModel], input_model), Nothing
|
||||
|
||||
origin = get_origin(return_type)
|
||||
if origin is NodeReturn:
|
||||
args = get_args(return_type)
|
||||
|
||||
@@ -20,6 +20,10 @@ class Nothing(BaseModel):
|
||||
"""Empty output model for nodes that only choose an outcome."""
|
||||
|
||||
|
||||
NoOutput = NodeReturn[Nothing]
|
||||
"""Type alias for outcome-only nodes that return no output payload."""
|
||||
|
||||
|
||||
@overload
|
||||
def outcome(name: str) -> NodeReturn[Nothing]: ...
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from .callables import (
|
||||
PlainNodeCallable,
|
||||
SyncRegistryHandler,
|
||||
)
|
||||
from .result import NodeReturn
|
||||
from .result import NodeReturn, Nothing
|
||||
from .schema import schema_ref_for
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ def _coerce_registry_result(
|
||||
default_outcome: str,
|
||||
raw: NodeReturn[BaseModel] | BaseModel,
|
||||
) -> dict[str, Any]:
|
||||
if raw is None and output_model is Nothing:
|
||||
return {"outcome": default_outcome, "output": {}}
|
||||
if isinstance(raw, NodeReturn):
|
||||
if not isinstance(raw.output, output_model):
|
||||
raise TypeError(
|
||||
@@ -69,7 +71,12 @@ class NodeSpec(Generic[InputT, OutputT]):
|
||||
self,
|
||||
payload: InputT,
|
||||
ctx: RuntimeContext | None = None,
|
||||
) -> NodeReturn[OutputT] | OutputT | Awaitable[NodeReturn[OutputT] | OutputT]:
|
||||
) -> (
|
||||
NodeReturn[OutputT]
|
||||
| OutputT
|
||||
| None
|
||||
| Awaitable[NodeReturn[OutputT] | OutputT | None]
|
||||
):
|
||||
if self.accepts_context:
|
||||
if ctx is None:
|
||||
raise TypeError(f"node {self.name!r} requires RuntimeContext")
|
||||
|
||||
@@ -10,6 +10,8 @@ class LocalPathError(ValueError):
|
||||
|
||||
def split_local_path(path: str) -> list[str]:
|
||||
"""Split one dotted node-local path, rejecting empty segments."""
|
||||
if path == ".":
|
||||
return []
|
||||
parts = path.split(".")
|
||||
if not path or any(not part for part in parts):
|
||||
raise LocalPathError(f"invalid local path {path!r}")
|
||||
@@ -18,6 +20,8 @@ def split_local_path(path: str) -> list[str]:
|
||||
|
||||
def get_local_value(payload: Mapping[str, Any], path: str) -> Any:
|
||||
"""Resolve one node-local path from a nested mapping payload."""
|
||||
if path == ".":
|
||||
return dict(payload)
|
||||
current: Any = payload
|
||||
for part in split_local_path(path):
|
||||
if not isinstance(current, Mapping) or part not in current:
|
||||
@@ -29,6 +33,12 @@ def get_local_value(payload: Mapping[str, Any], path: str) -> Any:
|
||||
def set_local_value(payload: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""Write one value into a nested node-local mapping payload."""
|
||||
parts = split_local_path(path)
|
||||
if not parts:
|
||||
if not isinstance(value, Mapping):
|
||||
raise LocalPathError("root local path requires a mapping value")
|
||||
payload.clear()
|
||||
payload.update(value)
|
||||
return
|
||||
current = payload
|
||||
for part in parts[:-1]:
|
||||
next_value = current.setdefault(part, {})
|
||||
|
||||
@@ -39,11 +39,10 @@ def validate_node_use(
|
||||
input_root_fields = set(workflow.input_schema.properties)
|
||||
|
||||
for source_path, destination_field in node.in_map.items():
|
||||
try:
|
||||
destination_root = split_local_path(destination_field)[0]
|
||||
except LocalPathError:
|
||||
destination_root = ""
|
||||
if destination_root not in input_fields:
|
||||
destination_root = _local_root(destination_field)
|
||||
if destination_root is None or (
|
||||
destination_root != "." and destination_root not in input_fields
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||
f"nodes[{index}].in_map[{source_path!r}]",
|
||||
@@ -66,11 +65,10 @@ def validate_node_use(
|
||||
)
|
||||
|
||||
for source_field, destination_path in node.out_map.items():
|
||||
try:
|
||||
source_root = split_local_path(source_field)[0]
|
||||
except LocalPathError:
|
||||
source_root = ""
|
||||
if source_root not in output_fields:
|
||||
source_root = _local_root(source_field)
|
||||
if source_root is None or (
|
||||
source_root != "." and source_root not in output_fields
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
@@ -90,6 +88,14 @@ def validate_node_use(
|
||||
)
|
||||
|
||||
|
||||
def _local_root(path: str) -> str | None:
|
||||
try:
|
||||
parts = split_local_path(path)
|
||||
except LocalPathError:
|
||||
return None
|
||||
return "." if not parts else parts[0]
|
||||
|
||||
|
||||
def validate_condition_node(
|
||||
node: ConditionNode,
|
||||
index: int,
|
||||
|
||||
@@ -59,6 +59,24 @@ def test_builder_preserves_explicit_nested_node_local_maps() -> None:
|
||||
assert step.out_map == {"payload.text": "state.text"}
|
||||
|
||||
|
||||
def test_builder_preserves_explicit_root_node_local_maps() -> None:
|
||||
builder = WorkflowBuilder(
|
||||
name="root_local_maps",
|
||||
input_schema=AutoBindInput,
|
||||
state_schema=AutoBindState,
|
||||
output_schema=AutoBindOutput,
|
||||
)
|
||||
|
||||
step = builder.use(
|
||||
auto_bind_node,
|
||||
in_map={"state.text": "."},
|
||||
out_map={".": "state.text"},
|
||||
)
|
||||
|
||||
assert step.in_map == {"state.text": "."}
|
||||
assert step.out_map == {".": "state.text"}
|
||||
|
||||
|
||||
def test_builder_can_auto_id_node_uses_from_spec_name() -> None:
|
||||
builder = WorkflowBuilder(
|
||||
name="auto_id_demo",
|
||||
|
||||
@@ -2,7 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wf_authoring import NodeCatalog, NodeReturn, Nothing, build_registry, node, outcome
|
||||
from wf_authoring import (
|
||||
NoOutput,
|
||||
NodeCatalog,
|
||||
NodeReturn,
|
||||
Nothing,
|
||||
build_registry,
|
||||
node,
|
||||
outcome,
|
||||
)
|
||||
from wf_core import RuntimeContext
|
||||
|
||||
|
||||
@@ -46,6 +54,10 @@ class DocumentedOutput(BaseModel):
|
||||
echoed: str = Field(description="Echoed message")
|
||||
|
||||
|
||||
class NoOutputInput(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
@node()
|
||||
def inferred_echo(
|
||||
payload: InferredEchoInput,
|
||||
@@ -137,6 +149,53 @@ def test_outcome_can_wrap_explicit_output() -> None:
|
||||
assert result.output is output
|
||||
|
||||
|
||||
def test_node_return_annotation_none_uses_nothing_output() -> None:
|
||||
@node
|
||||
def no_output(_: NoOutputInput) -> None:
|
||||
return None
|
||||
|
||||
registry = build_registry(no_output)
|
||||
result = registry["no_output"](
|
||||
{"value": "hello"},
|
||||
RuntimeContext(current_node_id="x"),
|
||||
)
|
||||
|
||||
assert no_output.output_model is Nothing
|
||||
assert result == {"outcome": "ok", "output": {}}
|
||||
|
||||
|
||||
def test_bare_nodereturn_annotation_defaults_to_nothing_output() -> None:
|
||||
@node(outcomes=("skip",))
|
||||
def no_output_with_outcome(
|
||||
_: NoOutputInput,
|
||||
) -> NodeReturn: # pyright: ignore[reportMissingTypeArgument]
|
||||
return outcome("skip")
|
||||
|
||||
registry = build_registry(no_output_with_outcome)
|
||||
result = registry["no_output_with_outcome"](
|
||||
{"value": "hello"},
|
||||
RuntimeContext(current_node_id="x"),
|
||||
)
|
||||
|
||||
assert no_output_with_outcome.output_model is Nothing
|
||||
assert result == {"outcome": "skip", "output": {}}
|
||||
|
||||
|
||||
def test_no_output_alias_annotates_outcome_only_nodes() -> None:
|
||||
@node(outcomes=("skip",))
|
||||
def no_output_alias(_: NoOutputInput) -> NoOutput:
|
||||
return outcome("skip")
|
||||
|
||||
registry = build_registry(no_output_alias)
|
||||
result = registry["no_output_alias"](
|
||||
{"value": "hello"},
|
||||
RuntimeContext(current_node_id="x"),
|
||||
)
|
||||
|
||||
assert no_output_alias.output_model is Nothing
|
||||
assert result == {"outcome": "skip", "output": {}}
|
||||
|
||||
|
||||
def test_node_decorator_infers_models_from_annotations() -> None:
|
||||
assert inferred_echo.input_model is InferredEchoInput
|
||||
assert inferred_echo.output_model is InferredEchoOutput
|
||||
|
||||
@@ -122,7 +122,7 @@ def test_state_field_accepts_configured_reducer_reference() -> None:
|
||||
),
|
||||
] = 0
|
||||
|
||||
field = State.model_fields["total"].metadata[0]
|
||||
field = State.model_fields["total"].metadata[0] # pylint: disable=unsubscriptable-object
|
||||
|
||||
assert field.reducer.name == "wf.std.modulo_add"
|
||||
assert field.reducer.config["modulus"] == 10
|
||||
|
||||
@@ -60,6 +60,59 @@ def test_missing_nested_node_output_path_fails() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
|
||||
workflow = Workflow(
|
||||
name="root_mapping",
|
||||
input_schema=SchemaRef.model_validate(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"rates": {"type": "object"}},
|
||||
}
|
||||
),
|
||||
state_schema=StateSchema(fields={"rates": StateField(type="object")}),
|
||||
output_schema=SchemaRef(type="object", properties={}),
|
||||
node_defs=[
|
||||
NodeDef(
|
||||
name="force_rates",
|
||||
input_schema=SchemaRef(type="object", properties={}),
|
||||
output_schema=SchemaRef(
|
||||
type="object",
|
||||
properties={
|
||||
"r_1": {"type": "number"},
|
||||
"r_10": {"type": "number"},
|
||||
},
|
||||
),
|
||||
outcomes=["ok"],
|
||||
)
|
||||
],
|
||||
start="force",
|
||||
nodes=[
|
||||
NodeUse(
|
||||
id="force",
|
||||
type="node",
|
||||
node="force_rates",
|
||||
in_map={"input.rates": "."},
|
||||
out_map={".": "state.rates"},
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})],
|
||||
)
|
||||
|
||||
run = execute_workflow(
|
||||
workflow,
|
||||
{"rates": {"r_1": 0.9, "r_10": 0.1}},
|
||||
{
|
||||
"force_rates": lambda payload, _ctx: {
|
||||
"outcome": "ok",
|
||||
"output": {"r_1": 0.0, "r_10": payload["r_10"]},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert run.trace[0].resolved_input == {"r_1": 0.9, "r_10": 0.1}
|
||||
assert run.state["rates"] == {"r_1": 0.0, "r_10": 0.1}
|
||||
|
||||
|
||||
def _nested_mapping_workflow() -> Workflow:
|
||||
return Workflow(
|
||||
name="nested_mapping",
|
||||
|
||||
+25
-19
@@ -1,6 +1,7 @@
|
||||
import random
|
||||
from typing import Final
|
||||
|
||||
|
||||
from tests.rewrite.models import (
|
||||
ContextInput,
|
||||
Countdown,
|
||||
@@ -15,7 +16,7 @@ from tests.rewrite.models import (
|
||||
how_do_i_explain_this,
|
||||
)
|
||||
from wf_authoring import NodeReturn, node
|
||||
from wf_authoring.nodes.result import Nothing, outcome
|
||||
from wf_authoring.nodes.result import NoOutput, Nothing, outcome
|
||||
from wf_core.tokens import END
|
||||
|
||||
# to the functions
|
||||
@@ -24,9 +25,9 @@ from wf_core.tokens import END
|
||||
|
||||
@node
|
||||
def init(
|
||||
inp: Input,
|
||||
_inp: Input,
|
||||
# ) -> NodeReturn[Input | Nothing]: # JUST doesnt work if the types are above
|
||||
) -> Input:
|
||||
) -> None:
|
||||
# why cant use basemodel? should we convert typeddicts to basemodels? Why cant use
|
||||
"""i hope that this is done by default, because langgraph DOESNT. why.
|
||||
|
||||
@@ -41,11 +42,13 @@ def init(
|
||||
# )
|
||||
# if inp.pity_120_available and inp.context["type"] == "normal":
|
||||
# "i doesnt care"
|
||||
return inp
|
||||
# return Nothing()
|
||||
# return None
|
||||
# return inp
|
||||
|
||||
|
||||
@node(outcomes=("0", "65"))
|
||||
def rate_booster(c: Counters) -> NodeReturn[Nothing]:
|
||||
def rate_booster(c: Counters) -> NoOutput:
|
||||
"""This was an edge.
|
||||
|
||||
Should I implement r65 here too... i think not.
|
||||
@@ -80,7 +83,7 @@ class CountersContextOutputInputAhhModelType(
|
||||
|
||||
|
||||
@node(outcomes=("240", "80", "10", "1"))
|
||||
def pre_roll_router(c: CountersContextOutputInputAhhModelType) -> NodeReturn[Nothing]:
|
||||
def pre_roll_router(c: CountersContextOutputInputAhhModelType) -> NoOutput:
|
||||
"""another edge.
|
||||
|
||||
the conditional router calculates which to reset to 0 (guarantee the rest)
|
||||
@@ -152,12 +155,13 @@ class RateChange:
|
||||
else:
|
||||
r240 = 0
|
||||
r80 = br["r_80"] * (1 + rpn)
|
||||
|
||||
r10 = br["r_10"] # use initial rates because i dont know how this works
|
||||
r1 = 1 - r240 - r80 - r10
|
||||
return Rates.model_validate(
|
||||
{
|
||||
"rates": {
|
||||
"r_1": 1 - r240 - r80 - br["r_10"],
|
||||
"r_10": br["r_10"],
|
||||
"r_1": r1,
|
||||
"r_10": r10,
|
||||
"r_80": r80,
|
||||
"r_240": r240,
|
||||
}
|
||||
@@ -173,24 +177,27 @@ class RateChange:
|
||||
class CounterUp:
|
||||
@node(name="counter 6* reset")
|
||||
@staticmethod
|
||||
def c80(c: Counters) -> Counters:
|
||||
def c80(_: Nothing) -> Counters:
|
||||
return Counters.model_validate(
|
||||
{
|
||||
"counter": {
|
||||
"c_80": 0,
|
||||
"c_10": 0,
|
||||
},
|
||||
"simple_counter": c.simple_counter,
|
||||
"simple_counter": 0,
|
||||
# this is influenced by the add reducer.
|
||||
# its top level. it doesnt reset. its a miracle. i hate this.
|
||||
}
|
||||
)
|
||||
|
||||
@node(name="counter 5* reset")
|
||||
@staticmethod
|
||||
def c10(c: Counters) -> Counters:
|
||||
c80 = c.counter["c_80"]
|
||||
return Counters.model_validate(
|
||||
{
|
||||
"counter": {"c_10": 0, "c_80": c.counter["c_80"]}, # merge with or_!
|
||||
"simple_counter": c.simple_counter,
|
||||
"counter": {"c_10": 0, "c_80": c80}, # merge with or_!
|
||||
"simple_counter": 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -198,9 +205,10 @@ class CounterUp:
|
||||
@staticmethod
|
||||
def c1(state: Counters) -> Counters:
|
||||
c = state.counter
|
||||
c10, c80 = c["c_10"], c["c_80"]
|
||||
return Counters(
|
||||
simple_counter=state.simple_counter + 1,
|
||||
counter={"c_10": (c["c_10"] + 1) % 10, "c_80": (c["c_80"] + 1) % 80},
|
||||
simple_counter=1,
|
||||
counter={"c_10": (c10 + 1) % 10, "c_80": (c80 + 1) % 80},
|
||||
)
|
||||
|
||||
|
||||
@@ -241,10 +249,8 @@ def roll(state: CurrentPools) -> ThisStorage:
|
||||
@node(outcomes=("240", "80", "10", "1")) # missed this! good job.
|
||||
def post_roll_router(
|
||||
state: CurrentRoll,
|
||||
) -> NodeReturn[
|
||||
Nothing
|
||||
]: # Literal["240", "80", "10", "1"] maybe you need to encode this in node returns. Literal of strings.
|
||||
return NodeReturn(state.this["category"], Nothing())
|
||||
) -> NoOutput: # Literal["240", "80", "10", "1"] maybe you need to encode this in node returns. Literal of strings.
|
||||
return outcome(state.this["category"])
|
||||
|
||||
|
||||
@node(name="main")
|
||||
|
||||
+19
-4
@@ -3,6 +3,7 @@ from typing import Annotated, Literal, TypedDict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wf_authoring.schemas import state_field
|
||||
from wf_core.models.reducers import ReducerRef
|
||||
|
||||
|
||||
class SophisticatedRates(TypedDict):
|
||||
@@ -17,8 +18,20 @@ class SophisticatedRates(TypedDict):
|
||||
|
||||
|
||||
class SophisticatedCounter(TypedDict):
|
||||
c_10: Annotated[int, state_field(reducer="wf.std.add")] # how do i convey "add" sublevel? can we have plugins for this? should we cover this; since langgraph doesnt.
|
||||
c_80: Annotated[int, state_field(reducer="wf.std.add")]
|
||||
c_10: Annotated[
|
||||
int,
|
||||
state_field(
|
||||
reducer=ReducerRef(name="wf.std.modulo_add", config={"modulus": 10})
|
||||
),
|
||||
] # add!
|
||||
c_80: Annotated[
|
||||
int,
|
||||
state_field(
|
||||
reducer=ReducerRef(name="wf.std.modulo_add", config={"modulus": 80})
|
||||
),
|
||||
]
|
||||
# all that reducerref and then nothing touches them. only way you do rn is
|
||||
# putting in/out maps on graph.use, which is cool ig. i just am not using them.
|
||||
|
||||
|
||||
class Counters(BaseModel):
|
||||
@@ -27,7 +40,7 @@ class Counters(BaseModel):
|
||||
default_factory=lambda: SophisticatedCounter(c_10=0, c_80=0)
|
||||
) # or, that is because of langgraph limitation,
|
||||
# id prefer counter.update with dict.update override (Overwrite, not like that ever worked)
|
||||
simple_counter: int # add
|
||||
simple_counter: Annotated[int, state_field(reducer="wf.std.add")] # add
|
||||
|
||||
|
||||
class Countdown(BaseModel):
|
||||
@@ -120,7 +133,7 @@ class CurrentRoll(BaseModel):
|
||||
this: Entity
|
||||
|
||||
|
||||
class PartialRates(SophisticatedRates, total=False):
|
||||
class PartialRates(SophisticatedRates, TypedDict, total=False):
|
||||
pass
|
||||
|
||||
|
||||
@@ -146,6 +159,8 @@ class State(
|
||||
"this forces basemodel, i used typeddict"
|
||||
|
||||
|
||||
# this feels fragmented. it is fragmented
|
||||
|
||||
# countdown: int # input carries here, should input have a bound like all(attr(input) in attr(state))? how tf do i even try to type that
|
||||
# simple_counter: int # how to signal that ts adds up? we have that.
|
||||
# counter: SophisticatedCounter
|
||||
|
||||
@@ -6,7 +6,6 @@ Constraints:
|
||||
"""
|
||||
|
||||
from itertools import islice
|
||||
import json
|
||||
from pprint import pprint
|
||||
from typing import Any
|
||||
|
||||
@@ -64,19 +63,31 @@ def test():
|
||||
d = gacha.execute(
|
||||
build_input(context)(
|
||||
20, # lets be optimistic
|
||||
rolled_previously=240 - 135,
|
||||
until_5=5,
|
||||
until_6=73,
|
||||
rolled_previously=240 - 135, # 15
|
||||
until_5=5, # 5
|
||||
until_6=73, # 7
|
||||
good_stuff=False, # lets pretend
|
||||
).model_dump()
|
||||
)
|
||||
assert d.status == RunStatus.COMPLETED, "oops"
|
||||
state = State.model_validate(d.state)
|
||||
pprint(state.storage)
|
||||
pprint(
|
||||
[
|
||||
t
|
||||
for t in d.trace
|
||||
if t.node_id
|
||||
in (
|
||||
"counter_up",
|
||||
"tick",
|
||||
)
|
||||
]
|
||||
)
|
||||
pprint(d.state)
|
||||
assert any(
|
||||
i["name"] in context["pool"]["n_240"]
|
||||
for i in islice(state.storage, 120 - (240 - 135))
|
||||
), "pity logic failed"
|
||||
pprint(state.storage)
|
||||
# pprint(gacha.compile().edges)
|
||||
# pprint(gacha.compile().nodes)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tests.authoring.test_reducers import modulo_add
|
||||
from tests.rewrite.actions import (
|
||||
CounterUp,
|
||||
RateChange,
|
||||
@@ -12,6 +13,7 @@ from tests.rewrite.actions import (
|
||||
from tests.rewrite.models import Input, State, Storage
|
||||
from wf_authoring.builder import WorkflowBuilder
|
||||
from wf_authoring.dsl.conditions import expr, state
|
||||
from wf_authoring.reducers.catalog import ReducerCatalog
|
||||
from wf_core.tokens import END
|
||||
|
||||
# alr so we start with workflowbuilder.
|
||||
@@ -21,6 +23,7 @@ gacha = WorkflowBuilder(
|
||||
input_schema=Input,
|
||||
output_schema=Storage, # could be State, since the OG doesnt care, ill probably dump out the list.
|
||||
state_schema=State,
|
||||
reducers=ReducerCatalog.from_reducers(modulo_add),
|
||||
)
|
||||
"example workflow"
|
||||
|
||||
@@ -53,7 +56,7 @@ rate_route = gacha.route(
|
||||
True: rate_up,
|
||||
False: rate_same,
|
||||
},
|
||||
id = "rate_booster"
|
||||
id="rate_booster",
|
||||
)
|
||||
|
||||
gacha.use(pre_roll_router, id="router")
|
||||
|
||||
@@ -111,15 +111,16 @@ def test_service_lists_all_capability_sources_with_owned_capability_names() -> N
|
||||
|
||||
std_source = sources_by_id["wf.std"]
|
||||
assert "wf.std.runtime_error" in std_source["capabilities"]["node_specs"]
|
||||
assert std_source["capabilities"]["reducers"] == [
|
||||
assert set(std_source["capabilities"]["reducers"]) == {
|
||||
"wf.std.append",
|
||||
"wf.std.max",
|
||||
"wf.std.merge_object",
|
||||
"wf.std.replace",
|
||||
"wf.std.set_union",
|
||||
]
|
||||
"wf.std.add",
|
||||
}
|
||||
assert std_source["capabilities"]["tools"] == []
|
||||
assert std_source["reducer_count"] == 5
|
||||
assert std_source["reducer_count"] == 6
|
||||
|
||||
mcp_source = sources_by_id["wf.mcp"]
|
||||
assert mcp_source["capabilities"]["node_specs"] == ["wf.mcp.call_tool"]
|
||||
@@ -164,6 +165,7 @@ def test_wf_std_source_contains_builtin_reducers() -> None:
|
||||
"wf.std.max",
|
||||
"wf.std.merge_object",
|
||||
"wf.std.set_union",
|
||||
"wf.std.add",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user