feat: use canonical TOML path strings

This commit is contained in:
lda
2026-06-27 15:24:34 +07:00 Verified
parent 4b03f54a1d
commit 5cfe513efc
29 changed files with 431 additions and 421 deletions
+12 -12
View File
@@ -63,18 +63,18 @@ Typical entry points:
```json ```json
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["text"] }, "target": "text",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["echoed"] } "target": "state.echoed"
} }
] ]
} }
``` ```
+25 -25
View File
@@ -43,18 +43,18 @@ Examples:
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["user", "name"] }, "target": "user.name",
"path": { "root": "state", "parts": ["person", "name"] } "path": "state.person.name"
}, },
{ {
"target": { "root": "local", "parts": ["mode"] }, "target": "mode",
"value": "fast" "value": "fast"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["job", "wage"] }, "source": "job.wage",
"target": { "root": "state", "parts": ["job", "wage"] } "target": "state.job.wage"
} }
] ]
} }
@@ -66,14 +66,14 @@ Whole-object mapping remains valid:
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["user"] }, "target": "user",
"path": { "root": "state", "parts": ["person"] } "path": "state.person"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["user"] }, "source": "user",
"target": { "root": "state", "parts": ["person"] } "target": "state.person"
} }
] ]
} }
@@ -85,14 +85,14 @@ Whole-payload mapping uses the local root path `"."`:
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": [] }, "target": ".",
"path": { "root": "state", "parts": ["rates"] } "path": "state.rates"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": [] }, "source": ".",
"target": { "root": "state", "parts": ["rates"] } "target": "state.rates"
} }
] ]
} }
@@ -122,12 +122,12 @@ Valid:
```json ```json
[ [
{ {
"target": { "root": "local", "parts": ["user", "name"] }, "target": "user.name",
"path": { "root": "state", "parts": ["person", "name"] } "path": "state.person.name"
}, },
{ {
"target": { "root": "local", "parts": ["user", "email"] }, "target": "user.email",
"path": { "root": "state", "parts": ["person", "email"] } "path": "state.person.email"
} }
] ]
``` ```
@@ -137,12 +137,12 @@ Invalid:
```json ```json
[ [
{ {
"target": { "root": "local", "parts": ["user"] }, "target": "user",
"path": { "root": "state", "parts": ["person"] } "path": "state.person"
}, },
{ {
"target": { "root": "local", "parts": ["user", "name"] }, "target": "user.name",
"path": { "root": "state", "parts": ["person", "name"] } "path": "state.person.name"
} }
] ]
``` ```
@@ -160,12 +160,12 @@ Invalid:
```json ```json
[ [
{ {
"source": { "root": "local", "parts": ["user"] }, "source": "user",
"target": { "root": "state", "parts": ["person"] } "target": "state.person"
}, },
{ {
"source": { "root": "local", "parts": ["user", "name"] }, "source": "user.name",
"target": { "root": "state", "parts": ["person", "name"] } "target": "state.person.name"
} }
] ]
``` ```
+3
View File
@@ -251,6 +251,9 @@ stable.
- Completed: challenge matrix operations now have compact OpenCode thread - Completed: challenge matrix operations now have compact OpenCode thread
titles, policy handling for canonical skill-document reads, and a central titles, policy handling for canonical skill-document reads, and a central
`summarize_trials.py` command for audited result tables. `summarize_trials.py` command for audited result tables.
- Completed: canonical TOML path strings replace structural JSON path objects
in workflow drafts. Paths now emit as `"input.text"`, `"state.echoed"`,
`"message"` (local) instead of `{"root": "input", "parts": ["text"]}`.
## Historical References ## Historical References
+16 -21
View File
@@ -1,4 +1,4 @@
# Structural Refs # Structured Refs
Qualified names are display strings. They are not authoritative identifiers. Qualified names are display strings. They are not authoritative identifiers.
@@ -78,15 +78,17 @@ Path strings such as `input.text`, `state.person.name`, and `output.echoed`
describe graph data movement. Do not reuse capability-ref parsing rules for describe graph data movement. Do not reuse capability-ref parsing rules for
graph paths. graph paths.
New canonical graph path JSON uses root/parts objects: New canonical graph path JSON uses TOML-key strings:
```json ```json
{ "input.message"
"root": "input",
"parts": ["message"]
}
``` ```
Structural root/parts objects are still accepted at model-parse boundaries for
old persisted records, but new schemas and new examples should emit strings.
Quote a segment when the field name itself contains a dot or space, for example
`state."person.name"` or `state.person."full name"`.
## Authoring Path Inputs ## Authoring Path Inputs
`wf_authoring` accepts ergonomic path inputs and normalizes them into the core `wf_authoring` accepts ergonomic path inputs and normalizes them into the core
@@ -114,14 +116,14 @@ g.use(
node, node,
input=[ input=[
{ {
"target": {"root": "local", "parts": ["payload.email"]}, "target": '"payload.email"',
"path": {"root": "input", "parts": ["email.address"]}, "path": 'input."email.address"',
} }
], ],
output=[ output=[
{ {
"source": {"root": "local", "parts": ["result.score"]}, "source": '"result.score"',
"target": {"root": "state", "parts": ["score"]}, "target": "state.score",
} }
], ],
) )
@@ -132,22 +134,15 @@ node-local input path. In an output binding, `source` is a node-local output
path and `target` is a workflow state destination path. path and `target` is a workflow state destination path.
`in_map`, `input_values`, and `out_map` remain deprecated Python sugar for `in_map`, `input_values`, and `out_map` remain deprecated Python sugar for
concise authoring. Structural path dicts are not valid map keys because Python concise authoring. Use canonical binding lists when working from JSON/MCP or
dict keys must be hashable. Use canonical binding lists when working from when path segments contain display punctuation.
JSON/MCP or when path segments contain display punctuation.
```json ```json
{ "state.\"person.name\".\"three and four\""
"root": "state",
"parts": ["person.name", "three and four"]
}
``` ```
```json ```json
{ "."
"root": "local",
"parts": []
}
``` ```
Old strings are accepted at parse boundaries for compatibility. Structural Old strings are accepted at parse boundaries for compatibility. Structural
@@ -2,7 +2,7 @@
Date: 2026-06-27 Date: 2026-06-27
Status: Approved for implementation planning. Status: Approved for implementation planning. Canonical Path Strings section implemented.
Related: Related:
+6 -6
View File
@@ -289,14 +289,14 @@ arguments:
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"input": [ "input": [
{ {
"target": {"root": "local", "parts": ["text"]}, "target": "text",
"path": {"root": "input", "parts": ["text"]} "path": "input.text"
} }
], ],
"output": [ "output": [
{ {
"source": {"root": "local", "parts": ["echoed"]}, "source": "echoed",
"target": {"root": "state", "parts": ["echoed"]} "target": "state.echoed"
} }
] ]
} }
@@ -666,8 +666,8 @@ For explicit final output projection from state, use:
```json ```json
{ {
"path": { "root": "state", "parts": ["result_text"] }, "path": "state.result_text",
"target": { "root": "local", "parts": ["result_text"] } "target": "result_text"
} }
``` ```
+4 -4
View File
@@ -598,14 +598,14 @@ Minimal example:
}, },
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["text"] }, "target": "text",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["echoed"] } "target": "state.echoed"
} }
] ]
} }
+53 -64
View File
@@ -71,14 +71,14 @@ A minimal draft looks like this:
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["text"] }, "target": "text",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["echoed"] } "target": "state.echoed"
} }
] ]
} }
@@ -117,8 +117,8 @@ Step output writes a node's local return payload into workflow state. It uses
```json ```json
{ {
"source": { "root": "local", "parts": ["text"] }, "source": "text",
"target": { "root": "state", "parts": ["result_text"] } "target": "state.result_text"
} }
``` ```
@@ -137,8 +137,8 @@ step-level node output bindings only.
```json ```json
{ {
"path": { "root": "state", "parts": ["result_text"] }, "path": "state.result_text",
"target": { "root": "local", "parts": ["result_text"] } "target": "result_text"
} }
``` ```
@@ -192,8 +192,8 @@ This complete draft shape:
"outcomes": ["ok", "error"], "outcomes": ["ok", "error"],
"output": [ "output": [
{ {
"target": { "root": "local", "parts": ["message"] }, "target": "message",
"path": { "root": "state", "parts": ["raw", "echoed"] } "path": "state.raw.echoed"
} }
], ],
"start": "call", "start": "call",
@@ -202,18 +202,18 @@ This complete draft shape:
"use": "demo.echo", "use": "demo.echo",
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["text"] }, "target": "text",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
}, },
{ {
"target": { "root": "local", "parts": ["fail"] }, "target": "fail",
"path": { "root": "input", "parts": ["fail"] } "path": "input.fail"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["raw", "echoed"] } "target": "state.raw.echoed"
} }
] ]
}, },
@@ -246,18 +246,18 @@ Draft `use` steps use the same canonical binding structs as core `NodeUse`:
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["message"] }, "target": "message",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
}, },
{ {
"target": { "root": "local", "parts": ["limit"] }, "target": "limit",
"value": 3 "value": 3
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["echoed"] } "target": "state.echoed"
} }
] ]
} }
@@ -271,19 +271,9 @@ Graph source paths in `in` normally start with `input.`, `state.`, or
`context.`. Node-local paths do not use those prefixes; they are paths inside `context.`. Node-local paths do not use those prefixes; they are paths inside
the target capability's input or output payload. the target capability's input or output payload.
In canonical structural paths, `parts` is a list of literal path segments. Do In canonical string paths, segments are joined with dots. For nested objects,
not put `"user.name"` in one segment unless the actual JSON property name is use `"input.user.name"`. For literal dots in property names, use TOML quoting:
literally `user.name`. For normal nested objects, write: `state."person.name"`.
```json
{ "root": "input", "parts": ["user", "name"] }
```
not:
```json
{ "root": "input", "parts": ["user.name"] }
```
For example, this canonical input/output pair: For example, this canonical input/output pair:
@@ -291,22 +281,22 @@ For example, this canonical input/output pair:
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["user", "name"] }, "target": "user.name",
"path": { "root": "input", "parts": ["user", "name"] } "path": "input.user.name"
}, },
{ {
"target": { "root": "local", "parts": ["job", "title"] }, "target": "job.title",
"path": { "root": "state", "parts": ["job", "title"] } "path": "state.job.title"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["user", "age"] }, "source": "user.age",
"target": { "root": "state", "parts": ["person", "age"] } "target": "state.person.age"
}, },
{ {
"source": { "root": "local", "parts": ["job", "years"] }, "source": "job.years",
"target": { "root": "state", "parts": ["experience", "years"] } "target": "state.experience.years"
} }
] ]
} }
@@ -325,8 +315,8 @@ Do not reverse the direction. This is wrong:
{ {
"input": [ "input": [
{ {
"target": { "root": "input", "parts": ["text"] }, "target": "input.text",
"path": { "root": "local", "parts": ["message"] } "path": "message"
} }
] ]
} }
@@ -341,8 +331,8 @@ Do not put constants in path bindings. This is wrong:
{ {
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["value"] }, "target": "value",
"path": { "root": "input", "parts": ["CLICKED"] } "path": "input.CLICKED"
} }
] ]
} }
@@ -361,14 +351,14 @@ Calls a workflow capability.
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["text"] }, "target": "text",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["echoed"] } "target": "state.echoed"
} }
] ]
} }
@@ -386,14 +376,14 @@ are part of the graph definition:
"use": "wf.std.constant", "use": "wf.std.constant",
"input": [ "input": [
{ {
"target": { "root": "local", "parts": ["value"] }, "target": "value",
"value": "CLICKED" "value": "CLICKED"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["value"] }, "source": "value",
"target": { "root": "state", "parts": ["wait_text"] } "target": "state.wait_text"
} }
] ]
} }
@@ -429,7 +419,7 @@ model: use `item_error` and `concurrent`, not draft-only field names.
```json ```json
{ {
"foreach": { "foreach": {
"over": { "root": "state", "parts": ["items"] }, "over": "state.items",
"as": "item", "as": "item",
"mode": "serial", "mode": "serial",
"item_error": "fail" "item_error": "fail"
@@ -442,7 +432,7 @@ Concurrent foreach uses the same canonical policy shape as core:
```json ```json
{ {
"foreach": { "foreach": {
"over": { "root": "state", "parts": ["items"] }, "over": "state.items",
"as": "item", "as": "item",
"mode": "concurrent", "mode": "concurrent",
"concurrent": { "concurrent": {
@@ -451,7 +441,7 @@ Concurrent foreach uses the same canonical policy shape as core:
}, },
"item_error": { "item_error": {
"action": "collect", "action": "collect",
"collect_to": { "root": "state", "parts": ["item_errors"] } "collect_to": "state.item_errors"
} }
} }
} }
@@ -472,14 +462,14 @@ Declares an interrupting step.
"kind": "input", "kind": "input",
"request": [ "request": [
{ {
"target": { "root": "local", "parts": ["question"] }, "target": "question",
"path": { "root": "state", "parts": ["question"] } "path": "state.question"
} }
], ],
"resume": [ "resume": [
{ {
"source": { "root": "local", "parts": ["answer"] }, "source": "answer",
"target": { "root": "state", "parts": ["answer"] } "target": "state.answer"
} }
], ],
"outcomes": ["resumed", "cancelled"] "outcomes": ["resumed", "cancelled"]
@@ -654,10 +644,9 @@ It does not guess that a normal output state path is also an error message.
Provider-specific error envelopes still belong in saved wrapper artifacts or Provider-specific error envelopes still belong in saved wrapper artifacts or
follow-up patches. follow-up patches.
`error_message_source` accepts the same structural graph path shape used by `error_message_source` accepts the same canonical string path shape used by
other mapping fields, for example other mapping fields, for example `"state.error_message"`. Legacy structural
`{"root": "state", "parts": ["error_message"]}`. Legacy strings such as shapes remain accepted for compatibility.
`state.error_message` remain accepted for compatibility.
In MCP Inspector, workspace mutation tools accept a single `request` object. In MCP Inspector, workspace mutation tools accept a single `request` object.
This is deliberate: the request object carries descriptions and validation for This is deliberate: the request object carries descriptions and validation for
@@ -54,7 +54,7 @@ and wrapper hints before using it. Do not assume every content block is text.
Incorrect: Incorrect:
```json ```json
{"source": {"root": "local", "parts": ["content"]}, "target": {"root": "state", "parts": ["summary"]}} {"source": "content", "target": "state.summary"}
``` ```
Correct: filter `content` to text blocks, extract each `text`, then combine or Correct: filter `content` to text blocks, extract each `text`, then combine or
@@ -73,14 +73,14 @@ The plan file is the low-level workflow model. It is not a draft workspace.
"node": "example.source.read", "node": "example.source.read",
"input": [ "input": [
{ {
"path": { "root": "input", "parts": ["text"] }, "path": "input.text",
"target": { "root": "local", "parts": ["text"] } "target": "text"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": ["text"] }, "source": "text",
"target": { "root": "state", "parts": ["notes"] } "target": "state.notes"
} }
] ]
}, },
@@ -90,14 +90,14 @@ The plan file is the low-level workflow model. It is not a draft workspace.
"node": "example.source.extract", "node": "example.source.extract",
"input": [ "input": [
{ {
"path": { "root": "state", "parts": ["notes"] }, "path": "state.notes",
"target": { "root": "local", "parts": ["text"] } "target": "text"
} }
], ],
"output": [ "output": [
{ {
"source": { "root": "local", "parts": [] }, "source": ".",
"target": { "root": "state", "parts": ["report"] } "target": "state.report"
} }
] ]
} }
@@ -108,8 +108,8 @@ The plan file is the low-level workflow model. It is not a draft workspace.
], ],
"output": [ "output": [
{ {
"path": { "root": "state", "parts": ["report"] }, "path": "state.report",
"target": { "root": "local", "parts": ["report"] } "target": "report"
} }
] ]
} }
@@ -30,8 +30,8 @@ Step input bindings read graph values into node-local input:
```json ```json
{ {
"target": { "root": "local", "parts": ["text"] }, "target": "text",
"path": { "root": "input", "parts": ["text"] } "path": "input.text"
} }
``` ```
@@ -39,8 +39,8 @@ Step output bindings write node-local output into workflow state:
```json ```json
{ {
"source": { "root": "local", "parts": ["echoed"] }, "source": "echoed",
"target": { "root": "state", "parts": ["echoed"] } "target": "state.echoed"
} }
``` ```
@@ -49,8 +49,8 @@ Top-level workflow output uses `path` / `target`, not step-level
```json ```json
{ {
"path": { "root": "state", "parts": ["echoed"] }, "path": "state.echoed",
"target": { "root": "local", "parts": ["echoed"] } "target": "echoed"
} }
``` ```
+3 -3
View File
@@ -694,16 +694,16 @@ def _path_text(value: Any, *, expected_root: str | None = None) -> str:
return root if not raw_parts else f"{root}.{'.'.join(raw_parts)}" return root if not raw_parts else f"{root}.{'.'.join(raw_parts)}"
def _graph_path_payload(value: str | GraphSourcePath) -> dict[str, str | list[str]]: def _graph_path_payload(value: str | GraphSourcePath) -> str:
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value) path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
return GraphSourcePath._serialize(path) return GraphSourcePath._serialize(path)
def _local_path_payload(value: str) -> dict[str, str | list[str]]: def _local_path_payload(value: str) -> str:
return LocalPath._serialize(LocalPath.parse(value)) return LocalPath._serialize(LocalPath.parse(value))
def _state_path_payload(value: str) -> dict[str, str | list[str]]: def _state_path_payload(value: str) -> str:
return StatePath._serialize(StatePath.parse(value)) return StatePath._serialize(StatePath.parse(value))
+8 -31
View File
@@ -1,44 +1,21 @@
from __future__ import annotations from __future__ import annotations
import tomllib
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from typing import TypeAlias, cast from typing import TypeAlias, cast
from wf_core.paths import GraphRoot, GraphSourcePath, LocalPath, StatePath from wf_core.paths import (
GraphRoot,
GraphSourcePath,
LocalPath,
StatePath,
parse_toml_path_segments,
)
PathInput: TypeAlias = ( PathInput: TypeAlias = (
str | Iterable[str] | Mapping[str, object] | GraphSourcePath | StatePath | LocalPath str | Iterable[str] | Mapping[str, object] | GraphSourcePath | StatePath | LocalPath
) )
def _parse_toml_key_expr(expr: str) -> tuple[str, ...]:
"""Parse one authoring string as a TOML key expression.
We intentionally lean on `tomllib` instead of maintaining our own dotted-key
parser. Quoted TOML keys are the escape hatch for literal dots and spaces.
"""
try:
parsed = tomllib.loads(f"{expr} = true")
except tomllib.TOMLDecodeError as exc:
raise ValueError(
f"invalid TOML key expression {expr!r}; use quoted keys, varargs, "
"or an iterable for literal path segments"
) from exc
parts: list[str] = []
current: object = parsed
while isinstance(current, dict):
if len(current) != 1:
raise ValueError(f"invalid TOML key expression {expr!r}")
key, current = next(iter(current.items()))
if not isinstance(key, str):
raise ValueError(f"invalid TOML key expression {expr!r}")
parts.append(key)
if current is not True or not parts:
raise ValueError(f"invalid TOML key expression {expr!r}")
return tuple(parts)
def _literal_parts(values: tuple[object, ...]) -> tuple[str, ...]: def _literal_parts(values: tuple[object, ...]) -> tuple[str, ...]:
"""Normalize varargs or non-string iterables into literal path segments.""" """Normalize varargs or non-string iterables into literal path segments."""
if not values: if not values:
@@ -46,7 +23,7 @@ def _literal_parts(values: tuple[object, ...]) -> tuple[str, ...]:
if len(values) == 1: if len(values) == 1:
value = values[0] value = values[0]
if isinstance(value, str): if isinstance(value, str):
return _parse_toml_key_expr(value) return parse_toml_path_segments(value)
if isinstance(value, Iterable) and not isinstance(value, Mapping): if isinstance(value, Iterable) and not isinstance(value, Mapping):
parts = tuple(value) parts = tuple(value)
if all(isinstance(part, str) for part in parts): if all(isinstance(part, str) for part in parts):
+1 -1
View File
@@ -98,7 +98,7 @@ class StateFieldDecl(BaseModel):
return schema_type if isinstance(schema_type, str) else None return schema_type if isinstance(schema_type, str) else None
@field_serializer("path") @field_serializer("path")
def _serialize_path(self, path: StatePath) -> dict[str, str | list[str]]: def _serialize_path(self, path: StatePath) -> str:
return StatePath._serialize(path) return StatePath._serialize(path)
@model_validator(mode="before") @model_validator(mode="before")
+76 -48
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
import re
import tomllib
from collections.abc import Mapping, MutableMapping from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, ClassVar, Literal from typing import Any, ClassVar, Literal
@@ -17,6 +20,10 @@ GraphRoot = Literal["input", "state", "context"]
def _validate_segment(segment: str, *, path_kind: str) -> str: def _validate_segment(segment: str, *, path_kind: str) -> str:
if not segment or not segment.strip(): if not segment or not segment.strip():
raise PathResolutionError(f"invalid {path_kind} segment {segment!r}") raise PathResolutionError(f"invalid {path_kind} segment {segment!r}")
if _CONTROL_CHAR.search(segment):
raise PathResolutionError(
f"invalid {path_kind} segment {segment!r}; control characters are not allowed"
)
return segment return segment
@@ -34,25 +41,52 @@ def _parse_fragments(*fragments: str, path_kind: str) -> tuple[str, ...]:
return tuple(parts) return tuple(parts)
def _path_json_schema(root: str | list[str], description: str) -> dict[str, Any]: _BARE_TOML_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
"""Return the canonical structural schema for saved path fields.""" _CONTROL_CHAR = re.compile(r"[\x00-\x1f\x7f]")
root_schema: dict[str, Any]
if isinstance(root, str):
root_schema = {"const": root} def parse_toml_path_segments(expr: str) -> tuple[str, ...]:
else: """Parse a TOML key expression into literal path segments."""
root_schema = {"enum": root} try:
# An inline table constrains ``expr`` to TOML key syntax. Parsing it as
# a whole document would also accept table headers and extra statements.
parsed = tomllib.loads(f"__wf_path__ = {{ {expr} = true }}")
except tomllib.TOMLDecodeError as exc:
raise PathResolutionError(
f"invalid TOML path {expr!r}; quote path segments containing dots or spaces"
) from exc
parts: list[str] = []
current: object = parsed.get("__wf_path__")
while isinstance(current, dict):
if len(current) != 1:
raise PathResolutionError(f"invalid TOML path {expr!r}")
key, current = next(iter(current.items()))
parts.append(_validate_segment(key, path_kind="TOML path"))
if current is not True or not parts:
raise PathResolutionError(f"invalid TOML path {expr!r}")
return tuple(parts)
def format_toml_path_segments(parts: tuple[str, ...]) -> str:
"""Format literal segments as one canonical TOML key expression."""
if not parts:
raise PathResolutionError("cannot format an empty TOML path")
return ".".join(
part if _BARE_TOML_KEY.fullmatch(part) else json.dumps(part, ensure_ascii=False)
for part in parts
)
def _path_json_schema(description: str) -> dict[str, Any]:
"""Return the canonical string schema for path fields.
Structural objects remain input-only compatibility for persisted records.
New schemas and serializers expose the canonical TOML-key string form.
"""
return { return {
"type": "object", "type": "string",
"description": description, "description": description,
"properties": {
"root": root_schema,
"parts": {
"type": "array",
"items": {"type": "string", "minLength": 1},
},
},
"required": ["root", "parts"],
"additionalProperties": False,
} }
@@ -88,10 +122,6 @@ class LocalPath:
parts: tuple[str, ...] parts: tuple[str, ...]
_JSON_PATTERN: ClassVar[str] = (
r"^(\.|[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*)$"
)
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__( object.__setattr__(
self, self,
@@ -113,10 +143,13 @@ class LocalPath:
def parse(cls, raw: str) -> LocalPath: def parse(cls, raw: str) -> LocalPath:
if raw == ".": if raw == ".":
return cls.root() return cls.root()
return cls.of(raw) all_parts = parse_toml_path_segments(raw)
return cls(all_parts)
def __str__(self) -> str: def __str__(self) -> str:
return "." if not self.parts else ".".join(self.parts) if not self.parts:
return "."
return format_toml_path_segments(self.parts)
@classmethod @classmethod
def __get_pydantic_core_schema__( def __get_pydantic_core_schema__(
@@ -142,17 +175,16 @@ class LocalPath:
) )
@staticmethod @staticmethod
def _serialize(value: LocalPath) -> dict[str, str | list[str]]: def _serialize(value: LocalPath) -> str:
"""Serialize canonical path JSON without relying on dotted display text.""" """Serialize canonical path JSON as a TOML-key string."""
return {"root": "local", "parts": list(value.parts)} return str(value)
@classmethod @classmethod
def __get_pydantic_json_schema__( def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]: ) -> dict[str, Any]:
return _path_json_schema( return _path_json_schema(
"local", "Node-local path. Use the root marker `.` for the whole payload.",
"Node-local path. Use an empty parts list for the whole payload.",
) )
@@ -164,7 +196,6 @@ class GraphSourcePath:
parts: tuple[str, ...] = () parts: tuple[str, ...] = ()
_ROOTS: ClassVar[set[str]] = {"input", "state", "context"} _ROOTS: ClassVar[set[str]] = {"input", "state", "context"}
_JSON_PATTERN: ClassVar[str] = r"^(input|state|context)(\.[A-Za-z_][A-Za-z0-9_]*)*$"
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.root not in self._ROOTS: if self.root not in self._ROOTS:
@@ -179,13 +210,13 @@ class GraphSourcePath:
@classmethod @classmethod
def parse(cls, raw: str) -> GraphSourcePath: def parse(cls, raw: str) -> GraphSourcePath:
root, *raw_parts = raw.split(".") all_parts = parse_toml_path_segments(raw)
if not all_parts:
raise PathResolutionError(f"invalid graph source path {raw!r}")
root, *parts = all_parts
if root not in cls._ROOTS: if root not in cls._ROOTS:
raise PathResolutionError(f"unknown path root {root!r}") raise PathResolutionError(f"unknown path root {root!r}")
parts = tuple( return cls(root, tuple(parts)) # type: ignore[arg-type]
_validate_segment(part, path_kind="graph source") for part in raw_parts
)
return cls(root, parts) # type: ignore[arg-type]
@classmethod @classmethod
def input(cls, *fragments: str) -> GraphSourcePath: def input(cls, *fragments: str) -> GraphSourcePath:
@@ -200,7 +231,8 @@ class GraphSourcePath:
return cls("context", _parse_fragments(*fragments, path_kind="graph source")) return cls("context", _parse_fragments(*fragments, path_kind="graph source"))
def __str__(self) -> str: def __str__(self) -> str:
return self.root if not self.parts else f"{self.root}.{'.'.join(self.parts)}" all_parts = (self.root, *self.parts)
return format_toml_path_segments(all_parts)
@classmethod @classmethod
def __get_pydantic_core_schema__( def __get_pydantic_core_schema__(
@@ -227,16 +259,15 @@ class GraphSourcePath:
) )
@staticmethod @staticmethod
def _serialize(value: GraphSourcePath) -> dict[str, str | list[str]]: def _serialize(value: GraphSourcePath) -> str:
"""Serialize canonical path JSON without relying on dotted display text.""" """Serialize canonical path JSON as a TOML-key string."""
return {"root": value.root, "parts": list(value.parts)} return str(value)
@classmethod @classmethod
def __get_pydantic_json_schema__( def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]: ) -> dict[str, Any]:
return _path_json_schema( return _path_json_schema(
sorted(cls._ROOTS),
"Readable graph path rooted at input, state, or context.", "Readable graph path rooted at input, state, or context.",
) )
@@ -247,10 +278,6 @@ class StatePath:
parts: tuple[str, ...] parts: tuple[str, ...]
_JSON_PATTERN: ClassVar[str] = (
r"^state\.[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$"
)
def __post_init__(self) -> None: def __post_init__(self) -> None:
parts = tuple(_validate_segment(part, path_kind="state") for part in self.parts) parts = tuple(_validate_segment(part, path_kind="state") for part in self.parts)
if not parts: if not parts:
@@ -272,7 +299,8 @@ class StatePath:
return cls(parsed.parts) return cls(parsed.parts)
def __str__(self) -> str: def __str__(self) -> str:
return f"state.{'.'.join(self.parts)}" all_parts = ("state", *self.parts)
return format_toml_path_segments(all_parts)
@classmethod @classmethod
def __get_pydantic_core_schema__( def __get_pydantic_core_schema__(
@@ -298,15 +326,15 @@ class StatePath:
) )
@staticmethod @staticmethod
def _serialize(value: StatePath) -> dict[str, str | list[str]]: def _serialize(value: StatePath) -> str:
"""Serialize canonical path JSON without relying on dotted display text.""" """Serialize canonical path JSON as a TOML-key string."""
return {"root": "state", "parts": list(value.parts)} return str(value)
@classmethod @classmethod
def __get_pydantic_json_schema__( def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]: ) -> dict[str, Any]:
return _path_json_schema("state", "Writable workflow state path.") return _path_json_schema("Writable workflow state path.")
def split_graph_path(path: str | GraphSourcePath | StatePath) -> tuple[str, list[str]]: def split_graph_path(path: str | GraphSourcePath | StatePath) -> tuple[str, list[str]]:
+6 -6
View File
@@ -77,10 +77,10 @@ def test_adapter_lowers_use_steps_to_canonical_bindings() -> None:
dumped = node.model_dump(mode="json") dumped = node.model_dump(mode="json")
assert "in_map" not in dumped assert "in_map" not in dumped
assert "out_map" not in dumped assert "out_map" not in dumped
assert dumped["input"][0]["target"] == {"root": "local", "parts": ["text"]} assert dumped["input"][0]["target"] == "text"
assert dumped["input"][0]["path"] == {"root": "input", "parts": ["text"]} assert dumped["input"][0]["path"] == "input.text"
assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]} assert dumped["output"][0]["source"] == "echoed"
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]} assert dumped["output"][0]["target"] == "state.echoed"
def test_adapter_lowers_root_workflow_output_bindings() -> None: def test_adapter_lowers_root_workflow_output_bindings() -> None:
@@ -111,8 +111,8 @@ def test_adapter_lowers_root_workflow_output_bindings() -> None:
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
dumped = workflow.model_dump(mode="json") dumped = workflow.model_dump(mode="json")
assert dumped["output"][0]["target"] == {"root": "local", "parts": ["message"]} assert dumped["output"][0]["target"] == "message"
assert dumped["output"][0]["path"] == {"root": "state", "parts": ["raw", "echoed"]} assert dumped["output"][0]["path"] == "state.raw.echoed"
def test_adapter_golden_draft_executes_ok_and_error_outcomes() -> None: def test_adapter_golden_draft_executes_ok_and_error_outcomes() -> None:
+1 -4
View File
@@ -24,10 +24,7 @@ def test_patch_workflow_draft_uses_stable_step_paths() -> None:
) )
assert result["status"] == "valid" assert result["status"] == "valid"
assert result["draft"]["steps"]["echo"]["input"][0]["target"] == { assert result["draft"]["steps"]["echo"]["input"][0]["target"] == "message"
"root": "local",
"parts": ["message"],
}
def _keyed_echo_draft() -> dict[str, object]: def _keyed_echo_draft() -> dict[str, object]:
+14 -33
View File
@@ -43,18 +43,9 @@ def test_workflow_draft_accepts_legacy_use_maps_but_dumps_canonical_bindings() -
assert "in" not in dumped["steps"]["echo"] assert "in" not in dumped["steps"]["echo"]
assert "with" not in dumped["steps"]["echo"] assert "with" not in dumped["steps"]["echo"]
assert "out" not in dumped["steps"]["echo"] assert "out" not in dumped["steps"]["echo"]
assert dumped["steps"]["echo"]["input"][0]["target"] == { assert dumped["steps"]["echo"]["input"][0]["target"] == "limit"
"root": "local", assert dumped["steps"]["echo"]["input"][1]["path"] == "input.text"
"parts": ["limit"], assert dumped["steps"]["echo"]["output"][0]["target"] == "state.echoed"
}
assert dumped["steps"]["echo"]["input"][1]["path"] == {
"root": "input",
"parts": ["text"],
}
assert dumped["steps"]["echo"]["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
def test_workflow_draft_accepts_legacy_interrupt_maps_but_dumps_canonical_bindings() -> ( def test_workflow_draft_accepts_legacy_interrupt_maps_but_dumps_canonical_bindings() -> (
@@ -79,22 +70,15 @@ def test_workflow_draft_accepts_legacy_interrupt_maps_but_dumps_canonical_bindin
dumped = draft.model_dump(mode="json") dumped = draft.model_dump(mode="json")
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["path"] == { assert (
"root": "input", dumped["steps"]["approval"]["interrupt"]["request"][0]["path"] == "input.text"
"parts": ["text"], )
} assert dumped["steps"]["approval"]["interrupt"]["request"][0]["target"] == "message"
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["target"] == { assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["source"] == "approved"
"root": "local", assert (
"parts": ["message"], dumped["steps"]["approval"]["interrupt"]["resume"][0]["target"]
} == "state.approved"
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["source"] == { )
"root": "local",
"parts": ["approved"],
}
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["target"] == {
"root": "state",
"parts": ["approved"],
}
def test_draft_step_requires_exactly_one_kind_key() -> None: def test_draft_step_requires_exactly_one_kind_key() -> None:
@@ -206,10 +190,7 @@ def test_workflow_draft_foreach_over_dumps_structural_path() -> None:
dumped = draft.model_dump(mode="json") dumped = draft.model_dump(mode="json")
assert dumped["steps"]["each_item"]["foreach"]["over"] == { assert dumped["steps"]["each_item"]["foreach"]["over"] == "state.items"
"root": "state",
"parts": ["items"],
}
def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None: def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None:
@@ -241,7 +222,7 @@ def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None:
assert isinstance(step, DraftForeachStep) assert isinstance(step, DraftForeachStep)
assert dumped["steps"]["each_item"]["foreach"]["item_error"] == { assert dumped["steps"]["each_item"]["foreach"]["item_error"] == {
"action": "collect", "action": "collect",
"collect_to": {"root": "state", "parts": ["item_errors"]}, "collect_to": "state.item_errors",
} }
assert "on_item_error" not in dumped["steps"]["each_item"]["foreach"] assert "on_item_error" not in dumped["steps"]["each_item"]["foreach"]
+4 -4
View File
@@ -176,10 +176,10 @@ def test_builder_emits_canonical_node_bindings() -> None:
dumped_node = builder.compile().model_dump(mode="json")["nodes"][0] dumped_node = builder.compile().model_dump(mode="json")["nodes"][0]
assert dumped_node["input"][0]["path"] == {"root": "input", "parts": ["text"]} assert dumped_node["input"][0]["path"] == "input.text"
assert dumped_node["input"][0]["target"] == {"root": "local", "parts": ["text"]} assert dumped_node["input"][0]["target"] == "text"
assert dumped_node["output"][0]["source"] == {"root": "local", "parts": ["text"]} assert dumped_node["output"][0]["source"] == "text"
assert dumped_node["output"][0]["target"] == {"root": "state", "parts": ["text"]} assert dumped_node["output"][0]["target"] == "state.text"
assert "in_map" not in dumped_node assert "in_map" not in dumped_node
assert "input_values" not in dumped_node assert "input_values" not in dumped_node
assert "out_map" not in dumped_node assert "out_map" not in dumped_node
@@ -47,10 +47,7 @@ def test_authoring_foreach_accepts_item_error_mapping_with_authoring_path() -> N
foreach = builder.compile().nodes[0] foreach = builder.compile().nodes[0]
assert foreach.model_dump(mode="json")["item_error"]["collect_to"] == { assert foreach.model_dump(mode="json")["item_error"]["collect_to"] == "state.errors"
"root": "state",
"parts": ["errors"],
}
def test_authoring_foreach_accepts_item_error_action_string() -> None: def test_authoring_foreach_accepts_item_error_action_string() -> None:
+4 -13
View File
@@ -14,12 +14,9 @@ def test_condition_dsl_compiles_to_core_condition() -> None:
dumped = condition.to_condition().model_dump(mode="json") dumped = condition.to_condition().model_dump(mode="json")
assert dumped["op"] == "and" assert dumped["op"] == "and"
assert dumped["args"][0]["left"]["path"] == { assert dumped["args"][0]["left"]["path"] == "state.should_email"
"root": "state",
"parts": ["should_email"],
}
assert dumped["args"][0]["right"]["value"] is True assert dumped["args"][0]["right"]["value"] is True
assert dumped["args"][1]["path"] == {"root": "state", "parts": ["summary"]} assert dumped["args"][1]["path"] == "state.summary"
def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None: def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None:
@@ -31,16 +28,10 @@ def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None:
assert isinstance(comparison.right, PathOperand) assert isinstance(comparison.right, PathOperand)
assert comparison.left.path == GraphSourcePath.state("score") assert comparison.left.path == GraphSourcePath.state("score")
assert comparison.right.path == GraphSourcePath.state("threshold") assert comparison.right.path == GraphSourcePath.state("threshold")
assert comparison.model_dump(mode="json")["left"]["path"] == { assert comparison.model_dump(mode="json")["left"]["path"] == "state.score"
"root": "state",
"parts": ["score"],
}
assert isinstance(existence, ExistsCondition) assert isinstance(existence, ExistsCondition)
assert existence.path == GraphSourcePath.state("summary") assert existence.path == GraphSourcePath.state("summary")
assert existence.model_dump(mode="json")["path"] == { assert existence.model_dump(mode="json")["path"] == "state.summary"
"root": "state",
"parts": ["summary"],
}
def test_condition_dsl_supports_not_ge_and_ne() -> None: def test_condition_dsl_supports_not_ge_and_ne() -> None:
+10 -1
View File
@@ -49,7 +49,7 @@ def test_structural_path_dicts_validate_through_core_models() -> None:
def test_invalid_toml_path_expression_has_actionable_message() -> None: def test_invalid_toml_path_expression_has_actionable_message() -> None:
with pytest.raises(ValueError, match="TOML key expression"): with pytest.raises(ValueError, match="TOML path"):
coerce_state_path("person..name") coerce_state_path("person..name")
@@ -68,3 +68,12 @@ def test_state_expr_helper_uses_same_path_input_rules() -> None:
assert isinstance(condition, BinaryCondition) assert isinstance(condition, BinaryCondition)
assert isinstance(condition.left, PathOperand) assert isinstance(condition.left, PathOperand)
assert condition.left.path == GraphSourcePath("state", ("person.name",)) assert condition.left.path == GraphSourcePath("state", ("person.name",))
def test_authoring_paths_share_core_toml_grammar() -> None:
assert coerce_graph_path('state."person.name"') == GraphSourcePath(
"state", ("person.name",)
)
assert coerce_graph_path('"person.name"', root="state") == GraphSourcePath(
"state", ("person.name",)
)
+26 -53
View File
@@ -56,14 +56,14 @@ def test_node_use_converts_old_maps_to_canonical_bindings():
assert "input_values" not in dumped assert "input_values" not in dumped
assert "out_map" not in dumped assert "out_map" not in dumped
assert dumped["input"][0]["value"] == "fast" assert dumped["input"][0]["value"] == "fast"
assert dumped["input"][0]["target"] == {"root": "local", "parts": ["mode"]} assert dumped["input"][0]["target"] == "mode"
assert dumped["input"][1]["path"] == {"root": "input", "parts": ["message"]} assert dumped["input"][1]["path"] == "input.message"
assert dumped["input"][1]["target"] == {"root": "local", "parts": ["message"]} assert dumped["input"][1]["target"] == "message"
assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]} assert dumped["output"][0]["source"] == "echoed"
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]} assert dumped["output"][0]["target"] == "state.echoed"
def test_node_use_serializes_canonical_binding_paths_as_structural_json(): def test_node_use_serializes_canonical_binding_paths_as_strings():
node = NodeUse.model_validate( node = NodeUse.model_validate(
{ {
"id": "echo", "id": "echo",
@@ -77,38 +77,14 @@ def test_node_use_serializes_canonical_binding_paths_as_structural_json():
python_dumped = node.model_dump() python_dumped = node.model_dump()
json_dumped = node.model_dump(mode="json") json_dumped = node.model_dump(mode="json")
assert python_dumped["input"][0]["target"] == { assert python_dumped["input"][0]["target"] == "message"
"root": "local", assert python_dumped["input"][0]["path"] == "input.message"
"parts": ["message"], assert python_dumped["output"][0]["source"] == "echoed"
} assert python_dumped["output"][0]["target"] == "state.echoed"
assert python_dumped["input"][0]["path"] == { assert json_dumped["input"][0]["target"] == "message"
"root": "input", assert json_dumped["input"][0]["path"] == "input.message"
"parts": ["message"], assert json_dumped["output"][0]["source"] == "echoed"
} assert json_dumped["output"][0]["target"] == "state.echoed"
assert python_dumped["output"][0]["source"] == {
"root": "local",
"parts": ["echoed"],
}
assert python_dumped["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
assert json_dumped["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert json_dumped["input"][0]["path"] == {
"root": "input",
"parts": ["message"],
}
assert json_dumped["output"][0]["source"] == {
"root": "local",
"parts": ["echoed"],
}
assert json_dumped["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
def test_canonical_binding_json_schema_describes_nested_fields(): def test_canonical_binding_json_schema_describes_nested_fields():
@@ -218,14 +194,14 @@ def test_deprecated_conversion_preserves_input_value_then_in_map_order():
) )
dumped_input = node.model_dump(mode="json")["input"] dumped_input = node.model_dump(mode="json")["input"]
assert dumped_input[0]["target"] == {"root": "local", "parts": ["first"]} assert dumped_input[0]["target"] == "first"
assert dumped_input[0]["value"] == 1 assert dumped_input[0]["value"] == 1
assert dumped_input[1]["target"] == {"root": "local", "parts": ["second"]} assert dumped_input[1]["target"] == "second"
assert dumped_input[1]["value"] == 2 assert dumped_input[1]["value"] == 2
assert dumped_input[2]["target"] == {"root": "local", "parts": ["third"]} assert dumped_input[2]["target"] == "third"
assert dumped_input[2]["path"] == {"root": "input", "parts": ["third"]} assert dumped_input[2]["path"] == "input.third"
assert dumped_input[3]["target"] == {"root": "local", "parts": ["fourth"]} assert dumped_input[3]["target"] == "fourth"
assert dumped_input[3]["path"] == {"root": "state", "parts": ["fourth"]} assert dumped_input[3]["path"] == "state.fourth"
def test_deprecated_input_value_preserves_explicit_null(): def test_deprecated_input_value_preserves_explicit_null():
@@ -243,7 +219,7 @@ def test_deprecated_input_value_preserves_explicit_null():
assert value_binding.value is None assert value_binding.value is None
dumped_input = node.model_dump(mode="json")["input"] dumped_input = node.model_dump(mode="json")["input"]
assert dumped_input[0]["target"] == {"root": "local", "parts": ["maybe"]} assert dumped_input[0]["target"] == "maybe"
assert dumped_input[0]["value"] is None assert dumped_input[0]["value"] is None
@@ -279,10 +255,10 @@ def test_interrupt_node_converts_old_maps_to_canonical_bindings():
dumped = node.model_dump(mode="json") dumped = node.model_dump(mode="json")
assert "request_map" not in dumped assert "request_map" not in dumped
assert "out_map" not in dumped assert "out_map" not in dumped
assert dumped["request"][0]["path"] == {"root": "input", "parts": ["message"]} assert dumped["request"][0]["path"] == "input.message"
assert dumped["request"][0]["target"] == {"root": "local", "parts": ["message"]} assert dumped["request"][0]["target"] == "message"
assert dumped["resume"][0]["source"] == {"root": "local", "parts": ["approved"]} assert dumped["resume"][0]["source"] == "approved"
assert dumped["resume"][0]["target"] == {"root": "state", "parts": ["approved"]} assert dumped["resume"][0]["target"] == "state.approved"
def test_interrupt_node_rejects_mixed_old_and_new_binding_styles(): def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
@@ -309,7 +285,4 @@ def test_foreach_node_serializes_over_path_as_structural_json():
) )
assert node.over == GraphSourcePath.state("items") assert node.over == GraphSourcePath.state("items")
assert node.model_dump(mode="json")["over"] == { assert node.model_dump(mode="json")["over"] == "state.items"
"root": "state",
"parts": ["items"],
}
+2 -5
View File
@@ -190,11 +190,8 @@ def test_state_field_decl_model_dump_serializes_path_structurally() -> None:
} }
) )
assert field.model_dump()["path"] == {"root": "state", "parts": ["person", "name"]} assert field.model_dump()["path"] == "state.person.name"
assert field.model_dump(mode="json")["path"] == { assert field.model_dump(mode="json")["path"] == "state.person.name"
"root": "state",
"parts": ["person", "name"],
}
def test_state_schema_model_dump_serializes_paths_as_strings() -> None: def test_state_schema_model_dump_serializes_paths_as_strings() -> None:
+98 -22
View File
@@ -161,7 +161,7 @@ def test_pydantic_revalidates_existing_path_objects() -> None:
) )
def test_pydantic_accepts_path_strings_and_serializes_structural_json() -> None: def test_pydantic_accepts_path_strings_and_serializes_path_strings() -> None:
class Payload(BaseModel): class Payload(BaseModel):
source: GraphSourcePath source: GraphSourcePath
target: StatePath target: StatePath
@@ -180,17 +180,17 @@ def test_pydantic_accepts_path_strings_and_serializes_structural_json() -> None:
assert payload.local == LocalPath.of("user") assert payload.local == LocalPath.of("user")
dumped = payload.model_dump(mode="json") dumped = payload.model_dump(mode="json")
assert dumped["source"] == {"root": "input", "parts": ["user"]} assert dumped["source"] == "input.user"
assert dumped["target"] == {"root": "state", "parts": ["person"]} assert dumped["target"] == "state.person"
assert dumped["local"] == {"root": "local", "parts": ["user"]} assert dumped["local"] == "user"
python_dumped = payload.model_dump() python_dumped = payload.model_dump()
assert python_dumped["source"] == {"root": "input", "parts": ["user"]} assert python_dumped["source"] == "input.user"
assert python_dumped["target"] == {"root": "state", "parts": ["person"]} assert python_dumped["target"] == "state.person"
assert python_dumped["local"] == {"root": "local", "parts": ["user"]} assert python_dumped["local"] == "user"
def test_path_json_schema_advertises_structural_shape() -> None: def test_path_json_schema_advertises_string_type() -> None:
class Payload(BaseModel): class Payload(BaseModel):
source: GraphSourcePath source: GraphSourcePath
target: StatePath target: StatePath
@@ -198,24 +198,16 @@ def test_path_json_schema_advertises_structural_shape() -> None:
schema = Payload.model_json_schema() schema = Payload.model_json_schema()
assert schema["properties"]["source"]["type"] == "object" assert schema["properties"]["source"]["type"] == "string"
assert schema["properties"]["source"]["properties"]["root"]["enum"] == [ assert schema["properties"]["target"]["type"] == "string"
"context", assert schema["properties"]["local"]["type"] == "string"
"input",
"state",
]
assert schema["properties"]["target"]["properties"]["root"]["const"] == "state"
assert schema["properties"]["local"]["properties"]["root"]["const"] == "local"
def test_condition_path_operand_serializes_path_as_structural_json() -> None: def test_condition_path_operand_serializes_path_as_string() -> None:
operand = PathOperand.model_validate({"path": "state.x"}) operand = PathOperand.model_validate({"path": "state.x"})
assert operand.model_dump()["path"] == {"root": "state", "parts": ["x"]} assert operand.model_dump()["path"] == "state.x"
assert operand.model_dump(mode="json")["path"] == { assert operand.model_dump(mode="json")["path"] == "state.x"
"root": "state",
"parts": ["x"],
}
def test_pydantic_accepts_existing_path_objects() -> None: def test_pydantic_accepts_existing_path_objects() -> None:
@@ -279,3 +271,87 @@ def test_path_parts_overlap_detects_equality_and_ancestry(
expected: bool, expected: bool,
) -> None: ) -> None:
assert path_parts_overlap(left, right) is expected assert path_parts_overlap(left, right) is expected
def test_toml_path_strings_round_trip_literal_segments() -> None:
source = GraphSourcePath.parse('input."customer.name"."display name"')
target = StatePath.parse('state."report.title"')
local = LocalPath.parse('payload."raw.value"')
assert source.parts == ("customer.name", "display name")
assert target.parts == ("report.title",)
assert local.parts == ("payload", "raw.value")
assert str(source) == 'input."customer.name"."display name"'
assert str(target) == 'state."report.title"'
assert str(local) == 'payload."raw.value"'
def test_toml_path_strings_bare_keys_round_trip() -> None:
source = GraphSourcePath.parse("input.user.name")
assert source.parts == ("user", "name")
assert str(source) == "input.user.name"
def test_local_path_parse_root_marker() -> None:
local = LocalPath.parse(".")
assert local.parts == ()
assert str(local) == "."
def test_toml_path_strings_reject_malformed_toml() -> None:
with pytest.raises(PathResolutionError, match="invalid TOML path"):
GraphSourcePath.parse('input."unclosed')
@pytest.mark.parametrize("raw", ["[input]\nname", "[input.user]\nname"])
def test_toml_path_strings_reject_document_table_syntax(raw: str) -> None:
with pytest.raises(PathResolutionError, match="invalid TOML path"):
GraphSourcePath.parse(raw)
def test_toml_path_strings_reject_inline_table_breakout_syntax() -> None:
with pytest.raises(PathResolutionError, match="invalid TOML path"):
LocalPath.parse("uhh = 1}\nfoo={bar")
def test_toml_path_strings_reject_control_characters_in_segments() -> None:
with pytest.raises(PathResolutionError, match="control characters"):
LocalPath.parse('"uhh = 1}\\nfoo={bar"')
def test_toml_path_strings_allow_brackets_inside_quoted_segments() -> None:
assert GraphSourcePath.parse('input."[name]"') == GraphSourcePath(
"input", ("[name]",)
)
def test_toml_path_strings_reject_invalid_root() -> None:
with pytest.raises(PathResolutionError, match="unknown path root"):
GraphSourcePath.parse('output."foo"')
def test_state_path_rejects_bare_state_without_segments() -> None:
with pytest.raises(PathResolutionError, match="state path"):
StatePath.parse("state")
def test_path_models_serialize_strings_but_accept_structural_compat() -> None:
class Payload(BaseModel):
source: GraphSourcePath
target: StatePath
local: LocalPath
payload = Payload.model_validate(
{
"source": {"root": "input", "parts": ["user.name"]},
"target": {"root": "state", "parts": ["person name"]},
"local": {"root": "local", "parts": ["payload.text"]},
}
)
assert payload.model_dump(mode="json") == {
"source": 'input."user.name"',
"target": 'state."person name"',
"local": '"payload.text"',
}
assert Payload.model_json_schema()["properties"]["source"]["type"] == "string"
@@ -32,11 +32,11 @@ def test_raw_canonical_workflow_serializes_new_shape() -> None:
assert "in_map" not in node assert "in_map" not in node
assert "input_values" not in node assert "input_values" not in node
assert "out_map" not in node assert "out_map" not in node
assert node["input"][0]["path"] == {"root": "input", "parts": ["text"]} assert node["input"][0]["path"] == "input.text"
assert node["input"][0]["target"] == {"root": "local", "parts": ["text"]} assert node["input"][0]["target"] == "text"
assert node["input"][1]["value"] == "raw:" assert node["input"][1]["value"] == "raw:"
assert node["output"][0]["source"] == {"root": "local", "parts": ["message"]} assert node["output"][0]["source"] == "message"
assert node["output"][0]["target"] == {"root": "state", "parts": ["message"]} assert node["output"][0]["target"] == "state.message"
assert message_schema["type"] == "string" assert message_schema["type"] == "string"
assert message_schema["reducer"] == "wf.std.replace" assert message_schema["reducer"] == "wf.std.replace"
@@ -62,8 +62,5 @@ def test_raw_concurrent_foreach_serializes_canonical_policy_shape() -> None:
assert foreach["mode"] == "concurrent" assert foreach["mode"] == "concurrent"
assert foreach["concurrent"]["max_active"] == 2 assert foreach["concurrent"]["max_active"] == 2
assert foreach["item_error"]["action"] == "collect" assert foreach["item_error"]["action"] == "collect"
assert foreach["item_error"]["collect_to"] == { assert foreach["item_error"]["collect_to"] == "state.errors"
"root": "state",
"parts": ["errors"],
}
assert "on_item_error" not in foreach assert "on_item_error" not in foreach
+16 -16
View File
@@ -248,14 +248,14 @@ async def test_draft_workspace_patch_helpers_update_revision_and_bindings(
assert fetched["draft"]["routes"]["echo"]["error"] == "__end__" assert fetched["draft"]["routes"]["echo"]["error"] == "__end__"
assert fetched["draft"]["steps"]["echo"]["input"] == [ assert fetched["draft"]["steps"]["echo"]["input"] == [
{ {
"target": {"root": "local", "parts": ["message"]}, "target": "message",
"path": {"root": "input", "parts": ["text"]}, "path": "input.text",
} }
] ]
assert fetched["draft"]["steps"]["echo"]["output"] == [ assert fetched["draft"]["steps"]["echo"]["output"] == [
{ {
"source": {"root": "local", "parts": ["echoed"]}, "source": "echoed",
"target": {"root": "state", "parts": ["echoed"]}, "target": "state.echoed",
} }
] ]
@@ -296,18 +296,18 @@ async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) ->
assert replaced["revision"] == 4 assert replaced["revision"] == 4
assert fetched["draft"]["steps"]["echo"]["input"] == [ assert fetched["draft"]["steps"]["echo"]["input"] == [
{ {
"target": {"root": "local", "parts": ["final"]}, "target": "final",
"path": {"root": "input", "parts": ["final"]}, "path": "input.final",
} }
] ]
assert fetched["draft"]["steps"]["echo"]["output"] == [ assert fetched["draft"]["steps"]["echo"]["output"] == [
{ {
"source": {"root": "local", "parts": ["echoed"]}, "source": "echoed",
"target": {"root": "state", "parts": ["echoed"]}, "target": "state.echoed",
}, },
{ {
"source": {"root": "local", "parts": ["extra"]}, "source": "extra",
"target": {"root": "state", "parts": ["extra"]}, "target": "state.extra",
}, },
] ]
@@ -644,12 +644,12 @@ async def test_bind_output_to_state_projects_schema_and_merges_output_map(
} }
assert draft["steps"]["snap"]["output"] == [ assert draft["steps"]["snap"]["output"] == [
{ {
"source": {"root": "local", "parts": ["before"]}, "source": "before",
"target": {"root": "state", "parts": ["before"]}, "target": "state.before",
}, },
{ {
"source": {"root": "local", "parts": ["after"]}, "source": "after",
"target": {"root": "state", "parts": ["after"]}, "target": "state.after",
}, },
] ]
@@ -798,8 +798,8 @@ async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
assert draft["routes"]["snap"]["ok"] == "__end__" assert draft["routes"]["snap"]["ok"] == "__end__"
assert draft["steps"]["snap"]["output"] == [ assert draft["steps"]["snap"]["output"] == [
{ {
"source": {"root": "local", "parts": ["after"]}, "source": "after",
"target": {"root": "state", "parts": ["after"]}, "target": "state.after",
} }
] ]
assert draft["state_schema"]["properties"]["after"]["$ref"] == "#/$defs/_Snapshot" assert draft["state_schema"]["properties"]["after"]["$ref"] == "#/$defs/_Snapshot"
+11 -11
View File
@@ -200,7 +200,7 @@ def test_workflow_surface_creates_minimal_draft_workspace_with_error_route(
assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error" assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error"
assert workspace.draft["steps"]["tool_error"]["input"] == [ assert workspace.draft["steps"]["tool_error"]["input"] == [
{ {
"target": {"root": "local", "parts": ["message"]}, "target": "message",
"value": "Capability call failed", "value": "Capability call failed",
} }
] ]
@@ -242,8 +242,8 @@ def test_workflow_surface_minimal_draft_honors_explicit_error_message_source(
assert workspace.draft["steps"]["tool_error"]["input"] == [ assert workspace.draft["steps"]["tool_error"]["input"] == [
{ {
"target": {"root": "local", "parts": ["message"]}, "target": "message",
"path": {"root": "state", "parts": ["error_message"]}, "path": "state.error_message",
} }
] ]
@@ -311,14 +311,14 @@ def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace(
assert result["workspace_id"] == "echo_draft_canonical" assert result["workspace_id"] == "echo_draft_canonical"
assert workspace.draft["steps"]["call"]["input"] == [ assert workspace.draft["steps"]["call"]["input"] == [
{ {
"target": {"root": "local", "parts": ["text"]}, "target": "text",
"path": {"root": "input", "parts": ["text"]}, "path": "input.text",
} }
] ]
assert workspace.draft["steps"]["call"]["output"] == [ assert workspace.draft["steps"]["call"]["output"] == [
{ {
"source": {"root": "local", "parts": ["echoed"]}, "source": "echoed",
"target": {"root": "state", "parts": ["echoed"]}, "target": "state.echoed",
} }
] ]
@@ -369,14 +369,14 @@ def test_workflow_surface_creates_draft_workspace_from_capability_hints(
assert workspace.draft["steps"]["call"]["use"] == "demo.personal.echo_tool" assert workspace.draft["steps"]["call"]["use"] == "demo.personal.echo_tool"
assert workspace.draft["steps"]["call"]["input"] == [ assert workspace.draft["steps"]["call"]["input"] == [
{ {
"target": {"root": "local", "parts": ["text"]}, "target": "text",
"path": {"root": "input", "parts": ["text"]}, "path": "input.text",
} }
] ]
assert workspace.draft["steps"]["call"]["output"] == [ assert workspace.draft["steps"]["call"]["output"] == [
{ {
"source": {"root": "local", "parts": ["echoed"]}, "source": "echoed",
"target": {"root": "state", "parts": ["echoed"]}, "target": "state.echoed",
} }
] ]
+8 -8
View File
@@ -763,22 +763,22 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
assert draft["routes"]["call"]["ok"] == "__end__" assert draft["routes"]["call"]["ok"] == "__end__"
assert draft["steps"]["call"]["input"] == [ assert draft["steps"]["call"]["input"] == [
{ {
"target": {"root": "local", "parts": ["value"]}, "target": "value",
"path": {"root": "input", "parts": ["value"]}, "path": "input.value",
}, },
{ {
"target": {"root": "local", "parts": ["extra"]}, "target": "extra",
"path": {"root": "input", "parts": ["extra"]}, "path": "input.extra",
}, },
] ]
assert draft["steps"]["call"]["output"] == [ assert draft["steps"]["call"]["output"] == [
{ {
"source": {"root": "local", "parts": ["value"]}, "source": "value",
"target": {"root": "state", "parts": ["extra_value"]}, "target": "state.extra_value",
}, },
{ {
"source": {"root": "local", "parts": ["extra"]}, "source": "extra",
"target": {"root": "state", "parts": ["extra"]}, "target": "state.extra",
}, },
] ]
assert ( assert (