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.
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
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
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.