feat: use canonical TOML path strings
This commit is contained in:
+12
-12
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user