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,781 @@
1
+ """Per-turn context assembly: base prompt file (AGENT.md), memory index, skills, tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import logging
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Literal, Protocol, runtime_checkable
12
+
13
+ import yaml
14
+
15
+ from monkeybot.core.attachments.config import attachments_enabled_from_env
16
+ from monkeybot.core.attachments.tools import read_attachment_tool_def, render_image_tool_def
17
+ from monkeybot.core.config.settings import SubagentConfig
18
+ from monkeybot.core.mcp.ports_mcp import MCPClientPort
19
+ from monkeybot.core.memory.subsystem import MemorySubsystem
20
+ from monkeybot.core.tools.types import ToolExecutionResult
21
+ from monkeybot.core.types.types_tools import ToolDef
22
+
23
+
24
+ @runtime_checkable
25
+ class CustomTool(Protocol):
26
+ """Protocol for lightweight in-process tools added by framework users.
27
+
28
+ Implement this to add any tool that doesn't warrant a full MCP server.
29
+ The ``tool_def`` is advertised to the model; ``execute`` is called by
30
+ :class:`~monkeybot.core.tools.core_tool_executor.CoreToolExecutor` when the
31
+ model invokes the tool by name.
32
+
33
+ Example::
34
+
35
+ class MyTool:
36
+ tool_def = ToolDef(
37
+ "my_lookup",
38
+ "Look up something.",
39
+ {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
40
+ )
41
+
42
+ async def execute(self, args: dict) -> str:
43
+ return f"result for {args['query']}"
44
+ """
45
+
46
+ tool_def: ToolDef
47
+
48
+ async def execute(self, args: dict[str, object]) -> str | ToolExecutionResult: ...
49
+
50
+
51
+ @runtime_checkable
52
+ class PendingResponseBusPort(Protocol):
53
+ """Minimal gateway bus surface for Story 5 pending UI responses (keeps core free of gateway imports)."""
54
+
55
+ pending_responses: dict[str, asyncio.Future[Any]]
56
+
57
+ def register_pending(self, pending_key: str) -> asyncio.Future[Any]: ...
58
+
59
+ def resolve_pending(self, pending_key: str, payload: Any) -> bool: ...
60
+
61
+ def is_pending_or_terminal(self, pending_key: str) -> Literal["pending", "terminated", "unknown"]: ...
62
+
63
+ def abandon_pending_timeout(self, pending_key: str) -> None: ...
64
+
65
+ def abandon_pending_cancel_all(self) -> None: ...
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class SkillRef:
70
+ """Discovered skill metadata for prompting and listing.
71
+
72
+ Attributes:
73
+ name: Directory name under the skills root.
74
+ description: First instructional line from SKILL.md (after optional frontmatter).
75
+ """
76
+
77
+ name: str
78
+ description: str
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class TurnContext:
83
+ """Immutable bundle of everything needed for one agent turn."""
84
+
85
+ thread_id: str
86
+ request_id: str
87
+ agent_md: str
88
+ memory_index: list[str]
89
+ skills: list[SkillRef]
90
+ tools: list[ToolDef]
91
+ user_id: str | None
92
+ parent_run_id: str | None
93
+ model: str
94
+ summarization_model: str | None = None
95
+ """Optional model id for history compression; env ``CONTEXT_SUMMARIZATION_MODEL`` overrides when set."""
96
+ cancelled: asyncio.Event | None = None
97
+ """When set by the parent run (e.g. gateway Stop), tool code may stop side effects early."""
98
+ context_window_tokens: int = 200_000
99
+ """Max input context for pre-flight checks; summarization triggers near this cap."""
100
+ workspace_root: Path | None = None
101
+ """Workspace root for spill cleanup; optional for tests / minimal harness."""
102
+ memory: MemorySubsystem | None = None
103
+ """Memory subsystem for index refresh and search; optional when memory is disabled."""
104
+ context_curation_enabled: bool = True
105
+ """When True (parent agent), optional LLM curation may narrow memory in the system prompt."""
106
+ sse_bus: PendingResponseBusPort | None = None
107
+ """Gateway session bus for Story 5 pending UI responses; None for CLI / harness."""
108
+ subagent_personas: tuple[tuple[str, str], ...] = ()
109
+ """Named subagent types (name, description) advertised to the parent in the harness."""
110
+ catalog_mcp_servers: tuple[str, ...] = ()
111
+ """Configured MCP server names available via ``enable_mcp`` (not connected until activated)."""
112
+
113
+
114
+ _log = logging.getLogger(__name__)
115
+
116
+ # Built-in tools that mutate the live MCP registry; loop refreshes ctx.tools after these.
117
+ MCP_REGISTRY_MUTATING_TOOLS = frozenset(
118
+ {
119
+ "add_mcp_server",
120
+ "remove_mcp_server",
121
+ "enable_mcp",
122
+ "disable_mcp",
123
+ }
124
+ )
125
+
126
+
127
+ def refresh_tools_after_mcp_change(
128
+ ctx: TurnContext,
129
+ mcp_client: Any,
130
+ ) -> TurnContext:
131
+ """Rebuild ``ctx.tools`` after add/remove/enable/disable MCP (same user turn).
132
+
133
+ Drops tools prefixed with any known MCP server name (catalog + ever-connected),
134
+ then appends the current ``mcp_client.all_tools()`` snapshot.
135
+
136
+ Mutates ``ctx.tools`` in place (frozen dataclass allows mutating the list) so
137
+ callers that hold the same ``TurnContext`` — including the realtime gateway —
138
+ observe the update without needing a returned replacement object.
139
+ """
140
+ prefixes = set(mcp_client.known_server_names())
141
+ kept = [
142
+ t
143
+ for t in ctx.tools
144
+ if not any(t.name.startswith(f"{prefix}__") for prefix in prefixes)
145
+ ]
146
+ ctx.tools[:] = kept + list(mcp_client.all_tools())
147
+ return ctx
148
+
149
+
150
+ def _core_tool_defs(
151
+ *,
152
+ include_task_tool: bool = True,
153
+ subagent_type_names: Sequence[str] | None = None,
154
+ ) -> list[ToolDef]:
155
+ """Static core tools always available before MCP extensions."""
156
+ read_schema: dict[str, object] = {
157
+ "type": "object",
158
+ "properties": {
159
+ "path": {"type": "string", "description": "Repo-relative path under the workspace root."},
160
+ "offset": {"type": "integer", "description": "1-based start line (optional)."},
161
+ "limit": {"type": "integer", "description": "Max lines to return (optional)."},
162
+ },
163
+ "required": ["path"],
164
+ }
165
+ write_schema: dict[str, object] = {
166
+ "type": "object",
167
+ "properties": {
168
+ "path": {"type": "string"},
169
+ "content": {"type": "string", "description": "Full file contents (may be empty)."},
170
+ },
171
+ "required": ["path"],
172
+ }
173
+ replace_schema: dict[str, object] = {
174
+ "type": "object",
175
+ "properties": {
176
+ "path": {"type": "string", "description": "Repo-relative path under the workspace root."},
177
+ "old_string": {
178
+ "type": "string",
179
+ "description": (
180
+ "Substring to replace. Prefer an exact unique match; the tool also "
181
+ "tries light fuzzy matching (line-trim / whitespace) when exact fails."
182
+ ),
183
+ },
184
+ "new_string": {"type": "string", "description": "Replacement text (may be empty)."},
185
+ "replace_all": {
186
+ "type": "boolean",
187
+ "description": "When true, replace every match (default false: require a unique match).",
188
+ },
189
+ },
190
+ "required": ["path", "old_string", "new_string"],
191
+ }
192
+ glob_schema: dict[str, object] = {
193
+ "type": "object",
194
+ "properties": {
195
+ "pattern": {
196
+ "type": "string",
197
+ "description": "Glob pattern (e.g. **/*.html, *.md).",
198
+ },
199
+ "root": {
200
+ "type": "string",
201
+ "description": "Optional repo-relative directory to search under.",
202
+ },
203
+ },
204
+ "required": ["pattern"],
205
+ }
206
+ grep_schema: dict[str, object] = {
207
+ "type": "object",
208
+ "properties": {
209
+ "pattern": {
210
+ "type": "string",
211
+ "description": "Python regex to search for in file contents.",
212
+ },
213
+ "root": {
214
+ "type": "string",
215
+ "description": "Optional repo-relative directory to search under (default workspace root).",
216
+ },
217
+ "ignore_case": {
218
+ "type": "boolean",
219
+ "description": "Case-insensitive search (default false).",
220
+ },
221
+ "file_glob": {
222
+ "type": "string",
223
+ "description": 'Optional filename filter (e.g. "*.py", "*.{ts,tsx}").',
224
+ },
225
+ "max_matches": {
226
+ "type": "integer",
227
+ "description": "Cap on returned matches (default server limit).",
228
+ },
229
+ },
230
+ "required": ["pattern"],
231
+ }
232
+ apply_patch_schema: dict[str, object] = {
233
+ "type": "object",
234
+ "properties": {
235
+ "patch_text": {
236
+ "type": "string",
237
+ "description": (
238
+ "Full Codex-style patch between *** Begin Patch and *** End Patch, "
239
+ "with *** Add File: / *** Update File: / *** Delete File: sections."
240
+ ),
241
+ },
242
+ },
243
+ "required": ["patch_text"],
244
+ }
245
+ search_schema: dict[str, object] = {
246
+ "type": "object",
247
+ "properties": {
248
+ "query": {"type": "string"},
249
+ "q": {"type": "string"},
250
+ "max_hits": {"type": "integer"},
251
+ },
252
+ "required": [],
253
+ }
254
+ run_schema: dict[str, object] = {
255
+ "type": "object",
256
+ "properties": {
257
+ "argv": {"type": "array", "items": {"type": "string"}},
258
+ "command": {"type": "string"},
259
+ "args": {"type": "array", "items": {"type": "string"}},
260
+ "arguments": {"type": "array", "items": {"type": "string"}},
261
+ "shell": {"type": "string"},
262
+ "script": {"type": "string"},
263
+ "timeout": {"type": "integer"},
264
+ },
265
+ "required": [],
266
+ }
267
+ mcp_add_schema: dict[str, object] = {
268
+ "type": "object",
269
+ "properties": {
270
+ "name": {"type": "string"},
271
+ "server_name": {"type": "string"},
272
+ "command": {"type": "string"},
273
+ "args": {"type": "array", "items": {"type": "string"}},
274
+ "env": {"type": "object"},
275
+ },
276
+ "required": [],
277
+ }
278
+ mcp_rm_schema: dict[str, object] = {
279
+ "type": "object",
280
+ "properties": {"name": {"type": "string"}, "server_name": {"type": "string"}},
281
+ "required": [],
282
+ }
283
+ enable_mcp_schema: dict[str, object] = {
284
+ "type": "object",
285
+ "properties": {
286
+ "name": {
287
+ "type": "string",
288
+ "description": "Server name from mcp.json (e.g. browser).",
289
+ },
290
+ "server_name": {"type": "string"},
291
+ "server": {"type": "string"},
292
+ },
293
+ "required": [],
294
+ }
295
+ disable_mcp_schema: dict[str, object] = {
296
+ "type": "object",
297
+ "properties": {
298
+ "name": {"type": "string"},
299
+ "server_name": {"type": "string"},
300
+ "server": {"type": "string"},
301
+ },
302
+ "required": [],
303
+ }
304
+ mcp_server_filter_schema: dict[str, object] = {
305
+ "type": "object",
306
+ "properties": {
307
+ "server": {
308
+ "type": "string",
309
+ "description": "Optional MCP server name. Omit to query all connected servers.",
310
+ },
311
+ "name": {"type": "string"},
312
+ "server_name": {"type": "string"},
313
+ },
314
+ "required": [],
315
+ }
316
+ read_mcp_resource_schema: dict[str, object] = {
317
+ "type": "object",
318
+ "properties": {
319
+ "server": {
320
+ "type": "string",
321
+ "description": "MCP server name exactly as returned by list_mcp_resources.",
322
+ },
323
+ "uri": {
324
+ "type": "string",
325
+ "description": "Resource URI from list_mcp_resources (not necessarily a file URL).",
326
+ },
327
+ "name": {"type": "string"},
328
+ "server_name": {"type": "string"},
329
+ },
330
+ "required": ["uri"],
331
+ }
332
+ get_mcp_prompt_schema: dict[str, object] = {
333
+ "type": "object",
334
+ "properties": {
335
+ "server": {
336
+ "type": "string",
337
+ "description": "MCP server name exactly as returned by list_mcp_prompts.",
338
+ },
339
+ "prompt": {
340
+ "type": "string",
341
+ "description": "Prompt name from list_mcp_prompts.",
342
+ },
343
+ "name": {"type": "string", "description": "Alias for prompt."},
344
+ "server_name": {"type": "string"},
345
+ "arguments": {
346
+ "type": "object",
347
+ "description": "Optional string arguments for the prompt template.",
348
+ "additionalProperties": {"type": "string"},
349
+ },
350
+ },
351
+ "required": [],
352
+ }
353
+ mcp_status_schema: dict[str, object] = {
354
+ "type": "object",
355
+ "properties": {
356
+ "server": {
357
+ "type": "string",
358
+ "description": "Optional server name; omit to list all tracked servers.",
359
+ },
360
+ "name": {"type": "string"},
361
+ "server_name": {"type": "string"},
362
+ },
363
+ "required": [],
364
+ }
365
+ list_skills_schema: dict[str, object] = {"type": "object", "properties": {}}
366
+ task_props: dict[str, object] = {
367
+ "task": {
368
+ "type": "string",
369
+ "description": "Focused objective or question for the subagent to complete.",
370
+ },
371
+ "context": {
372
+ "type": "string",
373
+ "description": "Optional background the parent already gathered (constraints, paths, prior tool output).",
374
+ },
375
+ }
376
+ type_names = sorted({n.strip() for n in (subagent_type_names or []) if n and str(n).strip()})
377
+ subagent_type_schema: dict[str, object] = {
378
+ "type": "string",
379
+ "description": (
380
+ "Named subagent persona from monkeybot.yaml subagents registry. "
381
+ "Omit to use the default subagent AGENT.md."
382
+ ),
383
+ }
384
+ if type_names:
385
+ subagent_type_schema["enum"] = type_names
386
+ task_props["subagent_type"] = subagent_type_schema
387
+ task_schema: dict[str, object] = {
388
+ "type": "object",
389
+ "properties": task_props,
390
+ "required": ["task"],
391
+ }
392
+ tools: list[ToolDef] = [
393
+ ToolDef(
394
+ "run_command",
395
+ "Run an allowlisted shell command with repo-scoped path checks (see TerminalExecutor).",
396
+ run_schema,
397
+ ),
398
+ ToolDef(
399
+ "read_file",
400
+ "Read a UTF-8 text file from the workspace with path validation.",
401
+ read_schema,
402
+ parallel_safe=True,
403
+ ),
404
+ ToolDef(
405
+ "write_file",
406
+ "Write or replace a UTF-8 text file under the workspace root.",
407
+ write_schema,
408
+ ),
409
+ ToolDef(
410
+ "replace_in_file",
411
+ "Replace old_string with new_string in an existing file. Requires a unique match "
412
+ "unless replace_all is true. Uses exact match first, then light fuzzy fallbacks.",
413
+ replace_schema,
414
+ ),
415
+ ToolDef(
416
+ "glob",
417
+ "List workspace file paths matching a glob pattern. Prefer over run_command+ls for discovery.",
418
+ glob_schema,
419
+ parallel_safe=True,
420
+ ),
421
+ ToolDef(
422
+ "grep",
423
+ "Search workspace file contents with a Python regex. Prefer over run_command+grep.",
424
+ grep_schema,
425
+ parallel_safe=True,
426
+ ),
427
+ ToolDef(
428
+ "apply_patch",
429
+ "Apply a multi-file Codex-style patch (Add / Update / Delete / Move). "
430
+ "Fail-closed: nothing is written if any hunk fails to validate.",
431
+ apply_patch_schema,
432
+ ),
433
+ ToolDef(
434
+ "search_memory",
435
+ "Search markdown/text under the memory directory for a keyword or phrase.",
436
+ search_schema,
437
+ parallel_safe=True,
438
+ ),
439
+ ToolDef(
440
+ "list_skills",
441
+ "List installed skills with names, descriptions, and entry points.",
442
+ list_skills_schema,
443
+ parallel_safe=True,
444
+ ),
445
+ ]
446
+ if include_task_tool:
447
+ tools.append(
448
+ ToolDef(
449
+ "task",
450
+ "Spawn a subprocess subagent with the same workspace, memory, and MCP configuration "
451
+ "to work on a delegated objective. Pass subagent_type to select a named persona "
452
+ "(see harness Subagent personas). Returns JSON with the subagent's streamed answer "
453
+ "summary, errors, and usage. Nested task calls are disabled inside the subagent.",
454
+ task_schema,
455
+ ),
456
+ )
457
+ tools.extend(
458
+ [
459
+ ToolDef(
460
+ "enable_mcp",
461
+ "Connect a configured MCP server by name from mcp.json (e.g. browser). "
462
+ "Prefer this over add_mcp_server for known servers. New tools appear on "
463
+ "the next model step this turn.",
464
+ enable_mcp_schema,
465
+ ),
466
+ ToolDef(
467
+ "disable_mcp",
468
+ "Disconnect a connected MCP server by name and drop its tools from the "
469
+ "next model step this turn.",
470
+ disable_mcp_schema,
471
+ ),
472
+ ToolDef(
473
+ "add_mcp_server",
474
+ "Connect an ad-hoc MCP stdio server by name/command; tools appear as "
475
+ "name__tool on the next model step this turn. Prefer enable_mcp for "
476
+ "servers already listed in mcp.json.",
477
+ mcp_add_schema,
478
+ ),
479
+ ToolDef(
480
+ "remove_mcp_server",
481
+ "Disconnect an MCP server by name and drop its tools (next model step).",
482
+ mcp_rm_schema,
483
+ ),
484
+ ToolDef(
485
+ "mcp_status",
486
+ "Show MCP server lifecycle status (catalogued / connected / failed / "
487
+ "needs_auth / disabled). Optional server name filters to one entry.",
488
+ mcp_status_schema,
489
+ parallel_safe=True,
490
+ ),
491
+ ToolDef(
492
+ "list_mcp_resources",
493
+ "List MCP resources from connected servers. Optional server filter. "
494
+ "Connect with enable_mcp first when the server is only catalogued.",
495
+ mcp_server_filter_schema,
496
+ parallel_safe=True,
497
+ ),
498
+ ToolDef(
499
+ "list_mcp_resource_templates",
500
+ "List MCP resource URI templates from connected servers. Optional server filter.",
501
+ mcp_server_filter_schema,
502
+ parallel_safe=True,
503
+ ),
504
+ ToolDef(
505
+ "read_mcp_resource",
506
+ "Read one MCP resource by server name and URI from list_mcp_resources.",
507
+ read_mcp_resource_schema,
508
+ parallel_safe=True,
509
+ ),
510
+ ToolDef(
511
+ "list_mcp_prompts",
512
+ "List MCP prompt templates from connected servers. Optional server filter.",
513
+ mcp_server_filter_schema,
514
+ parallel_safe=True,
515
+ ),
516
+ ToolDef(
517
+ "get_mcp_prompt",
518
+ "Fetch a named MCP prompt template (optional string arguments) from a connected server.",
519
+ get_mcp_prompt_schema,
520
+ parallel_safe=True,
521
+ ),
522
+ ToolDef(
523
+ "start_loop",
524
+ "Start a prompt-first scheduled loop after the user confirms. Pass the agreed "
525
+ "plan in prompt (BUSINESS/RULES). The scheduler fires that prompt on each tick. "
526
+ "Requires durable storage (DB_URL).",
527
+ {
528
+ "type": "object",
529
+ "properties": {
530
+ "prompt": {
531
+ "type": "string",
532
+ "description": "Agreed loop plan / tick instructions.",
533
+ },
534
+ "interval": {
535
+ "type": "string",
536
+ "description": "Tick interval, e.g. 20s, 5m, 1h.",
537
+ },
538
+ "loop_id": {"type": "string"},
539
+ "session_id": {
540
+ "type": "string",
541
+ "description": "Conversation thread for ticks (default loop-main).",
542
+ },
543
+ "max_ticks": {"type": "integer"},
544
+ "max_runtime": {
545
+ "type": "string",
546
+ "description": "Hard wall-clock limit, e.g. 1h.",
547
+ },
548
+ "unbounded": {
549
+ "type": "boolean",
550
+ "description": (
551
+ "Opt out of max_ticks/max_runtime guards. "
552
+ "Requires explicit user confirmation."
553
+ ),
554
+ },
555
+ "skip_if_busy": {"type": "boolean"},
556
+ },
557
+ "required": ["prompt", "interval"],
558
+ },
559
+ ),
560
+ ToolDef(
561
+ "loop_status",
562
+ "Get status of one scheduled loop or list all loops.",
563
+ {
564
+ "type": "object",
565
+ "properties": {"loop_id": {"type": "string"}},
566
+ "required": [],
567
+ },
568
+ parallel_safe=True,
569
+ doom_loop_exempt=True,
570
+ ),
571
+ ToolDef(
572
+ "pause_loop",
573
+ "Pause a scheduled loop.",
574
+ {
575
+ "type": "object",
576
+ "properties": {"loop_id": {"type": "string"}},
577
+ "required": ["loop_id"],
578
+ },
579
+ ),
580
+ ToolDef(
581
+ "resume_loop",
582
+ "Resume a paused scheduled loop.",
583
+ {
584
+ "type": "object",
585
+ "properties": {"loop_id": {"type": "string"}},
586
+ "required": ["loop_id"],
587
+ },
588
+ ),
589
+ ToolDef(
590
+ "stop_loop",
591
+ "Stop a scheduled loop permanently.",
592
+ {
593
+ "type": "object",
594
+ "properties": {"loop_id": {"type": "string"}},
595
+ "required": ["loop_id"],
596
+ },
597
+ ),
598
+ ]
599
+ )
600
+ return tools
601
+
602
+
603
+ def _first_body_line(lines: list[str], start: int, skill_name: str) -> str:
604
+ """Return the first non-empty body line, stripping a leading markdown heading."""
605
+ i = start
606
+ while i < len(lines) and not lines[i].strip():
607
+ i += 1
608
+ if i >= len(lines):
609
+ return f"Skill {skill_name}"
610
+ line = lines[i].strip()
611
+ if line.startswith("#"):
612
+ line = line.lstrip("#").strip()
613
+ return line or f"Skill {skill_name}"
614
+
615
+
616
+ def _parse_skill_description(body: str, skill_name: str) -> str:
617
+ """Return short description from SKILL.md YAML frontmatter or first body line."""
618
+ lines = body.splitlines()
619
+ i = 0
620
+ while i < len(lines) and not lines[i].strip():
621
+ i += 1
622
+ if i >= len(lines):
623
+ return f"Skill {skill_name}"
624
+ if lines[i].strip() != "---":
625
+ return _first_body_line(lines, i, skill_name)
626
+
627
+ frontmatter_end = i + 1
628
+ while frontmatter_end < len(lines) and lines[frontmatter_end].strip() != "---":
629
+ frontmatter_end += 1
630
+ if frontmatter_end >= len(lines):
631
+ return _first_body_line(lines, i, skill_name)
632
+
633
+ frontmatter_text = "\n".join(lines[i + 1 : frontmatter_end])
634
+ try:
635
+ meta = yaml.safe_load(frontmatter_text)
636
+ if isinstance(meta, dict):
637
+ desc = meta.get("description")
638
+ if isinstance(desc, str) and desc.strip():
639
+ return desc.strip()
640
+ except yaml.YAMLError:
641
+ pass
642
+
643
+ return _first_body_line(lines, frontmatter_end + 1, skill_name)
644
+
645
+
646
+ def _discover_skills(skills_path: Path) -> list[SkillRef]:
647
+ """List skills as immediate subdirectories containing ``SKILL.md``."""
648
+ if not skills_path.is_dir():
649
+ return []
650
+
651
+ result: list[SkillRef] = []
652
+ for child in sorted(skills_path.iterdir(), key=lambda p: p.name):
653
+ if not child.is_dir():
654
+ continue
655
+ skill_md = child / "SKILL.md"
656
+ if not skill_md.is_file():
657
+ continue
658
+ text = skill_md.read_text(encoding="utf-8")
659
+ desc = _parse_skill_description(text, child.name)
660
+ result.append(SkillRef(name=child.name, description=desc))
661
+ return result
662
+
663
+
664
+ def _load_agent_md(agent_md_path: Path) -> str:
665
+ """Read AGENT.md; trailing newlines stripped; fail if blank."""
666
+ raw = agent_md_path.read_text(encoding="utf-8")
667
+ content = raw.rstrip("\n")
668
+ if not content.strip():
669
+ raise ValueError(f"AGENT.md is empty or whitespace-only: {agent_md_path}")
670
+ return content
671
+
672
+
673
+ async def build_context(
674
+ thread_id: str,
675
+ request_id: str,
676
+ *,
677
+ agent_md_path: Path,
678
+ memory: MemorySubsystem | None,
679
+ skills_path: Path,
680
+ mcp_client: MCPClientPort,
681
+ user_id: str | None = None,
682
+ parent_run_id: str | None = None,
683
+ model: str = "gemini-2.5-flash",
684
+ summarization_model: str | None = None,
685
+ include_task_tool: bool = True,
686
+ cancelled: asyncio.Event | None = None,
687
+ context_window_tokens: int = 200_000,
688
+ workspace_root: Path | None = None,
689
+ enable_context_curation: bool = True,
690
+ sse_bus: PendingResponseBusPort | None = None,
691
+ extra_tools: Sequence[CustomTool] | None = None,
692
+ subagent_registry: dict[str, SubagentConfig] | None = None,
693
+ ) -> TurnContext:
694
+ """Assemble a TurnContext from filesystem paths and the MCP client snapshot.
695
+
696
+ Args:
697
+ thread_id: Conversation thread id.
698
+ request_id: Per-request correlation id.
699
+ agent_md_path: Path to AGENT.md (must be non-empty).
700
+ memory: Optional memory subsystem; when set, ``INDEX.md`` is loaded via storage.
701
+ skills_path: Root directory for skill folders (each with ``SKILL.md``).
702
+ mcp_client: Client exposing ``all_tools()`` for MCP-registered tools.
703
+ user_id: Optional authenticated user.
704
+ parent_run_id: Optional parent run for subagent linkage.
705
+ model: Model id for this turn.
706
+ summarization_model: Optional model id for sync history summarization; overridden by
707
+ ``CONTEXT_SUMMARIZATION_MODEL`` when that env var is non-empty.
708
+ include_task_tool: When False, omit the ``task`` tool (used by the subagent worker).
709
+ cancelled: Optional cooperative-cancel handle for the parent turn (gateway / CLI).
710
+ context_window_tokens: Model context budget for pre-flight and summarization triggers.
711
+ workspace_root: Optional workspace root for spill directory cleanup at run start.
712
+ enable_context_curation: When False (e.g. subagent), skip LLM context curation for prompts.
713
+ extra_tools: Optional list of in-process :class:`CustomTool` implementations.
714
+ Their ``tool_def`` is appended to the tool list advertised to the model and
715
+ their ``execute`` method is dispatched by :class:`CoreToolExecutor`.
716
+ subagent_registry: Optional map of named subagent personas from monkeybot.yaml.
717
+
718
+ Returns:
719
+ Frozen :class:`TurnContext`.
720
+
721
+ Raises:
722
+ ValueError: When AGENT.md is missing or empty after normalization.
723
+ """
724
+ agent_md = _load_agent_md(agent_md_path)
725
+ memory_index = await memory.load_index() if memory is not None else []
726
+ skills = _discover_skills(skills_path)
727
+ registry = subagent_registry or {}
728
+ type_names = sorted(registry)
729
+ personas = tuple((name, registry[name].description) for name in type_names)
730
+ tools = list(
731
+ _core_tool_defs(
732
+ include_task_tool=include_task_tool,
733
+ subagent_type_names=type_names,
734
+ )
735
+ )
736
+ if attachments_enabled_from_env():
737
+ tools.append(render_image_tool_def())
738
+ tools.append(read_attachment_tool_def())
739
+ tools.extend(mcp_client.all_tools())
740
+ for ct in extra_tools or []:
741
+ tools.append(ct.tool_def)
742
+ catalog_names = tuple(mcp_client.catalog_names())
743
+ return TurnContext(
744
+ thread_id=thread_id,
745
+ request_id=request_id,
746
+ agent_md=agent_md,
747
+ memory_index=memory_index,
748
+ skills=skills,
749
+ tools=tools,
750
+ user_id=user_id,
751
+ parent_run_id=parent_run_id,
752
+ model=model,
753
+ summarization_model=summarization_model,
754
+ cancelled=cancelled,
755
+ context_window_tokens=context_window_tokens,
756
+ workspace_root=workspace_root,
757
+ memory=memory,
758
+ context_curation_enabled=enable_context_curation,
759
+ sse_bus=sse_bus,
760
+ subagent_personas=personas,
761
+ catalog_mcp_servers=catalog_names,
762
+ )
763
+
764
+
765
+ async def refresh_memory_index(ctx: TurnContext) -> TurnContext:
766
+ """Re-read ``INDEX.md`` via ``ctx.memory``; on failure return ``ctx`` unchanged."""
767
+ if ctx.memory is None:
768
+ return ctx
769
+
770
+ try:
771
+ fresh = await ctx.memory.load_index()
772
+ return dataclasses.replace(ctx, memory_index=fresh)
773
+ except Exception as exc:
774
+ _log.warning(
775
+ "[MEMORY] Index refresh FAILED — serving stale index. "
776
+ "thread_id=%s memory_uri=%s error=%r",
777
+ ctx.thread_id,
778
+ ctx.memory.uri,
779
+ exc,
780
+ )
781
+ return ctx