nested state path: wf_authoring support
This commit is contained in:
@@ -154,6 +154,12 @@ fields = {
|
|||||||
Presentation layers may rebuild a tree for humans. Core should keep the simpler
|
Presentation layers may rebuild a tree for humans. Core should keep the simpler
|
||||||
path-keyed representation.
|
path-keyed representation.
|
||||||
|
|
||||||
|
`wf_authoring` keeps authored schemas nested for humans and LLM clients, but
|
||||||
|
projects nested authored state into this flat exact-path index. For example, a
|
||||||
|
Pydantic `person: Person` field may produce declarations for `person`,
|
||||||
|
`person.name`, and `person.tags` without forcing the author to spell those
|
||||||
|
paths manually.
|
||||||
|
|
||||||
### Exact-path ownership
|
### Exact-path ownership
|
||||||
|
|
||||||
Merge behavior belongs only to the exact declared state path being written.
|
Merge behavior belongs only to the exact declared state path being written.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Nested Authoring State Projection Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let `wf_authoring` project nested authored state schemas into the flat exact-path state-field index that `wf_core` now supports.
|
||||||
|
|
||||||
|
**Architecture:** Keep authored JSON Schema nested for users and LLM clients. Add a focused flattening helper for state-field projection only, emitting both parent object paths and descendant paths. Resolve nested `BaseModel` metadata by authored path where available, while leaving non-`BaseModel` authored types schema-capable with default merge metadata.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, Pydantic, pytest, existing `wf_authoring` schema adapter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/wf_authoring/schemas.py`
|
||||||
|
- flatten nested schema properties into exact-path `StateField`s
|
||||||
|
- gather nested `BaseModel` metadata by authored path
|
||||||
|
- Modify `tests/authoring/helpers.py`
|
||||||
|
- add nested state models used by tests
|
||||||
|
- Modify `tests/authoring/test_schemas.py`
|
||||||
|
- pin nested projection behavior and nested metadata
|
||||||
|
- Update `docs/core_state_mapping_and_merge.md`
|
||||||
|
- note that authoring now projects nested authored models into the flat core index
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Pin Nested Projection
|
||||||
|
|
||||||
|
- [ ] Add tests proving:
|
||||||
|
- nested authored state keeps parent and child declarations
|
||||||
|
- nested child metadata such as `append` lands on the exact child path
|
||||||
|
- parent object declaration remains independent from child declarations
|
||||||
|
- [ ] Run the focused authoring tests and confirm they fail under current top-level-only projection.
|
||||||
|
|
||||||
|
### Task 2: Implement Projection Helpers
|
||||||
|
|
||||||
|
- [ ] Add a schema-walking helper that yields `(path, property_schema)` for parent and descendant properties.
|
||||||
|
- [ ] Add nested `BaseModel` metadata traversal keyed by dotted path.
|
||||||
|
- [ ] Update `state_schema_from()` to build `StateField`s from the flattened path stream.
|
||||||
|
- [ ] Keep JSON Schema generation unchanged; flatten only the core `StateSchema.fields` index.
|
||||||
|
- [ ] Run the focused authoring tests and confirm they pass.
|
||||||
|
|
||||||
|
### Task 3: Document and Verify
|
||||||
|
|
||||||
|
- [ ] Update the core state mapping doc with the authoring projection rule.
|
||||||
|
- [ ] Run `uv run --with pytest pytest tests/authoring -q`
|
||||||
|
- [ ] Run `uv run --with pytest pytest -q`
|
||||||
|
- [ ] Run `uv run basedpyright --level error`
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- custom metadata support for every Pydantic-supported type form
|
||||||
|
- changing `SchemaRef` shape
|
||||||
|
- reducer registries
|
||||||
|
- automatic deep merge behavior
|
||||||
+63
-11
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Any, Iterator, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, TypeAdapter
|
from pydantic import BaseModel, TypeAdapter
|
||||||
|
|
||||||
@@ -49,15 +49,15 @@ def state_schema_from(value: StateSchemaLike) -> StateSchema:
|
|||||||
schema = schema_ref_from(value)
|
schema = schema_ref_from(value)
|
||||||
metadata_by_name = _state_metadata_by_name(value)
|
metadata_by_name = _state_metadata_by_name(value)
|
||||||
fields = {
|
fields = {
|
||||||
name: StateField(
|
path: StateField(
|
||||||
type=_state_field_type(property_schema),
|
type=_state_field_type(property_schema),
|
||||||
merge_strategy=metadata_by_name.get(
|
merge_strategy=metadata_by_name.get(
|
||||||
name, StateFieldMetadata()
|
path, StateFieldMetadata()
|
||||||
).merge_strategy,
|
).merge_strategy,
|
||||||
trace=metadata_by_name.get(name, StateFieldMetadata()).trace,
|
trace=metadata_by_name.get(path, StateFieldMetadata()).trace,
|
||||||
default=_state_field_default(value, name, property_schema),
|
default=_state_field_default(value, path, property_schema),
|
||||||
)
|
)
|
||||||
for name, property_schema in schema.properties.items()
|
for path, property_schema in _flatten_state_properties(schema)
|
||||||
}
|
}
|
||||||
return StateSchema(fields=fields)
|
return StateSchema(fields=fields)
|
||||||
|
|
||||||
@@ -66,13 +66,65 @@ def _state_metadata_by_name(value: object) -> dict[str, StateFieldMetadata]:
|
|||||||
if not isinstance(value, type) or not issubclass(value, BaseModel):
|
if not isinstance(value, type) or not issubclass(value, BaseModel):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
metadata: dict[str, StateFieldMetadata] = {}
|
return dict(_iter_model_metadata(value))
|
||||||
for name, field_info in value.model_fields.items():
|
|
||||||
|
|
||||||
|
def _iter_model_metadata(
|
||||||
|
model_type: type[BaseModel],
|
||||||
|
*,
|
||||||
|
prefix: str = "",
|
||||||
|
) -> Iterator[tuple[str, StateFieldMetadata]]:
|
||||||
|
for name, field_info in model_type.model_fields.items():
|
||||||
|
path = f"{prefix}.{name}" if prefix else name
|
||||||
for item in field_info.metadata:
|
for item in field_info.metadata:
|
||||||
if isinstance(item, StateFieldMetadata):
|
if isinstance(item, StateFieldMetadata):
|
||||||
metadata[name] = item
|
yield path, item
|
||||||
break
|
break
|
||||||
return metadata
|
annotation = field_info.annotation
|
||||||
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
|
yield from _iter_model_metadata(annotation, prefix=path)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_state_properties(schema: SchemaRef) -> Iterator[tuple[str, dict[str, Any]]]:
|
||||||
|
raw_schema = schema.model_dump(exclude_none=True)
|
||||||
|
yield from _iter_state_properties(raw_schema.get("properties", {}), raw_schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_state_properties(
|
||||||
|
properties: object,
|
||||||
|
root_schema: dict[str, Any],
|
||||||
|
*,
|
||||||
|
prefix: str = "",
|
||||||
|
) -> Iterator[tuple[str, dict[str, Any]]]:
|
||||||
|
if not isinstance(properties, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
for name, property_schema in properties.items():
|
||||||
|
if not isinstance(property_schema, dict):
|
||||||
|
continue
|
||||||
|
path = f"{prefix}.{name}" if prefix else name
|
||||||
|
resolved_schema = _resolve_property_schema(property_schema, root_schema)
|
||||||
|
yield path, resolved_schema
|
||||||
|
yield from _iter_state_properties(
|
||||||
|
resolved_schema.get("properties", {}),
|
||||||
|
root_schema,
|
||||||
|
prefix=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_property_schema(
|
||||||
|
property_schema: dict[str, Any],
|
||||||
|
root_schema: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ref = property_schema.get("$ref")
|
||||||
|
if not isinstance(ref, str) or not ref.startswith("#/$defs/"):
|
||||||
|
return property_schema
|
||||||
|
definition_name = ref.removeprefix("#/$defs/")
|
||||||
|
definitions = root_schema.get("$defs", {})
|
||||||
|
if not isinstance(definitions, dict):
|
||||||
|
return property_schema
|
||||||
|
resolved = definitions.get(definition_name)
|
||||||
|
return resolved if isinstance(resolved, dict) else property_schema
|
||||||
|
|
||||||
|
|
||||||
def _state_field_type(property_schema: object) -> str:
|
def _state_field_type(property_schema: object) -> str:
|
||||||
@@ -93,7 +145,7 @@ def _state_field_default(
|
|||||||
field_name: str,
|
field_name: str,
|
||||||
property_schema: object,
|
property_schema: object,
|
||||||
) -> object:
|
) -> object:
|
||||||
if isinstance(value, type) and issubclass(value, BaseModel):
|
if "." not in field_name and isinstance(value, type) and issubclass(value, BaseModel):
|
||||||
field_info = value.model_fields[field_name]
|
field_info = value.model_fields[field_name]
|
||||||
if not field_info.is_required():
|
if not field_info.is_required():
|
||||||
return field_info.get_default(call_default_factory=True)
|
return field_info.get_default(call_default_factory=True)
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ class DefaultedState(BaseModel):
|
|||||||
explicit: int = 3
|
explicit: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
class NestedPersonState(BaseModel):
|
||||||
|
name: str
|
||||||
|
tags: Annotated[list[str], state_field(merge_strategy="append")] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NestedWorkflowState(BaseModel):
|
||||||
|
person: NestedPersonState
|
||||||
|
|
||||||
|
|
||||||
@node(name="test.auto_bind")
|
@node(name="test.auto_bind")
|
||||||
def auto_bind_node(input: AutoBindInput) -> AutoBindOutput:
|
def auto_bind_node(input: AutoBindInput) -> AutoBindOutput:
|
||||||
"""Return updated fields using automatically mapped state input."""
|
"""Return updated fields using automatically mapped state input."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from wf_authoring import WorkflowBuilder
|
|||||||
from tests.authoring.helpers import (
|
from tests.authoring.helpers import (
|
||||||
AppendState,
|
AppendState,
|
||||||
DefaultedState,
|
DefaultedState,
|
||||||
|
NestedWorkflowState,
|
||||||
TypedDictInput,
|
TypedDictInput,
|
||||||
WorkflowInput,
|
WorkflowInput,
|
||||||
WorkflowOutput,
|
WorkflowOutput,
|
||||||
@@ -74,3 +75,26 @@ def test_state_basemodel_seeds_safe_initial_defaults() -> None:
|
|||||||
assert workflow.state_schema.fields["items"].default == []
|
assert workflow.state_schema.fields["items"].default == []
|
||||||
assert workflow.state_schema.fields["metadata"].default == {}
|
assert workflow.state_schema.fields["metadata"].default == {}
|
||||||
assert workflow.state_schema.fields["explicit"].default == 3
|
assert workflow.state_schema.fields["explicit"].default == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_state_basemodel_projects_parent_and_child_paths() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="nested_state_schema_demo",
|
||||||
|
input_schema=WorkflowInput,
|
||||||
|
state_schema=NestedWorkflowState,
|
||||||
|
output_schema=WorkflowOutput,
|
||||||
|
start="start",
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow = builder.compile()
|
||||||
|
|
||||||
|
assert set(workflow.state_schema.fields) == {
|
||||||
|
"person",
|
||||||
|
"person.name",
|
||||||
|
"person.tags",
|
||||||
|
}
|
||||||
|
assert workflow.state_schema.fields["person"].type == "object"
|
||||||
|
assert workflow.state_schema.fields["person.name"].type == "string"
|
||||||
|
assert workflow.state_schema.fields["person.tags"].type == "array"
|
||||||
|
assert workflow.state_schema.fields["person"].merge_strategy == "replace"
|
||||||
|
assert workflow.state_schema.fields["person.tags"].merge_strategy == "append"
|
||||||
|
|||||||
Reference in New Issue
Block a user