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,202 @@
|
|
|
1
|
+
"""recipes.text — deterministic text scoring without embeddings (§8, §67).
|
|
2
|
+
|
|
3
|
+
Keyword coverage is the neutral fallback where vectors are optional: the English
|
|
4
|
+
`knowledge` chat and the Russian `repair` demo rank documents/catalog rows by
|
|
5
|
+
how much of the query is present in the text. Everything here is pure and
|
|
6
|
+
LLM-free; `keyword_score` is stop-word- and (optionally) stem-aware.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_WORD = re.compile(r"[\wа-яё]{2,}")
|
|
14
|
+
|
|
15
|
+
EN_STOPWORDS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"a",
|
|
18
|
+
"an",
|
|
19
|
+
"the",
|
|
20
|
+
"and",
|
|
21
|
+
"or",
|
|
22
|
+
"of",
|
|
23
|
+
"to",
|
|
24
|
+
"in",
|
|
25
|
+
"for",
|
|
26
|
+
"on",
|
|
27
|
+
"with",
|
|
28
|
+
"at",
|
|
29
|
+
"by",
|
|
30
|
+
"from",
|
|
31
|
+
"into",
|
|
32
|
+
"off",
|
|
33
|
+
"out",
|
|
34
|
+
"over",
|
|
35
|
+
"under",
|
|
36
|
+
"about",
|
|
37
|
+
"up",
|
|
38
|
+
"down",
|
|
39
|
+
"after",
|
|
40
|
+
"before",
|
|
41
|
+
"during",
|
|
42
|
+
"between",
|
|
43
|
+
"through",
|
|
44
|
+
"against",
|
|
45
|
+
"is",
|
|
46
|
+
"are",
|
|
47
|
+
"was",
|
|
48
|
+
"were",
|
|
49
|
+
"be",
|
|
50
|
+
"been",
|
|
51
|
+
"being",
|
|
52
|
+
"has",
|
|
53
|
+
"have",
|
|
54
|
+
"had",
|
|
55
|
+
"do",
|
|
56
|
+
"does",
|
|
57
|
+
"did",
|
|
58
|
+
"will",
|
|
59
|
+
"would",
|
|
60
|
+
"can",
|
|
61
|
+
"could",
|
|
62
|
+
"should",
|
|
63
|
+
"how",
|
|
64
|
+
"what",
|
|
65
|
+
"why",
|
|
66
|
+
"which",
|
|
67
|
+
"who",
|
|
68
|
+
"whom",
|
|
69
|
+
"when",
|
|
70
|
+
"where",
|
|
71
|
+
"this",
|
|
72
|
+
"that",
|
|
73
|
+
"these",
|
|
74
|
+
"those",
|
|
75
|
+
"it",
|
|
76
|
+
"its",
|
|
77
|
+
"he",
|
|
78
|
+
"him",
|
|
79
|
+
"his",
|
|
80
|
+
"she",
|
|
81
|
+
"her",
|
|
82
|
+
"they",
|
|
83
|
+
"them",
|
|
84
|
+
"their",
|
|
85
|
+
"we",
|
|
86
|
+
"us",
|
|
87
|
+
"our",
|
|
88
|
+
"you",
|
|
89
|
+
"your",
|
|
90
|
+
"i",
|
|
91
|
+
"me",
|
|
92
|
+
"my",
|
|
93
|
+
"not",
|
|
94
|
+
"no",
|
|
95
|
+
"yes",
|
|
96
|
+
"but",
|
|
97
|
+
"so",
|
|
98
|
+
"then",
|
|
99
|
+
"than",
|
|
100
|
+
"too",
|
|
101
|
+
"very",
|
|
102
|
+
"just",
|
|
103
|
+
"also",
|
|
104
|
+
"because",
|
|
105
|
+
"as",
|
|
106
|
+
"if",
|
|
107
|
+
"much",
|
|
108
|
+
"many",
|
|
109
|
+
"more",
|
|
110
|
+
"most",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
#: Russian inflectional suffixes (used when `use_stems=True`).
|
|
115
|
+
_RU_SUFFIXES = (
|
|
116
|
+
"аться",
|
|
117
|
+
"иться",
|
|
118
|
+
"ами",
|
|
119
|
+
"ями",
|
|
120
|
+
"ой",
|
|
121
|
+
"ий",
|
|
122
|
+
"ый",
|
|
123
|
+
"ое",
|
|
124
|
+
"ее",
|
|
125
|
+
"ить",
|
|
126
|
+
"ать",
|
|
127
|
+
"ять",
|
|
128
|
+
"ом",
|
|
129
|
+
"ем",
|
|
130
|
+
"ам",
|
|
131
|
+
"ям",
|
|
132
|
+
"ия",
|
|
133
|
+
"ию",
|
|
134
|
+
"ией",
|
|
135
|
+
"ых",
|
|
136
|
+
"их",
|
|
137
|
+
"ая",
|
|
138
|
+
"яя",
|
|
139
|
+
"у",
|
|
140
|
+
"ю",
|
|
141
|
+
"о",
|
|
142
|
+
"а",
|
|
143
|
+
"е",
|
|
144
|
+
"ы",
|
|
145
|
+
"и",
|
|
146
|
+
"й",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def stem(word: str) -> str:
|
|
151
|
+
"""Truncates common inflectional suffixes of a Russian word.
|
|
152
|
+
|
|
153
|
+
«аутентификацию» and «аутентификация» must match where embedders are not
|
|
154
|
+
needed. English words pass through unchanged (the suffix list is Cyrillic).
|
|
155
|
+
"""
|
|
156
|
+
w = word.lower()
|
|
157
|
+
changed = True
|
|
158
|
+
while changed:
|
|
159
|
+
changed = False
|
|
160
|
+
for suffix in _RU_SUFFIXES:
|
|
161
|
+
if len(w) - len(suffix) >= 3 and w.endswith(suffix):
|
|
162
|
+
w = w[: -len(suffix)]
|
|
163
|
+
changed = True
|
|
164
|
+
break
|
|
165
|
+
return w
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _tokens(text: str) -> list[str]:
|
|
169
|
+
"""Lowercased word tokens; digit-only tokens never match a query term."""
|
|
170
|
+
lower = text.lower()
|
|
171
|
+
return [t for t in _WORD.findall(lower) if not t.isdigit()]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stem_words(text: str) -> frozenset[str]:
|
|
175
|
+
"""Frozenset of word stems in the text (Latin and Cyrillic)."""
|
|
176
|
+
return frozenset(stem(t) for t in _tokens(text))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def keyword_score(
|
|
180
|
+
text: str,
|
|
181
|
+
query: str,
|
|
182
|
+
*,
|
|
183
|
+
stopwords: frozenset[str] = EN_STOPWORDS,
|
|
184
|
+
use_stems: bool = False,
|
|
185
|
+
) -> float:
|
|
186
|
+
"""Coverage of the query terms by the text (0..1), without embedders.
|
|
187
|
+
|
|
188
|
+
`stopwords` are removed from both sides (English function words by default);
|
|
189
|
+
with `use_stems=True` both sides are stemmed (Russian morphology), so
|
|
190
|
+
«аутентификация» matches «аутентификацию». Returns 0.0 for an empty query.
|
|
191
|
+
"""
|
|
192
|
+
text_terms = set(_tokens(text)) - set(stopwords)
|
|
193
|
+
query_terms = set(_tokens(query)) - set(stopwords)
|
|
194
|
+
if not query_terms:
|
|
195
|
+
return 0.0
|
|
196
|
+
if use_stems:
|
|
197
|
+
text_terms = {stem(t) for t in text_terms}
|
|
198
|
+
query_terms = {stem(t) for t in query_terms}
|
|
199
|
+
return sum(1 for term in query_terms if term in text_terms) / len(query_terms)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ["EN_STOPWORDS", "keyword_score", "stem", "stem_words"]
|
reactifact/relations.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""reactifact.relations — the provenance/link graph (§15).
|
|
2
|
+
|
|
3
|
+
Extracted out of `Context`: `RelationGraph` owns only the
|
|
4
|
+
`(source, relation, target) → Relation` mapping and the query/mutation
|
|
5
|
+
operations over it. It has no knowledge of artifacts, commits, or merge —
|
|
6
|
+
`Context` still resolves "does this artifact exist" itself (`related()`
|
|
7
|
+
needs the artifact store, so that method stays on `Context`, as a thin
|
|
8
|
+
wrapper that filters `RelationGraph.relations()` against `self._artifacts`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .patches import Relation
|
|
16
|
+
|
|
17
|
+
RelationKey = tuple[str, str, str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RelationGraph:
|
|
21
|
+
"""The live link graph: create/remove/query edges (§15)."""
|
|
22
|
+
|
|
23
|
+
__slots__ = ("_edges",)
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._edges: dict[RelationKey, Relation] = {}
|
|
27
|
+
|
|
28
|
+
def link(self, source_id: str, relation: str, target_id: str) -> Relation:
|
|
29
|
+
"""Establishes a link `source_id —relation→ target_id` (idempotently, §42)."""
|
|
30
|
+
rel = Relation(source_id=source_id, relation=relation, target_id=target_id)
|
|
31
|
+
self._edges[(rel.source_id, rel.relation, rel.target_id)] = rel
|
|
32
|
+
return rel
|
|
33
|
+
|
|
34
|
+
def unlink(
|
|
35
|
+
self,
|
|
36
|
+
source_id: str,
|
|
37
|
+
relation: str | None = None,
|
|
38
|
+
target_id: str | None = None,
|
|
39
|
+
) -> int:
|
|
40
|
+
"""Removes edges; `relation`/`target_id` = None mean "any"."""
|
|
41
|
+
removed = 0
|
|
42
|
+
for key in [k for k in self._edges if k[0] == source_id]:
|
|
43
|
+
if relation is not None and key[1] != relation:
|
|
44
|
+
continue
|
|
45
|
+
if target_id is not None and key[2] != target_id:
|
|
46
|
+
continue
|
|
47
|
+
del self._edges[key]
|
|
48
|
+
removed += 1
|
|
49
|
+
return removed
|
|
50
|
+
|
|
51
|
+
def relations(
|
|
52
|
+
self,
|
|
53
|
+
source_id: str | None = None,
|
|
54
|
+
relation: str | None = None,
|
|
55
|
+
target_id: str | None = None,
|
|
56
|
+
) -> list[Relation]:
|
|
57
|
+
"""All edges, optionally filtered by any component."""
|
|
58
|
+
result: list[Relation] = []
|
|
59
|
+
for rel in self._edges.values():
|
|
60
|
+
if source_id is not None and rel.source_id != source_id:
|
|
61
|
+
continue
|
|
62
|
+
if relation is not None and rel.relation != relation:
|
|
63
|
+
continue
|
|
64
|
+
if target_id is not None and rel.target_id != target_id:
|
|
65
|
+
continue
|
|
66
|
+
result.append(rel)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def __contains__(self, key: RelationKey) -> bool:
|
|
70
|
+
return key in self._edges
|
|
71
|
+
|
|
72
|
+
def __len__(self) -> int:
|
|
73
|
+
return len(self._edges)
|
|
74
|
+
|
|
75
|
+
def values(self) -> list[Relation]:
|
|
76
|
+
return list(self._edges.values())
|
|
77
|
+
|
|
78
|
+
def items(self) -> list[tuple[RelationKey, Relation]]:
|
|
79
|
+
return list(self._edges.items())
|
|
80
|
+
|
|
81
|
+
def copy(self) -> RelationGraph:
|
|
82
|
+
clone = RelationGraph()
|
|
83
|
+
clone._edges = dict(self._edges)
|
|
84
|
+
return clone
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> list[dict[str, Any]]:
|
|
87
|
+
return [rel.to_dict() for rel in self._edges.values()]
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, items: list[dict[str, Any]]) -> RelationGraph:
|
|
91
|
+
graph = cls()
|
|
92
|
+
for d in items:
|
|
93
|
+
rel = Relation.from_dict(d)
|
|
94
|
+
graph._edges[(rel.source_id, rel.relation, rel.target_id)] = rel
|
|
95
|
+
return graph
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_mapping(cls, mapping: dict[RelationKey, Relation]) -> RelationGraph:
|
|
99
|
+
graph = cls()
|
|
100
|
+
graph._edges = dict(mapping)
|
|
101
|
+
return graph
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["RelationGraph", "RelationKey"]
|
reactifact/replay.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""reactifact.replay — deterministic reproduction of runs (§55).
|
|
2
|
+
|
|
3
|
+
Two complementary halves:
|
|
4
|
+
|
|
5
|
+
1. `ReplayLLM` — a provider-level recording studio. `mode="record"` wraps a real
|
|
6
|
+
provider and appends every `(request → response)` pair to a JSONL file;
|
|
7
|
+
`mode="replay"` answers *exactly* the recorded calls and raises `ReplayMiss`
|
|
8
|
+
when a call diverges from the recording — a divergent call must not be
|
|
9
|
+
answered with a wrong result (§59, §55).
|
|
10
|
+
|
|
11
|
+
Record once (real model), then re-run the runtime with the replaying LLM:
|
|
12
|
+
because every deterministic path is unchanged, the run reproduces the same
|
|
13
|
+
artifacts, and "why did the agent produce this answer?" (§55) can be answered
|
|
14
|
+
by walking the reproduced state.
|
|
15
|
+
|
|
16
|
+
2. `replay_context` / `replay_summary` — reconstruct a saved session's state at
|
|
17
|
+
a specific commit (the commit chain is deterministic, §14), i.e. a cheap,
|
|
18
|
+
offline "what was the state when the agent said that".
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
28
|
+
|
|
29
|
+
from .context import Context
|
|
30
|
+
from .providers import LLMProvider, LLMRequest, LLMResponse, LLMResponseChunk
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import AsyncIterator
|
|
34
|
+
|
|
35
|
+
from .session import SessionStore
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReplayMiss(RuntimeError):
|
|
41
|
+
"""A replaying call did not match the recording (§55)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _request_key(model: str, request: LLMRequest) -> str:
|
|
45
|
+
payload = {
|
|
46
|
+
"model": model,
|
|
47
|
+
"temperature": request.temperature,
|
|
48
|
+
"response_format": request.response_format,
|
|
49
|
+
"messages": [{"role": m.role, "content": m.content} for m in request.messages],
|
|
50
|
+
}
|
|
51
|
+
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
52
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReplayLLM(LLMProvider):
|
|
56
|
+
"""Records LLM calls to a JSONL file, or replays them exactly.
|
|
57
|
+
|
|
58
|
+
recorder = ReplayLLM("calls.jsonl", mode="record", inner=real_llm)
|
|
59
|
+
resources = RuntimeResources(llm=recorder)
|
|
60
|
+
runtime.run() # record pass
|
|
61
|
+
|
|
62
|
+
replay = ReplayLLM("calls.jsonl", mode="replay")
|
|
63
|
+
resources = RuntimeResources(llm=replay)
|
|
64
|
+
runtime.run() # deterministic reproduction (§55)
|
|
65
|
+
|
|
66
|
+
A recording also answers the "why" question: every call carries the exact
|
|
67
|
+
prompt and the exact response, so the product story is reproducible.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
recording: str | Path,
|
|
73
|
+
*,
|
|
74
|
+
mode: Literal["record", "replay"] = "replay",
|
|
75
|
+
inner: LLMProvider | None = None,
|
|
76
|
+
model: str = "",
|
|
77
|
+
):
|
|
78
|
+
if mode == "record" and inner is None:
|
|
79
|
+
raise ValueError("mode='record' requires an `inner` provider")
|
|
80
|
+
self.recording = Path(recording)
|
|
81
|
+
self.mode = mode
|
|
82
|
+
self._inner = inner
|
|
83
|
+
self.model = model
|
|
84
|
+
self._cache: dict[str, dict[str, Any]] | None = None
|
|
85
|
+
|
|
86
|
+
# -- LLMProvider ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
async def complete(self, request: LLMRequest) -> LLMResponse:
|
|
89
|
+
if self.mode == "replay":
|
|
90
|
+
return self._replay(request)
|
|
91
|
+
assert self._inner is not None
|
|
92
|
+
response = await self._inner.complete(request)
|
|
93
|
+
self._append(request, response)
|
|
94
|
+
return response
|
|
95
|
+
|
|
96
|
+
async def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
|
|
97
|
+
if self.mode == "record" and self._inner is not None:
|
|
98
|
+
async for chunk in self._inner.stream(request):
|
|
99
|
+
yield chunk
|
|
100
|
+
return
|
|
101
|
+
raise ReplayMiss(
|
|
102
|
+
"stream() is not replayed; record stream calls or use complete()"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# -- recording -----------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _append(self, request: LLMRequest, response: LLMResponse) -> None:
|
|
108
|
+
entry = {
|
|
109
|
+
"key": _request_key(self.model, request),
|
|
110
|
+
"model": self.model,
|
|
111
|
+
"response": {
|
|
112
|
+
"text": response.text,
|
|
113
|
+
"finish_reason": response.finish_reason,
|
|
114
|
+
},
|
|
115
|
+
"usage": response.usage,
|
|
116
|
+
}
|
|
117
|
+
self.recording.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
with self.recording.open("a", encoding="utf-8") as handle:
|
|
119
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
120
|
+
|
|
121
|
+
# -- replaying -----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _load(self) -> dict[str, dict[str, Any]]:
|
|
124
|
+
if self._cache is not None:
|
|
125
|
+
return self._cache
|
|
126
|
+
cache: dict[str, dict[str, Any]] = {}
|
|
127
|
+
if self.recording.exists():
|
|
128
|
+
for line in self.recording.read_text(encoding="utf-8").splitlines():
|
|
129
|
+
entry = json.loads(line)
|
|
130
|
+
cache[entry["key"]] = entry
|
|
131
|
+
self._cache = cache
|
|
132
|
+
return cache
|
|
133
|
+
|
|
134
|
+
def _replay(self, request: LLMRequest) -> LLMResponse:
|
|
135
|
+
key = _request_key(self.model, request)
|
|
136
|
+
entry = self._load().get(key)
|
|
137
|
+
if entry is None:
|
|
138
|
+
logger.warning("replay miss for %r", key)
|
|
139
|
+
raise ReplayMiss(
|
|
140
|
+
"the run diverged from the recording — a call was not recorded. "
|
|
141
|
+
"Re-record with mode='record', or check whether the prompt "
|
|
142
|
+
"changed since the recording was made."
|
|
143
|
+
)
|
|
144
|
+
response = entry["response"]
|
|
145
|
+
return LLMResponse(
|
|
146
|
+
text=response["text"],
|
|
147
|
+
finish_reason=response.get("finish_reason"),
|
|
148
|
+
usage=dict(entry.get("usage") or {}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def replay_context(
|
|
153
|
+
store: SessionStore,
|
|
154
|
+
session_id: str,
|
|
155
|
+
*,
|
|
156
|
+
version: int | None = None,
|
|
157
|
+
) -> Context:
|
|
158
|
+
"""Reconstructs a saved session's state, optionally at a past commit (§55).
|
|
159
|
+
|
|
160
|
+
The session checkpoint carries the full deterministic commit chain (§14), so
|
|
161
|
+
replaying to a version needs no agent execution — it is pure state recovery.
|
|
162
|
+
"""
|
|
163
|
+
context = await store.load_session(session_id)
|
|
164
|
+
if context is None:
|
|
165
|
+
raise KeyError(f"session {session_id!r} not found")
|
|
166
|
+
if version is not None:
|
|
167
|
+
context.checkout(version)
|
|
168
|
+
return context
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def replay_summary(context: Context) -> dict[str, Any]:
|
|
172
|
+
"""A compact "state at this point" summary for the replay CLI."""
|
|
173
|
+
artifacts = context.list_artifacts()
|
|
174
|
+
by_type: dict[str, int] = {}
|
|
175
|
+
for artifact in artifacts:
|
|
176
|
+
tname = artifact.data.__class__.__name__
|
|
177
|
+
by_type[tname] = by_type.get(tname, 0) + 1
|
|
178
|
+
return {
|
|
179
|
+
"version": context.version,
|
|
180
|
+
"artifacts": len(artifacts),
|
|
181
|
+
"by_type": by_type,
|
|
182
|
+
"relations": len(context.relations()),
|
|
183
|
+
"pending_questions": len(context.pending_questions()),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = ["ReplayLLM", "ReplayMiss", "replay_context", "replay_summary"]
|
reactifact/resources.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .providers import EmbeddingProvider, LLMProvider
|
|
6
|
+
from .sources import Source
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuntimeResources:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
llm: LLMProvider | None = None,
|
|
13
|
+
embedder: EmbeddingProvider | None = None,
|
|
14
|
+
sources: dict[str, Source] | None = None,
|
|
15
|
+
**additional: Any,
|
|
16
|
+
):
|
|
17
|
+
self.llm = llm
|
|
18
|
+
self.embedder = embedder
|
|
19
|
+
self.sources = sources or {}
|
|
20
|
+
self.additional = additional
|
|
21
|
+
|
|
22
|
+
def get_source(self, source_id: str) -> Source | None:
|
|
23
|
+
return self.sources.get(source_id)
|
|
24
|
+
|
|
25
|
+
def set(self, name: str, value: Any) -> None:
|
|
26
|
+
self.additional[name] = value
|
|
27
|
+
|
|
28
|
+
def get(self, name: str) -> Any:
|
|
29
|
+
return self.additional.get(name)
|
|
30
|
+
|
|
31
|
+
async def aclose(self) -> None:
|
|
32
|
+
"""Closes the llm/embedder clients if they support it.
|
|
33
|
+
|
|
34
|
+
Duck-typed: `LLMProvider`/`EmbeddingProvider` don't require `aclose`
|
|
35
|
+
(a fake/no-op test double doesn't need one), so it's called only when
|
|
36
|
+
present. Nothing in the runtime calls this automatically — resources
|
|
37
|
+
are typically shared across many turns/runtimes, and closing them
|
|
38
|
+
early would break whatever still holds a reference. Call it yourself
|
|
39
|
+
once, at real shutdown: a FastAPI `lifespan`, or the end of a script.
|
|
40
|
+
`ChatAssistant` is the one exception — see its docstring.
|
|
41
|
+
"""
|
|
42
|
+
for provider in (self.llm, self.embedder):
|
|
43
|
+
aclose = getattr(provider, "aclose", None)
|
|
44
|
+
if aclose is not None:
|
|
45
|
+
await aclose()
|