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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|
+
|