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,403 @@
1
+ """Poll-and-claim worker pool for queued subagent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+ import time
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from monkeybot.core.config.settings import auto_schema_enabled_from_config
15
+ from monkeybot.core.logging_utils import kv
16
+ from monkeybot.core.persistence.backends import RunStore, StorageBackend, create_storage_backend
17
+ from monkeybot.core.persistence.durable_runs import SubagentRunRow
18
+ from monkeybot.core.runtime.events import Error, TurnComplete, event_to_json
19
+ from monkeybot.core.subagents.subagent_proto import (
20
+ SubagentEnvelope,
21
+ resolve_subagent_script,
22
+ spawn_subagent,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _SHUTDOWN_FAILURE_MESSAGE = "subagent run cancelled during worker shutdown"
28
+ _STALE_CLAIM_WARN_FRACTION = 0.8
29
+
30
+
31
+ def _env_float(name: str, default: float) -> float:
32
+ raw = os.environ.get(name, "").strip()
33
+ if not raw:
34
+ return default
35
+ try:
36
+ return float(raw)
37
+ except ValueError:
38
+ return default
39
+
40
+
41
+ def _env_int(name: str, default: int) -> int:
42
+ raw = os.environ.get(name, "").strip()
43
+ if not raw:
44
+ return default
45
+ try:
46
+ return int(raw)
47
+ except ValueError:
48
+ return default
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class WorkerEnvSettings:
53
+ poll_interval_s: float
54
+ concurrency: int
55
+ stale_claim_ms: int
56
+
57
+
58
+ def worker_env_settings() -> WorkerEnvSettings:
59
+ """Read worker-pool tuning from environment once."""
60
+ return WorkerEnvSettings(
61
+ poll_interval_s=_env_float("MONKEYBOT_WORKER_POLL_INTERVAL_S", 2.0),
62
+ concurrency=_env_int("MONKEYBOT_WORKER_CONCURRENCY", 1),
63
+ stale_claim_ms=_env_int("MONKEYBOT_WORKER_STALE_CLAIM_MS", 600_000),
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class WorkerPoolHandle:
69
+ """In-process worker pool task plus shared shutdown state."""
70
+
71
+ task: asyncio.Task[None]
72
+ backend: StorageBackend
73
+ active_runs: set[str]
74
+ worker_id: str
75
+
76
+
77
+ def resolve_worker_id() -> str:
78
+ explicit = os.environ.get("MONKEYBOT_WORKER_ID", "").strip()
79
+ if explicit:
80
+ return explicit
81
+ return f"worker-{uuid.uuid4().hex[:12]}"
82
+
83
+
84
+ async def _still_owns_run(
85
+ run_store: RunStore,
86
+ run_id: str,
87
+ worker_id: str,
88
+ *,
89
+ claimed_at: int | None = None,
90
+ ) -> bool:
91
+ row = await run_store.get_run(run_id)
92
+ if row is None or row.status != "running" or row.worker_id != worker_id:
93
+ return False
94
+ if claimed_at is not None and row.claimed_at != claimed_at:
95
+ return False
96
+ return True
97
+
98
+
99
+ async def _fail_in_flight_runs(
100
+ run_store: RunStore,
101
+ run_ids: set[str],
102
+ *,
103
+ worker_id: str,
104
+ message: str,
105
+ ) -> None:
106
+ for run_id in list(run_ids):
107
+ try:
108
+ if await _still_owns_run(run_store, run_id, worker_id):
109
+ await run_store.record_failed(run_id, message)
110
+ except Exception:
111
+ logger.exception("failed to mark run_id=%s as failed during shutdown", run_id)
112
+ finally:
113
+ run_ids.discard(run_id)
114
+
115
+
116
+ async def _stale_claim_watchdog(
117
+ run_id: str,
118
+ claimed_at: int,
119
+ stale_claim_ms: int,
120
+ ) -> None:
121
+ """Warn once when a run approaches the stale-claim reclaim window."""
122
+ warn_after_ms = int(stale_claim_ms * _STALE_CLAIM_WARN_FRACTION)
123
+ deadline_s = max(0.0, (claimed_at + warn_after_ms - int(time.time() * 1000)) / 1000.0)
124
+ if deadline_s > 0:
125
+ await asyncio.sleep(deadline_s)
126
+ logger.warning(
127
+ "subagent run_id=%s has been running for >=%.0f%% of MONKEYBOT_WORKER_STALE_CLAIM_MS "
128
+ "(%d ms); if execution exceeds the full window another worker may reclaim and "
129
+ "re-execute the run",
130
+ run_id,
131
+ _STALE_CLAIM_WARN_FRACTION * 100,
132
+ stale_claim_ms,
133
+ )
134
+
135
+
136
+ async def execute_claimed_run(
137
+ run_store: RunStore,
138
+ row: SubagentRunRow,
139
+ *,
140
+ script: Path,
141
+ worker_id: str,
142
+ stale_claim_ms: int = 600_000,
143
+ ) -> None:
144
+ """Run one claimed subagent row to completion and persist the outcome."""
145
+ envelope = SubagentEnvelope.from_json(row.envelope_json)
146
+ logger.debug(
147
+ "worker executing %s",
148
+ kv(run_id=row.run_id, worker_id=worker_id, parent_run_id=envelope.parent_run_id),
149
+ )
150
+ scratch = Path(row.scratch_dir)
151
+ errors: list[str] = []
152
+ last_event_json: str | None = None
153
+ claimed_at = row.claimed_at
154
+ if claimed_at is None:
155
+ fresh = await run_store.get_run(row.run_id)
156
+ if fresh is not None:
157
+ claimed_at = fresh.claimed_at
158
+ watchdog: asyncio.Task[None] | None = None
159
+ if claimed_at is not None and stale_claim_ms > 0:
160
+ watchdog = asyncio.create_task(
161
+ _stale_claim_watchdog(row.run_id, claimed_at, stale_claim_ms),
162
+ name=f"monkeybot-stale-watchdog-{row.run_id}",
163
+ )
164
+
165
+ async def _record_failed_if_owner(message: str) -> bool:
166
+ if not await _still_owns_run(
167
+ run_store,
168
+ row.run_id,
169
+ worker_id,
170
+ claimed_at=claimed_at,
171
+ ):
172
+ logger.warning(
173
+ "skipping failed outcome for run_id=%s worker_id=%s: claim lost",
174
+ row.run_id,
175
+ worker_id,
176
+ )
177
+ return False
178
+ await run_store.record_failed(row.run_id, message)
179
+ return True
180
+
181
+ async def _record_completed_if_owner(result_json: str) -> bool:
182
+ if not await _still_owns_run(
183
+ run_store,
184
+ row.run_id,
185
+ worker_id,
186
+ claimed_at=claimed_at,
187
+ ):
188
+ logger.warning(
189
+ "skipping completed outcome for run_id=%s worker_id=%s: claim lost",
190
+ row.run_id,
191
+ worker_id,
192
+ )
193
+ return False
194
+ await run_store.record_completed(row.run_id, result_json)
195
+ return True
196
+
197
+ extra_env: dict[str, str] | None = None
198
+ if envelope.agent_md:
199
+ extra_env = {"MONKEYBOT_SUBAGENT_AGENT_MD": envelope.agent_md}
200
+
201
+ try:
202
+ async for evt in spawn_subagent(
203
+ str(script),
204
+ envelope,
205
+ scratch_dir=scratch,
206
+ extra_env=extra_env,
207
+ ):
208
+ if isinstance(evt, Error):
209
+ errors.append(evt.error)
210
+ elif isinstance(evt, TurnComplete):
211
+ last_event_json = event_to_json(evt)
212
+ except asyncio.CancelledError:
213
+ await _record_failed_if_owner(_SHUTDOWN_FAILURE_MESSAGE)
214
+ raise
215
+ except Exception as exc:
216
+ logger.exception("worker failed executing run_id=%s", row.run_id)
217
+ errors.append(str(exc))
218
+ finally:
219
+ if watchdog is not None:
220
+ watchdog.cancel()
221
+ with contextlib.suppress(asyncio.CancelledError):
222
+ await watchdog
223
+
224
+ if errors:
225
+ logger.debug(
226
+ "worker finished %s",
227
+ kv(run_id=row.run_id, worker_id=worker_id, outcome="failed"),
228
+ )
229
+ await _record_failed_if_owner("; ".join(errors))
230
+ return
231
+
232
+ if last_event_json is None:
233
+ logger.debug(
234
+ "worker finished %s",
235
+ kv(run_id=row.run_id, worker_id=worker_id, outcome="failed_no_turn_complete"),
236
+ )
237
+ await _record_failed_if_owner("subagent produced no TurnComplete event")
238
+ return
239
+
240
+ logger.debug(
241
+ "worker finished %s",
242
+ kv(run_id=row.run_id, worker_id=worker_id, outcome="completed"),
243
+ )
244
+ await _record_completed_if_owner(last_event_json)
245
+
246
+
247
+ async def run_worker_loop(
248
+ backend: StorageBackend,
249
+ *,
250
+ worker_id: str,
251
+ script: Path,
252
+ poll_interval_s: float = 2.0,
253
+ concurrency: int = 1,
254
+ stale_claim_ms: int = 600_000,
255
+ active_runs: set[str] | None = None,
256
+ ) -> None:
257
+ """Poll ``pending_runs``, claim work atomically, and execute claimed rows."""
258
+ run_store = backend.runs()
259
+ sem = asyncio.Semaphore(max(1, concurrency))
260
+ in_flight = active_runs if active_runs is not None else set()
261
+
262
+ async def _try_claim_and_run(row: SubagentRunRow) -> None:
263
+ if row.status != "pending":
264
+ return
265
+ async with sem:
266
+ if not await run_store.claim(row.run_id, worker_id):
267
+ return
268
+ claimed_row = await run_store.get_run(row.run_id)
269
+ if claimed_row is None:
270
+ return
271
+ logger.debug(
272
+ "worker claimed %s",
273
+ kv(run_id=row.run_id, worker_id=worker_id),
274
+ )
275
+ in_flight.add(row.run_id)
276
+ try:
277
+ await execute_claimed_run(
278
+ run_store,
279
+ claimed_row,
280
+ script=script,
281
+ worker_id=worker_id,
282
+ stale_claim_ms=stale_claim_ms,
283
+ )
284
+ except asyncio.CancelledError:
285
+ await _fail_in_flight_runs(
286
+ run_store,
287
+ {row.run_id},
288
+ worker_id=worker_id,
289
+ message=_SHUTDOWN_FAILURE_MESSAGE,
290
+ )
291
+ raise
292
+ finally:
293
+ in_flight.discard(row.run_id)
294
+
295
+ try:
296
+ while True:
297
+ try:
298
+ reset_count = await run_store.reset_stale_claims(stale_claim_ms)
299
+ if reset_count:
300
+ logger.info(
301
+ "reset stale subagent claims %s",
302
+ kv(count=reset_count, worker_id=worker_id),
303
+ )
304
+ pending = await run_store.pending_runs()
305
+ tasks = [_try_claim_and_run(row) for row in pending if row.status == "pending"]
306
+ if tasks:
307
+ await asyncio.gather(*tasks)
308
+ except asyncio.CancelledError:
309
+ raise
310
+ except Exception:
311
+ logger.exception("worker poll loop error worker_id=%s", worker_id)
312
+ await asyncio.sleep(poll_interval_s)
313
+ except asyncio.CancelledError:
314
+ await _fail_in_flight_runs(
315
+ run_store,
316
+ in_flight,
317
+ worker_id=worker_id,
318
+ message=_SHUTDOWN_FAILURE_MESSAGE,
319
+ )
320
+ raise
321
+
322
+
323
+ def start_worker_pool_background(
324
+ backend: StorageBackend,
325
+ *,
326
+ worker_id: str | None = None,
327
+ script: Path | None = None,
328
+ ) -> WorkerPoolHandle:
329
+ """Start the poll-and-claim loop as a background asyncio task.
330
+
331
+ Development-only entry point: the loop runs on the caller's event loop (e.g. the
332
+ gateway process), so subagent execution competes with whatever else that loop
333
+ serves (SSE streams) and there is no backpressure between them. For production,
334
+ run standalone worker processes via ``python -m monkeybot.subagents.worker``
335
+ (see :func:`run_worker_main`), which scale independently of the gateway.
336
+ """
337
+ wid = worker_id or resolve_worker_id()
338
+ resolved_script = script or resolve_subagent_script()
339
+ settings = worker_env_settings()
340
+ active_runs: set[str] = set()
341
+ logger.info(
342
+ "starting subagent worker pool worker_id=%s script=%s concurrency=%d "
343
+ "stale_claim_ms=%d (MONKEYBOT_WORKER_STALE_CLAIM_MS; runs without heartbeat "
344
+ "may be reclaimed and re-executed after this window)",
345
+ wid,
346
+ resolved_script,
347
+ settings.concurrency,
348
+ settings.stale_claim_ms,
349
+ )
350
+ task = asyncio.create_task(
351
+ run_worker_loop(
352
+ backend,
353
+ worker_id=wid,
354
+ script=resolved_script,
355
+ poll_interval_s=settings.poll_interval_s,
356
+ concurrency=settings.concurrency,
357
+ stale_claim_ms=settings.stale_claim_ms,
358
+ active_runs=active_runs,
359
+ ),
360
+ name=f"monkeybot-worker-{wid}",
361
+ )
362
+ return WorkerPoolHandle(
363
+ task=task,
364
+ backend=backend,
365
+ active_runs=active_runs,
366
+ worker_id=wid,
367
+ )
368
+
369
+
370
+ async def shutdown_worker_pool(handle: WorkerPoolHandle) -> None:
371
+ """Cancel an in-process worker pool and fail any still-running claimed rows."""
372
+ handle.task.cancel()
373
+ with contextlib.suppress(asyncio.CancelledError):
374
+ await handle.task
375
+ await _fail_in_flight_runs(
376
+ handle.backend.runs(),
377
+ handle.active_runs,
378
+ worker_id=handle.worker_id,
379
+ message=_SHUTDOWN_FAILURE_MESSAGE,
380
+ )
381
+
382
+
383
+ async def run_worker_main() -> None:
384
+ """CLI entry: open storage backend and run the worker loop until cancelled."""
385
+ db_url = os.environ.get("DB_URL", "sqlite:///data/monkeybot.db")
386
+ settings = worker_env_settings()
387
+ logger.info(
388
+ "subagent worker starting stale_claim_ms=%d (MONKEYBOT_WORKER_STALE_CLAIM_MS)",
389
+ settings.stale_claim_ms,
390
+ )
391
+ backend = create_storage_backend(db_url)
392
+ await backend.open(run_schema=auto_schema_enabled_from_config())
393
+ try:
394
+ await run_worker_loop(
395
+ backend,
396
+ worker_id=resolve_worker_id(),
397
+ script=resolve_subagent_script(),
398
+ poll_interval_s=settings.poll_interval_s,
399
+ concurrency=settings.concurrency,
400
+ stale_claim_ms=settings.stale_claim_ms,
401
+ )
402
+ finally:
403
+ await backend.close()
@@ -0,0 +1 @@
1
+ """Test doubles for core components."""
@@ -0,0 +1,86 @@
1
+ """Test doubles for :class:`~monkeybot.core.llm.provider.Provider` without network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncIterator, Sequence
7
+
8
+ from monkeybot.core.llm.provider import Message, ProviderEvent
9
+ from monkeybot.core.types.content_blocks import ContentBlock, Text, ToolRequest, ToolResponse
10
+ from monkeybot.core.types.types_tools import ToolDef
11
+
12
+
13
+ def fake_provider_prompt_tokens(messages: Sequence[Message], tools: Sequence[ToolDef]) -> int:
14
+ """Deterministic char÷4 tally for fake providers in unit tests (no vendor APIs)."""
15
+
16
+ def char_blocks(blocks: Sequence[ContentBlock]) -> int:
17
+ total = 0
18
+ for b in blocks:
19
+ if isinstance(b, Text):
20
+ total += len(b.text)
21
+ elif isinstance(b, ToolRequest):
22
+ total += len(b.id) + len(b.name) + len(json.dumps(b.args, sort_keys=True))
23
+ elif isinstance(b, ToolResponse):
24
+ total += len(b.id) + len(b.tool_name) + char_blocks(b.result)
25
+ else:
26
+ total += len(json.dumps(b.to_dict(), sort_keys=True))
27
+ return total
28
+
29
+ n = sum(char_blocks(m.content) for m in messages) // 4
30
+ for t in tools:
31
+ n += (
32
+ len(t.name)
33
+ + len(t.description)
34
+ + len(json.dumps(t.input_schema, sort_keys=True, default=str))
35
+ ) // 4
36
+ return max(0, n)
37
+
38
+
39
+ class ScriptedFakeProvider:
40
+ """Deterministic provider yielding a fixed :class:`~monkeybot.core.llm.provider.ProviderEvent` list."""
41
+
42
+ def __init__(
43
+ self,
44
+ events: list[ProviderEvent],
45
+ *,
46
+ name: str = "fake",
47
+ supports_streaming: bool = True,
48
+ ) -> None:
49
+ self._events = list(events)
50
+ self._name = name
51
+ self._supports_streaming = supports_streaming
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return self._name
56
+
57
+ @property
58
+ def supports_streaming(self) -> bool:
59
+ return self._supports_streaming
60
+
61
+ async def stream(
62
+ self,
63
+ messages: Sequence[Message],
64
+ tools: Sequence[ToolDef],
65
+ *,
66
+ model: str,
67
+ thinking_budget: int | None = None,
68
+ ) -> AsyncIterator[ProviderEvent]:
69
+ del thinking_budget
70
+ # Copy protects against callers mutating the original list mid-stream.
71
+ for ev in list(self._events):
72
+ yield ev
73
+
74
+ async def count_input_tokens(
75
+ self,
76
+ messages: Sequence[Message],
77
+ tools: Sequence[ToolDef],
78
+ *,
79
+ model: str,
80
+ thinking_budget: int | None = None,
81
+ ) -> int:
82
+ del model, thinking_budget
83
+ return fake_provider_prompt_tokens(messages, tools)
84
+
85
+
86
+ __all__ = ["ScriptedFakeProvider", "fake_provider_prompt_tokens"]
@@ -0,0 +1,137 @@
1
+ """Test doubles for :class:`~monkeybot.core.llm.realtime_provider.RealtimeSession`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Sequence
6
+
7
+ from monkeybot.core.llm.realtime_provider import (
8
+ AudioFormat,
9
+ RealtimeEvent,
10
+ RealtimeSession,
11
+ RealtimeSessionConfig,
12
+ )
13
+
14
+
15
+ class ScriptedRealtimeSession:
16
+ """Deterministic in-memory realtime session for unit tests."""
17
+
18
+ def __init__(
19
+ self,
20
+ events: list[RealtimeEvent],
21
+ *,
22
+ input_format: AudioFormat | None = None,
23
+ output_format: AudioFormat | None = None,
24
+ ) -> None:
25
+ self._events = list(events)
26
+ self._input_format = input_format or AudioFormat(
27
+ encoding="pcm_s16le", sample_rate_hz=24000, channels=1, frame_ms=200
28
+ )
29
+ self._output_format = output_format or AudioFormat(
30
+ encoding="pcm_s16le", sample_rate_hz=24000, channels=1, frame_ms=200
31
+ )
32
+ self._audio_sent: list[bytes] = []
33
+ self._text_sent: list[str] = []
34
+ self._context_sent: list[str] = []
35
+ self._tool_results_sent: list[tuple[str, str, dict[str, object], bool]] = []
36
+ self._audio_turn_ends = 0
37
+ self._interrupt_count = 0
38
+ self._closed = False
39
+ self._close_reason: str | None = None
40
+
41
+ @property
42
+ def input_format(self) -> AudioFormat:
43
+ return self._input_format
44
+
45
+ @property
46
+ def output_format(self) -> AudioFormat:
47
+ return self._output_format
48
+
49
+ async def send_audio(self, chunk: bytes) -> None:
50
+ self._audio_sent.append(chunk)
51
+
52
+ async def end_audio_turn(self) -> None:
53
+ self._audio_turn_ends += 1
54
+
55
+ async def send_text(self, text: str) -> None:
56
+ self._text_sent.append(text)
57
+
58
+ async def send_context(self, text: str) -> None:
59
+ self._context_sent.append(text)
60
+
61
+ async def send_tool_results(
62
+ self,
63
+ results: Sequence[tuple[str, str, dict[str, object], bool]],
64
+ ) -> None:
65
+ self._tool_results_sent.extend(results)
66
+
67
+ async def interrupt(self) -> None:
68
+ self._interrupt_count += 1
69
+
70
+ def events(self) -> AsyncIterator[RealtimeEvent]:
71
+ async def _gen() -> AsyncIterator[RealtimeEvent]:
72
+ for ev in list(self._events):
73
+ yield ev
74
+
75
+ return _gen()
76
+
77
+ async def close(self, *, reason: str = "session_end") -> None:
78
+ self._closed = True
79
+ self._close_reason = reason
80
+
81
+ def audio_sent(self) -> list[bytes]:
82
+ return list(self._audio_sent)
83
+
84
+ def audio_turn_ends(self) -> int:
85
+ return self._audio_turn_ends
86
+
87
+ def text_sent(self) -> list[str]:
88
+ return list(self._text_sent)
89
+
90
+ def context_sent(self) -> list[str]:
91
+ return list(self._context_sent)
92
+
93
+ def tool_results_sent(self) -> list[tuple[str, str, dict[str, object], bool]]:
94
+ return list(self._tool_results_sent)
95
+
96
+ def interrupt_count(self) -> int:
97
+ return self._interrupt_count
98
+
99
+ def is_closed(self) -> bool:
100
+ return self._closed
101
+
102
+ def close_reason(self) -> str | None:
103
+ return self._close_reason
104
+
105
+
106
+ class ScriptedRealtimeProvider:
107
+ """Deterministic factory for :class:`ScriptedRealtimeSession`."""
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ name: str = "fake-realtime",
113
+ sessions: list[ScriptedRealtimeSession] | None = None,
114
+ ) -> None:
115
+ self._name = name
116
+ self._sessions = sessions or []
117
+ self._calls: list[tuple[str, RealtimeSessionConfig]] = []
118
+
119
+ @property
120
+ def name(self) -> str:
121
+ return self._name
122
+
123
+ async def connect(
124
+ self,
125
+ *,
126
+ config: RealtimeSessionConfig,
127
+ ) -> RealtimeSession:
128
+ self._calls.append((config.model, config))
129
+ if not self._sessions:
130
+ return ScriptedRealtimeSession([])
131
+ return self._sessions.pop(0)
132
+
133
+ def connect_calls(self) -> list[tuple[str, RealtimeSessionConfig]]:
134
+ return list(self._calls)
135
+
136
+
137
+ __all__ = ["ScriptedRealtimeProvider", "ScriptedRealtimeSession"]
@@ -0,0 +1 @@
1
+ """Tool execution, workspace I/O, sandbox, terminal, and inspection."""