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,200 @@
|
|
|
1
|
+
"""REST control plane for prompt-first scheduled loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, Request
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from monkeybot.core.persistence.backends import ScheduledLoopStore, StorageBackend
|
|
13
|
+
from monkeybot.core.persistence.scheduled_loops import ScheduledLoopCreate, ScheduledLoopRow
|
|
14
|
+
from monkeybot.core.types.content_blocks import Text
|
|
15
|
+
from monkeybot.gateway.sse.loop_port import LoopPort
|
|
16
|
+
from monkeybot.gateway.sse.models import APIError
|
|
17
|
+
from monkeybot.gateway.sse.scheduler_wiring import GatewaySessionEnsurer
|
|
18
|
+
from monkeybot.gateway.sse.session_bus import SessionRegistry
|
|
19
|
+
from monkeybot.scheduler.interval import parse_interval_ms, parse_optional_duration_ms
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_registry(request: Request) -> SessionRegistry:
|
|
23
|
+
return cast(SessionRegistry, request.app.state.registry)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _storage_backend(request: Request) -> StorageBackend:
|
|
27
|
+
backend: StorageBackend | None = getattr(request.app.state, "storage", None)
|
|
28
|
+
if backend is None:
|
|
29
|
+
raise APIError(503, "STORAGE_NOT_READY", "Storage backend not initialized", uuid.uuid4().hex)
|
|
30
|
+
return backend
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _loop_store(request: Request) -> ScheduledLoopStore:
|
|
34
|
+
return _storage_backend(request).scheduled_loops()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CreateLoopRequest(BaseModel):
|
|
38
|
+
prompt: str = Field(..., min_length=1)
|
|
39
|
+
interval: str | int | float
|
|
40
|
+
session_id: str = "loop-main"
|
|
41
|
+
loop_id: str | None = None
|
|
42
|
+
max_ticks: int | None = Field(default=None, ge=1)
|
|
43
|
+
max_runtime: str | int | float | None = None
|
|
44
|
+
skip_if_busy: bool = True
|
|
45
|
+
unbounded: bool = False
|
|
46
|
+
confirmed: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvokeTickRequest(BaseModel):
|
|
50
|
+
session_id: str
|
|
51
|
+
request_id: str
|
|
52
|
+
message: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _row_dict(row: ScheduledLoopRow) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"loop_id": row.loop_id,
|
|
58
|
+
"session_id": row.session_id,
|
|
59
|
+
"status": row.status,
|
|
60
|
+
"prompt": row.prompt,
|
|
61
|
+
"interval_ms": row.interval_ms,
|
|
62
|
+
"max_ticks": row.max_ticks,
|
|
63
|
+
"max_runtime_ms": row.max_runtime_ms,
|
|
64
|
+
"skip_if_busy": row.skip_if_busy,
|
|
65
|
+
"tick_index": row.tick_index,
|
|
66
|
+
"next_tick_at_ms": row.next_tick_at_ms,
|
|
67
|
+
"started_at_ms": row.started_at_ms,
|
|
68
|
+
"last_tick_at_ms": row.last_tick_at_ms,
|
|
69
|
+
"last_error": row.last_error,
|
|
70
|
+
"stop_reason": row.stop_reason,
|
|
71
|
+
"tick_in_flight": row.tick_in_flight,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_scheduler_router(*, loop_port: LoopPort, registry: SessionRegistry) -> APIRouter:
|
|
76
|
+
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
|
77
|
+
ensurer = GatewaySessionEnsurer(registry)
|
|
78
|
+
|
|
79
|
+
@router.post("/loops", status_code=201)
|
|
80
|
+
async def create_loop(
|
|
81
|
+
body: CreateLoopRequest,
|
|
82
|
+
request: Request,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
store = _loop_store(request)
|
|
85
|
+
if not body.confirmed:
|
|
86
|
+
raise APIError(
|
|
87
|
+
400,
|
|
88
|
+
"CONFIRMATION_REQUIRED",
|
|
89
|
+
"confirmed=true is required to register a scheduled loop via REST/CLI",
|
|
90
|
+
uuid.uuid4().hex,
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
interval_ms = parse_interval_ms(body.interval)
|
|
94
|
+
max_runtime_ms = parse_optional_duration_ms(body.max_runtime)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise APIError(400, "INVALID_INTERVAL", str(exc), uuid.uuid4().hex) from exc
|
|
97
|
+
spec = ScheduledLoopCreate(
|
|
98
|
+
prompt=body.prompt,
|
|
99
|
+
interval_ms=interval_ms,
|
|
100
|
+
session_id=body.session_id,
|
|
101
|
+
loop_id=body.loop_id,
|
|
102
|
+
max_ticks=body.max_ticks,
|
|
103
|
+
max_runtime_ms=max_runtime_ms,
|
|
104
|
+
skip_if_busy=body.skip_if_busy,
|
|
105
|
+
unbounded=body.unbounded,
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
row = await store.create(spec)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
msg = str(exc)
|
|
111
|
+
if "scheduled loops require" in msg:
|
|
112
|
+
raise APIError(400, "INVALID_GUARDS", msg, uuid.uuid4().hex) from exc
|
|
113
|
+
raise APIError(409, "LOOP_EXISTS", msg, uuid.uuid4().hex) from exc
|
|
114
|
+
await ensurer.ensure_session(row.session_id)
|
|
115
|
+
return {"loop": _row_dict(row)}
|
|
116
|
+
|
|
117
|
+
@router.get("/loops")
|
|
118
|
+
async def list_loops(request: Request) -> dict[str, Any]:
|
|
119
|
+
rows = await _loop_store(request).list_all()
|
|
120
|
+
return {"loops": [_row_dict(r) for r in rows]}
|
|
121
|
+
|
|
122
|
+
@router.get("/loops/{loop_id}")
|
|
123
|
+
async def get_loop(loop_id: str, request: Request) -> dict[str, Any]:
|
|
124
|
+
row = await _loop_store(request).get(loop_id)
|
|
125
|
+
if row is None:
|
|
126
|
+
raise APIError(404, "LOOP_NOT_FOUND", f"Unknown loop {loop_id}", uuid.uuid4().hex)
|
|
127
|
+
usage = await _storage_backend(request).usage().summary(
|
|
128
|
+
thread_id=row.session_id,
|
|
129
|
+
since_ms=row.started_at_ms,
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
"loop": _row_dict(row),
|
|
133
|
+
"usage": {
|
|
134
|
+
"session_id": row.session_id,
|
|
135
|
+
"since_ms": row.started_at_ms,
|
|
136
|
+
"turns": usage.turns,
|
|
137
|
+
"input_tokens": usage.input_tokens,
|
|
138
|
+
"output_tokens": usage.output_tokens,
|
|
139
|
+
"cached_tokens": usage.cached_tokens,
|
|
140
|
+
"cache_read_tokens": usage.cache_read_tokens,
|
|
141
|
+
"cache_creation_tokens": usage.cache_creation_tokens,
|
|
142
|
+
"cost_usd": usage.cost_usd,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@router.post("/loops/{loop_id}/pause")
|
|
147
|
+
async def pause_loop(loop_id: str, request: Request) -> dict[str, Any]:
|
|
148
|
+
ok = await _loop_store(request).pause(loop_id)
|
|
149
|
+
if not ok:
|
|
150
|
+
raise APIError(404, "LOOP_NOT_FOUND", f"Unknown loop {loop_id}", uuid.uuid4().hex)
|
|
151
|
+
return {"loop_id": loop_id, "status": "paused"}
|
|
152
|
+
|
|
153
|
+
@router.post("/loops/{loop_id}/resume")
|
|
154
|
+
async def resume_loop(loop_id: str, request: Request) -> dict[str, Any]:
|
|
155
|
+
ok = await _loop_store(request).resume(loop_id)
|
|
156
|
+
if not ok:
|
|
157
|
+
raise APIError(404, "LOOP_NOT_FOUND", f"Paused loop {loop_id} not found", uuid.uuid4().hex)
|
|
158
|
+
return {"loop_id": loop_id, "status": "active"}
|
|
159
|
+
|
|
160
|
+
@router.post("/loops/{loop_id}/stop")
|
|
161
|
+
async def stop_loop(loop_id: str, request: Request) -> dict[str, Any]:
|
|
162
|
+
ok = await _loop_store(request).stop(loop_id)
|
|
163
|
+
if not ok:
|
|
164
|
+
raise APIError(404, "LOOP_NOT_FOUND", f"Unknown loop {loop_id}", uuid.uuid4().hex)
|
|
165
|
+
return {"loop_id": loop_id, "status": "completed"}
|
|
166
|
+
|
|
167
|
+
@router.post("/invoke-tick")
|
|
168
|
+
async def invoke_tick_sync(
|
|
169
|
+
body: InvokeTickRequest,
|
|
170
|
+
request: Request,
|
|
171
|
+
reg_dep: SessionRegistry = Depends(_get_registry),
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Run one agent turn synchronously (scheduler worker / internal automation)."""
|
|
174
|
+
if reg_dep.get(body.session_id) is None:
|
|
175
|
+
await ensurer.ensure_session(body.session_id)
|
|
176
|
+
turn_locks = _storage_backend(request).session_turns()
|
|
177
|
+
acquired = await turn_locks.try_acquire(body.session_id, body.request_id)
|
|
178
|
+
if not acquired:
|
|
179
|
+
raise APIError(
|
|
180
|
+
409,
|
|
181
|
+
"SESSION_BUSY",
|
|
182
|
+
"Session already processing a request",
|
|
183
|
+
uuid.uuid4().hex,
|
|
184
|
+
)
|
|
185
|
+
bus = reg_dep.get(body.session_id)
|
|
186
|
+
if bus is not None:
|
|
187
|
+
bus.current_request_id = body.request_id
|
|
188
|
+
try:
|
|
189
|
+
await loop_port.start_turn(
|
|
190
|
+
body.session_id,
|
|
191
|
+
body.request_id,
|
|
192
|
+
[Text(text=body.message)],
|
|
193
|
+
)
|
|
194
|
+
finally:
|
|
195
|
+
await turn_locks.release(body.session_id, body.request_id)
|
|
196
|
+
if bus is not None and bus.current_request_id == body.request_id:
|
|
197
|
+
bus.current_request_id = None
|
|
198
|
+
return {"ok": True, "session_id": body.session_id, "request_id": body.request_id}
|
|
199
|
+
|
|
200
|
+
return router
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Gateway adapters for the scheduled-loop engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from monkeybot.core.persistence.backends import SessionTurnLockStore, StorageBackend
|
|
10
|
+
from monkeybot.core.types.content_blocks import ContentBlock
|
|
11
|
+
from monkeybot.gateway.sse.loop_port import LoopPort
|
|
12
|
+
from monkeybot.gateway.sse.session_bus import SessionAlreadyExistsError, SessionRegistry
|
|
13
|
+
from monkeybot.scheduler.tick_result import TickInvokeResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StorageSessionBusyChecker:
|
|
19
|
+
"""Check session busy state via durable storage (multi-replica safe)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, turn_locks: SessionTurnLockStore) -> None:
|
|
22
|
+
self._turn_locks = turn_locks
|
|
23
|
+
|
|
24
|
+
def is_busy(self, session_id: str) -> bool:
|
|
25
|
+
del session_id
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
async def is_busy_async(self, session_id: str) -> bool:
|
|
29
|
+
return await self._turn_locks.is_busy(session_id)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GatewaySessionBusyChecker:
|
|
33
|
+
"""Legacy in-process checker; prefer :class:`StorageSessionBusyChecker`."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, registry: SessionRegistry) -> None:
|
|
36
|
+
self._registry = registry
|
|
37
|
+
|
|
38
|
+
def is_busy(self, session_id: str) -> bool:
|
|
39
|
+
bus = self._registry.get(session_id)
|
|
40
|
+
return bus is not None and bus.current_request_id is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GatewaySessionEnsurer:
|
|
44
|
+
def __init__(self, registry: SessionRegistry) -> None:
|
|
45
|
+
self._registry = registry
|
|
46
|
+
|
|
47
|
+
async def ensure_session(self, session_id: str) -> None:
|
|
48
|
+
if self._registry.get(session_id) is not None:
|
|
49
|
+
return
|
|
50
|
+
agent_md = os.environ.get("AGENT_MD")
|
|
51
|
+
try:
|
|
52
|
+
self._registry.create(
|
|
53
|
+
session_id,
|
|
54
|
+
agent_md=agent_md,
|
|
55
|
+
created_at_ms=int(time.time() * 1000),
|
|
56
|
+
)
|
|
57
|
+
logger.info("scheduler created session_id=%s", session_id)
|
|
58
|
+
except SessionAlreadyExistsError:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GatewayTickInvoker:
|
|
63
|
+
"""Invoke ``LoopPort.start_turn`` and surface turn-level outcomes."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
loop_port: LoopPort,
|
|
68
|
+
registry: SessionRegistry,
|
|
69
|
+
turn_locks: SessionTurnLockStore,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._loop_port = loop_port
|
|
72
|
+
self._registry = registry
|
|
73
|
+
self._turn_locks = turn_locks
|
|
74
|
+
|
|
75
|
+
async def invoke_tick(
|
|
76
|
+
self,
|
|
77
|
+
session_id: str,
|
|
78
|
+
request_id: str,
|
|
79
|
+
user_content: list[ContentBlock],
|
|
80
|
+
) -> TickInvokeResult:
|
|
81
|
+
acquired = await self._turn_locks.try_acquire(session_id, request_id)
|
|
82
|
+
if not acquired:
|
|
83
|
+
return TickInvokeResult.session_busy()
|
|
84
|
+
bus = self._registry.get(session_id)
|
|
85
|
+
if bus is not None:
|
|
86
|
+
bus.current_request_id = request_id
|
|
87
|
+
try:
|
|
88
|
+
await self._loop_port.start_turn(session_id, request_id, user_content)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
return TickInvokeResult.fail(str(exc))
|
|
91
|
+
finally:
|
|
92
|
+
await self._turn_locks.release(session_id, request_id)
|
|
93
|
+
bus = self._registry.get(session_id)
|
|
94
|
+
if bus is not None and bus.current_request_id == request_id:
|
|
95
|
+
bus.current_request_id = None
|
|
96
|
+
return TickInvokeResult.ok()
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory per-session SSE bus with replay buffer and live subscribers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
from collections import deque
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
|
|
13
|
+
from monkeybot.core.persistence.transcript import TranscriptWriter
|
|
14
|
+
from monkeybot.core.runtime.input_admission import InputAdmission
|
|
15
|
+
from monkeybot.core.tools.permission import SessionApprovals
|
|
16
|
+
|
|
17
|
+
from .sse import format_data_event
|
|
18
|
+
|
|
19
|
+
PENDING_RESPONSE_TIMEOUT_SEC: float = float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _replay_maxlen_from_env() -> int:
|
|
23
|
+
raw = os.environ.get("SSE_REPLAY_MAX", "256")
|
|
24
|
+
try:
|
|
25
|
+
n = int(raw)
|
|
26
|
+
return max(1, n)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return 256
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SessionAlreadyExistsError(Exception):
|
|
32
|
+
"""Raised when POST /sessions repeats an existing client-supplied id."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionBus:
|
|
36
|
+
"""Broadcasts framed SSE events; buffers numbered data events for replay."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
created_at_ms: int,
|
|
42
|
+
agent_md: str | None,
|
|
43
|
+
replay_maxlen: int | None = None,
|
|
44
|
+
provider: Any | None = None,
|
|
45
|
+
model_name: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.created_at_ms = created_at_ms
|
|
48
|
+
self.agent_md = agent_md
|
|
49
|
+
self.provider = provider
|
|
50
|
+
self.model_name = model_name
|
|
51
|
+
self.current_request_id: str | None = None
|
|
52
|
+
self.cancel_requested_for: str | None = None
|
|
53
|
+
self._seq = 0
|
|
54
|
+
maxlen = replay_maxlen if replay_maxlen is not None else _replay_maxlen_from_env()
|
|
55
|
+
self._replay: deque[tuple[int, str]] = deque(maxlen=maxlen)
|
|
56
|
+
self._subscribers: set[asyncio.Queue[str]] = set()
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
self.pending_responses: dict[str, asyncio.Future[Any]] = {}
|
|
59
|
+
self.terminated_pending_keys: deque[str] = deque(maxlen=256)
|
|
60
|
+
self.attachment_catalog: SessionAttachmentCatalog | None = None
|
|
61
|
+
self.transcript_writer: TranscriptWriter | None = None
|
|
62
|
+
"""Lazily-created ``TranscriptWriter`` (internal debugging only); None when disabled."""
|
|
63
|
+
self.admission = InputAdmission()
|
|
64
|
+
"""Process-local steer + follow-up queues (not shared across gateway replicas)."""
|
|
65
|
+
self.session_approvals = SessionApprovals()
|
|
66
|
+
"""Process-local 'always allow' rememberies (not shared across gateway replicas)."""
|
|
67
|
+
self.follow_up_retry_task: asyncio.Task[None] | None = None
|
|
68
|
+
"""Scheduled drain retry after a failed durable turn-lock acquire."""
|
|
69
|
+
|
|
70
|
+
def cancel_follow_up_retry(self) -> None:
|
|
71
|
+
"""Cancel any pending follow-up lock-retry task."""
|
|
72
|
+
task = self.follow_up_retry_task
|
|
73
|
+
self.follow_up_retry_task = None
|
|
74
|
+
if task is not None and not task.done():
|
|
75
|
+
task.cancel()
|
|
76
|
+
|
|
77
|
+
def register_pending(self, pending_key: str) -> asyncio.Future[Any]:
|
|
78
|
+
fut = asyncio.get_running_loop().create_future()
|
|
79
|
+
self.pending_responses[pending_key] = fut
|
|
80
|
+
return fut
|
|
81
|
+
|
|
82
|
+
def resolve_pending(self, pending_key: str, payload: Any) -> bool:
|
|
83
|
+
fut = self.pending_responses.get(pending_key)
|
|
84
|
+
if fut is None or fut.done():
|
|
85
|
+
return False
|
|
86
|
+
fut.set_result(payload)
|
|
87
|
+
self.pending_responses.pop(pending_key, None)
|
|
88
|
+
self.terminated_pending_keys.append(pending_key)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def abandon_pending_timeout(self, pending_key: str) -> None:
|
|
92
|
+
fut = self.pending_responses.pop(pending_key, None)
|
|
93
|
+
if fut is None:
|
|
94
|
+
return
|
|
95
|
+
if not fut.done():
|
|
96
|
+
fut.cancel()
|
|
97
|
+
self.terminated_pending_keys.append(pending_key)
|
|
98
|
+
|
|
99
|
+
def abandon_pending_cancel_all(self) -> None:
|
|
100
|
+
for pending_key in list(self.pending_responses.keys()):
|
|
101
|
+
fut = self.pending_responses.pop(pending_key, None)
|
|
102
|
+
if fut is not None and not fut.done():
|
|
103
|
+
fut.cancel()
|
|
104
|
+
self.terminated_pending_keys.append(pending_key)
|
|
105
|
+
|
|
106
|
+
def is_pending_or_terminal(self, pending_key: str) -> Literal["pending", "terminated", "unknown"]:
|
|
107
|
+
if pending_key in self.pending_responses:
|
|
108
|
+
return "pending"
|
|
109
|
+
if pending_key in self.terminated_pending_keys:
|
|
110
|
+
return "terminated"
|
|
111
|
+
return "unknown"
|
|
112
|
+
|
|
113
|
+
async def publish_data(self, data_json: str) -> int:
|
|
114
|
+
"""Buffer and broadcast one JSON data event; returns monotonic sequence id."""
|
|
115
|
+
async with self._lock:
|
|
116
|
+
self._seq += 1
|
|
117
|
+
seq = self._seq
|
|
118
|
+
frame = format_data_event(seq, data_json)
|
|
119
|
+
self._replay.append((seq, frame))
|
|
120
|
+
subscribers = list(self._subscribers)
|
|
121
|
+
for q in subscribers:
|
|
122
|
+
await q.put(frame)
|
|
123
|
+
return seq
|
|
124
|
+
|
|
125
|
+
async def publish_comment(self, comment_line: str) -> None:
|
|
126
|
+
"""Send a comment/heartbeat line to live subscribers only (no replay)."""
|
|
127
|
+
async with self._lock:
|
|
128
|
+
subscribers = list(self._subscribers)
|
|
129
|
+
for q in subscribers:
|
|
130
|
+
await q.put(comment_line)
|
|
131
|
+
|
|
132
|
+
async def subscribe(
|
|
133
|
+
self, last_event_id: int | None
|
|
134
|
+
) -> tuple[list[str], asyncio.Queue[str]]:
|
|
135
|
+
"""
|
|
136
|
+
Register a subscriber and return buffered frames after last_event_id.
|
|
137
|
+
|
|
138
|
+
If last_event_id is None, replay all buffered frames (seq > 0).
|
|
139
|
+
"""
|
|
140
|
+
async with self._lock:
|
|
141
|
+
q: asyncio.Queue[str] = asyncio.Queue()
|
|
142
|
+
self._subscribers.add(q)
|
|
143
|
+
cutoff = last_event_id if last_event_id is not None else 0
|
|
144
|
+
replay_frames = [frame for seq, frame in self._replay if seq > cutoff]
|
|
145
|
+
return replay_frames, q
|
|
146
|
+
|
|
147
|
+
async def unsubscribe(self, queue: asyncio.Queue[str]) -> None:
|
|
148
|
+
"""Remove a subscriber queue (call from SSE disconnect finally)."""
|
|
149
|
+
async with self._lock:
|
|
150
|
+
self._subscribers.discard(queue)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _await_user_response(
|
|
154
|
+
bus: SessionBus,
|
|
155
|
+
*,
|
|
156
|
+
pending_key: str,
|
|
157
|
+
timeout_sec: float | None = None,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Block until POST resolves *pending_key*, timeout, cancel, or disconnect policy.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Normal POST payloads (structure depends on flow).
|
|
163
|
+
On timeout: ``{"_timeout": True}`` (sentinel).
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
asyncio.CancelledError: When the backing Future was cancelled (Stop button path).
|
|
167
|
+
"""
|
|
168
|
+
from monkeybot.core.runtime.loop import _await_user_response_any
|
|
169
|
+
|
|
170
|
+
fut = bus.pending_responses[pending_key]
|
|
171
|
+
return await _await_user_response_any(bus, fut, pending_key, timeout_sec=timeout_sec)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SessionRegistry:
|
|
175
|
+
"""Process-local registry of SessionBus instances."""
|
|
176
|
+
|
|
177
|
+
def __init__(self) -> None:
|
|
178
|
+
self._sessions: dict[str, SessionBus] = {} # ponytail: in-process registry, use Redis pub/sub for multi-instance deployments
|
|
179
|
+
|
|
180
|
+
def get(self, session_id: str) -> SessionBus | None:
|
|
181
|
+
"""Return the bus for id or None."""
|
|
182
|
+
return self._sessions.get(session_id)
|
|
183
|
+
|
|
184
|
+
def create(
|
|
185
|
+
self,
|
|
186
|
+
session_id: str,
|
|
187
|
+
*,
|
|
188
|
+
agent_md: str | None,
|
|
189
|
+
created_at_ms: int,
|
|
190
|
+
provider: Any | None = None,
|
|
191
|
+
model_name: str | None = None,
|
|
192
|
+
) -> SessionBus:
|
|
193
|
+
"""Create a new session bus or raise SessionAlreadyExistsError."""
|
|
194
|
+
if session_id in self._sessions:
|
|
195
|
+
raise SessionAlreadyExistsError(session_id)
|
|
196
|
+
bus = SessionBus(
|
|
197
|
+
created_at_ms=created_at_ms,
|
|
198
|
+
agent_md=agent_md,
|
|
199
|
+
provider=provider,
|
|
200
|
+
model_name=model_name,
|
|
201
|
+
)
|
|
202
|
+
bus.attachment_catalog = SessionAttachmentCatalog(session_id=session_id)
|
|
203
|
+
self._sessions[session_id] = bus
|
|
204
|
+
return bus
|
|
205
|
+
|
|
206
|
+
def remove(self, session_id: str) -> bool:
|
|
207
|
+
"""Drop a session bus and any auxiliary per-session state keyed by it.
|
|
208
|
+
|
|
209
|
+
Cancels outstanding pending-response futures so awaiting callers don't
|
|
210
|
+
hang, then evicts the memory-curation cache entry for this thread id
|
|
211
|
+
(see ``memory_prompt._curation_cache``) so both structures share the
|
|
212
|
+
same lifecycle instead of growing unbounded for the life of the process.
|
|
213
|
+
|
|
214
|
+
Returns True if a session was found and removed, False otherwise.
|
|
215
|
+
"""
|
|
216
|
+
bus = self._sessions.pop(session_id, None)
|
|
217
|
+
if bus is None:
|
|
218
|
+
return False
|
|
219
|
+
bus.abandon_pending_cancel_all()
|
|
220
|
+
bus.cancel_follow_up_retry()
|
|
221
|
+
bus.admission.clear_all()
|
|
222
|
+
|
|
223
|
+
from monkeybot.core.context.memory_prompt import evict_curation_cache
|
|
224
|
+
|
|
225
|
+
evict_curation_cache(session_id)
|
|
226
|
+
return True
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSE wire framing and mapping from agent events (kind) to SSE JSON (type).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import fields as dc_fields
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from monkeybot.core.runtime.events import AgentEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_data_event(seq: int, data: str) -> str:
|
|
13
|
+
"""Build one SSE data event with an `id` line (stored in replay buffer)."""
|
|
14
|
+
return f"id: {seq}\ndata: {data}\n\n"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_ping(n: int) -> str:
|
|
18
|
+
"""Heartbeat comment line; not buffered for replay."""
|
|
19
|
+
return f": ping {n}\n\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_active_requests(request_ids: list[str]) -> str:
|
|
23
|
+
"""ActiveRequests frame without an `id:` line."""
|
|
24
|
+
payload = json_dumps_wire({"type": "ActiveRequests", "request_ids": request_ids})
|
|
25
|
+
return f"data: {payload}\n\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def agent_event_to_wire_dict(event: AgentEvent) -> dict[str, Any]:
|
|
29
|
+
"""Map an AgentEvent to an SSE JSON dict with ``type`` + ``chat_request_id``."""
|
|
30
|
+
kind = str(getattr(event, "kind"))
|
|
31
|
+
payload = {
|
|
32
|
+
f.name: getattr(event, f.name)
|
|
33
|
+
for f in dc_fields(cast(Any, event))
|
|
34
|
+
if f.name != "kind"
|
|
35
|
+
}
|
|
36
|
+
rid = str(payload.get("request_id", ""))
|
|
37
|
+
out: dict[str, Any] = {"type": kind, "request_id": rid, "chat_request_id": rid}
|
|
38
|
+
for key, val in payload.items():
|
|
39
|
+
if key != "request_id":
|
|
40
|
+
out[key] = val
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def json_dumps_wire(obj: dict[str, Any]) -> str:
|
|
45
|
+
"""Stable JSON for SSE data lines (no extra whitespace)."""
|
|
46
|
+
return json.dumps(obj, separators=(",", ":"))
|