markov-protocols 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markov_protocols/__init__.py +109 -0
- markov_protocols/base.py +23 -0
- markov_protocols/conditions/__init__.py +44 -0
- markov_protocols/conditions/evaluate.py +129 -0
- markov_protocols/conditions/models.py +111 -0
- markov_protocols/definition/__init__.py +1 -0
- markov_protocols/definition/metadata.py +30 -0
- markov_protocols/definition/registry.py +54 -0
- markov_protocols/definition/state.py +113 -0
- markov_protocols/definition/states/__init__.py +9 -0
- markov_protocols/definition/states/action_execute.py +48 -0
- markov_protocols/definition/states/data_collection.py +27 -0
- markov_protocols/definition/states/human_handoff.py +46 -0
- markov_protocols/definition/transition.py +23 -0
- markov_protocols/definition/workflow.py +101 -0
- markov_protocols/py.typed +0 -0
- markov_protocols/references.py +96 -0
- markov_protocols/result.py +76 -0
- markov_protocols/runtime/__init__.py +1 -0
- markov_protocols/runtime/blackboard.py +55 -0
- markov_protocols/runtime/events.py +73 -0
- markov_protocols/runtime/session.py +325 -0
- markov_protocols/serialization/__init__.py +83 -0
- markov_protocols/serialization/converter.py +78 -0
- markov_protocols/serialization/document.py +70 -0
- markov_protocols/slug.py +21 -0
- markov_protocols-0.1.0.dist-info/METADATA +76 -0
- markov_protocols-0.1.0.dist-info/RECORD +30 -0
- markov_protocols-0.1.0.dist-info/WHEEL +4 -0
- markov_protocols-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""markov-protocols: a deterministic finite state machine for modeling
|
|
2
|
+
workflows that guide AI agents.
|
|
3
|
+
|
|
4
|
+
Author with ``Workflow`` + the concrete state types, express logic with
|
|
5
|
+
``Condition`` + ``Ref``, and handle outcomes with ``Result``. The runtime
|
|
6
|
+
(``Session``) arrives in a later slice.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .conditions import (
|
|
10
|
+
All,
|
|
11
|
+
Any,
|
|
12
|
+
Condition,
|
|
13
|
+
Eq,
|
|
14
|
+
Exists,
|
|
15
|
+
Gt,
|
|
16
|
+
Gte,
|
|
17
|
+
In,
|
|
18
|
+
Lt,
|
|
19
|
+
Lte,
|
|
20
|
+
Ne,
|
|
21
|
+
Not,
|
|
22
|
+
Regex,
|
|
23
|
+
condition_fields,
|
|
24
|
+
evaluate,
|
|
25
|
+
)
|
|
26
|
+
from .definition.metadata import StateMetadata, TransitionMetadata
|
|
27
|
+
from .definition.registry import state_registry
|
|
28
|
+
from .definition.state import Directive, Requirement, State, Status
|
|
29
|
+
from .definition.states import ActionExecuteState, DataCollectionState, HumanHandoffState
|
|
30
|
+
from .definition.transition import Transition
|
|
31
|
+
from .definition.workflow import Workflow
|
|
32
|
+
from .references import Ref, referenced_fields, resolve
|
|
33
|
+
from .result import ErrorType, Result
|
|
34
|
+
from .runtime.blackboard import Blackboard
|
|
35
|
+
from .runtime.events import (
|
|
36
|
+
ActionExecuted,
|
|
37
|
+
Event,
|
|
38
|
+
StateCompleted,
|
|
39
|
+
StateReopened,
|
|
40
|
+
TransitionTaken,
|
|
41
|
+
ValueChanged,
|
|
42
|
+
ValueSet,
|
|
43
|
+
)
|
|
44
|
+
from .runtime.session import Session, UpdateResult
|
|
45
|
+
from .serialization import (
|
|
46
|
+
export_to_dict,
|
|
47
|
+
export_to_file,
|
|
48
|
+
from_json,
|
|
49
|
+
from_yaml,
|
|
50
|
+
import_from_dict,
|
|
51
|
+
import_from_file,
|
|
52
|
+
to_json,
|
|
53
|
+
to_yaml,
|
|
54
|
+
)
|
|
55
|
+
from .slug import slugify
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"ActionExecuteState",
|
|
59
|
+
"ActionExecuted",
|
|
60
|
+
"All",
|
|
61
|
+
"Any",
|
|
62
|
+
"Blackboard",
|
|
63
|
+
"Condition",
|
|
64
|
+
"DataCollectionState",
|
|
65
|
+
"Directive",
|
|
66
|
+
"Eq",
|
|
67
|
+
"ErrorType",
|
|
68
|
+
"Event",
|
|
69
|
+
"Exists",
|
|
70
|
+
"Gt",
|
|
71
|
+
"Gte",
|
|
72
|
+
"HumanHandoffState",
|
|
73
|
+
"In",
|
|
74
|
+
"Lt",
|
|
75
|
+
"Lte",
|
|
76
|
+
"Ne",
|
|
77
|
+
"Not",
|
|
78
|
+
"Ref",
|
|
79
|
+
"Regex",
|
|
80
|
+
"Requirement",
|
|
81
|
+
"Result",
|
|
82
|
+
"Session",
|
|
83
|
+
"State",
|
|
84
|
+
"StateCompleted",
|
|
85
|
+
"StateMetadata",
|
|
86
|
+
"StateReopened",
|
|
87
|
+
"Status",
|
|
88
|
+
"Transition",
|
|
89
|
+
"TransitionMetadata",
|
|
90
|
+
"TransitionTaken",
|
|
91
|
+
"UpdateResult",
|
|
92
|
+
"ValueChanged",
|
|
93
|
+
"ValueSet",
|
|
94
|
+
"Workflow",
|
|
95
|
+
"condition_fields",
|
|
96
|
+
"evaluate",
|
|
97
|
+
"export_to_dict",
|
|
98
|
+
"export_to_file",
|
|
99
|
+
"from_json",
|
|
100
|
+
"from_yaml",
|
|
101
|
+
"import_from_dict",
|
|
102
|
+
"import_from_file",
|
|
103
|
+
"referenced_fields",
|
|
104
|
+
"resolve",
|
|
105
|
+
"slugify",
|
|
106
|
+
"state_registry",
|
|
107
|
+
"to_json",
|
|
108
|
+
"to_yaml",
|
|
109
|
+
]
|
markov_protocols/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared Pydantic bases used across every layer.
|
|
2
|
+
|
|
3
|
+
``StrictModel`` is the default: it rejects unknown fields and revalidates on
|
|
4
|
+
assignment, so a malformed definition can never silently exist. ``StrictOpenModel``
|
|
5
|
+
relaxes only the "unknown fields" rule — used for metadata, where we validate the
|
|
6
|
+
*shape* we rely on but let business policy attach anything else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StrictModel(BaseModel):
|
|
15
|
+
"""Reject unknown fields; validate on every assignment."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StrictOpenModel(BaseModel):
|
|
21
|
+
"""Validate declared fields, but allow extra ones (extensible metadata)."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="allow", validate_assignment=True)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Conditions: a small, closed, typed boolean vocabulary over the Blackboard.
|
|
2
|
+
|
|
3
|
+
A ``Condition`` reads collected data and returns a ``bool`` — the only way the
|
|
4
|
+
machine asks a yes/no question. The vocabulary is deliberately closed (not an open
|
|
5
|
+
expression language): it validates at authoring time, serializes to JSON, and is
|
|
6
|
+
safe to evaluate with no ``eval``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .evaluate import condition_fields, evaluate
|
|
12
|
+
from .models import (
|
|
13
|
+
All,
|
|
14
|
+
Any,
|
|
15
|
+
Condition,
|
|
16
|
+
Eq,
|
|
17
|
+
Exists,
|
|
18
|
+
Gt,
|
|
19
|
+
Gte,
|
|
20
|
+
In,
|
|
21
|
+
Lt,
|
|
22
|
+
Lte,
|
|
23
|
+
Ne,
|
|
24
|
+
Not,
|
|
25
|
+
Regex,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"All",
|
|
30
|
+
"Any",
|
|
31
|
+
"Condition",
|
|
32
|
+
"Eq",
|
|
33
|
+
"Exists",
|
|
34
|
+
"Gt",
|
|
35
|
+
"Gte",
|
|
36
|
+
"In",
|
|
37
|
+
"Lt",
|
|
38
|
+
"Lte",
|
|
39
|
+
"Ne",
|
|
40
|
+
"Not",
|
|
41
|
+
"Regex",
|
|
42
|
+
"condition_fields",
|
|
43
|
+
"evaluate",
|
|
44
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Pure evaluation of a ``Condition`` against collected data.
|
|
2
|
+
|
|
3
|
+
The evaluator is a dispatch table: ``op`` → a tiny pure function. Two invariants
|
|
4
|
+
hold for every leaf:
|
|
5
|
+
|
|
6
|
+
* A condition over a **missing** field is ``False`` — never an error. Absent data
|
|
7
|
+
can never assert anything, so a workflow can't be steered by data it doesn't have.
|
|
8
|
+
* Evaluation **never raises** on type mismatches (e.g. comparing a string to a
|
|
9
|
+
number) — it returns ``False``. Determinism must not depend on data being clean.
|
|
10
|
+
|
|
11
|
+
Only ``Not`` inverts, so ``Not(Exists("x"))`` is ``True`` when ``x`` is absent — the
|
|
12
|
+
one intentional way to assert on absence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .models import (
|
|
22
|
+
All,
|
|
23
|
+
Condition,
|
|
24
|
+
Eq,
|
|
25
|
+
Exists,
|
|
26
|
+
Gt,
|
|
27
|
+
Gte,
|
|
28
|
+
In,
|
|
29
|
+
Lt,
|
|
30
|
+
Lte,
|
|
31
|
+
Ne,
|
|
32
|
+
Not,
|
|
33
|
+
Regex,
|
|
34
|
+
)
|
|
35
|
+
from .models import (
|
|
36
|
+
Any as AnyCond,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_MISSING = object()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def evaluate(condition: Condition, data: Mapping[str, Any]) -> bool:
|
|
43
|
+
"""Return whether ``condition`` holds for ``data``."""
|
|
44
|
+
return _DISPATCH[type(condition)](condition, data)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def condition_fields(condition: Condition) -> set[str]:
|
|
48
|
+
"""Every Blackboard field name a condition tree references.
|
|
49
|
+
|
|
50
|
+
Used to report which data a state is still waiting on: the absent members of
|
|
51
|
+
this set are its missing fields.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(condition, All | AnyCond):
|
|
54
|
+
if not condition.of:
|
|
55
|
+
return set()
|
|
56
|
+
return set().union(*(condition_fields(sub) for sub in condition.of))
|
|
57
|
+
if isinstance(condition, Not):
|
|
58
|
+
return condition_fields(condition.of)
|
|
59
|
+
return {condition.field}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get(data: Mapping[str, Any], field: str) -> Any:
|
|
63
|
+
return data.get(field, _MISSING)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _exists(c: Exists, data: Mapping[str, Any]) -> bool:
|
|
67
|
+
value = _get(data, c.field)
|
|
68
|
+
return value is not _MISSING and value is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _eq(c: Eq, data: Mapping[str, Any]) -> bool:
|
|
72
|
+
value = _get(data, c.field)
|
|
73
|
+
return value is not _MISSING and value == c.value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ne(c: Ne, data: Mapping[str, Any]) -> bool:
|
|
77
|
+
value = _get(data, c.field)
|
|
78
|
+
return value is not _MISSING and value != c.value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _in(c: In, data: Mapping[str, Any]) -> bool:
|
|
82
|
+
value = _get(data, c.field)
|
|
83
|
+
return value is not _MISSING and value in c.value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _regex(c: Regex, data: Mapping[str, Any]) -> bool:
|
|
87
|
+
value = _get(data, c.field)
|
|
88
|
+
return isinstance(value, str) and re.search(c.value, value) is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compare(op: Callable[[Any, Any], bool]) -> Callable[[Any, Mapping[str, Any]], bool]:
|
|
92
|
+
def run(c: Any, data: Mapping[str, Any]) -> bool:
|
|
93
|
+
value = _get(data, c.field)
|
|
94
|
+
if value is _MISSING:
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
return op(value, c.value)
|
|
98
|
+
except TypeError:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
return run
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _all(c: All, data: Mapping[str, Any]) -> bool:
|
|
105
|
+
return all(evaluate(sub, data) for sub in c.of)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _any(c: AnyCond, data: Mapping[str, Any]) -> bool:
|
|
109
|
+
return any(evaluate(sub, data) for sub in c.of)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _not(c: Not, data: Mapping[str, Any]) -> bool:
|
|
113
|
+
return not evaluate(c.of, data)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_DISPATCH: dict[type, Callable[[Any, Mapping[str, Any]], bool]] = {
|
|
117
|
+
Exists: _exists,
|
|
118
|
+
Eq: _eq,
|
|
119
|
+
Ne: _ne,
|
|
120
|
+
In: _in,
|
|
121
|
+
Regex: _regex,
|
|
122
|
+
Gt: _compare(lambda a, b: a > b),
|
|
123
|
+
Gte: _compare(lambda a, b: a >= b),
|
|
124
|
+
Lt: _compare(lambda a, b: a < b),
|
|
125
|
+
Lte: _compare(lambda a, b: a <= b),
|
|
126
|
+
All: _all,
|
|
127
|
+
AnyCond: _any,
|
|
128
|
+
Not: _not,
|
|
129
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The ``Condition`` discriminated union.
|
|
2
|
+
|
|
3
|
+
Each condition is a small immutable value object tagged by ``op``. Leaf conditions
|
|
4
|
+
test one field; combinators (``all``/``any``/``not``) nest other conditions. The
|
|
5
|
+
union is discriminated on ``op`` so it round-trips cleanly through JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
from typing import Any as AnyType
|
|
12
|
+
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from ..base import StrictModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Exists(StrictModel):
|
|
19
|
+
"""True when ``field`` is present and not null."""
|
|
20
|
+
|
|
21
|
+
op: Literal["exists"] = "exists"
|
|
22
|
+
field: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Eq(StrictModel):
|
|
26
|
+
"""True when ``field`` exists and equals ``value``."""
|
|
27
|
+
|
|
28
|
+
op: Literal["eq"] = "eq"
|
|
29
|
+
field: str
|
|
30
|
+
value: AnyType
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Ne(StrictModel):
|
|
34
|
+
"""True when ``field`` exists and does not equal ``value`` (missing → False)."""
|
|
35
|
+
|
|
36
|
+
op: Literal["ne"] = "ne"
|
|
37
|
+
field: str
|
|
38
|
+
value: AnyType
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class In(StrictModel):
|
|
42
|
+
"""True when ``field`` exists and its value is one of ``value``."""
|
|
43
|
+
|
|
44
|
+
op: Literal["in"] = "in"
|
|
45
|
+
field: str
|
|
46
|
+
value: list[AnyType]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Regex(StrictModel):
|
|
50
|
+
"""True when ``field`` is a string that ``value`` (a regex) finds a match in."""
|
|
51
|
+
|
|
52
|
+
op: Literal["regex"] = "regex"
|
|
53
|
+
field: str
|
|
54
|
+
value: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Gt(StrictModel):
|
|
58
|
+
op: Literal["gt"] = "gt"
|
|
59
|
+
field: str
|
|
60
|
+
value: AnyType
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Gte(StrictModel):
|
|
64
|
+
op: Literal["gte"] = "gte"
|
|
65
|
+
field: str
|
|
66
|
+
value: AnyType
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Lt(StrictModel):
|
|
70
|
+
op: Literal["lt"] = "lt"
|
|
71
|
+
field: str
|
|
72
|
+
value: AnyType
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Lte(StrictModel):
|
|
76
|
+
op: Literal["lte"] = "lte"
|
|
77
|
+
field: str
|
|
78
|
+
value: AnyType
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class All(StrictModel):
|
|
82
|
+
"""True when every nested condition is true (vacuously true when empty)."""
|
|
83
|
+
|
|
84
|
+
op: Literal["all"] = "all"
|
|
85
|
+
of: list[Condition]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Any(StrictModel):
|
|
89
|
+
"""True when at least one nested condition is true (false when empty)."""
|
|
90
|
+
|
|
91
|
+
op: Literal["any"] = "any"
|
|
92
|
+
of: list[Condition]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Not(StrictModel):
|
|
96
|
+
"""The negation of a nested condition."""
|
|
97
|
+
|
|
98
|
+
op: Literal["not"] = "not"
|
|
99
|
+
of: Condition
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
Condition = Annotated[
|
|
103
|
+
Exists | Eq | Ne | In | Regex | Gt | Gte | Lt | Lte | All | Any | Not,
|
|
104
|
+
Field(discriminator="op"),
|
|
105
|
+
]
|
|
106
|
+
"""Any boolean rule over the Blackboard."""
|
|
107
|
+
|
|
108
|
+
# Resolve the forward references inside the recursive combinators.
|
|
109
|
+
All.model_rebuild()
|
|
110
|
+
Any.model_rebuild()
|
|
111
|
+
Not.model_rebuild()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The definition layer: the immutable, serializable models a user authors."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""State and transition metadata: typed-but-open policy carriers.
|
|
2
|
+
|
|
3
|
+
The reserved sections are the shape the machine relies on being *present*; their
|
|
4
|
+
contents are opaque business policy the machine never interprets. ``extra="allow"``
|
|
5
|
+
(via ``StrictOpenModel``) lets authors attach anything beyond the reserved keys.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from ..base import StrictOpenModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StateMetadata(StrictOpenModel):
|
|
18
|
+
"""Policy attached to a state. Contents are opaque to the machine."""
|
|
19
|
+
|
|
20
|
+
tools: dict[str, Any] = Field(default_factory=dict)
|
|
21
|
+
prompts: dict[str, Any] = Field(default_factory=dict)
|
|
22
|
+
guardrails: dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransitionMetadata(StrictOpenModel):
|
|
26
|
+
"""Policy attached to a transition. Contents are opaque to the machine."""
|
|
27
|
+
|
|
28
|
+
events: dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
resources: dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
reward: float = Field(default=1.0, ge=0.0, description="Transition reward (experimental)")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The state-type registry — how the definition layer stays open for extension.
|
|
2
|
+
|
|
3
|
+
Concrete state types register their ``type`` tag here. That lets a workflow (with
|
|
4
|
+
any mix of built-in or third-party states) round-trip through JSON: on load, each
|
|
5
|
+
state dict is dispatched to the class its ``type`` names. The engine itself never
|
|
6
|
+
consults this — it only ever talks to the ``State`` interface.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .state import State
|
|
14
|
+
from .states import ActionExecuteState, DataCollectionState, HumanHandoffState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StateRegistry:
|
|
18
|
+
"""Maps a state ``type`` tag to the class that implements it."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._by_type: dict[str, type[State]] = {}
|
|
22
|
+
|
|
23
|
+
def register(self, state_type: type[State]) -> None:
|
|
24
|
+
"""Register a concrete state class. Rejects duplicate ``type`` tags."""
|
|
25
|
+
tag = _type_tag(state_type)
|
|
26
|
+
existing = self._by_type.get(tag)
|
|
27
|
+
if existing is not None and existing is not state_type:
|
|
28
|
+
raise ValueError(f"state type {tag!r} is already registered to {existing.__name__}")
|
|
29
|
+
self._by_type[tag] = state_type
|
|
30
|
+
|
|
31
|
+
def parse(self, value: Any) -> State:
|
|
32
|
+
"""Build a concrete ``State`` from an instance or its serialized dict form."""
|
|
33
|
+
if isinstance(value, State):
|
|
34
|
+
return value
|
|
35
|
+
if not isinstance(value, dict) or "type" not in value:
|
|
36
|
+
raise ValueError("cannot parse state: expected a State or a dict with a 'type' key")
|
|
37
|
+
tag = value["type"]
|
|
38
|
+
cls = self._by_type.get(tag)
|
|
39
|
+
if cls is None:
|
|
40
|
+
raise ValueError(f"unknown state type: {tag!r}")
|
|
41
|
+
return cls.model_validate(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _type_tag(state_type: type[State]) -> str:
|
|
45
|
+
field = state_type.model_fields.get("type")
|
|
46
|
+
if field is None or field.default is None:
|
|
47
|
+
raise ValueError(f"{state_type.__name__} must declare a literal 'type' field")
|
|
48
|
+
return str(field.default)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
state_registry = StateRegistry()
|
|
52
|
+
state_registry.register(DataCollectionState)
|
|
53
|
+
state_registry.register(ActionExecuteState)
|
|
54
|
+
state_registry.register(HumanHandoffState)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The ``State`` interface — the extension seam of the whole engine.
|
|
2
|
+
|
|
3
|
+
Every step in a workflow is a ``State``. The engine never branches on a state's
|
|
4
|
+
concrete type; it only ever asks two questions through this interface:
|
|
5
|
+
|
|
6
|
+
* ``completion_condition()`` — the ``Condition`` that, when true over the
|
|
7
|
+
Blackboard, means this step is done.
|
|
8
|
+
* ``depends_on()`` — the Blackboard fields whose change should reopen this step.
|
|
9
|
+
|
|
10
|
+
Adding a new kind of state (here or in a downstream project) means implementing
|
|
11
|
+
these two methods and registering the type — with zero changes to the engine.
|
|
12
|
+
|
|
13
|
+
Note the two levels: a ``Requirement`` is an authoring input (a needed field with
|
|
14
|
+
an optional extra rule); ``completion_condition()`` compiles those into the single
|
|
15
|
+
evaluable ``Condition`` the engine checks.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pydantic import Field
|
|
26
|
+
|
|
27
|
+
from ..base import StrictModel
|
|
28
|
+
from ..conditions.models import All, Condition, Exists
|
|
29
|
+
from ..slug import slugify
|
|
30
|
+
from .metadata import StateMetadata
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Status(StrEnum):
|
|
34
|
+
"""A state's lifecycle within one running session."""
|
|
35
|
+
|
|
36
|
+
PENDING = "PENDING" # not yet reached
|
|
37
|
+
ACTIVE = "ACTIVE" # the current step
|
|
38
|
+
COMPLETED = "COMPLETED" # its completion condition held
|
|
39
|
+
REOPENED = "REOPENED" # was completed, then data it depends on changed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Directive(StrictModel):
|
|
43
|
+
"""The host-actionable instruction a state surfaces while it is active.
|
|
44
|
+
|
|
45
|
+
The machine never acts; it hands the host a fully-resolved ``payload`` to
|
|
46
|
+
execute. ``ready`` is false when a referenced field is still missing, and
|
|
47
|
+
``missing`` names those fields — so a side effect can't fire half-blank.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
kind: str
|
|
51
|
+
ready: bool
|
|
52
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
missing: list[str] = Field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Requirement(StrictModel):
|
|
57
|
+
"""A field a data-collection state needs, with an optional extra condition."""
|
|
58
|
+
|
|
59
|
+
field: str
|
|
60
|
+
condition: Condition | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class State(StrictModel, ABC):
|
|
64
|
+
"""A single step. ``id`` is derived from ``title`` and is never stored."""
|
|
65
|
+
|
|
66
|
+
title: str
|
|
67
|
+
metadata: StateMetadata = StateMetadata()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def id(self) -> str:
|
|
71
|
+
"""The deterministic identifier: the slug of the title."""
|
|
72
|
+
return slugify(self.title)
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def completion_condition(self) -> Condition:
|
|
76
|
+
"""The condition that, when true over the Blackboard, marks this state done."""
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def depends_on(self) -> set[str]:
|
|
80
|
+
"""Blackboard fields whose change should reopen this state."""
|
|
81
|
+
|
|
82
|
+
def produces(self) -> set[str]:
|
|
83
|
+
"""Blackboard fields this state *outputs* when it completes.
|
|
84
|
+
|
|
85
|
+
Empty by default (data-collection states produce nothing — the user
|
|
86
|
+
supplies the data). Action/handoff states return their result field.
|
|
87
|
+
When a state is reopened these fields are cleared, so an action that had
|
|
88
|
+
already run is forced to run again with the corrected inputs.
|
|
89
|
+
"""
|
|
90
|
+
return set()
|
|
91
|
+
|
|
92
|
+
def directive(self, data: Mapping[str, Any]) -> Directive | None:
|
|
93
|
+
"""The side effect the host must run while this state is active.
|
|
94
|
+
|
|
95
|
+
Defaults to ``None`` — states that only wait on the user (e.g. data
|
|
96
|
+
collection) surface no host action. Action/handoff states override this.
|
|
97
|
+
"""
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def all_of(conditions: list[Condition]) -> Condition:
|
|
102
|
+
"""Combine conditions with AND. A lone condition is returned unwrapped."""
|
|
103
|
+
if len(conditions) == 1:
|
|
104
|
+
return conditions[0]
|
|
105
|
+
return All(of=conditions)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def requirement_condition(requirement: Requirement) -> Condition:
|
|
109
|
+
"""A requirement holds when its field exists and its condition (if any) holds."""
|
|
110
|
+
exists: Condition = Exists(field=requirement.field)
|
|
111
|
+
if requirement.condition is None:
|
|
112
|
+
return exists
|
|
113
|
+
return All(of=[exists, requirement.condition])
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Concrete state types — one building piece per file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .action_execute import ActionExecuteState
|
|
6
|
+
from .data_collection import DataCollectionState
|
|
7
|
+
from .human_handoff import HumanHandoffState
|
|
8
|
+
|
|
9
|
+
__all__ = ["ActionExecuteState", "DataCollectionState", "HumanHandoffState"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""A state that represents the host running an action and reporting its result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, field_validator
|
|
9
|
+
|
|
10
|
+
from ...conditions.models import Condition, Exists
|
|
11
|
+
from ...references import normalize_refs, referenced_fields, resolve
|
|
12
|
+
from ..state import Directive, State
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ActionExecuteState(State):
|
|
16
|
+
"""Completes when the host writes the action's outcome to ``result_field``.
|
|
17
|
+
|
|
18
|
+
The machine never runs the action. It surfaces the ``payload`` (with any
|
|
19
|
+
``Ref`` resolved) for the host to execute, then waits for the result to land.
|
|
20
|
+
Because the payload *consumes* the fields it references, changing any of them
|
|
21
|
+
later reopens this state.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
type: Literal["ACTION_EXECUTE"] = "ACTION_EXECUTE"
|
|
25
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
result_field: str
|
|
27
|
+
|
|
28
|
+
@field_validator("payload", mode="after")
|
|
29
|
+
@classmethod
|
|
30
|
+
def _normalize_payload(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
+
return normalize_refs(value)
|
|
32
|
+
|
|
33
|
+
def completion_condition(self) -> Condition:
|
|
34
|
+
return Exists(field=self.result_field)
|
|
35
|
+
|
|
36
|
+
def depends_on(self) -> set[str]:
|
|
37
|
+
return referenced_fields(self.payload)
|
|
38
|
+
|
|
39
|
+
def produces(self) -> set[str]:
|
|
40
|
+
return {self.result_field}
|
|
41
|
+
|
|
42
|
+
def directive(self, data: Mapping[str, Any]) -> Directive:
|
|
43
|
+
resolved = resolve(self.payload, data)
|
|
44
|
+
if resolved.is_success:
|
|
45
|
+
# Resolving a dict payload always yields a dict on success.
|
|
46
|
+
payload = cast(dict[str, Any], resolved.value)
|
|
47
|
+
return Directive(kind="action", ready=True, payload=payload)
|
|
48
|
+
return Directive(kind="action", ready=False, missing=resolved.error_details or [])
|