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.
@@ -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
+ ]
@@ -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 [])