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