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,1548 @@
1
+ """Default tool executor: core filesystem, memory search, shell (allowlisted), and MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import contextlib
8
+ import json
9
+ import logging
10
+ import os
11
+ import shlex
12
+ import uuid
13
+ from collections.abc import Awaitable, Callable, Sequence
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
18
+ from monkeybot.core.attachments.config import IMAGE_MIME_TYPES
19
+ from monkeybot.core.attachments.store import AttachmentStore, sniff_mime
20
+ from monkeybot.core.config.settings import SubagentConfig
21
+ from monkeybot.core.context import CustomTool, TurnContext
22
+ from monkeybot.core.context.tool_result_ingress import (
23
+ cap_tool_result_text,
24
+ sanitize_tool_result_text,
25
+ skip_tool_result_sanitize,
26
+ )
27
+ from monkeybot.core.llm.provider import ToolCall
28
+ from monkeybot.core.logging_utils import kv
29
+ from monkeybot.core.mcp.mcp_client import (
30
+ MCPConnectionError,
31
+ MCPDiagnosticError,
32
+ MCPServerNotConnectedError,
33
+ )
34
+ from monkeybot.core.mcp.ports_mcp import MCPClientPort
35
+ from monkeybot.core.memory.subsystem import MemorySubsystem
36
+ from monkeybot.core.persistence.backends import RunStore, ScheduledLoopStore
37
+ from monkeybot.core.persistence.durable_runs import SubagentEnvelope as PersistedSubagentEnvelope
38
+ from monkeybot.core.persistence.scheduled_loops import ScheduledLoopCreate
39
+ from monkeybot.scheduler.interval import parse_interval_ms, parse_optional_duration_ms
40
+ from monkeybot.core.persistence.runs import make_run_id
41
+ from monkeybot.core.runtime.events import (
42
+ AssistantDelta,
43
+ Error,
44
+ ToolCallResult,
45
+ ToolCallStarted,
46
+ TurnComplete,
47
+ )
48
+ from monkeybot.core.runtime.loop import ToolExecutorPort
49
+ from monkeybot.core.subagents.subagent_proto import (
50
+ SubagentEnvelope,
51
+ normalize_sqlite_db_url,
52
+ resolve_agent_project_root,
53
+ resolve_project_path,
54
+ resolve_subagent_script,
55
+ resolve_task_agent_md_path,
56
+ spawn_subagent,
57
+ )
58
+ from monkeybot.core.tools.sandbox_executor import SandboxConfig, SandboxExecutor
59
+ from monkeybot.core.tools.spill_inventory import spill_inventory_note, spill_min_chars_from_env
60
+ from monkeybot.core.tools.terminal import (
61
+ ALLOWED_COMMANDS,
62
+ ALLOWED_PATHS,
63
+ SecurityError,
64
+ TerminalExecutor,
65
+ )
66
+ from monkeybot.core.tools.types import ToolExecutionResult
67
+ from monkeybot.core.tools.workspace_service import (
68
+ WorkspaceError,
69
+ WorkspaceFileService,
70
+ WorkspaceSettings,
71
+ )
72
+ from monkeybot.core.types.content_blocks import ContentBlock, File, Image, Text
73
+
74
+ logger = logging.getLogger(__name__)
75
+
76
+ _SUBAGENT_OTEL_SERVICE_NAME = "monkeybot-subagent"
77
+
78
+ _PARENT_CANCEL_TASK_ERR = "task: cancelled (parent)"
79
+
80
+ _SPILL_DIR = ".monkeybot/spill"
81
+
82
+ _CORE_TOOL_NAMES = frozenset(
83
+ {
84
+ "read_attachment",
85
+ "render_image",
86
+ "read_file",
87
+ "write_file",
88
+ "replace_in_file",
89
+ "glob",
90
+ "grep",
91
+ "apply_patch",
92
+ "search_memory",
93
+ "list_skills",
94
+ "task",
95
+ "run_command",
96
+ "add_mcp_server",
97
+ "remove_mcp_server",
98
+ "enable_mcp",
99
+ "disable_mcp",
100
+ "mcp_status",
101
+ "list_mcp_resources",
102
+ "list_mcp_resource_templates",
103
+ "read_mcp_resource",
104
+ "list_mcp_prompts",
105
+ "get_mcp_prompt",
106
+ "start_loop",
107
+ "loop_status",
108
+ "pause_loop",
109
+ "resume_loop",
110
+ "stop_loop",
111
+ }
112
+ )
113
+
114
+ _SPILL_SKIP_TOOLS = frozenset({"read_file", "read_attachment"})
115
+
116
+ def _tool_handler_kind(name: str, *, mcp: MCPClientPort, extra_tools: dict[str, CustomTool]) -> str:
117
+ if name in _CORE_TOOL_NAMES:
118
+ return "core"
119
+ if name in extra_tools:
120
+ return "extra"
121
+ if mcp.split_prefixed_tool(name) is not None:
122
+ return "mcp"
123
+ return "unknown"
124
+
125
+
126
+ def _safe_spill_filename(call_id: str) -> str:
127
+ safe = "".join(c for c in call_id if c.isalnum() or c in "-_")[:200]
128
+ return safe or "call"
129
+
130
+
131
+ def _int_env(name: str, default: int) -> int:
132
+ raw = os.environ.get(name, "").strip()
133
+ if not raw:
134
+ return default
135
+ try:
136
+ return int(raw)
137
+ except ValueError:
138
+ return default
139
+
140
+
141
+ def workspace_settings_from_env() -> WorkspaceSettings:
142
+ """Build workspace read limits from harness env (yaml-backed via runtime_env)."""
143
+ return WorkspaceSettings(
144
+ WORKSPACE_READ_MAX_LINES=_int_env("MONKEYBOT_READ_MAX_LINES", 5000),
145
+ WORKSPACE_READ_DEFAULT_LINES=_int_env("MONKEYBOT_READ_DEFAULT_LINES", 2000),
146
+ WORKSPACE_SPILL_READ_MAX_LINES=_int_env("MONKEYBOT_SPILL_READ_MAX_LINES", 50_000),
147
+ )
148
+
149
+
150
+ def _write_spill_with_inventory(
151
+ text: str,
152
+ workspace_root: Path,
153
+ thread_id: str,
154
+ call_id: str,
155
+ ) -> str:
156
+ """Write raw ``text`` to spill file; return inventory pointer only (not inline body)."""
157
+ rel = f"{_SPILL_DIR}/{thread_id}/{_safe_spill_filename(call_id)}.txt"
158
+ out_path = (Path(workspace_root) / rel).resolve()
159
+ out_path.parent.mkdir(parents=True, exist_ok=True)
160
+ out_path.write_text(text, encoding="utf-8")
161
+ return spill_inventory_note(text, rel)
162
+
163
+
164
+ def _is_under_spill_path(workspace_root: Path, rel_path: str) -> bool:
165
+ s = str(rel_path).strip().replace("\\", "/").lstrip("/")
166
+ if not s or ".." in s or s.startswith("~"):
167
+ return False
168
+ try:
169
+ resolved = (Path(workspace_root) / s).resolve()
170
+ resolved.relative_to(Path(workspace_root).resolve())
171
+ except ValueError:
172
+ return False
173
+ spill_root = (Path(workspace_root).resolve() / _SPILL_DIR).resolve()
174
+ try:
175
+ resolved.relative_to(spill_root)
176
+ return True
177
+ except ValueError:
178
+ return False
179
+
180
+
181
+ async def _stop_subagent_process(proc: asyncio.subprocess.Process | None) -> None:
182
+ """SIGTERM then reap; SIGKILL if still alive after a short wait."""
183
+ if proc is None or proc.returncode is not None:
184
+ return
185
+ try:
186
+ proc.terminate()
187
+ except ProcessLookupError:
188
+ return
189
+ try:
190
+ await asyncio.wait_for(proc.wait(), timeout=8.0)
191
+ except TimeoutError:
192
+ with contextlib.suppress(ProcessLookupError):
193
+ proc.kill()
194
+ with contextlib.suppress(ProcessLookupError):
195
+ await proc.wait()
196
+
197
+
198
+ def _j(data: object) -> str:
199
+ return json.dumps(data, ensure_ascii=False)
200
+
201
+
202
+ def _built_in_tool_error(
203
+ error_kind: str,
204
+ message: str,
205
+ hint: str,
206
+ details: dict[str, Any] | None = None,
207
+ ) -> str:
208
+ """JSON tool-error body for built-in tools (helps the model recover without MCP wrapping)."""
209
+ payload: dict[str, Any] = {
210
+ "ok": False,
211
+ "error_kind": error_kind,
212
+ "message": message,
213
+ "hint": hint,
214
+ }
215
+ if details:
216
+ payload["details"] = details
217
+ return _j(payload)
218
+
219
+
220
+ def _workspace_error_envelope(exc: WorkspaceError) -> str:
221
+ code = getattr(exc, "code", "workspace_error")
222
+ msg = str(exc)
223
+ if code == "path_escape":
224
+ hint = (
225
+ "Use a path relative to the workspace root with no `..` or `~` "
226
+ '(e.g. {"path": "README.md"}).'
227
+ )
228
+ elif code == "invalid_path":
229
+ hint = "Remove `..`, `~`, and leading `/`; use a repo-relative path."
230
+ elif code == "missing_path":
231
+ hint = 'Include a non-empty "path" argument (workspace-relative).'
232
+ elif code == "write_outside_scope":
233
+ hint = "Write only under the configured write scope, or ask the operator to adjust policy."
234
+ elif code == "not_found":
235
+ hint = "Create the file with write_file first, or fix the path spelling."
236
+ elif code == "not_found_replace":
237
+ hint = "Re-read the file and provide an exact unique old_string (or rely on fuzzy match with more context)."
238
+ elif code == "ambiguous_replace":
239
+ hint = "Widen old_string until unique, or set replace_all=true."
240
+ elif code == "invalid_offset":
241
+ hint = 'Use "offset" as a positive integer (1 = first line).'
242
+ elif code in ("write_failed", "glob_failed", "delete_failed"):
243
+ hint = "Check disk permissions and path; retry after fixing the underlying issue."
244
+ else:
245
+ hint = "Fix the path or arguments per read_file/write_file rules, then retry once."
246
+ return _built_in_tool_error("validation", msg, hint, {"code": code})
247
+
248
+
249
+ def _run_command_parse_envelope(exc: ValueError) -> str:
250
+ return _built_in_tool_error(
251
+ "validation",
252
+ str(exc),
253
+ 'Use a non-empty argv list, e.g. {"argv": ["git", "--version"]} '
254
+ 'or {"command": "git", "args": ["--version"]}.',
255
+ {"example": {"argv": ["git", "--version"]}},
256
+ )
257
+
258
+
259
+ def _coerce_int(val: object | None, default: int | None = None) -> int | None:
260
+ if val is None:
261
+ return default
262
+ if isinstance(val, bool):
263
+ return int(val)
264
+ if isinstance(val, int):
265
+ return val
266
+ if isinstance(val, float):
267
+ return int(val)
268
+ if isinstance(val, str) and val.strip():
269
+ s = val.strip()
270
+ if s.lstrip("-").isdigit():
271
+ return int(s)
272
+ return default
273
+
274
+
275
+ def _inject_subagent_traceparent() -> str | None:
276
+ try:
277
+ from monkeybot.observability.propagation import inject_traceparent
278
+ except ImportError:
279
+ return None
280
+ carrier: dict[str, str] = {}
281
+ inject_traceparent(carrier)
282
+ return carrier.get("traceparent")
283
+
284
+
285
+ def _str_arg(args: dict[str, Any], *keys: str) -> str | None:
286
+ for k in keys:
287
+ v = args.get(k)
288
+ if isinstance(v, str) and v.strip():
289
+ return v.strip()
290
+ return None
291
+
292
+
293
+ _SHELL_OPERATOR_TOKENS = frozenset({"&&", "||", ";", "|", "&"})
294
+
295
+
296
+ def _needs_shell_wrapper(stripped: str, parts: list[str]) -> bool:
297
+ if any(part in _SHELL_OPERATOR_TOKENS for part in parts):
298
+ return True
299
+ return any(op in stripped for op in _SHELL_OPERATOR_TOKENS)
300
+
301
+
302
+ def _argv_from_command_string(stripped: str) -> tuple[str, list[str]]:
303
+ """Parse a command string; wrap in ``bash -c`` when shell operators are present."""
304
+ if not any(ch.isspace() for ch in stripped):
305
+ parts = shlex.split(stripped, posix=True)
306
+ if parts and _needs_shell_wrapper(stripped, parts):
307
+ return "bash", ["-c", stripped]
308
+ return stripped, []
309
+ parts = shlex.split(stripped, posix=True)
310
+ if not parts:
311
+ return stripped, []
312
+ if _needs_shell_wrapper(stripped, parts):
313
+ return "bash", ["-c", stripped]
314
+ return parts[0], parts[1:]
315
+
316
+
317
+ def _parse_run_command(args: dict[str, Any]) -> tuple[str, list[str]]:
318
+ argv_raw = args.get("argv")
319
+ if isinstance(argv_raw, list) and argv_raw:
320
+ argv = [str(x) for x in argv_raw]
321
+ return argv[0], argv[1:]
322
+
323
+ cmd = args.get("command")
324
+ if isinstance(cmd, str) and cmd.strip():
325
+ extra = args.get("args")
326
+ if extra is None:
327
+ extra = args.get("arguments")
328
+ if isinstance(extra, list):
329
+ if extra:
330
+ return cmd.strip(), [str(x) for x in extra]
331
+ return _argv_from_command_string(cmd.strip())
332
+ return _argv_from_command_string(cmd.strip())
333
+
334
+ shell = args.get("shell") or args.get("script")
335
+ if isinstance(shell, str) and shell.strip():
336
+ stripped = shell.strip()
337
+ parts = shlex.split(stripped, posix=True)
338
+ if not parts:
339
+ raise ValueError("shell/script is empty after parsing")
340
+ return _argv_from_command_string(stripped)
341
+
342
+ raise ValueError(
343
+ "run_command needs one of: argv (non-empty list), command+args/arguments, "
344
+ "or shell/script (parsed with shlex)"
345
+ )
346
+
347
+
348
+
349
+ class CoreToolExecutor(ToolExecutorPort):
350
+ """Executes built-in tools and delegates ``server__tool`` calls to :class:`MCPClient`."""
351
+
352
+ def __init__(
353
+ self,
354
+ *,
355
+ workspace_root: Path,
356
+ memory: MemorySubsystem | None,
357
+ skills_path: Path,
358
+ mcp: MCPClientPort,
359
+ terminal: TerminalExecutor | SandboxExecutor | None = None,
360
+ extra_tools: Sequence[CustomTool] | None = None,
361
+ run_command_allowed_commands: list[str] | tuple[str, ...] | None = None,
362
+ run_command_allowed_path_prefixes: list[str] | tuple[str, ...] | None = None,
363
+ attachment_store: AttachmentStore | None = None,
364
+ attachment_catalog: SessionAttachmentCatalog | None = None,
365
+ run_store: RunStore | None = None,
366
+ scheduled_loop_store: ScheduledLoopStore | None = None,
367
+ subagent_registry: dict[str, SubagentConfig] | None = None,
368
+ ) -> None:
369
+ ws_settings = workspace_settings_from_env()
370
+ self._workspace = WorkspaceFileService(Path(workspace_root).resolve(), settings=ws_settings)
371
+ self._spill_read_max_lines = ws_settings.WORKSPACE_SPILL_READ_MAX_LINES
372
+ self._spill_min_chars = spill_min_chars_from_env()
373
+ self._memory = memory
374
+ self._skills_path = Path(skills_path).resolve()
375
+ self._mcp = mcp
376
+ self._attachment_store = attachment_store
377
+ self._attachment_catalog = attachment_catalog
378
+ self._run_store = run_store
379
+ self._scheduled_loop_store = scheduled_loop_store
380
+ self._subagent_registry = dict(subagent_registry or {})
381
+ self._terminal: TerminalExecutor | SandboxExecutor
382
+ if terminal is not None:
383
+ self._terminal = terminal
384
+ self._run_cmd_allowed_commands = tuple(terminal.allowed_commands)
385
+ self._run_cmd_allowed_paths = tuple(terminal.allowed_path_prefixes)
386
+ else:
387
+ cmds = (
388
+ tuple(run_command_allowed_commands)
389
+ if run_command_allowed_commands is not None
390
+ else tuple(ALLOWED_COMMANDS)
391
+ )
392
+ paths = (
393
+ tuple(run_command_allowed_path_prefixes)
394
+ if run_command_allowed_path_prefixes is not None
395
+ else tuple(ALLOWED_PATHS)
396
+ )
397
+ self._run_cmd_allowed_commands = cmds
398
+ self._run_cmd_allowed_paths = paths
399
+ _scfg = SandboxConfig.from_env()
400
+ self._terminal = (
401
+ SandboxExecutor(_scfg, workspace_root, allowed_commands=cmds)
402
+ if _scfg.enabled
403
+ else TerminalExecutor(allowed_commands=cmds, allowed_path_prefixes=paths)
404
+ )
405
+ self._extra_tools: dict[str, Any] = {
406
+ ct.tool_def.name: ct for ct in (extra_tools or [])
407
+ }
408
+
409
+ @property
410
+ def mcp(self) -> MCPClientPort:
411
+ """MCP client used for built-in MCP tools and ``server__tool`` dispatch."""
412
+ return self._mcp
413
+
414
+ def _run_command_security_envelope(self, exc: SecurityError) -> str:
415
+ raw = str(exc)
416
+ if raw.startswith("Command '") and "not allowed" in raw:
417
+ return _built_in_tool_error(
418
+ "policy",
419
+ raw,
420
+ "Pick a binary from the harness allowlist (run_command); do not retry the same command name.",
421
+ {
422
+ "example_argv": ["git", "--version"],
423
+ "allowed_commands": list(self._run_cmd_allowed_commands),
424
+ },
425
+ )
426
+ if raw.startswith("Path '") and "not allowed" in raw:
427
+ return _built_in_tool_error(
428
+ "policy",
429
+ raw,
430
+ "Arguments starting with ./ or / must use an allowed prefix (see harness Runtime paths / run_command); change the path, then retry.",
431
+ {
432
+ "example_argv": ["grep", "pattern", "./skills/SKILL.md"],
433
+ "allowed_path_prefixes": list(self._run_cmd_allowed_paths),
434
+ },
435
+ )
436
+ return _built_in_tool_error(
437
+ "policy",
438
+ raw,
439
+ "Adjust the shell invocation to satisfy run_command policy (see harness), then retry once.",
440
+ {},
441
+ )
442
+
443
+ async def aclose(self) -> None:
444
+ """Release resources held by the terminal executor for this session."""
445
+ await self._terminal.aclose()
446
+
447
+ async def execute(self, *, call: ToolCall, ctx: TurnContext) -> ToolExecutionResult:
448
+ name = call.name
449
+ args: dict[str, Any] = dict(call.args)
450
+ result_text: str | None = None
451
+ err_text: str | None = None
452
+ handler = _tool_handler_kind(name, mcp=self._mcp, extra_tools=self._extra_tools)
453
+ logger.debug(
454
+ "tool dispatch %s",
455
+ kv(
456
+ request_id=ctx.request_id,
457
+ thread_id=ctx.thread_id,
458
+ tool=name,
459
+ call_id=call.call_id,
460
+ handler=handler,
461
+ ),
462
+ )
463
+
464
+ try:
465
+ if name == "read_attachment":
466
+ return self._tool_read_attachment(args, ctx)
467
+ if name == "render_image":
468
+ return self._tool_render_image(args, ctx)
469
+ if name == "read_file":
470
+ result_text, err_text = self._tool_read_file(args)
471
+ elif name == "write_file":
472
+ result_text, err_text = self._tool_write_file(args)
473
+ elif name == "replace_in_file":
474
+ result_text, err_text = self._tool_replace_in_file(args)
475
+ elif name == "glob":
476
+ result_text, err_text = self._tool_glob(args)
477
+ elif name == "grep":
478
+ result_text, err_text = self._tool_grep(args)
479
+ elif name == "apply_patch":
480
+ result_text, err_text = self._tool_apply_patch(args)
481
+ elif name == "search_memory":
482
+ result_text, err_text = await self._tool_search_memory(args)
483
+ elif name == "list_skills":
484
+ result_text, err_text = self._tool_list_skills(ctx)
485
+ elif name == "task":
486
+ result_text, err_text = await self._tool_task(call, ctx)
487
+ elif name == "run_command":
488
+ result_text, err_text = await self._tool_run_command(args)
489
+ elif name == "add_mcp_server":
490
+ result_text, err_text = await self._tool_add_mcp_server(args)
491
+ elif name == "remove_mcp_server":
492
+ result_text, err_text = await self._tool_remove_mcp_server(args)
493
+ elif name == "enable_mcp":
494
+ result_text, err_text = await self._tool_enable_mcp(args)
495
+ elif name == "disable_mcp":
496
+ result_text, err_text = await self._tool_disable_mcp(args)
497
+ elif name == "mcp_status":
498
+ result_text, err_text = self._tool_mcp_status(args)
499
+ elif name == "list_mcp_resources":
500
+ result_text, err_text = await self._tool_list_mcp_resources(args)
501
+ elif name == "list_mcp_resource_templates":
502
+ result_text, err_text = await self._tool_list_mcp_resource_templates(args)
503
+ elif name == "read_mcp_resource":
504
+ result_text, err_text = await self._tool_read_mcp_resource(args)
505
+ elif name == "list_mcp_prompts":
506
+ result_text, err_text = await self._tool_list_mcp_prompts(args)
507
+ elif name == "get_mcp_prompt":
508
+ result_text, err_text = await self._tool_get_mcp_prompt(args)
509
+ elif name == "start_loop":
510
+ result_text, err_text = await self._tool_start_loop(args, ctx)
511
+ elif name == "loop_status":
512
+ result_text, err_text = await self._tool_loop_status(args)
513
+ elif name == "pause_loop":
514
+ result_text, err_text = await self._tool_pause_loop(args)
515
+ elif name == "resume_loop":
516
+ result_text, err_text = await self._tool_resume_loop(args)
517
+ elif name == "stop_loop":
518
+ result_text, err_text = await self._tool_stop_loop(args)
519
+ elif name in self._extra_tools:
520
+ try:
521
+ raw = await self._extra_tools[name].execute(args)
522
+ if isinstance(raw, ToolExecutionResult):
523
+ return raw
524
+ result_text = raw
525
+ err_text = None
526
+ except Exception as exc:
527
+ logger.warning(
528
+ "tool handler failed %s",
529
+ kv(
530
+ request_id=ctx.request_id,
531
+ thread_id=ctx.thread_id,
532
+ tool=name,
533
+ call_id=call.call_id,
534
+ handler="extra",
535
+ ),
536
+ exc_info=True,
537
+ )
538
+ result_text, err_text = None, _built_in_tool_error(
539
+ "runtime",
540
+ str(exc),
541
+ "Fix the underlying issue described in message, then retry once if appropriate.",
542
+ {"tool": name},
543
+ )
544
+ else:
545
+ mcp_pair = self._mcp.split_prefixed_tool(name)
546
+ if mcp_pair is not None:
547
+ server_name, tool_name = mcp_pair
548
+ try:
549
+ text = await self._mcp.call_tool(server_name, tool_name, args)
550
+ result_text, err_text = text, None
551
+ except MCPServerNotConnectedError as exc:
552
+ result_text, err_text = None, str(exc)
553
+ else:
554
+ result_text, err_text = None, _built_in_tool_error(
555
+ "runtime",
556
+ f"unknown tool: {name}",
557
+ "Use a tool from the active tool list for this turn.",
558
+ {"tool": name},
559
+ )
560
+ except WorkspaceError as exc:
561
+ result_text, err_text = None, _workspace_error_envelope(exc)
562
+ except MCPDiagnosticError as exc:
563
+ result_text, err_text = None, str(exc)
564
+ except MCPConnectionError as exc:
565
+ result_text, err_text = None, str(exc)
566
+ except SecurityError as exc:
567
+ result_text, err_text = None, self._run_command_security_envelope(exc)
568
+ except (TimeoutError, ValueError, TypeError, OSError) as exc:
569
+ result_text, err_text = None, _built_in_tool_error(
570
+ "runtime",
571
+ str(exc),
572
+ "Fix the underlying issue described in message, then retry once if appropriate.",
573
+ {"tool": name},
574
+ )
575
+ except Exception as exc:
576
+ logger.exception("tool %s failed", name)
577
+ result_text, err_text = None, _built_in_tool_error(
578
+ "runtime",
579
+ str(exc),
580
+ "If this persists, stop retrying the same tool call and report the error.",
581
+ {"tool": name},
582
+ )
583
+
584
+ if err_text is None and result_text is not None:
585
+ skip_sanitize = skip_tool_result_sanitize(name)
586
+ should_spill = (
587
+ name not in _SPILL_SKIP_TOOLS
588
+ and self._spill_min_chars > 0
589
+ and len(result_text) >= self._spill_min_chars
590
+ )
591
+ if should_spill:
592
+ result_text = _write_spill_with_inventory(
593
+ result_text, self._workspace.repo_root, ctx.thread_id, call.call_id
594
+ )
595
+ if not skip_sanitize:
596
+ result_text = sanitize_tool_result_text(result_text)
597
+ else:
598
+ if not skip_sanitize:
599
+ result_text = sanitize_tool_result_text(result_text)
600
+ result_text = cap_tool_result_text(result_text)
601
+ if err_text is not None:
602
+ return ToolExecutionResult.err(err_text)
603
+ return ToolExecutionResult.ok_text(result_text or "")
604
+
605
+ def _tool_read_attachment(self, args: dict[str, Any], ctx: TurnContext) -> ToolExecutionResult:
606
+ attachment_id = _str_arg(args, "attachment_id", "id")
607
+ if not attachment_id:
608
+ return ToolExecutionResult.err("read_attachment requires attachment_id")
609
+ if self._attachment_store is None:
610
+ return ToolExecutionResult.err("Attachments are not enabled for this session")
611
+ catalog = self._attachment_catalog
612
+ if catalog is not None and not catalog.contains(attachment_id):
613
+ if not self._attachment_store.exists(ctx.thread_id, attachment_id):
614
+ return ToolExecutionResult.err(f"Unknown attachment_id: {attachment_id}")
615
+ elif not self._attachment_store.exists(ctx.thread_id, attachment_id):
616
+ return ToolExecutionResult.err(f"Unknown attachment_id: {attachment_id}")
617
+ try:
618
+ data_b64, mime, filename = self._attachment_store.read_base64(
619
+ ctx.thread_id, attachment_id
620
+ )
621
+ except FileNotFoundError:
622
+ return ToolExecutionResult.err(
623
+ f"Attachment {attachment_id} expired or removed; ask user to re-upload"
624
+ )
625
+ meta: dict[str, object] = {"attachment_id": attachment_id, "filename": filename}
626
+ if mime in IMAGE_MIME_TYPES:
627
+ return ToolExecutionResult.ok_blocks(
628
+ [Image(mime_type=mime, data=data_b64, metadata=meta)]
629
+ )
630
+ return ToolExecutionResult.ok_blocks(
631
+ [File(mime_type=mime, data=data_b64, metadata=meta)]
632
+ )
633
+
634
+ def _tool_render_image(self, args: dict[str, Any], ctx: TurnContext) -> ToolExecutionResult:
635
+ path = _str_arg(args, "path", "file_path", "file")
636
+ if not path:
637
+ return ToolExecutionResult.err("render_image requires path")
638
+ caption = _str_arg(args, "caption", "text") or ""
639
+ try:
640
+ fp = self._workspace._resolve_under_root(path, label="path")
641
+ except WorkspaceError as exc:
642
+ return ToolExecutionResult.err(_workspace_error_envelope(exc))
643
+ if not fp.is_file():
644
+ return ToolExecutionResult.err(f"Not a file: {path}")
645
+ try:
646
+ raw = fp.read_bytes()
647
+ except OSError as exc:
648
+ return ToolExecutionResult.err(f"Failed to read image at {path}: {exc}")
649
+ mime = sniff_mime(raw[:512])
650
+ if mime is None:
651
+ ext_map = {
652
+ ".png": "image/png",
653
+ ".jpg": "image/jpeg",
654
+ ".jpeg": "image/jpeg",
655
+ ".gif": "image/gif",
656
+ ".webp": "image/webp",
657
+ }
658
+ mime = ext_map.get(fp.suffix.lower())
659
+ if mime is None or mime not in IMAGE_MIME_TYPES:
660
+ return ToolExecutionResult.err(
661
+ f"render_image requires an image file (png/jpeg/gif/webp); got {mime or 'unknown'}"
662
+ )
663
+ data_b64 = base64.b64encode(raw).decode("ascii")
664
+ filename = fp.name
665
+ meta: dict[str, object] = {"filename": filename, "path": path}
666
+ if self._attachment_store is not None:
667
+ try:
668
+ stored = self._attachment_store.save(
669
+ ctx.thread_id,
670
+ data=raw,
671
+ mime_type=mime,
672
+ filename=filename,
673
+ )
674
+ meta["attachment_id"] = stored.attachment_id
675
+ except Exception as exc:
676
+ logger.warning("render_image attachment save failed: %s", exc)
677
+ blocks: list[ContentBlock] = [
678
+ Image(mime_type=mime, data=data_b64, metadata=meta),
679
+ ]
680
+ if caption.strip():
681
+ blocks.append(Text(text=caption.strip()))
682
+ return ToolExecutionResult.ok_blocks(blocks)
683
+
684
+ def _tool_read_file(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
685
+ path = _str_arg(args, "path", "file_path", "file")
686
+ if not path:
687
+ return (
688
+ None,
689
+ _built_in_tool_error(
690
+ "validation",
691
+ "read_file requires a path argument.",
692
+ 'Pass a workspace-relative path, e.g. {"path": "README.md"}.',
693
+ {"field": "path", "example": {"path": "README.md"}},
694
+ ),
695
+ )
696
+ offset = _coerce_int(args.get("offset"), 1) or 1
697
+ limit = _coerce_int(args.get("limit"), None)
698
+ max_lines_cap = None
699
+ if _is_under_spill_path(self._workspace.repo_root, path):
700
+ max_lines_cap = self._spill_read_max_lines
701
+ try:
702
+ payload = self._workspace.read_file(
703
+ path,
704
+ offset=offset,
705
+ limit=limit,
706
+ max_lines_cap=max_lines_cap,
707
+ )
708
+ return (_j(payload), None)
709
+ except WorkspaceError as exc:
710
+ return (None, _workspace_error_envelope(exc))
711
+
712
+ def _tool_write_file(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
713
+ path = _str_arg(args, "path", "file_path", "file")
714
+ if not path:
715
+ return (
716
+ None,
717
+ _built_in_tool_error(
718
+ "validation",
719
+ "write_file requires a path argument.",
720
+ 'Pass path and content, e.g. {"path": "README.md", "content": "..."}.',
721
+ {"field": "path", "example": {"path": "README.md", "content": ""}},
722
+ ),
723
+ )
724
+ content = args.get("content")
725
+ if content is None:
726
+ content = args.get("body", "")
727
+ if not isinstance(content, str):
728
+ content = str(content)
729
+ try:
730
+ payload = self._workspace.write_file(path, content)
731
+ return (_j(payload), None)
732
+ except WorkspaceError as exc:
733
+ return (None, _workspace_error_envelope(exc))
734
+
735
+ def _tool_replace_in_file(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
736
+ path = _str_arg(args, "path", "file_path", "file")
737
+ if not path:
738
+ return (
739
+ None,
740
+ _built_in_tool_error(
741
+ "validation",
742
+ "replace_in_file requires a path argument.",
743
+ 'Pass path, old_string, and new_string, e.g. {"path": "README.md", "old_string": "a", "new_string": "b"}.',
744
+ {
745
+ "field": "path",
746
+ "example": {
747
+ "path": "README.md",
748
+ "old_string": "old",
749
+ "new_string": "new",
750
+ },
751
+ },
752
+ ),
753
+ )
754
+ old_string = args.get("old_string")
755
+ if old_string is None:
756
+ old_string = args.get("old", "")
757
+ if not isinstance(old_string, str):
758
+ old_string = str(old_string)
759
+ new_string = args.get("new_string")
760
+ if new_string is None:
761
+ new_string = args.get("new", "")
762
+ if not isinstance(new_string, str):
763
+ new_string = str(new_string)
764
+ replace_all = args.get("replace_all", False)
765
+ if not isinstance(replace_all, bool):
766
+ replace_all = str(replace_all).strip().lower() in ("1", "true", "yes", "on")
767
+ try:
768
+ payload = self._workspace.replace_in_file(
769
+ path, old_string, new_string, replace_all=replace_all
770
+ )
771
+ return (_j(payload), None)
772
+ except WorkspaceError as exc:
773
+ return (None, _workspace_error_envelope(exc))
774
+
775
+ def _tool_glob(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
776
+ pattern = _str_arg(args, "pattern", "glob")
777
+ if not pattern:
778
+ return (
779
+ None,
780
+ _built_in_tool_error(
781
+ "validation",
782
+ "glob requires a pattern argument.",
783
+ 'Pass a glob pattern, e.g. {"pattern": "**/*.html"}.',
784
+ {"field": "pattern", "example": {"pattern": "**/*.md"}},
785
+ ),
786
+ )
787
+ root = _str_arg(args, "root")
788
+ try:
789
+ payload = self._workspace.glob_paths(pattern, root=root)
790
+ return (_j(payload), None)
791
+ except WorkspaceError as exc:
792
+ return (None, _workspace_error_envelope(exc))
793
+
794
+ def _tool_grep(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
795
+ pattern = _str_arg(args, "pattern", "regex", "query")
796
+ if not pattern:
797
+ return (
798
+ None,
799
+ _built_in_tool_error(
800
+ "validation",
801
+ "grep requires a pattern argument.",
802
+ 'Pass a regex pattern, e.g. {"pattern": "TODO", "file_glob": "*.py"}.',
803
+ {"field": "pattern", "example": {"pattern": "def main", "file_glob": "*.py"}},
804
+ ),
805
+ )
806
+ root = _str_arg(args, "root", "path")
807
+ ignore_case = args.get("ignore_case", False)
808
+ if not isinstance(ignore_case, bool):
809
+ ignore_case = str(ignore_case).strip().lower() in ("1", "true", "yes", "on")
810
+ file_glob = _str_arg(args, "file_glob", "include", "glob")
811
+ max_matches = _coerce_int(args.get("max_matches"), None)
812
+ try:
813
+ payload = self._workspace.grep(
814
+ pattern,
815
+ root=root,
816
+ ignore_case=ignore_case,
817
+ file_glob=file_glob,
818
+ max_matches=max_matches,
819
+ )
820
+ return (_j(payload), None)
821
+ except WorkspaceError as exc:
822
+ return (None, _workspace_error_envelope(exc))
823
+
824
+ def _tool_apply_patch(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
825
+ from monkeybot.core.tools.patch import PatchError, parse_patch, plan_and_apply_patch
826
+
827
+ patch_text = args.get("patch_text")
828
+ if patch_text is None:
829
+ patch_text = args.get("patch") or args.get("text") or ""
830
+ if not isinstance(patch_text, str):
831
+ patch_text = str(patch_text)
832
+ if not patch_text.strip():
833
+ return (
834
+ None,
835
+ _built_in_tool_error(
836
+ "validation",
837
+ "apply_patch requires non-empty patch_text.",
838
+ "Pass a Codex-style patch between *** Begin Patch and *** End Patch.",
839
+ {"field": "patch_text"},
840
+ ),
841
+ )
842
+ try:
843
+ hunks = parse_patch(patch_text)
844
+ payload = plan_and_apply_patch(self._workspace, hunks)
845
+ return (_j(payload), None)
846
+ except PatchError as exc:
847
+ return (
848
+ None,
849
+ _built_in_tool_error(
850
+ "validation",
851
+ str(exc),
852
+ "Fix the patch (markers, paths, or hunk context) and retry once.",
853
+ {"code": getattr(exc, "code", "patch_error")},
854
+ ),
855
+ )
856
+ except WorkspaceError as exc:
857
+ return (None, _workspace_error_envelope(exc))
858
+
859
+ async def _tool_search_memory(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
860
+ query = _str_arg(args, "query", "q", "keyword", "phrase")
861
+ if not query:
862
+ return (
863
+ None,
864
+ _built_in_tool_error(
865
+ "validation",
866
+ "search_memory requires a non-empty query.",
867
+ 'Use query (or q / keyword / phrase), e.g. {"query": "deployment"}.',
868
+ {"field": "query", "example": {"query": "keyword"}},
869
+ ),
870
+ )
871
+ max_hits = _coerce_int(args.get("max_hits"), 40) or 40
872
+ if self._memory is None:
873
+ return (
874
+ None,
875
+ _built_in_tool_error(
876
+ "validation",
877
+ "search_memory requires memory to be configured.",
878
+ "Enable the memory hook and set paths.memory_storage_uri in monkeybot.yaml.",
879
+ {"field": "memory"},
880
+ ),
881
+ )
882
+ payload = await self._memory.search_files(query, max_hits=max_hits, skip_raw=False)
883
+ return (_j(payload), None)
884
+
885
+ def _tool_list_skills(self, ctx: TurnContext) -> tuple[str | None, str | None]:
886
+ rows = [
887
+ {"name": s.name, "description": s.description}
888
+ for s in ctx.skills
889
+ ]
890
+ return (
891
+ _j(
892
+ {
893
+ "ok": True,
894
+ "skills_path": str(self._skills_path),
895
+ "skills": rows,
896
+ }
897
+ ),
898
+ None,
899
+ )
900
+
901
+ async def _tool_task(self, call: ToolCall, ctx: TurnContext) -> tuple[str | None, str | None]:
902
+ args = dict(call.args)
903
+ task = _str_arg(args, "task", "instructions", "prompt", "objective")
904
+ if not task:
905
+ return (
906
+ None,
907
+ _built_in_tool_error(
908
+ "validation",
909
+ "task requires a non-empty string in the task (or instructions / prompt / objective) field.",
910
+ 'Example: {"task": "Summarize the open issue and propose a fix."}.',
911
+ {"field": "task", "example": {"task": "Do the thing"}},
912
+ ),
913
+ )
914
+
915
+ context_val = args.get("context") or args.get("background") or ""
916
+ if not isinstance(context_val, str):
917
+ context_val = str(context_val)
918
+
919
+ subagent_type = _str_arg(args, "subagent_type", "type", "persona")
920
+ agent_root = resolve_agent_project_root()
921
+ try:
922
+ agent_md_path = resolve_task_agent_md_path(
923
+ subagent_type=subagent_type,
924
+ registry=self._subagent_registry,
925
+ agent_root=agent_root,
926
+ )
927
+ except ValueError as exc:
928
+ return (
929
+ None,
930
+ _built_in_tool_error(
931
+ "validation",
932
+ str(exc),
933
+ "Use a subagent_type from the harness Subagent personas list, or omit it for the default.",
934
+ {"field": "subagent_type", "subagent_type": subagent_type},
935
+ ),
936
+ )
937
+
938
+ memory_uri = self._memory.uri if self._memory is not None else ""
939
+
940
+ script = resolve_subagent_script()
941
+ if not script.is_file():
942
+ return (
943
+ None,
944
+ _built_in_tool_error(
945
+ "runtime",
946
+ f"task: worker script missing at {script}",
947
+ "Set MONKEYBOT_SUBAGENT_SCRIPT to a valid subagent_worker.py path.",
948
+ {"path": str(script)},
949
+ ),
950
+ )
951
+
952
+ parent_label = f"{ctx.request_id}:{call.call_id}"
953
+ traceparent = _inject_subagent_traceparent()
954
+ envelope = SubagentEnvelope(
955
+ task=task,
956
+ context=context_val,
957
+ memory_storage_uri=memory_uri,
958
+ parent_run_id=parent_label,
959
+ model=ctx.model,
960
+ traceparent=traceparent,
961
+ agent_md=str(agent_md_path),
962
+ subagent_type=subagent_type,
963
+ )
964
+
965
+ scratch = (self._workspace.repo_root / ".monkeybot" / "subagent-runs" / uuid.uuid4().hex)
966
+ scratch.mkdir(parents=True, exist_ok=True)
967
+
968
+ run_id = make_run_id()
969
+ persisted = PersistedSubagentEnvelope(
970
+ task=envelope.task,
971
+ context=envelope.context,
972
+ memory_storage_uri=envelope.memory_storage_uri,
973
+ parent_run_id=envelope.parent_run_id,
974
+ model=envelope.model,
975
+ traceparent=envelope.traceparent,
976
+ agent_md=envelope.agent_md,
977
+ subagent_type=envelope.subagent_type,
978
+ )
979
+ queue_mode = os.environ.get("MONKEYBOT_TASK_QUEUE", "").strip().lower() in (
980
+ "1",
981
+ "true",
982
+ "yes",
983
+ )
984
+ if queue_mode and self._run_store is None:
985
+ raise RuntimeError("MONKEYBOT_TASK_QUEUE=1 requires a configured storage backend")
986
+ if self._run_store is not None:
987
+ if queue_mode:
988
+ await self._run_store.record_pending(
989
+ run_id=run_id,
990
+ parent_run_id=parent_label,
991
+ script=str(script),
992
+ envelope=persisted,
993
+ scratch_dir=scratch,
994
+ )
995
+ return (
996
+ _j(
997
+ {
998
+ "ok": True,
999
+ "queued": True,
1000
+ "run_id": run_id,
1001
+ "scratch_dir": str(scratch),
1002
+ "message": "Subagent run queued for worker pool.",
1003
+ }
1004
+ ),
1005
+ None,
1006
+ )
1007
+ await self._run_store.record_started(
1008
+ run_id=run_id,
1009
+ parent_run_id=parent_label,
1010
+ script=str(script),
1011
+ envelope=persisted,
1012
+ scratch_dir=scratch,
1013
+ )
1014
+
1015
+ child_env = {
1016
+ "MONKEYBOT_SUBAGENT_WORKSPACE": str(self._workspace.repo_root),
1017
+ "MONKEYBOT_AGENT_ROOT": str(agent_root),
1018
+ "MEMORY_STORAGE_URI": memory_uri,
1019
+ "MONKEYBOT_SUBAGENT_SKILLS_PATH": str(self._skills_path),
1020
+ "OTEL_SERVICE_NAME": _SUBAGENT_OTEL_SERVICE_NAME,
1021
+ "MONKEYBOT_SUBAGENT_AGENT_MD": str(agent_md_path),
1022
+ }
1023
+
1024
+ for env_key, raw_val in (
1025
+ ("MCP_CONFIG", os.environ.get("MCP_CONFIG", "")),
1026
+ ("COMMAND_ALLOWLIST_CONFIG", os.environ.get("COMMAND_ALLOWLIST_CONFIG", "")),
1027
+ ):
1028
+ if raw_val.strip():
1029
+ child_env[env_key] = str(resolve_project_path(raw_val.strip(), agent_root))
1030
+
1031
+ db_raw = os.environ.get("DB_URL", "").strip()
1032
+ if db_raw:
1033
+ child_env["DB_URL"] = normalize_sqlite_db_url(db_raw, agent_root)
1034
+
1035
+ timeout_raw = os.environ.get("SUBAGENT_TIMEOUT_SEC", "600").strip()
1036
+ try:
1037
+ timeout = max(1.0, float(timeout_raw))
1038
+ except ValueError:
1039
+ timeout = 600.0
1040
+
1041
+ deltas: list[str] = []
1042
+ errors: list[str] = []
1043
+ tool_call_count = 0
1044
+ tool_results: list[dict[str, str]] = []
1045
+ turn_complete: TurnComplete | None = None
1046
+ proc_holder: list[asyncio.subprocess.Process | None] = [None]
1047
+
1048
+ async def _subprocess_exec(*cmd: str | bytes) -> asyncio.subprocess.Process:
1049
+ env = dict(os.environ)
1050
+ env.update(child_env)
1051
+ env["PYTHONUNBUFFERED"] = "1"
1052
+ p = await asyncio.create_subprocess_exec(
1053
+ *cmd,
1054
+ stdin=asyncio.subprocess.PIPE,
1055
+ stdout=asyncio.subprocess.PIPE,
1056
+ stderr=asyncio.subprocess.DEVNULL,
1057
+ env=env,
1058
+ )
1059
+ proc_holder[0] = p
1060
+ return p
1061
+
1062
+ async def _drain() -> None:
1063
+ nonlocal turn_complete, tool_call_count
1064
+ async for evt in spawn_subagent(
1065
+ str(script),
1066
+ envelope,
1067
+ scratch_dir=scratch,
1068
+ subprocess_exec=_subprocess_exec,
1069
+ ):
1070
+ if isinstance(evt, AssistantDelta):
1071
+ deltas.append(evt.delta)
1072
+ elif isinstance(evt, ToolCallStarted):
1073
+ tool_call_count += 1
1074
+ elif isinstance(evt, ToolCallResult):
1075
+ snippet = (evt.result or evt.error or "").strip()
1076
+ if len(snippet) > 600:
1077
+ snippet = snippet[:600] + "…"
1078
+ tool_results.append({"tool": evt.tool, "snippet": snippet})
1079
+ elif isinstance(evt, Error):
1080
+ errors.append(evt.error)
1081
+ elif isinstance(evt, TurnComplete):
1082
+ turn_complete = evt
1083
+
1084
+ drain_task = asyncio.create_task(_drain())
1085
+ cancel_wait = asyncio.create_task(ctx.cancelled.wait()) if ctx.cancelled is not None else None
1086
+
1087
+ try:
1088
+ if cancel_wait is None:
1089
+ try:
1090
+ await asyncio.wait_for(drain_task, timeout=timeout)
1091
+ except TimeoutError:
1092
+ errors.append(f"task: subagent exceeded {timeout:g}s timeout")
1093
+ await _stop_subagent_process(proc_holder[0])
1094
+ if not drain_task.done():
1095
+ drain_task.cancel()
1096
+ with contextlib.suppress(asyncio.CancelledError):
1097
+ await drain_task
1098
+ else:
1099
+ done, _ = await asyncio.wait(
1100
+ {drain_task, cancel_wait},
1101
+ timeout=timeout,
1102
+ return_when=asyncio.FIRST_COMPLETED,
1103
+ )
1104
+ if not done:
1105
+ errors.append(f"task: subagent exceeded {timeout:g}s timeout")
1106
+ await _stop_subagent_process(proc_holder[0])
1107
+ cancel_wait.cancel()
1108
+ with contextlib.suppress(asyncio.CancelledError):
1109
+ await cancel_wait
1110
+ if not drain_task.done():
1111
+ drain_task.cancel()
1112
+ with contextlib.suppress(asyncio.CancelledError):
1113
+ await drain_task
1114
+ elif drain_task in done:
1115
+ if not cancel_wait.done():
1116
+ cancel_wait.cancel()
1117
+ with contextlib.suppress(asyncio.CancelledError):
1118
+ await cancel_wait
1119
+ try:
1120
+ drain_task.result()
1121
+ except asyncio.CancelledError:
1122
+ pass
1123
+ except Exception as exc:
1124
+ errors.append(str(exc))
1125
+ else:
1126
+ errors.append(_PARENT_CANCEL_TASK_ERR)
1127
+ await _stop_subagent_process(proc_holder[0])
1128
+ if not drain_task.done():
1129
+ drain_task.cancel()
1130
+ with contextlib.suppress(asyncio.CancelledError):
1131
+ await drain_task
1132
+ finally:
1133
+ if cancel_wait is not None and not cancel_wait.done():
1134
+ cancel_wait.cancel()
1135
+ with contextlib.suppress(asyncio.CancelledError):
1136
+ await cancel_wait
1137
+
1138
+ usage_payload = None
1139
+ if turn_complete is not None:
1140
+ u = turn_complete.usage
1141
+ usage_payload = {
1142
+ "input_tokens": u.input_tokens,
1143
+ "output_tokens": u.output_tokens,
1144
+ "cached_tokens": u.cached_tokens,
1145
+ "cost_usd": u.cost_usd,
1146
+ "duration_ms": u.duration_ms,
1147
+ }
1148
+
1149
+ full_text = "".join(deltas).strip()
1150
+ final_text = full_text
1151
+
1152
+ payload = {
1153
+ "ok": len(errors) == 0,
1154
+ "final_message": final_text,
1155
+ "assistant_text": full_text,
1156
+ "tool_call_count": tool_call_count,
1157
+ "tool_results": tool_results[-10:],
1158
+ "errors": errors,
1159
+ "usage": usage_payload,
1160
+ "scratch_dir": str(scratch),
1161
+ "run_id": run_id,
1162
+ }
1163
+ if self._run_store is not None and not queue_mode:
1164
+ result_json = _j(payload)
1165
+ if len(errors) == 0:
1166
+ await self._run_store.record_completed(run_id, result_json)
1167
+ else:
1168
+ await self._run_store.record_failed(run_id, "; ".join(errors))
1169
+ return (_j(payload), None)
1170
+
1171
+ async def _tool_run_command(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1172
+ try:
1173
+ cmd, argv = _parse_run_command(args)
1174
+ except ValueError as exc:
1175
+ return None, _run_command_parse_envelope(exc)
1176
+ timeout = _coerce_int(args.get("timeout"), 60) or 60
1177
+ try:
1178
+ result = await self._terminal.execute(
1179
+ cmd,
1180
+ argv,
1181
+ timeout=timeout,
1182
+ cwd=self._workspace.repo_root,
1183
+ )
1184
+ except SecurityError as exc:
1185
+ return None, self._run_command_security_envelope(exc)
1186
+ except TimeoutError as exc:
1187
+ return (
1188
+ None,
1189
+ _built_in_tool_error(
1190
+ "runtime",
1191
+ str(exc),
1192
+ "Increase run_command timeout (seconds) or use a shorter command, then retry once.",
1193
+ {"example": {"argv": ["git", "--version"], "timeout": 120}},
1194
+ ),
1195
+ )
1196
+ return (
1197
+ _j(
1198
+ {
1199
+ "ok": result.exit_code == 0,
1200
+ "stdout": result.stdout,
1201
+ "stderr": result.stderr,
1202
+ "exit_code": result.exit_code,
1203
+ }
1204
+ ),
1205
+ None,
1206
+ )
1207
+
1208
+ async def _tool_add_mcp_server(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1209
+ sname = _str_arg(args, "name", "server_name", "server")
1210
+ if not sname:
1211
+ return (None, "add_mcp_server requires name (or server_name / server)")
1212
+ command = _str_arg(args, "command", "cmd")
1213
+ if not command:
1214
+ return (None, "add_mcp_server requires command")
1215
+ raw_args = args.get("args")
1216
+ arg_list = [str(x) for x in raw_args] if isinstance(raw_args, list) else []
1217
+ env: dict[str, str] = {}
1218
+ env_src = args.get("env")
1219
+ if isinstance(env_src, dict):
1220
+ for k, val in env_src.items():
1221
+ env[str(k)] = "" if val is None else str(val)
1222
+ defs = await self._mcp.connect(sname, command, arg_list, env)
1223
+ logger.info(
1224
+ "add_mcp_server ok %s",
1225
+ kv(server=sname, tools=len(defs)),
1226
+ )
1227
+ return (
1228
+ _j(
1229
+ {
1230
+ "ok": True,
1231
+ "server": sname,
1232
+ "tools": [{"name": t.name, "description": t.description} for t in defs],
1233
+ "note": "New tools apply on the next model step this turn.",
1234
+ }
1235
+ ),
1236
+ None,
1237
+ )
1238
+
1239
+ async def _disconnect_mcp_server(
1240
+ self, *, tool_name: str, args: dict[str, Any]
1241
+ ) -> tuple[str | None, str | None]:
1242
+ sname = _str_arg(args, "name", "server_name", "server")
1243
+ if not sname:
1244
+ return (None, f"{tool_name} requires name (or server_name / server)")
1245
+ known = list(self._mcp.known_server_names())
1246
+ if sname not in known and not self._mcp.is_connected(sname):
1247
+ known_msg = ", ".join(known) if known else "(none)"
1248
+ return (
1249
+ None,
1250
+ f"Unknown MCP server {sname!r}. Known servers: {known_msg}",
1251
+ )
1252
+ await self._mcp.disconnect(sname)
1253
+ logger.info("%s ok %s", tool_name, kv(server=sname, disconnected=True))
1254
+ return (
1255
+ _j(
1256
+ {
1257
+ "ok": True,
1258
+ "server": sname,
1259
+ "disconnected": True,
1260
+ "note": "Tools drop on the next model step this turn.",
1261
+ }
1262
+ ),
1263
+ None,
1264
+ )
1265
+
1266
+ async def _tool_remove_mcp_server(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1267
+ return await self._disconnect_mcp_server(tool_name="remove_mcp_server", args=args)
1268
+
1269
+ async def _tool_enable_mcp(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1270
+ sname = _str_arg(args, "name", "server_name", "server")
1271
+ if not sname:
1272
+ return (None, "enable_mcp requires name (or server_name / server)")
1273
+ already = self._mcp.is_connected(sname)
1274
+ try:
1275
+ defs = await self._mcp.connect_from_catalog(sname)
1276
+ except MCPDiagnosticError as exc:
1277
+ logger.warning("enable_mcp failed %s", kv(server=sname, error=str(exc)))
1278
+ return (None, str(exc))
1279
+ except MCPConnectionError as exc:
1280
+ logger.warning("enable_mcp failed %s", kv(server=sname, error=str(exc)))
1281
+ return (None, str(exc))
1282
+ logger.info(
1283
+ "enable_mcp ok %s",
1284
+ kv(server=sname, already_connected=already, tools=len(defs)),
1285
+ )
1286
+ return (
1287
+ _j(
1288
+ {
1289
+ "ok": True,
1290
+ "server": sname,
1291
+ "already_connected": already,
1292
+ "tools": [{"name": t.name, "description": t.description} for t in defs],
1293
+ "note": "New tools apply on the next model step this turn.",
1294
+ }
1295
+ ),
1296
+ None,
1297
+ )
1298
+
1299
+ async def _tool_disable_mcp(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1300
+ return await self._disconnect_mcp_server(tool_name="disable_mcp", args=args)
1301
+
1302
+ def _tool_mcp_status(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1303
+ sname = _str_arg(args, "server", "name", "server_name")
1304
+ snapshot = self._mcp.status(sname or None)
1305
+ return (
1306
+ _j(
1307
+ {
1308
+ "ok": True,
1309
+ "servers": snapshot if isinstance(snapshot, list) else [snapshot],
1310
+ }
1311
+ ),
1312
+ None,
1313
+ )
1314
+
1315
+ async def _mcp_meta_call(
1316
+ self,
1317
+ tool_name: str,
1318
+ coro: Awaitable[Any],
1319
+ *,
1320
+ ok_payload: Callable[[Any], dict[str, Any]] | None = None,
1321
+ ) -> tuple[str | None, str | None]:
1322
+ """Run an MCP meta-tool coroutine; log and surface typed failures like enable_mcp."""
1323
+ try:
1324
+ result = await coro
1325
+ except MCPServerNotConnectedError as exc:
1326
+ logger.warning("%s failed %s", tool_name, kv(error=str(exc)))
1327
+ return (None, str(exc))
1328
+ except MCPDiagnosticError as exc:
1329
+ logger.warning(
1330
+ "%s failed %s",
1331
+ tool_name,
1332
+ kv(server=exc.server_name, error=str(exc)),
1333
+ )
1334
+ return (None, str(exc))
1335
+ except Exception as exc:
1336
+ logger.warning("%s failed %s", tool_name, kv(error=str(exc)))
1337
+ return (None, f"{tool_name} failed: {exc}")
1338
+ if ok_payload is None:
1339
+ return (_j({"ok": True, **result}), None)
1340
+ return (_j({"ok": True, **ok_payload(result)}), None)
1341
+
1342
+ async def _tool_list_mcp_resources(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1343
+ sname = _str_arg(args, "server", "name", "server_name") or None
1344
+ return await self._mcp_meta_call(
1345
+ "list_mcp_resources",
1346
+ self._mcp.list_resources(sname),
1347
+ ok_payload=lambda resources: {"resources": resources, "count": len(resources)},
1348
+ )
1349
+
1350
+ async def _tool_list_mcp_resource_templates(
1351
+ self, args: dict[str, Any]
1352
+ ) -> tuple[str | None, str | None]:
1353
+ sname = _str_arg(args, "server", "name", "server_name") or None
1354
+ return await self._mcp_meta_call(
1355
+ "list_mcp_resource_templates",
1356
+ self._mcp.list_resource_templates(sname),
1357
+ ok_payload=lambda templates: {
1358
+ "resourceTemplates": templates,
1359
+ "count": len(templates),
1360
+ },
1361
+ )
1362
+
1363
+ async def _tool_read_mcp_resource(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1364
+ sname = _str_arg(args, "server", "name", "server_name")
1365
+ uri = _str_arg(args, "uri", "resource_uri", "resource")
1366
+ if not sname:
1367
+ return (None, "read_mcp_resource requires server (or name / server_name)")
1368
+ if not uri:
1369
+ return (None, "read_mcp_resource requires uri")
1370
+ return await self._mcp_meta_call(
1371
+ "read_mcp_resource",
1372
+ self._mcp.read_resource(sname, uri),
1373
+ )
1374
+
1375
+ async def _tool_list_mcp_prompts(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1376
+ sname = _str_arg(args, "server", "name", "server_name") or None
1377
+ return await self._mcp_meta_call(
1378
+ "list_mcp_prompts",
1379
+ self._mcp.list_prompts(sname),
1380
+ ok_payload=lambda prompts: {"prompts": prompts, "count": len(prompts)},
1381
+ )
1382
+
1383
+ async def _tool_get_mcp_prompt(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1384
+ sname = _str_arg(args, "server", "server_name")
1385
+ prompt_name = _str_arg(args, "prompt", "name", "prompt_name")
1386
+ if not sname:
1387
+ return (None, "get_mcp_prompt requires server (or server_name)")
1388
+ if not prompt_name:
1389
+ return (None, "get_mcp_prompt requires prompt (or name / prompt_name)")
1390
+ raw_args = args.get("arguments")
1391
+ prompt_args: dict[str, str] = {}
1392
+ if isinstance(raw_args, dict):
1393
+ prompt_args = {str(k): "" if v is None else str(v) for k, v in raw_args.items()}
1394
+ return await self._mcp_meta_call(
1395
+ "get_mcp_prompt",
1396
+ self._mcp.get_prompt(sname, prompt_name, prompt_args or None),
1397
+ )
1398
+
1399
+ def _require_loop_store(self) -> ScheduledLoopStore | tuple[None, str]:
1400
+ if self._scheduled_loop_store is None:
1401
+ return (
1402
+ None,
1403
+ _built_in_tool_error(
1404
+ "policy",
1405
+ "scheduled loops require durable storage (DB_URL: sqlite, postgresql, or firestore)",
1406
+ "Configure paths.db_url in monkeybot.yaml and enable MONKEYBOT_SCHEDULER_ENABLED=1 on the gateway.",
1407
+ {},
1408
+ ),
1409
+ )
1410
+ return self._scheduled_loop_store
1411
+
1412
+ async def _tool_start_loop(
1413
+ self, args: dict[str, Any], ctx: TurnContext
1414
+ ) -> tuple[str | None, str | None]:
1415
+ store_or_err = self._require_loop_store()
1416
+ if isinstance(store_or_err, tuple):
1417
+ return None, store_or_err[1]
1418
+ store = store_or_err
1419
+ prompt = _str_arg(args, "prompt", "plan", "message")
1420
+ if not prompt:
1421
+ return (None, "start_loop requires prompt with the agreed loop plan")
1422
+ interval_raw = args.get("interval")
1423
+ if interval_raw is None:
1424
+ return (None, "start_loop requires interval (e.g. 20s, 5m, 1h)")
1425
+ try:
1426
+ interval_ms = parse_interval_ms(interval_raw)
1427
+ max_runtime_ms = parse_optional_duration_ms(args.get("max_runtime"))
1428
+ except ValueError as exc:
1429
+ return (None, str(exc))
1430
+ max_ticks_raw = args.get("max_ticks")
1431
+ max_ticks: int | None = None
1432
+ if max_ticks_raw is not None:
1433
+ max_ticks = _coerce_int(max_ticks_raw, 0)
1434
+ if max_ticks is None or max_ticks < 1:
1435
+ return (None, "max_ticks must be a positive integer when set")
1436
+ session_id = _str_arg(args, "session_id") or ctx.thread_id
1437
+ loop_id = _str_arg(args, "loop_id")
1438
+ skip_if_busy = args.get("skip_if_busy", True)
1439
+ if not isinstance(skip_if_busy, bool):
1440
+ skip_if_busy = True
1441
+ unbounded = args.get("unbounded", False)
1442
+ if not isinstance(unbounded, bool):
1443
+ unbounded = False
1444
+ spec = ScheduledLoopCreate(
1445
+ prompt=prompt,
1446
+ interval_ms=interval_ms,
1447
+ session_id=session_id,
1448
+ loop_id=loop_id,
1449
+ max_ticks=max_ticks,
1450
+ max_runtime_ms=max_runtime_ms,
1451
+ skip_if_busy=skip_if_busy,
1452
+ unbounded=unbounded,
1453
+ )
1454
+ try:
1455
+ row = await store.create(spec)
1456
+ except ValueError as exc:
1457
+ return (None, str(exc))
1458
+ return (
1459
+ _j(
1460
+ {
1461
+ "ok": True,
1462
+ "loop_id": row.loop_id,
1463
+ "session_id": row.session_id,
1464
+ "status": row.status,
1465
+ "interval_ms": row.interval_ms,
1466
+ "max_ticks": row.max_ticks,
1467
+ "max_runtime_ms": row.max_runtime_ms,
1468
+ "message": (
1469
+ "Scheduled loop registered. The scheduler worker will fire the prompt "
1470
+ "on each tick when MONKEYBOT_SCHEDULER_ENABLED=1 or the standalone "
1471
+ "scheduler worker is running."
1472
+ ),
1473
+ }
1474
+ ),
1475
+ None,
1476
+ )
1477
+
1478
+ async def _tool_loop_status(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1479
+ store_or_err = self._require_loop_store()
1480
+ if isinstance(store_or_err, tuple):
1481
+ return None, store_or_err[1]
1482
+ store = store_or_err
1483
+ loop_id = _str_arg(args, "loop_id", "id")
1484
+ if loop_id:
1485
+ row = await store.get(loop_id)
1486
+ if row is None:
1487
+ return (None, f"unknown loop: {loop_id}")
1488
+ return (_j({"ok": True, "loop": self._loop_row_json(row)}), None)
1489
+ rows = await store.list_all()
1490
+ return (_j({"ok": True, "loops": [self._loop_row_json(r) for r in rows]}), None)
1491
+
1492
+ @staticmethod
1493
+ def _loop_row_json(row: object) -> dict[str, object]:
1494
+ from monkeybot.core.persistence.scheduled_loops import ScheduledLoopRow
1495
+
1496
+ if not isinstance(row, ScheduledLoopRow):
1497
+ raise TypeError("expected ScheduledLoopRow")
1498
+ return {
1499
+ "loop_id": row.loop_id,
1500
+ "session_id": row.session_id,
1501
+ "status": row.status,
1502
+ "tick_index": row.tick_index,
1503
+ "interval_ms": row.interval_ms,
1504
+ "max_ticks": row.max_ticks,
1505
+ "max_runtime_ms": row.max_runtime_ms,
1506
+ "next_tick_at_ms": row.next_tick_at_ms,
1507
+ "last_tick_at_ms": row.last_tick_at_ms,
1508
+ "last_error": row.last_error,
1509
+ "stop_reason": row.stop_reason,
1510
+ "tick_in_flight": row.tick_in_flight,
1511
+ }
1512
+
1513
+ async def _tool_pause_loop(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1514
+ store_or_err = self._require_loop_store()
1515
+ if isinstance(store_or_err, tuple):
1516
+ return None, store_or_err[1]
1517
+ loop_id = _str_arg(args, "loop_id", "id")
1518
+ if not loop_id:
1519
+ return (None, "pause_loop requires loop_id")
1520
+ ok = await store_or_err.pause(loop_id)
1521
+ if not ok:
1522
+ return (None, f"unknown loop: {loop_id}")
1523
+ return (_j({"ok": True, "loop_id": loop_id, "status": "paused"}), None)
1524
+
1525
+ async def _tool_resume_loop(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1526
+ store_or_err = self._require_loop_store()
1527
+ if isinstance(store_or_err, tuple):
1528
+ return None, store_or_err[1]
1529
+ loop_id = _str_arg(args, "loop_id", "id")
1530
+ if not loop_id:
1531
+ return (None, "resume_loop requires loop_id")
1532
+ ok = await store_or_err.resume(loop_id)
1533
+ if not ok:
1534
+ return (None, f"paused loop not found: {loop_id}")
1535
+ return (_j({"ok": True, "loop_id": loop_id, "status": "active"}), None)
1536
+
1537
+ async def _tool_stop_loop(self, args: dict[str, Any]) -> tuple[str | None, str | None]:
1538
+ store_or_err = self._require_loop_store()
1539
+ if isinstance(store_or_err, tuple):
1540
+ return None, store_or_err[1]
1541
+ loop_id = _str_arg(args, "loop_id", "id")
1542
+ if not loop_id:
1543
+ return (None, "stop_loop requires loop_id")
1544
+ ok = await store_or_err.stop(loop_id)
1545
+ if not ok:
1546
+ return (None, f"unknown loop: {loop_id}")
1547
+ return (_j({"ok": True, "loop_id": loop_id, "status": "completed"}), None)
1548
+