monkeybot 2.1.1__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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,336 @@
1
+ """Firestore persistence for prompt-first scheduled agent loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import cast
8
+
9
+ from google.cloud import firestore
10
+ from google.cloud.firestore import AsyncClient
11
+ from google.cloud.firestore_v1.base_query import FieldFilter
12
+
13
+ from monkeybot.core.persistence.scheduled_loops import (
14
+ ScheduledLoopCreate,
15
+ ScheduledLoopRow,
16
+ _loop_id_from_create,
17
+ doc_to_scheduled_loop_row,
18
+ validate_loop_guards,
19
+ )
20
+
21
+ _LOOP_STATUSES = frozenset({"active", "paused", "completed", "failed"})
22
+
23
+
24
+ def _collection_name(prefix: str, base: str) -> str:
25
+ if not prefix:
26
+ return base
27
+ return f"{prefix}_{base}"
28
+
29
+
30
+ class FirestoreScheduledLoopStore:
31
+ """Firestore-backed scheduled-loop store."""
32
+
33
+ def __init__(self, client: AsyncClient, prefix: str) -> None:
34
+ self._client = client
35
+ self._collection = _collection_name(prefix, "scheduled_loops")
36
+
37
+ def _doc(self, loop_id: str) -> firestore.AsyncDocumentReference:
38
+ return self._client.collection(self._collection).document(loop_id)
39
+
40
+ async def create(self, spec: ScheduledLoopCreate) -> ScheduledLoopRow:
41
+ loop_id = _loop_id_from_create(spec)
42
+ validate_loop_guards(
43
+ max_ticks=spec.max_ticks,
44
+ max_runtime_ms=spec.max_runtime_ms,
45
+ unbounded=spec.unbounded,
46
+ )
47
+ now_ms = int(time.time() * 1000)
48
+ payload = {
49
+ "session_id": spec.session_id.strip() or "loop-main",
50
+ "status": "active",
51
+ "prompt": spec.prompt.strip(),
52
+ "interval_ms": spec.interval_ms,
53
+ "max_ticks": spec.max_ticks,
54
+ "max_runtime_ms": spec.max_runtime_ms,
55
+ "skip_if_busy": 1 if spec.skip_if_busy else 0,
56
+ "tick_index": 0,
57
+ "next_tick_at_ms": now_ms,
58
+ "started_at_ms": now_ms,
59
+ "last_tick_at_ms": None,
60
+ "last_error": None,
61
+ "stop_reason": None,
62
+ "tick_in_flight": 0,
63
+ "worker_id": None,
64
+ "claimed_at_ms": None,
65
+ }
66
+ doc_ref = self._doc(loop_id)
67
+ transaction = self._client.transaction()
68
+
69
+ async def _create_body(
70
+ txn: firestore.AsyncTransaction,
71
+ ref: firestore.AsyncDocumentReference,
72
+ ) -> None:
73
+ snapshot = await ref.get(transaction=txn)
74
+ if snapshot.exists:
75
+ raise ValueError(f"scheduled loop already exists: {loop_id}")
76
+ txn.set(ref, payload)
77
+
78
+ create_txn = cast(
79
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[None]],
80
+ firestore.async_transactional(_create_body),
81
+ )
82
+ await create_txn(transaction, doc_ref)
83
+ row = await self.get(loop_id)
84
+ if row is None:
85
+ raise RuntimeError("failed to read scheduled loop after insert")
86
+ return row
87
+
88
+ async def get(self, loop_id: str) -> ScheduledLoopRow | None:
89
+ snapshot = await self._doc(loop_id).get()
90
+ if not snapshot.exists:
91
+ return None
92
+ return doc_to_scheduled_loop_row(snapshot.id, snapshot.to_dict() or {})
93
+
94
+ async def list_all(self) -> list[ScheduledLoopRow]:
95
+ rows: list[ScheduledLoopRow] = []
96
+ query = self._client.collection(self._collection).order_by(
97
+ "started_at_ms", direction=firestore.Query.DESCENDING
98
+ )
99
+ async for doc in query.stream():
100
+ rows.append(doc_to_scheduled_loop_row(doc.id, doc.to_dict() or {}))
101
+ return rows
102
+
103
+ async def list_due(self, now_ms: int) -> list[ScheduledLoopRow]:
104
+ rows: list[ScheduledLoopRow] = []
105
+ query = (
106
+ self._client.collection(self._collection)
107
+ .where(filter=FieldFilter("status", "==", "active"))
108
+ .where(filter=FieldFilter("tick_in_flight", "==", 0))
109
+ .where(filter=FieldFilter("next_tick_at_ms", "<=", now_ms))
110
+ .order_by("next_tick_at_ms")
111
+ )
112
+ async for doc in query.stream():
113
+ rows.append(doc_to_scheduled_loop_row(doc.id, doc.to_dict() or {}))
114
+ return rows
115
+
116
+ async def claim_tick(self, loop_id: str, worker_id: str) -> ScheduledLoopRow | None:
117
+ doc_ref = self._doc(loop_id)
118
+ transaction = self._client.transaction()
119
+ now_ms = int(time.time() * 1000)
120
+
121
+ async def _claim_body(
122
+ txn: firestore.AsyncTransaction,
123
+ ref: firestore.AsyncDocumentReference,
124
+ ) -> bool:
125
+ snapshot = await ref.get(transaction=txn)
126
+ if not snapshot.exists:
127
+ return False
128
+ data = snapshot.to_dict() or {}
129
+ if data.get("status") != "active":
130
+ return False
131
+ if int(cast(int, data.get("tick_in_flight", 0))) != 0:
132
+ return False
133
+ if int(cast(int, data.get("next_tick_at_ms", 0))) > now_ms:
134
+ return False
135
+ txn.update(
136
+ ref,
137
+ {
138
+ "tick_in_flight": 1,
139
+ "worker_id": worker_id,
140
+ "claimed_at_ms": now_ms,
141
+ },
142
+ )
143
+ return True
144
+
145
+ claim_txn = cast(
146
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[bool]],
147
+ firestore.async_transactional(_claim_body),
148
+ )
149
+ if not await claim_txn(transaction, doc_ref):
150
+ return None
151
+ return await self.get(loop_id)
152
+
153
+ async def release_stale_claims(self, stale_after_ms: int) -> int:
154
+ cutoff = int(time.time() * 1000) - stale_after_ms
155
+ query = (
156
+ self._client.collection(self._collection)
157
+ .where(filter=FieldFilter("tick_in_flight", "==", 1))
158
+ .where(filter=FieldFilter("claimed_at_ms", "<", cutoff))
159
+ )
160
+ reset = 0
161
+ batch = self._client.batch()
162
+ count = 0
163
+ async for doc in query.stream():
164
+ data = doc.to_dict() or {}
165
+ if data.get("claimed_at_ms") is None:
166
+ continue
167
+ batch.update(
168
+ doc.reference,
169
+ {
170
+ "tick_in_flight": 0,
171
+ "worker_id": None,
172
+ "claimed_at_ms": None,
173
+ "last_error": data.get("last_error") or "stale tick claim released",
174
+ },
175
+ )
176
+ reset += 1
177
+ count += 1
178
+ if count >= 400:
179
+ await batch.commit()
180
+ batch = self._client.batch()
181
+ count = 0
182
+ if count:
183
+ await batch.commit()
184
+ return reset
185
+
186
+ async def complete_tick(
187
+ self,
188
+ loop_id: str,
189
+ *,
190
+ worker_id: str,
191
+ error: str | None = None,
192
+ ) -> ScheduledLoopRow | None:
193
+ doc_ref = self._doc(loop_id)
194
+ transaction = self._client.transaction()
195
+ now_ms = int(time.time() * 1000)
196
+
197
+ async def _complete_body(
198
+ txn: firestore.AsyncTransaction,
199
+ ref: firestore.AsyncDocumentReference,
200
+ ) -> bool:
201
+ snapshot = await ref.get(transaction=txn)
202
+ if not snapshot.exists:
203
+ return False
204
+ data = snapshot.to_dict() or {}
205
+ if data.get("worker_id") != worker_id or int(data.get("tick_in_flight", 0)) != 1:
206
+ return False
207
+ tick_index = int(cast(int, data.get("tick_index", 0))) + 1
208
+ status = str(data.get("status", "active"))
209
+ stop_reason: str | None = None
210
+ interval_ms = int(cast(int, data.get("interval_ms", 0)))
211
+ started_at_ms = int(cast(int, data.get("started_at_ms", now_ms)))
212
+ if error:
213
+ status = "failed"
214
+ stop_reason = "tick_error"
215
+ else:
216
+ max_ticks = data.get("max_ticks")
217
+ max_runtime_ms = data.get("max_runtime_ms")
218
+ if max_ticks is not None and tick_index >= int(cast(int, max_ticks)):
219
+ status = "completed"
220
+ stop_reason = "max_ticks"
221
+ elif (
222
+ max_runtime_ms is not None
223
+ and (now_ms - started_at_ms) >= int(cast(int, max_runtime_ms))
224
+ ):
225
+ status = "completed"
226
+ stop_reason = "max_runtime"
227
+ next_tick_at = (
228
+ now_ms + interval_ms
229
+ if status == "active"
230
+ else int(cast(int, data.get("next_tick_at_ms", now_ms)))
231
+ )
232
+ txn.update(
233
+ ref,
234
+ {
235
+ "tick_index": tick_index,
236
+ "last_tick_at_ms": now_ms,
237
+ "last_error": error,
238
+ "status": status,
239
+ "stop_reason": stop_reason,
240
+ "next_tick_at_ms": next_tick_at,
241
+ "tick_in_flight": 0,
242
+ "worker_id": None,
243
+ "claimed_at_ms": None,
244
+ },
245
+ )
246
+ return True
247
+
248
+ complete_txn = cast(
249
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[bool]],
250
+ firestore.async_transactional(_complete_body),
251
+ )
252
+ if not await complete_txn(transaction, doc_ref):
253
+ return None
254
+ return await self.get(loop_id)
255
+
256
+ async def defer_tick(self, loop_id: str, *, worker_id: str, reason: str) -> None:
257
+ row = await self.get(loop_id)
258
+ if row is None or row.worker_id != worker_id:
259
+ return
260
+ now_ms = int(time.time() * 1000)
261
+ await self._doc(loop_id).update(
262
+ {
263
+ "tick_in_flight": 0,
264
+ "worker_id": None,
265
+ "claimed_at_ms": None,
266
+ "next_tick_at_ms": now_ms + row.interval_ms,
267
+ "last_error": reason,
268
+ }
269
+ )
270
+
271
+ async def renew_tick_claim(self, loop_id: str, worker_id: str) -> bool:
272
+ doc_ref = self._doc(loop_id)
273
+ transaction = self._client.transaction()
274
+ now_ms = int(time.time() * 1000)
275
+
276
+ async def _renew_body(
277
+ txn: firestore.AsyncTransaction,
278
+ ref: firestore.AsyncDocumentReference,
279
+ ) -> bool:
280
+ snapshot = await ref.get(transaction=txn)
281
+ if not snapshot.exists:
282
+ return False
283
+ data = snapshot.to_dict() or {}
284
+ if data.get("worker_id") != worker_id or int(data.get("tick_in_flight", 0)) != 1:
285
+ return False
286
+ txn.update(ref, {"claimed_at_ms": now_ms})
287
+ return True
288
+
289
+ renew_txn = cast(
290
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[bool]],
291
+ firestore.async_transactional(_renew_body),
292
+ )
293
+ return bool(await renew_txn(transaction, doc_ref))
294
+
295
+ async def set_status(self, loop_id: str, status: str, *, stop_reason: str | None = None) -> bool:
296
+ if status not in _LOOP_STATUSES:
297
+ raise ValueError(f"invalid loop status: {status}")
298
+ snapshot = await self._doc(loop_id).get()
299
+ if not snapshot.exists:
300
+ return False
301
+ payload: dict[str, object] = {
302
+ "status": status,
303
+ "tick_in_flight": 0,
304
+ "worker_id": None,
305
+ "claimed_at_ms": None,
306
+ }
307
+ if stop_reason is not None:
308
+ payload["stop_reason"] = stop_reason
309
+ await self._doc(loop_id).update(payload)
310
+ return True
311
+
312
+ async def pause(self, loop_id: str) -> bool:
313
+ return await self.set_status(loop_id, "paused")
314
+
315
+ async def resume(self, loop_id: str) -> bool:
316
+ snapshot = await self._doc(loop_id).get()
317
+ if not snapshot.exists:
318
+ return False
319
+ data = snapshot.to_dict() or {}
320
+ if data.get("status") != "paused":
321
+ return False
322
+ now_ms = int(time.time() * 1000)
323
+ await self._doc(loop_id).update(
324
+ {
325
+ "status": "active",
326
+ "stop_reason": None,
327
+ "next_tick_at_ms": now_ms,
328
+ "tick_in_flight": 0,
329
+ "worker_id": None,
330
+ "claimed_at_ms": None,
331
+ }
332
+ )
333
+ return True
334
+
335
+ async def stop(self, loop_id: str, *, stop_reason: str = "manual") -> bool:
336
+ return await self.set_status(loop_id, "completed", stop_reason=stop_reason)
@@ -0,0 +1,156 @@
1
+ """SQLite-backed conversation history store.
2
+
3
+ Durable conversation facts live here as ``Message`` rows with typed
4
+ ``ContentBlock`` JSON (not as ``AgentEvent`` rows). Tool settlement is the
5
+ ``ToolResponse`` block(s) on a user row after a tool batch; the live SSE
6
+ ``ToolCallResult`` event is the progress mirror (see ``events.is_durable_event``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import time
14
+ from typing import cast
15
+
16
+ import aiosqlite
17
+
18
+ from monkeybot.core.types.content_blocks import ContentBlock
19
+ from monkeybot.core.llm.provider import Message, Role
20
+ from monkeybot.core.persistence.thread_summary import ChatThreadSummary, preview_from_content_blob
21
+
22
+ logger = logging.getLogger("monkeybot.core.persistence.history")
23
+
24
+ _VALID_ROLES: tuple[str, ...] = ("user", "assistant", "system")
25
+
26
+
27
+ def _validate_message(message: Message) -> None:
28
+ """Reject persisted shapes that SQLite/provider contracts disallow."""
29
+ role = message.role
30
+ if role not in _VALID_ROLES:
31
+ raise ValueError(f"invalid role: {role!r}")
32
+
33
+
34
+ class SQLiteHistoryStore:
35
+ """Append/read conversation rows keyed by ``thread_id``."""
36
+
37
+ def __init__(self, conn: aiosqlite.Connection) -> None:
38
+ self._conn = conn
39
+
40
+ async def append(self, thread_id: str, message: Message) -> None:
41
+ """Validate ``message``, JSON-encode content blocks, insert one row."""
42
+ _validate_message(message)
43
+ payload = json.dumps(
44
+ [b.to_dict() for b in message.content],
45
+ separators=(",", ":"),
46
+ ensure_ascii=False,
47
+ )
48
+ created_at = int(time.time() * 1000)
49
+ await self._conn.execute(
50
+ """
51
+ INSERT INTO conversation_history(thread_id, role, content, created_at)
52
+ VALUES (?, ?, ?, ?)
53
+ """,
54
+ (thread_id, message.role, payload, created_at),
55
+ )
56
+ await self._conn.commit()
57
+
58
+ async def load(self, thread_id: str, limit: int = 100) -> list[Message]:
59
+ """Return up to ``limit`` messages for ``thread_id``, oldest first."""
60
+ cursor = await self._conn.execute(
61
+ """
62
+ SELECT id, role, content, created_at
63
+ FROM conversation_history
64
+ WHERE thread_id = ?
65
+ ORDER BY created_at DESC, id DESC
66
+ LIMIT ?
67
+ """,
68
+ (thread_id, limit),
69
+ )
70
+ rows = await cursor.fetchall()
71
+ await cursor.close()
72
+ rows_chrono = list(reversed(list(rows)))
73
+ out: list[Message] = []
74
+ for row in rows_chrono:
75
+ row_id = int(row[0])
76
+ role = row[1]
77
+ content_blob = row[2]
78
+ try:
79
+ raw = json.loads(content_blob)
80
+ if not isinstance(raw, list):
81
+ raise ValueError("stored content must be a JSON array")
82
+ blocks = [ContentBlock.from_dict(b) for b in raw]
83
+ except (json.JSONDecodeError, ValueError, TypeError) as exc:
84
+ logger.error(
85
+ "Unparseable history row id=%s thread_id=%s",
86
+ row_id,
87
+ thread_id,
88
+ exc_info=True,
89
+ )
90
+ raise ValueError(f"history row {row_id} unparseable: {exc}") from exc
91
+ if role not in _VALID_ROLES:
92
+ raise ValueError(f"history row {row_id} has invalid role: {role!r}")
93
+ out.append(Message(role=cast(Role, role), content=blocks))
94
+ return out
95
+
96
+ async def clear(self, thread_id: str) -> None:
97
+ """Delete every stored message for ``thread_id``."""
98
+ await self._conn.execute(
99
+ "DELETE FROM conversation_history WHERE thread_id = ?", (thread_id,)
100
+ )
101
+ await self._conn.commit()
102
+
103
+ async def reset(self, thread_id: str, messages: list[Message]) -> None:
104
+ """Replace the thread transcript with ``messages`` (validated like ``append``)."""
105
+ await self._conn.execute(
106
+ "DELETE FROM conversation_history WHERE thread_id = ?", (thread_id,)
107
+ )
108
+ await self._conn.commit()
109
+ for msg in messages:
110
+ await self.append(thread_id, msg)
111
+
112
+ async def list_threads(self, limit: int = 50) -> list[ChatThreadSummary]:
113
+ """Return recent threads ordered by last activity (newest first).
114
+
115
+ The correlated subquery on ``last_content`` is O(threads × messages) per call.
116
+ For production SQLite load, add a composite index on
117
+ ``conversation_history(thread_id, created_at DESC, id DESC)``.
118
+ """
119
+ cap = max(1, min(limit, 200))
120
+ cursor = await self._conn.execute(
121
+ """
122
+ SELECT
123
+ h.thread_id,
124
+ MAX(h.created_at) AS last_message_at,
125
+ COUNT(*) AS message_count,
126
+ (
127
+ SELECT h2.content
128
+ FROM conversation_history h2
129
+ WHERE h2.thread_id = h.thread_id
130
+ ORDER BY h2.created_at DESC, h2.id DESC
131
+ LIMIT 1
132
+ ) AS last_content
133
+ FROM conversation_history h
134
+ GROUP BY h.thread_id
135
+ ORDER BY last_message_at DESC
136
+ LIMIT ?
137
+ """,
138
+ (cap,),
139
+ )
140
+ rows = await cursor.fetchall()
141
+ await cursor.close()
142
+ out: list[ChatThreadSummary] = []
143
+ for row in rows:
144
+ thread_id = str(row[0])
145
+ last_at = int(row[1])
146
+ count = int(row[2])
147
+ preview = preview_from_content_blob(str(row[3] or ""))
148
+ out.append(
149
+ ChatThreadSummary(
150
+ thread_id=thread_id,
151
+ last_message_at=last_at,
152
+ message_count=count,
153
+ preview=preview or "(empty)",
154
+ )
155
+ )
156
+ return out