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