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,1101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI routes and app factory for the v2 SSE gateway.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import AsyncIterator, Callable
|
|
13
|
+
from contextlib import AbstractAsyncContextManager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, FastAPI, File, Request, Response, UploadFile
|
|
18
|
+
from fastapi.exceptions import RequestValidationError
|
|
19
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
20
|
+
|
|
21
|
+
from monkeybot.core.attachments.config import (
|
|
22
|
+
ALLOWED_MIME_TYPES,
|
|
23
|
+
attachments_enabled_from_env,
|
|
24
|
+
)
|
|
25
|
+
from monkeybot.core.attachments.store import (
|
|
26
|
+
AttachmentSessionLimitError,
|
|
27
|
+
AttachmentStore,
|
|
28
|
+
AttachmentTooLargeError,
|
|
29
|
+
UnsupportedAttachmentTypeError,
|
|
30
|
+
)
|
|
31
|
+
from monkeybot.core.logging_utils import kv
|
|
32
|
+
from monkeybot.core.runtime.context_budget import summarization_trigger_ratio_from_env
|
|
33
|
+
from monkeybot.core.runtime.events import QueuedInputAccepted, event_to_json
|
|
34
|
+
from monkeybot.core.runtime.input_admission import AdmissionQueueFullError, FollowUpItem
|
|
35
|
+
from monkeybot.core.tools.workspace_service import WorkspaceError, WorkspaceFileService
|
|
36
|
+
from monkeybot.core.types.content_blocks import ContentBlock
|
|
37
|
+
|
|
38
|
+
from .loop_port import LoopPort, UsagePort
|
|
39
|
+
from .models import (
|
|
40
|
+
APIError,
|
|
41
|
+
AdmissionAcceptedResponse,
|
|
42
|
+
AttachmentUploadResponse,
|
|
43
|
+
CancelRequest,
|
|
44
|
+
CreateSessionRequest,
|
|
45
|
+
CreateSessionResponse,
|
|
46
|
+
ElicitationPOST,
|
|
47
|
+
FrontendToolResultPOST,
|
|
48
|
+
HealthResponse,
|
|
49
|
+
QueueRequest,
|
|
50
|
+
ReplyBodyFields,
|
|
51
|
+
ReplyRequest,
|
|
52
|
+
ReplyResponse,
|
|
53
|
+
SessionUsageResponse,
|
|
54
|
+
SteerRequest,
|
|
55
|
+
ToolConfirmationPOST,
|
|
56
|
+
error_payload_dict,
|
|
57
|
+
)
|
|
58
|
+
from .reply_body import ReplyBodyError, normalize_reply_to_user_content
|
|
59
|
+
from .scheduler_routes import build_scheduler_router
|
|
60
|
+
from .session_bus import SessionAlreadyExistsError, SessionBus, SessionRegistry
|
|
61
|
+
from .sse import format_active_requests, format_ping
|
|
62
|
+
from .workspace_layout import resolve_agent_workspace_root
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_registry(request: Request) -> SessionRegistry:
|
|
68
|
+
"""FastAPI dependency returning the process-local session registry."""
|
|
69
|
+
return cast(SessionRegistry, request.app.state.registry)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _attachment_store(request: Request) -> AttachmentStore | None:
|
|
73
|
+
return getattr(request.app.state, "attachment_store", None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _default_loop_port(registry: SessionRegistry) -> LoopPort:
|
|
77
|
+
"""Fallback loop that only clears busy state (no events); wire a real LoopPort in production."""
|
|
78
|
+
|
|
79
|
+
class _DefaultLoop:
|
|
80
|
+
async def start_turn(
|
|
81
|
+
self,
|
|
82
|
+
session_id: str,
|
|
83
|
+
request_id: str,
|
|
84
|
+
user_content: list[ContentBlock],
|
|
85
|
+
) -> None:
|
|
86
|
+
_ = (request_id, user_content)
|
|
87
|
+
bus = registry.get(session_id)
|
|
88
|
+
if bus is not None:
|
|
89
|
+
bus.current_request_id = None
|
|
90
|
+
|
|
91
|
+
return _DefaultLoop()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_bus(reg: SessionRegistry, session_id: str) -> SessionBus:
|
|
95
|
+
bus = reg.get(session_id)
|
|
96
|
+
if bus is None:
|
|
97
|
+
raise APIError(
|
|
98
|
+
404,
|
|
99
|
+
"SESSION_NOT_FOUND",
|
|
100
|
+
"Unknown session",
|
|
101
|
+
uuid.uuid4().hex,
|
|
102
|
+
)
|
|
103
|
+
return bus
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_user_content(
|
|
107
|
+
*,
|
|
108
|
+
body: ReplyBodyFields,
|
|
109
|
+
session_id: str,
|
|
110
|
+
request: Request,
|
|
111
|
+
) -> list[ContentBlock]:
|
|
112
|
+
try:
|
|
113
|
+
return normalize_reply_to_user_content(
|
|
114
|
+
message=body.message,
|
|
115
|
+
content=body.content,
|
|
116
|
+
session_id=session_id,
|
|
117
|
+
attachment_store=_attachment_store(request),
|
|
118
|
+
)
|
|
119
|
+
except ReplyBodyError as exc:
|
|
120
|
+
status = 404 if exc.code == "ATTACHMENT_NOT_FOUND" else 400
|
|
121
|
+
raise APIError(status, exc.code, str(exc), uuid.uuid4().hex) from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _try_acquire_turn(
|
|
125
|
+
*,
|
|
126
|
+
bus: SessionBus,
|
|
127
|
+
storage: Any,
|
|
128
|
+
session_id: str,
|
|
129
|
+
request_id: str,
|
|
130
|
+
busy_is_error: bool,
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""Acquire the session turn lock. Return True if acquired.
|
|
133
|
+
|
|
134
|
+
When ``busy_is_error`` is True, raise ``SESSION_BUSY`` instead of returning False.
|
|
135
|
+
"""
|
|
136
|
+
if storage is None:
|
|
137
|
+
if bus.current_request_id is not None:
|
|
138
|
+
if busy_is_error:
|
|
139
|
+
raise APIError(
|
|
140
|
+
409,
|
|
141
|
+
"SESSION_BUSY",
|
|
142
|
+
"Session already processing a request",
|
|
143
|
+
uuid.uuid4().hex,
|
|
144
|
+
)
|
|
145
|
+
return False
|
|
146
|
+
return True
|
|
147
|
+
acquired = await storage.session_turns().try_acquire(session_id, request_id)
|
|
148
|
+
if acquired:
|
|
149
|
+
return True
|
|
150
|
+
if busy_is_error:
|
|
151
|
+
raise APIError(
|
|
152
|
+
409,
|
|
153
|
+
"SESSION_BUSY",
|
|
154
|
+
"Session already processing a request",
|
|
155
|
+
uuid.uuid4().hex,
|
|
156
|
+
)
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _schedule_turn(
|
|
161
|
+
*,
|
|
162
|
+
bus: SessionBus,
|
|
163
|
+
loop_ref: LoopPort,
|
|
164
|
+
storage: Any,
|
|
165
|
+
session_id: str,
|
|
166
|
+
request_id: str,
|
|
167
|
+
user_content: list[ContentBlock],
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Background a turn and drain follow-up queue when it finishes."""
|
|
170
|
+
|
|
171
|
+
async def _turn() -> None:
|
|
172
|
+
try:
|
|
173
|
+
await loop_ref.start_turn(session_id, request_id, user_content)
|
|
174
|
+
finally:
|
|
175
|
+
if storage is not None:
|
|
176
|
+
await storage.session_turns().release(session_id, request_id)
|
|
177
|
+
if bus.current_request_id == request_id:
|
|
178
|
+
bus.current_request_id = None
|
|
179
|
+
await _drain_follow_up(
|
|
180
|
+
bus=bus,
|
|
181
|
+
loop_ref=loop_ref,
|
|
182
|
+
storage=storage,
|
|
183
|
+
session_id=session_id,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
asyncio.create_task(_turn())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _drain_follow_up(
|
|
190
|
+
*,
|
|
191
|
+
bus: SessionBus,
|
|
192
|
+
loop_ref: LoopPort,
|
|
193
|
+
storage: Any,
|
|
194
|
+
session_id: str,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Start the next queued follow-up if the session is idle.
|
|
197
|
+
|
|
198
|
+
Queues are process-local (same as ``SessionBus``); multi-replica gateways
|
|
199
|
+
do not share steer/follow-up state across instances.
|
|
200
|
+
|
|
201
|
+
When the durable turn lock cannot be acquired (e.g. held by another replica
|
|
202
|
+
or a crashed claim that has not yet gone stale), the item is requeued and a
|
|
203
|
+
delayed retry is scheduled. After waiting longer than the session-turn stale
|
|
204
|
+
window the item is dropped so the queue cannot wedge permanently.
|
|
205
|
+
"""
|
|
206
|
+
if bus.current_request_id is not None:
|
|
207
|
+
return
|
|
208
|
+
item = bus.admission.pop_follow_up()
|
|
209
|
+
if item is None:
|
|
210
|
+
return
|
|
211
|
+
if storage is not None:
|
|
212
|
+
acquired = await storage.session_turns().try_acquire(
|
|
213
|
+
session_id, item.request_id
|
|
214
|
+
)
|
|
215
|
+
if not acquired:
|
|
216
|
+
now_ms = int(time.time() * 1000)
|
|
217
|
+
first_fail = item.first_lock_fail_at_ms or now_ms
|
|
218
|
+
waited_ms = now_ms - first_fail
|
|
219
|
+
give_up_ms = _follow_up_lock_wait_ms()
|
|
220
|
+
if waited_ms >= give_up_ms:
|
|
221
|
+
logger.error(
|
|
222
|
+
"follow-up dropped; turn lock held past wait budget %s",
|
|
223
|
+
kv(
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
request_id=item.request_id,
|
|
226
|
+
waited_ms=waited_ms,
|
|
227
|
+
give_up_ms=give_up_ms,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
# Continue with the next queued item (if any).
|
|
231
|
+
await _drain_follow_up(
|
|
232
|
+
bus=bus,
|
|
233
|
+
loop_ref=loop_ref,
|
|
234
|
+
storage=storage,
|
|
235
|
+
session_id=session_id,
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
logger.warning(
|
|
239
|
+
"follow-up requeued; lock held elsewhere %s",
|
|
240
|
+
kv(
|
|
241
|
+
session_id=session_id,
|
|
242
|
+
request_id=item.request_id,
|
|
243
|
+
waited_ms=waited_ms,
|
|
244
|
+
retry_s=_follow_up_lock_retry_s(),
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
bus.admission.requeue_follow_up_front(
|
|
248
|
+
FollowUpItem(
|
|
249
|
+
request_id=item.request_id,
|
|
250
|
+
content=item.content,
|
|
251
|
+
first_lock_fail_at_ms=first_fail,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
_schedule_follow_up_retry(
|
|
255
|
+
bus=bus,
|
|
256
|
+
loop_ref=loop_ref,
|
|
257
|
+
storage=storage,
|
|
258
|
+
session_id=session_id,
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
bus.cancel_follow_up_retry()
|
|
262
|
+
bus.current_request_id = item.request_id
|
|
263
|
+
logger.info(
|
|
264
|
+
"follow-up promoted %s",
|
|
265
|
+
kv(session_id=session_id, request_id=item.request_id),
|
|
266
|
+
)
|
|
267
|
+
_schedule_turn(
|
|
268
|
+
bus=bus,
|
|
269
|
+
loop_ref=loop_ref,
|
|
270
|
+
storage=storage,
|
|
271
|
+
session_id=session_id,
|
|
272
|
+
request_id=item.request_id,
|
|
273
|
+
user_content=item.content,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _follow_up_lock_retry_s() -> float:
|
|
278
|
+
"""Delay between follow-up drain retries when the turn lock is held."""
|
|
279
|
+
raw = os.environ.get("MONKEYBOT_FOLLOW_UP_LOCK_RETRY_S", "").strip()
|
|
280
|
+
if not raw:
|
|
281
|
+
return 1.0
|
|
282
|
+
try:
|
|
283
|
+
return max(0.05, float(raw))
|
|
284
|
+
except ValueError:
|
|
285
|
+
return 1.0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _follow_up_lock_wait_ms() -> int:
|
|
289
|
+
"""Max time to retry a follow-up blocked on the durable turn lock.
|
|
290
|
+
|
|
291
|
+
Defaults to the session-turn stale window so a crashed claim can expire and
|
|
292
|
+
be released on the next ``try_acquire`` before we give up.
|
|
293
|
+
"""
|
|
294
|
+
raw = os.environ.get("MONKEYBOT_FOLLOW_UP_LOCK_WAIT_MS", "").strip()
|
|
295
|
+
if raw:
|
|
296
|
+
try:
|
|
297
|
+
return max(1, int(raw))
|
|
298
|
+
except ValueError:
|
|
299
|
+
pass
|
|
300
|
+
try:
|
|
301
|
+
from monkeybot.core.persistence.session_turn_locks import session_turn_stale_ms
|
|
302
|
+
|
|
303
|
+
return session_turn_stale_ms()
|
|
304
|
+
except Exception:
|
|
305
|
+
return 600_000
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _schedule_follow_up_retry(
|
|
309
|
+
*,
|
|
310
|
+
bus: SessionBus,
|
|
311
|
+
loop_ref: LoopPort,
|
|
312
|
+
storage: Any,
|
|
313
|
+
session_id: str,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Schedule a single delayed ``_drain_follow_up`` (deduped per bus).
|
|
316
|
+
|
|
317
|
+
When called from inside the active retry task (lock still held after a drain
|
|
318
|
+
attempt), replace that task so another delay is scheduled after we return.
|
|
319
|
+
"""
|
|
320
|
+
existing = bus.follow_up_retry_task
|
|
321
|
+
current = asyncio.current_task()
|
|
322
|
+
if existing is not None and not existing.done() and existing is not current:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
async def _retry() -> None:
|
|
326
|
+
try:
|
|
327
|
+
await asyncio.sleep(_follow_up_lock_retry_s())
|
|
328
|
+
await _drain_follow_up(
|
|
329
|
+
bus=bus,
|
|
330
|
+
loop_ref=loop_ref,
|
|
331
|
+
storage=storage,
|
|
332
|
+
session_id=session_id,
|
|
333
|
+
)
|
|
334
|
+
except asyncio.CancelledError:
|
|
335
|
+
raise
|
|
336
|
+
finally:
|
|
337
|
+
if bus.follow_up_retry_task is asyncio.current_task():
|
|
338
|
+
bus.follow_up_retry_task = None
|
|
339
|
+
|
|
340
|
+
bus.follow_up_retry_task = asyncio.create_task(_retry())
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def _publish_admission_accepted(
|
|
344
|
+
bus: SessionBus,
|
|
345
|
+
*,
|
|
346
|
+
request_id: str,
|
|
347
|
+
queue: Literal["steer", "follow_up"],
|
|
348
|
+
position: int,
|
|
349
|
+
) -> AdmissionAcceptedResponse:
|
|
350
|
+
await bus.publish_data(
|
|
351
|
+
event_to_json(
|
|
352
|
+
QueuedInputAccepted(
|
|
353
|
+
request_id=request_id,
|
|
354
|
+
queue=queue,
|
|
355
|
+
position=position,
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
logger.info(
|
|
360
|
+
"admission accepted %s",
|
|
361
|
+
kv(request_id=request_id, queue=queue, position=position),
|
|
362
|
+
)
|
|
363
|
+
return AdmissionAcceptedResponse(
|
|
364
|
+
request_id=request_id, queue=queue, position=position
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class _StaticUsagePort:
|
|
369
|
+
"""UsagePort that returns zeroed aggregates (Story 7 placeholder)."""
|
|
370
|
+
|
|
371
|
+
async def session_usage(
|
|
372
|
+
self,
|
|
373
|
+
session_id: str,
|
|
374
|
+
*,
|
|
375
|
+
since: str | None,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
_ = since
|
|
378
|
+
cap_raw = os.environ.get("MODEL_CONTEXT_WINDOW", "200000").strip()
|
|
379
|
+
try:
|
|
380
|
+
cw = max(1, int(cap_raw))
|
|
381
|
+
except ValueError:
|
|
382
|
+
cw = 200_000
|
|
383
|
+
st = max(1, int(cw * summarization_trigger_ratio_from_env()))
|
|
384
|
+
return {
|
|
385
|
+
"session_id": session_id,
|
|
386
|
+
"turns": 0,
|
|
387
|
+
"input_tokens": 0,
|
|
388
|
+
"output_tokens": 0,
|
|
389
|
+
"cached_tokens": 0,
|
|
390
|
+
"cache_read_tokens": 0,
|
|
391
|
+
"cache_creation_tokens": 0,
|
|
392
|
+
"cost_usd": 0.0,
|
|
393
|
+
"period_start": 0,
|
|
394
|
+
"period_end": 0,
|
|
395
|
+
"last_prompt_tokens": 0,
|
|
396
|
+
"estimated_prompt_tokens": 0,
|
|
397
|
+
"summarization_threshold_tokens": st,
|
|
398
|
+
"context_window_tokens": cw,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def _ping_loop(bus: SessionBus) -> None:
|
|
403
|
+
"""Emit `: ping N` heartbeats every 0.5s until cancelled."""
|
|
404
|
+
n = 0
|
|
405
|
+
try:
|
|
406
|
+
while True:
|
|
407
|
+
await asyncio.sleep(0.5)
|
|
408
|
+
n += 1
|
|
409
|
+
await bus.publish_comment(format_ping(n))
|
|
410
|
+
except asyncio.CancelledError:
|
|
411
|
+
raise
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _parse_last_event_id(request: Request) -> int | None:
|
|
415
|
+
raw = request.headers.get("last-event-id")
|
|
416
|
+
if raw is None or raw == "":
|
|
417
|
+
return None
|
|
418
|
+
try:
|
|
419
|
+
return int(raw)
|
|
420
|
+
except ValueError:
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _workspace_api_enabled() -> bool:
|
|
425
|
+
"""Opt out with ``MONKEYBOT_WORKSPACE_API=0`` (or ``false`` / ``no`` / ``off``)."""
|
|
426
|
+
v = os.environ.get("MONKEYBOT_WORKSPACE_API", "1").strip().lower()
|
|
427
|
+
return v not in ("0", "false", "no", "off")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _api_workspace_root() -> Path:
|
|
431
|
+
"""Workspace root for listing/reads; aligned with :func:`resolve_agent_workspace_root`."""
|
|
432
|
+
return resolve_agent_workspace_root()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _workspace_exc_to_api(exc: WorkspaceError) -> APIError:
|
|
436
|
+
rid = uuid.uuid4().hex
|
|
437
|
+
if exc.code == "not_found":
|
|
438
|
+
return APIError(404, "NOT_FOUND", str(exc), rid)
|
|
439
|
+
return APIError(400, "BAD_REQUEST", str(exc), rid)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _chat_history_api_enabled() -> bool:
|
|
443
|
+
"""Opt out with ``MONKEYBOT_CHAT_HISTORY_API=0``."""
|
|
444
|
+
v = os.environ.get("MONKEYBOT_CHAT_HISTORY_API", "1").strip().lower()
|
|
445
|
+
return v not in ("0", "false", "no", "off")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _storage_backend(request: Request) -> Any:
|
|
449
|
+
backend = getattr(request.app.state, "storage", None)
|
|
450
|
+
if backend is None:
|
|
451
|
+
raise APIError(
|
|
452
|
+
503,
|
|
453
|
+
"STORAGE_UNAVAILABLE",
|
|
454
|
+
"Storage backend is not initialized",
|
|
455
|
+
uuid.uuid4().hex,
|
|
456
|
+
)
|
|
457
|
+
return backend
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def create_app(
|
|
464
|
+
*,
|
|
465
|
+
loop_port: LoopPort | None = None,
|
|
466
|
+
usage_port: UsagePort | None = None,
|
|
467
|
+
registry: SessionRegistry | None = None,
|
|
468
|
+
lifespan: Lifespan | None = None,
|
|
469
|
+
) -> FastAPI:
|
|
470
|
+
"""
|
|
471
|
+
Build a FastAPI app with v2 SSE routes.
|
|
472
|
+
|
|
473
|
+
For tests, pass FakeLoopPort / custom UsagePort. Story 8 wires the real loop.
|
|
474
|
+
"""
|
|
475
|
+
reg = registry or SessionRegistry()
|
|
476
|
+
loop = loop_port or _default_loop_port(reg)
|
|
477
|
+
usage = usage_port or _StaticUsagePort()
|
|
478
|
+
|
|
479
|
+
app = FastAPI(
|
|
480
|
+
title="monkeybot v2 Gateway",
|
|
481
|
+
version="2.0.0",
|
|
482
|
+
lifespan=lifespan,
|
|
483
|
+
)
|
|
484
|
+
app.state.registry = reg
|
|
485
|
+
app.state.loop = loop
|
|
486
|
+
app.state.usage = usage
|
|
487
|
+
if not hasattr(app.state, "attachment_store"):
|
|
488
|
+
app.state.attachment_store = None
|
|
489
|
+
|
|
490
|
+
@app.exception_handler(APIError)
|
|
491
|
+
async def api_error_handler(_request: Request, exc: APIError) -> JSONResponse:
|
|
492
|
+
return JSONResponse(
|
|
493
|
+
status_code=exc.status_code,
|
|
494
|
+
content=error_payload_dict(exc.code, exc.message, exc.request_id),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
@app.exception_handler(RequestValidationError)
|
|
498
|
+
async def validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
499
|
+
rid = uuid.uuid4().hex
|
|
500
|
+
errors = exc.errors()
|
|
501
|
+
message = "; ".join(str(e.get("msg", "")) for e in errors) or "Validation error"
|
|
502
|
+
return JSONResponse(
|
|
503
|
+
status_code=400,
|
|
504
|
+
content=error_payload_dict("BAD_REQUEST", message, rid),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
api = APIRouter()
|
|
508
|
+
|
|
509
|
+
@api.post("/sessions", status_code=201, response_model=CreateSessionResponse)
|
|
510
|
+
async def create_session(
|
|
511
|
+
body: CreateSessionRequest,
|
|
512
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
513
|
+
) -> CreateSessionResponse:
|
|
514
|
+
"""Create a session and its event bus."""
|
|
515
|
+
created_at_ms = int(time.time() * 1000)
|
|
516
|
+
sid = body.session_id or str(uuid.uuid4())
|
|
517
|
+
session_provider = None
|
|
518
|
+
session_model = None
|
|
519
|
+
if body.model_provider or body.model_name:
|
|
520
|
+
from monkeybot.core.config.settings import get_provider_config
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
cfg = get_provider_config(
|
|
524
|
+
provider=body.model_provider,
|
|
525
|
+
model_name=body.model_name,
|
|
526
|
+
)
|
|
527
|
+
session_provider = cfg.provider
|
|
528
|
+
session_model = cfg.model
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
raise APIError(
|
|
531
|
+
400,
|
|
532
|
+
"MODEL_UNAVAILABLE",
|
|
533
|
+
f"Model provider '{body.model_provider}' unavailable: {exc}",
|
|
534
|
+
uuid.uuid4().hex,
|
|
535
|
+
) from exc
|
|
536
|
+
try:
|
|
537
|
+
reg_dep.create(
|
|
538
|
+
sid,
|
|
539
|
+
agent_md=body.agent_md,
|
|
540
|
+
created_at_ms=created_at_ms,
|
|
541
|
+
provider=session_provider,
|
|
542
|
+
model_name=session_model,
|
|
543
|
+
)
|
|
544
|
+
except SessionAlreadyExistsError:
|
|
545
|
+
raise APIError(
|
|
546
|
+
409,
|
|
547
|
+
"SESSION_ALREADY_EXISTS",
|
|
548
|
+
f"Session {sid} already exists",
|
|
549
|
+
uuid.uuid4().hex,
|
|
550
|
+
) from None
|
|
551
|
+
return CreateSessionResponse(session_id=sid, created_at=created_at_ms)
|
|
552
|
+
|
|
553
|
+
@api.delete("/sessions/{session_id}", status_code=204)
|
|
554
|
+
async def delete_session(
|
|
555
|
+
session_id: str,
|
|
556
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
557
|
+
) -> Response:
|
|
558
|
+
"""End a session: cancel pending work and free its in-process state.
|
|
559
|
+
|
|
560
|
+
Idempotent — deleting an unknown or already-deleted session_id is a no-op
|
|
561
|
+
204 rather than a 404, since the end state (no session) is identical.
|
|
562
|
+
"""
|
|
563
|
+
reg_dep.remove(session_id)
|
|
564
|
+
return Response(status_code=204)
|
|
565
|
+
|
|
566
|
+
@api.post("/sessions/{session_id}/reply", response_model=ReplyResponse)
|
|
567
|
+
async def post_reply(
|
|
568
|
+
session_id: str,
|
|
569
|
+
body: ReplyRequest,
|
|
570
|
+
request: Request,
|
|
571
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
572
|
+
) -> ReplyResponse:
|
|
573
|
+
"""Accept a user message and schedule the agent loop in the background."""
|
|
574
|
+
bus = _require_bus(reg_dep, session_id)
|
|
575
|
+
storage = getattr(request.app.state, "storage", None)
|
|
576
|
+
await _try_acquire_turn(
|
|
577
|
+
bus=bus,
|
|
578
|
+
storage=storage,
|
|
579
|
+
session_id=session_id,
|
|
580
|
+
request_id=body.request_id,
|
|
581
|
+
busy_is_error=True,
|
|
582
|
+
)
|
|
583
|
+
user_content = _parse_user_content(
|
|
584
|
+
body=body, session_id=session_id, request=request
|
|
585
|
+
)
|
|
586
|
+
bus.current_request_id = body.request_id
|
|
587
|
+
_schedule_turn(
|
|
588
|
+
bus=bus,
|
|
589
|
+
loop_ref=request.app.state.loop,
|
|
590
|
+
storage=storage,
|
|
591
|
+
session_id=session_id,
|
|
592
|
+
request_id=body.request_id,
|
|
593
|
+
user_content=user_content,
|
|
594
|
+
)
|
|
595
|
+
return ReplyResponse(request_id=body.request_id)
|
|
596
|
+
|
|
597
|
+
@api.post(
|
|
598
|
+
"/sessions/{session_id}/steer",
|
|
599
|
+
response_model=AdmissionAcceptedResponse,
|
|
600
|
+
status_code=202,
|
|
601
|
+
)
|
|
602
|
+
async def post_steer(
|
|
603
|
+
session_id: str,
|
|
604
|
+
body: SteerRequest,
|
|
605
|
+
request: Request,
|
|
606
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
607
|
+
) -> AdmissionAcceptedResponse:
|
|
608
|
+
"""Enqueue mid-turn user text; injected after the current tool batch."""
|
|
609
|
+
bus = _require_bus(reg_dep, session_id)
|
|
610
|
+
# Capture once: subsequent awaits (parsing, enqueue) may cross a turn
|
|
611
|
+
# boundary if the in-flight turn completes concurrently, so the id
|
|
612
|
+
# reported back to the caller must reflect the turn that was busy at
|
|
613
|
+
# acceptance time, not whatever is current when we respond.
|
|
614
|
+
current_request_id = bus.current_request_id
|
|
615
|
+
if current_request_id is None:
|
|
616
|
+
raise APIError(
|
|
617
|
+
409,
|
|
618
|
+
"SESSION_IDLE",
|
|
619
|
+
"Session is idle; use POST /reply instead of /steer",
|
|
620
|
+
uuid.uuid4().hex,
|
|
621
|
+
)
|
|
622
|
+
user_content = _parse_user_content(
|
|
623
|
+
body=body, session_id=session_id, request=request
|
|
624
|
+
)
|
|
625
|
+
try:
|
|
626
|
+
position = bus.admission.enqueue_steer(user_content)
|
|
627
|
+
except AdmissionQueueFullError as exc:
|
|
628
|
+
logger.warning(
|
|
629
|
+
"steer queue full %s",
|
|
630
|
+
kv(session_id=session_id, max_size=exc.max_size),
|
|
631
|
+
)
|
|
632
|
+
raise APIError(
|
|
633
|
+
429,
|
|
634
|
+
"STEER_QUEUE_FULL",
|
|
635
|
+
str(exc),
|
|
636
|
+
uuid.uuid4().hex,
|
|
637
|
+
) from exc
|
|
638
|
+
return await _publish_admission_accepted(
|
|
639
|
+
bus,
|
|
640
|
+
request_id=current_request_id,
|
|
641
|
+
queue="steer",
|
|
642
|
+
position=position,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
@api.post(
|
|
646
|
+
"/sessions/{session_id}/queue",
|
|
647
|
+
response_model=AdmissionAcceptedResponse,
|
|
648
|
+
status_code=202,
|
|
649
|
+
)
|
|
650
|
+
async def post_queue(
|
|
651
|
+
session_id: str,
|
|
652
|
+
body: QueueRequest,
|
|
653
|
+
request: Request,
|
|
654
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
655
|
+
) -> AdmissionAcceptedResponse:
|
|
656
|
+
"""Enqueue a follow-up, or start immediately when the session is idle."""
|
|
657
|
+
bus = _require_bus(reg_dep, session_id)
|
|
658
|
+
user_content = _parse_user_content(
|
|
659
|
+
body=body, session_id=session_id, request=request
|
|
660
|
+
)
|
|
661
|
+
storage = getattr(request.app.state, "storage", None)
|
|
662
|
+
acquired = await _try_acquire_turn(
|
|
663
|
+
bus=bus,
|
|
664
|
+
storage=storage,
|
|
665
|
+
session_id=session_id,
|
|
666
|
+
request_id=body.request_id,
|
|
667
|
+
busy_is_error=False,
|
|
668
|
+
)
|
|
669
|
+
if acquired:
|
|
670
|
+
bus.current_request_id = body.request_id
|
|
671
|
+
_schedule_turn(
|
|
672
|
+
bus=bus,
|
|
673
|
+
loop_ref=request.app.state.loop,
|
|
674
|
+
storage=storage,
|
|
675
|
+
session_id=session_id,
|
|
676
|
+
request_id=body.request_id,
|
|
677
|
+
user_content=user_content,
|
|
678
|
+
)
|
|
679
|
+
return await _publish_admission_accepted(
|
|
680
|
+
bus,
|
|
681
|
+
request_id=body.request_id,
|
|
682
|
+
queue="follow_up",
|
|
683
|
+
position=0,
|
|
684
|
+
)
|
|
685
|
+
try:
|
|
686
|
+
position = bus.admission.enqueue_follow_up(body.request_id, user_content)
|
|
687
|
+
except AdmissionQueueFullError as exc:
|
|
688
|
+
logger.warning(
|
|
689
|
+
"follow-up queue full %s",
|
|
690
|
+
kv(session_id=session_id, max_size=exc.max_size),
|
|
691
|
+
)
|
|
692
|
+
raise APIError(
|
|
693
|
+
429,
|
|
694
|
+
"FOLLOW_UP_QUEUE_FULL",
|
|
695
|
+
str(exc),
|
|
696
|
+
uuid.uuid4().hex,
|
|
697
|
+
) from exc
|
|
698
|
+
# Idle locally but durable lock held elsewhere: schedule retries so the
|
|
699
|
+
# queue cannot sit forever waiting for a turn-complete that never comes
|
|
700
|
+
# on this replica.
|
|
701
|
+
_schedule_follow_up_retry(
|
|
702
|
+
bus=bus,
|
|
703
|
+
loop_ref=request.app.state.loop,
|
|
704
|
+
storage=storage,
|
|
705
|
+
session_id=session_id,
|
|
706
|
+
)
|
|
707
|
+
return await _publish_admission_accepted(
|
|
708
|
+
bus,
|
|
709
|
+
request_id=body.request_id,
|
|
710
|
+
queue="follow_up",
|
|
711
|
+
position=position,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
@api.post(
|
|
715
|
+
"/sessions/{session_id}/attachments",
|
|
716
|
+
status_code=201,
|
|
717
|
+
response_model=AttachmentUploadResponse,
|
|
718
|
+
)
|
|
719
|
+
async def post_attachment(
|
|
720
|
+
session_id: str,
|
|
721
|
+
request: Request,
|
|
722
|
+
file: UploadFile = File(...),
|
|
723
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
724
|
+
) -> AttachmentUploadResponse:
|
|
725
|
+
if not attachments_enabled_from_env():
|
|
726
|
+
raise APIError(404, "NOT_FOUND", "Attachments are disabled", uuid.uuid4().hex)
|
|
727
|
+
bus = reg_dep.get(session_id)
|
|
728
|
+
if bus is None:
|
|
729
|
+
raise APIError(404, "SESSION_NOT_FOUND", "Unknown session", uuid.uuid4().hex)
|
|
730
|
+
store = _attachment_store(request)
|
|
731
|
+
if store is None:
|
|
732
|
+
raise APIError(404, "NOT_FOUND", "Attachments are disabled", uuid.uuid4().hex)
|
|
733
|
+
filename = (file.filename or "upload").strip() or "upload"
|
|
734
|
+
mime_type = (file.content_type or "application/octet-stream").strip()
|
|
735
|
+
if mime_type not in ALLOWED_MIME_TYPES:
|
|
736
|
+
raise APIError(
|
|
737
|
+
415,
|
|
738
|
+
"UNSUPPORTED_MEDIA_TYPE",
|
|
739
|
+
f"Unsupported mime type: {mime_type}",
|
|
740
|
+
uuid.uuid4().hex,
|
|
741
|
+
)
|
|
742
|
+
data = await file.read()
|
|
743
|
+
try:
|
|
744
|
+
stored = store.save(
|
|
745
|
+
session_id,
|
|
746
|
+
data=data,
|
|
747
|
+
mime_type=mime_type,
|
|
748
|
+
filename=filename,
|
|
749
|
+
)
|
|
750
|
+
except AttachmentTooLargeError as exc:
|
|
751
|
+
raise APIError(413, "PAYLOAD_TOO_LARGE", str(exc), uuid.uuid4().hex) from exc
|
|
752
|
+
except UnsupportedAttachmentTypeError as exc:
|
|
753
|
+
raise APIError(415, "UNSUPPORTED_MEDIA_TYPE", str(exc), uuid.uuid4().hex) from exc
|
|
754
|
+
except AttachmentSessionLimitError as exc:
|
|
755
|
+
raise APIError(400, "BAD_REQUEST", str(exc), uuid.uuid4().hex) from exc
|
|
756
|
+
return AttachmentUploadResponse(
|
|
757
|
+
attachment_id=stored.attachment_id,
|
|
758
|
+
mime_type=stored.mime_type,
|
|
759
|
+
size_bytes=stored.size_bytes,
|
|
760
|
+
filename=stored.filename,
|
|
761
|
+
created_at=stored.created_at_ms,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
@api.get("/sessions/{session_id}/events")
|
|
765
|
+
async def stream_events(
|
|
766
|
+
session_id: str,
|
|
767
|
+
request: Request,
|
|
768
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
769
|
+
) -> StreamingResponse:
|
|
770
|
+
"""SSE stream with replay, ActiveRequests snapshot, and ping heartbeats."""
|
|
771
|
+
bus = reg_dep.get(session_id)
|
|
772
|
+
if bus is None:
|
|
773
|
+
raise APIError(
|
|
774
|
+
404,
|
|
775
|
+
"SESSION_NOT_FOUND",
|
|
776
|
+
"Unknown session",
|
|
777
|
+
uuid.uuid4().hex,
|
|
778
|
+
)
|
|
779
|
+
last_id = _parse_last_event_id(request)
|
|
780
|
+
|
|
781
|
+
async def gen() -> AsyncIterator[bytes]:
|
|
782
|
+
replay, q = await bus.subscribe(last_id)
|
|
783
|
+
ping_task = asyncio.create_task(_ping_loop(bus))
|
|
784
|
+
try:
|
|
785
|
+
for frame in replay:
|
|
786
|
+
yield frame.encode("utf-8")
|
|
787
|
+
active_ids = [bus.current_request_id] if bus.current_request_id else []
|
|
788
|
+
yield format_active_requests(active_ids).encode("utf-8")
|
|
789
|
+
while True:
|
|
790
|
+
if await request.is_disconnected():
|
|
791
|
+
break
|
|
792
|
+
try:
|
|
793
|
+
frame = await asyncio.wait_for(q.get(), timeout=1.0)
|
|
794
|
+
except asyncio.TimeoutError:
|
|
795
|
+
continue
|
|
796
|
+
yield frame.encode("utf-8")
|
|
797
|
+
except asyncio.CancelledError:
|
|
798
|
+
raise
|
|
799
|
+
finally:
|
|
800
|
+
ping_task.cancel()
|
|
801
|
+
try:
|
|
802
|
+
await ping_task
|
|
803
|
+
except asyncio.CancelledError:
|
|
804
|
+
pass
|
|
805
|
+
await bus.unsubscribe(q)
|
|
806
|
+
|
|
807
|
+
headers = {
|
|
808
|
+
"Cache-Control": "no-cache",
|
|
809
|
+
"Connection": "keep-alive",
|
|
810
|
+
"X-Accel-Buffering": "no",
|
|
811
|
+
}
|
|
812
|
+
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
|
813
|
+
|
|
814
|
+
@api.post("/sessions/{session_id}/cancel", status_code=200)
|
|
815
|
+
async def post_cancel(
|
|
816
|
+
session_id: str,
|
|
817
|
+
body: CancelRequest,
|
|
818
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
819
|
+
) -> Response:
|
|
820
|
+
"""Record cancel intent for the agent loop (integration in a later story)."""
|
|
821
|
+
bus = reg_dep.get(session_id)
|
|
822
|
+
if bus is None:
|
|
823
|
+
raise APIError(
|
|
824
|
+
404,
|
|
825
|
+
"SESSION_NOT_FOUND",
|
|
826
|
+
"Unknown session",
|
|
827
|
+
uuid.uuid4().hex,
|
|
828
|
+
)
|
|
829
|
+
bus.cancel_requested_for = body.request_id
|
|
830
|
+
bus.admission.clear_steer()
|
|
831
|
+
for fut in list(bus.pending_responses.values()):
|
|
832
|
+
if not fut.done():
|
|
833
|
+
fut.cancel()
|
|
834
|
+
bus.abandon_pending_cancel_all()
|
|
835
|
+
return Response(status_code=200)
|
|
836
|
+
|
|
837
|
+
@api.post(
|
|
838
|
+
"/sessions/{session_id}/tool-confirmations/{tool_call_id}",
|
|
839
|
+
status_code=202,
|
|
840
|
+
)
|
|
841
|
+
async def post_tool_confirmation(
|
|
842
|
+
session_id: str,
|
|
843
|
+
tool_call_id: str,
|
|
844
|
+
body: ToolConfirmationPOST,
|
|
845
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
846
|
+
) -> dict[str, bool]:
|
|
847
|
+
rid = uuid.uuid4().hex
|
|
848
|
+
bus = reg_dep.get(session_id)
|
|
849
|
+
if bus is None:
|
|
850
|
+
raise APIError(404, "SESSION_NOT_FOUND", "Unknown session", rid)
|
|
851
|
+
state = bus.is_pending_or_terminal(tool_call_id)
|
|
852
|
+
if state == "unknown":
|
|
853
|
+
raise APIError(
|
|
854
|
+
404,
|
|
855
|
+
"PENDING_UNKNOWN",
|
|
856
|
+
"Pending response id is not registered for this session",
|
|
857
|
+
rid,
|
|
858
|
+
)
|
|
859
|
+
if state == "terminated":
|
|
860
|
+
raise APIError(
|
|
861
|
+
409,
|
|
862
|
+
"STALE_PENDING_RESPONSE",
|
|
863
|
+
"This pending response was already resolved, cancelled, or timed out",
|
|
864
|
+
rid,
|
|
865
|
+
)
|
|
866
|
+
payload: dict[str, Any] = {"approved": body.approved}
|
|
867
|
+
if body.reason is not None:
|
|
868
|
+
payload["reason"] = body.reason
|
|
869
|
+
if body.always:
|
|
870
|
+
payload["always"] = True
|
|
871
|
+
if not bus.resolve_pending(tool_call_id, payload):
|
|
872
|
+
raise APIError(
|
|
873
|
+
409,
|
|
874
|
+
"STALE_PENDING_RESPONSE",
|
|
875
|
+
"This pending response was already resolved, cancelled, or timed out",
|
|
876
|
+
rid,
|
|
877
|
+
)
|
|
878
|
+
return {"ok": True}
|
|
879
|
+
|
|
880
|
+
@api.post("/sessions/{session_id}/elicitations/{elicitation_id}", status_code=202)
|
|
881
|
+
async def post_elicitation(
|
|
882
|
+
session_id: str,
|
|
883
|
+
elicitation_id: str,
|
|
884
|
+
body: ElicitationPOST,
|
|
885
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
886
|
+
) -> dict[str, bool]:
|
|
887
|
+
rid = uuid.uuid4().hex
|
|
888
|
+
bus = reg_dep.get(session_id)
|
|
889
|
+
if bus is None:
|
|
890
|
+
raise APIError(404, "SESSION_NOT_FOUND", "Unknown session", rid)
|
|
891
|
+
state = bus.is_pending_or_terminal(elicitation_id)
|
|
892
|
+
if state == "unknown":
|
|
893
|
+
raise APIError(
|
|
894
|
+
404,
|
|
895
|
+
"PENDING_UNKNOWN",
|
|
896
|
+
"Pending response id is not registered for this session",
|
|
897
|
+
rid,
|
|
898
|
+
)
|
|
899
|
+
if state == "terminated":
|
|
900
|
+
raise APIError(
|
|
901
|
+
409,
|
|
902
|
+
"STALE_PENDING_RESPONSE",
|
|
903
|
+
"This pending response was already resolved, cancelled, or timed out",
|
|
904
|
+
rid,
|
|
905
|
+
)
|
|
906
|
+
payload = {"user_data": body.user_data}
|
|
907
|
+
if not bus.resolve_pending(elicitation_id, payload):
|
|
908
|
+
raise APIError(
|
|
909
|
+
409,
|
|
910
|
+
"STALE_PENDING_RESPONSE",
|
|
911
|
+
"This pending response was already resolved, cancelled, or timed out",
|
|
912
|
+
rid,
|
|
913
|
+
)
|
|
914
|
+
return {"ok": True}
|
|
915
|
+
|
|
916
|
+
@api.post(
|
|
917
|
+
"/sessions/{session_id}/frontend-tool-results/{tool_call_id}",
|
|
918
|
+
status_code=202,
|
|
919
|
+
)
|
|
920
|
+
async def post_frontend_tool_result(
|
|
921
|
+
session_id: str,
|
|
922
|
+
tool_call_id: str,
|
|
923
|
+
body: FrontendToolResultPOST,
|
|
924
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
925
|
+
) -> dict[str, bool]:
|
|
926
|
+
rid = uuid.uuid4().hex
|
|
927
|
+
bus = reg_dep.get(session_id)
|
|
928
|
+
if bus is None:
|
|
929
|
+
raise APIError(404, "SESSION_NOT_FOUND", "Unknown session", rid)
|
|
930
|
+
state = bus.is_pending_or_terminal(tool_call_id)
|
|
931
|
+
if state == "unknown":
|
|
932
|
+
raise APIError(
|
|
933
|
+
404,
|
|
934
|
+
"PENDING_UNKNOWN",
|
|
935
|
+
"Pending response id is not registered for this session",
|
|
936
|
+
rid,
|
|
937
|
+
)
|
|
938
|
+
if state == "terminated":
|
|
939
|
+
raise APIError(
|
|
940
|
+
409,
|
|
941
|
+
"STALE_PENDING_RESPONSE",
|
|
942
|
+
"This pending response was already resolved, cancelled, or timed out",
|
|
943
|
+
rid,
|
|
944
|
+
)
|
|
945
|
+
parsed: list[ContentBlock] = []
|
|
946
|
+
for item in body.result:
|
|
947
|
+
try:
|
|
948
|
+
parsed.append(ContentBlock.from_dict(item))
|
|
949
|
+
except ValueError as exc:
|
|
950
|
+
raise APIError(400, "BAD_REQUEST", str(exc), rid) from exc
|
|
951
|
+
payload = {
|
|
952
|
+
"result": [b.to_dict() for b in parsed],
|
|
953
|
+
"is_error": body.is_error,
|
|
954
|
+
}
|
|
955
|
+
if not bus.resolve_pending(tool_call_id, payload):
|
|
956
|
+
raise APIError(
|
|
957
|
+
409,
|
|
958
|
+
"STALE_PENDING_RESPONSE",
|
|
959
|
+
"This pending response was already resolved, cancelled, or timed out",
|
|
960
|
+
rid,
|
|
961
|
+
)
|
|
962
|
+
return {"ok": True}
|
|
963
|
+
|
|
964
|
+
@api.get("/sessions/{session_id}/usage", response_model=SessionUsageResponse)
|
|
965
|
+
async def get_usage(
|
|
966
|
+
session_id: str,
|
|
967
|
+
request: Request,
|
|
968
|
+
since: str | None = None,
|
|
969
|
+
reg_dep: SessionRegistry = Depends(get_registry),
|
|
970
|
+
) -> SessionUsageResponse:
|
|
971
|
+
"""Return token/cost aggregates for the session (UsagePort backend)."""
|
|
972
|
+
if reg_dep.get(session_id) is None:
|
|
973
|
+
raise APIError(
|
|
974
|
+
404,
|
|
975
|
+
"SESSION_NOT_FOUND",
|
|
976
|
+
"Unknown session",
|
|
977
|
+
uuid.uuid4().hex,
|
|
978
|
+
)
|
|
979
|
+
usage_ref: UsagePort = request.app.state.usage
|
|
980
|
+
raw = await usage_ref.session_usage(session_id, since=since)
|
|
981
|
+
return SessionUsageResponse.model_validate(raw)
|
|
982
|
+
|
|
983
|
+
@api.get("/api/workspace/tree")
|
|
984
|
+
async def workspace_tree(
|
|
985
|
+
path: str | None = None,
|
|
986
|
+
) -> dict[str, Any]:
|
|
987
|
+
"""List one directory under the gateway workspace (repo-relative ``path``)."""
|
|
988
|
+
if not _workspace_api_enabled():
|
|
989
|
+
raise APIError(
|
|
990
|
+
404,
|
|
991
|
+
"NOT_FOUND",
|
|
992
|
+
"Workspace API is disabled",
|
|
993
|
+
uuid.uuid4().hex,
|
|
994
|
+
)
|
|
995
|
+
rel = path.strip() if path and path.strip() else None
|
|
996
|
+
ws = WorkspaceFileService(_api_workspace_root())
|
|
997
|
+
try:
|
|
998
|
+
entries = ws.list_directory(rel)
|
|
999
|
+
except WorkspaceError as exc:
|
|
1000
|
+
raise _workspace_exc_to_api(exc) from exc
|
|
1001
|
+
display = rel if rel else "."
|
|
1002
|
+
return {"path": display, "entries": entries}
|
|
1003
|
+
|
|
1004
|
+
@api.get("/api/workspace/file")
|
|
1005
|
+
async def workspace_file(
|
|
1006
|
+
path: str,
|
|
1007
|
+
offset: int = 1,
|
|
1008
|
+
limit: int | None = 200,
|
|
1009
|
+
) -> dict[str, Any]:
|
|
1010
|
+
"""Read a text slice from a file under the gateway workspace (numbered lines)."""
|
|
1011
|
+
if not _workspace_api_enabled():
|
|
1012
|
+
raise APIError(
|
|
1013
|
+
404,
|
|
1014
|
+
"NOT_FOUND",
|
|
1015
|
+
"Workspace API is disabled",
|
|
1016
|
+
uuid.uuid4().hex,
|
|
1017
|
+
)
|
|
1018
|
+
if not path or not str(path).strip():
|
|
1019
|
+
raise APIError(
|
|
1020
|
+
400,
|
|
1021
|
+
"BAD_REQUEST",
|
|
1022
|
+
"path query parameter is required",
|
|
1023
|
+
uuid.uuid4().hex,
|
|
1024
|
+
)
|
|
1025
|
+
ws = WorkspaceFileService(_api_workspace_root())
|
|
1026
|
+
try:
|
|
1027
|
+
result = ws.read_file(path.strip(), offset=offset, limit=limit)
|
|
1028
|
+
except WorkspaceError as exc:
|
|
1029
|
+
raise _workspace_exc_to_api(exc) from exc
|
|
1030
|
+
return {
|
|
1031
|
+
"path": result["path"],
|
|
1032
|
+
"content": result["content"],
|
|
1033
|
+
"start_line": result["start_line"],
|
|
1034
|
+
"end_line": result["end_line"],
|
|
1035
|
+
"total_lines": result["total_lines"],
|
|
1036
|
+
"truncated": result["truncated"],
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
@api.get("/api/chat-history")
|
|
1040
|
+
async def chat_history_list(
|
|
1041
|
+
request: Request,
|
|
1042
|
+
limit: int = 50,
|
|
1043
|
+
) -> dict[str, Any]:
|
|
1044
|
+
"""List recent persisted chat threads."""
|
|
1045
|
+
if not _chat_history_api_enabled():
|
|
1046
|
+
raise APIError(
|
|
1047
|
+
404,
|
|
1048
|
+
"NOT_FOUND",
|
|
1049
|
+
"Chat history API is disabled",
|
|
1050
|
+
uuid.uuid4().hex,
|
|
1051
|
+
)
|
|
1052
|
+
from monkeybot.core.persistence.thread_summary import ChatThreadSummary
|
|
1053
|
+
|
|
1054
|
+
backend = _storage_backend(request)
|
|
1055
|
+
cap = max(1, min(limit, 200))
|
|
1056
|
+
rows: list[ChatThreadSummary] = await backend.history().list_threads(cap)
|
|
1057
|
+
return {
|
|
1058
|
+
"threads": [
|
|
1059
|
+
{
|
|
1060
|
+
"session_id": row.thread_id,
|
|
1061
|
+
"last_message_at": row.last_message_at,
|
|
1062
|
+
"message_count": row.message_count,
|
|
1063
|
+
"preview": row.preview,
|
|
1064
|
+
}
|
|
1065
|
+
for row in rows
|
|
1066
|
+
]
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
@api.get("/api/chat-history/{session_id}")
|
|
1070
|
+
async def chat_history_detail(
|
|
1071
|
+
session_id: str,
|
|
1072
|
+
request: Request,
|
|
1073
|
+
limit: int = 200,
|
|
1074
|
+
) -> dict[str, Any]:
|
|
1075
|
+
"""Return persisted user/assistant text for one chat thread."""
|
|
1076
|
+
if not _chat_history_api_enabled():
|
|
1077
|
+
raise APIError(
|
|
1078
|
+
404,
|
|
1079
|
+
"NOT_FOUND",
|
|
1080
|
+
"Chat history API is disabled",
|
|
1081
|
+
uuid.uuid4().hex,
|
|
1082
|
+
)
|
|
1083
|
+
from monkeybot.core.persistence.thread_summary import messages_to_wire
|
|
1084
|
+
|
|
1085
|
+
backend = _storage_backend(request)
|
|
1086
|
+
cap = max(1, min(limit, 500))
|
|
1087
|
+
messages = await backend.history().load(session_id.strip(), limit=cap)
|
|
1088
|
+
return {
|
|
1089
|
+
"session_id": session_id,
|
|
1090
|
+
"messages": messages_to_wire(messages),
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
app.include_router(api)
|
|
1094
|
+
app.include_router(build_scheduler_router(loop_port=loop, registry=reg))
|
|
1095
|
+
|
|
1096
|
+
@app.get("/health", response_model=HealthResponse)
|
|
1097
|
+
async def health() -> HealthResponse:
|
|
1098
|
+
"""Liveness probe without authentication."""
|
|
1099
|
+
return HealthResponse(status="ok", version="2.0.0")
|
|
1100
|
+
|
|
1101
|
+
return app
|