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,76 @@
|
|
|
1
|
+
"""Run identifiers, scratch directory layout, and completed-run cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _encode_ulid_payload(payload: bytes) -> str:
|
|
16
|
+
"""Encode 16 bytes into a Crockford-base32 ULID string (uppercase length 26)."""
|
|
17
|
+
if len(payload) != 16:
|
|
18
|
+
msg = "ULID payload must be 16 bytes (48-bit timestamp + 80-bit randomness)."
|
|
19
|
+
raise ValueError(msg)
|
|
20
|
+
buffer = int.from_bytes(payload, "big")
|
|
21
|
+
chars: list[str] = []
|
|
22
|
+
for _ in range(26):
|
|
23
|
+
chars.append(_CROCKFORD_ALPHABET[buffer & 0x1F])
|
|
24
|
+
buffer >>= 5
|
|
25
|
+
return "".join(reversed(chars))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_run_id() -> str:
|
|
29
|
+
"""Return a new ULID string (26 Crockford base32 chars)."""
|
|
30
|
+
timestamp_ms = int(time.time() * 1000) & 0xFFFFFFFFFFFF
|
|
31
|
+
suffix = secrets.token_bytes(10)
|
|
32
|
+
payload = timestamp_ms.to_bytes(6, "big") + suffix
|
|
33
|
+
return _encode_ulid_payload(payload)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def create_scratch_dir(run_id: str, base: Path) -> Path:
|
|
37
|
+
"""Create ``base/{run_id}/`` with required subdirectories."""
|
|
38
|
+
root = base / run_id
|
|
39
|
+
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=False)
|
|
40
|
+
input_dir = root / "input"
|
|
41
|
+
promotions = root / "memory-promotions"
|
|
42
|
+
await asyncio.to_thread(input_dir.mkdir, parents=True, exist_ok=True)
|
|
43
|
+
await asyncio.to_thread(promotions.mkdir, parents=True, exist_ok=True)
|
|
44
|
+
return root
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _mtime_utc(child: Path) -> datetime:
|
|
48
|
+
st = child.stat()
|
|
49
|
+
return datetime.fromtimestamp(st.st_mtime, tz=UTC)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def cleanup_old_runs(base: Path, older_than_days: int = 7) -> int:
|
|
53
|
+
"""Delete eligible run directories older than cutoff. Returns number deleted."""
|
|
54
|
+
if older_than_days < 1:
|
|
55
|
+
msg = "older_than_days must be >= 1"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
|
|
58
|
+
now = datetime.now(tz=UTC)
|
|
59
|
+
cutoff = now - timedelta(days=older_than_days)
|
|
60
|
+
|
|
61
|
+
deleted = 0
|
|
62
|
+
|
|
63
|
+
def scan_children() -> list[Path]:
|
|
64
|
+
return sorted(p for p in base.iterdir() if p.is_dir())
|
|
65
|
+
|
|
66
|
+
for child in await asyncio.to_thread(scan_children):
|
|
67
|
+
marker = child / "output.json"
|
|
68
|
+
if not await asyncio.to_thread(marker.exists):
|
|
69
|
+
continue
|
|
70
|
+
dir_mtime = await asyncio.to_thread(_mtime_utc, child)
|
|
71
|
+
if not (dir_mtime < cutoff):
|
|
72
|
+
continue
|
|
73
|
+
await asyncio.to_thread(shutil.rmtree, child, False)
|
|
74
|
+
deleted += 1
|
|
75
|
+
|
|
76
|
+
return deleted
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Durable scheduled agent loop records (prompt-first /loop-style automation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
import aiosqlite
|
|
12
|
+
|
|
13
|
+
_SCHEDULED_LOOP_COLUMNS: tuple[str, ...] = (
|
|
14
|
+
"loop_id",
|
|
15
|
+
"session_id",
|
|
16
|
+
"status",
|
|
17
|
+
"prompt",
|
|
18
|
+
"interval_ms",
|
|
19
|
+
"max_ticks",
|
|
20
|
+
"max_runtime_ms",
|
|
21
|
+
"skip_if_busy",
|
|
22
|
+
"tick_index",
|
|
23
|
+
"next_tick_at_ms",
|
|
24
|
+
"started_at_ms",
|
|
25
|
+
"last_tick_at_ms",
|
|
26
|
+
"last_error",
|
|
27
|
+
"stop_reason",
|
|
28
|
+
"tick_in_flight",
|
|
29
|
+
"worker_id",
|
|
30
|
+
"claimed_at_ms",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_LOOP_STATUSES = frozenset({"active", "paused", "completed", "failed"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ScheduledLoopRow:
|
|
38
|
+
"""One row from the ``scheduled_loops`` table."""
|
|
39
|
+
|
|
40
|
+
loop_id: str
|
|
41
|
+
session_id: str
|
|
42
|
+
status: str
|
|
43
|
+
prompt: str
|
|
44
|
+
interval_ms: int
|
|
45
|
+
max_ticks: int | None
|
|
46
|
+
max_runtime_ms: int | None
|
|
47
|
+
skip_if_busy: bool
|
|
48
|
+
tick_index: int
|
|
49
|
+
next_tick_at_ms: int
|
|
50
|
+
started_at_ms: int
|
|
51
|
+
last_tick_at_ms: int | None
|
|
52
|
+
last_error: str | None
|
|
53
|
+
stop_reason: str | None
|
|
54
|
+
tick_in_flight: bool
|
|
55
|
+
worker_id: str | None = None
|
|
56
|
+
claimed_at_ms: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _optional_int_field(raw: object) -> int | None:
|
|
60
|
+
if raw is None:
|
|
61
|
+
return None
|
|
62
|
+
if isinstance(raw, bool):
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(raw, int):
|
|
65
|
+
return raw
|
|
66
|
+
if isinstance(raw, float):
|
|
67
|
+
return int(raw)
|
|
68
|
+
if isinstance(raw, str) and raw.strip():
|
|
69
|
+
try:
|
|
70
|
+
return int(raw)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bool_field(raw: object, *, default: bool = False) -> bool:
|
|
77
|
+
if isinstance(raw, bool):
|
|
78
|
+
return raw
|
|
79
|
+
if isinstance(raw, int):
|
|
80
|
+
return bool(raw)
|
|
81
|
+
if isinstance(raw, str):
|
|
82
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
83
|
+
return default
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def doc_to_scheduled_loop_row(loop_id: str, data: dict[str, object]) -> ScheduledLoopRow:
|
|
87
|
+
"""Map a Firestore document (or JSON blob) to :class:`ScheduledLoopRow`."""
|
|
88
|
+
return ScheduledLoopRow(
|
|
89
|
+
loop_id=loop_id,
|
|
90
|
+
session_id=str(data.get("session_id", "")),
|
|
91
|
+
status=str(data.get("status", "")),
|
|
92
|
+
prompt=str(data.get("prompt", "")),
|
|
93
|
+
interval_ms=int(cast(int, data.get("interval_ms", 0))),
|
|
94
|
+
max_ticks=_optional_int_field(data.get("max_ticks")),
|
|
95
|
+
max_runtime_ms=_optional_int_field(data.get("max_runtime_ms")),
|
|
96
|
+
skip_if_busy=_bool_field(data.get("skip_if_busy"), default=True),
|
|
97
|
+
tick_index=int(cast(int, data.get("tick_index", 0))),
|
|
98
|
+
next_tick_at_ms=int(cast(int, data.get("next_tick_at_ms", 0))),
|
|
99
|
+
started_at_ms=int(cast(int, data.get("started_at_ms", 0))),
|
|
100
|
+
last_tick_at_ms=_optional_int_field(data.get("last_tick_at_ms")),
|
|
101
|
+
last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
|
|
102
|
+
stop_reason=str(data["stop_reason"]) if data.get("stop_reason") is not None else None,
|
|
103
|
+
tick_in_flight=_bool_field(data.get("tick_in_flight")),
|
|
104
|
+
worker_id=str(data["worker_id"]) if data.get("worker_id") is not None else None,
|
|
105
|
+
claimed_at_ms=_optional_int_field(data.get("claimed_at_ms")),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ScheduledLoopCreate:
|
|
111
|
+
"""Arguments for registering a new scheduled loop."""
|
|
112
|
+
|
|
113
|
+
prompt: str
|
|
114
|
+
interval_ms: int
|
|
115
|
+
session_id: str = "loop-main"
|
|
116
|
+
loop_id: str | None = None
|
|
117
|
+
max_ticks: int | None = None
|
|
118
|
+
max_runtime_ms: int | None = None
|
|
119
|
+
skip_if_busy: bool = True
|
|
120
|
+
unbounded: bool = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_loop_guards(
|
|
124
|
+
*,
|
|
125
|
+
max_ticks: int | None,
|
|
126
|
+
max_runtime_ms: int | None,
|
|
127
|
+
unbounded: bool,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Require an explicit stop guard unless the operator opts into unbounded loops."""
|
|
130
|
+
if unbounded:
|
|
131
|
+
return
|
|
132
|
+
if max_ticks is None and max_runtime_ms is None:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"scheduled loops require max_ticks, max_runtime, or unbounded=true"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _row_from_tuple(row: tuple[object, ...]) -> ScheduledLoopRow:
|
|
139
|
+
d = dict(zip(_SCHEDULED_LOOP_COLUMNS, row, strict=True))
|
|
140
|
+
return ScheduledLoopRow(
|
|
141
|
+
loop_id=str(d["loop_id"]),
|
|
142
|
+
session_id=str(d["session_id"]),
|
|
143
|
+
status=str(d["status"]),
|
|
144
|
+
prompt=str(d["prompt"]),
|
|
145
|
+
interval_ms=int(cast(int, d["interval_ms"])),
|
|
146
|
+
max_ticks=int(cast(int, d["max_ticks"])) if d["max_ticks"] is not None else None,
|
|
147
|
+
max_runtime_ms=(
|
|
148
|
+
int(cast(int, d["max_runtime_ms"])) if d["max_runtime_ms"] is not None else None
|
|
149
|
+
),
|
|
150
|
+
skip_if_busy=bool(int(cast(int, d["skip_if_busy"]))),
|
|
151
|
+
tick_index=int(cast(int, d["tick_index"])),
|
|
152
|
+
next_tick_at_ms=int(cast(int, d["next_tick_at_ms"])),
|
|
153
|
+
started_at_ms=int(cast(int, d["started_at_ms"])),
|
|
154
|
+
last_tick_at_ms=(
|
|
155
|
+
int(cast(int, d["last_tick_at_ms"])) if d["last_tick_at_ms"] is not None else None
|
|
156
|
+
),
|
|
157
|
+
last_error=str(d["last_error"]) if d["last_error"] is not None else None,
|
|
158
|
+
stop_reason=str(d["stop_reason"]) if d["stop_reason"] is not None else None,
|
|
159
|
+
tick_in_flight=bool(int(cast(int, d["tick_in_flight"]))),
|
|
160
|
+
worker_id=str(d["worker_id"]) if d["worker_id"] is not None else None,
|
|
161
|
+
claimed_at_ms=(
|
|
162
|
+
int(cast(int, d["claimed_at_ms"])) if d["claimed_at_ms"] is not None else None
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _loop_id_from_create(spec: ScheduledLoopCreate) -> str:
|
|
168
|
+
if spec.loop_id and spec.loop_id.strip():
|
|
169
|
+
return spec.loop_id.strip()
|
|
170
|
+
return f"loop-{uuid.uuid4().hex[:12]}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_tick_prompt(row: ScheduledLoopRow) -> str:
|
|
174
|
+
"""Wrap the stored user prompt with tick metadata for each scheduled invocation."""
|
|
175
|
+
max_label = str(row.max_ticks) if row.max_ticks is not None else "∞"
|
|
176
|
+
tick_num = row.tick_index + 1
|
|
177
|
+
header = (
|
|
178
|
+
f"[SCHEDULED TICK {tick_num}/{max_label} · loop_id={row.loop_id} · "
|
|
179
|
+
f"session={row.session_id}]"
|
|
180
|
+
)
|
|
181
|
+
return f"{header}\n\n{row.prompt.strip()}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SQLiteScheduledLoopStore:
|
|
185
|
+
"""SQLite persistence for scheduled agent loops."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, conn: aiosqlite.Connection) -> None:
|
|
188
|
+
self._conn = conn
|
|
189
|
+
|
|
190
|
+
async def create(self, spec: ScheduledLoopCreate) -> ScheduledLoopRow:
|
|
191
|
+
loop_id = _loop_id_from_create(spec)
|
|
192
|
+
existing = await self.get(loop_id)
|
|
193
|
+
if existing is not None:
|
|
194
|
+
raise ValueError(f"scheduled loop already exists: {loop_id}")
|
|
195
|
+
validate_loop_guards(
|
|
196
|
+
max_ticks=spec.max_ticks,
|
|
197
|
+
max_runtime_ms=spec.max_runtime_ms,
|
|
198
|
+
unbounded=spec.unbounded,
|
|
199
|
+
)
|
|
200
|
+
now_ms = int(time.time() * 1000)
|
|
201
|
+
await self._conn.execute(
|
|
202
|
+
"""
|
|
203
|
+
INSERT INTO scheduled_loops(
|
|
204
|
+
loop_id, session_id, status, prompt, interval_ms,
|
|
205
|
+
max_ticks, max_runtime_ms, skip_if_busy, tick_index,
|
|
206
|
+
next_tick_at_ms, started_at_ms, last_tick_at_ms,
|
|
207
|
+
last_error, stop_reason, tick_in_flight, worker_id, claimed_at_ms
|
|
208
|
+
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, 0, ?, ?, NULL, NULL, NULL, 0, NULL, NULL)
|
|
209
|
+
""",
|
|
210
|
+
(
|
|
211
|
+
loop_id,
|
|
212
|
+
spec.session_id.strip() or "loop-main",
|
|
213
|
+
spec.prompt.strip(),
|
|
214
|
+
spec.interval_ms,
|
|
215
|
+
spec.max_ticks,
|
|
216
|
+
spec.max_runtime_ms,
|
|
217
|
+
1 if spec.skip_if_busy else 0,
|
|
218
|
+
now_ms,
|
|
219
|
+
now_ms,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
await self._conn.commit()
|
|
223
|
+
row = await self.get(loop_id)
|
|
224
|
+
if row is None:
|
|
225
|
+
raise RuntimeError("failed to read scheduled loop after insert")
|
|
226
|
+
return row
|
|
227
|
+
|
|
228
|
+
async def get(self, loop_id: str) -> ScheduledLoopRow | None:
|
|
229
|
+
columns = ", ".join(_SCHEDULED_LOOP_COLUMNS)
|
|
230
|
+
cursor = await self._conn.execute(
|
|
231
|
+
f"SELECT {columns} FROM scheduled_loops WHERE loop_id = ?",
|
|
232
|
+
(loop_id,),
|
|
233
|
+
)
|
|
234
|
+
row = await cursor.fetchone()
|
|
235
|
+
await cursor.close()
|
|
236
|
+
if row is None:
|
|
237
|
+
return None
|
|
238
|
+
return _row_from_tuple(tuple(row))
|
|
239
|
+
|
|
240
|
+
async def list_all(self) -> list[ScheduledLoopRow]:
|
|
241
|
+
columns = ", ".join(_SCHEDULED_LOOP_COLUMNS)
|
|
242
|
+
cursor = await self._conn.execute(
|
|
243
|
+
f"SELECT {columns} FROM scheduled_loops ORDER BY started_at_ms DESC"
|
|
244
|
+
)
|
|
245
|
+
rows = await cursor.fetchall()
|
|
246
|
+
await cursor.close()
|
|
247
|
+
return [_row_from_tuple(tuple(r)) for r in rows]
|
|
248
|
+
|
|
249
|
+
async def list_due(self, now_ms: int) -> list[ScheduledLoopRow]:
|
|
250
|
+
columns = ", ".join(_SCHEDULED_LOOP_COLUMNS)
|
|
251
|
+
cursor = await self._conn.execute(
|
|
252
|
+
f"""
|
|
253
|
+
SELECT {columns} FROM scheduled_loops
|
|
254
|
+
WHERE status = 'active'
|
|
255
|
+
AND tick_in_flight = 0
|
|
256
|
+
AND next_tick_at_ms <= ?
|
|
257
|
+
ORDER BY next_tick_at_ms ASC
|
|
258
|
+
""",
|
|
259
|
+
(now_ms,),
|
|
260
|
+
)
|
|
261
|
+
rows = await cursor.fetchall()
|
|
262
|
+
await cursor.close()
|
|
263
|
+
return [_row_from_tuple(tuple(r)) for r in rows]
|
|
264
|
+
|
|
265
|
+
async def claim_tick(self, loop_id: str, worker_id: str) -> ScheduledLoopRow | None:
|
|
266
|
+
"""Atomically mark a due loop as in-flight; returns None if not claimable."""
|
|
267
|
+
now_ms = int(time.time() * 1000)
|
|
268
|
+
cursor = await self._conn.execute(
|
|
269
|
+
"""
|
|
270
|
+
UPDATE scheduled_loops
|
|
271
|
+
SET tick_in_flight = 1, worker_id = ?, claimed_at_ms = ?
|
|
272
|
+
WHERE loop_id = ?
|
|
273
|
+
AND status = 'active'
|
|
274
|
+
AND tick_in_flight = 0
|
|
275
|
+
AND next_tick_at_ms <= ?
|
|
276
|
+
""",
|
|
277
|
+
(worker_id, now_ms, loop_id, now_ms),
|
|
278
|
+
)
|
|
279
|
+
await self._conn.commit()
|
|
280
|
+
if cursor.rowcount != 1:
|
|
281
|
+
return None
|
|
282
|
+
return await self.get(loop_id)
|
|
283
|
+
|
|
284
|
+
async def release_stale_claims(self, stale_after_ms: int) -> int:
|
|
285
|
+
"""Reset in-flight ticks with no heartbeat after ``stale_after_ms``."""
|
|
286
|
+
cutoff = int(time.time() * 1000) - stale_after_ms
|
|
287
|
+
cursor = await self._conn.execute(
|
|
288
|
+
"""
|
|
289
|
+
UPDATE scheduled_loops
|
|
290
|
+
SET tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL,
|
|
291
|
+
last_error = COALESCE(last_error, 'stale tick claim released')
|
|
292
|
+
WHERE tick_in_flight = 1
|
|
293
|
+
AND claimed_at_ms IS NOT NULL
|
|
294
|
+
AND claimed_at_ms < ?
|
|
295
|
+
""",
|
|
296
|
+
(cutoff,),
|
|
297
|
+
)
|
|
298
|
+
await self._conn.commit()
|
|
299
|
+
return int(cursor.rowcount)
|
|
300
|
+
|
|
301
|
+
async def renew_tick_claim(self, loop_id: str, worker_id: str) -> bool:
|
|
302
|
+
"""Extend the in-flight claim lease for a worker that is still executing a tick."""
|
|
303
|
+
now_ms = int(time.time() * 1000)
|
|
304
|
+
cursor = await self._conn.execute(
|
|
305
|
+
"""
|
|
306
|
+
UPDATE scheduled_loops
|
|
307
|
+
SET claimed_at_ms = ?
|
|
308
|
+
WHERE loop_id = ?
|
|
309
|
+
AND worker_id = ?
|
|
310
|
+
AND tick_in_flight = 1
|
|
311
|
+
""",
|
|
312
|
+
(now_ms, loop_id, worker_id),
|
|
313
|
+
)
|
|
314
|
+
await self._conn.commit()
|
|
315
|
+
return cursor.rowcount == 1
|
|
316
|
+
|
|
317
|
+
async def complete_tick(
|
|
318
|
+
self,
|
|
319
|
+
loop_id: str,
|
|
320
|
+
*,
|
|
321
|
+
worker_id: str,
|
|
322
|
+
error: str | None = None,
|
|
323
|
+
) -> ScheduledLoopRow | None:
|
|
324
|
+
row = await self.get(loop_id)
|
|
325
|
+
if row is None or not row.tick_in_flight or row.worker_id != worker_id:
|
|
326
|
+
return None
|
|
327
|
+
now_ms = int(time.time() * 1000)
|
|
328
|
+
tick_index = row.tick_index + 1
|
|
329
|
+
stop_reason: str | None = None
|
|
330
|
+
status = row.status
|
|
331
|
+
if error:
|
|
332
|
+
status = "failed"
|
|
333
|
+
stop_reason = "tick_error"
|
|
334
|
+
elif row.max_ticks is not None and tick_index >= row.max_ticks:
|
|
335
|
+
status = "completed"
|
|
336
|
+
stop_reason = "max_ticks"
|
|
337
|
+
elif row.max_runtime_ms is not None and (now_ms - row.started_at_ms) >= row.max_runtime_ms:
|
|
338
|
+
status = "completed"
|
|
339
|
+
stop_reason = "max_runtime"
|
|
340
|
+
next_tick = now_ms + row.interval_ms if status == "active" else row.next_tick_at_ms
|
|
341
|
+
await self._conn.execute(
|
|
342
|
+
"""
|
|
343
|
+
UPDATE scheduled_loops
|
|
344
|
+
SET tick_index = ?, last_tick_at_ms = ?, last_error = ?,
|
|
345
|
+
status = ?, stop_reason = ?, next_tick_at_ms = ?,
|
|
346
|
+
tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL
|
|
347
|
+
WHERE loop_id = ? AND worker_id = ? AND tick_in_flight = 1
|
|
348
|
+
""",
|
|
349
|
+
(
|
|
350
|
+
tick_index,
|
|
351
|
+
now_ms,
|
|
352
|
+
error,
|
|
353
|
+
status,
|
|
354
|
+
stop_reason,
|
|
355
|
+
next_tick,
|
|
356
|
+
loop_id,
|
|
357
|
+
worker_id,
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
await self._conn.commit()
|
|
361
|
+
return await self.get(loop_id)
|
|
362
|
+
|
|
363
|
+
async def defer_tick(self, loop_id: str, *, worker_id: str, reason: str) -> None:
|
|
364
|
+
"""Release claim and push next tick forward (e.g. session busy)."""
|
|
365
|
+
row = await self.get(loop_id)
|
|
366
|
+
if row is None or row.worker_id != worker_id:
|
|
367
|
+
return
|
|
368
|
+
now_ms = int(time.time() * 1000)
|
|
369
|
+
await self._conn.execute(
|
|
370
|
+
"""
|
|
371
|
+
UPDATE scheduled_loops
|
|
372
|
+
SET tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL,
|
|
373
|
+
next_tick_at_ms = ?, last_error = ?
|
|
374
|
+
WHERE loop_id = ? AND worker_id = ?
|
|
375
|
+
""",
|
|
376
|
+
(now_ms + row.interval_ms, reason, loop_id, worker_id),
|
|
377
|
+
)
|
|
378
|
+
await self._conn.commit()
|
|
379
|
+
|
|
380
|
+
async def set_status(self, loop_id: str, status: str, *, stop_reason: str | None = None) -> bool:
|
|
381
|
+
if status not in _LOOP_STATUSES:
|
|
382
|
+
raise ValueError(f"invalid loop status: {status}")
|
|
383
|
+
cursor = await self._conn.execute(
|
|
384
|
+
"""
|
|
385
|
+
UPDATE scheduled_loops
|
|
386
|
+
SET status = ?, stop_reason = COALESCE(?, stop_reason),
|
|
387
|
+
tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL
|
|
388
|
+
WHERE loop_id = ?
|
|
389
|
+
""",
|
|
390
|
+
(status, stop_reason, loop_id),
|
|
391
|
+
)
|
|
392
|
+
await self._conn.commit()
|
|
393
|
+
return cursor.rowcount == 1
|
|
394
|
+
|
|
395
|
+
async def pause(self, loop_id: str) -> bool:
|
|
396
|
+
return await self.set_status(loop_id, "paused")
|
|
397
|
+
|
|
398
|
+
async def resume(self, loop_id: str) -> bool:
|
|
399
|
+
row = await self.get(loop_id)
|
|
400
|
+
if row is None:
|
|
401
|
+
return False
|
|
402
|
+
now_ms = int(time.time() * 1000)
|
|
403
|
+
cursor = await self._conn.execute(
|
|
404
|
+
"""
|
|
405
|
+
UPDATE scheduled_loops
|
|
406
|
+
SET status = 'active', stop_reason = NULL, next_tick_at_ms = ?,
|
|
407
|
+
tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL
|
|
408
|
+
WHERE loop_id = ? AND status = 'paused'
|
|
409
|
+
""",
|
|
410
|
+
(now_ms, loop_id),
|
|
411
|
+
)
|
|
412
|
+
await self._conn.commit()
|
|
413
|
+
return cursor.rowcount == 1
|
|
414
|
+
|
|
415
|
+
async def stop(self, loop_id: str, *, stop_reason: str = "manual") -> bool:
|
|
416
|
+
return await self.set_status(loop_id, "completed", stop_reason=stop_reason)
|
|
417
|
+
|
|
418
|
+
def row_to_json(self, row: ScheduledLoopRow) -> dict[str, object]:
|
|
419
|
+
return {
|
|
420
|
+
"loop_id": row.loop_id,
|
|
421
|
+
"session_id": row.session_id,
|
|
422
|
+
"status": row.status,
|
|
423
|
+
"prompt": row.prompt,
|
|
424
|
+
"interval_ms": row.interval_ms,
|
|
425
|
+
"max_ticks": row.max_ticks,
|
|
426
|
+
"max_runtime_ms": row.max_runtime_ms,
|
|
427
|
+
"skip_if_busy": row.skip_if_busy,
|
|
428
|
+
"tick_index": row.tick_index,
|
|
429
|
+
"next_tick_at_ms": row.next_tick_at_ms,
|
|
430
|
+
"started_at_ms": row.started_at_ms,
|
|
431
|
+
"last_tick_at_ms": row.last_tick_at_ms,
|
|
432
|
+
"last_error": row.last_error,
|
|
433
|
+
"stop_reason": row.stop_reason,
|
|
434
|
+
"tick_in_flight": row.tick_in_flight,
|
|
435
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""SQLite durable per-session turn locks for multi-replica gateway deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import aiosqlite
|
|
9
|
+
|
|
10
|
+
_DEFAULT_STALE_MS = 600_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def session_turn_stale_ms() -> int:
|
|
14
|
+
raw = os.environ.get("MONKEYBOT_SESSION_TURN_STALE_MS", "").strip()
|
|
15
|
+
if not raw:
|
|
16
|
+
return _DEFAULT_STALE_MS
|
|
17
|
+
try:
|
|
18
|
+
return max(1, int(raw))
|
|
19
|
+
except ValueError:
|
|
20
|
+
return _DEFAULT_STALE_MS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SQLiteSessionTurnLockStore:
|
|
24
|
+
"""SQLite-backed exclusive turn lock per session."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, conn: aiosqlite.Connection) -> None:
|
|
27
|
+
self._conn = conn
|
|
28
|
+
|
|
29
|
+
async def release_stale_claims(self, stale_after_ms: int) -> int:
|
|
30
|
+
cutoff = int(time.time() * 1000) - stale_after_ms
|
|
31
|
+
cursor = await self._conn.execute(
|
|
32
|
+
"""
|
|
33
|
+
UPDATE session_turn_locks
|
|
34
|
+
SET request_id = NULL, claimed_at_ms = NULL
|
|
35
|
+
WHERE request_id IS NOT NULL
|
|
36
|
+
AND claimed_at_ms IS NOT NULL
|
|
37
|
+
AND claimed_at_ms < ?
|
|
38
|
+
""",
|
|
39
|
+
(cutoff,),
|
|
40
|
+
)
|
|
41
|
+
await self._conn.commit()
|
|
42
|
+
return int(cursor.rowcount)
|
|
43
|
+
|
|
44
|
+
async def try_acquire(self, session_id: str, request_id: str) -> bool:
|
|
45
|
+
await self.release_stale_claims(session_turn_stale_ms())
|
|
46
|
+
now_ms = int(time.time() * 1000)
|
|
47
|
+
cursor = await self._conn.execute(
|
|
48
|
+
"""
|
|
49
|
+
UPDATE session_turn_locks
|
|
50
|
+
SET request_id = ?, claimed_at_ms = ?
|
|
51
|
+
WHERE session_id = ? AND request_id IS NULL
|
|
52
|
+
""",
|
|
53
|
+
(request_id, now_ms, session_id),
|
|
54
|
+
)
|
|
55
|
+
if cursor.rowcount == 1:
|
|
56
|
+
await self._conn.commit()
|
|
57
|
+
return True
|
|
58
|
+
try:
|
|
59
|
+
await self._conn.execute(
|
|
60
|
+
"""
|
|
61
|
+
INSERT INTO session_turn_locks (session_id, request_id, claimed_at_ms)
|
|
62
|
+
VALUES (?, ?, ?)
|
|
63
|
+
""",
|
|
64
|
+
(session_id, request_id, now_ms),
|
|
65
|
+
)
|
|
66
|
+
await self._conn.commit()
|
|
67
|
+
return True
|
|
68
|
+
except aiosqlite.IntegrityError:
|
|
69
|
+
await self._conn.rollback()
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
async def release(self, session_id: str, request_id: str) -> None:
|
|
73
|
+
await self._conn.execute(
|
|
74
|
+
"""
|
|
75
|
+
UPDATE session_turn_locks
|
|
76
|
+
SET request_id = NULL, claimed_at_ms = NULL
|
|
77
|
+
WHERE session_id = ? AND request_id = ?
|
|
78
|
+
""",
|
|
79
|
+
(session_id, request_id),
|
|
80
|
+
)
|
|
81
|
+
await self._conn.commit()
|
|
82
|
+
|
|
83
|
+
async def is_busy(self, session_id: str) -> bool:
|
|
84
|
+
cursor = await self._conn.execute(
|
|
85
|
+
"""
|
|
86
|
+
SELECT 1 FROM session_turn_locks
|
|
87
|
+
WHERE session_id = ? AND request_id IS NOT NULL
|
|
88
|
+
LIMIT 1
|
|
89
|
+
""",
|
|
90
|
+
(session_id,),
|
|
91
|
+
)
|
|
92
|
+
row = await cursor.fetchone()
|
|
93
|
+
await cursor.close()
|
|
94
|
+
return row is not None
|