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,333 @@
1
+ """Poll-and-fire engine for prompt-first scheduled agent loops."""
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 typing import Protocol
13
+
14
+ from monkeybot.core.persistence.backends import ScheduledLoopStore
15
+ from monkeybot.core.persistence.scheduled_loops import ScheduledLoopRow, format_tick_prompt
16
+ from monkeybot.core.types.content_blocks import ContentBlock, Text
17
+ from monkeybot.scheduler.tick_result import TickInvokeResult, TickInvokeStatus
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _DEFAULT_HEARTBEAT_INTERVAL_S = 30.0
22
+
23
+
24
+ class SessionBusyChecker(Protocol):
25
+ def is_busy(self, session_id: str) -> bool: ...
26
+
27
+
28
+ class AsyncSessionBusyChecker(Protocol):
29
+ async def is_busy_async(self, session_id: str) -> bool: ...
30
+
31
+
32
+ class SessionEnsurer(Protocol):
33
+ async def ensure_session(self, session_id: str) -> None: ...
34
+
35
+
36
+ class TickInvoker(Protocol):
37
+ async def invoke_tick(
38
+ self,
39
+ session_id: str,
40
+ request_id: str,
41
+ user_content: list[ContentBlock],
42
+ ) -> TickInvokeResult:
43
+ """Run one scheduled tick."""
44
+
45
+
46
+ def _env_float(name: str, default: float) -> float:
47
+ raw = os.environ.get(name, "").strip()
48
+ if not raw:
49
+ return default
50
+ try:
51
+ return float(raw)
52
+ except ValueError:
53
+ return default
54
+
55
+
56
+ def _env_int(name: str, default: int) -> int:
57
+ raw = os.environ.get(name, "").strip()
58
+ if not raw:
59
+ return default
60
+ try:
61
+ return int(raw)
62
+ except ValueError:
63
+ return default
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class SchedulerSettings:
68
+ poll_interval_s: float
69
+ stale_claim_ms: int
70
+ max_concurrency: int
71
+ claim_heartbeat_interval_s: float
72
+
73
+
74
+ def scheduler_settings() -> SchedulerSettings:
75
+ stale_claim_ms = _env_int("MONKEYBOT_SCHEDULER_STALE_CLAIM_MS", 600_000)
76
+ heartbeat_raw = os.environ.get("MONKEYBOT_SCHEDULER_CLAIM_HEARTBEAT_S", "").strip()
77
+ if heartbeat_raw:
78
+ heartbeat_s = _env_float("MONKEYBOT_SCHEDULER_CLAIM_HEARTBEAT_S", _DEFAULT_HEARTBEAT_INTERVAL_S)
79
+ else:
80
+ heartbeat_s = min(_DEFAULT_HEARTBEAT_INTERVAL_S, max(5.0, stale_claim_ms / 4000.0))
81
+ return SchedulerSettings(
82
+ poll_interval_s=_env_float("MONKEYBOT_SCHEDULER_POLL_INTERVAL_S", 5.0),
83
+ stale_claim_ms=stale_claim_ms,
84
+ max_concurrency=max(1, _env_int("MONKEYBOT_SCHEDULER_CONCURRENCY", 1)),
85
+ claim_heartbeat_interval_s=heartbeat_s,
86
+ )
87
+
88
+
89
+ def scheduler_enabled_from_env() -> bool:
90
+ raw = os.environ.get("MONKEYBOT_SCHEDULER_ENABLED", "").strip().lower()
91
+ return raw in {"1", "true", "yes", "on"}
92
+
93
+
94
+ def resolve_scheduler_worker_id() -> str:
95
+ explicit = os.environ.get("MONKEYBOT_SCHEDULER_WORKER_ID", "").strip()
96
+ if explicit:
97
+ return explicit
98
+ return f"scheduler-{uuid.uuid4().hex[:12]}"
99
+
100
+
101
+ async def _claim_heartbeat(
102
+ *,
103
+ store: ScheduledLoopStore,
104
+ loop_id: str,
105
+ worker_id: str,
106
+ interval_s: float,
107
+ stop: asyncio.Event,
108
+ ) -> None:
109
+ while not stop.is_set():
110
+ try:
111
+ await asyncio.wait_for(stop.wait(), timeout=interval_s)
112
+ return
113
+ except TimeoutError:
114
+ renewed = await store.renew_tick_claim(loop_id, worker_id)
115
+ if not renewed:
116
+ return
117
+
118
+
119
+ async def run_scheduler_loop(
120
+ *,
121
+ store: ScheduledLoopStore,
122
+ invoker: TickInvoker,
123
+ session_busy: SessionBusyChecker,
124
+ ensure_session: SessionEnsurer,
125
+ worker_id: str,
126
+ poll_interval_s: float = 5.0,
127
+ stale_claim_ms: int = 600_000,
128
+ max_concurrency: int = 1,
129
+ claim_heartbeat_interval_s: float = _DEFAULT_HEARTBEAT_INTERVAL_S,
130
+ shutdown: asyncio.Event | None = None,
131
+ ) -> None:
132
+ """Poll due loops and invoke agent ticks until ``shutdown`` is set."""
133
+ semaphore = asyncio.Semaphore(max(1, max_concurrency))
134
+ tick_tasks: set[asyncio.Task[None]] = set()
135
+
136
+ def _prune_done() -> None:
137
+ tick_tasks.difference_update({task for task in tick_tasks if task.done()})
138
+
139
+ while shutdown is None or not shutdown.is_set():
140
+ try:
141
+ released = await store.release_stale_claims(stale_claim_ms)
142
+ if released:
143
+ logger.warning("scheduler released %d stale tick claims", released)
144
+ now_ms = int(time.time() * 1000)
145
+ due = await store.list_due(now_ms)
146
+ claimed_rows: list[ScheduledLoopRow] = []
147
+ for candidate in due:
148
+ if shutdown is not None and shutdown.is_set():
149
+ break
150
+ claimed = await store.claim_tick(candidate.loop_id, worker_id)
151
+ if claimed is not None:
152
+ claimed_rows.append(claimed)
153
+
154
+ async def _run_claimed(row: ScheduledLoopRow) -> None:
155
+ async with semaphore:
156
+ await _execute_claimed_tick(
157
+ store=store,
158
+ invoker=invoker,
159
+ session_busy=session_busy,
160
+ ensure_session=ensure_session,
161
+ row=row,
162
+ worker_id=worker_id,
163
+ claim_heartbeat_interval_s=claim_heartbeat_interval_s,
164
+ )
165
+
166
+ for row in claimed_rows:
167
+ if shutdown is not None and shutdown.is_set():
168
+ break
169
+ task = asyncio.create_task(
170
+ _run_claimed(row),
171
+ name=f"scheduler-tick-{row.loop_id}",
172
+ )
173
+ tick_tasks.add(task)
174
+ task.add_done_callback(tick_tasks.discard)
175
+ _prune_done()
176
+ except asyncio.CancelledError:
177
+ raise
178
+ except Exception:
179
+ logger.exception("scheduler poll loop error worker_id=%s", worker_id)
180
+ if shutdown is not None and shutdown.is_set():
181
+ break
182
+ await asyncio.sleep(poll_interval_s)
183
+
184
+ if tick_tasks:
185
+ await asyncio.gather(*tick_tasks, return_exceptions=True)
186
+
187
+
188
+ async def _session_is_busy(session_busy: SessionBusyChecker, session_id: str) -> bool:
189
+ async_check = getattr(session_busy, "is_busy_async", None)
190
+ if async_check is not None:
191
+ return bool(await async_check(session_id))
192
+ return session_busy.is_busy(session_id)
193
+
194
+
195
+ async def _execute_claimed_tick(
196
+ *,
197
+ store: ScheduledLoopStore,
198
+ invoker: TickInvoker,
199
+ session_busy: SessionBusyChecker,
200
+ ensure_session: SessionEnsurer,
201
+ row: ScheduledLoopRow,
202
+ worker_id: str,
203
+ claim_heartbeat_interval_s: float,
204
+ ) -> None:
205
+ if row.skip_if_busy and await _session_is_busy(session_busy, row.session_id):
206
+ await store.defer_tick(
207
+ row.loop_id,
208
+ worker_id=worker_id,
209
+ reason="session busy; deferred",
210
+ )
211
+ logger.info(
212
+ "scheduler deferred tick loop_id=%s session_id=%s (busy)",
213
+ row.loop_id,
214
+ row.session_id,
215
+ )
216
+ return
217
+
218
+ heartbeat_stop = asyncio.Event()
219
+ heartbeat_task = asyncio.create_task(
220
+ _claim_heartbeat(
221
+ store=store,
222
+ loop_id=row.loop_id,
223
+ worker_id=worker_id,
224
+ interval_s=claim_heartbeat_interval_s,
225
+ stop=heartbeat_stop,
226
+ ),
227
+ name=f"scheduler-heartbeat-{row.loop_id}",
228
+ )
229
+ try:
230
+ await ensure_session.ensure_session(row.session_id)
231
+ request_id = f"loop-{row.loop_id}-{row.tick_index + 1}-{uuid.uuid4().hex[:8]}"
232
+ tick_prompt = format_tick_prompt(row)
233
+ result = await invoker.invoke_tick(
234
+ row.session_id,
235
+ request_id,
236
+ [Text(text=tick_prompt)],
237
+ )
238
+ if result.status is TickInvokeStatus.SESSION_BUSY:
239
+ await store.defer_tick(
240
+ row.loop_id,
241
+ worker_id=worker_id,
242
+ reason="session busy; deferred",
243
+ )
244
+ logger.info(
245
+ "scheduler deferred tick loop_id=%s session_id=%s (remote busy)",
246
+ row.loop_id,
247
+ row.session_id,
248
+ )
249
+ return
250
+
251
+ error = result.error if result.status is TickInvokeStatus.ERROR else None
252
+ await store.complete_tick(row.loop_id, worker_id=worker_id, error=error)
253
+ if error:
254
+ logger.warning(
255
+ "scheduler tick failed loop_id=%s session_id=%s error=%s",
256
+ row.loop_id,
257
+ row.session_id,
258
+ error,
259
+ )
260
+ else:
261
+ logger.info(
262
+ "scheduler tick ok loop_id=%s session_id=%s tick=%d",
263
+ row.loop_id,
264
+ row.session_id,
265
+ row.tick_index + 1,
266
+ )
267
+ except Exception as exc:
268
+ logger.exception("scheduler tick exception loop_id=%s", row.loop_id)
269
+ await store.complete_tick(row.loop_id, worker_id=worker_id, error=str(exc))
270
+ finally:
271
+ heartbeat_stop.set()
272
+ heartbeat_task.cancel()
273
+ with contextlib.suppress(asyncio.CancelledError):
274
+ await heartbeat_task
275
+
276
+
277
+ @dataclass
278
+ class SchedulerHandle:
279
+ task: asyncio.Task[None]
280
+ shutdown: asyncio.Event
281
+
282
+
283
+ def start_scheduler_background(
284
+ *,
285
+ store: ScheduledLoopStore,
286
+ invoker: TickInvoker,
287
+ session_busy: SessionBusyChecker,
288
+ ensure_session: SessionEnsurer,
289
+ worker_id: str | None = None,
290
+ poll_interval_s: float | None = None,
291
+ stale_claim_ms: int | None = None,
292
+ max_concurrency: int | None = None,
293
+ claim_heartbeat_interval_s: float | None = None,
294
+ ) -> SchedulerHandle:
295
+ settings = scheduler_settings()
296
+ shutdown = asyncio.Event()
297
+ wid = worker_id or resolve_scheduler_worker_id()
298
+ task = asyncio.create_task(
299
+ run_scheduler_loop(
300
+ store=store,
301
+ invoker=invoker,
302
+ session_busy=session_busy,
303
+ ensure_session=ensure_session,
304
+ worker_id=wid,
305
+ poll_interval_s=(
306
+ poll_interval_s if poll_interval_s is not None else settings.poll_interval_s
307
+ ),
308
+ stale_claim_ms=stale_claim_ms if stale_claim_ms is not None else settings.stale_claim_ms,
309
+ max_concurrency=(
310
+ max_concurrency if max_concurrency is not None else settings.max_concurrency
311
+ ),
312
+ claim_heartbeat_interval_s=(
313
+ claim_heartbeat_interval_s
314
+ if claim_heartbeat_interval_s is not None
315
+ else settings.claim_heartbeat_interval_s
316
+ ),
317
+ shutdown=shutdown,
318
+ ),
319
+ name=f"monkeybot-scheduler-{wid}",
320
+ )
321
+ return SchedulerHandle(task=task, shutdown=shutdown)
322
+
323
+
324
+ async def shutdown_scheduler(handle: SchedulerHandle, *, drain_timeout_s: float = 30.0) -> None:
325
+ handle.shutdown.set()
326
+ try:
327
+ await asyncio.wait_for(handle.task, timeout=drain_timeout_s)
328
+ except TimeoutError:
329
+ handle.task.cancel()
330
+ with contextlib.suppress(asyncio.CancelledError):
331
+ await handle.task
332
+ except asyncio.CancelledError:
333
+ raise
@@ -0,0 +1,61 @@
1
+ """HTTP tick invoker for standalone scheduler workers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from monkeybot.core.types.content_blocks import ContentBlock, Text
8
+ from monkeybot.scheduler.tick_result import TickInvokeResult
9
+
10
+
11
+ class HttpTickInvoker:
12
+ """Call the gateway ``/scheduler/invoke-tick`` endpoint synchronously."""
13
+
14
+ def __init__(self, base_url: str, *, timeout_s: float = 3600.0) -> None:
15
+ self._base_url = base_url.rstrip("/")
16
+ self._timeout_s = timeout_s
17
+ self._sessions_ready: set[str] = set()
18
+
19
+ async def _ensure_session(self, client: httpx.AsyncClient, session_id: str) -> TickInvokeResult | None:
20
+ if session_id in self._sessions_ready:
21
+ return None
22
+ session_resp = await client.post(
23
+ f"{self._base_url}/sessions",
24
+ json={"session_id": session_id},
25
+ )
26
+ if session_resp.status_code in (201, 409):
27
+ self._sessions_ready.add(session_id)
28
+ return None
29
+ return TickInvokeResult.fail(
30
+ f"create session failed: {session_resp.status_code} {session_resp.text}"
31
+ )
32
+
33
+ async def invoke_tick(
34
+ self,
35
+ session_id: str,
36
+ request_id: str,
37
+ user_content: list[ContentBlock],
38
+ ) -> TickInvokeResult:
39
+ message = "\n".join(
40
+ block.text for block in user_content if isinstance(block, Text)
41
+ )
42
+ payload = {
43
+ "session_id": session_id,
44
+ "request_id": request_id,
45
+ "message": message,
46
+ }
47
+ async with httpx.AsyncClient(timeout=self._timeout_s) as client:
48
+ try:
49
+ ensure_err = await self._ensure_session(client, session_id)
50
+ if ensure_err is not None:
51
+ return ensure_err
52
+ resp = await client.post(f"{self._base_url}/scheduler/invoke-tick", json=payload)
53
+ except httpx.HTTPError as exc:
54
+ return TickInvokeResult.fail(str(exc))
55
+ if resp.status_code == 409:
56
+ return TickInvokeResult.session_busy()
57
+ if resp.status_code >= 400:
58
+ return TickInvokeResult.fail(
59
+ f"invoke-tick failed: {resp.status_code} {resp.text}"
60
+ )
61
+ return TickInvokeResult.ok()
@@ -0,0 +1,77 @@
1
+ """Parse human interval strings for scheduled loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
8
+ _INTERVAL_RE = re.compile(
9
+ r"^\s*(?P<num>\d+(?:\.\d+)?)\s*(?P<unit>s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)?\s*$",
10
+ re.IGNORECASE,
11
+ )
12
+
13
+ _UNIT_TO_MS: dict[str, int] = {
14
+ "s": 1000,
15
+ "sec": 1000,
16
+ "secs": 1000,
17
+ "second": 1000,
18
+ "seconds": 1000,
19
+ "m": 60_000,
20
+ "min": 60_000,
21
+ "mins": 60_000,
22
+ "minute": 60_000,
23
+ "minutes": 60_000,
24
+ "h": 3_600_000,
25
+ "hr": 3_600_000,
26
+ "hrs": 3_600_000,
27
+ "hour": 3_600_000,
28
+ "hours": 3_600_000,
29
+ }
30
+
31
+
32
+ _MIN_INTERVAL_MS = 5_000
33
+
34
+
35
+ def min_interval_ms() -> int:
36
+ raw = os.environ.get("MONKEYBOT_SCHEDULER_MIN_INTERVAL_MS", "").strip()
37
+ if not raw:
38
+ return _MIN_INTERVAL_MS
39
+ try:
40
+ return max(1, int(raw))
41
+ except ValueError:
42
+ return _MIN_INTERVAL_MS
43
+
44
+
45
+ def parse_interval_ms(value: str | int | float) -> int:
46
+ """Parse ``20s``, ``5m``, ``1h``, or raw seconds into milliseconds."""
47
+ if isinstance(value, (int, float)):
48
+ seconds = float(value)
49
+ if seconds <= 0:
50
+ raise ValueError("interval must be positive")
51
+ ms = max(1, int(seconds * 1000))
52
+ else:
53
+ raw = str(value).strip()
54
+ if not raw:
55
+ raise ValueError("interval is required")
56
+ match = _INTERVAL_RE.match(raw)
57
+ if not match:
58
+ raise ValueError(f"invalid interval: {value!r}")
59
+ num = float(match.group("num"))
60
+ if num <= 0:
61
+ raise ValueError("interval must be positive")
62
+ unit = (match.group("unit") or "s").lower()
63
+ ms = int(num * _UNIT_TO_MS[unit])
64
+ ms = max(1, ms)
65
+ floor = min_interval_ms()
66
+ if ms < floor:
67
+ raise ValueError(f"interval must be at least {floor}ms")
68
+ return ms
69
+
70
+
71
+ def parse_optional_duration_ms(value: str | int | float | None) -> int | None:
72
+ """Parse optional max-runtime style durations."""
73
+ if value is None:
74
+ return None
75
+ if isinstance(value, str) and not value.strip():
76
+ return None
77
+ return parse_interval_ms(value)
@@ -0,0 +1,34 @@
1
+ """Typed outcomes from a scheduled-loop tick invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class TickInvokeStatus(StrEnum):
10
+ """Result of one scheduler tick invocation."""
11
+
12
+ OK = "ok"
13
+ SESSION_BUSY = "session_busy"
14
+ ERROR = "error"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TickInvokeResult:
19
+ """Outcome passed from :class:`TickInvoker` to the scheduler engine."""
20
+
21
+ status: TickInvokeStatus
22
+ error: str | None = None
23
+
24
+ @classmethod
25
+ def ok(cls) -> TickInvokeResult:
26
+ return cls(status=TickInvokeStatus.OK)
27
+
28
+ @classmethod
29
+ def session_busy(cls) -> TickInvokeResult:
30
+ return cls(status=TickInvokeStatus.SESSION_BUSY)
31
+
32
+ @classmethod
33
+ def fail(cls, message: str) -> TickInvokeResult:
34
+ return cls(status=TickInvokeStatus.ERROR, error=message)
@@ -0,0 +1,87 @@
1
+ """Standalone scheduler worker (HTTP client to a running gateway)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+
9
+ from monkeybot.core.config.settings import auto_schema_enabled_from_config
10
+ from monkeybot.core.persistence.backends import StorageBackend, create_storage_backend
11
+ from monkeybot.gateway.bootstrap import ensure_gateway_runtime_env
12
+ from monkeybot.scheduler.engine import (
13
+ resolve_scheduler_worker_id,
14
+ run_scheduler_loop,
15
+ scheduler_settings,
16
+ )
17
+ from monkeybot.scheduler.http_invoker import HttpTickInvoker
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class _NoopSessionBusyChecker:
23
+ """Remote busy detection is handled by :class:`HttpTickInvoker` (HTTP 409)."""
24
+
25
+ def is_busy(self, session_id: str) -> bool:
26
+ del session_id
27
+ return False
28
+
29
+
30
+ class _NoopSessionEnsurer:
31
+ """Session creation is handled by :class:`HttpTickInvoker` before each tick."""
32
+
33
+ async def ensure_session(self, session_id: str) -> None:
34
+ del session_id
35
+
36
+
37
+ def _gateway_base_url() -> str:
38
+ port = os.environ.get("PORT", os.environ.get("GATEWAY_PORT", "8000"))
39
+ host = os.environ.get("MONKEYBOT_GATEWAY_HOST", "127.0.0.1")
40
+ explicit = os.environ.get("MONKEYBOT_GATEWAY_URL", "").strip()
41
+ if explicit:
42
+ return explicit.rstrip("/")
43
+ return f"http://{host}:{port}"
44
+
45
+
46
+ async def _run() -> None:
47
+ ensure_gateway_runtime_env()
48
+ db_url = os.environ.get("DB_URL", "sqlite:///data/monkeybot.db")
49
+ backend: StorageBackend = create_storage_backend(db_url)
50
+ await backend.open(run_schema=auto_schema_enabled_from_config())
51
+ settings = scheduler_settings()
52
+ worker_id = resolve_scheduler_worker_id()
53
+ base_url = _gateway_base_url()
54
+ invoker = HttpTickInvoker(base_url)
55
+ logger.info(
56
+ "scheduler worker starting worker_id=%s gateway=%s poll_interval_s=%s concurrency=%s",
57
+ worker_id,
58
+ base_url,
59
+ settings.poll_interval_s,
60
+ settings.max_concurrency,
61
+ )
62
+ try:
63
+ await run_scheduler_loop(
64
+ store=backend.scheduled_loops(),
65
+ invoker=invoker,
66
+ session_busy=_NoopSessionBusyChecker(),
67
+ ensure_session=_NoopSessionEnsurer(),
68
+ worker_id=worker_id,
69
+ poll_interval_s=settings.poll_interval_s,
70
+ stale_claim_ms=settings.stale_claim_ms,
71
+ max_concurrency=settings.max_concurrency,
72
+ claim_heartbeat_interval_s=settings.claim_heartbeat_interval_s,
73
+ )
74
+ finally:
75
+ await backend.close()
76
+
77
+
78
+ def main() -> None:
79
+ logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
80
+ try:
81
+ asyncio.run(_run())
82
+ except KeyboardInterrupt:
83
+ raise SystemExit(130) from None
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
@@ -0,0 +1 @@
1
+ """Top-level subagent worker package."""
@@ -0,0 +1 @@
1
+ """Worker entry package."""
@@ -0,0 +1,22 @@
1
+ """Standalone worker process for queued subagent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+
9
+ from monkeybot.core.logging_utils import normalize_log_level
10
+ from monkeybot.core.subagents.worker_pool import run_worker_main
11
+
12
+
13
+ def main() -> None:
14
+ logging.basicConfig(level=normalize_log_level(os.environ.get("LOG_LEVEL"), default="INFO"))
15
+ try:
16
+ asyncio.run(run_worker_main())
17
+ except KeyboardInterrupt:
18
+ raise SystemExit(0) from None
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()