reflexr 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.
Files changed (80) hide show
  1. reflexr/__init__.py +78 -0
  2. reflexr/agent/__init__.py +24 -0
  3. reflexr/agent/actions.py +63 -0
  4. reflexr/agent/context.py +90 -0
  5. reflexr/agent/graphs.py +411 -0
  6. reflexr/agent/models.py +69 -0
  7. reflexr/agent/render.py +33 -0
  8. reflexr/agent/tools.py +102 -0
  9. reflexr/core/__init__.py +379 -0
  10. reflexr/core/actors.py +148 -0
  11. reflexr/core/conditions.py +463 -0
  12. reflexr/core/errors.py +107 -0
  13. reflexr/core/evaluation.py +488 -0
  14. reflexr/core/events.py +656 -0
  15. reflexr/core/feedback.py +164 -0
  16. reflexr/core/ids.py +81 -0
  17. reflexr/core/protocol.py +306 -0
  18. reflexr/core/rules.py +290 -0
  19. reflexr/core/runs.py +264 -0
  20. reflexr/core/schema.py +66 -0
  21. reflexr/core/state.py +164 -0
  22. reflexr/core/stored.py +214 -0
  23. reflexr/core/tagged.py +13 -0
  24. reflexr/evals/__init__.py +38 -0
  25. reflexr/evals/action.py +61 -0
  26. reflexr/evals/context.py +59 -0
  27. reflexr/evals/experiments.py +81 -0
  28. reflexr/evals/measures.py +131 -0
  29. reflexr/evals/source.py +142 -0
  30. reflexr/fastapi/__init__.py +30 -0
  31. reflexr/fastapi/openapi.py +42 -0
  32. reflexr/fastapi/router.py +280 -0
  33. reflexr/fastapi/stream.py +211 -0
  34. reflexr/langfuse/__init__.py +35 -0
  35. reflexr/langfuse/client.py +26 -0
  36. reflexr/langfuse/tracing.py +88 -0
  37. reflexr/litellm/__init__.py +38 -0
  38. reflexr/litellm/gateway.py +198 -0
  39. reflexr/mcp/__init__.py +10 -0
  40. reflexr/mcp/server.py +573 -0
  41. reflexr/otel/__init__.py +52 -0
  42. reflexr/otel/configure.py +142 -0
  43. reflexr/py.typed +0 -0
  44. reflexr/scores/__init__.py +25 -0
  45. reflexr/scores/mapping.py +31 -0
  46. reflexr/scores/mirror.py +179 -0
  47. reflexr/sql/__init__.py +18 -0
  48. reflexr/sql/migrations/env.py +17 -0
  49. reflexr/sql/migrations/script.py.mako +26 -0
  50. reflexr/sql/migrations/versions/0001_initial.py +141 -0
  51. reflexr/sql/migrations/versions/0002_scope_heads.py +29 -0
  52. reflexr/sql/migrations/versions/0003_event_types.py +43 -0
  53. reflexr/sql/migrations/versions/0004_cursors.py +35 -0
  54. reflexr/sql/migrations/versions/0005_qualified_facts.py +101 -0
  55. reflexr/sql/migrations/versions/0006_stored_rules.py +44 -0
  56. reflexr/sql/migrations/versions/0007_row_level_security.py +57 -0
  57. reflexr/sql/migrations/versions/0008_rule_history.py +56 -0
  58. reflexr/sql/schema.py +66 -0
  59. reflexr/sql/sqlite.py +18 -0
  60. reflexr/sql/storage.py +716 -0
  61. reflexr/sql/tables.py +236 -0
  62. reflexr/sql/tenancy.py +18 -0
  63. reflexr/telemetry/__init__.py +51 -0
  64. reflexr/telemetry/attributes.py +63 -0
  65. reflexr/telemetry/metrics.py +231 -0
  66. reflexr/telemetry/telemetry.py +141 -0
  67. reflexr/telemetry/traces.py +68 -0
  68. reflexr/workspace/__init__.py +80 -0
  69. reflexr/workspace/actions.py +199 -0
  70. reflexr/workspace/executor.py +469 -0
  71. reflexr/workspace/memory.py +370 -0
  72. reflexr/workspace/reactor.py +573 -0
  73. reflexr/workspace/results.py +69 -0
  74. reflexr/workspace/schedules.py +102 -0
  75. reflexr/workspace/storage.py +353 -0
  76. reflexr/workspace/workspace.py +1622 -0
  77. reflexr-0.1.0.dist-info/METADATA +196 -0
  78. reflexr-0.1.0.dist-info/RECORD +80 -0
  79. reflexr-0.1.0.dist-info/WHEEL +4 -0
  80. reflexr-0.1.0.dist-info/licenses/LICENSE +21 -0
reflexr/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """reflexr: rules over event streams that run LLM workflows.
2
+
3
+ Applications publish events into tenant-scoped workspaces. Rules watch each workspace's log, and
4
+ when a rule's condition holds, it runs a workflow: a pydantic-ai agent, a pydantic-graph graph,
5
+ or a plain async function::
6
+
7
+ class OpsEvent(Event, abstract=True, event_namespace="ops"): ...
8
+
9
+
10
+ class ServiceError(OpsEvent, name="service.error"): # ops:service.error
11
+ service: str
12
+ severity: int
13
+
14
+
15
+ error_spike = Rule(
16
+ name="ops:error-spike",
17
+ when=on(ServiceError).where(F.severity >= 7).count(at_least=3, within=minute),
18
+ scope=by(F.service),
19
+ then=run("triage"),
20
+ )
21
+
22
+ reflexr's design is at <https://lattice.alexnodeland.com/reflexr/architecture/>.
23
+ """
24
+
25
+ from importlib.metadata import version
26
+
27
+ from reflexr.core import (
28
+ Actor,
29
+ AgentActor,
30
+ Envelope,
31
+ EvaluatorActor,
32
+ Event,
33
+ ExternalAgentActor,
34
+ F,
35
+ Feedback,
36
+ InvalidRule,
37
+ NotFound,
38
+ Rejection,
39
+ RetryPolicy,
40
+ Rule,
41
+ SourceActor,
42
+ SystemActor,
43
+ UserActor,
44
+ by,
45
+ field,
46
+ new_id,
47
+ on,
48
+ run,
49
+ sequence,
50
+ )
51
+
52
+ __version__ = version("reflexr")
53
+
54
+ __all__ = [
55
+ "Actor",
56
+ "AgentActor",
57
+ "Envelope",
58
+ "EvaluatorActor",
59
+ "Event",
60
+ "ExternalAgentActor",
61
+ "F",
62
+ "Feedback",
63
+ "InvalidRule",
64
+ "NotFound",
65
+ "Rejection",
66
+ "RetryPolicy",
67
+ "Rule",
68
+ "SourceActor",
69
+ "SystemActor",
70
+ "UserActor",
71
+ "__version__",
72
+ "by",
73
+ "field",
74
+ "new_id",
75
+ "on",
76
+ "run",
77
+ "sequence",
78
+ ]
@@ -0,0 +1,24 @@
1
+ """Agent and graph actions: pydantic-ai and pydantic-graph as adapters of the action port.
2
+
3
+ An :class:`AgentAction` runs a pydantic-ai agent whose ``deps_type`` is
4
+ :class:`~reflexr.workspace.Reaction`; the :class:`EventContext` capability gives it tools to
5
+ read the log and emit events. A :class:`GraphAction` runs a pydantic-graph graph, checkpointed
6
+ at its step boundaries so a retry resumes after the last completed step. :func:`function_model`
7
+ scripts a model for tests.
8
+ """
9
+
10
+ from reflexr.agent.actions import AgentAction
11
+ from reflexr.agent.context import EventContext
12
+ from reflexr.agent.graphs import GraphAction
13
+ from reflexr.agent.models import Respond, function_model
14
+ from reflexr.agent.render import envelope_text, firing_text
15
+
16
+ __all__ = [
17
+ "AgentAction",
18
+ "EventContext",
19
+ "GraphAction",
20
+ "Respond",
21
+ "envelope_text",
22
+ "firing_text",
23
+ "function_model",
24
+ ]
@@ -0,0 +1,63 @@
1
+ """Agent actions: pydantic-ai agents as adapters of reflexr's action port (ADR-0025)."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import KW_ONLY, dataclass
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic_ai import Agent
8
+ from pydantic_ai.usage import UsageLimits
9
+
10
+ from reflexr.agent.render import firing_text
11
+ from reflexr.workspace import Reaction
12
+
13
+ DEFAULT_PROMPT = "Respond to this firing."
14
+
15
+
16
+ @dataclass
17
+ class AgentAction[D, O]:
18
+ """Run a pydantic-ai agent in response to a firing.
19
+
20
+ The agent's ``deps_type`` is ``Reaction[D]``; add the :class:`~reflexr.agent.EventContext`
21
+ capability to give it tools for the log. By default the prompt describes the firing: the
22
+ rule, the scope and the events that made it fire. The agent's output is the run's output.
23
+ Each run joins its causal chain's conversation, so the traces of one incident are one
24
+ session.
25
+
26
+ Args:
27
+ agent: The agent.
28
+ name: The name rules refer to the action by; defaults to the agent's name.
29
+ prompt: The user prompt, or a function of the reaction that returns it. Defaults to a
30
+ description of the firing.
31
+ usage_limits: Limits on requests and tokens per attempt.
32
+ params: The model of the params rules pass the action. Its prompt and tools read them
33
+ with ``reaction.params_as(Model)``, naming the model again.
34
+ """
35
+
36
+ agent: Agent[Reaction[D], O]
37
+ _: KW_ONLY
38
+ name: str = ""
39
+ prompt: str | Callable[[Reaction[D]], str] | None = None
40
+ usage_limits: UsageLimits | None = None
41
+ params: type[BaseModel] | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ self.name = self.name or self.agent.name or ""
45
+ if not self.name:
46
+ raise ValueError("name the action, or the agent")
47
+
48
+ async def __call__(self, reaction: Reaction[D]) -> O:
49
+ """Run the agent for one attempt of a run."""
50
+ result = await self.agent.run(
51
+ self._prompt(reaction),
52
+ deps=reaction,
53
+ conversation_id=reaction.run.correlation_id,
54
+ usage_limits=self.usage_limits,
55
+ )
56
+ return result.output
57
+
58
+ def _prompt(self, reaction: Reaction[D]) -> str:
59
+ if callable(self.prompt):
60
+ return self.prompt(reaction)
61
+ if self.prompt is not None:
62
+ return self.prompt
63
+ return f"{firing_text(reaction)}\n\n{DEFAULT_PROMPT}"
@@ -0,0 +1,90 @@
1
+ """The EventContext capability: reflexr's integration with pydantic-ai (ADR-0008).
2
+
3
+ Add it to an agent whose ``deps_type`` is :class:`~reflexr.workspace.Reaction`. It gives the
4
+ agent a tool to read back through the workspace's log and, if it may emit events, a tool to
5
+ publish them. The prompt of an :class:`~reflexr.agent.AgentAction` describes what fired.
6
+
7
+ Its ``wrap_run`` hook runs inside pydantic-ai's ``invoke_agent`` span and adds reflexr's
8
+ attribution to it.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import KW_ONLY, dataclass, field
13
+ from typing import Any, override
14
+
15
+ from opentelemetry import trace
16
+ from pydantic_ai import AgentRunResult, FunctionToolset, RunContext
17
+ from pydantic_ai.capabilities import AbstractCapability
18
+ from pydantic_ai.capabilities.abstract import WrapRunHandler
19
+
20
+ from reflexr.agent.tools import event_tools
21
+ from reflexr.core import Event
22
+ from reflexr.telemetry import attributes as a
23
+ from reflexr.telemetry import chain_attributes, workspace_attributes
24
+ from reflexr.workspace import Reaction
25
+
26
+ type Context = RunContext[Reaction[Any]]
27
+
28
+ INTRO = (
29
+ "You respond to events in an event log. A rule watching the log fired, and you are its "
30
+ "response: the prompt describes the firing. Read further back in the log if you need to."
31
+ )
32
+
33
+
34
+ @dataclass
35
+ class EventContext(AbstractCapability[Reaction[Any]]):
36
+ """Make an agent a response to a firing.
37
+
38
+ Args:
39
+ emit: The event types the agent may publish with the ``emit_event`` tool. Empty, the
40
+ default, gives it no way to publish.
41
+ max_event_chars: How much of each event ``read_events`` shows the model.
42
+ read_limit: The most envelopes one ``read_events`` call returns.
43
+ max_retries: How many times the model may retry a refused tool call.
44
+ """
45
+
46
+ emit: Sequence[type[Event]] = ()
47
+ _: KW_ONLY
48
+ max_event_chars: int = 2000
49
+ read_limit: int = 50
50
+ max_retries: int = 3
51
+ _toolset: FunctionToolset[Reaction[Any]] = field(init=False, repr=False)
52
+
53
+ def __post_init__(self) -> None:
54
+ self._toolset = event_tools(
55
+ self.emit,
56
+ read_limit=self.read_limit,
57
+ max_event_chars=self.max_event_chars,
58
+ max_retries=self.max_retries,
59
+ )
60
+
61
+ @override
62
+ def get_toolset(self) -> FunctionToolset[Reaction[Any]]:
63
+ """Return the tools for reading the log and emitting events."""
64
+ return self._toolset
65
+
66
+ @override
67
+ def get_instructions(self) -> str:
68
+ """Return the instructions: how to respond, and what the agent may emit."""
69
+ if not self.emit:
70
+ return INTRO
71
+ kinds = ", ".join(t.event_type for t in self.emit)
72
+ return f"{INTRO}\n\nYou may publish these kinds of event with emit_event: {kinds}."
73
+
74
+ @override
75
+ async def wrap_run(self, ctx: Context, *, handler: WrapRunHandler) -> AgentRunResult[Any]:
76
+ """Attribute the agent's span to its workspace, rule, run and causal chain."""
77
+ reaction = ctx.deps
78
+ run = reaction.run
79
+ workspace = reaction.workspace
80
+ trace.get_current_span().set_attributes(
81
+ {
82
+ **workspace_attributes(workspace.tenant_id, workspace.workspace_id),
83
+ **chain_attributes(run.correlation_id),
84
+ a.RULE: run.rule,
85
+ a.SCOPE: run.scope_key,
86
+ a.RUN_ID: run.id,
87
+ a.ATTEMPT: run.attempts,
88
+ }
89
+ )
90
+ return await handler()
@@ -0,0 +1,411 @@
1
+ """Graph actions: pydantic-graph graphs as adapters of the action port, checkpointed (ADR-0043).
2
+
3
+ A :class:`GraphAction` drives a ``GraphBuilder`` graph step by step. At each step boundary
4
+ where it is safe to, it saves the graph's state and the tasks that run next to the run, so a
5
+ retry, or another executor after a crash, resumes after the last saved step.
6
+
7
+ It is safe to checkpoint when nothing runs in parallel: one task comes next, and it is not
8
+ between a fork and its join, and the next node's input type is known. Inside a fork, a crash
9
+ resumes from the checkpoint before the fork, so the branches that had finished run again: the
10
+ same at-least-once guarantee runs have everywhere.
11
+
12
+ A step's input type is its ``StepContext`` annotation. A fork runs no code of its own, so its
13
+ input is whatever the edges into it carry: when every such edge comes, with no transform, from a
14
+ step with a return annotation or the graph's start, and they all carry the same type, that is
15
+ the fork's input type. The boundary before a decision is never saved: a decision runs no code
16
+ either, so the boundary after it saves the same progress.
17
+
18
+ A checkpoint is saved only if what it restores reads back as it was: the state, the graph's
19
+ inputs and the next node's inputs. A checkpoint that cannot be resumed, because the graph or
20
+ its types changed since, is ignored and the run starts over; the attempt's span says why.
21
+ """
22
+
23
+ import inspect
24
+ from collections.abc import Callable, Iterator, Mapping, Sequence
25
+ from contextlib import suppress
26
+ from dataclasses import KW_ONLY, dataclass, field
27
+ from typing import Any, TypeVar, get_args, get_origin, get_type_hints
28
+
29
+ from opentelemetry import trace
30
+ from pydantic import (
31
+ BaseModel,
32
+ JsonValue,
33
+ PydanticSchemaGenerationError,
34
+ TypeAdapter,
35
+ ValidationError,
36
+ )
37
+ from pydantic_core import PydanticSerializationError
38
+ from pydantic_graph import (
39
+ Decision,
40
+ EndMarker,
41
+ EndNode,
42
+ Fork,
43
+ Graph,
44
+ GraphRun,
45
+ GraphTask,
46
+ GraphTaskRequest,
47
+ StartNode,
48
+ Step,
49
+ StepContext,
50
+ )
51
+
52
+ # pydantic-graph 2.51 does not re-export these from its top level, so they come from its modules,
53
+ # here and nowhere else in reflexr, so that if they move only these lines change: the ids in a
54
+ # task's fork stack, which a checkpoint saves, and the markers on an edge's path, which inferring
55
+ # a fork's input type reads.
56
+ from pydantic_graph.id_types import ForkStackItem, NodeID, NodeRunID
57
+ from pydantic_graph.paths import DestinationMarker, TransformMarker
58
+
59
+ from reflexr.telemetry import attributes as a
60
+ from reflexr.workspace import Reaction
61
+
62
+ CHECKPOINT_VERSION = 1
63
+ """The version of the checkpoint format, stored in each checkpoint."""
64
+
65
+ _STACK: TypeAdapter[tuple[ForkStackItem, ...]] = TypeAdapter(tuple[ForkStackItem, ...])
66
+
67
+ type _Batch = Sequence[GraphTask] | EndMarker[Any]
68
+ """What a step boundary yields: the tasks that run next, or the graph's output."""
69
+
70
+ DISCARDED = "reflexr.checkpoint.discarded"
71
+ """The event on an attempt's span when the run's checkpoint cannot be resumed."""
72
+
73
+
74
+ class _Unwritable(Exception):
75
+ """A value its type would not read back as the same value, so it is not saved."""
76
+
77
+
78
+ @dataclass
79
+ class GraphAction[D, S, I, O]:
80
+ """Run a pydantic-graph graph in response to a firing, checkpointed after its steps.
81
+
82
+ The graph's deps are the :class:`~reflexr.workspace.Reaction`, so steps can read the
83
+ firing and emit events. Its output is the run's output.
84
+
85
+ A checkpoint saves the next node's inputs as that node's input type. A step's is its
86
+ ``StepContext`` annotation, and the end node's the graph's output type. A fork's is inferred
87
+ from the edges into it: the return type of the steps they come from, or the graph's input
88
+ type from its start. It stays unknown when an edge has a transform, or comes from a step with
89
+ no return annotation (or ``Any``), a decision, a join or a fork, or when the edges carry
90
+ different types. A boundary before a node whose input type is unknown is not saved, and
91
+ neither is one before a decision, which runs no code: the boundary after it saves the same.
92
+
93
+ Nor is a boundary whose state, graph inputs or next inputs would not read back equal to
94
+ what they were, such as a model given to a stream step or a ``BaseNode``, whose input types
95
+ say ``Any``.
96
+
97
+ Args:
98
+ graph: A graph built with ``GraphBuilder``, whose deps type is ``Reaction[D]``.
99
+ name: The name rules refer to the action by; defaults to the graph's name.
100
+ state: Builds the graph's initial state from the reaction; defaults to the state
101
+ type's constructor with no arguments.
102
+ inputs: Builds the graph's inputs from the reaction; defaults to None.
103
+ input_types: Input types by node id, for the steps and forks whose type reflexr
104
+ cannot read or infer, such as a stream step, whose input type reads as ``Any``, or a
105
+ fork after a transform. An explicit type wins over an annotation or an inferred one.
106
+ params: The model of the params rules pass the action. Its steps, and ``state`` and
107
+ ``inputs``, read them with ``reaction.params_as(Model)``, naming the model again.
108
+
109
+ Raises:
110
+ ValueError: If the action has no name, or ``input_types`` names a node that is not a
111
+ step or a fork.
112
+ """
113
+
114
+ graph: Graph[S, Reaction[D], I, O]
115
+ _: KW_ONLY
116
+ name: str = ""
117
+ state: Callable[[Reaction[D]], S] | None = None
118
+ inputs: Callable[[Reaction[D]], I] | None = None
119
+ input_types: Mapping[str, Any] = field(default_factory=dict[str, Any])
120
+ params: type[BaseModel] | None = None
121
+ _types: dict[str, TypeAdapter[Any]] = field(init=False, repr=False)
122
+
123
+ def __post_init__(self) -> None:
124
+ self.name = self.name or self.graph.name or ""
125
+ if not self.name:
126
+ raise ValueError("name the action, or the graph")
127
+ self._types = _input_adapters(self.graph, self.input_types)
128
+
129
+ async def __call__(self, reaction: Reaction[D]) -> JsonValue:
130
+ """Run the graph for one attempt, resuming from the run's checkpoint if it has one."""
131
+ graph = self.graph
132
+ saved = self._restorable(reaction.run.checkpoint)
133
+ if saved is not None and "output" in saved:
134
+ return saved["output"] # the graph finished; only recording the run did not
135
+ resumed = None if saved is None else self._resume(saved, reaction.run.epoch)
136
+ if resumed is None:
137
+ state = self.state(reaction) if self.state else graph.state_type()
138
+ # Without an inputs builder the graph gets None, which its input type must accept.
139
+ inputs = (
140
+ self.inputs(reaction)
141
+ if self.inputs
142
+ else TypeAdapter(graph.input_type).validate_python(None)
143
+ )
144
+ pending: EndMarker[O] | Sequence[GraphTaskRequest] | None = None
145
+ else:
146
+ state, inputs, pending = resumed
147
+ tracer = reaction.workspace.telemetry.tracer
148
+ async with graph.iter(state=state, deps=reaction, inputs=inputs, infer_name=False) as run:
149
+ if pending is None:
150
+ pending = await anext(run) # runs only the start node
151
+ await self._checkpoint(reaction, run, inputs, pending, "__start__")
152
+ while not isinstance(pending, EndMarker):
153
+ ran = ", ".join(str(task.node_id) for task in pending)
154
+ with tracer.start_as_current_span(
155
+ f"execute_step {ran}", attributes={a.RUN_ID: reaction.run.id}
156
+ ):
157
+ pending = await run.next(pending)
158
+ await self._checkpoint(reaction, run, inputs, pending, ran)
159
+ return TypeAdapter(graph.output_type).dump_python(pending.value, mode="json")
160
+
161
+ def _restorable(self, checkpoint: JsonValue) -> dict[str, Any] | None:
162
+ """Return the run's checkpoint if this graph wrote it, or None to start over.
163
+
164
+ A checkpoint from another version of the format, or from a graph whose nodes have
165
+ changed since, or whose next node's input type is no longer known, cannot be resumed;
166
+ the run starts over, as any retry may, and the attempt's span says why.
167
+ """
168
+ if checkpoint is None:
169
+ return None # nothing saved yet
170
+ if not isinstance(checkpoint, dict) or checkpoint.get("v") != CHECKPOINT_VERSION:
171
+ return _discarded("format")
172
+ saved: dict[str, Any] = checkpoint
173
+ if saved.get("nodes") != sorted(self.graph.nodes):
174
+ return _discarded("graph_changed")
175
+ # A fork's inferred type depends on the edges into it, not only on the nodes, so a
176
+ # checkpoint's next node may have no type now (an edge into it gained a transform).
177
+ frontier: list[dict[str, Any]] = saved.get("frontier", [])
178
+ if any(task["node_id"] not in self._types for task in frontier):
179
+ return _discarded("untyped")
180
+ return saved
181
+
182
+ def _resume(
183
+ self, saved: dict[str, Any], epoch: int
184
+ ) -> tuple[S, I, list[GraphTaskRequest]] | None:
185
+ """Read a checkpoint back, or return None to start over if it no longer validates.
186
+
187
+ Every checkpoint saved now reads back, so one that does not was saved before a type it
188
+ holds changed, or by a build that did not check.
189
+ """
190
+ graph = self.graph
191
+ try:
192
+ state = TypeAdapter(graph.state_type).validate_python(saved["state"])
193
+ inputs = TypeAdapter(graph.input_type).validate_python(saved["inputs"])
194
+ requests = self._requests(saved, epoch)
195
+ except ValidationError as error:
196
+ return _discarded("invalid", error)
197
+ return state, inputs, requests
198
+
199
+ def _requests(self, saved: dict[str, Any], epoch: int) -> list[GraphTaskRequest]:
200
+ requests: list[GraphTaskRequest] = []
201
+ for task in saved["frontier"]:
202
+ node_id: str = task["node_id"]
203
+ stack = tuple(
204
+ # Restored runs number their node runs afresh, so prefix the saved ids with the
205
+ # attempt's epoch, which a retry never sets back, unlike its attempt number.
206
+ ForkStackItem(
207
+ item.fork_id, NodeRunID(f"e{epoch}:{item.node_run_id}"), item.thread_index
208
+ )
209
+ for item in _STACK.validate_python(task["fork_stack"])
210
+ )
211
+ inputs = self._types[node_id].validate_python(task["inputs"])
212
+ requests.append(GraphTaskRequest(NodeID(node_id), inputs, stack))
213
+ return requests
214
+
215
+ async def _checkpoint(
216
+ self,
217
+ reaction: Reaction[D],
218
+ run: GraphRun[S, Reaction[D], O],
219
+ inputs: I,
220
+ batch: _Batch,
221
+ ran: str,
222
+ ) -> None:
223
+ saved = self._snapshot(run, inputs, batch)
224
+ if saved is not None:
225
+ await reaction.checkpoint(ran, saved)
226
+
227
+ def _snapshot(self, run: GraphRun[S, Reaction[D], O], inputs: I, batch: _Batch) -> JsonValue:
228
+ """Return a checkpoint of the run at this boundary, or None if it is not safe to save."""
229
+ graph = self.graph
230
+ header: dict[str, Any] = {"v": CHECKPOINT_VERSION, "nodes": sorted(graph.nodes)}
231
+ if isinstance(batch, EndMarker):
232
+ return {
233
+ **header,
234
+ "inputs": TypeAdapter(graph.input_type).dump_python(inputs, mode="json"),
235
+ "state": TypeAdapter(graph.state_type).dump_python(run.state, mode="json"),
236
+ "output": TypeAdapter(graph.output_type).dump_python(batch.value, mode="json"),
237
+ }
238
+ if not _quiescent(graph, batch) or batch[0].node_id not in self._types:
239
+ return None
240
+ [task] = batch
241
+ if isinstance(task.inputs, Iterator):
242
+ return None # writing a one-shot iterator down would use it up before the node runs
243
+ # What cannot be written down, or would not read back as it was, is not saved: the run
244
+ # resumes from an earlier step instead.
245
+ try:
246
+ return {
247
+ **header,
248
+ "inputs": _write(TypeAdapter(graph.input_type), inputs),
249
+ "state": _write(TypeAdapter(graph.state_type), run.state),
250
+ "frontier": [
251
+ {
252
+ "node_id": task.node_id,
253
+ "inputs": _write(self._types[task.node_id], task.inputs),
254
+ "fork_stack": _STACK.dump_python(task.fork_stack, mode="json"),
255
+ }
256
+ ],
257
+ }
258
+ except _Unwritable:
259
+ return None
260
+
261
+
262
+ def _input_adapters(
263
+ graph: Graph[Any, Any, Any, Any], overrides: Mapping[str, Any]
264
+ ) -> dict[str, TypeAdapter[Any]]:
265
+ """Map each node whose input type is known to an adapter for its inputs.
266
+
267
+ An explicit type wins. Otherwise a step's type is its annotation, the end node's the graph's
268
+ output type, and a fork's the one type the edges into it carry. A decision has none, so the
269
+ boundary before it is never saved. An explicit type pydantic cannot handle raises; an
270
+ inferred one is dropped, so the graph runs as it would without it, with no checkpoint before
271
+ that fork.
272
+
273
+ Raises:
274
+ ValueError: If ``overrides`` names a node that is not a step or a fork, such as a
275
+ decision, before which nothing is saved.
276
+ """
277
+ wrong = sorted(n for n in overrides if not isinstance(graph.nodes.get(NodeID(n)), Step | Fork))
278
+ if wrong:
279
+ raise ValueError(f"input_types names nodes that are not steps or forks: {', '.join(wrong)}")
280
+ declared: dict[str, Any] = {}
281
+ for node_id, node in graph.nodes.items():
282
+ if isinstance(node, Step):
283
+ found = _step_input_type(node)
284
+ if found is not None:
285
+ declared[node_id] = found
286
+ elif isinstance(node, EndNode):
287
+ declared[node_id] = graph.output_type
288
+ declared.update(overrides)
289
+ adapters = {node_id: TypeAdapter(kind) for node_id, kind in declared.items()}
290
+ into = _edges_into(graph)
291
+ for node_id, node in graph.nodes.items():
292
+ if isinstance(node, Fork) and node_id not in declared:
293
+ kind = _carried(graph, into.get(node_id, []))
294
+ if kind is not None:
295
+ with suppress(PydanticSchemaGenerationError):
296
+ adapters[node_id] = TypeAdapter(kind)
297
+ return adapters
298
+
299
+
300
+ def _step_input_type(step: Step[Any, Any, Any, Any]) -> Any:
301
+ """The ``InputT`` of a step's ``StepContext[StateT, DepsT, InputT]`` parameter, if typed."""
302
+ call = step.call
303
+ context = next(iter(inspect.signature(call).parameters), "")
304
+ hint = get_type_hints(call).get(context)
305
+ if get_origin(hint) is not StepContext:
306
+ return None
307
+ return get_args(hint)[2]
308
+
309
+
310
+ def _step_output_type(step: Step[Any, Any, Any, Any]) -> Any:
311
+ """A step's return annotation, or None if it has none or it says nothing (Any, a TypeVar).
312
+
313
+ Read like its input annotation, without ``Annotated`` metadata: the graph never validated
314
+ the value against constraints, so a resumed run must not either.
315
+ """
316
+ hint = get_type_hints(step.call).get("return")
317
+ return None if hint is Any or isinstance(hint, TypeVar) else hint
318
+
319
+
320
+ def _edges_into(graph: Graph[Any, Any, Any, Any]) -> dict[NodeID, list[tuple[NodeID, bool]]]:
321
+ """Each node's incoming edges: the node each comes from, and whether it has a transform.
322
+
323
+ The graph's paths end at one destination each, forks having been split out of them when
324
+ it was built; a decision's branches are paths from the decision.
325
+ """
326
+ paths = [
327
+ (source, path) for source, outgoing in graph.edges_by_source.items() for path in outgoing
328
+ ] + [
329
+ (node.id, branch.path)
330
+ for node in graph.nodes.values()
331
+ if isinstance(node, Decision)
332
+ for branch in node.branches
333
+ ]
334
+ into: dict[NodeID, list[tuple[NodeID, bool]]] = {}
335
+ for source, path in paths:
336
+ transformed = any(isinstance(item, TransformMarker) for item in path.items)
337
+ for item in path.items:
338
+ if isinstance(item, DestinationMarker):
339
+ into.setdefault(item.destination_id, []).append((source, transformed))
340
+ return into
341
+
342
+
343
+ def _carried(graph: Graph[Any, Any, Any, Any], edges: Sequence[tuple[NodeID, bool]]) -> Any:
344
+ """The type every one of these edges carries, or None if one is unknown or they differ."""
345
+ kinds = [None if transformed else _output_type(graph, source) for source, transformed in edges]
346
+ first = kinds[0] if kinds else None
347
+ if first is None or any(kind != first for kind in kinds):
348
+ return None
349
+ return first
350
+
351
+
352
+ def _output_type(graph: Graph[Any, Any, Any, Any], source: NodeID) -> Any:
353
+ """The type a node passes along its edges, or None if it is not known."""
354
+ node = graph.nodes[source]
355
+ if isinstance(node, StartNode):
356
+ return graph.input_type
357
+ if isinstance(node, Step):
358
+ return _step_output_type(node)
359
+ return None # a decision's input, a join's reduced value, or a fork's items
360
+
361
+
362
+ def _write(adapter: TypeAdapter[Any], value: object) -> JsonValue:
363
+ """Write a value down as JSON, if it reads back equal to it.
364
+
365
+ Raises:
366
+ _Unwritable: If it does not: a model saved through a type that says ``Any`` reads back
367
+ as a dict.
368
+ """
369
+ try:
370
+ dumped = adapter.dump_python(value, mode="json", warnings="error")
371
+ back = adapter.validate_python(dumped)
372
+ except (PydanticSerializationError, ValidationError) as error:
373
+ raise _Unwritable from error
374
+ if back != value:
375
+ raise _Unwritable
376
+ return dumped
377
+
378
+
379
+ def _discarded(reason: str, error: ValidationError | None = None) -> None:
380
+ """Say on the attempt's span why the run's checkpoint is not resumed, and resume nothing.
381
+
382
+ The reason is ``format``, ``graph_changed``, ``untyped`` or ``invalid``. What did not
383
+ validate is described by location and message, without the values, which may be private.
384
+ """
385
+ attributes = {a.CHECKPOINT_REASON: reason}
386
+ if error is not None:
387
+ problems = [
388
+ f"{'.'.join(map(str, e['loc'])) or 'the value'}: {e['msg']}"
389
+ for e in error.errors(include_input=False)
390
+ ]
391
+ attributes[a.CHECKPOINT_ERROR] = f"{error.title}: {'; '.join(problems)}"
392
+ trace.get_current_span().add_event(DISCARDED, attributes)
393
+
394
+
395
+ def _quiescent(graph: Graph[Any, Any, Any, Any], batch: Sequence[GraphTask]) -> bool:
396
+ """Whether nothing but the one next task is in flight, and no join holds partial results."""
397
+ if len(batch) != 1:
398
+ return False
399
+ [task] = batch
400
+ forks = [f for f in task.fork_stack if isinstance(graph.nodes.get(f.fork_id), Fork)]
401
+ if not forks:
402
+ return True
403
+ joined: set[str] = set()
404
+ for parent in graph.parent_forks.values():
405
+ joined |= {parent.fork_id, *parent.intermediate_nodes}
406
+ if any(f.fork_id not in joined for f in forks):
407
+ return False # a fork without a join: its branches never meet again
408
+ return not any(
409
+ task.node_id == join_id or task.node_id in parent.intermediate_nodes
410
+ for join_id, parent in graph.parent_forks.items()
411
+ )