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,658 @@
1
+ """Firestore storage backend (requires ``pip install 'monkeybot[firestore]'``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import cast
10
+
11
+ from google.cloud import firestore
12
+ from google.cloud.firestore import AsyncClient
13
+ from google.cloud.firestore_v1.base_query import FieldFilter
14
+
15
+ from monkeybot.core.llm.provider import Message, Role
16
+ from monkeybot.core.llm.usage import Usage, UsageSummary
17
+ from monkeybot.core.persistence.backends import FirestoreConfig
18
+ from monkeybot.core.persistence.firestore_scheduled_loops import FirestoreScheduledLoopStore
19
+ from monkeybot.core.persistence.durable_runs import (
20
+ SubagentEnvelope,
21
+ SubagentRunRow,
22
+ )
23
+ from monkeybot.core.persistence.thread_summary import ChatThreadSummary, preview_from_content_blob
24
+ from monkeybot.core.types.content_blocks import ContentBlock
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _VALID_ROLES: tuple[str, ...] = ("user", "assistant", "system")
29
+
30
+
31
+ def _collection_name(prefix: str, base: str) -> str:
32
+ if not prefix:
33
+ return base
34
+ return f"{prefix}_{base}"
35
+
36
+
37
+ def _int_value(raw: object, default: int = 0) -> int:
38
+ if isinstance(raw, int):
39
+ return raw
40
+ if isinstance(raw, float):
41
+ return int(raw)
42
+ if isinstance(raw, str):
43
+ try:
44
+ return int(raw)
45
+ except ValueError:
46
+ return default
47
+ return default
48
+
49
+
50
+ def _float_value(raw: object, default: float = 0.0) -> float:
51
+ if isinstance(raw, (int, float)):
52
+ return float(raw)
53
+ if isinstance(raw, str):
54
+ try:
55
+ return float(raw)
56
+ except ValueError:
57
+ return default
58
+ return default
59
+
60
+
61
+ def _field_int(row: dict[str, object], key: str, default: int = 0) -> int:
62
+ return _int_value(row.get(key, default), default)
63
+
64
+
65
+ def _field_float(row: dict[str, object], key: str, default: float = 0.0) -> float:
66
+ return _float_value(row.get(key, default), default)
67
+
68
+
69
+ class FirestoreHistoryStore:
70
+ """Firestore-backed conversation history store."""
71
+
72
+ def __init__(self, client: AsyncClient, prefix: str) -> None:
73
+ self._client = client
74
+ self._collection = _collection_name(prefix, "conversation_history")
75
+ self._threads_collection = _collection_name(prefix, "threads")
76
+
77
+ async def _upsert_thread_summary(
78
+ self,
79
+ thread_id: str,
80
+ *,
81
+ created_at: int,
82
+ content: str,
83
+ ) -> None:
84
+ """Maintain one summary doc per thread for indexed ``list_threads`` reads."""
85
+ thread_ref = self._client.collection(self._threads_collection).document(thread_id)
86
+ await thread_ref.set(
87
+ {
88
+ "thread_id": thread_id,
89
+ "last_message_at": created_at,
90
+ "message_count": firestore.Increment(1),
91
+ "last_content": content,
92
+ },
93
+ merge=True,
94
+ )
95
+
96
+ async def _delete_thread_summary(self, thread_id: str) -> None:
97
+ await self._client.collection(self._threads_collection).document(thread_id).delete()
98
+
99
+ async def append(self, thread_id: str, message: Message) -> None:
100
+ role = message.role
101
+ if role not in _VALID_ROLES:
102
+ raise ValueError(f"invalid role: {role!r}")
103
+ payload = json.dumps(
104
+ [b.to_dict() for b in message.content],
105
+ separators=(",", ":"),
106
+ ensure_ascii=False,
107
+ )
108
+ created_at = int(time.time() * 1000)
109
+ await self._client.collection(self._collection).add(
110
+ {
111
+ "thread_id": thread_id,
112
+ "role": role,
113
+ "content": payload,
114
+ "created_at": created_at,
115
+ }
116
+ )
117
+ await self._upsert_thread_summary(thread_id, created_at=created_at, content=payload)
118
+
119
+ async def load(self, thread_id: str, limit: int = 100) -> list[Message]:
120
+ query = (
121
+ self._client.collection(self._collection)
122
+ .where(filter=FieldFilter("thread_id", "==", thread_id))
123
+ .order_by("created_at", direction=firestore.Query.DESCENDING)
124
+ .limit(limit)
125
+ )
126
+ rows = [doc async for doc in query.stream()]
127
+ rows_chrono = list(reversed(rows))
128
+ out: list[Message] = []
129
+ for doc in rows_chrono:
130
+ data = doc.to_dict() or {}
131
+ row_id = doc.id
132
+ role = str(data.get("role", ""))
133
+ content_blob = str(data.get("content", ""))
134
+ try:
135
+ raw = json.loads(content_blob)
136
+ if not isinstance(raw, list):
137
+ raise ValueError("stored content must be a JSON array")
138
+ blocks = [ContentBlock.from_dict(b) for b in raw]
139
+ except (json.JSONDecodeError, ValueError, TypeError) as exc:
140
+ logger.error(
141
+ "Skipping unparseable history row id=%s thread_id=%s: %s",
142
+ row_id,
143
+ thread_id,
144
+ exc,
145
+ exc_info=True,
146
+ )
147
+ continue
148
+ if role not in _VALID_ROLES:
149
+ logger.error(
150
+ "Skipping history row id=%s thread_id=%s with invalid role=%r",
151
+ row_id,
152
+ thread_id,
153
+ role,
154
+ )
155
+ continue
156
+ out.append(Message(role=cast(Role, role), content=blocks))
157
+ return out
158
+
159
+ async def reset(self, thread_id: str, messages: list[Message]) -> None:
160
+ query = self._client.collection(self._collection).where(
161
+ filter=FieldFilter("thread_id", "==", thread_id)
162
+ )
163
+ batch = self._client.batch()
164
+ count = 0
165
+ async for doc in query.stream():
166
+ batch.delete(doc.reference)
167
+ count += 1
168
+ if count >= 400:
169
+ await batch.commit()
170
+ batch = self._client.batch()
171
+ count = 0
172
+ if count:
173
+ await batch.commit()
174
+ await self._delete_thread_summary(thread_id)
175
+ for msg in messages:
176
+ await self.append(thread_id, msg)
177
+
178
+ async def list_threads(self, limit: int = 50) -> list[ChatThreadSummary]:
179
+ cap = max(1, min(limit, 200))
180
+ query = (
181
+ self._client.collection(self._threads_collection)
182
+ .order_by("last_message_at", direction=firestore.Query.DESCENDING)
183
+ .limit(cap)
184
+ )
185
+ out: list[ChatThreadSummary] = []
186
+ async for doc in query.stream():
187
+ data = doc.to_dict() or {}
188
+ thread_id = str(data.get("thread_id") or doc.id)
189
+ preview = preview_from_content_blob(str(data.get("last_content", "")))
190
+ out.append(
191
+ ChatThreadSummary(
192
+ thread_id=thread_id,
193
+ last_message_at=_field_int(data, "last_message_at"),
194
+ message_count=_field_int(data, "message_count"),
195
+ preview=preview or "(empty)",
196
+ )
197
+ )
198
+ return out
199
+
200
+
201
+ class FirestoreUsageStore:
202
+ """Firestore-backed usage store."""
203
+
204
+ def __init__(self, client: AsyncClient, prefix: str) -> None:
205
+ self._client = client
206
+ self._collection = _collection_name(prefix, "turn_usage")
207
+
208
+ async def record(
209
+ self,
210
+ thread_id: str,
211
+ model: str,
212
+ usage: Usage,
213
+ run_id: str | None = None,
214
+ *,
215
+ context_json: str | None = None,
216
+ ) -> None:
217
+ now_ms = int(time.time() * 1000)
218
+ await self._client.collection(self._collection).add(
219
+ {
220
+ "thread_id": thread_id,
221
+ "run_id": run_id,
222
+ "model": model,
223
+ "input_tokens": usage.input_tokens,
224
+ "output_tokens": usage.output_tokens,
225
+ "cached_tokens": usage.cached_tokens,
226
+ "cost_usd": usage.cost_usd,
227
+ "duration_ms": usage.duration_ms,
228
+ "created_at": now_ms,
229
+ "context_json": context_json,
230
+ "estimated_prompt_tokens": usage.estimated_prompt_tokens,
231
+ "cache_read_tokens": usage.cache_read_tokens,
232
+ "cache_creation_tokens": usage.cache_creation_tokens,
233
+ }
234
+ )
235
+
236
+ async def _fetch_usage_rows(
237
+ self,
238
+ thread_id: str | None,
239
+ since_ms: int | None,
240
+ ) -> list[dict[str, object]]:
241
+ collection = self._client.collection(self._collection)
242
+ if thread_id is None:
243
+ doc_stream = collection.stream()
244
+ else:
245
+ doc_stream = collection.where(
246
+ filter=FieldFilter("thread_id", "==", thread_id)
247
+ ).stream()
248
+ rows: list[dict[str, object]] = []
249
+ async for doc in doc_stream:
250
+ data = doc.to_dict() or {}
251
+ created_at = _field_int(data, "created_at")
252
+ if since_ms is not None and created_at < since_ms:
253
+ continue
254
+ rows.append(data)
255
+ return rows
256
+
257
+ async def summary(
258
+ self,
259
+ thread_id: str | None = None,
260
+ since_ms: int | None = None,
261
+ ) -> UsageSummary:
262
+ """Aggregate usage rows.
263
+
264
+ When ``thread_id`` is ``None``, streams the entire ``turn_usage`` collection
265
+ (small-scale only; not suitable for large production datasets).
266
+ """
267
+ rows = await self._fetch_usage_rows(thread_id, since_ms)
268
+ if not rows:
269
+ return UsageSummary(
270
+ turns=0,
271
+ input_tokens=0,
272
+ output_tokens=0,
273
+ cached_tokens=0,
274
+ cost_usd=0.0,
275
+ period_start_ms=None,
276
+ period_end_ms=None,
277
+ last_prompt_tokens=0,
278
+ last_estimated_prompt_tokens=0,
279
+ cache_read_tokens=0,
280
+ cache_creation_tokens=0,
281
+ )
282
+
283
+ input_tokens = sum(_field_int(r, "input_tokens") for r in rows)
284
+ output_tokens = sum(_field_int(r, "output_tokens") for r in rows)
285
+ cached_tokens = sum(_field_int(r, "cached_tokens") for r in rows)
286
+ cost_usd = sum(_field_float(r, "cost_usd") for r in rows)
287
+ cache_read_tokens = sum(_field_int(r, "cache_read_tokens") for r in rows)
288
+ cache_creation_tokens = sum(_field_int(r, "cache_creation_tokens") for r in rows)
289
+ created_times = [_field_int(r, "created_at") for r in rows]
290
+
291
+ last_pt = 0
292
+ last_est = 0
293
+ if thread_id is not None:
294
+ latest = max(rows, key=lambda r: _field_int(r, "created_at"))
295
+ last_pt = _field_int(latest, "input_tokens")
296
+ last_est = _field_int(latest, "estimated_prompt_tokens")
297
+
298
+ return UsageSummary(
299
+ turns=len(rows),
300
+ input_tokens=input_tokens,
301
+ output_tokens=output_tokens,
302
+ cached_tokens=cached_tokens,
303
+ cost_usd=cost_usd,
304
+ period_start_ms=min(created_times),
305
+ period_end_ms=max(created_times),
306
+ last_prompt_tokens=last_pt,
307
+ last_estimated_prompt_tokens=last_est,
308
+ cache_read_tokens=cache_read_tokens,
309
+ cache_creation_tokens=cache_creation_tokens,
310
+ )
311
+
312
+
313
+ def _doc_to_run_row(doc_id: str, data: dict[str, object]) -> SubagentRunRow:
314
+ parent = data.get("parent_run_id")
315
+ result = data.get("result_json")
316
+ error = data.get("error_json")
317
+ finished: int | None = data.get("finished_at") # type: ignore[assignment]
318
+ worker = data.get("worker_id")
319
+ claimed: int | None = data.get("claimed_at") # type: ignore[assignment]
320
+ return SubagentRunRow(
321
+ run_id=doc_id,
322
+ parent_run_id=str(parent) if parent is not None else None,
323
+ script=str(data.get("script", "")),
324
+ envelope_json=str(data.get("envelope_json", "")),
325
+ status=str(data.get("status", "")),
326
+ result_json=str(result) if result is not None else None,
327
+ error_json=str(error) if error is not None else None,
328
+ started_at=_field_int(data, "started_at"),
329
+ finished_at=int(finished) if finished is not None else None,
330
+ scratch_dir=str(data.get("scratch_dir", "")),
331
+ worker_id=str(worker) if worker is not None else None,
332
+ claimed_at=int(claimed) if claimed is not None else None,
333
+ )
334
+
335
+
336
+ class FirestoreRunStore:
337
+ """Firestore-backed subagent run lifecycle store."""
338
+
339
+ def __init__(self, client: AsyncClient, prefix: str) -> None:
340
+ self._client = client
341
+ self._collection = _collection_name(prefix, "subagent_runs")
342
+
343
+ def _doc(self, run_id: str) -> firestore.AsyncDocumentReference:
344
+ return self._client.collection(self._collection).document(run_id)
345
+
346
+ async def record_pending(
347
+ self,
348
+ run_id: str,
349
+ parent_run_id: str | None,
350
+ script: str,
351
+ envelope: SubagentEnvelope,
352
+ scratch_dir: object,
353
+ ) -> None:
354
+ await self._record_run("pending", run_id, parent_run_id, script, envelope, scratch_dir)
355
+
356
+ async def record_started(
357
+ self,
358
+ run_id: str,
359
+ parent_run_id: str | None,
360
+ script: str,
361
+ envelope: SubagentEnvelope,
362
+ scratch_dir: object,
363
+ ) -> None:
364
+ await self._record_run("running", run_id, parent_run_id, script, envelope, scratch_dir)
365
+
366
+ async def _record_run(
367
+ self,
368
+ status: str,
369
+ run_id: str,
370
+ parent_run_id: str | None,
371
+ script: str,
372
+ envelope: SubagentEnvelope,
373
+ scratch_dir: object,
374
+ ) -> None:
375
+ now_ms = int(time.time() * 1000)
376
+ await self._doc(run_id).set(
377
+ {
378
+ "parent_run_id": parent_run_id,
379
+ "script": script,
380
+ "envelope_json": envelope.to_json(),
381
+ "status": status,
382
+ "result_json": None,
383
+ "error_json": None,
384
+ "started_at": now_ms,
385
+ "finished_at": None,
386
+ "scratch_dir": str(scratch_dir),
387
+ "worker_id": None,
388
+ "claimed_at": None,
389
+ }
390
+ )
391
+
392
+ async def claim(self, run_id: str, worker_id: str) -> bool:
393
+ doc_ref = self._doc(run_id)
394
+ transaction = self._client.transaction()
395
+
396
+ async def _claim_body(
397
+ txn: firestore.AsyncTransaction,
398
+ ref: firestore.AsyncDocumentReference,
399
+ ) -> bool:
400
+ snapshot = await ref.get(transaction=txn)
401
+ if not snapshot.exists:
402
+ return False
403
+ data = snapshot.to_dict() or {}
404
+ if data.get("status") != "pending":
405
+ return False
406
+ now_ms = int(time.time() * 1000)
407
+ txn.update(
408
+ ref,
409
+ {
410
+ "status": "running",
411
+ "worker_id": worker_id,
412
+ "claimed_at": now_ms,
413
+ },
414
+ )
415
+ return True
416
+
417
+ claim_txn = cast(
418
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[bool]],
419
+ firestore.async_transactional(_claim_body),
420
+ )
421
+ return bool(await claim_txn(transaction, doc_ref))
422
+
423
+ async def reset_stale_claims(self, stale_after_ms: int) -> int:
424
+ cutoff = int(time.time() * 1000) - stale_after_ms
425
+ query = (
426
+ self._client.collection(self._collection)
427
+ .where(filter=FieldFilter("status", "==", "running"))
428
+ .where(filter=FieldFilter("claimed_at", "<", cutoff))
429
+ )
430
+ reset = 0
431
+ batch = self._client.batch()
432
+ count = 0
433
+ async for doc in query.stream():
434
+ data = doc.to_dict() or {}
435
+ if data.get("claimed_at") is None:
436
+ continue
437
+ batch.update(
438
+ doc.reference,
439
+ {"status": "pending", "worker_id": None, "claimed_at": None},
440
+ )
441
+ reset += 1
442
+ count += 1
443
+ if count >= 400:
444
+ await batch.commit()
445
+ batch = self._client.batch()
446
+ count = 0
447
+ if count:
448
+ await batch.commit()
449
+ return reset
450
+
451
+ async def record_completed(self, run_id: str, result_json: str) -> None:
452
+ now_ms = int(time.time() * 1000)
453
+ await self._doc(run_id).update(
454
+ {
455
+ "status": "completed",
456
+ "result_json": result_json,
457
+ "finished_at": now_ms,
458
+ "error_json": None,
459
+ }
460
+ )
461
+
462
+ async def record_failed(self, run_id: str, error: str) -> None:
463
+ now_ms = int(time.time() * 1000)
464
+ err_payload = json.dumps({"message": error})
465
+ await self._doc(run_id).update(
466
+ {
467
+ "status": "failed",
468
+ "error_json": err_payload,
469
+ "finished_at": now_ms,
470
+ }
471
+ )
472
+
473
+ async def pending_runs(self) -> list[SubagentRunRow]:
474
+ rows: list[SubagentRunRow] = []
475
+ query = (
476
+ self._client.collection(self._collection)
477
+ .where(filter=FieldFilter("status", "==", "pending"))
478
+ .order_by("started_at")
479
+ )
480
+ async for doc in query.stream():
481
+ rows.append(_doc_to_run_row(doc.id, doc.to_dict() or {}))
482
+ return rows
483
+
484
+ async def get_run(self, run_id: str) -> SubagentRunRow | None:
485
+ snapshot = await self._doc(run_id).get()
486
+ if not snapshot.exists:
487
+ return None
488
+ return _doc_to_run_row(snapshot.id, snapshot.to_dict() or {})
489
+
490
+
491
+ def _turn_lock_collection(prefix: str) -> str:
492
+ if not prefix:
493
+ return "session_turn_locks"
494
+ return f"{prefix}_session_turn_locks"
495
+
496
+
497
+ class FirestoreSessionTurnLockStore:
498
+ """Firestore-backed exclusive turn lock per session."""
499
+
500
+ def __init__(self, client: AsyncClient, prefix: str) -> None:
501
+ self._client = client
502
+ self._collection = _turn_lock_collection(prefix)
503
+
504
+ def _doc(self, session_id: str) -> firestore.AsyncDocumentReference:
505
+ return self._client.collection(self._collection).document(session_id)
506
+
507
+ async def release_stale_claims(self, stale_after_ms: int) -> int:
508
+ cutoff = int(time.time() * 1000) - stale_after_ms
509
+ query = (
510
+ self._client.collection(self._collection)
511
+ .where(filter=FieldFilter("claimed_at_ms", "<", cutoff))
512
+ )
513
+ reset = 0
514
+ batch = self._client.batch()
515
+ count = 0
516
+ async for doc in query.stream():
517
+ data = doc.to_dict() or {}
518
+ if data.get("request_id") is None:
519
+ continue
520
+ batch.update(doc.reference, {"request_id": None, "claimed_at_ms": None})
521
+ reset += 1
522
+ count += 1
523
+ if count >= 400:
524
+ await batch.commit()
525
+ batch = self._client.batch()
526
+ count = 0
527
+ if count:
528
+ await batch.commit()
529
+ return reset
530
+
531
+ async def try_acquire(self, session_id: str, request_id: str) -> bool:
532
+ from monkeybot.core.persistence.session_turn_locks import session_turn_stale_ms
533
+
534
+ stale_ms = session_turn_stale_ms()
535
+ doc_ref = self._doc(session_id)
536
+ transaction = self._client.transaction()
537
+ now_ms = int(time.time() * 1000)
538
+ cutoff = now_ms - stale_ms
539
+
540
+ async def _claim_body(
541
+ txn: firestore.AsyncTransaction,
542
+ ref: firestore.AsyncDocumentReference,
543
+ ) -> bool:
544
+ snapshot = await ref.get(transaction=txn)
545
+ if not snapshot.exists:
546
+ txn.set(ref, {"request_id": request_id, "claimed_at_ms": now_ms})
547
+ return True
548
+ data = snapshot.to_dict() or {}
549
+ current = data.get("request_id")
550
+ claimed_at = data.get("claimed_at_ms")
551
+ if current is None:
552
+ txn.update(ref, {"request_id": request_id, "claimed_at_ms": now_ms})
553
+ return True
554
+ if isinstance(claimed_at, int) and claimed_at < cutoff:
555
+ txn.update(ref, {"request_id": request_id, "claimed_at_ms": now_ms})
556
+ return True
557
+ return False
558
+
559
+ claim_txn = cast(
560
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[bool]],
561
+ firestore.async_transactional(_claim_body),
562
+ )
563
+ return bool(await claim_txn(transaction, doc_ref))
564
+
565
+ async def release(self, session_id: str, request_id: str) -> None:
566
+ doc_ref = self._doc(session_id)
567
+ transaction = self._client.transaction()
568
+
569
+ async def _release_body(
570
+ txn: firestore.AsyncTransaction,
571
+ ref: firestore.AsyncDocumentReference,
572
+ ) -> None:
573
+ snapshot = await ref.get(transaction=txn)
574
+ if not snapshot.exists:
575
+ return
576
+ data = snapshot.to_dict() or {}
577
+ if data.get("request_id") != request_id:
578
+ return
579
+ txn.update(ref, {"request_id": None, "claimed_at_ms": None})
580
+
581
+ release_txn = cast(
582
+ Callable[[firestore.AsyncTransaction, firestore.AsyncDocumentReference], Awaitable[None]],
583
+ firestore.async_transactional(_release_body),
584
+ )
585
+ await release_txn(transaction, doc_ref)
586
+
587
+ async def is_busy(self, session_id: str) -> bool:
588
+ snapshot = await self._doc(session_id).get()
589
+ if not snapshot.exists:
590
+ return False
591
+ data = snapshot.to_dict() or {}
592
+ return data.get("request_id") is not None
593
+
594
+
595
+ class FirestoreStorageBackend:
596
+ """Firestore-backed storage backend using ``google.cloud.firestore.AsyncClient``."""
597
+
598
+ def __init__(self, config: FirestoreConfig) -> None:
599
+ self._config = config
600
+ self._client: AsyncClient | None = None
601
+ self._history_store: FirestoreHistoryStore | None = None
602
+ self._usage_store: FirestoreUsageStore | None = None
603
+ self._runs_store: FirestoreRunStore | None = None
604
+ self._scheduled_loops_store: FirestoreScheduledLoopStore | None = None
605
+ self._session_turn_lock_store: FirestoreSessionTurnLockStore | None = None
606
+
607
+ async def open(self, *, run_schema: bool = True) -> None:
608
+ """Open the Firestore client.
609
+
610
+ ``run_schema`` is accepted for API parity with other backends; Firestore
611
+ is schemaless and performs no DDL.
612
+ """
613
+ self._client = AsyncClient(
614
+ project=self._config.project,
615
+ database=self._config.database,
616
+ )
617
+ prefix = self._config.prefix
618
+ self._history_store = FirestoreHistoryStore(self._client, prefix)
619
+ self._usage_store = FirestoreUsageStore(self._client, prefix)
620
+ self._runs_store = FirestoreRunStore(self._client, prefix)
621
+ self._scheduled_loops_store = FirestoreScheduledLoopStore(self._client, prefix)
622
+ self._session_turn_lock_store = FirestoreSessionTurnLockStore(self._client, prefix)
623
+
624
+ async def close(self) -> None:
625
+ if self._client is not None:
626
+ close_fn = cast(Callable[[], None], self._client.close)
627
+ close_fn()
628
+ self._client = None
629
+ self._history_store = None
630
+ self._usage_store = None
631
+ self._runs_store = None
632
+ self._scheduled_loops_store = None
633
+ self._session_turn_lock_store = None
634
+
635
+ def history(self) -> FirestoreHistoryStore:
636
+ if self._history_store is None:
637
+ raise RuntimeError("FirestoreStorageBackend.open() has not been called")
638
+ return self._history_store
639
+
640
+ def usage(self) -> FirestoreUsageStore:
641
+ if self._usage_store is None:
642
+ raise RuntimeError("FirestoreStorageBackend.open() has not been called")
643
+ return self._usage_store
644
+
645
+ def runs(self) -> FirestoreRunStore:
646
+ if self._runs_store is None:
647
+ raise RuntimeError("FirestoreStorageBackend.open() has not been called")
648
+ return self._runs_store
649
+
650
+ def scheduled_loops(self) -> FirestoreScheduledLoopStore:
651
+ if self._scheduled_loops_store is None:
652
+ raise RuntimeError("FirestoreStorageBackend.open() has not been called")
653
+ return self._scheduled_loops_store
654
+
655
+ def session_turns(self) -> FirestoreSessionTurnLockStore:
656
+ if self._session_turn_lock_store is None:
657
+ raise RuntimeError("FirestoreStorageBackend.open() has not been called")
658
+ return self._session_turn_lock_store