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.
Files changed (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. 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