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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CheckpointBackend(ABC):
|
|
14
|
+
"""Interface for saving and loading the Workspace state (single blob)."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def save(self, data: dict[str, Any]) -> None:
|
|
18
|
+
"""Saves the state dictionary."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def load(self) -> dict[str, Any]:
|
|
23
|
+
"""Loads the state dictionary."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def aclose(self) -> None: # noqa: B027
|
|
27
|
+
"""Releases held resources (a pooled connection, ...). No-op by default."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KVBackend(ABC):
|
|
31
|
+
"""Key-value store. Used for sessions (session_id → Context)."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def set(self, key: str, data: dict[str, Any]) -> None: ...
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def get(self, key: str) -> dict[str, Any] | None: ...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def delete(self, key: str) -> None: ...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def keys(self) -> list[str]: ...
|
|
44
|
+
|
|
45
|
+
async def aclose(self) -> None: # noqa: B027
|
|
46
|
+
"""Releases held resources (a pooled connection, ...). No-op by default."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FileKVBackend(KVBackend):
|
|
50
|
+
"""File KV backend: one JSON file per key in a directory.
|
|
51
|
+
|
|
52
|
+
Every call offloads its blocking filesystem I/O to a worker thread
|
|
53
|
+
(`asyncio.to_thread`) so it never stalls the event loop reactifact's runtime
|
|
54
|
+
and chat/web layers run on.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, directory: str):
|
|
58
|
+
self.directory = Path(directory)
|
|
59
|
+
|
|
60
|
+
def _path(self, key: str) -> Path:
|
|
61
|
+
safe = urllib.parse.quote(key, safe="")
|
|
62
|
+
return self.directory / f"{safe}.json"
|
|
63
|
+
|
|
64
|
+
def _set_sync(self, key: str, data: dict[str, Any]) -> None:
|
|
65
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path = self._path(key)
|
|
67
|
+
tmp = path.with_suffix(".tmp")
|
|
68
|
+
# json.dumps() + one write(), not json.dump(obj, f): CPython's
|
|
69
|
+
# json.dump() streams via JSONEncoder.iterencode(), which — unlike
|
|
70
|
+
# .encode() (what dumps() calls) — never takes the C-accelerated
|
|
71
|
+
# encoder path, so it's the pure-Python encoder walking the whole
|
|
72
|
+
# object graph node by node. For a large session (thousands of
|
|
73
|
+
# artifacts/commits, written on every save — see reactifact/session.py)
|
|
74
|
+
# that's a real, measured ~5-10x difference on identical content.
|
|
75
|
+
payload = json.dumps({"key": key, "data": data})
|
|
76
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
77
|
+
f.write(payload)
|
|
78
|
+
tmp.replace(path)
|
|
79
|
+
|
|
80
|
+
async def set(self, key: str, data: dict[str, Any]) -> None:
|
|
81
|
+
await asyncio.to_thread(self._set_sync, key, data)
|
|
82
|
+
|
|
83
|
+
def _get_sync(self, key: str) -> dict[str, Any] | None:
|
|
84
|
+
path = self._path(key)
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return None
|
|
87
|
+
with open(path, encoding="utf-8") as f:
|
|
88
|
+
return cast(dict[str, Any] | None, json.load(f).get("data"))
|
|
89
|
+
|
|
90
|
+
async def get(self, key: str) -> dict[str, Any] | None:
|
|
91
|
+
return await asyncio.to_thread(self._get_sync, key)
|
|
92
|
+
|
|
93
|
+
def _delete_sync(self, key: str) -> None:
|
|
94
|
+
path = self._path(key)
|
|
95
|
+
if path.exists():
|
|
96
|
+
path.unlink()
|
|
97
|
+
|
|
98
|
+
async def delete(self, key: str) -> None:
|
|
99
|
+
await asyncio.to_thread(self._delete_sync, key)
|
|
100
|
+
|
|
101
|
+
def _keys_sync(self) -> list[str]:
|
|
102
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
return [
|
|
104
|
+
urllib.parse.unquote(p.stem) for p in sorted(self.directory.glob("*.json"))
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
async def keys(self) -> list[str]:
|
|
108
|
+
return await asyncio.to_thread(self._keys_sync)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FileBackend(CheckpointBackend):
|
|
112
|
+
"""File backend: state is stored in a single JSON file."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, path: str):
|
|
115
|
+
self.path = path
|
|
116
|
+
|
|
117
|
+
def _save_sync(self, data: dict[str, Any]) -> None:
|
|
118
|
+
with open(self.path, "w", encoding="utf-8") as f:
|
|
119
|
+
json.dump(data, f, indent=2)
|
|
120
|
+
|
|
121
|
+
async def save(self, data: dict[str, Any]) -> None:
|
|
122
|
+
await asyncio.to_thread(self._save_sync, data)
|
|
123
|
+
|
|
124
|
+
def _load_sync(self) -> dict[str, Any]:
|
|
125
|
+
with open(self.path, encoding="utf-8") as f:
|
|
126
|
+
return cast(dict[str, Any], json.load(f))
|
|
127
|
+
|
|
128
|
+
async def load(self) -> dict[str, Any]:
|
|
129
|
+
return await asyncio.to_thread(self._load_sync)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _AsyncSQLite:
|
|
133
|
+
"""One persistent `sqlite3.Connection` per backend instance, guarded by an
|
|
134
|
+
`asyncio.Lock` and run off-thread via `asyncio.to_thread`.
|
|
135
|
+
|
|
136
|
+
Reconnecting on every call (the previous behavior) meant every checkpoint
|
|
137
|
+
write paid a fresh `connect()`, and concurrent writers had no `busy_timeout`
|
|
138
|
+
to wait out a lock — they just raised `sqlite3.OperationalError: database is
|
|
139
|
+
locked`. WAL mode lets readers and the single writer overlap; the lock here
|
|
140
|
+
only serializes access from *this process* (SQLite itself still serializes
|
|
141
|
+
writers across processes via the database file).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, db_path: str, init_sql: str):
|
|
145
|
+
self.db_path = db_path
|
|
146
|
+
self._init_sql = init_sql
|
|
147
|
+
self._conn: sqlite3.Connection | None = None
|
|
148
|
+
self._lock = asyncio.Lock()
|
|
149
|
+
|
|
150
|
+
def _connect(self) -> sqlite3.Connection:
|
|
151
|
+
# `busy_timeout` only takes effect once set on *this* connection, so
|
|
152
|
+
# the pragmas below race the very connections that would honor it —
|
|
153
|
+
# switching journal mode is an exclusive operation SQLite does not
|
|
154
|
+
# always retry through the normal busy handler. Several backends
|
|
155
|
+
# opening a brand-new file at once can still hit `database is
|
|
156
|
+
# locked` here; retry the one-time bootstrap instead of the hot path.
|
|
157
|
+
conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=5.0)
|
|
158
|
+
attempts = 0
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
162
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
163
|
+
conn.execute(self._init_sql)
|
|
164
|
+
conn.commit()
|
|
165
|
+
return conn
|
|
166
|
+
except sqlite3.OperationalError:
|
|
167
|
+
attempts += 1
|
|
168
|
+
if attempts >= 10:
|
|
169
|
+
raise
|
|
170
|
+
time.sleep(0.05 * attempts)
|
|
171
|
+
|
|
172
|
+
def _exec(self, sql: str, params: tuple[Any, ...]) -> list[tuple[Any, ...]]:
|
|
173
|
+
assert self._conn is not None
|
|
174
|
+
cur = self._conn.execute(sql, params)
|
|
175
|
+
self._conn.commit()
|
|
176
|
+
return cur.fetchall()
|
|
177
|
+
|
|
178
|
+
async def execute(
|
|
179
|
+
self, sql: str, params: tuple[Any, ...] = ()
|
|
180
|
+
) -> list[tuple[Any, ...]]:
|
|
181
|
+
async with self._lock:
|
|
182
|
+
if self._conn is None:
|
|
183
|
+
self._conn = await asyncio.to_thread(self._connect)
|
|
184
|
+
return await asyncio.to_thread(self._exec, sql, params)
|
|
185
|
+
|
|
186
|
+
async def aclose(self) -> None:
|
|
187
|
+
async with self._lock:
|
|
188
|
+
if self._conn is not None:
|
|
189
|
+
await asyncio.to_thread(self._conn.close)
|
|
190
|
+
self._conn = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class SQLiteKVBackend(KVBackend):
|
|
194
|
+
"""SQLite KV backend: table kv_entries(key PRIMARY KEY, data JSON)."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, db_path: str):
|
|
197
|
+
self.db_path = db_path
|
|
198
|
+
self._sql = _AsyncSQLite(
|
|
199
|
+
db_path,
|
|
200
|
+
"CREATE TABLE IF NOT EXISTS kv_entries "
|
|
201
|
+
"(key TEXT PRIMARY KEY, data TEXT NOT NULL)",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def set(self, key: str, data: dict[str, Any]) -> None:
|
|
205
|
+
await self._sql.execute(
|
|
206
|
+
"INSERT OR REPLACE INTO kv_entries (key, data) VALUES (?, ?)",
|
|
207
|
+
(key, json.dumps(data)),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def get(self, key: str) -> dict[str, Any] | None:
|
|
211
|
+
rows = await self._sql.execute(
|
|
212
|
+
"SELECT data FROM kv_entries WHERE key = ?", (key,)
|
|
213
|
+
)
|
|
214
|
+
return cast(dict[str, Any], json.loads(rows[0][0])) if rows else None
|
|
215
|
+
|
|
216
|
+
async def delete(self, key: str) -> None:
|
|
217
|
+
await self._sql.execute("DELETE FROM kv_entries WHERE key = ?", (key,))
|
|
218
|
+
|
|
219
|
+
async def keys(self) -> list[str]:
|
|
220
|
+
rows = await self._sql.execute("SELECT key FROM kv_entries")
|
|
221
|
+
return [cast(str, r[0]) for r in rows]
|
|
222
|
+
|
|
223
|
+
async def aclose(self) -> None:
|
|
224
|
+
await self._sql.aclose()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class SQLiteBackend(CheckpointBackend):
|
|
228
|
+
"""SQLite backend: state is stored in the checkpoint table (single row)."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, db_path: str):
|
|
231
|
+
self.db_path = db_path
|
|
232
|
+
self._sql = _AsyncSQLite(
|
|
233
|
+
db_path,
|
|
234
|
+
"CREATE TABLE IF NOT EXISTS checkpoint "
|
|
235
|
+
"(id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def save(self, data: dict[str, Any]) -> None:
|
|
239
|
+
await self._sql.execute(
|
|
240
|
+
"INSERT OR REPLACE INTO checkpoint (id, data) VALUES (1, ?)",
|
|
241
|
+
(json.dumps(data),),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def load(self) -> dict[str, Any]:
|
|
245
|
+
rows = await self._sql.execute("SELECT data FROM checkpoint WHERE id = 1")
|
|
246
|
+
if not rows:
|
|
247
|
+
raise ValueError(f"Checkpoint not found in SQLite database: {self.db_path}")
|
|
248
|
+
return cast(dict[str, Any], json.loads(rows[0][0]))
|
|
249
|
+
|
|
250
|
+
async def aclose(self) -> None:
|
|
251
|
+
await self._sql.aclose()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class PostgreSQLKVBackend(KVBackend):
|
|
255
|
+
"""PostgreSQL KV backend (`kv_entries(key, data)`) — behind the `pg` extra.
|
|
256
|
+
|
|
257
|
+
Like the SQLite KV backend, but shared across processes: a natural store
|
|
258
|
+
when sessions are persisted in the same Postgres as the application. The
|
|
259
|
+
driver (`psycopg`) is imported lazily so the core stays dependency-free;
|
|
260
|
+
install it with `uv sync --extra pg` (or group `pg`).
|
|
261
|
+
|
|
262
|
+
Uses psycopg3's native async API (`AsyncConnection`) over one persistent,
|
|
263
|
+
lazily-(re)connected connection, serialized by an `asyncio.Lock` — the same
|
|
264
|
+
shape as `SQLiteKVBackend`. A dedicated connection per backend instance is
|
|
265
|
+
enough for session-checkpoint traffic (small, infrequent writes); reach for
|
|
266
|
+
`psycopg_pool.AsyncConnectionPool` yourself if you need many concurrent
|
|
267
|
+
writers sharing one DSN.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(self, dsn: str):
|
|
271
|
+
from ._extras import require_extra
|
|
272
|
+
|
|
273
|
+
self._psycopg = require_extra("PostgreSQLKVBackend", "psycopg", "pg")
|
|
274
|
+
self.dsn = dsn
|
|
275
|
+
self._conn: Any | None = None
|
|
276
|
+
self._lock = asyncio.Lock()
|
|
277
|
+
|
|
278
|
+
async def _connection(self) -> Any:
|
|
279
|
+
conn = self._conn
|
|
280
|
+
if conn is None or conn.closed:
|
|
281
|
+
conn = await self._psycopg.AsyncConnection.connect(self.dsn)
|
|
282
|
+
async with conn.cursor() as cur:
|
|
283
|
+
await cur.execute(
|
|
284
|
+
"CREATE TABLE IF NOT EXISTS kv_entries "
|
|
285
|
+
"(key TEXT PRIMARY KEY, data TEXT NOT NULL)"
|
|
286
|
+
)
|
|
287
|
+
await conn.commit()
|
|
288
|
+
self._conn = conn
|
|
289
|
+
return conn
|
|
290
|
+
|
|
291
|
+
async def set(self, key: str, data: dict[str, Any]) -> None:
|
|
292
|
+
async with self._lock:
|
|
293
|
+
conn = await self._connection()
|
|
294
|
+
async with conn.cursor() as cur:
|
|
295
|
+
await cur.execute(
|
|
296
|
+
"INSERT INTO kv_entries (key, data) VALUES (%s, %s) "
|
|
297
|
+
"ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data",
|
|
298
|
+
(key, json.dumps(data)),
|
|
299
|
+
)
|
|
300
|
+
await conn.commit()
|
|
301
|
+
|
|
302
|
+
async def get(self, key: str) -> dict[str, Any] | None:
|
|
303
|
+
async with self._lock:
|
|
304
|
+
conn = await self._connection()
|
|
305
|
+
async with conn.cursor() as cur:
|
|
306
|
+
await cur.execute("SELECT data FROM kv_entries WHERE key = %s", (key,))
|
|
307
|
+
row = await cur.fetchone()
|
|
308
|
+
return cast(dict[str, Any], json.loads(row[0])) if row is not None else None
|
|
309
|
+
|
|
310
|
+
async def delete(self, key: str) -> None:
|
|
311
|
+
async with self._lock:
|
|
312
|
+
conn = await self._connection()
|
|
313
|
+
async with conn.cursor() as cur:
|
|
314
|
+
await cur.execute("DELETE FROM kv_entries WHERE key = %s", (key,))
|
|
315
|
+
await conn.commit()
|
|
316
|
+
|
|
317
|
+
async def keys(self) -> list[str]:
|
|
318
|
+
async with self._lock:
|
|
319
|
+
conn = await self._connection()
|
|
320
|
+
async with conn.cursor() as cur:
|
|
321
|
+
await cur.execute("SELECT key FROM kv_entries")
|
|
322
|
+
rows = await cur.fetchall()
|
|
323
|
+
return [cast(str, r[0]) for r in rows]
|
|
324
|
+
|
|
325
|
+
async def aclose(self) -> None:
|
|
326
|
+
async with self._lock:
|
|
327
|
+
if self._conn is not None and not self._conn.closed:
|
|
328
|
+
await self._conn.close()
|
|
329
|
+
self._conn = None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""python -m reactifact — inspect & visualize the runtime as Mermaid.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
graph static agent blueprint from a module path ("pkg.mod:Attr")
|
|
6
|
+
context live provenance graph from a saved session (KV backend)
|
|
7
|
+
trace run diagram from a trace store (SQLite)
|
|
8
|
+
replay deterministic state replay of a saved session
|
|
9
|
+
branch persistent forks over a KV backend
|
|
10
|
+
scenario run reactifact.testing scenarios (separate from pytest)
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
|
|
14
|
+
python -m reactifact graph examples.knowledge.agents
|
|
15
|
+
python -m reactifact context examples/knowledge/sessions/traces.sqlite3
|
|
16
|
+
python -m reactifact trace traces.db
|
|
17
|
+
|
|
18
|
+
Everything prints Mermaid source to stdout — paste it into GitHub, Notion or
|
|
19
|
+
mermaid.live.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
from .. import __version__
|
|
28
|
+
from . import branch, context, graph, replay, scenario, trace
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="reactifact",
|
|
34
|
+
description="Inspect & visualize reactifact agents, contexts and traces as Mermaid.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"-V",
|
|
38
|
+
"--version",
|
|
39
|
+
action="version",
|
|
40
|
+
version=f"%(prog)s {__version__}",
|
|
41
|
+
)
|
|
42
|
+
sub = parser.add_subparsers(dest="command")
|
|
43
|
+
|
|
44
|
+
for module in (graph, context, trace, replay, branch, scenario):
|
|
45
|
+
module.add_parser(sub)
|
|
46
|
+
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str] | None = None) -> int:
|
|
51
|
+
# Make local modules importable the same way `python -m reactifact` does —
|
|
52
|
+
# the installed console-script entry point doesn't prepend cwd on its own.
|
|
53
|
+
if "" not in sys.path:
|
|
54
|
+
sys.path.insert(0, "")
|
|
55
|
+
parser = build_parser()
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
if args.command is None:
|
|
58
|
+
print(
|
|
59
|
+
f"reactifact {__version__} — inspect & visualize agents, contexts and"
|
|
60
|
+
" traces as Mermaid.\n"
|
|
61
|
+
)
|
|
62
|
+
print("Render what your agent app already did:")
|
|
63
|
+
print(" reactifact graph <module:Agent> static blueprint")
|
|
64
|
+
print(" reactifact trace <trace.db> run diagram")
|
|
65
|
+
print(" reactifact context <sessions.sqlite3> live provenance graph")
|
|
66
|
+
print(" reactifact replay <sessions.sqlite3> deterministic replay")
|
|
67
|
+
print(" reactifact branch <path> <session> <action> persistent forks")
|
|
68
|
+
print(" reactifact scenario <module> run reactifact.testing scenarios\n")
|
|
69
|
+
print("Run 'reactifact <command> --help' for details, or try an example:")
|
|
70
|
+
print(" uv run python ./examples/llm_ladder/level1.py")
|
|
71
|
+
parser.print_help()
|
|
72
|
+
return 0
|
|
73
|
+
return int(args.func(args))
|
reactifact/cli/branch.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""`reactifact branch` — persistent forks over a KV backend (§39-§40)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from .common import open_store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _cmd_branch(args: argparse.Namespace) -> int:
|
|
12
|
+
from ..branching import BranchStore
|
|
13
|
+
from ..session import SessionStore
|
|
14
|
+
|
|
15
|
+
store = SessionStore(open_store(args.path, args.backend))
|
|
16
|
+
branches = BranchStore(store.backend)
|
|
17
|
+
|
|
18
|
+
if args.action == "list":
|
|
19
|
+
names = await branches.list_branches(args.session)
|
|
20
|
+
stat = f"branches of {args.session!r}: " + (
|
|
21
|
+
", ".join(names) if names else "none"
|
|
22
|
+
)
|
|
23
|
+
print(stat)
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
if args.action == "save":
|
|
27
|
+
context = await store.load_session(args.session)
|
|
28
|
+
if context is None:
|
|
29
|
+
print(f"session {args.session!r} not found")
|
|
30
|
+
return 1
|
|
31
|
+
await branches.save_branch(context, session_id=args.session, name=args.name)
|
|
32
|
+
print(f"saved branch {args.session!r}:{args.name}")
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
# merge
|
|
36
|
+
into = await branches.load_branch(args.session, args.into)
|
|
37
|
+
source = await branches.load_branch(args.session, args.source)
|
|
38
|
+
if into is None or source is None:
|
|
39
|
+
print("missing branch (use 'branch list')")
|
|
40
|
+
return 1
|
|
41
|
+
try:
|
|
42
|
+
into.merge(source)
|
|
43
|
+
except Exception as exc: # MergeConflict and friends
|
|
44
|
+
print(str(exc))
|
|
45
|
+
return 1
|
|
46
|
+
if args.as_name:
|
|
47
|
+
await branches.save_branch(into, session_id=args.session, name=args.as_name)
|
|
48
|
+
print(f"merged into {args.session!r}:{args.as_name}")
|
|
49
|
+
else:
|
|
50
|
+
await branches.save_branch(into, session_id=args.session, name=args.into)
|
|
51
|
+
print(f"merged {args.session!r}:{args.source} into {args.into}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_branch(args: argparse.Namespace) -> int:
|
|
56
|
+
return asyncio.run(_cmd_branch(args))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
60
|
+
p_branch = sub.add_parser(
|
|
61
|
+
"branch", help="persistent forks over a KV backend (§39-§40)"
|
|
62
|
+
)
|
|
63
|
+
p_branch.add_argument(
|
|
64
|
+
"path", help="KV backend file (sessions.sqlite3) or directory"
|
|
65
|
+
)
|
|
66
|
+
p_branch.add_argument("session", help="session id")
|
|
67
|
+
p_branch.add_argument(
|
|
68
|
+
"action", choices=["list", "save", "merge"], help="what to do"
|
|
69
|
+
)
|
|
70
|
+
p_branch.add_argument("name", nargs="?", default=None, help="name (save)")
|
|
71
|
+
p_branch.add_argument("--into", default=None, help="merge target branch")
|
|
72
|
+
p_branch.add_argument("--source", default=None, help="merge source branch")
|
|
73
|
+
p_branch.add_argument(
|
|
74
|
+
"--as", dest="as_name", default=None, help="result branch name"
|
|
75
|
+
)
|
|
76
|
+
p_branch.add_argument("--backend", choices=["file", "sqlite"], default="auto")
|
|
77
|
+
p_branch.set_defaults(func=cmd_branch)
|
reactifact/cli/common.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared helpers for CLI subcommands: module-path agent loading, KV-backend
|
|
2
|
+
opening by path/extension. No argparse wiring here — see each subcommand
|
|
3
|
+
module for that."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ..agents import Agent
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..checkpoints import FileKVBackend, SQLiteKVBackend
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_agents(spec: str) -> list[Agent]:
|
|
17
|
+
module_name, has_attr, attr = spec.partition(":")
|
|
18
|
+
try:
|
|
19
|
+
module = importlib.import_module(module_name)
|
|
20
|
+
except ModuleNotFoundError as exc:
|
|
21
|
+
raise SystemExit(
|
|
22
|
+
f"could not import {module_name!r}: {exc}. If this is a local "
|
|
23
|
+
"module (not installed as a package), run via "
|
|
24
|
+
f"`uv run python -m reactifact graph {spec}` instead."
|
|
25
|
+
) from exc
|
|
26
|
+
if has_attr:
|
|
27
|
+
obj = getattr(module, attr)
|
|
28
|
+
if isinstance(obj, Agent):
|
|
29
|
+
return [obj]
|
|
30
|
+
if isinstance(obj, (list, tuple)):
|
|
31
|
+
if not obj:
|
|
32
|
+
raise SystemExit(f"{spec!r} resolved to an empty list")
|
|
33
|
+
agents = [o for o in obj if isinstance(o, Agent)]
|
|
34
|
+
if len(agents) != len(obj):
|
|
35
|
+
raise SystemExit(
|
|
36
|
+
f"{spec!r} must resolve to an Agent or a list of Agents"
|
|
37
|
+
)
|
|
38
|
+
return agents
|
|
39
|
+
raise SystemExit(f"expected an Agent or list[Agent] at {spec!r}")
|
|
40
|
+
|
|
41
|
+
agents = [value for value in vars(module).values() if isinstance(value, Agent)]
|
|
42
|
+
if not agents:
|
|
43
|
+
# The demos instantiate their agents inline — synthesize the blueprint
|
|
44
|
+
# from every Agent subclass defined in the module (stateless containers).
|
|
45
|
+
classes = [
|
|
46
|
+
value
|
|
47
|
+
for _name, value in sorted(vars(module).items())
|
|
48
|
+
if isinstance(value, type)
|
|
49
|
+
and issubclass(value, Agent)
|
|
50
|
+
and value is not Agent
|
|
51
|
+
and value.__module__ == module_name
|
|
52
|
+
]
|
|
53
|
+
agents = [cls() for cls in classes]
|
|
54
|
+
if not agents:
|
|
55
|
+
raise SystemExit(
|
|
56
|
+
f"no Agent instances or subclasses found in {module_name!r}; "
|
|
57
|
+
'use the "module:Attr" form'
|
|
58
|
+
)
|
|
59
|
+
return agents
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def open_store(path: str, backend: str) -> FileKVBackend | SQLiteKVBackend:
|
|
63
|
+
from ..checkpoints import FileKVBackend, SQLiteKVBackend
|
|
64
|
+
|
|
65
|
+
if backend == "sqlite" or path.endswith((".db", ".sqlite", ".sqlite3")):
|
|
66
|
+
return SQLiteKVBackend(path)
|
|
67
|
+
return FileKVBackend(path)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""`reactifact context` — live provenance graph from a saved session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from .common import open_store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _cmd_context(args: argparse.Namespace) -> int:
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
from ..session import SessionStore
|
|
15
|
+
from ..viz import context_to_mermaid
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
store = SessionStore(open_store(args.path, args.backend))
|
|
19
|
+
sessions = await store.list_sessions()
|
|
20
|
+
except sqlite3.OperationalError:
|
|
21
|
+
print(f"no session store found at {args.path!r}")
|
|
22
|
+
return 1
|
|
23
|
+
if not sessions:
|
|
24
|
+
print("no sessions found")
|
|
25
|
+
return 1
|
|
26
|
+
session_id = args.session or sessions[-1]
|
|
27
|
+
context = await store.load_session(session_id)
|
|
28
|
+
if context is None:
|
|
29
|
+
print(f"session {session_id!r} not found")
|
|
30
|
+
return 1
|
|
31
|
+
print(context_to_mermaid(context, limit=args.limit))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_context(args: argparse.Namespace) -> int:
|
|
36
|
+
return asyncio.run(_cmd_context(args))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
40
|
+
p_context = sub.add_parser(
|
|
41
|
+
"context", help="live provenance graph of a saved session"
|
|
42
|
+
)
|
|
43
|
+
p_context.add_argument(
|
|
44
|
+
"path", help="KV backend file (sessions.sqlite3) or directory"
|
|
45
|
+
)
|
|
46
|
+
p_context.add_argument(
|
|
47
|
+
"--session", default=None, help="session id (default: latest)"
|
|
48
|
+
)
|
|
49
|
+
p_context.add_argument("--backend", choices=["file", "sqlite"], default="auto")
|
|
50
|
+
p_context.add_argument(
|
|
51
|
+
"--limit", type=int, default=None, help="max artifacts shown"
|
|
52
|
+
)
|
|
53
|
+
p_context.set_defaults(func=cmd_context)
|
reactifact/cli/graph.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""`reactifact graph` — static agent blueprint from a module path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..viz import blueprint
|
|
8
|
+
from .common import load_agents
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_graph(args: argparse.Namespace) -> int:
|
|
12
|
+
agents = load_agents(args.agents)
|
|
13
|
+
print(blueprint(agents, title=args.title))
|
|
14
|
+
return 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
18
|
+
p_graph = sub.add_parser("graph", help="static agent blueprint")
|
|
19
|
+
p_graph.add_argument("agents", help='module path, e.g. "examples.knowledge.agents"')
|
|
20
|
+
p_graph.add_argument("--title", default="reactifact blueprint")
|
|
21
|
+
p_graph.set_defaults(func=cmd_graph)
|
reactifact/cli/replay.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""`reactifact replay` — deterministic state replay of a saved session (§55)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from .common import open_store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _cmd_replay(args: argparse.Namespace) -> int:
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
from ..replay import replay_context, replay_summary
|
|
15
|
+
from ..session import SessionStore
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
store = SessionStore(open_store(args.path, args.backend))
|
|
19
|
+
sessions = await store.list_sessions()
|
|
20
|
+
except sqlite3.OperationalError:
|
|
21
|
+
print(f"no session store found at {args.path!r}")
|
|
22
|
+
return 1
|
|
23
|
+
if not sessions:
|
|
24
|
+
print("no sessions found")
|
|
25
|
+
return 1
|
|
26
|
+
session_id = args.session or sessions[-1]
|
|
27
|
+
try:
|
|
28
|
+
context = await replay_context(store, session_id, version=args.version)
|
|
29
|
+
except KeyError as exc:
|
|
30
|
+
print(str(exc))
|
|
31
|
+
return 1
|
|
32
|
+
summary = replay_summary(context)
|
|
33
|
+
print(f"session {session_id!r} — replay v{summary['version']}")
|
|
34
|
+
print(
|
|
35
|
+
f"artifacts: {summary['artifacts']} · relations: {summary['relations']} · "
|
|
36
|
+
f"pending questions: {summary['pending_questions']}"
|
|
37
|
+
)
|
|
38
|
+
for tname, count in sorted(summary["by_type"].items()):
|
|
39
|
+
print(f" {tname}: {count}")
|
|
40
|
+
if args.diagram:
|
|
41
|
+
from ..viz import context_to_mermaid
|
|
42
|
+
|
|
43
|
+
print()
|
|
44
|
+
print(context_to_mermaid(context))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_replay(args: argparse.Namespace) -> int:
|
|
49
|
+
return asyncio.run(_cmd_replay(args))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
53
|
+
p_replay = sub.add_parser(
|
|
54
|
+
"replay", help="deterministic state replay of a saved session (§55)"
|
|
55
|
+
)
|
|
56
|
+
p_replay.add_argument(
|
|
57
|
+
"path", help="KV backend file (sessions.sqlite3) or directory"
|
|
58
|
+
)
|
|
59
|
+
p_replay.add_argument(
|
|
60
|
+
"--session", default=None, help="session id (default: latest)"
|
|
61
|
+
)
|
|
62
|
+
p_replay.add_argument(
|
|
63
|
+
"--version", type=int, default=None, help="replay to this commit"
|
|
64
|
+
)
|
|
65
|
+
p_replay.add_argument("--backend", choices=["file", "sqlite"], default="auto")
|
|
66
|
+
p_replay.add_argument(
|
|
67
|
+
"--diagram", action="store_true", help="also print the provenance graph"
|
|
68
|
+
)
|
|
69
|
+
p_replay.set_defaults(func=cmd_replay)
|