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
reactifact/branching.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""reactifact.branching — fork/merge semantics and their durable persistence
|
|
2
|
+
(§39-§40).
|
|
3
|
+
|
|
4
|
+
`clone_context`/`fork_context`/`merge_context_from`/`merge_contexts` are the
|
|
5
|
+
git-like state operations `Context.clone()`/`.branch()`/`.merge_from()`/
|
|
6
|
+
`.merge()` delegate to — moved here so the git-like algorithm lives next to
|
|
7
|
+
the concept it implements, not folded into `Context`'s general CRUD/relations/
|
|
8
|
+
HITL surface. `BranchStore` only *persists* named forks so they survive a
|
|
9
|
+
restart:
|
|
10
|
+
|
|
11
|
+
store = BranchStore(SQLiteKVBackend("sessions.sqlite3"))
|
|
12
|
+
await store.save_branch(ctx_branch, session_id="demo", name="hypothesis-a")
|
|
13
|
+
restored = await store.load_branch("demo", "hypothesis-a")
|
|
14
|
+
|
|
15
|
+
Each branch key holds the full self-contained context (`to_dict`, which now also
|
|
16
|
+
carries the fork base snapshot), so a merged restart keeps `merge()` conflict
|
|
17
|
+
detection working. Naming follows `branch:<session>:<name>`; the KV backend stays
|
|
18
|
+
the only storage primitive — branches are a convention on top of it, not a new
|
|
19
|
+
backend (matching the constitution: semantics live in Context operations).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from .artifacts import Artifact
|
|
29
|
+
from .checkpoints import KVBackend
|
|
30
|
+
from .commit import Commit
|
|
31
|
+
from .context import Context
|
|
32
|
+
from .patches import Create, Delete, Link, Operation, Update
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MergeConflict(Exception):
|
|
36
|
+
"""Two branches changed the same artifact differently since their fork (§40).
|
|
37
|
+
|
|
38
|
+
The framework never chooses silently; a verifier or merge policy resolves.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, conflicts: list[str] | None = None):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.conflicts = conflicts or []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clone_context(source: Context) -> Context:
|
|
47
|
+
"""Deep copy of `source`'s live state (artifacts, log, relations)."""
|
|
48
|
+
new_ws = Context()
|
|
49
|
+
for artifact in source._artifacts.values():
|
|
50
|
+
new_artifact = Artifact(
|
|
51
|
+
data=artifact.data.model_copy(deep=True),
|
|
52
|
+
id=artifact.id, # <-- important!
|
|
53
|
+
created_by_commit=artifact.created_by_commit,
|
|
54
|
+
)
|
|
55
|
+
new_artifact._history = [v.model_copy(deep=True) for v in artifact._history]
|
|
56
|
+
new_artifact.created_at = artifact.created_at
|
|
57
|
+
new_artifact.updated_at = artifact.updated_at
|
|
58
|
+
new_ws._artifacts[artifact.id] = new_artifact
|
|
59
|
+
new_ws._log = source._log.copy()
|
|
60
|
+
new_ws._relations = source._relations.copy()
|
|
61
|
+
new_ws._recompute_stale()
|
|
62
|
+
return new_ws
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fork_context(source: Context, *, name: str = "") -> Context:
|
|
66
|
+
"""Forks an isolated copy of `source` for alternative state exploration (§39).
|
|
67
|
+
|
|
68
|
+
The fork records a snapshot of its base, so a later `merge_contexts` of
|
|
69
|
+
two fork-mates can detect diverged artifacts three-way (§40). The branch
|
|
70
|
+
shares `resources` with the parent but is otherwise fully independent:
|
|
71
|
+
subsequent changes on either side do not affect the other.
|
|
72
|
+
"""
|
|
73
|
+
fork = clone_context(source)
|
|
74
|
+
fork.resources = source.resources
|
|
75
|
+
fork._base = clone_context(source)
|
|
76
|
+
fork._fork_name = name
|
|
77
|
+
return fork
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def merge_context_from(target: Context, other: Context) -> None:
|
|
81
|
+
"""Two-way merge: adopts everything in `other` that is newer/absent in
|
|
82
|
+
`target` — no conflict detection (see `merge_contexts` for that)."""
|
|
83
|
+
operations: list[Operation] = []
|
|
84
|
+
for other_artifact in other.list_artifacts():
|
|
85
|
+
other_id = other_artifact.id
|
|
86
|
+
current = target.get(other_id)
|
|
87
|
+
if current is not None:
|
|
88
|
+
if other_artifact.version > current.version:
|
|
89
|
+
new_data = other_artifact.data.model_copy(deep=True)
|
|
90
|
+
target.update(other_id, new_data)
|
|
91
|
+
operations.append(Update(other_id, new_data))
|
|
92
|
+
else:
|
|
93
|
+
new_data = other_artifact.data.model_copy(deep=True)
|
|
94
|
+
# NOTE: no `id=other_id` — matches the pre-extraction behavior of
|
|
95
|
+
# `Context.merge_from()` exactly (a merged-in artifact absent from
|
|
96
|
+
# `target` gets a freshly minted id, not `other_id`). Not fixed
|
|
97
|
+
# here — a real behavior change belongs in its own change, not a
|
|
98
|
+
# pure extraction.
|
|
99
|
+
target.create(new_data)
|
|
100
|
+
operations.append(Create(new_data))
|
|
101
|
+
for rel in other.relations():
|
|
102
|
+
if (rel.source_id, rel.relation, rel.target_id) not in target._relations:
|
|
103
|
+
target.link(rel.source_id, rel.relation, rel.target_id)
|
|
104
|
+
operations.append(Link(rel.source_id, rel.relation, rel.target_id))
|
|
105
|
+
if operations:
|
|
106
|
+
target.log_commit(
|
|
107
|
+
Commit(author="merge", message="Merged Context", operations=operations)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _data_sig(artifact: Artifact[Any] | None) -> Any:
|
|
112
|
+
"""Canonical signature of an artifact's current data (None = absent)."""
|
|
113
|
+
if artifact is None:
|
|
114
|
+
return None
|
|
115
|
+
return artifact.data.model_dump(mode="json")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _kind_short(signature: Any) -> str:
|
|
119
|
+
if signature is None:
|
|
120
|
+
return "absent"
|
|
121
|
+
return "changed"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def merge_contexts(
|
|
125
|
+
target: Context, other: Context, *, message: str = "Merged branch"
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Merges `other` into `target` with explicit conflicts, atomically (§40).
|
|
128
|
+
|
|
129
|
+
Three-way merge against the shared fork base (the fork snapshot of
|
|
130
|
+
`other`, or of `target` when `other` has none). For every artifact that
|
|
131
|
+
exists anywhere among base/target/other:
|
|
132
|
+
|
|
133
|
+
equal(target, other) → no-op
|
|
134
|
+
equal(target, base) → adopt `other` (only it moved the artifact)
|
|
135
|
+
equal(other, base) → keep `target` (only it moved the artifact)
|
|
136
|
+
otherwise → MergeConflict, nothing is applied
|
|
137
|
+
|
|
138
|
+
So a change adopted from `other` never silently overwrites a change made
|
|
139
|
+
on `target` since the fork (§40: the framework must not choose silently).
|
|
140
|
+
"""
|
|
141
|
+
base = other._base if other._base is not None else target._base
|
|
142
|
+
if base is None:
|
|
143
|
+
base = Context()
|
|
144
|
+
|
|
145
|
+
ids = set(base._artifacts) | set(target._artifacts) | set(other._artifacts)
|
|
146
|
+
operations: list[Operation] = []
|
|
147
|
+
pending: dict[str, BaseModel] = {}
|
|
148
|
+
conflicts: list[str] = []
|
|
149
|
+
|
|
150
|
+
for artifact_id in sorted(ids):
|
|
151
|
+
base_art = base._artifacts.get(artifact_id)
|
|
152
|
+
target_art = target._artifacts.get(artifact_id)
|
|
153
|
+
other_art = other._artifacts.get(artifact_id)
|
|
154
|
+
sb = _data_sig(base_art)
|
|
155
|
+
st = _data_sig(target_art)
|
|
156
|
+
so = _data_sig(other_art)
|
|
157
|
+
if st == so:
|
|
158
|
+
continue
|
|
159
|
+
if st == sb or so == sb:
|
|
160
|
+
if so == sb:
|
|
161
|
+
continue # only target moved it — keep as is
|
|
162
|
+
# only other moved it — adopt
|
|
163
|
+
if other_art is None:
|
|
164
|
+
operations.append(Delete(artifact_id))
|
|
165
|
+
else:
|
|
166
|
+
pending[artifact_id] = other_art.data.model_copy(deep=True)
|
|
167
|
+
operations.append(
|
|
168
|
+
Create(other_art.data)
|
|
169
|
+
if target_art is None
|
|
170
|
+
else Update(artifact_id, other_art.data)
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
conflicts.append(
|
|
174
|
+
f"{artifact_id} diverged since the fork "
|
|
175
|
+
f"(target={_kind_short(st)}, other={_kind_short(so)})"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if conflicts:
|
|
179
|
+
raise MergeConflict(
|
|
180
|
+
"merge would overwrite diverged state — resolve first (§40):\n"
|
|
181
|
+
+ "\n".join(conflicts),
|
|
182
|
+
conflicts=conflicts,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
removed: set[str] = set()
|
|
186
|
+
for op in operations:
|
|
187
|
+
if isinstance(op, Delete):
|
|
188
|
+
target._artifacts.pop(op.artifact_id, None)
|
|
189
|
+
removed.add(op.artifact_id)
|
|
190
|
+
for artifact_id, data in pending.items():
|
|
191
|
+
existing = target._artifacts.get(artifact_id)
|
|
192
|
+
if existing is None:
|
|
193
|
+
target._artifacts[artifact_id] = Artifact(data=data, id=artifact_id)
|
|
194
|
+
else:
|
|
195
|
+
existing.update(data)
|
|
196
|
+
|
|
197
|
+
for rel in other.relations():
|
|
198
|
+
if rel.source_id in removed or rel.target_id in removed:
|
|
199
|
+
continue
|
|
200
|
+
if (rel.source_id, rel.relation, rel.target_id) not in target._relations:
|
|
201
|
+
target.link(rel.source_id, rel.relation, rel.target_id)
|
|
202
|
+
operations.append(Link(rel.source_id, rel.relation, rel.target_id))
|
|
203
|
+
|
|
204
|
+
if operations:
|
|
205
|
+
target.log_commit(
|
|
206
|
+
Commit(author="merge", message=message, operations=operations)
|
|
207
|
+
)
|
|
208
|
+
# merge mutates `_artifacts` directly (create/update/delete above),
|
|
209
|
+
# bypassing the incremental hooks in `create()`/`update()`/
|
|
210
|
+
# `delete()` — resync `_stale` from scratch rather than risk it
|
|
211
|
+
# drifting from the post-merge state.
|
|
212
|
+
target._recompute_stale()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class BranchStore:
|
|
216
|
+
"""Persists named branches (`branch:<session>:<name>`) over a KV backend."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, backend: KVBackend):
|
|
219
|
+
self.backend = backend
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _key(session_id: str, name: str) -> str:
|
|
223
|
+
return f"branch:{session_id}:{name}"
|
|
224
|
+
|
|
225
|
+
async def save_branch(
|
|
226
|
+
self, context: Context, *, session_id: str, name: str
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Saves a branch (including its fork base snapshot, §40)."""
|
|
229
|
+
await context.to_kv(self.backend, self._key(session_id, name))
|
|
230
|
+
|
|
231
|
+
async def load_branch(self, session_id: str, name: str) -> Context | None:
|
|
232
|
+
"""Loads a branch, or None if it does not exist."""
|
|
233
|
+
return await Context.from_kv(self.backend, self._key(session_id, name))
|
|
234
|
+
|
|
235
|
+
async def list_branches(self, session_id: str) -> list[str]:
|
|
236
|
+
prefix = f"branch:{session_id}:"
|
|
237
|
+
names: list[str] = []
|
|
238
|
+
all_keys = await self.backend.keys()
|
|
239
|
+
for key in all_keys:
|
|
240
|
+
if key.startswith(prefix):
|
|
241
|
+
names.append(key[len(prefix) :])
|
|
242
|
+
return sorted(names)
|
|
243
|
+
|
|
244
|
+
async def delete_branch(self, session_id: str, name: str) -> None:
|
|
245
|
+
await self.backend.delete(self._key(session_id, name))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
__all__ = [
|
|
249
|
+
"BranchStore",
|
|
250
|
+
"MergeConflict",
|
|
251
|
+
"clone_context",
|
|
252
|
+
"fork_context",
|
|
253
|
+
"merge_context_from",
|
|
254
|
+
"merge_contexts",
|
|
255
|
+
]
|
reactifact/budget.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RunOutcome(StrEnum):
|
|
10
|
+
"""Deterministic run outcome (instead of silent nothingness, §58).
|
|
11
|
+
|
|
12
|
+
The application routes on it: completed → answer is ready;
|
|
13
|
+
budget_* / iterations_exhausted → not enough resources, show an honest status.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
COMPLETED = "completed"
|
|
17
|
+
ITERATIONS_EXHAUSTED = "iterations_exhausted"
|
|
18
|
+
BUDGET_RUNS_EXCEEDED = "budget_runs_exceeded"
|
|
19
|
+
BUDGET_TIME_EXCEEDED = "budget_time_exceeded"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Budget(BaseModel):
|
|
23
|
+
"""Resource limit for a single run (turn), applied by the runtime."""
|
|
24
|
+
|
|
25
|
+
max_runs: int | None = None # max agent runs
|
|
26
|
+
max_iterations: int | None = None # max loop generations
|
|
27
|
+
max_seconds: float | None = None # time budget
|
|
28
|
+
max_tool_calls: int | None = None # max tool calls executed (LLM agent)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RunStats:
|
|
33
|
+
"""Run summary: how much was done and why it stopped."""
|
|
34
|
+
|
|
35
|
+
runs: int
|
|
36
|
+
iterations: int
|
|
37
|
+
outcome: RunOutcome
|
|
38
|
+
duration: float
|
|
39
|
+
#: Agent executions that raised and were isolated (`Runtime(isolate_errors=True)`).
|
|
40
|
+
#: Always 0 when isolation is off — an exception propagates instead (§69).
|
|
41
|
+
errors: int = 0
|
reactifact/chat.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""reactifact.chat — the app-facing chat layer (sessions + turns + history).
|
|
2
|
+
|
|
3
|
+
A thin, transport-agnostic layer on top of the runtime: it owns sessions
|
|
4
|
+
(`SessionStore`), the wire-neutral turn loop ("create the user artifact, stream
|
|
5
|
+
status events, then the terminal reply") and history reconstruction. It knows
|
|
6
|
+
nothing about HTTP/SSE — the web adapter lives in `reactifact.web`.
|
|
7
|
+
|
|
8
|
+
Two levels of use:
|
|
9
|
+
|
|
10
|
+
- `ChatAssistant` — concrete, batteries-included for the canonical chat
|
|
11
|
+
contract. Configure it with hooks (agents, `user_message` model, `reply`).
|
|
12
|
+
- `run_message` / `default_session_state` — building blocks, for apps whose
|
|
13
|
+
loop or transport differs (bots, custom SSE, medic-lab-style steering).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
from .agents import Agent
|
|
27
|
+
from .budget import Budget
|
|
28
|
+
from .context import Context
|
|
29
|
+
from .events import Event
|
|
30
|
+
from .resources import RuntimeResources
|
|
31
|
+
from .runtime import Runtime
|
|
32
|
+
from .session import Session, SessionStore
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("reactifact.chat")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChatEvent(BaseModel):
|
|
38
|
+
"""One frame of a chat turn, transport-neutral.
|
|
39
|
+
|
|
40
|
+
`kind` is the wire contract of the canonical chat:
|
|
41
|
+
``session`` (start), ``status`` (progress announcement) or ``message``
|
|
42
|
+
(terminal reply).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
kind: str
|
|
46
|
+
session_id: str = ""
|
|
47
|
+
message: str = ""
|
|
48
|
+
waiting: bool = False
|
|
49
|
+
payload: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- building blocks ------------------------------------------------------- #
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fallback_payload(fallback_reply: str) -> dict[str, Any]:
|
|
56
|
+
"""The honest terminal payload when a turn crashed (§59)."""
|
|
57
|
+
return {"reply": fallback_reply, "waiting": False, "error": True}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def run_message(
|
|
61
|
+
runtime: Runtime,
|
|
62
|
+
text: str,
|
|
63
|
+
*,
|
|
64
|
+
user_message: type[BaseModel],
|
|
65
|
+
reply: Callable[[Context, str], dict[str, Any]],
|
|
66
|
+
session_id: str = "",
|
|
67
|
+
create_message: Callable[[Context, str], str] | None = None,
|
|
68
|
+
status_kinds: Sequence[str] = ("status",),
|
|
69
|
+
fallback_reply: str = "No reply assembled.",
|
|
70
|
+
) -> AsyncIterator[ChatEvent]:
|
|
71
|
+
"""Run one user turn: create the input artifact, stream status events,
|
|
72
|
+
emit the terminal reply.
|
|
73
|
+
|
|
74
|
+
`reply(ctx, msg_id) -> dict` adapts the app's state into the `message`
|
|
75
|
+
payload (the only domain hook). Statuses are deduplicated so repeated
|
|
76
|
+
progress announcements don't double-emit.
|
|
77
|
+
|
|
78
|
+
`create_message(ctx, text) -> msg_id` overrides how the turn enters the
|
|
79
|
+
context (default: create a `user_message` artifact) — HITL apps where a
|
|
80
|
+
new turn resumes a pending question instead of appending a message
|
|
81
|
+
(devops-style clarify) pass their own.
|
|
82
|
+
|
|
83
|
+
`status_kinds` selects which progress event kinds are forwarded as `status`
|
|
84
|
+
frames (default: only `status`; e.g. tool-announcing demos also forward
|
|
85
|
+
`agent`).
|
|
86
|
+
|
|
87
|
+
Errors never escape: a failed runtime/reply degrades to the `fallback_reply`
|
|
88
|
+
`message` event (logged via `reactifact.chat` logger) so a web layer never
|
|
89
|
+
delivers a 500 mid-stream.
|
|
90
|
+
"""
|
|
91
|
+
forwarded = set(status_kinds)
|
|
92
|
+
ctx = runtime.context
|
|
93
|
+
try:
|
|
94
|
+
if create_message is not None:
|
|
95
|
+
msg_id = create_message(ctx, text)
|
|
96
|
+
else:
|
|
97
|
+
msg_id = ctx.create(user_message(text=text, session_id=session_id)).id
|
|
98
|
+
except Exception:
|
|
99
|
+
logger.exception("chat.run_message: failed to enter the turn")
|
|
100
|
+
yield ChatEvent(
|
|
101
|
+
kind="message",
|
|
102
|
+
session_id=session_id,
|
|
103
|
+
payload=_fallback_payload(fallback_reply),
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
last: str | None = None
|
|
108
|
+
try:
|
|
109
|
+
async for event in runtime.astream():
|
|
110
|
+
if event.kind not in forwarded:
|
|
111
|
+
continue
|
|
112
|
+
if event.message == last:
|
|
113
|
+
continue
|
|
114
|
+
last = event.message
|
|
115
|
+
yield ChatEvent(kind="status", session_id=session_id, message=event.message)
|
|
116
|
+
except Exception:
|
|
117
|
+
# The runtime crashed — the context may be half-applied. Skip the reply
|
|
118
|
+
# hook (it could echo stale state) and degregate to the honest fallback.
|
|
119
|
+
logger.exception("chat.run_message: runtime crashed inside the turn")
|
|
120
|
+
yield ChatEvent(
|
|
121
|
+
kind="message",
|
|
122
|
+
session_id=session_id,
|
|
123
|
+
payload=_fallback_payload(fallback_reply),
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
try:
|
|
127
|
+
payload = reply(ctx, msg_id)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.exception("chat.run_message: reply hook crashed")
|
|
130
|
+
payload = _fallback_payload(fallback_reply)
|
|
131
|
+
yield ChatEvent(
|
|
132
|
+
kind="message",
|
|
133
|
+
session_id=session_id,
|
|
134
|
+
payload=payload or _fallback_payload(fallback_reply),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def default_session_state(
|
|
139
|
+
ctx: Context, *, user_message: type[BaseModel]
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Generic history: every artifact with a `text` field, in creation order.
|
|
142
|
+
|
|
143
|
+
Artifacts of `user_message`'s type are marked `user`, everything else —
|
|
144
|
+
`assistant`. Apps with richer reply payloads pass their own hook.
|
|
145
|
+
"""
|
|
146
|
+
messages: list[dict[str, Any]] = []
|
|
147
|
+
for artifact in sorted(ctx.list_artifacts(), key=lambda a: a.created_at):
|
|
148
|
+
data = artifact.data
|
|
149
|
+
text = getattr(data, "text", None)
|
|
150
|
+
if not isinstance(text, str):
|
|
151
|
+
continue
|
|
152
|
+
role = "user" if isinstance(data, user_message) else "assistant"
|
|
153
|
+
messages.append(
|
|
154
|
+
{
|
|
155
|
+
"role": role,
|
|
156
|
+
"text": text,
|
|
157
|
+
"at": artifact.created_at.isoformat(),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return {"messages": messages}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve(value: Any) -> Any:
|
|
164
|
+
return value() if callable(value) else value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- the canonical chat assistant ----------------------------------------- #
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ChatAssistant:
|
|
171
|
+
"""Session-persisted chat over the runtime, for the canonical contract.
|
|
172
|
+
|
|
173
|
+
Configure with hooks — the assistant owns sessions, the turn loop and
|
|
174
|
+
history. `agents`/`resources` accept values or callables (resolved per
|
|
175
|
+
request, so a fresh `RuntimeResources` can replace providers).
|
|
176
|
+
|
|
177
|
+
A callable `resources=` is assumed to build a fresh, turn-scoped
|
|
178
|
+
`RuntimeResources` each call (e.g. `resources=lambda: build_resources()`)
|
|
179
|
+
— `stream()` closes it (`RuntimeResources.aclose()`) after every turn, so
|
|
180
|
+
its provider's HTTP client doesn't leak. Pass a plain `RuntimeResources`
|
|
181
|
+
instance instead when you want one shared, long-lived provider across
|
|
182
|
+
turns/sessions — that instance is never closed automatically; close it
|
|
183
|
+
yourself at real shutdown.
|
|
184
|
+
|
|
185
|
+
Base usage:
|
|
186
|
+
|
|
187
|
+
assistant = ChatAssistant(
|
|
188
|
+
store=store,
|
|
189
|
+
agents=ALL_AGENTS,
|
|
190
|
+
user_message=UserQuery,
|
|
191
|
+
reply=knowledge_reply,
|
|
192
|
+
resources=lambda: build_resources(),
|
|
193
|
+
)
|
|
194
|
+
async for ev in assistant.stream("hello", session_id="s1"):
|
|
195
|
+
...
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
store: SessionStore,
|
|
202
|
+
agents: Sequence[Agent] | Callable[[], Sequence[Agent]],
|
|
203
|
+
user_message: type[BaseModel],
|
|
204
|
+
reply: Callable[[Context, str], dict[str, Any]],
|
|
205
|
+
session_state: Callable[[Context], dict[str, Any]] | None = None,
|
|
206
|
+
resources: RuntimeResources | Callable[[], RuntimeResources] | None = None,
|
|
207
|
+
budget: Budget | None = None,
|
|
208
|
+
max_concurrency: int | None = None,
|
|
209
|
+
tracer: Any = None,
|
|
210
|
+
create_message: Callable[[Context, str], str] | None = None,
|
|
211
|
+
status_kinds: Sequence[str] = ("status",),
|
|
212
|
+
fallback_reply: str = "No reply assembled.",
|
|
213
|
+
isolate_errors: bool = False,
|
|
214
|
+
on_agent_error: Callable[[Agent, Event, BaseException], None] | None = None,
|
|
215
|
+
):
|
|
216
|
+
self.store = store
|
|
217
|
+
self._agents = agents
|
|
218
|
+
self._user_message = user_message
|
|
219
|
+
self._reply = reply
|
|
220
|
+
self._session_state = session_state
|
|
221
|
+
self._resources = resources
|
|
222
|
+
self._budget = budget
|
|
223
|
+
self._max_concurrency = max_concurrency
|
|
224
|
+
self._tracer = tracer
|
|
225
|
+
self._create_message = create_message
|
|
226
|
+
self._status_kinds = tuple(status_kinds)
|
|
227
|
+
self._fallback_reply = fallback_reply
|
|
228
|
+
self._isolate_errors = isolate_errors
|
|
229
|
+
self._on_agent_error = on_agent_error
|
|
230
|
+
# Serializes concurrent turns on the *same* session_id (a double
|
|
231
|
+
# submit, a client retry): without this, two overlapping stream()
|
|
232
|
+
# calls both load the same starting state and the later save() wins,
|
|
233
|
+
# silently dropping the other turn (§59: no silent data loss).
|
|
234
|
+
# Different session_ids never block each other. Entries are removed
|
|
235
|
+
# once uncontended (`_lock_refs` hits 0) so this stays bounded by
|
|
236
|
+
# concurrently-active sessions, not by every session_id ever seen —
|
|
237
|
+
# see `_locked_session`.
|
|
238
|
+
self._locks_guard = asyncio.Lock()
|
|
239
|
+
self._session_locks: dict[str, asyncio.Lock] = {}
|
|
240
|
+
self._lock_refs: dict[str, int] = {}
|
|
241
|
+
|
|
242
|
+
@asynccontextmanager
|
|
243
|
+
async def _locked_session(self, session_id: str) -> AsyncGenerator[None, None]:
|
|
244
|
+
"""Mutual exclusion per `session_id` for the turn's duration (§59).
|
|
245
|
+
|
|
246
|
+
The lock is created on first use and dropped once nothing holds it
|
|
247
|
+
(`_lock_refs` hits 0) — sized by concurrently-active sessions, not
|
|
248
|
+
every session_id ever seen, so a long-lived server doesn't grow this
|
|
249
|
+
dict without bound.
|
|
250
|
+
"""
|
|
251
|
+
async with self._locks_guard:
|
|
252
|
+
lock = self._session_locks.setdefault(session_id, asyncio.Lock())
|
|
253
|
+
self._lock_refs[session_id] = self._lock_refs.get(session_id, 0) + 1
|
|
254
|
+
async with lock:
|
|
255
|
+
try:
|
|
256
|
+
yield
|
|
257
|
+
finally:
|
|
258
|
+
async with self._locks_guard:
|
|
259
|
+
self._lock_refs[session_id] -= 1
|
|
260
|
+
if self._lock_refs[session_id] <= 0:
|
|
261
|
+
self._lock_refs.pop(session_id, None)
|
|
262
|
+
self._session_locks.pop(session_id, None)
|
|
263
|
+
|
|
264
|
+
async def _open(self, session_id: str) -> Session:
|
|
265
|
+
return await self.store.open(session_id, resources=_resolve(self._resources))
|
|
266
|
+
|
|
267
|
+
def _build_runtime(self, session: Session) -> Runtime:
|
|
268
|
+
return Runtime(
|
|
269
|
+
session.context,
|
|
270
|
+
agents=list(_resolve(self._agents)),
|
|
271
|
+
session=session,
|
|
272
|
+
budget=self._budget,
|
|
273
|
+
max_concurrency=self._max_concurrency,
|
|
274
|
+
tracer=_resolve(self._tracer),
|
|
275
|
+
isolate_errors=self._isolate_errors,
|
|
276
|
+
on_agent_error=self._on_agent_error,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def stream(self, text: str, session_id: str = "") -> AsyncIterator[ChatEvent]:
|
|
280
|
+
"""Stream one turn: ``session`` → ``status``… → ``message``.
|
|
281
|
+
|
|
282
|
+
Never raises for app-level failures: session open / runtime crash /
|
|
283
|
+
reply hook all degrade to the fallback `message` and are logged via the
|
|
284
|
+
`reactifact.chat` logger, so a web layer never delivers a 500 mid-stream.
|
|
285
|
+
|
|
286
|
+
Turns on the same `session_id` are serialized (`_locked_session`): a
|
|
287
|
+
second concurrent call for the same session waits for the first to
|
|
288
|
+
finish instead of racing it to `session.save()` (§59 — no silent lost
|
|
289
|
+
update). Different `session_id`s never block each other. This only
|
|
290
|
+
covers calls made through this `ChatAssistant` instance — the
|
|
291
|
+
lower-level `run_message` building block has no such guarantee, by
|
|
292
|
+
design (see the module docstring).
|
|
293
|
+
"""
|
|
294
|
+
async with self._locked_session(session_id):
|
|
295
|
+
try:
|
|
296
|
+
session = await self._open(session_id)
|
|
297
|
+
runtime = self._build_runtime(session)
|
|
298
|
+
except Exception:
|
|
299
|
+
logger.exception(
|
|
300
|
+
"chat.ChatAssistant: failed to open session %r", session_id
|
|
301
|
+
)
|
|
302
|
+
yield ChatEvent(
|
|
303
|
+
kind="message",
|
|
304
|
+
session_id=session_id,
|
|
305
|
+
payload=_fallback_payload(self._fallback_reply),
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
yield ChatEvent(kind="session", session_id=session_id)
|
|
309
|
+
try:
|
|
310
|
+
async for event in run_message(
|
|
311
|
+
runtime,
|
|
312
|
+
text,
|
|
313
|
+
user_message=self._user_message,
|
|
314
|
+
reply=self._reply,
|
|
315
|
+
create_message=self._create_message,
|
|
316
|
+
status_kinds=self._status_kinds,
|
|
317
|
+
fallback_reply=self._fallback_reply,
|
|
318
|
+
session_id=session_id,
|
|
319
|
+
):
|
|
320
|
+
yield event
|
|
321
|
+
finally:
|
|
322
|
+
try:
|
|
323
|
+
await session.save() # persist the conversation after the turn
|
|
324
|
+
except Exception:
|
|
325
|
+
logger.exception(
|
|
326
|
+
"chat.ChatAssistant: failed to save session %r", session_id
|
|
327
|
+
)
|
|
328
|
+
if callable(self._resources):
|
|
329
|
+
# A callable `resources=` builds a fresh RuntimeResources (and
|
|
330
|
+
# typically a fresh provider + HTTP client) on every turn —
|
|
331
|
+
# nothing else will ever reference this instance again, so
|
|
332
|
+
# it's this turn's job to close it. A shared instance passed
|
|
333
|
+
# directly is not touched here: it must outlive this turn.
|
|
334
|
+
try:
|
|
335
|
+
await session.context.resources.aclose()
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.exception(
|
|
338
|
+
"chat.ChatAssistant: failed to close per-turn resources %r",
|
|
339
|
+
session_id,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def invoke(self, text: str, session_id: str = "") -> dict[str, Any]:
|
|
343
|
+
"""Run one turn and return the terminal reply (aggregated stream)."""
|
|
344
|
+
message: dict[str, Any] = {}
|
|
345
|
+
async for event in self.stream(text, session_id=session_id):
|
|
346
|
+
if event.kind == "message":
|
|
347
|
+
message = dict(event.payload) or {"reply": self._fallback_reply}
|
|
348
|
+
return message
|
|
349
|
+
|
|
350
|
+
async def history(self, session_id: str = "") -> dict[str, Any]:
|
|
351
|
+
"""Reconstruct the chat thread of a persisted session."""
|
|
352
|
+
try:
|
|
353
|
+
session = await self._open(session_id)
|
|
354
|
+
except Exception:
|
|
355
|
+
logger.exception("chat.ChatAssistant: history open failed %r", session_id)
|
|
356
|
+
return {"messages": []}
|
|
357
|
+
if not session.loaded:
|
|
358
|
+
return {"messages": []}
|
|
359
|
+
if self._session_state is not None:
|
|
360
|
+
return self._session_state(session.context)
|
|
361
|
+
return default_session_state(session.context, user_message=self._user_message)
|
|
362
|
+
|
|
363
|
+
def reply_fallback(self) -> dict[str, Any]:
|
|
364
|
+
"""The honest terminal payload when nothing was assembled (§59)."""
|
|
365
|
+
return {"reply": self._fallback_reply, "waiting": False}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
__all__ = [
|
|
369
|
+
"ChatAssistant",
|
|
370
|
+
"ChatEvent",
|
|
371
|
+
"default_session_state",
|
|
372
|
+
"run_message",
|
|
373
|
+
]
|