reactifact 0.6.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 (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/__init__.py ADDED
@@ -0,0 +1,96 @@
1
+ """reactifact's core public API.
2
+
3
+ Deliberately small: the primitives from the README's "Core primitives"
4
+ section, plus the everyday building blocks (tool calling, sessions, the LLM
5
+ provider protocol) most agents need regardless of what else they use.
6
+
7
+ Everything else — eval, tracing, checkpoint/branch backends beyond the
8
+ in-memory default, the chat/web layer, the adaptive scheduler, replay,
9
+ structured-LLM helpers, viz, prompts — is one level down, in its own
10
+ submodule (`reactifact.eval`, `reactifact.tracing`, `reactifact.chat`, ...). Import it
11
+ from there:
12
+
13
+ from reactifact.structured import structured_llm
14
+ from reactifact.tracing import TraceStore
15
+ from reactifact.chat import ChatAssistant
16
+
17
+ This keeps `dir(reactifact)` / editor autocomplete to what you need to build a
18
+ first agent, and keeps optional-dependency features (Postgres, FastAPI) out
19
+ of the names you see by default even though they were always cheap to import
20
+ (the driver itself is still lazily imported inside the class, see
21
+ `reactifact._extras`).
22
+ """
23
+
24
+ from .agents import Agent, create_agent
25
+ from .artifacts import Artifact
26
+ from .branching import MergeConflict
27
+ from .budget import Budget, RunOutcome, RunStats
28
+ from .consume import Consume, consume
29
+ from .context import Context, View
30
+ from .effects import Effects, Handle
31
+ from .events import Event, EventType
32
+ from .interrupt import PendingQuestion
33
+ from .patches import Create, Delete, Link, Patch, Relation, Unlink, Update
34
+ from .produce import Produce, produce
35
+ from .providers import (
36
+ EmbeddingProvider,
37
+ FakeEmbedder,
38
+ FakeLLM,
39
+ LLMProvider,
40
+ LLMRequest,
41
+ LLMResponse,
42
+ LLMResponseChunk,
43
+ Message,
44
+ )
45
+ from .resources import RuntimeResources
46
+ from .runtime import Runtime
47
+ from .session import Session, SessionStore
48
+ from .tools import FunctionTool, Tool, ToolOutput, tool
49
+ from .triggers import Trigger
50
+
51
+ __version__ = "0.6.0"
52
+
53
+ __all__ = [
54
+ "Agent",
55
+ "Artifact",
56
+ "Budget",
57
+ "Consume",
58
+ "Context",
59
+ "Create",
60
+ "Delete",
61
+ "EmbeddingProvider",
62
+ "Effects",
63
+ "Event",
64
+ "EventType",
65
+ "FakeEmbedder",
66
+ "FakeLLM",
67
+ "FunctionTool",
68
+ "Handle",
69
+ "Link",
70
+ "Message",
71
+ "MergeConflict",
72
+ "Patch",
73
+ "PendingQuestion",
74
+ "Produce",
75
+ "Relation",
76
+ "RunOutcome",
77
+ "RunStats",
78
+ "Runtime",
79
+ "RuntimeResources",
80
+ "LLMProvider",
81
+ "LLMRequest",
82
+ "LLMResponse",
83
+ "LLMResponseChunk",
84
+ "Session",
85
+ "SessionStore",
86
+ "Tool",
87
+ "ToolOutput",
88
+ "Trigger",
89
+ "Unlink",
90
+ "Update",
91
+ "View",
92
+ "consume",
93
+ "create_agent",
94
+ "produce",
95
+ "tool",
96
+ ]
reactifact/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Console-script / `python -m reactifact` entry point — see `reactifact.cli`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
reactifact/_extras.py ADDED
@@ -0,0 +1,36 @@
1
+ """reactifact._extras — readable errors for optional dependencies.
2
+
3
+ Core must stay dependency-free; extra-gated features lazily import their
4
+ driver. Instead of a bare `ModuleNotFoundError`, `require_extra` explains what
5
+ it needs and how to install it:
6
+
7
+ from reactifact._extras import require_extra
8
+ psycopg = require_extra("PostgreSQLKVBackend", "psycopg", "pg")
9
+
10
+ # -> ImportError:
11
+ # reactifact.PostgreSQLKVBackend requires the 'pg' extra (psycopg).
12
+ # Install it with `pip install "reactifact[pg]"` or `uv sync --extra pg`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ from typing import Any
19
+
20
+
21
+ def require_extra(feature: str, module: str, extra: str) -> Any:
22
+ """Imports `module`, or raises a readable error with the install hint."""
23
+ try:
24
+ return importlib.import_module(module)
25
+ except ModuleNotFoundError as exc:
26
+ missing = exc.name or module
27
+ if not (missing == module or missing.startswith(module + ".")):
28
+ raise # not our dependency — re-raise the original error
29
+ raise ImportError(
30
+ f"reactifact.{feature} requires the {extra!r} extra ({module}). "
31
+ f'Install it with `pip install "reactifact[{extra}]"` '
32
+ f"or `uv sync --extra {extra}`."
33
+ ) from None
34
+
35
+
36
+ __all__ = ["require_extra"]
reactifact/agents.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ from .artifacts import Artifact
8
+ from .consume import Consume
9
+ from .context import Context
10
+ from .events import Event
11
+ from .patches import Patch
12
+ from .produce import Produce
13
+ from .triggers import Trigger
14
+
15
+
16
+ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() has a default
17
+ """Base container: consumes some artifacts and produces others.
18
+
19
+ If `run` is not overridden, automatically collects inputs according to
20
+ consumes and calls produce on every produce, merging the patches.
21
+ """
22
+
23
+ name: str = ""
24
+ consumes: Sequence[Consume] | None = None
25
+ produces: Sequence[Produce[Any]] | None = None
26
+ #: Declarative capability labels (§25), consumed by the adaptive policy.
27
+ capabilities: tuple[str, ...] = ()
28
+ triggers: list[Trigger] = []
29
+ # Run priority within a single generation: lower value runs earlier.
30
+ # Useful for "finishers"/evaluators that logically run last (§24).
31
+ priority: int = 0
32
+ # Max parallel executions of this agent within a generation. Leave None for
33
+ # the runtime default (max_concurrency). Use it to throttle LLM-bound
34
+ # producers (rate limits) independently of cheap I/O (file reads).
35
+ concurrency_limit: int | None = None
36
+
37
+ def __init__(
38
+ self,
39
+ name: str | None = None,
40
+ triggers: list[Trigger] | None = None,
41
+ priority: int | None = None,
42
+ ):
43
+ self.name = name or self.name or self.__class__.__name__
44
+
45
+ self.priority = (
46
+ priority if priority is not None else getattr(self.__class__, "priority", 0)
47
+ )
48
+
49
+ if triggers is not None:
50
+ self.triggers = list(triggers)
51
+ elif self.triggers:
52
+ self.triggers = list(self.triggers)
53
+ elif self.consumes is not None:
54
+ self.triggers = self._generate_triggers_from_consumes()
55
+ else:
56
+ self.triggers = []
57
+
58
+ self._validate_contracts()
59
+
60
+ def _generate_triggers_from_consumes(self) -> list[Trigger]:
61
+ result = []
62
+ for c in self.consumes or []:
63
+ result.extend(c.to_triggers())
64
+ return result
65
+
66
+ def _validate_contracts(self) -> None:
67
+ if self.consumes is not None:
68
+ for c in self.consumes:
69
+ if not isinstance(c, Consume):
70
+ raise TypeError(
71
+ f"Agent {self.name!r}: consumes must contain Consume "
72
+ f"instances, got {type(c)}"
73
+ )
74
+ if self.produces is not None:
75
+ for p in self.produces:
76
+ if not isinstance(p, Produce):
77
+ raise TypeError(
78
+ f"Agent {self.name!r}: produces must contain Produce "
79
+ f"instances, got {type(p)}"
80
+ )
81
+
82
+ def matches(self, event: Event, context: Context | None = None) -> bool:
83
+ return any(trigger.matches(event, context) for trigger in self.triggers)
84
+
85
+ def collect_inputs(self, context: Context) -> list[Artifact[Any]]:
86
+ """Public access to the consumed artifacts.
87
+
88
+ Used by the runtime to record the reads linkage (provenance) on run.
89
+ """
90
+ return self._collect_inputs(context)
91
+
92
+ def _collect_inputs(self, context: Context) -> list[Artifact[Any]]:
93
+ """Collects all artifacts matching consumes and conditions."""
94
+ if not self.consumes:
95
+ return []
96
+ inputs: list[Artifact[Any]] = []
97
+ for c in self.consumes:
98
+ artifacts = context.list_artifacts(c.artifact_type)
99
+ if c.condition:
100
+ artifacts = [a for a in artifacts if c.condition(a)]
101
+ inputs.extend(artifacts)
102
+ return inputs
103
+
104
+ async def run(self, event: Event, context: Context) -> Patch | None:
105
+ """Default: runs `self.produces` via `execute()` (the effects-first
106
+ path — write a `Produce` subclass or `@produce` function instead of
107
+ overriding this).
108
+
109
+ Overriding `run()` to return a `Patch` by hand is a low-level,
110
+ internal escape hatch for cases `effects` genuinely can't express —
111
+ not a third everyday produce style (see `reactifact.produce`'s module
112
+ docstring for the two you should reach for first). No example in
113
+ this repo overrides it; only this repo's own tests do.
114
+ """
115
+ await self.execute(context, event)
116
+ return None
117
+
118
+ async def execute(self, context: Context, event: Event | None = None) -> None:
119
+ """Runs the agent's produces (usually on an event).
120
+
121
+ Effects-first (§24): produces write `self.effects.*` and return None;
122
+ the *runtime* compiles the effect slot into one atomic patch. This method
123
+ only *runs* the produces — it does not build a patch. (`run` remains the
124
+ agent-level escape hatch for custom Agent subclasses that assemble a
125
+ change-set by hand; the runtime merges its result after the effects.)
126
+ """
127
+ if not self.produces:
128
+ return None
129
+ inputs = self._collect_inputs(context)
130
+ for p in self.produces:
131
+ await p.produce(context, inputs, event)
132
+ return None
133
+
134
+
135
+ def create_agent(
136
+ name: str,
137
+ *,
138
+ consumes: Sequence[Consume] | None = None,
139
+ produces: Sequence[Produce[Any]] | None = None,
140
+ capabilities: tuple[str, ...] = (),
141
+ priority: int = 0,
142
+ concurrency_limit: int | None = None,
143
+ triggers: list[Trigger] | None = None,
144
+ ) -> Agent:
145
+ """Builds an Agent instance without subclassing.
146
+
147
+ `Agent` is a container — nothing needs overriding in the common case — so a
148
+ subclass is only ceremony. This constructor-style builder covers all of the
149
+ declarative knobs:
150
+
151
+ ```
152
+ echo = create_agent(
153
+ name="echo",
154
+ consumes=[Consume(Question)],
155
+ produces=[echo_produce], # a Produce or @produce(...) function
156
+ )
157
+ ```
158
+
159
+ Falls back to `name` defaults the same way as `Agent.__init__`.
160
+ """
161
+ agent = Agent(
162
+ name=name, triggers=triggers if triggers is not None else [], priority=priority
163
+ )
164
+ if consumes is not None:
165
+ agent.consumes = consumes
166
+ if triggers is None:
167
+ agent.triggers = agent._generate_triggers_from_consumes()
168
+ if produces is not None:
169
+ agent.produces = produces
170
+ agent.capabilities = capabilities
171
+ agent.concurrency_limit = concurrency_limit
172
+ agent._validate_contracts()
173
+ return agent
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from pydantic import BaseModel
9
+
10
+ TData = TypeVar("TData", bound=BaseModel)
11
+ ArtifactType = type[BaseModel]
12
+
13
+
14
+ def compute_dict_diff(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
15
+ """Simple dictionary comparison: returns changed, added and removed keys."""
16
+ diff: dict[str, Any] = {"changed": {}, "added": {}, "removed": {}}
17
+ all_keys = set(old.keys()) | set(new.keys())
18
+ for key in all_keys:
19
+ if key not in old:
20
+ diff["added"][key] = new[key]
21
+ elif key not in new:
22
+ diff["removed"][key] = old[key]
23
+ elif old[key] != new[key]:
24
+ # If both values are dictionaries, compare them recursively
25
+ if isinstance(old[key], dict) and isinstance(new[key], dict):
26
+ nested = compute_dict_diff(old[key], new[key])
27
+ if any(nested.values()):
28
+ diff["changed"][key] = nested
29
+ else:
30
+ diff["changed"][key] = {"old": old[key], "new": new[key]}
31
+ return diff
32
+
33
+
34
+ def _import_class(full_name: str) -> Any:
35
+ module_name, class_name = full_name.rsplit(".", 1)
36
+ module = importlib.import_module(module_name)
37
+ return getattr(module, class_name)
38
+
39
+
40
+ class Artifact(Generic[TData]):
41
+ """Wrapper around a Pydantic model with versioning."""
42
+
43
+ def __init__(
44
+ self,
45
+ data: TData,
46
+ id: str | None = None,
47
+ created_by_commit: str | None = None,
48
+ ):
49
+ self.id = id or str(uuid.uuid4())
50
+ self.data = data
51
+ self.data_type = f"{type(data).__module__}.{type(data).__qualname__}"
52
+ self.created_at = datetime.now(UTC)
53
+ self.updated_at = self.created_at
54
+ self.created_by_commit = created_by_commit
55
+ self._history: list[
56
+ TData
57
+ ] = [] # previous data versions (excluding the current one)
58
+ # Memoized to_dict(), keyed by version: re-derived on every save()
59
+ # otherwise (session persistence saves after every commit, §reactifact.session)
60
+ # even though most artifacts in a large context are unchanged since the
61
+ # last save — see reactifact/session.py for the calling context.
62
+ self._dict_cache: tuple[int, dict[str, Any]] | None = None
63
+
64
+ def update(self, new_data: TData) -> None:
65
+ """Saves the current version to history and replaces the data."""
66
+ self._history.append(self.data)
67
+ self.data = new_data
68
+ self.updated_at = datetime.now(UTC)
69
+
70
+ @property
71
+ def history(self) -> list[TData]:
72
+ """Returns a copy of the list of previous versions (excluding the current one)."""
73
+ return list(self._history)
74
+
75
+ @property
76
+ def version(self) -> int:
77
+ """Current version (0 – original, 1 – after the first update, etc.)"""
78
+ return len(self._history)
79
+
80
+ def get_all_versions(self) -> list[TData]:
81
+ """Returns all data versions, including the current one, from oldest to newest."""
82
+ return self._history + [self.data]
83
+
84
+ def diff(self, old_version: int, new_version: int) -> dict[str, Any]:
85
+ """Returns a diff between two versions by their numbers (0 – the oldest)."""
86
+ versions = self.get_all_versions()
87
+ if old_version < 0 or new_version >= len(versions) or old_version > new_version:
88
+ raise ValueError(
89
+ f"Invalid version indices: old={old_version}, new={new_version}"
90
+ )
91
+ old_data = versions[old_version].model_dump()
92
+ new_data = versions[new_version].model_dump()
93
+ return compute_dict_diff(old_data, new_data)
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ """Serializes the artifact, including its full version history.
97
+
98
+ Memoized per `version` (bumped by `update()`): an unchanged artifact
99
+ returns the same dict instance on a later call instead of re-walking
100
+ `model_dump()` over its entire history again — the history only grows
101
+ monotonically, so a cache keyed by version can never go stale.
102
+ """
103
+ if self._dict_cache is not None and self._dict_cache[0] == self.version:
104
+ return self._dict_cache[1]
105
+ d = {
106
+ "id": self.id,
107
+ "data_type": self.data_type,
108
+ "data": self.data.model_dump(mode="json"),
109
+ "created_at": self.created_at.isoformat(),
110
+ "updated_at": self.updated_at.isoformat(),
111
+ "history": [v.model_dump(mode="json") for v in self._history],
112
+ "created_by_commit": self.created_by_commit,
113
+ }
114
+ self._dict_cache = (self.version, d)
115
+ return d
116
+
117
+ @classmethod
118
+ def from_dict(cls, d: dict[str, Any]) -> Artifact[Any]:
119
+ model_class = _import_class(d["data_type"])
120
+ data = model_class.model_validate(d["data"])
121
+ artifact = cls(
122
+ data=data, id=d["id"], created_by_commit=d.get("created_by_commit")
123
+ )
124
+ artifact.created_at = datetime.fromisoformat(d["created_at"])
125
+ artifact.updated_at = datetime.fromisoformat(d["updated_at"])
126
+ artifact._history = [model_class.model_validate(h) for h in d["history"]]
127
+ return artifact
128
+
129
+ def __repr__(self) -> str:
130
+ return f"<Artifact id={self.id!r} type={self.data.__class__.__name__} v{self.version}>"