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
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user