feat: add interrupt schema contract fields

This commit is contained in:
lda
2026-07-01 05:50:40 +07:00 Verified
parent cdffb1d620
commit 9dff1d7305
2 changed files with 96 additions and 1 deletions
+40 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Annotated, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from jsonschema import Draft202012Validator, SchemaError, validators
from wf_core.models.conditions import Condition
from wf_core.models.schemas import SchemaRef
@@ -332,6 +334,31 @@ class EndNode(BaseModel):
outcome: str = Field(default="ok", min_length=1)
def _object_schema() -> dict[str, object]:
"""Default legacy interrupt contract: any JSON object payload is accepted."""
return {"type": "object", "additionalProperties": True}
def _validate_json_schema(value: object, *, field_name: str) -> dict[str, object]:
"""Validate one interrupt contract schema with jsonschema."""
if not isinstance(value, Mapping):
raise ValueError(f"{field_name} must be a JSON Schema object")
schema = dict(value)
validator_cls = (
validators.validator_for(schema)
if "$schema" in schema
else Draft202012Validator
)
try:
validator_cls.check_schema(schema)
except SchemaError as exc:
raise ValueError(f"invalid JSON Schema: {exc.message}") from exc
schema_type = schema.get("type")
if schema_type != "object":
raise ValueError(f"{field_name} must describe a JSON object")
return schema
class InterruptNode(BaseModel):
"""Control-flow step that pauses a run and waits for resume input."""
@@ -351,6 +378,15 @@ class InterruptNode(BaseModel):
),
)
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
request_schema: dict[str, object] = Field(default_factory=_object_schema)
resume_schema: dict[str, object] = Field(default_factory=_object_schema)
has_explicit_contract: bool = Field(default=False, exclude=True)
@field_validator("request_schema", "resume_schema", mode="before")
@classmethod
def _validate_interrupt_schema(cls, value: object, info: object) -> object:
field_name = getattr(info, "field_name", "interrupt schema")
return _validate_json_schema(value, field_name=field_name)
@model_validator(mode="before")
@classmethod
@@ -388,6 +424,9 @@ class InterruptNode(BaseModel):
normalized["request"] = request_bindings
normalized["resume"] = resume_bindings
normalized["has_explicit_contract"] = (
"request_schema" in normalized or "resume_schema" in normalized
)
return normalized
@@ -274,6 +274,62 @@ def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
)
def test_interrupt_node_defaults_to_untyped_object_contract():
node = InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
}
)
assert node.request_schema == {
"type": "object",
"additionalProperties": True,
}
assert node.resume_schema == {
"type": "object",
"additionalProperties": True,
}
assert node.has_explicit_contract is False
dumped = node.model_dump(mode="json")
assert dumped["request_schema"] == {
"type": "object",
"additionalProperties": True,
}
assert dumped["resume_schema"] == {
"type": "object",
"additionalProperties": True,
}
def test_interrupt_node_accepts_explicit_request_and_resume_schemas():
node = InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request_schema": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
"additionalProperties": False,
},
"resume_schema": {
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
"additionalProperties": False,
},
}
)
assert node.has_explicit_contract is True
assert node.request_schema["required"] == ["message"]
assert node.resume_schema["required"] == ["approved"]
def test_foreach_node_serializes_over_path_as_canonical_string():
node = ForeachNode.model_validate(
{