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,733 @@
1
+ """Production FastAPI application wiring for the SSE gateway (Story 8).
2
+
3
+ Bootstraps storage backend, MCP, command tiers, and a real :func:`monkeybot.core.runtime.loop.run` driver;
4
+ exposes module-level ``app`` for ``uvicorn monkeybot.gateway.sse.app:app``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import json
12
+ import logging
13
+ import os
14
+ from collections.abc import AsyncIterator
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from fastapi import FastAPI
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+
22
+ from monkeybot.gateway.bootstrap import ensure_gateway_runtime_env
23
+
24
+ ensure_gateway_runtime_env()
25
+ from monkeybot.core.attachments.config import attachments_enabled_from_env
26
+ from monkeybot.core.attachments.store import AttachmentStore, FilesystemAttachmentStore
27
+ from monkeybot.core.config.settings import (
28
+ ConfigError,
29
+ SubagentConfig,
30
+ auto_schema_enabled_from_config,
31
+ get_provider_config,
32
+ get_subagent_registry,
33
+ normalize_model_provider,
34
+ vertex_google_search_enabled_from_config,
35
+ )
36
+ from monkeybot.core.context import build_context
37
+ from monkeybot.core.hooks import HookManager
38
+ from monkeybot.core.llm.provider import (
39
+ Done,
40
+ Provider,
41
+ ProviderEvent,
42
+ TextDelta,
43
+ ToolCall,
44
+ UsageEvent,
45
+ )
46
+ from monkeybot.core.llm.usage import Usage as UsageRecord
47
+ from monkeybot.core.mcp.mcp_client import MCPClient
48
+ from monkeybot.core.memory.subsystem import MemorySubsystem
49
+ from monkeybot.core.persistence.backends import (
50
+ StorageBackend,
51
+ UsageStore,
52
+ create_storage_backend,
53
+ )
54
+ from monkeybot.core.persistence.transcript import TranscriptWriter, transcript_enabled_from_env
55
+ from monkeybot.core.runtime.events import Error as AgentError
56
+ from monkeybot.core.runtime.events import TurnComplete, UsageTotals, event_to_json
57
+ from monkeybot.core.runtime.loop import SUMMARY_TRIGGER_RATIO
58
+ from monkeybot.core.runtime.loop import run as run_loop
59
+ from monkeybot.core.testing.mocks_provider import ScriptedFakeProvider
60
+ from monkeybot.core.tools.core_tool_executor import CoreToolExecutor
61
+ from monkeybot.core.tools.inspector import CommandTierInspector, RulesInspector, ToolInspector
62
+ from monkeybot.core.tools.permission import try_load_permission_inspector
63
+ from monkeybot.core.tools.loop_inspector import LoopStartInspector
64
+ from monkeybot.core.types.content_blocks import ContentBlock, Text
65
+ from monkeybot.core.workspace import create_workspace_storage
66
+ from monkeybot.gateway.sse.loop_port import UsagePort
67
+ from monkeybot.gateway.sse.routes import create_app as build_sse_app
68
+ from monkeybot.gateway.sse.session_bus import SessionBus, SessionRegistry
69
+ from monkeybot.gateway.sse.workspace_layout import resolve_agent_workspace_root
70
+ from monkeybot.providers.gemini import GeminiProvider
71
+ from monkeybot.web_search import WebSearchTool
72
+ from monkeybot.web_search import build_backend as _build_web_search_backend
73
+
74
+ logger = logging.getLogger(__name__)
75
+
76
+
77
+ @dataclass
78
+ class _GatewayDeps:
79
+ """Process-level deps populated on startup (mutable module singleton)."""
80
+
81
+ mcp: MCPClient | None = None
82
+ inspectors: list[ToolInspector] = field(default_factory=list)
83
+ provider: Provider | None = None
84
+ curator_provider: Provider | None = None
85
+ hook_manager: HookManager | None = None
86
+ memory: MemorySubsystem | None = None
87
+ web_search_tool: WebSearchTool | None = None
88
+ run_command_allowed_commands: list[str] | None = None
89
+ run_command_allowed_path_prefixes: list[str] | None = None
90
+ subagent_registry: dict[str, SubagentConfig] = field(default_factory=dict)
91
+
92
+
93
+ _deps = _GatewayDeps()
94
+
95
+
96
+ def _memory_enabled() -> bool:
97
+ """Default on; explicit off via ``MONKEYBOT_MEMORY_HOOK_ENABLED=false``."""
98
+ raw = os.environ.get("MONKEYBOT_MEMORY_HOOK_ENABLED", "true").strip().lower()
99
+ return raw not in {"0", "false", "no", "off"}
100
+
101
+
102
+ def _env_context_window_tokens() -> int:
103
+ cap_raw = os.environ.get("MODEL_CONTEXT_WINDOW", "200000").strip()
104
+ try:
105
+ return max(1, int(cap_raw))
106
+ except ValueError:
107
+ return 200_000
108
+
109
+
110
+ def _resolved_workspace_paths() -> tuple[Path, Path]:
111
+ """Resolve agent workspace root and skills dir (relative paths use process ``cwd``)."""
112
+ root = resolve_agent_workspace_root()
113
+ cwd = Path.cwd().resolve()
114
+ skills = Path(os.environ.get("SKILLS_PATH", "skills"))
115
+ skills_p = skills.resolve() if skills.is_absolute() else (cwd / skills).resolve()
116
+ return root, skills_p
117
+
118
+
119
+ def _memory_storage_uri() -> str:
120
+ """Effective memory storage URI (``MEMORY_STORAGE_URI`` or legacy ``MEMORY_PATH``)."""
121
+ uri = os.environ.get("MEMORY_STORAGE_URI", "").strip()
122
+ if uri:
123
+ return uri
124
+ raw_mp = os.environ.get("MEMORY_PATH")
125
+ if raw_mp is not None:
126
+ logger.info(
127
+ "memory: deriving storage URI from MEMORY_PATH; prefer paths.memory_storage_uri in monkeybot.yaml"
128
+ )
129
+ legacy = (raw_mp or "data/memory").strip()
130
+ mem = Path(legacy)
131
+ resolved = mem.resolve() if mem.is_absolute() else (Path.cwd().resolve() / mem).resolve()
132
+ return f"local://{resolved}"
133
+
134
+
135
+ class _UsageStoreAdapter(UsagePort):
136
+ """GET /usage backed by the UsageStore returned from the storage backend."""
137
+
138
+ def __init__(self, store: UsageStore) -> None:
139
+ self._store = store
140
+
141
+ async def session_usage(
142
+ self,
143
+ session_id: str,
144
+ *,
145
+ since: str | None,
146
+ ) -> dict[str, Any]:
147
+ since_ms: int | None = None
148
+ if since is not None and since.isdigit():
149
+ since_ms = int(since)
150
+ s = await self._store.summary(thread_id=session_id, since_ms=since_ms)
151
+ context_window_tokens = _env_context_window_tokens()
152
+ summarization_threshold_tokens = max(1, int(context_window_tokens * SUMMARY_TRIGGER_RATIO))
153
+ return {
154
+ "session_id": session_id,
155
+ "turns": s.turns,
156
+ "input_tokens": s.input_tokens,
157
+ "output_tokens": s.output_tokens,
158
+ "cached_tokens": s.cached_tokens,
159
+ "cache_read_tokens": s.cache_read_tokens,
160
+ "cache_creation_tokens": s.cache_creation_tokens,
161
+ "cost_usd": s.cost_usd,
162
+ "period_start": s.period_start_ms if s.period_start_ms is not None else 0,
163
+ "period_end": s.period_end_ms if s.period_end_ms is not None else 0,
164
+ "last_prompt_tokens": s.last_prompt_tokens,
165
+ "estimated_prompt_tokens": s.last_estimated_prompt_tokens,
166
+ "summarization_threshold_tokens": summarization_threshold_tokens,
167
+ "context_window_tokens": context_window_tokens,
168
+ }
169
+
170
+
171
+ class _StaticUsagePortZeros(UsagePort):
172
+ """Pre-startup placeholder so route wiring can construct the FastAPI app."""
173
+
174
+ async def session_usage(
175
+ self,
176
+ session_id: str,
177
+ *,
178
+ since: str | None,
179
+ ) -> dict[str, Any]:
180
+ del since
181
+ cw = _env_context_window_tokens()
182
+ return {
183
+ "session_id": session_id,
184
+ "turns": 0,
185
+ "input_tokens": 0,
186
+ "output_tokens": 0,
187
+ "cached_tokens": 0,
188
+ "cache_read_tokens": 0,
189
+ "cache_creation_tokens": 0,
190
+ "cost_usd": 0.0,
191
+ "period_start": 0,
192
+ "period_end": 0,
193
+ "last_prompt_tokens": 0,
194
+ "estimated_prompt_tokens": 0,
195
+ "summarization_threshold_tokens": max(1, int(cw * SUMMARY_TRIGGER_RATIO)),
196
+ "context_window_tokens": cw,
197
+ }
198
+
199
+
200
+ def _content_blocks_to_text(blocks: list[ContentBlock]) -> str:
201
+ """Transcript-only rendering of the incoming user turn (non-text blocks summarized)."""
202
+ parts: list[str] = []
203
+ for block in blocks:
204
+ if isinstance(block, Text):
205
+ parts.append(block.text)
206
+ else:
207
+ parts.append(f"[{type(block).__name__}]")
208
+ return "\n".join(parts)
209
+
210
+
211
+ def _default_agent_path(bus: SessionBus) -> Path:
212
+ if bus.agent_md:
213
+ return Path(bus.agent_md)
214
+ env = os.environ.get("AGENT_MD")
215
+ if not env:
216
+ raise RuntimeError("AGENT_MD must be set when session has no agent_md path")
217
+ return Path(env)
218
+
219
+
220
+ def _scripted_fake_provider() -> Provider:
221
+ """Deterministic provider for ``MODEL_PROVIDER=fake`` (tests and local dev)."""
222
+ raw = os.environ.get("MONKEYBOT_FAKE_PROVIDER_EVENTS", "")
223
+ if not raw:
224
+ return ScriptedFakeProvider(
225
+ [
226
+ TextDelta(text="hello"),
227
+ UsageEvent(input_tokens=1, output_tokens=2, cached_tokens=0),
228
+ Done(),
229
+ ]
230
+ )
231
+
232
+ decoded = json.loads(raw)
233
+ turns: list[list[ProviderEvent]] = []
234
+ for turn in decoded:
235
+ events: list[ProviderEvent] = []
236
+ if not isinstance(turn, list):
237
+ continue
238
+ for item in turn:
239
+ if not isinstance(item, dict):
240
+ continue
241
+ k = item.get("kind")
242
+ if k == "text_delta":
243
+ events.append(TextDelta(text=str(item.get("text", ""))))
244
+ elif k == "usage":
245
+ events.append(
246
+ UsageEvent(
247
+ input_tokens=int(item.get("input_tokens", 0)),
248
+ output_tokens=int(item.get("output_tokens", 0)),
249
+ cached_tokens=int(item.get("cached_tokens", 0)),
250
+ )
251
+ )
252
+ elif k == "tool_call":
253
+ events.append(
254
+ ToolCall(
255
+ call_id=str(item["call_id"]),
256
+ name=str(item["name"]),
257
+ args=dict(item.get("args", {})),
258
+ )
259
+ )
260
+ elif k == "done":
261
+ events.append(Done())
262
+ if events:
263
+ turns.append(events)
264
+ if not turns:
265
+ turns = [[TextDelta(text="hello"), Done()]]
266
+ flat: list[ProviderEvent] = [ev for turn in turns for ev in turn]
267
+ return ScriptedFakeProvider(flat)
268
+
269
+
270
+ def _resolve_provider() -> Provider:
271
+ mode = normalize_model_provider(os.environ.get("MODEL_PROVIDER", "google_vertexai"))
272
+ if mode == "fake":
273
+ return _scripted_fake_provider()
274
+ return get_provider_config(provider=mode).provider
275
+
276
+
277
+ def _resolve_curator_provider(main_provider: Provider) -> Provider:
278
+ """Dedicated provider for context curation with thinking and token cap overrides.
279
+
280
+ Uses a small ``max_tokens`` (the curator only needs ~50 JSON tokens) and
281
+ ``thinking_budget=0`` to explicitly disable extended thinking, which can stall
282
+ preview models for 10s+ on a short JSON-only completion.
283
+
284
+ Fake / vertex_anthropic modes reuse ``main_provider`` — curation is no-op in tests
285
+ and vertex-claude has no thinking budget concept.
286
+ """
287
+ mode = normalize_model_provider(os.environ.get("MODEL_PROVIDER", "google_vertexai"))
288
+ if mode in ("fake", "vertex_anthropic"):
289
+ return main_provider
290
+ return GeminiProvider(thinking_budget=0, max_tokens=1024)
291
+
292
+
293
+ class GatewayLoopPort:
294
+ """Schedules :func:`~monkeybot.core.runtime.loop.run` and forwards events to the session bus."""
295
+
296
+ def __init__(self, registry: SessionRegistry) -> None:
297
+ self._registry = registry
298
+ # Bound when this port is used by a non-module app (e.g. create_realtime_app).
299
+ self._fastapi_app: FastAPI | None = None
300
+
301
+ def bind_app(self, fastapi_app: FastAPI) -> None:
302
+ """Point storage/memory lookups at the serving FastAPI app."""
303
+ self._fastapi_app = fastapi_app
304
+
305
+ def _serving_app(self) -> FastAPI:
306
+ return self._fastapi_app if self._fastapi_app is not None else app
307
+
308
+ async def start_turn(
309
+ self,
310
+ session_id: str,
311
+ request_id: str,
312
+ user_content: list[ContentBlock],
313
+ ) -> None:
314
+ bus = self._registry.get(session_id)
315
+ if bus is None:
316
+ return
317
+
318
+ mcp = _deps.mcp
319
+ inspectors = _deps.inspectors
320
+ provider = bus.provider or _deps.provider
321
+
322
+ if mcp is None or provider is None:
323
+ logger.error("gateway deps not initialized")
324
+ await bus.publish_data(
325
+ event_to_json(AgentError(request_id=request_id, error="gateway_not_ready"))
326
+ )
327
+ await bus.publish_data(
328
+ event_to_json(TurnComplete(request_id=request_id, usage=UsageTotals()))
329
+ )
330
+ return
331
+
332
+ serving = self._serving_app()
333
+ backend: StorageBackend = serving.state.storage
334
+ history = backend.history()
335
+ usage_store = backend.usage()
336
+
337
+ cancel_event = asyncio.Event()
338
+
339
+ async def _watch_cancel() -> None:
340
+ while True:
341
+ if bus.cancel_requested_for == request_id:
342
+ cancel_event.set()
343
+ return
344
+ await asyncio.sleep(0.05)
345
+
346
+ watcher = asyncio.create_task(_watch_cancel())
347
+
348
+ executor: CoreToolExecutor | None = None
349
+ try:
350
+ model_name = bus.model_name or os.environ.get("MODEL_NAME", "gemini-2.5-flash")
351
+ agent_path = _default_agent_path(bus)
352
+
353
+ workspace_root, skills_resolved = _resolved_workspace_paths()
354
+
355
+ transcript_writer: TranscriptWriter | None = None
356
+ if transcript_enabled_from_env():
357
+ if bus.transcript_writer is None:
358
+ bus.transcript_writer = TranscriptWriter(
359
+ session_id, workspace_root=workspace_root
360
+ )
361
+ transcript_writer = bus.transcript_writer
362
+ await transcript_writer.ensure_manifest(
363
+ agent_md=str(agent_path),
364
+ model=model_name,
365
+ provider=provider.name,
366
+ workspace_root=str(workspace_root),
367
+ )
368
+
369
+ attachment_store: AttachmentStore | None = getattr(
370
+ serving.state, "attachment_store", None
371
+ )
372
+ if bus.attachment_catalog is not None:
373
+ rows = await history.load(session_id)
374
+ bus.attachment_catalog.rebuild_from_history(rows)
375
+
376
+ extra_tools = [_deps.web_search_tool] if _deps.web_search_tool is not None else []
377
+
378
+ try:
379
+ ctx = await build_context(
380
+ session_id,
381
+ request_id,
382
+ agent_md_path=agent_path,
383
+ memory=getattr(serving.state, "memory", None),
384
+ skills_path=skills_resolved,
385
+ mcp_client=mcp,
386
+ model=model_name,
387
+ cancelled=cancel_event,
388
+ context_window_tokens=_env_context_window_tokens(),
389
+ workspace_root=workspace_root,
390
+ sse_bus=bus,
391
+ extra_tools=extra_tools,
392
+ subagent_registry=_deps.subagent_registry,
393
+ )
394
+ except Exception as exc:
395
+ logger.exception("build_context failed")
396
+ await bus.publish_data(
397
+ event_to_json(AgentError(request_id=request_id, error=str(exc)))
398
+ )
399
+ await bus.publish_data(
400
+ event_to_json(TurnComplete(request_id=request_id, usage=UsageTotals()))
401
+ )
402
+ return
403
+
404
+ storage_backend = getattr(serving.state, "storage", None)
405
+ executor = CoreToolExecutor(
406
+ workspace_root=workspace_root,
407
+ memory=getattr(serving.state, "memory", None),
408
+ skills_path=skills_resolved,
409
+ mcp=mcp,
410
+ extra_tools=extra_tools,
411
+ run_command_allowed_commands=_deps.run_command_allowed_commands,
412
+ run_command_allowed_path_prefixes=_deps.run_command_allowed_path_prefixes,
413
+ attachment_store=attachment_store,
414
+ attachment_catalog=bus.attachment_catalog,
415
+ run_store=storage_backend.runs() if storage_backend is not None else None,
416
+ scheduled_loop_store=(
417
+ storage_backend.scheduled_loops() if storage_backend is not None else None
418
+ ),
419
+ subagent_registry=_deps.subagent_registry,
420
+ )
421
+ if transcript_writer is not None:
422
+ await transcript_writer.write_user_message(
423
+ request_id=request_id,
424
+ content=_content_blocks_to_text(user_content),
425
+ )
426
+ async for evt in run_loop(
427
+ user_content,
428
+ ctx,
429
+ provider=provider,
430
+ history=history,
431
+ inspectors=inspectors,
432
+ tool_executor=executor,
433
+ cancelled=cancel_event,
434
+ hook_manager=_deps.hook_manager,
435
+ curator_provider=_deps.curator_provider,
436
+ attachment_store=attachment_store,
437
+ attachment_catalog=bus.attachment_catalog,
438
+ transcript_writer=transcript_writer,
439
+ vertex_google_search=vertex_google_search_enabled_from_config(),
440
+ input_admission=bus.admission,
441
+ ):
442
+ if isinstance(evt, TurnComplete):
443
+ u = evt.usage
444
+ await usage_store.record(
445
+ session_id,
446
+ model_name,
447
+ UsageRecord(
448
+ input_tokens=u.input_tokens,
449
+ output_tokens=u.output_tokens,
450
+ cached_tokens=u.cached_tokens,
451
+ cache_read_tokens=u.cache_read_tokens,
452
+ cache_creation_tokens=u.cache_creation_tokens,
453
+ cost_usd=u.cost_usd,
454
+ duration_ms=u.duration_ms,
455
+ estimated_prompt_tokens=u.estimated_prompt_tokens,
456
+ ),
457
+ run_id=request_id,
458
+ )
459
+ if transcript_writer is not None:
460
+ await transcript_writer.write_event(evt)
461
+ await bus.publish_data(event_to_json(evt))
462
+ finally:
463
+ if executor is not None:
464
+ await executor.aclose()
465
+ watcher.cancel()
466
+ with contextlib.suppress(asyncio.CancelledError):
467
+ await watcher
468
+ bus.cancel_requested_for = None
469
+
470
+
471
+ def _cors_allow_origins() -> list[str]:
472
+ """Browser origins allowed for cross-origin API calls (SSE, JSON).
473
+
474
+ Comma-separated ``MONKEYBOT_CORS_ALLOW_ORIGINS`` overrides the dev default
475
+ (``http://localhost:5173``). Use when the chat UI is hosted on another origin
476
+ and talks to the gateway directly (without a same-origin dev proxy).
477
+
478
+ Set to ``*`` to allow any origin (credentials disabled by Starlette rules for ``*``).
479
+ """
480
+ raw = os.environ.get("MONKEYBOT_CORS_ALLOW_ORIGINS", "").strip()
481
+ if not raw:
482
+ return ["http://localhost:5173"]
483
+ if raw == "*":
484
+ return ["*"]
485
+ parts = [p.strip() for p in raw.split(",") if p.strip()]
486
+ return parts if parts else ["http://localhost:5173"]
487
+
488
+
489
+ def _tool_denied_patterns() -> list[str]:
490
+ """Substring deny list for :class:`RulesInspector`. Empty ``MONKEYBOT_TOOL_DENIED_PATTERNS`` disables."""
491
+ raw = os.environ.get("MONKEYBOT_TOOL_DENIED_PATTERNS")
492
+ if raw is None:
493
+ return ["rm -rf", "/etc/passwd", "DROP TABLE"]
494
+ return [p.strip() for p in raw.split(",") if p.strip()]
495
+
496
+
497
+ async def _startup(fastapi_app: FastAPI) -> None:
498
+ """Wire storage backend, MCP, inspectors, and provider."""
499
+ from monkeybot.observability import init_observability
500
+
501
+ otel_enabled = init_observability()
502
+ if otel_enabled:
503
+ logger.info("OpenTelemetry tracing initialized")
504
+ else:
505
+ logger.info("OpenTelemetry tracing not enabled")
506
+
507
+ db_url = os.environ.get("DB_URL", "sqlite:///data/monkeybot.db")
508
+
509
+ backend = create_storage_backend(db_url)
510
+ await backend.open(run_schema=auto_schema_enabled_from_config())
511
+ fastapi_app.state.storage = backend
512
+ fastapi_app.state.usage = _UsageStoreAdapter(backend.usage())
513
+
514
+ mcp = MCPClient()
515
+ _deps.mcp = mcp
516
+ mcp_config = Path(os.environ.get("MCP_CONFIG", "/app/monkeybot_config/mcp.json"))
517
+ strict = os.environ.get("MCP_STRICT_LOAD", "").strip().lower() in ("1", "true", "yes")
518
+ try:
519
+ await mcp.load_from_config(mcp_config, raise_on_error=strict)
520
+ except OSError as exc:
521
+ logger.info("MCP config skipped (%s): %s", mcp_config, exc)
522
+
523
+ tiers_path = Path(
524
+ os.environ.get("COMMAND_ALLOWLIST_CONFIG", "/app/monkeybot_config/command_allowlist.yaml")
525
+ )
526
+ _deps.run_command_allowed_commands = None
527
+ _deps.run_command_allowed_path_prefixes = None
528
+ inspectors: list[ToolInspector] = []
529
+ try:
530
+ tier_insp = CommandTierInspector(tiers_path)
531
+ inspectors.append(tier_insp)
532
+ _deps.run_command_allowed_commands = list(tier_insp.allowed_commands)
533
+ _deps.run_command_allowed_path_prefixes = list(tier_insp.allowed_path_prefixes)
534
+ except FileNotFoundError:
535
+ logger.info("command tiers missing (%s); allowing all tool calls", tiers_path)
536
+ except Exception as exc:
537
+ logger.exception("command tier load failed: %s", exc)
538
+
539
+ denied = _tool_denied_patterns()
540
+ if denied:
541
+ inspectors.append(RulesInspector(denied))
542
+
543
+ perm_path = Path(
544
+ os.environ.get("PERMISSION_CONFIG", "/app/monkeybot_config/permissions.yaml")
545
+ )
546
+ perm_insp = try_load_permission_inspector(perm_path)
547
+ if perm_insp is not None:
548
+ inspectors.append(perm_insp)
549
+
550
+ inspectors.append(LoopStartInspector())
551
+ _deps.inspectors = inspectors
552
+
553
+ _deps.provider = _resolve_provider()
554
+ _deps.curator_provider = _resolve_curator_provider(_deps.provider)
555
+
556
+ vertex_gs = vertex_google_search_enabled_from_config()
557
+ try:
558
+ backend_ws = _build_web_search_backend()
559
+ if backend_ws is not None:
560
+ _deps.web_search_tool = WebSearchTool(backend_ws)
561
+ logger.info("web search enabled: backend=%s", backend_ws.name)
562
+ else:
563
+ _deps.web_search_tool = None
564
+ except Exception as exc:
565
+ logger.warning("web search backend init failed — disabling: %s", exc)
566
+ _deps.web_search_tool = None
567
+
568
+ if vertex_gs:
569
+ logger.info("vertex google_search grounding enabled")
570
+ if _deps.web_search_tool is None and not vertex_gs:
571
+ logger.info("web search disabled (WEB_SEARCH_BACKEND=none)")
572
+
573
+ if _memory_enabled():
574
+ try:
575
+ mem_uri = _memory_storage_uri()
576
+ storage = create_workspace_storage(mem_uri)
577
+ mgr = HookManager()
578
+ model_name = os.environ.get("MODEL_NAME", "gemini-2.5-flash")
579
+ memory = MemorySubsystem(
580
+ storage=storage,
581
+ provider=_deps.provider,
582
+ model=model_name,
583
+ memory_uri=mem_uri,
584
+ )
585
+ memory.register_hooks(mgr)
586
+ _deps.hook_manager = mgr
587
+ _deps.memory = memory
588
+ fastapi_app.state.memory = memory
589
+ logger.info("memory hook enabled (memory_storage_uri=%s)", mem_uri)
590
+ try:
591
+ gc_stats = await memory.gc_processed()
592
+ if gc_stats["deleted"] or gc_stats["errors"]:
593
+ logger.info(
594
+ "memory gc: scanned=%d deleted=%d errors=%d",
595
+ gc_stats["scanned"],
596
+ gc_stats["deleted"],
597
+ gc_stats["errors"],
598
+ )
599
+ except Exception as gc_exc:
600
+ logger.warning("memory gc on startup failed: %r", gc_exc)
601
+ except Exception as exc:
602
+ logger.warning("memory hook setup failed; continuing without: %r", exc)
603
+ _deps.hook_manager = None
604
+ _deps.memory = None
605
+ fastapi_app.state.memory = None
606
+ else:
607
+ logger.info("memory hook disabled via MONKEYBOT_MEMORY_HOOK_ENABLED")
608
+ fastapi_app.state.memory = None
609
+
610
+ if attachments_enabled_from_env():
611
+ try:
612
+ fastapi_app.state.attachment_store = FilesystemAttachmentStore(
613
+ resolve_agent_workspace_root()
614
+ )
615
+ logger.info("attachments enabled")
616
+ except Exception as exc:
617
+ logger.warning("attachment store init failed: %s", exc)
618
+ fastapi_app.state.attachment_store = None
619
+ else:
620
+ fastapi_app.state.attachment_store = None
621
+ logger.info("attachments disabled via ATTACHMENTS_ENABLED")
622
+
623
+ try:
624
+ _deps.subagent_registry = get_subagent_registry()
625
+ if _deps.subagent_registry:
626
+ logger.info(
627
+ "subagent personas loaded: %s",
628
+ ", ".join(sorted(_deps.subagent_registry)),
629
+ )
630
+ except ConfigError as exc:
631
+ logger.error("invalid subagents config: %s", exc)
632
+ raise
633
+
634
+ if otel_enabled:
635
+ from monkeybot.observability.instrumentation import instrument_fastapi_app
636
+
637
+ instrument_fastapi_app(fastapi_app)
638
+
639
+ if os.environ.get("MONKEYBOT_WORKER_POOL", "").strip().lower() in ("1", "true", "yes"):
640
+ # Development-only: runs the subagent worker loop on the gateway's own asyncio
641
+ # event loop, so it competes with SSE streaming under load and has no backpressure.
642
+ # For production, run standalone worker processes (`python -m monkeybot.subagents.worker`)
643
+ # that scale independently of the gateway.
644
+ from monkeybot.core.subagents.worker_pool import (
645
+ start_worker_pool_background,
646
+ )
647
+
648
+ logger.warning(
649
+ "MONKEYBOT_WORKER_POOL=1: running the subagent worker pool in-process is "
650
+ "development-only — it shares the gateway event loop with SSE streaming. "
651
+ "For production, run standalone workers via `python -m monkeybot.subagents.worker`."
652
+ )
653
+ fastapi_app.state.worker_pool = start_worker_pool_background(backend)
654
+
655
+ from monkeybot.scheduler.engine import scheduler_enabled_from_env, start_scheduler_background
656
+ from monkeybot.gateway.sse.scheduler_wiring import (
657
+ GatewaySessionEnsurer,
658
+ GatewayTickInvoker,
659
+ StorageSessionBusyChecker,
660
+ )
661
+
662
+ if scheduler_enabled_from_env():
663
+ loop_port = GatewayLoopPort(_registry)
664
+ turn_locks = backend.session_turns()
665
+ fastapi_app.state.scheduler = start_scheduler_background(
666
+ store=backend.scheduled_loops(),
667
+ invoker=GatewayTickInvoker(loop_port, _registry, turn_locks),
668
+ session_busy=StorageSessionBusyChecker(turn_locks),
669
+ ensure_session=GatewaySessionEnsurer(_registry),
670
+ )
671
+ logger.info("scheduled-loop engine enabled (in-process; development-friendly)")
672
+
673
+
674
+ async def _shutdown(fastapi_app: FastAPI) -> None:
675
+ """Tear down MCP sessions and storage backend."""
676
+ from monkeybot.observability import shutdown_observability
677
+
678
+ try:
679
+ shutdown_observability()
680
+ except Exception as exc:
681
+ logger.warning("observability shutdown failed: %s", exc)
682
+
683
+ mcp = _deps.mcp
684
+ if mcp is not None:
685
+ for name in list(getattr(mcp, "_servers", {}).keys()):
686
+ await mcp.disconnect(name)
687
+
688
+ worker_pool_handle = getattr(fastapi_app.state, "worker_pool", None)
689
+ if worker_pool_handle is not None:
690
+ from monkeybot.core.subagents.worker_pool import shutdown_worker_pool
691
+
692
+ await shutdown_worker_pool(worker_pool_handle)
693
+ fastapi_app.state.worker_pool = None
694
+
695
+ scheduler_handle = getattr(fastapi_app.state, "scheduler", None)
696
+ if scheduler_handle is not None:
697
+ from monkeybot.scheduler.engine import shutdown_scheduler
698
+
699
+ await shutdown_scheduler(scheduler_handle)
700
+ fastapi_app.state.scheduler = None
701
+
702
+ storage: StorageBackend | None = getattr(fastapi_app.state, "storage", None)
703
+ if storage is not None:
704
+ await storage.close()
705
+
706
+
707
+ @contextlib.asynccontextmanager
708
+ async def _gateway_lifespan(fastapi_app: FastAPI) -> AsyncIterator[None]:
709
+ await _startup(fastapi_app)
710
+ try:
711
+ yield
712
+ finally:
713
+ await _shutdown(fastapi_app)
714
+
715
+
716
+ _registry = SessionRegistry()
717
+ app = build_sse_app(
718
+ registry=_registry,
719
+ loop_port=GatewayLoopPort(_registry),
720
+ usage_port=_StaticUsagePortZeros(),
721
+ lifespan=_gateway_lifespan,
722
+ )
723
+ app.add_middleware(
724
+ CORSMiddleware,
725
+ allow_origins=_cors_allow_origins(),
726
+ allow_methods=["*"],
727
+ allow_headers=["*"],
728
+ )
729
+
730
+
731
+ def create_app() -> FastAPI:
732
+ """Return the module-level ASGI app (for symmetry with docs/tests)."""
733
+ return app