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,249 @@
1
+ """Provider and deployment configuration validation for monkeybot.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from monkeybot.core.config.settings import ConfigError, normalize_model_provider
8
+
9
+ SUPPORTED_MEMORY_BACKENDS = frozenset({"local", "gcs", "drive"})
10
+ SUPPORTED_SECRETS_PROVIDERS = frozenset({"env", "gcp_secret_manager"})
11
+ SUPPORTED_MODEL_PROVIDERS = frozenset(
12
+ {
13
+ "google_vertexai",
14
+ "google_genai",
15
+ "openai",
16
+ "anthropic",
17
+ "vertex_anthropic",
18
+ "huggingface",
19
+ "ollama",
20
+ "nvidia",
21
+ "fake",
22
+ "aws_bedrock",
23
+ }
24
+ )
25
+
26
+ SUPPORTED_YAML_MODEL_PROVIDERS = frozenset(
27
+ {
28
+ "gemini",
29
+ "vertex",
30
+ "google_vertexai",
31
+ "google_genai",
32
+ "openai",
33
+ "anthropic",
34
+ "vertex-claude",
35
+ "vertex_claude",
36
+ "vertex_anthropic",
37
+ "huggingface",
38
+ "ollama",
39
+ "nvidia",
40
+ "fake",
41
+ "aws_bedrock",
42
+ }
43
+ )
44
+
45
+ SUPPORTED_HARNESS_MODES = frozenset({"turn_based", "realtime"})
46
+ SUPPORTED_REALTIME_AUDIO_FORMATS = frozenset(
47
+ {
48
+ "pcm_s16le_24khz_mono",
49
+ "pcm_s16le_16khz_mono",
50
+ }
51
+ )
52
+
53
+
54
+ def validate_provider_env(config: dict[str, str]) -> None:
55
+ """Validate flattened env-style provider configuration.
56
+
57
+ Args:
58
+ config: Flat env var dict (MODEL_PROVIDER, MEMORY_BACKEND, etc.)
59
+
60
+ Raises:
61
+ ConfigError: If unsupported provider or missing required configuration.
62
+ """
63
+ memory_backend = config.get("MEMORY_BACKEND", "local")
64
+ if memory_backend not in SUPPORTED_MEMORY_BACKENDS:
65
+ supported = ", ".join(sorted(SUPPORTED_MEMORY_BACKENDS))
66
+ if memory_backend == "s3":
67
+ raise ConfigError(
68
+ f"memory.backend is set to 's3' but AWS S3 is not yet supported.\n\n"
69
+ f"Currently supported backends: {supported}"
70
+ )
71
+ if memory_backend == "azure_blob":
72
+ raise ConfigError(
73
+ f"memory.backend is set to 'azure_blob' but Azure Blob Storage is not yet supported.\n\n"
74
+ f"Currently supported backends: {supported}"
75
+ )
76
+ raise ConfigError(
77
+ f"memory.backend is set to '{memory_backend}' which is not supported.\n"
78
+ f"Currently supported backends: {supported}"
79
+ )
80
+
81
+ secrets_provider = config.get("SECRETS_PROVIDER", "env")
82
+ if secrets_provider not in SUPPORTED_SECRETS_PROVIDERS:
83
+ supported = ", ".join(sorted(SUPPORTED_SECRETS_PROVIDERS))
84
+ raise ConfigError(
85
+ f"secrets.provider is set to '{secrets_provider}' which is not supported.\n"
86
+ f"Currently supported providers: {supported}"
87
+ )
88
+
89
+ model_provider = normalize_model_provider(config.get("MODEL_PROVIDER", "google_vertexai"))
90
+ if model_provider not in SUPPORTED_MODEL_PROVIDERS:
91
+ supported = ", ".join(sorted(SUPPORTED_MODEL_PROVIDERS))
92
+ if model_provider == "azure_openai":
93
+ raise ConfigError(
94
+ f"model.provider is set to 'azure_openai' but Azure OpenAI is not yet supported.\n\n"
95
+ f"Currently supported providers: {supported}"
96
+ )
97
+ raise ConfigError(
98
+ f"model.provider is set to '{model_provider}' which is not supported.\n"
99
+ f"Currently supported providers: {supported}"
100
+ )
101
+
102
+ gcp_project = (
103
+ config.get("GCP_PROJECT_ID")
104
+ or config.get("VERTEX_AI_PROJECT_ID")
105
+ or config.get("ANTHROPIC_VERTEX_PROJECT_ID")
106
+ or config.get("GOOGLE_CLOUD_PROJECT")
107
+ or ""
108
+ )
109
+
110
+ if memory_backend == "gcs" and not gcp_project:
111
+ raise ConfigError(
112
+ "memory.backend is set to 'gcs' but gcp.project_id is not configured.\n"
113
+ "Add 'gcp.project_id: your-project-id' to monkeybot.yaml"
114
+ )
115
+
116
+ if secrets_provider == "gcp_secret_manager" and not gcp_project:
117
+ raise ConfigError(
118
+ "secrets.provider is set to 'gcp_secret_manager' but gcp.project_id is not configured.\n"
119
+ "Add 'gcp.project_id: your-project-id' to monkeybot.yaml"
120
+ )
121
+
122
+ if model_provider == "vertex_anthropic" and not gcp_project:
123
+ raise ConfigError(
124
+ "model.provider is set to 'vertex_anthropic' but gcp.project_id is not configured.\n"
125
+ "Add 'gcp.project_id: your-project-id' to monkeybot.yaml"
126
+ )
127
+
128
+
129
+ def _validate_harness_mode(doc: dict[str, Any]) -> None:
130
+ """Validate ``harness.mode`` if present. Defaults to ``turn_based``."""
131
+ harness = doc.get("harness")
132
+ if not isinstance(harness, dict):
133
+ return
134
+ mode = str(harness.get("mode", "turn_based")).strip().lower()
135
+ if mode not in SUPPORTED_HARNESS_MODES:
136
+ supported = ", ".join(sorted(SUPPORTED_HARNESS_MODES))
137
+ raise ConfigError(
138
+ f"harness.mode is set to '{mode}' which is not supported.\n"
139
+ f"Supported modes: {supported}"
140
+ )
141
+
142
+
143
+ def _validate_realtime_config(doc: dict[str, Any]) -> None:
144
+ """Validate ``realtime.*`` settings when ``harness.mode`` is ``realtime``."""
145
+ harness = doc.get("harness")
146
+ mode = "turn_based"
147
+ if isinstance(harness, dict):
148
+ mode = str(harness.get("mode", "turn_based")).strip().lower()
149
+
150
+ realtime_raw = doc.get("realtime")
151
+ if not isinstance(realtime_raw, dict):
152
+ realtime_raw = {}
153
+
154
+ def _require_positive(path: str, default: int) -> None:
155
+ parts = path.split(".")
156
+ value: Any = realtime_raw
157
+ for part in parts:
158
+ if not isinstance(value, dict):
159
+ value = None
160
+ break
161
+ value = value.get(part)
162
+ if value is None:
163
+ return
164
+ if not isinstance(value, (int, float)) or value <= 0:
165
+ raise ConfigError(f"realtime.{path} must be a positive number, got {value!r}")
166
+
167
+ # Validate audio format strings even when mode is turn_based so misconfigurations
168
+ # are caught before the mode is flipped.
169
+ audio_raw = realtime_raw.get("audio")
170
+ audio: dict[str, Any] = audio_raw if isinstance(audio_raw, dict) else {}
171
+ for fmt_key in ("input_format", "output_format"):
172
+ fmt = str(audio.get(fmt_key, "pcm_s16le_24khz_mono")).strip().lower()
173
+ if fmt and fmt not in SUPPORTED_REALTIME_AUDIO_FORMATS:
174
+ supported = ", ".join(sorted(SUPPORTED_REALTIME_AUDIO_FORMATS))
175
+ raise ConfigError(
176
+ f"realtime.audio.{fmt_key} is set to '{fmt}' which is not supported.\n"
177
+ f"Supported formats: {supported}"
178
+ )
179
+
180
+ chunk_ms = audio.get("chunk_ms")
181
+ if chunk_ms is not None and (not isinstance(chunk_ms, int) or chunk_ms <= 0):
182
+ raise ConfigError(f"realtime.audio.chunk_ms must be a positive integer, got {chunk_ms!r}")
183
+
184
+ _require_positive("audio.max_utterance_sec", 60)
185
+ _require_positive("session.max_duration_sec", 1800)
186
+ _require_positive("session.idle_timeout_sec", 120)
187
+ _require_positive("session.max_response_turn_sec", 300)
188
+
189
+ max_concurrent = realtime_raw.get("session", {}).get("max_concurrent_sessions")
190
+ if max_concurrent is not None and (not isinstance(max_concurrent, int) or max_concurrent <= 0):
191
+ raise ConfigError(
192
+ f"realtime.session.max_concurrent_sessions must be a positive integer, "
193
+ f"got {max_concurrent!r}"
194
+ )
195
+
196
+ if mode != "realtime":
197
+ return
198
+
199
+ # Realtime mode requires the provider to be from the Gemini family for v1.
200
+ model_raw = doc.get("model")
201
+ model: dict[str, Any] = model_raw if isinstance(model_raw, dict) else {}
202
+ provider = str(model.get("provider", "gemini")).strip().lower()
203
+ if provider not in {"gemini", "vertex", "google_vertexai", "google_genai"}:
204
+ raise ConfigError(
205
+ "harness.mode is 'realtime' but model.provider is not a Gemini family provider. "
206
+ "Realtime v1 only supports google_vertexai or google_genai (Gemini Live)."
207
+ )
208
+
209
+
210
+ def validate_monkeybot_yaml_doc(doc: dict[str, Any], *, env: dict[str, str] | None = None) -> None:
211
+ """Validate a parsed monkeybot.yaml document (new schema).
212
+
213
+ Args:
214
+ doc: Parsed YAML root mapping.
215
+ env: Optional environment for GCP project resolution.
216
+
217
+ Raises:
218
+ ConfigError: If unsupported provider or missing required configuration.
219
+ """
220
+ env = env or {}
221
+ model_raw = doc.get("model")
222
+ model: dict[str, Any] = model_raw if isinstance(model_raw, dict) else {}
223
+ provider_raw = str(model.get("provider", "gemini")).strip().lower()
224
+ if provider_raw and provider_raw not in SUPPORTED_YAML_MODEL_PROVIDERS:
225
+ supported = ", ".join(sorted(SUPPORTED_YAML_MODEL_PROVIDERS))
226
+ raise ConfigError(
227
+ f"model.provider is set to '{provider_raw}' which is not supported.\n"
228
+ f"Currently supported providers: {supported}"
229
+ )
230
+
231
+ _validate_harness_mode(doc)
232
+ _validate_realtime_config(doc)
233
+
234
+ flat: dict[str, str] = {}
235
+ if isinstance(model, dict) and model.get("provider"):
236
+ flat["MODEL_PROVIDER"] = str(model["provider"])
237
+ memory_uri = ""
238
+ paths = doc.get("paths") if isinstance(doc.get("paths"), dict) else {}
239
+ if isinstance(paths, dict):
240
+ memory_uri = str(paths.get("memory_storage_uri", ""))
241
+ if memory_uri.startswith("gcs://"):
242
+ flat["MEMORY_BACKEND"] = "gcs"
243
+ gcp = doc.get("gcp") if isinstance(doc.get("gcp"), dict) else {}
244
+ if isinstance(gcp, dict) and gcp.get("project_id"):
245
+ flat["GCP_PROJECT_ID"] = str(gcp["project_id"])
246
+ for key in ("GCP_PROJECT_ID", "VERTEX_AI_PROJECT_ID", "ANTHROPIC_VERTEX_PROJECT_ID", "GOOGLE_CLOUD_PROJECT"):
247
+ if key in env and env[key]:
248
+ flat[key] = env[key]
249
+ validate_provider_env(flat)
@@ -0,0 +1,45 @@
1
+ """Load and merge monkeybot.yaml documents (shared by runtime and subagent registry)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from monkeybot.core.config.runtime_env import _load_yaml_file, _merge_with_includes
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def resolve_monkeybot_config_path(
16
+ explicit: str | Path | None = None,
17
+ *,
18
+ cwd: Path | None = None,
19
+ ) -> Path | None:
20
+ """Resolve monkeybot.yaml path from explicit arg, MONKEYBOT_CONFIG, or default."""
21
+ if explicit is not None:
22
+ p = Path(explicit).expanduser()
23
+ return p.resolve() if p.is_file() else None
24
+ env_path = os.environ.get("MONKEYBOT_CONFIG", "").strip()
25
+ if env_path:
26
+ p = Path(env_path).expanduser()
27
+ if p.is_file():
28
+ return p.resolve()
29
+ logger.warning("MONKEYBOT_CONFIG is set but not a file: %s", p)
30
+ return None
31
+ base = (cwd if cwd is not None else Path.cwd()).expanduser().resolve()
32
+ default = base / "monkeybot_config" / "monkeybot.yaml"
33
+ if default.is_file():
34
+ return default.resolve()
35
+ return None
36
+
37
+
38
+ def load_monkeybot_yaml_dict(config_path: str | Path | None = None) -> tuple[Path | None, dict[str, Any]]:
39
+ """Load merged monkeybot.yaml (with includes) as a dict."""
40
+ path = resolve_monkeybot_config_path(config_path)
41
+ if path is None:
42
+ return None, {}
43
+ root = _load_yaml_file(path)
44
+ merged = _merge_with_includes(path, root)
45
+ return path, merged