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
@@ -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
+ ]