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,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
|