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