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.
- reflexr/__init__.py +78 -0
- reflexr/agent/__init__.py +24 -0
- reflexr/agent/actions.py +63 -0
- reflexr/agent/context.py +90 -0
- reflexr/agent/graphs.py +411 -0
- reflexr/agent/models.py +69 -0
- reflexr/agent/render.py +33 -0
- reflexr/agent/tools.py +102 -0
- reflexr/core/__init__.py +379 -0
- reflexr/core/actors.py +148 -0
- reflexr/core/conditions.py +463 -0
- reflexr/core/errors.py +107 -0
- reflexr/core/evaluation.py +488 -0
- reflexr/core/events.py +656 -0
- reflexr/core/feedback.py +164 -0
- reflexr/core/ids.py +81 -0
- reflexr/core/protocol.py +306 -0
- reflexr/core/rules.py +290 -0
- reflexr/core/runs.py +264 -0
- reflexr/core/schema.py +66 -0
- reflexr/core/state.py +164 -0
- reflexr/core/stored.py +214 -0
- reflexr/core/tagged.py +13 -0
- reflexr/evals/__init__.py +38 -0
- reflexr/evals/action.py +61 -0
- reflexr/evals/context.py +59 -0
- reflexr/evals/experiments.py +81 -0
- reflexr/evals/measures.py +131 -0
- reflexr/evals/source.py +142 -0
- reflexr/fastapi/__init__.py +30 -0
- reflexr/fastapi/openapi.py +42 -0
- reflexr/fastapi/router.py +280 -0
- reflexr/fastapi/stream.py +211 -0
- reflexr/langfuse/__init__.py +35 -0
- reflexr/langfuse/client.py +26 -0
- reflexr/langfuse/tracing.py +88 -0
- reflexr/litellm/__init__.py +38 -0
- reflexr/litellm/gateway.py +198 -0
- reflexr/mcp/__init__.py +10 -0
- reflexr/mcp/server.py +573 -0
- reflexr/otel/__init__.py +52 -0
- reflexr/otel/configure.py +142 -0
- reflexr/py.typed +0 -0
- reflexr/scores/__init__.py +25 -0
- reflexr/scores/mapping.py +31 -0
- reflexr/scores/mirror.py +179 -0
- reflexr/sql/__init__.py +18 -0
- reflexr/sql/migrations/env.py +17 -0
- reflexr/sql/migrations/script.py.mako +26 -0
- reflexr/sql/migrations/versions/0001_initial.py +141 -0
- reflexr/sql/migrations/versions/0002_scope_heads.py +29 -0
- reflexr/sql/migrations/versions/0003_event_types.py +43 -0
- reflexr/sql/migrations/versions/0004_cursors.py +35 -0
- reflexr/sql/migrations/versions/0005_qualified_facts.py +101 -0
- reflexr/sql/migrations/versions/0006_stored_rules.py +44 -0
- reflexr/sql/migrations/versions/0007_row_level_security.py +57 -0
- reflexr/sql/migrations/versions/0008_rule_history.py +56 -0
- reflexr/sql/schema.py +66 -0
- reflexr/sql/sqlite.py +18 -0
- reflexr/sql/storage.py +716 -0
- reflexr/sql/tables.py +236 -0
- reflexr/sql/tenancy.py +18 -0
- reflexr/telemetry/__init__.py +51 -0
- reflexr/telemetry/attributes.py +63 -0
- reflexr/telemetry/metrics.py +231 -0
- reflexr/telemetry/telemetry.py +141 -0
- reflexr/telemetry/traces.py +68 -0
- reflexr/workspace/__init__.py +80 -0
- reflexr/workspace/actions.py +199 -0
- reflexr/workspace/executor.py +469 -0
- reflexr/workspace/memory.py +370 -0
- reflexr/workspace/reactor.py +573 -0
- reflexr/workspace/results.py +69 -0
- reflexr/workspace/schedules.py +102 -0
- reflexr/workspace/storage.py +353 -0
- reflexr/workspace/workspace.py +1622 -0
- reflexr-0.1.0.dist-info/METADATA +196 -0
- reflexr-0.1.0.dist-info/RECORD +80 -0
- reflexr-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
reflexr/agent/actions.py
ADDED
|
@@ -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}"
|
reflexr/agent/context.py
ADDED
|
@@ -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()
|
reflexr/agent/graphs.py
ADDED
|
@@ -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
|
+
)
|