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 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
View File
@@ -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)
+11
View File
@@ -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."""
+24
View File
@@ -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"