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,381 @@
1
+ """
2
+ Secure terminal command execution with allowlist-based security.
3
+
4
+ This module provides the TerminalExecutor class, which is the security boundary
5
+ for all shell command execution in Monkeybot. It implements strict allowlist validation
6
+ for both commands and file paths to prevent unauthorized operations.
7
+
8
+ Security Model:
9
+ - Deny by default: Only pre-approved commands and paths are allowed
10
+ - Command allowlist: ALLOWED_COMMANDS defines executable binaries
11
+ - Path allowlist: ALLOWED_PATHS defines accessible directories
12
+ - Timeout enforcement: All commands have maximum execution time
13
+ - Output limits: Large outputs are truncated to prevent memory exhaustion
14
+
15
+ Example:
16
+ >>> executor = TerminalExecutor()
17
+ >>> result = await executor.execute("ls", ["./data/memory/"])
18
+ >>> print(result.stdout)
19
+ """
20
+
21
+ import asyncio
22
+ import logging
23
+ import os
24
+ import sys
25
+ from collections.abc import Sequence
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import List
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # SECURITY: Command allowlist - modify with extreme caution
33
+ # Only add commands that are essential and have been security reviewed
34
+ ALLOWED_COMMANDS = [
35
+ "cat", # Read file contents
36
+ "ls", # List directory contents
37
+ "grep", # Search file contents (line-oriented)
38
+ "echo", # Print text (used in tests and debugging)
39
+ "python", # Execute Python scripts (skills)
40
+ "python3", # Execute Python scripts (skills)
41
+ "uv", # Package runner (install subcommands blocked by deny_patterns)
42
+ "git", # Version control (clone, branch, commit, push, etc.)
43
+ "gh", # GitHub CLI (e.g. gh pr create)
44
+ "bash", # Shell interpreter — use for builtins (cd, &&, pipes) via bash -c "..."
45
+ ]
46
+
47
+ # SECURITY: Path allowlist - modify with extreme caution
48
+ # Only add paths that are safe for agent access
49
+ ALLOWED_PATHS = [
50
+ "./data/memory/", # Memory storage directory
51
+ "./data/memory", # Same, when callers omit trailing slash
52
+ "./skills/", # Skills directory
53
+ "./skills", # Same, when callers omit trailing slash
54
+ "./test-data/", # Test data directory (for tests only)
55
+ "./code/", # Reference / cloned repos (explicit ./ paths in argv)
56
+ "./code", # Same, when callers omit trailing slash
57
+ ]
58
+
59
+
60
+ _VERTEX_SKILL_ENV_KEYS = (
61
+ "VERTEX_AI_PROJECT_ID",
62
+ "GOOGLE_CLOUD_PROJECT",
63
+ "GCP_PROJECT_ID",
64
+ "VERTEX_AI_LOCATION",
65
+ "GOOGLE_CLOUD_REGION",
66
+ )
67
+
68
+
69
+ def build_skill_runtime_env(*, cwd: Path | str) -> dict[str, str]:
70
+ """Environment for skill scripts (inherits host env + workspace/GCP overrides)."""
71
+ exec_cwd = str(Path(cwd).resolve())
72
+ env = os.environ.copy()
73
+ env["MONKEYBOT_WORKSPACE_ROOT"] = exec_cwd
74
+ env["WORKSPACE_ROOT"] = exec_cwd
75
+ for key in _VERTEX_SKILL_ENV_KEYS:
76
+ val = os.environ.get(key, "").strip()
77
+ if val:
78
+ env[key] = val
79
+ for cred_env in ("GOOGLE_APPLICATION_CREDENTIALS", "GCP_AUTH_FILE"):
80
+ raw = os.environ.get(cred_env, "").strip()
81
+ if not raw:
82
+ continue
83
+ cred_path = Path(raw).expanduser()
84
+ if not cred_path.is_absolute():
85
+ cred_path = (Path.cwd() / cred_path).resolve()
86
+ else:
87
+ cred_path = cred_path.resolve()
88
+ env[cred_env] = str(cred_path)
89
+ return env
90
+
91
+
92
+ @dataclass
93
+ class ExecutionResult:
94
+ """
95
+ Result from terminal command execution.
96
+
97
+ Attributes:
98
+ stdout: Standard output from command (decoded UTF-8)
99
+ stderr: Standard error from command (decoded UTF-8)
100
+ exit_code: Command exit code (0 = success, non-zero = error)
101
+ """
102
+ stdout: str
103
+ stderr: str
104
+ exit_code: int
105
+
106
+
107
+ class SecurityError(Exception):
108
+ """
109
+ Raised when a security violation is detected.
110
+
111
+ This exception is raised when attempting to execute:
112
+ - A command not in ALLOWED_COMMANDS
113
+ - A command accessing paths not in ALLOWED_PATHS
114
+
115
+ Security violations are logged with ERROR severity for audit purposes.
116
+ """
117
+ pass
118
+
119
+
120
+ class TerminalExecutor:
121
+ """
122
+ Secure terminal command executor with allowlist-based security.
123
+
124
+ This class is the security boundary for all shell command execution in Monkeybot.
125
+ It enforces strict allowlist validation for commands and file paths, implements
126
+ timeout handling, and limits output size to prevent resource exhaustion.
127
+
128
+ Security Features:
129
+ - Command allowlist validation (ALLOWED_COMMANDS)
130
+ - Path allowlist validation (ALLOWED_PATHS)
131
+ - Timeout enforcement with process cleanup
132
+ - Output size limits (1MB per stream)
133
+ - Security violation logging
134
+
135
+ Example:
136
+ >>> executor = TerminalExecutor()
137
+ >>>
138
+ >>> # Allowed command + allowed path - succeeds
139
+ >>> result = await executor.execute("cat", ["./data/memory/file.txt"])
140
+ >>> print(result.stdout)
141
+ >>>
142
+ >>> # Blocked command - raises SecurityError
143
+ >>> try:
144
+ >>> await executor.execute("rm", ["-rf", "/"])
145
+ >>> except SecurityError as e:
146
+ >>> print(f"Blocked: {e}")
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ *,
152
+ allowed_commands: Sequence[str] | None = None,
153
+ allowed_path_prefixes: Sequence[str] | None = None,
154
+ ) -> None:
155
+ self._allowed_commands: tuple[str, ...] = (
156
+ tuple(allowed_commands) if allowed_commands is not None else tuple(ALLOWED_COMMANDS)
157
+ )
158
+ self._allowed_path_prefixes: tuple[str, ...] = (
159
+ tuple(allowed_path_prefixes)
160
+ if allowed_path_prefixes is not None
161
+ else tuple(ALLOWED_PATHS)
162
+ )
163
+
164
+ @property
165
+ def allowed_commands(self) -> tuple[str, ...]:
166
+ return self._allowed_commands
167
+
168
+ @property
169
+ def allowed_path_prefixes(self) -> tuple[str, ...]:
170
+ return self._allowed_path_prefixes
171
+
172
+ async def aclose(self) -> None:
173
+ """No-op — TerminalExecutor holds no persistent resources."""
174
+
175
+ async def execute(
176
+ self,
177
+ command: str,
178
+ args: List[str],
179
+ timeout: int = 60,
180
+ *,
181
+ cwd: Path | str | None = None,
182
+ ) -> ExecutionResult:
183
+ """
184
+ Execute a terminal command securely with allowlist validation.
185
+
186
+ This method is the single entry point for all shell command execution.
187
+ It performs security validation before execution and enforces resource
188
+ limits during execution.
189
+
190
+ Args:
191
+ command: Command to execute (must be in ALLOWED_COMMANDS)
192
+ args: Command arguments (paths must be in ALLOWED_PATHS)
193
+ timeout: Maximum execution time in seconds (default: 60)
194
+
195
+ Returns:
196
+ ExecutionResult containing stdout, stderr, and exit code
197
+
198
+ Raises:
199
+ SecurityError: If command or path violates security policy
200
+ TimeoutError: If command exceeds timeout duration
201
+
202
+ Example:
203
+ >>> executor = TerminalExecutor()
204
+ >>> result = await executor.execute("ls", ["-la", "./data/memory/"])
205
+ >>> if result.exit_code == 0:
206
+ >>> print(f"Files: {result.stdout}")
207
+
208
+ Security Notes:
209
+ - This method logs all security violations with ERROR severity
210
+ - Failed security checks never execute the command
211
+ - Processes are killed if they exceed timeout
212
+ - Output is truncated if it exceeds 1MB per stream
213
+ """
214
+ # CRITICAL: Validate command against allowlist
215
+ self._validate_command(command)
216
+
217
+ # CRITICAL: Validate all paths in arguments
218
+ self._validate_paths(args)
219
+
220
+ # Log execution for audit trail
221
+ logger.info(
222
+ f"Executing command: {command} {' '.join(args)}",
223
+ extra={
224
+ "component": "terminal_executor",
225
+ "command": command,
226
+ "args_count": len(args)
227
+ }
228
+ )
229
+
230
+ exec_cwd: str | None = None
231
+ env: dict[str, str] | None = None
232
+ if cwd is not None:
233
+ exec_cwd = str(Path(cwd).resolve())
234
+ env = build_skill_runtime_env(cwd=exec_cwd)
235
+
236
+ executable = sys.executable if command in ("python3", "python") else command
237
+
238
+ try:
239
+ # Create subprocess with captured output
240
+ process = await asyncio.create_subprocess_exec(
241
+ executable,
242
+ *args,
243
+ stdout=asyncio.subprocess.PIPE,
244
+ stderr=asyncio.subprocess.PIPE,
245
+ cwd=exec_cwd,
246
+ env=env,
247
+ )
248
+
249
+ # Wait for completion with timeout
250
+ stdout, stderr = await asyncio.wait_for(
251
+ process.communicate(),
252
+ timeout=timeout
253
+ )
254
+
255
+ # Truncate large outputs to prevent memory exhaustion
256
+ stdout = self._truncate_output(stdout, "stdout")
257
+ stderr = self._truncate_output(stderr, "stderr")
258
+
259
+ return ExecutionResult(
260
+ stdout=stdout.decode("utf-8", errors="replace"),
261
+ stderr=stderr.decode("utf-8", errors="replace"),
262
+ exit_code=process.returncode or 0
263
+ )
264
+
265
+ except asyncio.TimeoutError:
266
+ # CRITICAL: Kill process on timeout to prevent zombie processes
267
+ if process.returncode is None:
268
+ process.kill()
269
+ await process.wait()
270
+
271
+ error_msg = f"Command exceeded {timeout}s timeout"
272
+ logger.error(
273
+ error_msg,
274
+ extra={
275
+ "component": "terminal_executor",
276
+ "command": command,
277
+ "timeout": timeout
278
+ }
279
+ )
280
+ raise TimeoutError(error_msg)
281
+
282
+ def _validate_command(self, command: str) -> None:
283
+ """
284
+ Validate command against allowlist.
285
+
286
+ Args:
287
+ command: Command to validate
288
+
289
+ Raises:
290
+ SecurityError: If command is not in ALLOWED_COMMANDS
291
+ """
292
+ if command not in self._allowed_commands:
293
+ error_msg = f"Command '{command}' not allowed"
294
+ logger.error(
295
+ f"Security violation: {error_msg}",
296
+ extra={
297
+ "component": "terminal_executor",
298
+ "severity": "SECURITY_VIOLATION",
299
+ "command": command,
300
+ "allowed_commands": list(self._allowed_commands),
301
+ },
302
+ )
303
+ raise SecurityError(error_msg)
304
+
305
+ def _validate_paths(self, args: List[str]) -> None:
306
+ """
307
+ Validate file paths in arguments against allowlist.
308
+
309
+ This method checks each argument to see if it looks like a file path
310
+ (starts with "./" or "/"). If so, it validates that the path starts
311
+ with one of the allowed path prefixes.
312
+
313
+ Args:
314
+ args: Command arguments to validate
315
+
316
+ Raises:
317
+ SecurityError: If any path argument is not in ALLOWED_PATHS
318
+
319
+ Security Notes:
320
+ - Uses startswith() to allow subdirectories of allowed paths
321
+ - Empty args list is allowed (no paths to validate)
322
+ - Non-path arguments (flags, values) are ignored
323
+ - Absolute paths starting with /tmp or /var/folders are allowed (for tests)
324
+ """
325
+ from pathlib import Path
326
+
327
+ for arg in args:
328
+ # Check if this argument is a file path
329
+ if arg.startswith("./") or arg.startswith("/"):
330
+ # Allow test directories (pytest tmp_path)
331
+ # macOS: /private/var/folders/, Linux: /tmp/
332
+ if (arg.startswith("/tmp/") or
333
+ arg.startswith("/var/folders/") or
334
+ arg.startswith("/private/var/folders/")):
335
+ continue
336
+
337
+ # Validate path is in allowed directories
338
+ if not any(arg.startswith(allowed) for allowed in self._allowed_path_prefixes):
339
+ error_msg = f"Path '{arg}' not allowed"
340
+ logger.error(
341
+ f"Security violation: {error_msg}",
342
+ extra={
343
+ "component": "terminal_executor",
344
+ "severity": "SECURITY_VIOLATION",
345
+ "path": arg,
346
+ "allowed_paths": list(self._allowed_path_prefixes),
347
+ },
348
+ )
349
+ raise SecurityError(error_msg)
350
+
351
+ def _truncate_output(self, output: bytes, stream_name: str) -> bytes:
352
+ """
353
+ Truncate large output to prevent memory exhaustion.
354
+
355
+ Args:
356
+ output: Raw output bytes from subprocess
357
+ stream_name: Name of stream for logging ("stdout" or "stderr")
358
+
359
+ Returns:
360
+ Truncated output bytes (original if under limit)
361
+
362
+ Notes:
363
+ - Maximum output size: 1MB per stream
364
+ - Truncated outputs include warning message
365
+ - Truncation is logged at WARNING level
366
+ """
367
+ MAX_OUTPUT_SIZE = 1024 * 1024 # 1MB
368
+
369
+ if len(output) > MAX_OUTPUT_SIZE:
370
+ logger.warning(
371
+ f"Truncating {stream_name}: {len(output)} bytes -> {MAX_OUTPUT_SIZE} bytes",
372
+ extra={
373
+ "component": "terminal_executor",
374
+ "stream": stream_name,
375
+ "original_size": len(output),
376
+ "truncated_size": MAX_OUTPUT_SIZE
377
+ }
378
+ )
379
+ return output[:MAX_OUTPUT_SIZE] + b"\n[Output truncated at 1MB limit]"
380
+
381
+ return output
@@ -0,0 +1,25 @@
1
+ """Shared text normalization for fuzzy match paths (patch + replace_in_file)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def normalize_unicode_punctuation(s: str) -> str:
7
+ """Map common smart quotes/dashes/nbsp to ASCII equivalents for matching."""
8
+ return (
9
+ s.replace("\u2018", "'")
10
+ .replace("\u2019", "'")
11
+ .replace("\u201a", "'")
12
+ .replace("\u201b", "'")
13
+ .replace("\u201c", '"')
14
+ .replace("\u201d", '"')
15
+ .replace("\u201e", '"')
16
+ .replace("\u201f", '"')
17
+ .replace("\u2010", "-")
18
+ .replace("\u2011", "-")
19
+ .replace("\u2012", "-")
20
+ .replace("\u2013", "-")
21
+ .replace("\u2014", "-")
22
+ .replace("\u2015", "-")
23
+ .replace("\u2026", "...")
24
+ .replace("\u00a0", " ")
25
+ )
@@ -0,0 +1,33 @@
1
+ """Tool execution result types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from monkeybot.core.types.content_blocks import ContentBlock, Text
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ToolExecutionResult:
12
+ blocks: list[ContentBlock]
13
+ error: str | None = None
14
+
15
+ @staticmethod
16
+ def ok_text(text: str) -> ToolExecutionResult:
17
+ return ToolExecutionResult(blocks=[Text(text=text)])
18
+
19
+ @staticmethod
20
+ def ok_blocks(blocks: list[ContentBlock]) -> ToolExecutionResult:
21
+ return ToolExecutionResult(blocks=list(blocks))
22
+
23
+ @staticmethod
24
+ def err(message: str) -> ToolExecutionResult:
25
+ return ToolExecutionResult(blocks=[Text(text=message)], error=message)
26
+
27
+
28
+ def unwrap_tool_execution_result(result: ToolExecutionResult) -> tuple[str | None, str | None]:
29
+ """Collapse blocks to ``(text, None)`` or ``(None, error)`` for text-only callers."""
30
+ if result.error is not None:
31
+ return None, result.error
32
+ parts = [b.text for b in result.blocks if isinstance(b, Text)]
33
+ return "".join(parts), None