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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|