statepilot 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.
- statepilot/__about__.py +5 -0
- statepilot/__init__.py +64 -0
- statepilot/adapters/__init__.py +13 -0
- statepilot/adapters/langgraph.py +81 -0
- statepilot/decorator.py +63 -0
- statepilot/exceptions.py +92 -0
- statepilot/machine.py +324 -0
- statepilot/pilot.py +297 -0
- statepilot/py.typed +0 -0
- statepilot-0.1.0.dist-info/METADATA +339 -0
- statepilot-0.1.0.dist-info/RECORD +13 -0
- statepilot-0.1.0.dist-info/WHEEL +4 -0
- statepilot-0.1.0.dist-info/licenses/LICENSE +21 -0
statepilot/__about__.py
ADDED
statepilot/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""statepilot — deterministic state-machine guards for AI-agent workflows.
|
|
2
|
+
|
|
3
|
+
Define a state machine, then enforce at *runtime* which tools an agent may call,
|
|
4
|
+
in which order, with loop detection, a cost budget, and a hard step cap.
|
|
5
|
+
|
|
6
|
+
Quickstart::
|
|
7
|
+
|
|
8
|
+
from statepilot import StateMachine, Pilot, guarded
|
|
9
|
+
|
|
10
|
+
machine = (
|
|
11
|
+
StateMachine.builder()
|
|
12
|
+
.initial("research")
|
|
13
|
+
.transition("research", "research", tool="search") # loop allowed...
|
|
14
|
+
.transition("research", "draft", tool="write_draft")
|
|
15
|
+
.transition("draft", "review", tool="review")
|
|
16
|
+
.transition("review", "published", tool="publish")
|
|
17
|
+
.terminal("published")
|
|
18
|
+
.build()
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
pilot = Pilot(machine, budget=5.0, max_state_visits=3)
|
|
22
|
+
|
|
23
|
+
@guarded(pilot, cost=1.0)
|
|
24
|
+
def search(q: str) -> str:
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
search("agents") # allowed, charges 1.0
|
|
28
|
+
pilot.step("write_draft") # advance to draft
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from .__about__ import __version__
|
|
34
|
+
from .decorator import guarded
|
|
35
|
+
from .exceptions import (
|
|
36
|
+
BudgetExceeded,
|
|
37
|
+
GuardViolation,
|
|
38
|
+
LoopLimitExceeded,
|
|
39
|
+
StateMachineError,
|
|
40
|
+
StatepilotError,
|
|
41
|
+
StepLimitExceeded,
|
|
42
|
+
TransitionError,
|
|
43
|
+
)
|
|
44
|
+
from .machine import StateMachine, StateMachineBuilder, Transition
|
|
45
|
+
from .pilot import Pilot, StepRecord
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
# core
|
|
50
|
+
"Pilot",
|
|
51
|
+
"StateMachine",
|
|
52
|
+
"StateMachineBuilder",
|
|
53
|
+
"StepRecord",
|
|
54
|
+
"Transition",
|
|
55
|
+
"guarded",
|
|
56
|
+
# exceptions
|
|
57
|
+
"BudgetExceeded",
|
|
58
|
+
"GuardViolation",
|
|
59
|
+
"LoopLimitExceeded",
|
|
60
|
+
"StateMachineError",
|
|
61
|
+
"StatepilotError",
|
|
62
|
+
"StepLimitExceeded",
|
|
63
|
+
"TransitionError",
|
|
64
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Optional, weakly-coupled adapters for orchestration frameworks.
|
|
2
|
+
|
|
3
|
+
Importing :mod:`statepilot.adapters` does **not** import any third-party
|
|
4
|
+
framework. Each adapter is written against the framework's stable *callable
|
|
5
|
+
contract* rather than its internal API, so it keeps working across framework
|
|
6
|
+
versions and needs the framework only at the call site, not at import time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .langgraph import guard_node
|
|
12
|
+
|
|
13
|
+
__all__ = ["guard_node"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""LangGraph adapter — **experimental**.
|
|
2
|
+
|
|
3
|
+
LangGraph's public surface (``StateGraph``, ``add_node``, ``ToolNode``) has moved
|
|
4
|
+
across releases, but one contract has been stable since early versions: a *node*
|
|
5
|
+
is a callable that takes the graph state and returns a partial-state ``dict``::
|
|
6
|
+
|
|
7
|
+
def my_node(state: dict) -> dict:
|
|
8
|
+
...
|
|
9
|
+
return {"some_key": value}
|
|
10
|
+
|
|
11
|
+
This adapter hooks into that contract. :func:`guard_node` wraps any node callable
|
|
12
|
+
so that a :class:`statepilot.pilot.Pilot` validates the transition *before* the
|
|
13
|
+
node body runs. Because it targets the callable contract and never imports
|
|
14
|
+
``langgraph``, it does not break when the LangGraph API churns, and it adds no
|
|
15
|
+
import-time dependency.
|
|
16
|
+
|
|
17
|
+
It is deliberately minimal. For anything beyond "guard this node", drive the
|
|
18
|
+
:class:`~statepilot.pilot.Pilot` yourself inside your node functions — that is the
|
|
19
|
+
fully supported path. Treat this module as a convenience, not a framework
|
|
20
|
+
integration layer.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import functools
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from statepilot.pilot import Pilot
|
|
32
|
+
|
|
33
|
+
__all__ = ["guard_node"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def guard_node(
|
|
37
|
+
pilot: Pilot,
|
|
38
|
+
node: Callable[[Any], Any],
|
|
39
|
+
*,
|
|
40
|
+
tool: str | None = None,
|
|
41
|
+
cost: float = 0.0,
|
|
42
|
+
) -> Callable[[Any], Any]:
|
|
43
|
+
"""Wrap a LangGraph node callable with a statepilot guard.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
pilot: The :class:`~statepilot.pilot.Pilot` enforcing the rules.
|
|
47
|
+
node: A LangGraph node — any callable ``state -> partial_state_dict``.
|
|
48
|
+
tool: Tool name used for the transition. Defaults to the node's
|
|
49
|
+
``__name__`` (or ``"node"`` for objects without one).
|
|
50
|
+
cost: Cost to attribute to entering this node.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A callable with the same ``state -> result`` signature as ``node``. It
|
|
54
|
+
calls :meth:`Pilot.step` before invoking ``node``; a
|
|
55
|
+
:class:`~statepilot.exceptions.GuardViolation` therefore stops the node
|
|
56
|
+
from running.
|
|
57
|
+
|
|
58
|
+
Example (pseudocode — requires ``langgraph`` installed at call time)::
|
|
59
|
+
|
|
60
|
+
from langgraph.graph import StateGraph
|
|
61
|
+
from statepilot import StateMachine, Pilot
|
|
62
|
+
from statepilot.adapters import guard_node
|
|
63
|
+
|
|
64
|
+
pilot = Pilot(machine, budget=5.0)
|
|
65
|
+
graph = StateGraph(MyState)
|
|
66
|
+
graph.add_node("research", guard_node(pilot, research_node, cost=1.0))
|
|
67
|
+
graph.add_node("draft", guard_node(pilot, draft_node))
|
|
68
|
+
|
|
69
|
+
.. warning::
|
|
70
|
+
Experimental. The contract it relies on (node = callable returning a
|
|
71
|
+
partial-state dict) is stable, but conditional edges, ``Send`` fan-out
|
|
72
|
+
and checkpoint/resume are out of scope and untested here.
|
|
73
|
+
"""
|
|
74
|
+
tool_name = tool if tool is not None else getattr(node, "__name__", "node")
|
|
75
|
+
|
|
76
|
+
@functools.wraps(node)
|
|
77
|
+
def wrapped(state: Any) -> Any:
|
|
78
|
+
pilot.step(tool_name, cost=cost)
|
|
79
|
+
return node(state)
|
|
80
|
+
|
|
81
|
+
return wrapped
|
statepilot/decorator.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The :func:`guarded` decorator.
|
|
2
|
+
|
|
3
|
+
Wrap a tool function so that a :class:`statepilot.pilot.Pilot` validates the call
|
|
4
|
+
*before* the wrapped function runs. If the pilot rejects the call, the function
|
|
5
|
+
body never executes and the guard exception propagates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
from .pilot import Pilot
|
|
17
|
+
|
|
18
|
+
__all__ = ["guarded"]
|
|
19
|
+
|
|
20
|
+
F = TypeVar("F", bound="Callable[..., Any]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def guarded(
|
|
24
|
+
pilot: Pilot,
|
|
25
|
+
*,
|
|
26
|
+
tool: str | None = None,
|
|
27
|
+
cost: float = 0.0,
|
|
28
|
+
) -> Callable[[F], F]:
|
|
29
|
+
"""Decorate a tool function with a state-machine guard.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
pilot: The :class:`~statepilot.pilot.Pilot` that enforces the rules.
|
|
33
|
+
tool: The tool name used for the transition. Defaults to the wrapped
|
|
34
|
+
function's ``__name__``.
|
|
35
|
+
cost: Cost to attribute to the call (counts against the pilot budget).
|
|
36
|
+
|
|
37
|
+
The decorated function calls :meth:`Pilot.step` with the resolved tool name
|
|
38
|
+
and cost *before* executing its body. A
|
|
39
|
+
:class:`~statepilot.exceptions.GuardViolation` therefore prevents the body
|
|
40
|
+
from running at all.
|
|
41
|
+
|
|
42
|
+
Example::
|
|
43
|
+
|
|
44
|
+
pilot = Pilot(machine, budget=5.0)
|
|
45
|
+
|
|
46
|
+
@guarded(pilot, cost=1.0)
|
|
47
|
+
def research(query: str) -> str:
|
|
48
|
+
return do_research(query)
|
|
49
|
+
|
|
50
|
+
research("agents") # advances the machine, charges 1.0, then runs
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def decorate(func: F) -> F:
|
|
54
|
+
tool_name = tool if tool is not None else func.__name__
|
|
55
|
+
|
|
56
|
+
@functools.wraps(func)
|
|
57
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
58
|
+
pilot.step(tool_name, cost=cost)
|
|
59
|
+
return func(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
return wrapper # type: ignore[return-value]
|
|
62
|
+
|
|
63
|
+
return decorate
|
statepilot/exceptions.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Exception hierarchy for statepilot.
|
|
2
|
+
|
|
3
|
+
All runtime guard violations derive from :class:`GuardViolation`, so callers can
|
|
4
|
+
catch the whole class of "the agent tried to do something the state machine
|
|
5
|
+
forbids" with a single ``except``. Definition-time problems (bad state machine
|
|
6
|
+
spec) raise :class:`StateMachineError` instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BudgetExceeded",
|
|
13
|
+
"GuardViolation",
|
|
14
|
+
"LoopLimitExceeded",
|
|
15
|
+
"StateMachineError",
|
|
16
|
+
"StatepilotError",
|
|
17
|
+
"StepLimitExceeded",
|
|
18
|
+
"TransitionError",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StatepilotError(Exception):
|
|
23
|
+
"""Base class for every error raised by statepilot."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StateMachineError(StatepilotError):
|
|
27
|
+
"""Raised when a state machine definition is invalid.
|
|
28
|
+
|
|
29
|
+
This is a *definition-time* error (unknown state, duplicate transition,
|
|
30
|
+
missing initial state, …). It is not a guard violation — the machine itself
|
|
31
|
+
is malformed.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GuardViolation(StatepilotError):
|
|
36
|
+
"""Base class for all *runtime* guard violations.
|
|
37
|
+
|
|
38
|
+
Catch this to treat "the agent broke a rule" uniformly, regardless of
|
|
39
|
+
whether it was a forbidden transition, a loop, a budget overrun, or too many
|
|
40
|
+
steps.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TransitionError(GuardViolation):
|
|
45
|
+
"""Raised when a tool/event is not allowed from the current state."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, tool: str, state: str, allowed: tuple[str, ...]) -> None:
|
|
48
|
+
self.tool = tool
|
|
49
|
+
self.state = state
|
|
50
|
+
self.allowed = allowed
|
|
51
|
+
allowed_str = ", ".join(sorted(allowed)) if allowed else "<none>"
|
|
52
|
+
super().__init__(
|
|
53
|
+
f"Tool '{tool}' is not allowed in state '{state}'. "
|
|
54
|
+
f"Allowed tools here: {allowed_str}."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LoopLimitExceeded(GuardViolation):
|
|
59
|
+
"""Raised when a state or tool repeats more often than the configured limit."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, kind: str, name: str, count: int, limit: int) -> None:
|
|
62
|
+
self.kind = kind
|
|
63
|
+
self.name = name
|
|
64
|
+
self.count = count
|
|
65
|
+
self.limit = limit
|
|
66
|
+
super().__init__(
|
|
67
|
+
f"Loop limit exceeded: {kind} '{name}' would reach {count} "
|
|
68
|
+
f"occurrences (limit {limit})."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BudgetExceeded(GuardViolation):
|
|
73
|
+
"""Raised when the cumulative cost would exceed the configured budget."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, spent: float, cost: float, budget: float) -> None:
|
|
76
|
+
self.spent = spent
|
|
77
|
+
self.cost = cost
|
|
78
|
+
self.budget = budget
|
|
79
|
+
super().__init__(
|
|
80
|
+
f"Budget exceeded: spent {spent} + {cost} would exceed budget {budget}."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StepLimitExceeded(GuardViolation):
|
|
85
|
+
"""Raised when the total number of steps would exceed ``max_steps``."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, steps: int, limit: int) -> None:
|
|
88
|
+
self.steps = steps
|
|
89
|
+
self.limit = limit
|
|
90
|
+
super().__init__(
|
|
91
|
+
f"Step limit exceeded: step {steps} would exceed max_steps {limit}."
|
|
92
|
+
)
|
statepilot/machine.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The :class:`StateMachine` definition and its builder.
|
|
2
|
+
|
|
3
|
+
A :class:`StateMachine` is an immutable, validated description of:
|
|
4
|
+
|
|
5
|
+
* the set of valid states,
|
|
6
|
+
* the initial state,
|
|
7
|
+
* which states are terminal (no outgoing transitions allowed), and
|
|
8
|
+
* the transitions, each bound to a *tool* (or generic event) name.
|
|
9
|
+
|
|
10
|
+
It carries no runtime state. Apply it to a :class:`statepilot.pilot.Pilot` to
|
|
11
|
+
enforce it at runtime. Build one with the fluent builder, :meth:`from_dict`, or
|
|
12
|
+
:meth:`from_yaml`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from .exceptions import StateMachineError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from os import PathLike
|
|
25
|
+
|
|
26
|
+
__all__ = ["StateMachine", "StateMachineBuilder", "Transition"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Transition:
|
|
31
|
+
"""A single edge in the state machine.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
source: State the transition starts from.
|
|
35
|
+
dest: State the transition leads to.
|
|
36
|
+
tool: Name of the tool/event that triggers this transition.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
source: str
|
|
40
|
+
dest: str
|
|
41
|
+
tool: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class StateMachine:
|
|
46
|
+
"""An immutable, validated state machine definition.
|
|
47
|
+
|
|
48
|
+
Prefer the constructors (:meth:`builder`, :meth:`from_dict`,
|
|
49
|
+
:meth:`from_yaml`) over instantiating this class directly — they validate
|
|
50
|
+
inputs and produce the internal lookup index.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
states: frozenset[str]
|
|
54
|
+
initial: str
|
|
55
|
+
transitions: tuple[Transition, ...]
|
|
56
|
+
terminal: frozenset[str] = field(default_factory=frozenset)
|
|
57
|
+
# Index: (source_state, tool) -> dest_state. Built in __post_init__.
|
|
58
|
+
_index: dict[tuple[str, str], str] = field(
|
|
59
|
+
default_factory=dict, compare=False, repr=False
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
if not self.states:
|
|
64
|
+
raise StateMachineError("A state machine needs at least one state.")
|
|
65
|
+
if self.initial not in self.states:
|
|
66
|
+
raise StateMachineError(
|
|
67
|
+
f"Initial state '{self.initial}' is not in the set of states."
|
|
68
|
+
)
|
|
69
|
+
for bad in self.terminal - self.states:
|
|
70
|
+
raise StateMachineError(
|
|
71
|
+
f"Terminal state '{bad}' is not in the set of states."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
index: dict[tuple[str, str], str] = {}
|
|
75
|
+
for tr in self.transitions:
|
|
76
|
+
if tr.source not in self.states:
|
|
77
|
+
raise StateMachineError(
|
|
78
|
+
f"Transition source '{tr.source}' is not a known state."
|
|
79
|
+
)
|
|
80
|
+
if tr.dest not in self.states:
|
|
81
|
+
raise StateMachineError(
|
|
82
|
+
f"Transition dest '{tr.dest}' is not a known state."
|
|
83
|
+
)
|
|
84
|
+
if tr.source in self.terminal:
|
|
85
|
+
raise StateMachineError(
|
|
86
|
+
f"Terminal state '{tr.source}' cannot have outgoing "
|
|
87
|
+
f"transitions (tool '{tr.tool}')."
|
|
88
|
+
)
|
|
89
|
+
key = (tr.source, tr.tool)
|
|
90
|
+
if key in index:
|
|
91
|
+
raise StateMachineError(
|
|
92
|
+
f"Ambiguous transition: tool '{tr.tool}' is defined more "
|
|
93
|
+
f"than once for state '{tr.source}'."
|
|
94
|
+
)
|
|
95
|
+
index[key] = tr.dest
|
|
96
|
+
# frozen dataclass: bypass the frozen setter for the cached index.
|
|
97
|
+
object.__setattr__(self, "_index", index)
|
|
98
|
+
|
|
99
|
+
# -- queries ----------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def is_terminal(self, state: str) -> bool:
|
|
102
|
+
"""Return ``True`` if ``state`` is a terminal state."""
|
|
103
|
+
return state in self.terminal
|
|
104
|
+
|
|
105
|
+
def allowed_tools(self, state: str) -> tuple[str, ...]:
|
|
106
|
+
"""Return the tools allowed from ``state``, sorted for determinism."""
|
|
107
|
+
if state not in self.states:
|
|
108
|
+
raise StateMachineError(f"Unknown state '{state}'.")
|
|
109
|
+
return tuple(sorted(tool for (src, tool) in self._index if src == state))
|
|
110
|
+
|
|
111
|
+
def resolve(self, state: str, tool: str) -> str | None:
|
|
112
|
+
"""Return the destination state for ``(state, tool)``.
|
|
113
|
+
|
|
114
|
+
Returns ``None`` when the transition is not allowed. Does not raise; the
|
|
115
|
+
:class:`statepilot.pilot.Pilot` decides how to react to ``None``.
|
|
116
|
+
"""
|
|
117
|
+
return self._index.get((state, tool))
|
|
118
|
+
|
|
119
|
+
# -- constructors -----------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def builder(cls, initial: str | None = None) -> StateMachineBuilder:
|
|
123
|
+
"""Return a fresh fluent :class:`StateMachineBuilder`."""
|
|
124
|
+
builder = StateMachineBuilder()
|
|
125
|
+
if initial is not None:
|
|
126
|
+
builder.initial(initial)
|
|
127
|
+
return builder
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_dict(cls, data: Mapping[str, Any]) -> StateMachine:
|
|
131
|
+
"""Build a state machine from a plain mapping.
|
|
132
|
+
|
|
133
|
+
Expected shape::
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
"initial": "idle",
|
|
137
|
+
"states": ["idle", "running", "done"], # optional, inferred otherwise
|
|
138
|
+
"terminal": ["done"], # optional
|
|
139
|
+
"transitions": [
|
|
140
|
+
{"from": "idle", "to": "running", "tool": "start"},
|
|
141
|
+
{"from": "running", "to": "done", "tool": "finish"},
|
|
142
|
+
],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
``states`` may be omitted; it is then inferred from ``initial`` plus
|
|
146
|
+
every state mentioned in ``transitions`` and ``terminal``.
|
|
147
|
+
"""
|
|
148
|
+
if not isinstance(data, dict):
|
|
149
|
+
raise StateMachineError("State machine definition must be a mapping.")
|
|
150
|
+
|
|
151
|
+
initial = data.get("initial")
|
|
152
|
+
if not isinstance(initial, str) or not initial:
|
|
153
|
+
raise StateMachineError(
|
|
154
|
+
"State machine definition needs a non-empty 'initial' state."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
raw_transitions = data.get("transitions", [])
|
|
158
|
+
if not isinstance(raw_transitions, list):
|
|
159
|
+
raise StateMachineError("'transitions' must be a list.")
|
|
160
|
+
|
|
161
|
+
transitions: list[Transition] = []
|
|
162
|
+
mentioned: set[str] = {initial}
|
|
163
|
+
for i, raw in enumerate(raw_transitions):
|
|
164
|
+
if not isinstance(raw, dict):
|
|
165
|
+
raise StateMachineError(f"Transition #{i} must be a mapping.")
|
|
166
|
+
source = raw.get("from")
|
|
167
|
+
dest = raw.get("to")
|
|
168
|
+
tool = raw.get("tool", raw.get("event"))
|
|
169
|
+
for label, value in (("from", source), ("to", dest), ("tool", tool)):
|
|
170
|
+
if not isinstance(value, str) or not value:
|
|
171
|
+
raise StateMachineError(
|
|
172
|
+
f"Transition #{i} is missing a valid '{label}'."
|
|
173
|
+
)
|
|
174
|
+
assert isinstance(source, str)
|
|
175
|
+
assert isinstance(dest, str)
|
|
176
|
+
assert isinstance(tool, str)
|
|
177
|
+
transitions.append(Transition(source=source, dest=dest, tool=tool))
|
|
178
|
+
mentioned.update((source, dest))
|
|
179
|
+
|
|
180
|
+
terminal_raw = data.get("terminal", [])
|
|
181
|
+
if not isinstance(terminal_raw, list) or not all(
|
|
182
|
+
isinstance(s, str) for s in terminal_raw
|
|
183
|
+
):
|
|
184
|
+
raise StateMachineError("'terminal' must be a list of strings.")
|
|
185
|
+
terminal: set[str] = set(terminal_raw)
|
|
186
|
+
mentioned.update(terminal)
|
|
187
|
+
|
|
188
|
+
states_raw = data.get("states")
|
|
189
|
+
if states_raw is None:
|
|
190
|
+
states = mentioned
|
|
191
|
+
else:
|
|
192
|
+
if not isinstance(states_raw, list) or not all(
|
|
193
|
+
isinstance(s, str) for s in states_raw
|
|
194
|
+
):
|
|
195
|
+
raise StateMachineError("'states' must be a list of strings.")
|
|
196
|
+
states = set(states_raw)
|
|
197
|
+
missing = mentioned - states
|
|
198
|
+
if missing:
|
|
199
|
+
raise StateMachineError(
|
|
200
|
+
"States referenced but not declared in 'states': "
|
|
201
|
+
+ ", ".join(sorted(missing))
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return cls(
|
|
205
|
+
states=frozenset(states),
|
|
206
|
+
initial=initial,
|
|
207
|
+
transitions=tuple(transitions),
|
|
208
|
+
terminal=frozenset(terminal),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_yaml(cls, text: str) -> StateMachine:
|
|
213
|
+
"""Build a state machine from an inline YAML **string**.
|
|
214
|
+
|
|
215
|
+
Requires the optional ``pyyaml`` dependency — install with
|
|
216
|
+
``pip install statepilot[yaml]``. This method never touches the
|
|
217
|
+
filesystem, so an inline string is never accidentally interpreted as a
|
|
218
|
+
path; to load a file use :meth:`from_yaml_file`.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
import yaml
|
|
222
|
+
except ModuleNotFoundError as exc: # pragma: no cover - import guard
|
|
223
|
+
raise StateMachineError(
|
|
224
|
+
"from_yaml requires the optional 'pyyaml' dependency. "
|
|
225
|
+
"Install it with: pip install statepilot[yaml]"
|
|
226
|
+
) from exc
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
data = yaml.safe_load(text)
|
|
230
|
+
except yaml.YAMLError as exc:
|
|
231
|
+
raise StateMachineError(f"Could not parse YAML: {exc}") from exc
|
|
232
|
+
if not isinstance(data, dict):
|
|
233
|
+
raise StateMachineError(
|
|
234
|
+
"Top-level YAML must be a mapping with 'initial' and "
|
|
235
|
+
"'transitions' keys."
|
|
236
|
+
)
|
|
237
|
+
return cls.from_dict(data)
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def from_yaml_file(cls, path: str | PathLike[str]) -> StateMachine:
|
|
241
|
+
"""Build a state machine by reading the YAML **file** at ``path``.
|
|
242
|
+
|
|
243
|
+
Requires the optional ``pyyaml`` dependency. Use :meth:`from_yaml` for an
|
|
244
|
+
inline YAML string.
|
|
245
|
+
"""
|
|
246
|
+
from pathlib import Path
|
|
247
|
+
|
|
248
|
+
return cls.from_yaml(Path(path).read_text(encoding="utf-8"))
|
|
249
|
+
|
|
250
|
+
def to_dict(self) -> dict[str, Any]:
|
|
251
|
+
"""Serialise back to a plain dict (round-trips with :meth:`from_dict`)."""
|
|
252
|
+
return {
|
|
253
|
+
"initial": self.initial,
|
|
254
|
+
"states": sorted(self.states),
|
|
255
|
+
"terminal": sorted(self.terminal),
|
|
256
|
+
"transitions": [
|
|
257
|
+
{"from": tr.source, "to": tr.dest, "tool": tr.tool}
|
|
258
|
+
for tr in self.transitions
|
|
259
|
+
],
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class StateMachineBuilder:
|
|
264
|
+
"""Fluent builder for :class:`StateMachine`.
|
|
265
|
+
|
|
266
|
+
Example::
|
|
267
|
+
|
|
268
|
+
sm = (
|
|
269
|
+
StateMachine.builder()
|
|
270
|
+
.initial("idle")
|
|
271
|
+
.transition("idle", "running", tool="start")
|
|
272
|
+
.transition("running", "done", tool="finish")
|
|
273
|
+
.terminal("done")
|
|
274
|
+
.build()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
Every mutating method returns ``self`` so calls can be chained.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self) -> None:
|
|
281
|
+
self._initial: str | None = None
|
|
282
|
+
self._states: set[str] = set()
|
|
283
|
+
self._terminal: set[str] = set()
|
|
284
|
+
self._transitions: list[Transition] = []
|
|
285
|
+
|
|
286
|
+
def initial(self, state: str) -> StateMachineBuilder:
|
|
287
|
+
"""Set the initial state (also registers it as a known state)."""
|
|
288
|
+
self._initial = state
|
|
289
|
+
self._states.add(state)
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def state(self, *states: str) -> StateMachineBuilder:
|
|
293
|
+
"""Explicitly register one or more states (usually optional)."""
|
|
294
|
+
self._states.update(states)
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
def transition(self, source: str, dest: str, *, tool: str) -> StateMachineBuilder:
|
|
298
|
+
"""Add a transition ``source --tool--> dest``.
|
|
299
|
+
|
|
300
|
+
States are auto-registered. The keyword-only ``tool`` argument makes the
|
|
301
|
+
binding explicit at the call site.
|
|
302
|
+
"""
|
|
303
|
+
self._states.update((source, dest))
|
|
304
|
+
self._transitions.append(Transition(source=source, dest=dest, tool=tool))
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def terminal(self, *states: str) -> StateMachineBuilder:
|
|
308
|
+
"""Mark one or more states as terminal."""
|
|
309
|
+
self._states.update(states)
|
|
310
|
+
self._terminal.update(states)
|
|
311
|
+
return self
|
|
312
|
+
|
|
313
|
+
def build(self) -> StateMachine:
|
|
314
|
+
"""Validate and return the immutable :class:`StateMachine`."""
|
|
315
|
+
if self._initial is None:
|
|
316
|
+
raise StateMachineError(
|
|
317
|
+
"No initial state set. Call .initial(state) before .build()."
|
|
318
|
+
)
|
|
319
|
+
return StateMachine(
|
|
320
|
+
states=frozenset(self._states),
|
|
321
|
+
initial=self._initial,
|
|
322
|
+
transitions=tuple(self._transitions),
|
|
323
|
+
terminal=frozenset(self._terminal),
|
|
324
|
+
)
|
statepilot/pilot.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""The runtime guard: :class:`Pilot`.
|
|
2
|
+
|
|
3
|
+
A :class:`Pilot` wraps a :class:`statepilot.machine.StateMachine` and holds the
|
|
4
|
+
*live* state of one agent run. Every tool call must go through :meth:`Pilot.step`
|
|
5
|
+
(directly or via the :func:`guarded` decorator). The pilot:
|
|
6
|
+
|
|
7
|
+
* refuses tool calls that are not allowed from the current state
|
|
8
|
+
(:class:`~statepilot.exceptions.TransitionError`),
|
|
9
|
+
* refuses to leave a terminal state,
|
|
10
|
+
* enforces a loop limit (per-state visits and consecutive-tool repeats),
|
|
11
|
+
* enforces a cumulative cost budget, and
|
|
12
|
+
* enforces a hard cap on the total number of steps.
|
|
13
|
+
|
|
14
|
+
It records every accepted step in :attr:`Pilot.history` and can export a trace
|
|
15
|
+
for logging or assertions in tests.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
BudgetExceeded,
|
|
26
|
+
LoopLimitExceeded,
|
|
27
|
+
StepLimitExceeded,
|
|
28
|
+
TransitionError,
|
|
29
|
+
)
|
|
30
|
+
from .machine import StateMachine
|
|
31
|
+
|
|
32
|
+
__all__ = ["Pilot", "StepRecord"]
|
|
33
|
+
|
|
34
|
+
# Budget comparisons are forgiving by one tiny epsilon so that accumulating
|
|
35
|
+
# e.g. 0.1 ten times does not spuriously trip a budget of exactly 1.0.
|
|
36
|
+
_EPSILON = 1e-9
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class StepRecord:
|
|
41
|
+
"""One accepted transition in a pilot's history.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
index: Zero-based position of this step in the run.
|
|
45
|
+
tool: The tool/event that triggered the transition.
|
|
46
|
+
source: State before the transition.
|
|
47
|
+
dest: State after the transition.
|
|
48
|
+
cost: Cost attributed to this step.
|
|
49
|
+
cumulative_cost: Total cost spent up to and including this step.
|
|
50
|
+
timestamp: ``time.time()`` when the step was accepted.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
index: int
|
|
54
|
+
tool: str
|
|
55
|
+
source: str
|
|
56
|
+
dest: str
|
|
57
|
+
cost: float
|
|
58
|
+
cumulative_cost: float
|
|
59
|
+
timestamp: float
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
"""Return a JSON-serialisable view of this step."""
|
|
63
|
+
return {
|
|
64
|
+
"index": self.index,
|
|
65
|
+
"tool": self.tool,
|
|
66
|
+
"source": self.source,
|
|
67
|
+
"dest": self.dest,
|
|
68
|
+
"cost": self.cost,
|
|
69
|
+
"cumulative_cost": self.cumulative_cost,
|
|
70
|
+
"timestamp": self.timestamp,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class Pilot:
|
|
76
|
+
"""Stateful runtime enforcer for a :class:`StateMachine`.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
machine: The state machine to enforce.
|
|
80
|
+
budget: Optional cumulative cost cap. ``None`` disables the budget guard.
|
|
81
|
+
max_steps: Optional hard cap on total accepted steps. ``None`` disables it.
|
|
82
|
+
max_state_visits: Optional cap on how often any single state may be
|
|
83
|
+
entered (loop guard). ``None`` disables per-state loop detection.
|
|
84
|
+
max_consecutive_tool: Optional cap on how many times the *same* tool may
|
|
85
|
+
be invoked back-to-back (loop guard). ``None`` disables it.
|
|
86
|
+
|
|
87
|
+
The pilot starts in ``machine.initial``. Use :meth:`step` to advance it.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
machine: StateMachine
|
|
91
|
+
budget: float | None = None
|
|
92
|
+
max_steps: int | None = None
|
|
93
|
+
max_state_visits: int | None = None
|
|
94
|
+
max_consecutive_tool: int | None = None
|
|
95
|
+
|
|
96
|
+
state: str = field(init=False)
|
|
97
|
+
cost_spent: float = field(init=False, default=0.0)
|
|
98
|
+
_history: list[StepRecord] = field(init=False, default_factory=list)
|
|
99
|
+
_state_visits: dict[str, int] = field(init=False, default_factory=dict)
|
|
100
|
+
_last_tool: str | None = field(init=False, default=None)
|
|
101
|
+
_consecutive_tool: int = field(init=False, default=0)
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
if self.budget is not None and self.budget < 0:
|
|
105
|
+
raise ValueError("budget must be >= 0 or None.")
|
|
106
|
+
if self.max_steps is not None and self.max_steps < 0:
|
|
107
|
+
raise ValueError("max_steps must be >= 0 or None.")
|
|
108
|
+
if self.max_state_visits is not None and self.max_state_visits < 1:
|
|
109
|
+
raise ValueError("max_state_visits must be >= 1 or None.")
|
|
110
|
+
if self.max_consecutive_tool is not None and self.max_consecutive_tool < 1:
|
|
111
|
+
raise ValueError("max_consecutive_tool must be >= 1 or None.")
|
|
112
|
+
self.state = self.machine.initial
|
|
113
|
+
# The initial state counts as one visit so loop limits include the start.
|
|
114
|
+
self._state_visits[self.state] = 1
|
|
115
|
+
|
|
116
|
+
# -- introspection ----------------------------------------------------
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def steps_taken(self) -> int:
|
|
120
|
+
"""Number of accepted steps so far."""
|
|
121
|
+
return len(self._history)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def history(self) -> tuple[StepRecord, ...]:
|
|
125
|
+
"""Immutable snapshot of accepted steps (oldest first).
|
|
126
|
+
|
|
127
|
+
Returned as a tuple so external code can't desync the run by mutating
|
|
128
|
+
it — the step-limit guard counts from the pilot's own internal record.
|
|
129
|
+
"""
|
|
130
|
+
return tuple(self._history)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def done(self) -> bool:
|
|
134
|
+
"""``True`` if the pilot is currently in a terminal state."""
|
|
135
|
+
return self.machine.is_terminal(self.state)
|
|
136
|
+
|
|
137
|
+
def allowed_tools(self) -> tuple[str, ...]:
|
|
138
|
+
"""Tools allowed from the current state (empty when terminal)."""
|
|
139
|
+
return self.machine.allowed_tools(self.state)
|
|
140
|
+
|
|
141
|
+
def can(self, tool: str, *, cost: float = 0.0) -> bool:
|
|
142
|
+
"""Return ``True`` if :meth:`step` would currently accept ``tool``.
|
|
143
|
+
|
|
144
|
+
Pure check — never mutates state and never raises for a guard violation.
|
|
145
|
+
(Like :meth:`step`, a negative ``cost`` is a programming error and raises
|
|
146
|
+
:class:`ValueError` — that is an invalid argument, not a guard decision.)
|
|
147
|
+
Useful for letting an agent *plan* before acting.
|
|
148
|
+
"""
|
|
149
|
+
if cost < 0:
|
|
150
|
+
raise ValueError("cost must be >= 0.")
|
|
151
|
+
if self.machine.is_terminal(self.state):
|
|
152
|
+
return False
|
|
153
|
+
dest = self.machine.resolve(self.state, tool)
|
|
154
|
+
if dest is None:
|
|
155
|
+
return False
|
|
156
|
+
if self.max_steps is not None and self.steps_taken + 1 > self.max_steps:
|
|
157
|
+
return False
|
|
158
|
+
if self.budget is not None and self.cost_spent + cost > self.budget + _EPSILON:
|
|
159
|
+
return False
|
|
160
|
+
if (
|
|
161
|
+
self.max_consecutive_tool is not None
|
|
162
|
+
and tool == self._last_tool
|
|
163
|
+
and self._consecutive_tool + 1 > self.max_consecutive_tool
|
|
164
|
+
):
|
|
165
|
+
return False
|
|
166
|
+
return not (
|
|
167
|
+
self.max_state_visits is not None
|
|
168
|
+
and self._state_visits.get(dest, 0) + 1 > self.max_state_visits
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# -- the one method that matters --------------------------------------
|
|
172
|
+
|
|
173
|
+
def step(self, tool: str, *, cost: float = 0.0) -> str:
|
|
174
|
+
"""Validate and apply one tool call. Returns the new state.
|
|
175
|
+
|
|
176
|
+
Order of checks (fail fast, most fundamental first):
|
|
177
|
+
|
|
178
|
+
1. terminal state -> :class:`TransitionError`
|
|
179
|
+
2. transition allowed? -> :class:`TransitionError`
|
|
180
|
+
3. step limit -> :class:`StepLimitExceeded`
|
|
181
|
+
4. budget -> :class:`BudgetExceeded`
|
|
182
|
+
5. consecutive-tool loop -> :class:`LoopLimitExceeded`
|
|
183
|
+
6. per-state loop -> :class:`LoopLimitExceeded`
|
|
184
|
+
|
|
185
|
+
On success the state advances, cost and counters update, and a
|
|
186
|
+
:class:`StepRecord` is appended to :attr:`history`. On any violation the
|
|
187
|
+
pilot's state is left unchanged.
|
|
188
|
+
"""
|
|
189
|
+
if cost < 0:
|
|
190
|
+
raise ValueError("cost must be >= 0.")
|
|
191
|
+
|
|
192
|
+
source = self.state
|
|
193
|
+
|
|
194
|
+
if self.machine.is_terminal(source):
|
|
195
|
+
raise TransitionError(tool=tool, state=source, allowed=())
|
|
196
|
+
|
|
197
|
+
dest = self.machine.resolve(source, tool)
|
|
198
|
+
if dest is None:
|
|
199
|
+
raise TransitionError(
|
|
200
|
+
tool=tool,
|
|
201
|
+
state=source,
|
|
202
|
+
allowed=self.machine.allowed_tools(source),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
next_step_number = self.steps_taken + 1
|
|
206
|
+
if self.max_steps is not None and next_step_number > self.max_steps:
|
|
207
|
+
raise StepLimitExceeded(steps=next_step_number, limit=self.max_steps)
|
|
208
|
+
|
|
209
|
+
prospective_cost = self.cost_spent + cost
|
|
210
|
+
if self.budget is not None and prospective_cost > self.budget + _EPSILON:
|
|
211
|
+
raise BudgetExceeded(spent=self.cost_spent, cost=cost, budget=self.budget)
|
|
212
|
+
|
|
213
|
+
prospective_consecutive = (
|
|
214
|
+
self._consecutive_tool + 1 if tool == self._last_tool else 1
|
|
215
|
+
)
|
|
216
|
+
if (
|
|
217
|
+
self.max_consecutive_tool is not None
|
|
218
|
+
and prospective_consecutive > self.max_consecutive_tool
|
|
219
|
+
):
|
|
220
|
+
raise LoopLimitExceeded(
|
|
221
|
+
kind="tool",
|
|
222
|
+
name=tool,
|
|
223
|
+
count=prospective_consecutive,
|
|
224
|
+
limit=self.max_consecutive_tool,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
prospective_visits = self._state_visits.get(dest, 0) + 1
|
|
228
|
+
if (
|
|
229
|
+
self.max_state_visits is not None
|
|
230
|
+
and prospective_visits > self.max_state_visits
|
|
231
|
+
):
|
|
232
|
+
raise LoopLimitExceeded(
|
|
233
|
+
kind="state",
|
|
234
|
+
name=dest,
|
|
235
|
+
count=prospective_visits,
|
|
236
|
+
limit=self.max_state_visits,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# All guards passed — commit.
|
|
240
|
+
self.cost_spent = prospective_cost
|
|
241
|
+
self._consecutive_tool = prospective_consecutive
|
|
242
|
+
self._last_tool = tool
|
|
243
|
+
self._state_visits[dest] = prospective_visits
|
|
244
|
+
self.state = dest
|
|
245
|
+
record = StepRecord(
|
|
246
|
+
index=self.steps_taken,
|
|
247
|
+
tool=tool,
|
|
248
|
+
source=source,
|
|
249
|
+
dest=dest,
|
|
250
|
+
cost=cost,
|
|
251
|
+
cumulative_cost=self.cost_spent,
|
|
252
|
+
timestamp=time.time(),
|
|
253
|
+
)
|
|
254
|
+
self._history.append(record)
|
|
255
|
+
return dest
|
|
256
|
+
|
|
257
|
+
# -- trace / reset ----------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def to_trace(self) -> dict[str, Any]:
|
|
260
|
+
"""Export a JSON-serialisable trace of the whole run.
|
|
261
|
+
|
|
262
|
+
Shape::
|
|
263
|
+
|
|
264
|
+
{
|
|
265
|
+
"initial": "<initial state>",
|
|
266
|
+
"state": "<current state>",
|
|
267
|
+
"done": <bool>,
|
|
268
|
+
"steps_taken": <int>,
|
|
269
|
+
"cost_spent": <float>,
|
|
270
|
+
"budget": <float | None>,
|
|
271
|
+
"limits": {"max_steps", "max_state_visits", "max_consecutive_tool"},
|
|
272
|
+
"history": [ {<StepRecord.to_dict()>}, ... ],
|
|
273
|
+
}
|
|
274
|
+
"""
|
|
275
|
+
return {
|
|
276
|
+
"initial": self.machine.initial,
|
|
277
|
+
"state": self.state,
|
|
278
|
+
"done": self.done,
|
|
279
|
+
"steps_taken": self.steps_taken,
|
|
280
|
+
"cost_spent": self.cost_spent,
|
|
281
|
+
"budget": self.budget,
|
|
282
|
+
"limits": {
|
|
283
|
+
"max_steps": self.max_steps,
|
|
284
|
+
"max_state_visits": self.max_state_visits,
|
|
285
|
+
"max_consecutive_tool": self.max_consecutive_tool,
|
|
286
|
+
},
|
|
287
|
+
"history": [record.to_dict() for record in self._history],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def reset(self) -> None:
|
|
291
|
+
"""Reset to the initial state, clearing cost, counters and history."""
|
|
292
|
+
self.state = self.machine.initial
|
|
293
|
+
self.cost_spent = 0.0
|
|
294
|
+
self._history.clear()
|
|
295
|
+
self._state_visits = {self.state: 1}
|
|
296
|
+
self._last_tool = None
|
|
297
|
+
self._consecutive_tool = 0
|
statepilot/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: statepilot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic state-machine guards for AI-agent workflows: enforce which tools an agent may call, in which order, with loop detection, cost budgets and step caps.
|
|
5
|
+
Project-URL: Homepage, https://github.com/studiomeyer-io/statepilot
|
|
6
|
+
Project-URL: Repository, https://github.com/studiomeyer-io/statepilot
|
|
7
|
+
Project-URL: Issues, https://github.com/studiomeyer-io/statepilot/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/studiomeyer-io/statepilot/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: StudioMeyer <hello@studiomeyer.io>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,determinism,guardrails,langgraph,llm,state-machine,workflow
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
34
|
+
Provides-Extra: yaml
|
|
35
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# statepilot
|
|
39
|
+
|
|
40
|
+
**Deterministic state-machine guards for AI-agent workflows.**
|
|
41
|
+
|
|
42
|
+
Define a state machine, then enforce — at *runtime* — which tools your agent may
|
|
43
|
+
call, in which order, with loop detection, a cost budget, and a hard step cap.
|
|
44
|
+
The agent only gets to do what the state machine allows. Anything else raises.
|
|
45
|
+
|
|
46
|
+
Zero runtime dependencies in the core. Fully typed. Python 3.10+.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install statepilot
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## The problem
|
|
53
|
+
|
|
54
|
+
A recurring theme in 2026 agent tooling is "wrap the non-deterministic LLM in
|
|
55
|
+
deterministic code." A few data points that frame the gap:
|
|
56
|
+
|
|
57
|
+
- **Statewright** (Rust + MCP) put deterministic state machines for agents on the
|
|
58
|
+
map — it reached the [Hacker News front page](https://news.ycombinator.com/)
|
|
59
|
+
and lives at <https://github.com/statewright/statewright>. The framing clearly
|
|
60
|
+
resonates.
|
|
61
|
+
- **llm-canary** ships *policy gates for agent traces* — tool order, cost
|
|
62
|
+
budgets, runaway-loop checks — but as a **post-hoc test layer** over recorded
|
|
63
|
+
traces, not as runtime enforcement.
|
|
64
|
+
- Orchestrators like **LangGraph**, **CrewAI** and the **OpenAI Agents SDK** are
|
|
65
|
+
excellent at *routing*, but they don't hand you a small, hard rule that says
|
|
66
|
+
"tool X is illegal in state Y, full stop."
|
|
67
|
+
|
|
68
|
+
The missing piece is a **Python-native runtime guard**: a thin layer you put in
|
|
69
|
+
front of every tool call that enforces the allowed transitions and trips on
|
|
70
|
+
loops and budget overruns. That is what statepilot is.
|
|
71
|
+
|
|
72
|
+
It does not orchestrate, plan, or call your LLM. It is the bouncer at the door.
|
|
73
|
+
|
|
74
|
+
## Quickstart (Python builder)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from statepilot import StateMachine, Pilot
|
|
78
|
+
|
|
79
|
+
machine = (
|
|
80
|
+
StateMachine.builder()
|
|
81
|
+
.initial("research")
|
|
82
|
+
.transition("research", "research", tool="search") # looping allowed...
|
|
83
|
+
.transition("research", "draft", tool="write_draft")
|
|
84
|
+
.transition("draft", "review", tool="review")
|
|
85
|
+
.transition("review", "draft", tool="revise") # send it back
|
|
86
|
+
.transition("review", "published", tool="publish")
|
|
87
|
+
.terminal("published")
|
|
88
|
+
.build()
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
pilot = Pilot(machine, budget=5.0, max_state_visits=4, max_steps=20)
|
|
92
|
+
|
|
93
|
+
pilot.step("search", cost=0.5) # ok, still in "research"
|
|
94
|
+
pilot.step("write_draft", cost=1.0) # -> "draft"
|
|
95
|
+
pilot.step("review") # -> "review"
|
|
96
|
+
pilot.step("publish") # -> "published" (terminal)
|
|
97
|
+
|
|
98
|
+
pilot.step("review") # raises TransitionError: terminal state
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Every accepted step is recorded:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
for record in pilot.history:
|
|
105
|
+
print(record.index, record.source, "--", record.tool, "->", record.dest)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## The `@guarded` decorator
|
|
109
|
+
|
|
110
|
+
Bind your actual tool functions to the pilot. The guard runs **before** the
|
|
111
|
+
function body, so a violation means the body never executes.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from statepilot import StateMachine, Pilot, guarded, GuardViolation
|
|
115
|
+
|
|
116
|
+
machine = (
|
|
117
|
+
StateMachine.builder()
|
|
118
|
+
.initial("research")
|
|
119
|
+
.transition("research", "research", tool="search")
|
|
120
|
+
.transition("research", "draft", tool="write_draft")
|
|
121
|
+
.terminal("draft")
|
|
122
|
+
.build()
|
|
123
|
+
)
|
|
124
|
+
pilot = Pilot(machine, budget=5.0)
|
|
125
|
+
|
|
126
|
+
@guarded(pilot, cost=1.0) # tool name defaults to the function name
|
|
127
|
+
def search(query: str) -> list[str]:
|
|
128
|
+
return real_search(query)
|
|
129
|
+
|
|
130
|
+
@guarded(pilot, tool="write_draft") # or name it explicitly
|
|
131
|
+
def make_draft(notes: list[str]) -> str:
|
|
132
|
+
return real_draft(notes)
|
|
133
|
+
|
|
134
|
+
search("agent guardrails") # advances the machine, charges 1.0
|
|
135
|
+
make_draft(["..."]) # -> "draft"
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
make_draft(["..."]) # already terminal
|
|
139
|
+
except GuardViolation as exc:
|
|
140
|
+
print("blocked:", exc)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## YAML definition
|
|
144
|
+
|
|
145
|
+
Prefer config over code? Define the machine in YAML and load it. (YAML support is
|
|
146
|
+
an optional extra: `pip install statepilot[yaml]`.)
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
# pipeline.yaml
|
|
150
|
+
initial: research
|
|
151
|
+
terminal:
|
|
152
|
+
- published
|
|
153
|
+
transitions:
|
|
154
|
+
- {from: research, to: research, tool: search}
|
|
155
|
+
- {from: research, to: draft, tool: write_draft}
|
|
156
|
+
- {from: draft, to: review, tool: review}
|
|
157
|
+
- {from: review, to: published, tool: publish}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from statepilot import StateMachine, Pilot
|
|
162
|
+
|
|
163
|
+
machine = StateMachine.from_yaml_file("pipeline.yaml") # from a file
|
|
164
|
+
# or: StateMachine.from_yaml(yaml_string) # from an inline string
|
|
165
|
+
pilot = Pilot(machine, budget=5.0)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`states` may be omitted — it is inferred from `initial`, `terminal`, and every
|
|
169
|
+
state named in `transitions`. `StateMachine.from_dict(...)` accepts the same
|
|
170
|
+
shape if you already have a dict.
|
|
171
|
+
|
|
172
|
+
## A realistic agent example
|
|
173
|
+
|
|
174
|
+
"Research, then draft, then review, then publish. Never publish before review.
|
|
175
|
+
Allow at most 3 research loops. Stop if cost exceeds \$5."
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from statepilot import StateMachine, Pilot, guarded, GuardViolation
|
|
179
|
+
|
|
180
|
+
machine = (
|
|
181
|
+
StateMachine.builder()
|
|
182
|
+
.initial("research")
|
|
183
|
+
.transition("research", "research", tool="search")
|
|
184
|
+
.transition("research", "draft", tool="write_draft")
|
|
185
|
+
.transition("draft", "review", tool="review")
|
|
186
|
+
.transition("review", "draft", tool="revise")
|
|
187
|
+
.transition("review", "published", tool="publish")
|
|
188
|
+
.terminal("published")
|
|
189
|
+
.build()
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# initial visit counts as 1, so max_state_visits=4 allows 3 extra research loops
|
|
193
|
+
pilot = Pilot(machine, budget=5.0, max_state_visits=4, max_steps=25)
|
|
194
|
+
|
|
195
|
+
@guarded(pilot, cost=0.8)
|
|
196
|
+
def search(q: str) -> str: ...
|
|
197
|
+
|
|
198
|
+
@guarded(pilot, cost=1.2)
|
|
199
|
+
def write_draft(notes: str) -> str: ...
|
|
200
|
+
|
|
201
|
+
@guarded(pilot)
|
|
202
|
+
def review(draft: str) -> bool: ...
|
|
203
|
+
|
|
204
|
+
@guarded(pilot, cost=0.3)
|
|
205
|
+
def publish(draft: str) -> str: ...
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The agent loop calls these as it sees fit. statepilot makes the illegal paths
|
|
209
|
+
impossible:
|
|
210
|
+
|
|
211
|
+
- calling `publish()` while still in `research` -> `TransitionError`
|
|
212
|
+
- a 4th `search()` loop -> `LoopLimitExceeded`
|
|
213
|
+
- cumulative cost over \$5 -> `BudgetExceeded`
|
|
214
|
+
- more than 25 steps -> `StepLimitExceeded`
|
|
215
|
+
|
|
216
|
+
A runnable version is in [`examples/research_pipeline.py`](examples/research_pipeline.py).
|
|
217
|
+
|
|
218
|
+
## Why deterministic guards
|
|
219
|
+
|
|
220
|
+
LLMs are probabilistic. Most of the time the model follows the plan; occasionally
|
|
221
|
+
it calls `publish` before `review`, gets stuck re-searching the same thing, or
|
|
222
|
+
burns the budget. "Most of the time" is not a guarantee, and prompt-only
|
|
223
|
+
constraints are suggestions, not enforcement.
|
|
224
|
+
|
|
225
|
+
A state machine turns those soft expectations into a hard contract that lives in
|
|
226
|
+
code, runs on every tool call, and is trivial to unit-test. You get:
|
|
227
|
+
|
|
228
|
+
- **Safety** — illegal tool sequences cannot happen; they raise instead.
|
|
229
|
+
- **Cost control** — a real budget cap, enforced before the expensive call runs.
|
|
230
|
+
- **Loop protection** — runaway repetition trips a clear, typed exception.
|
|
231
|
+
- **Auditability** — `pilot.history` and `pilot.to_trace()` give you a complete,
|
|
232
|
+
JSON-serialisable record of what the agent actually did.
|
|
233
|
+
|
|
234
|
+
It is intentionally small. The whole core is a `StateMachine` plus a `Pilot`, and
|
|
235
|
+
the runtime cost is a dict lookup and a few integer comparisons per step.
|
|
236
|
+
|
|
237
|
+
## API reference
|
|
238
|
+
|
|
239
|
+
### `StateMachine`
|
|
240
|
+
|
|
241
|
+
Immutable, validated machine definition. Carries no runtime state.
|
|
242
|
+
|
|
243
|
+
- `StateMachine.builder(initial=None) -> StateMachineBuilder` — fluent builder.
|
|
244
|
+
- `StateMachine.from_dict(data) -> StateMachine` — build from a mapping.
|
|
245
|
+
- `StateMachine.from_yaml(text) -> StateMachine` — build from an inline YAML
|
|
246
|
+
string (needs the `yaml` extra).
|
|
247
|
+
- `StateMachine.from_yaml_file(path) -> StateMachine` — build from a YAML file
|
|
248
|
+
(needs the `yaml` extra).
|
|
249
|
+
- `.to_dict()` — round-trips with `from_dict`.
|
|
250
|
+
- `.allowed_tools(state) -> tuple[str, ...]`
|
|
251
|
+
- `.resolve(state, tool) -> str | None` — destination, or `None` if disallowed.
|
|
252
|
+
- `.is_terminal(state) -> bool`
|
|
253
|
+
|
|
254
|
+
### `StateMachineBuilder`
|
|
255
|
+
|
|
256
|
+
- `.initial(state)`, `.state(*states)`, `.transition(src, dest, *, tool)`,
|
|
257
|
+
`.terminal(*states)`, `.build()`. Every mutator returns `self`.
|
|
258
|
+
|
|
259
|
+
### `Pilot`
|
|
260
|
+
|
|
261
|
+
Stateful runtime enforcer. Construct with the machine and optional limits:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
Pilot(
|
|
265
|
+
machine,
|
|
266
|
+
budget=None, # cumulative cost cap
|
|
267
|
+
max_steps=None, # total steps cap
|
|
268
|
+
max_state_visits=None, # per-state visit cap (initial state counts as 1)
|
|
269
|
+
max_consecutive_tool=None # same tool back-to-back cap
|
|
270
|
+
)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- `.step(tool, *, cost=0.0) -> str` — validate + apply; returns the new state.
|
|
274
|
+
Raises on violation; state is unchanged on failure.
|
|
275
|
+
- `.can(tool, *, cost=0.0) -> bool` — pure check, never mutates, never raises.
|
|
276
|
+
- `.allowed_tools() -> tuple[str, ...]`, `.state`, `.done`, `.steps_taken`,
|
|
277
|
+
`.cost_spent`, `.history`.
|
|
278
|
+
- `.to_trace() -> dict` — JSON-serialisable run trace.
|
|
279
|
+
- `.reset()` — back to the initial state, clears cost/counters/history.
|
|
280
|
+
|
|
281
|
+
### `@guarded(pilot, *, tool=None, cost=0.0)`
|
|
282
|
+
|
|
283
|
+
Decorator that calls `pilot.step(...)` before the function body. `tool` defaults
|
|
284
|
+
to the function name.
|
|
285
|
+
|
|
286
|
+
### Exceptions
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
StatepilotError
|
|
290
|
+
├── StateMachineError # invalid machine definition (definition-time)
|
|
291
|
+
└── GuardViolation # runtime rule broken — catch this for "agent misbehaved"
|
|
292
|
+
├── TransitionError # tool not allowed in the current state
|
|
293
|
+
├── LoopLimitExceeded # state revisited / tool repeated too often
|
|
294
|
+
├── BudgetExceeded # cumulative cost over budget
|
|
295
|
+
└── StepLimitExceeded # too many total steps
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## LangGraph adapter (experimental)
|
|
299
|
+
|
|
300
|
+
If you orchestrate with [LangGraph](https://github.com/langchain-ai/langgraph),
|
|
301
|
+
`statepilot.adapters.guard_node` wraps a node so the pilot guards it:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from statepilot import StateMachine, Pilot
|
|
305
|
+
from statepilot.adapters import guard_node
|
|
306
|
+
# from langgraph.graph import StateGraph
|
|
307
|
+
|
|
308
|
+
pilot = Pilot(machine, budget=5.0)
|
|
309
|
+
# graph = StateGraph(MyState)
|
|
310
|
+
# graph.add_node("research", guard_node(pilot, research_node, cost=1.0))
|
|
311
|
+
# graph.add_node("draft", guard_node(pilot, draft_node))
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
It targets LangGraph's stable *node contract* (a callable `state -> partial
|
|
315
|
+
state dict`) and **never imports langgraph itself**, so it adds no import-time
|
|
316
|
+
dependency and does not break when the LangGraph API changes. It is deliberately
|
|
317
|
+
minimal and marked experimental — conditional edges, `Send` fan-out, and
|
|
318
|
+
checkpoint/resume are out of scope. For full control, just drive the `Pilot`
|
|
319
|
+
inside your own node functions; that path is fully supported.
|
|
320
|
+
|
|
321
|
+
The adapter needs no extra dependency — it works with any callable. Install
|
|
322
|
+
LangGraph in your own project if you use it.
|
|
323
|
+
|
|
324
|
+
## Concurrency
|
|
325
|
+
|
|
326
|
+
A `Pilot` holds the mutable state of **one** agent run and is **not
|
|
327
|
+
thread-safe** — use one pilot per run, don't share it across threads, and call
|
|
328
|
+
`pilot.reset()` to reuse it. `pilot.history` is an immutable snapshot (a tuple),
|
|
329
|
+
so reading or logging it can never desync the run's guards.
|
|
330
|
+
|
|
331
|
+
## Status
|
|
332
|
+
|
|
333
|
+
Beta (`0.1.0`). The core API (`StateMachine`, `Pilot`, `@guarded`) is what we
|
|
334
|
+
intend to keep stable. No benchmarks are claimed — the design goal is
|
|
335
|
+
correctness and a tiny footprint, not throughput. Issues and PRs welcome.
|
|
336
|
+
|
|
337
|
+
## License
|
|
338
|
+
|
|
339
|
+
MIT © 2026 StudioMeyer. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
statepilot/__about__.py,sha256=XrsPMtJ9XgUU5vqSZ6rEuOaTXXxRsvnBWSZtSL31lDQ,113
|
|
2
|
+
statepilot/__init__.py,sha256=lv7VNi4U_nrHazo6suL5GP6zipi_reWCbjr3-Pyrolc,1652
|
|
3
|
+
statepilot/decorator.py,sha256=UJ4YAC0XZIgHpwA4m2kSCAqm8uMY24BiIp0-TO6II60,1804
|
|
4
|
+
statepilot/exceptions.py,sha256=n8q_uUD6XmEI17GRjIGrVdpSTym9pvVwDPum_MZyCGk,2908
|
|
5
|
+
statepilot/machine.py,sha256=WN4eXHgBV24a65KPprrgV6buGFTvwic0eEWykQTmdRE,12012
|
|
6
|
+
statepilot/pilot.py,sha256=kFuKfpKTzLn6fpIwMzYz7DZCkeq-wJe_eiD5jaKl2vc,10955
|
|
7
|
+
statepilot/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
statepilot/adapters/__init__.py,sha256=fGGtHGgjFf-Bcl3K0UscJmLoGKUw8L3Y19ATetj0PNQ,471
|
|
9
|
+
statepilot/adapters/langgraph.py,sha256=KWoGGTYhwSP7V7bz7ricX-CY3n9C11jxZjtxwft5VIk,2953
|
|
10
|
+
statepilot-0.1.0.dist-info/METADATA,sha256=1le2IIltYP45PONfjdbyD22ip-fGcRGvGUFm1V0eY70,12749
|
|
11
|
+
statepilot-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
statepilot-0.1.0.dist-info/licenses/LICENSE,sha256=R9PjrkWmJX6bZqDuDj_NJtFiNicHKt1rzPB3G_6Vtic,1068
|
|
13
|
+
statepilot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StudioMeyer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|