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
{
"input": [
{
"target": { "root": "local", "parts": ["text"] },
"path": { "root": "input", "parts": ["text"] }
}
],
"output": [
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["echoed"] }
}
]
"input": [
{
"target": "text",
"path": "input.text"
}
],
"output": [
{
"source": "echoed",
"target": "state.echoed"
}
]
}
```
+25 -25
View File
@@ -43,18 +43,18 @@ Examples:
{
"input": [
{
"target": { "root": "local", "parts": ["user", "name"] },
"path": { "root": "state", "parts": ["person", "name"] }
"target": "user.name",
"path": "state.person.name"
},
{
"target": { "root": "local", "parts": ["mode"] },
"target": "mode",
"value": "fast"
}
],
"output": [
{
"source": { "root": "local", "parts": ["job", "wage"] },
"target": { "root": "state", "parts": ["job", "wage"] }
"source": "job.wage",
"target": "state.job.wage"
}
]
}
@@ -66,14 +66,14 @@ Whole-object mapping remains valid:
{
"input": [
{
"target": { "root": "local", "parts": ["user"] },
"path": { "root": "state", "parts": ["person"] }
"target": "user",
"path": "state.person"
}
],
"output": [
{
"source": { "root": "local", "parts": ["user"] },
"target": { "root": "state", "parts": ["person"] }
"source": "user",
"target": "state.person"
}
]
}
@@ -85,14 +85,14 @@ Whole-payload mapping uses the local root path `"."`:
{
"input": [
{
"target": { "root": "local", "parts": [] },
"path": { "root": "state", "parts": ["rates"] }
"target": ".",
"path": "state.rates"
}
],
"output": [
{
"source": { "root": "local", "parts": [] },
"target": { "root": "state", "parts": ["rates"] }
"source": ".",
"target": "state.rates"
}
]
}
@@ -122,12 +122,12 @@ Valid:
```json
[
{
"target": { "root": "local", "parts": ["user", "name"] },
"path": { "root": "state", "parts": ["person", "name"] }
"target": "user.name",
"path": "state.person.name"
},
{
"target": { "root": "local", "parts": ["user", "email"] },
"path": { "root": "state", "parts": ["person", "email"] }
"target": "user.email",
"path": "state.person.email"
}
]
```
@@ -137,12 +137,12 @@ Invalid:
```json
[
{
"target": { "root": "local", "parts": ["user"] },
"path": { "root": "state", "parts": ["person"] }
"target": "user",
"path": "state.person"
},
{
"target": { "root": "local", "parts": ["user", "name"] },
"path": { "root": "state", "parts": ["person", "name"] }
"target": "user.name",
"path": "state.person.name"
}
]
```
@@ -160,12 +160,12 @@ Invalid:
```json
[
{
"source": { "root": "local", "parts": ["user"] },
"target": { "root": "state", "parts": ["person"] }
"source": "user",
"target": "state.person"
},
{
"source": { "root": "local", "parts": ["user", "name"] },
"target": { "root": "state", "parts": ["person", "name"] }
"source": "user.name",
"target": "state.person.name"
}
]
```
+3
View File
@@ -251,6 +251,9 @@ stable.
- Completed: challenge matrix operations now have compact OpenCode thread
titles, policy handling for canonical skill-document reads, and a central
`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
+16 -21
View File
@@ -1,4 +1,4 @@
# Structural Refs
# Structured Refs
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
graph paths.
New canonical graph path JSON uses root/parts objects:
New canonical graph path JSON uses TOML-key strings:
```json
{
"root": "input",
"parts": ["message"]
}
"input.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
`wf_authoring` accepts ergonomic path inputs and normalizes them into the core
@@ -114,14 +116,14 @@ g.use(
node,
input=[
{
"target": {"root": "local", "parts": ["payload.email"]},
"path": {"root": "input", "parts": ["email.address"]},
"target": '"payload.email"',
"path": 'input."email.address"',
}
],
output=[
{
"source": {"root": "local", "parts": ["result.score"]},
"target": {"root": "state", "parts": ["score"]},
"source": '"result.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.
`in_map`, `input_values`, and `out_map` remain deprecated Python sugar for
concise authoring. Structural path dicts are not valid map keys because Python
dict keys must be hashable. Use canonical binding lists when working from
JSON/MCP or when path segments contain display punctuation.
concise authoring. Use canonical binding lists when working from JSON/MCP or
when path segments contain display punctuation.
```json
{
"root": "state",
"parts": ["person.name", "three and four"]
}
"state.\"person.name\".\"three and four\""
```
```json
{
"root": "local",
"parts": []
}
"."
```
Old strings are accepted at parse boundaries for compatibility. Structural
@@ -2,7 +2,7 @@
Date: 2026-06-27
Status: Approved for implementation planning.
Status: Approved for implementation planning. Canonical Path Strings section implemented.
Related:
+6 -6
View File
@@ -289,14 +289,14 @@ arguments:
"use": "demo.personal.echo_tool",
"input": [
{
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]}
"target": "text",
"path": "input.text"
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]}
"source": "echoed",
"target": "state.echoed"
}
]
}
@@ -666,8 +666,8 @@ For explicit final output projection from state, use:
```json
{
"path": { "root": "state", "parts": ["result_text"] },
"target": { "root": "local", "parts": ["result_text"] }
"path": "state.result_text",
"target": "result_text"
}
```
+4 -4
View File
@@ -598,14 +598,14 @@ Minimal example:
},
"input": [
{
"target": { "root": "local", "parts": ["text"] },
"path": { "root": "input", "parts": ["text"] }
"target": "text",
"path": "input.text"
}
],
"output": [
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["echoed"] }
"source": "echoed",
"target": "state.echoed"
}
]
}
+53 -64
View File
@@ -71,14 +71,14 @@ A minimal draft looks like this:
"use": "demo.personal.echo_tool",
"input": [
{
"target": { "root": "local", "parts": ["text"] },
"path": { "root": "input", "parts": ["text"] }
"target": "text",
"path": "input.text"
}
],
"output": [
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["echoed"] }
"source": "echoed",
"target": "state.echoed"
}
]
}
@@ -117,8 +117,8 @@ Step output writes a node's local return payload into workflow state. It uses
```json
{
"source": { "root": "local", "parts": ["text"] },
"target": { "root": "state", "parts": ["result_text"] }
"source": "text",
"target": "state.result_text"
}
```
@@ -137,8 +137,8 @@ step-level node output bindings only.
```json
{
"path": { "root": "state", "parts": ["result_text"] },
"target": { "root": "local", "parts": ["result_text"] }
"path": "state.result_text",
"target": "result_text"
}
```
@@ -192,8 +192,8 @@ This complete draft shape:
"outcomes": ["ok", "error"],
"output": [
{
"target": { "root": "local", "parts": ["message"] },
"path": { "root": "state", "parts": ["raw", "echoed"] }
"target": "message",
"path": "state.raw.echoed"
}
],
"start": "call",
@@ -202,18 +202,18 @@ This complete draft shape:
"use": "demo.echo",
"input": [
{
"target": { "root": "local", "parts": ["text"] },
"path": { "root": "input", "parts": ["text"] }
"target": "text",
"path": "input.text"
},
{
"target": { "root": "local", "parts": ["fail"] },
"path": { "root": "input", "parts": ["fail"] }
"target": "fail",
"path": "input.fail"
}
],
"output": [
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["raw", "echoed"] }
"source": "echoed",
"target": "state.raw.echoed"
}
]
},
@@ -246,18 +246,18 @@ Draft `use` steps use the same canonical binding structs as core `NodeUse`:
{
"input": [
{
"target": { "root": "local", "parts": ["message"] },
"path": { "root": "input", "parts": ["text"] }
"target": "message",
"path": "input.text"
},
{
"target": { "root": "local", "parts": ["limit"] },
"target": "limit",
"value": 3
}
],
"output": [
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["echoed"] }
"source": "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
the target capability's input or output payload.
In canonical structural paths, `parts` is a list of literal path segments. Do
not put `"user.name"` in one segment unless the actual JSON property name is
literally `user.name`. For normal nested objects, write:
```json
{ "root": "input", "parts": ["user", "name"] }
```
not:
```json
{ "root": "input", "parts": ["user.name"] }
```
In canonical string paths, segments are joined with dots. For nested objects,
use `"input.user.name"`. For literal dots in property names, use TOML quoting:
`state."person.name"`.
For example, this canonical input/output pair:
@@ -291,22 +281,22 @@ For example, this canonical input/output pair:
{
"input": [
{
"target": { "root": "local", "parts": ["user", "name"] },
"path": { "root": "input", "parts": ["user", "name"] }
"target": "user.name",
"path": "input.user.name"
},
{
"target": { "root": "local", "parts": ["job", "title"] },
"path": { "root": "state", "parts": ["job", "title"] }
"target": "job.title",
"path": "state.job.title"
}
],
"output": [
{
"source": { "root": "local", "parts": ["user", "age"] },
"target": { "root": "state", "parts": ["person", "age"] }
"source": "user.age",
"target": "state.person.age"
},
{
"source": { "root": "local", "parts": ["job", "years"] },
"target": { "root": "state", "parts": ["experience", "years"] }
"source": "job.years",
"target": "state.experience.years"
}
]
}
@@ -325,8 +315,8 @@ Do not reverse the direction. This is wrong:
{
"input": [
{
"target": { "root": "input", "parts": ["text"] },
"path": { "root": "local", "parts": ["message"] }
"target": "input.text",
"path": "message"
}
]
}
@@ -341,8 +331,8 @@ Do not put constants in path bindings. This is wrong:
{
"input": [
{
"target": { "root": "local", "parts": ["value"] },
"path": { "root": "input", "parts": ["CLICKED"] }
"target": "value",
"path": "input.CLICKED"
}
]
}
@@ -361,14 +351,14 @@ Calls a workflow capability.
"use": "demo.personal.echo_tool",
"input": [
{
"target": { "root": "local", "parts": ["text"] },
"path": { "root": "input", "parts": ["text"] }
"target": "text",
"path": "input.text"
}
],
"output": [
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["echoed"] }
"source": "echoed",
"target": "state.echoed"
}
]
}
@@ -386,14 +376,14 @@ are part of the graph definition:
"use": "wf.std.constant",
"input": [
{
"target": { "root": "local", "parts": ["value"] },
"target": "value",
"value": "CLICKED"
}
],
"output": [
{
"source": { "root": "local", "parts": ["value"] },
"target": { "root": "state", "parts": ["wait_text"] }
"source": "value",
"target": "state.wait_text"
}
]
}
@@ -429,7 +419,7 @@ model: use `item_error` and `concurrent`, not draft-only field names.
```json
{
"foreach": {
"over": { "root": "state", "parts": ["items"] },
"over": "state.items",
"as": "item",
"mode": "serial",
"item_error": "fail"
@@ -442,7 +432,7 @@ Concurrent foreach uses the same canonical policy shape as core:
```json
{
"foreach": {
"over": { "root": "state", "parts": ["items"] },
"over": "state.items",
"as": "item",
"mode": "concurrent",
"concurrent": {
@@ -451,7 +441,7 @@ Concurrent foreach uses the same canonical policy shape as core:
},
"item_error": {
"action": "collect",
"collect_to": { "root": "state", "parts": ["item_errors"] }
"collect_to": "state.item_errors"
}
}
}
@@ -472,14 +462,14 @@ Declares an interrupting step.
"kind": "input",
"request": [
{
"target": { "root": "local", "parts": ["question"] },
"path": { "root": "state", "parts": ["question"] }
"target": "question",
"path": "state.question"
}
],
"resume": [
{
"source": { "root": "local", "parts": ["answer"] },
"target": { "root": "state", "parts": ["answer"] }
"source": "answer",
"target": "state.answer"
}
],
"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
follow-up patches.
`error_message_source` accepts the same structural graph path shape used by
other mapping fields, for example
`{"root": "state", "parts": ["error_message"]}`. Legacy strings such as
`state.error_message` remain accepted for compatibility.
`error_message_source` accepts the same canonical string path shape used by
other mapping fields, for example `"state.error_message"`. Legacy structural
shapes remain accepted for compatibility.
In MCP Inspector, workspace mutation tools accept a single `request` object.
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:
```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
@@ -73,14 +73,14 @@ The plan file is the low-level workflow model. It is not a draft workspace.
"node": "example.source.read",
"input": [
{
"path": { "root": "input", "parts": ["text"] },
"target": { "root": "local", "parts": ["text"] }
"path": "input.text",
"target": "text"
}
],
"output": [
{
"source": { "root": "local", "parts": ["text"] },
"target": { "root": "state", "parts": ["notes"] }
"source": "text",
"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",
"input": [
{
"path": { "root": "state", "parts": ["notes"] },
"target": { "root": "local", "parts": ["text"] }
"path": "state.notes",
"target": "text"
}
],
"output": [
{
"source": { "root": "local", "parts": [] },
"target": { "root": "state", "parts": ["report"] }
"source": ".",
"target": "state.report"
}
]
}
@@ -108,8 +108,8 @@ The plan file is the low-level workflow model. It is not a draft workspace.
],
"output": [
{
"path": { "root": "state", "parts": ["report"] },
"target": { "root": "local", "parts": ["report"] }
"path": "state.report",
"target": "report"
}
]
}
@@ -30,8 +30,8 @@ Step input bindings read graph values into node-local input:
```json
{
"target": { "root": "local", "parts": ["text"] },
"path": { "root": "input", "parts": ["text"] }
"target": "text",
"path": "input.text"
}
```
@@ -39,8 +39,8 @@ Step output bindings write node-local output into workflow state:
```json
{
"source": { "root": "local", "parts": ["echoed"] },
"target": { "root": "state", "parts": ["echoed"] }
"source": "echoed",
"target": "state.echoed"
}
```
@@ -49,8 +49,8 @@ Top-level workflow output uses `path` / `target`, not step-level
```json
{
"path": { "root": "state", "parts": ["echoed"] },
"target": { "root": "local", "parts": ["echoed"] }
"path": "state.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)}"
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)
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))
def _state_path_payload(value: str) -> dict[str, str | list[str]]:
def _state_path_payload(value: str) -> str:
return StatePath._serialize(StatePath.parse(value))
+8 -31
View File
@@ -1,44 +1,21 @@
from __future__ import annotations
import tomllib
from collections.abc import Iterable, Mapping
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 = (
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, ...]:
"""Normalize varargs or non-string iterables into literal path segments."""
if not values:
@@ -46,7 +23,7 @@ def _literal_parts(values: tuple[object, ...]) -> tuple[str, ...]:
if len(values) == 1:
value = values[0]
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):
parts = tuple(value)
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
@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)
@model_validator(mode="before")
+76 -48
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import json
import re
import tomllib
from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass
from typing import Any, ClassVar, Literal
@@ -17,6 +20,10 @@ GraphRoot = Literal["input", "state", "context"]
def _validate_segment(segment: str, *, path_kind: str) -> str:
if not segment or not segment.strip():
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
@@ -34,25 +41,52 @@ def _parse_fragments(*fragments: str, path_kind: str) -> tuple[str, ...]:
return tuple(parts)
def _path_json_schema(root: str | list[str], description: str) -> dict[str, Any]:
"""Return the canonical structural schema for saved path fields."""
root_schema: dict[str, Any]
if isinstance(root, str):
root_schema = {"const": root}
else:
root_schema = {"enum": root}
_BARE_TOML_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
_CONTROL_CHAR = re.compile(r"[\x00-\x1f\x7f]")
def parse_toml_path_segments(expr: str) -> tuple[str, ...]:
"""Parse a TOML key expression into literal path segments."""
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 {
"type": "object",
"type": "string",
"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, ...]
_JSON_PATTERN: ClassVar[str] = (
r"^(\.|[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*)$"
)
def __post_init__(self) -> None:
object.__setattr__(
self,
@@ -113,10 +143,13 @@ class LocalPath:
def parse(cls, raw: str) -> LocalPath:
if raw == ".":
return cls.root()
return cls.of(raw)
all_parts = parse_toml_path_segments(raw)
return cls(all_parts)
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
def __get_pydantic_core_schema__(
@@ -142,17 +175,16 @@ class LocalPath:
)
@staticmethod
def _serialize(value: LocalPath) -> dict[str, str | list[str]]:
"""Serialize canonical path JSON without relying on dotted display text."""
return {"root": "local", "parts": list(value.parts)}
def _serialize(value: LocalPath) -> str:
"""Serialize canonical path JSON as a TOML-key string."""
return str(value)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]:
return _path_json_schema(
"local",
"Node-local path. Use an empty parts list for the whole payload.",
"Node-local path. Use the root marker `.` for the whole payload.",
)
@@ -164,7 +196,6 @@ class GraphSourcePath:
parts: tuple[str, ...] = ()
_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:
if self.root not in self._ROOTS:
@@ -179,13 +210,13 @@ class GraphSourcePath:
@classmethod
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:
raise PathResolutionError(f"unknown path root {root!r}")
parts = tuple(
_validate_segment(part, path_kind="graph source") for part in raw_parts
)
return cls(root, parts) # type: ignore[arg-type]
return cls(root, tuple(parts)) # type: ignore[arg-type]
@classmethod
def input(cls, *fragments: str) -> GraphSourcePath:
@@ -200,7 +231,8 @@ class GraphSourcePath:
return cls("context", _parse_fragments(*fragments, path_kind="graph source"))
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
def __get_pydantic_core_schema__(
@@ -227,16 +259,15 @@ class GraphSourcePath:
)
@staticmethod
def _serialize(value: GraphSourcePath) -> dict[str, str | list[str]]:
"""Serialize canonical path JSON without relying on dotted display text."""
return {"root": value.root, "parts": list(value.parts)}
def _serialize(value: GraphSourcePath) -> str:
"""Serialize canonical path JSON as a TOML-key string."""
return str(value)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]:
return _path_json_schema(
sorted(cls._ROOTS),
"Readable graph path rooted at input, state, or context.",
)
@@ -247,10 +278,6 @@ class StatePath:
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:
parts = tuple(_validate_segment(part, path_kind="state") for part in self.parts)
if not parts:
@@ -272,7 +299,8 @@ class StatePath:
return cls(parsed.parts)
def __str__(self) -> str:
return f"state.{'.'.join(self.parts)}"
all_parts = ("state", *self.parts)
return format_toml_path_segments(all_parts)
@classmethod
def __get_pydantic_core_schema__(
@@ -298,15 +326,15 @@ class StatePath:
)
@staticmethod
def _serialize(value: StatePath) -> dict[str, str | list[str]]:
"""Serialize canonical path JSON without relying on dotted display text."""
return {"root": "state", "parts": list(value.parts)}
def _serialize(value: StatePath) -> str:
"""Serialize canonical path JSON as a TOML-key string."""
return str(value)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> 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]]:
+6 -6
View File
@@ -77,10 +77,10 @@ def test_adapter_lowers_use_steps_to_canonical_bindings() -> None:
dumped = node.model_dump(mode="json")
assert "in_map" not in dumped
assert "out_map" not in dumped
assert dumped["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert dumped["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]}
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]}
assert dumped["input"][0]["target"] == "text"
assert dumped["input"][0]["path"] == "input.text"
assert dumped["output"][0]["source"] == "echoed"
assert dumped["output"][0]["target"] == "state.echoed"
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)
dumped = workflow.model_dump(mode="json")
assert dumped["output"][0]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["output"][0]["path"] == {"root": "state", "parts": ["raw", "echoed"]}
assert dumped["output"][0]["target"] == "message"
assert dumped["output"][0]["path"] == "state.raw.echoed"
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["draft"]["steps"]["echo"]["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert result["draft"]["steps"]["echo"]["input"][0]["target"] == "message"
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 "with" not in dumped["steps"]["echo"]
assert "out" not in dumped["steps"]["echo"]
assert dumped["steps"]["echo"]["input"][0]["target"] == {
"root": "local",
"parts": ["limit"],
}
assert dumped["steps"]["echo"]["input"][1]["path"] == {
"root": "input",
"parts": ["text"],
}
assert dumped["steps"]["echo"]["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
assert dumped["steps"]["echo"]["input"][0]["target"] == "limit"
assert dumped["steps"]["echo"]["input"][1]["path"] == "input.text"
assert dumped["steps"]["echo"]["output"][0]["target"] == "state.echoed"
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")
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["path"] == {
"root": "input",
"parts": ["text"],
}
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["source"] == {
"root": "local",
"parts": ["approved"],
}
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["target"] == {
"root": "state",
"parts": ["approved"],
}
assert (
dumped["steps"]["approval"]["interrupt"]["request"][0]["path"] == "input.text"
)
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["target"] == "message"
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["source"] == "approved"
assert (
dumped["steps"]["approval"]["interrupt"]["resume"][0]["target"]
== "state.approved"
)
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")
assert dumped["steps"]["each_item"]["foreach"]["over"] == {
"root": "state",
"parts": ["items"],
}
assert dumped["steps"]["each_item"]["foreach"]["over"] == "state.items"
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 dumped["steps"]["each_item"]["foreach"]["item_error"] == {
"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"]
+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]
assert dumped_node["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert dumped_node["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert dumped_node["output"][0]["source"] == {"root": "local", "parts": ["text"]}
assert dumped_node["output"][0]["target"] == {"root": "state", "parts": ["text"]}
assert dumped_node["input"][0]["path"] == "input.text"
assert dumped_node["input"][0]["target"] == "text"
assert dumped_node["output"][0]["source"] == "text"
assert dumped_node["output"][0]["target"] == "state.text"
assert "in_map" not in dumped_node
assert "input_values" 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]
assert foreach.model_dump(mode="json")["item_error"]["collect_to"] == {
"root": "state",
"parts": ["errors"],
}
assert foreach.model_dump(mode="json")["item_error"]["collect_to"] == "state.errors"
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")
assert dumped["op"] == "and"
assert dumped["args"][0]["left"]["path"] == {
"root": "state",
"parts": ["should_email"],
}
assert dumped["args"][0]["left"]["path"] == "state.should_email"
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:
@@ -31,16 +28,10 @@ def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None:
assert isinstance(comparison.right, PathOperand)
assert comparison.left.path == GraphSourcePath.state("score")
assert comparison.right.path == GraphSourcePath.state("threshold")
assert comparison.model_dump(mode="json")["left"]["path"] == {
"root": "state",
"parts": ["score"],
}
assert comparison.model_dump(mode="json")["left"]["path"] == "state.score"
assert isinstance(existence, ExistsCondition)
assert existence.path == GraphSourcePath.state("summary")
assert existence.model_dump(mode="json")["path"] == {
"root": "state",
"parts": ["summary"],
}
assert existence.model_dump(mode="json")["path"] == "state.summary"
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:
with pytest.raises(ValueError, match="TOML key expression"):
with pytest.raises(ValueError, match="TOML path"):
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.left, PathOperand)
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 "out_map" not in dumped
assert dumped["input"][0]["value"] == "fast"
assert dumped["input"][0]["target"] == {"root": "local", "parts": ["mode"]}
assert dumped["input"][1]["path"] == {"root": "input", "parts": ["message"]}
assert dumped["input"][1]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]}
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]}
assert dumped["input"][0]["target"] == "mode"
assert dumped["input"][1]["path"] == "input.message"
assert dumped["input"][1]["target"] == "message"
assert dumped["output"][0]["source"] == "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(
{
"id": "echo",
@@ -77,38 +77,14 @@ def test_node_use_serializes_canonical_binding_paths_as_structural_json():
python_dumped = node.model_dump()
json_dumped = node.model_dump(mode="json")
assert python_dumped["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert python_dumped["input"][0]["path"] == {
"root": "input",
"parts": ["message"],
}
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"],
}
assert python_dumped["input"][0]["target"] == "message"
assert python_dumped["input"][0]["path"] == "input.message"
assert python_dumped["output"][0]["source"] == "echoed"
assert python_dumped["output"][0]["target"] == "state.echoed"
assert json_dumped["input"][0]["target"] == "message"
assert json_dumped["input"][0]["path"] == "input.message"
assert json_dumped["output"][0]["source"] == "echoed"
assert json_dumped["output"][0]["target"] == "state.echoed"
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"]
assert dumped_input[0]["target"] == {"root": "local", "parts": ["first"]}
assert dumped_input[0]["target"] == "first"
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[2]["target"] == {"root": "local", "parts": ["third"]}
assert dumped_input[2]["path"] == {"root": "input", "parts": ["third"]}
assert dumped_input[3]["target"] == {"root": "local", "parts": ["fourth"]}
assert dumped_input[3]["path"] == {"root": "state", "parts": ["fourth"]}
assert dumped_input[2]["target"] == "third"
assert dumped_input[2]["path"] == "input.third"
assert dumped_input[3]["target"] == "fourth"
assert dumped_input[3]["path"] == "state.fourth"
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
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
@@ -279,10 +255,10 @@ def test_interrupt_node_converts_old_maps_to_canonical_bindings():
dumped = node.model_dump(mode="json")
assert "request_map" not in dumped
assert "out_map" not in dumped
assert dumped["request"][0]["path"] == {"root": "input", "parts": ["message"]}
assert dumped["request"][0]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["resume"][0]["source"] == {"root": "local", "parts": ["approved"]}
assert dumped["resume"][0]["target"] == {"root": "state", "parts": ["approved"]}
assert dumped["request"][0]["path"] == "input.message"
assert dumped["request"][0]["target"] == "message"
assert dumped["resume"][0]["source"] == "approved"
assert dumped["resume"][0]["target"] == "state.approved"
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.model_dump(mode="json")["over"] == {
"root": "state",
"parts": ["items"],
}
assert node.model_dump(mode="json")["over"] == "state.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(mode="json")["path"] == {
"root": "state",
"parts": ["person", "name"],
}
assert field.model_dump()["path"] == "state.person.name"
assert field.model_dump(mode="json")["path"] == "state.person.name"
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):
source: GraphSourcePath
target: StatePath
@@ -180,17 +180,17 @@ def test_pydantic_accepts_path_strings_and_serializes_structural_json() -> None:
assert payload.local == LocalPath.of("user")
dumped = payload.model_dump(mode="json")
assert dumped["source"] == {"root": "input", "parts": ["user"]}
assert dumped["target"] == {"root": "state", "parts": ["person"]}
assert dumped["local"] == {"root": "local", "parts": ["user"]}
assert dumped["source"] == "input.user"
assert dumped["target"] == "state.person"
assert dumped["local"] == "user"
python_dumped = payload.model_dump()
assert python_dumped["source"] == {"root": "input", "parts": ["user"]}
assert python_dumped["target"] == {"root": "state", "parts": ["person"]}
assert python_dumped["local"] == {"root": "local", "parts": ["user"]}
assert python_dumped["source"] == "input.user"
assert python_dumped["target"] == "state.person"
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):
source: GraphSourcePath
target: StatePath
@@ -198,24 +198,16 @@ def test_path_json_schema_advertises_structural_shape() -> None:
schema = Payload.model_json_schema()
assert schema["properties"]["source"]["type"] == "object"
assert schema["properties"]["source"]["properties"]["root"]["enum"] == [
"context",
"input",
"state",
]
assert schema["properties"]["target"]["properties"]["root"]["const"] == "state"
assert schema["properties"]["local"]["properties"]["root"]["const"] == "local"
assert schema["properties"]["source"]["type"] == "string"
assert schema["properties"]["target"]["type"] == "string"
assert schema["properties"]["local"]["type"] == "string"
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"})
assert operand.model_dump()["path"] == {"root": "state", "parts": ["x"]}
assert operand.model_dump(mode="json")["path"] == {
"root": "state",
"parts": ["x"],
}
assert operand.model_dump()["path"] == "state.x"
assert operand.model_dump(mode="json")["path"] == "state.x"
def test_pydantic_accepts_existing_path_objects() -> None:
@@ -279,3 +271,87 @@ def test_path_parts_overlap_detects_equality_and_ancestry(
expected: bool,
) -> None:
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 "input_values" not in node
assert "out_map" not in node
assert node["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert node["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert node["input"][0]["path"] == "input.text"
assert node["input"][0]["target"] == "text"
assert node["input"][1]["value"] == "raw:"
assert node["output"][0]["source"] == {"root": "local", "parts": ["message"]}
assert node["output"][0]["target"] == {"root": "state", "parts": ["message"]}
assert node["output"][0]["source"] == "message"
assert node["output"][0]["target"] == "state.message"
assert message_schema["type"] == "string"
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["concurrent"]["max_active"] == 2
assert foreach["item_error"]["action"] == "collect"
assert foreach["item_error"]["collect_to"] == {
"root": "state",
"parts": ["errors"],
}
assert foreach["item_error"]["collect_to"] == "state.errors"
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"]["steps"]["echo"]["input"] == [
{
"target": {"root": "local", "parts": ["message"]},
"path": {"root": "input", "parts": ["text"]},
"target": "message",
"path": "input.text",
}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
"source": "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 fetched["draft"]["steps"]["echo"]["input"] == [
{
"target": {"root": "local", "parts": ["final"]},
"path": {"root": "input", "parts": ["final"]},
"target": "final",
"path": "input.final",
}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
"source": "echoed",
"target": "state.echoed",
},
{
"source": {"root": "local", "parts": ["extra"]},
"target": {"root": "state", "parts": ["extra"]},
"source": "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"] == [
{
"source": {"root": "local", "parts": ["before"]},
"target": {"root": "state", "parts": ["before"]},
"source": "before",
"target": "state.before",
},
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]},
"source": "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["steps"]["snap"]["output"] == [
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]},
"source": "after",
"target": "state.after",
}
]
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"]["input"] == [
{
"target": {"root": "local", "parts": ["message"]},
"target": "message",
"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"] == [
{
"target": {"root": "local", "parts": ["message"]},
"path": {"root": "state", "parts": ["error_message"]},
"target": "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 workspace.draft["steps"]["call"]["input"] == [
{
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
"target": "text",
"path": "input.text",
}
]
assert workspace.draft["steps"]["call"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
"source": "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"]["input"] == [
{
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
"target": "text",
"path": "input.text",
}
]
assert workspace.draft["steps"]["call"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
"source": "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["steps"]["call"]["input"] == [
{
"target": {"root": "local", "parts": ["value"]},
"path": {"root": "input", "parts": ["value"]},
"target": "value",
"path": "input.value",
},
{
"target": {"root": "local", "parts": ["extra"]},
"path": {"root": "input", "parts": ["extra"]},
"target": "extra",
"path": "input.extra",
},
]
assert draft["steps"]["call"]["output"] == [
{
"source": {"root": "local", "parts": ["value"]},
"target": {"root": "state", "parts": ["extra_value"]},
"source": "value",
"target": "state.extra_value",
},
{
"source": {"root": "local", "parts": ["extra"]},
"target": {"root": "state", "parts": ["extra"]},
"source": "extra",
"target": "state.extra",
},
]
assert (