nested state path: wf_authoring support

This commit is contained in:
lda
2026-05-17 15:58:19 +07:00 Verified
parent 5476010f12
commit c5b43c7dbe
5 changed files with 159 additions and 11 deletions
+6
View File
@@ -154,6 +154,12 @@ fields = {
Presentation layers may rebuild a tree for humans. Core should keep the simpler
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
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
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from typing import Any, Iterator, Literal
from pydantic import BaseModel, TypeAdapter
@@ -49,15 +49,15 @@ def state_schema_from(value: StateSchemaLike) -> StateSchema:
schema = schema_ref_from(value)
metadata_by_name = _state_metadata_by_name(value)
fields = {
name: StateField(
path: StateField(
type=_state_field_type(property_schema),
merge_strategy=metadata_by_name.get(
name, StateFieldMetadata()
path, StateFieldMetadata()
).merge_strategy,
trace=metadata_by_name.get(name, StateFieldMetadata()).trace,
default=_state_field_default(value, name, property_schema),
trace=metadata_by_name.get(path, StateFieldMetadata()).trace,
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)
@@ -66,13 +66,65 @@ def _state_metadata_by_name(value: object) -> dict[str, StateFieldMetadata]:
if not isinstance(value, type) or not issubclass(value, BaseModel):
return {}
metadata: dict[str, StateFieldMetadata] = {}
for name, field_info in value.model_fields.items():
return dict(_iter_model_metadata(value))
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:
if isinstance(item, StateFieldMetadata):
metadata[name] = item
yield path, item
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:
@@ -93,7 +145,7 @@ def _state_field_default(
field_name: str,
property_schema: 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]
if not field_info.is_required():
return field_info.get_default(call_default_factory=True)
+11
View File
@@ -52,6 +52,17 @@ class DefaultedState(BaseModel):
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")
def auto_bind_node(input: AutoBindInput) -> AutoBindOutput:
"""Return updated fields using automatically mapped state input."""
+24
View File
@@ -5,6 +5,7 @@ from wf_authoring import WorkflowBuilder
from tests.authoring.helpers import (
AppendState,
DefaultedState,
NestedWorkflowState,
TypedDictInput,
WorkflowInput,
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["metadata"].default == {}
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"