mito-ai-python-tool-executor 0.1.1__tar.gz

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.
@@ -0,0 +1,139 @@
1
+ # Ignore the uv.lock file that Cursor keeps making
2
+ uv.lock
3
+
4
+ # Ignore all the ipynb files in mitosheet and mito-ai
5
+ mitosheet/**/*.ipynb
6
+ mito-ai/**/*.ipynb
7
+
8
+ # Ignore all the ipynb_checkpoints in mitosheet and mito-ai
9
+ mitosheet/.ipynb_checkpoints/*
10
+ mito-ai/.ipynb_checkpoints/*
11
+ tests/.ipynb_checkpoints/*
12
+ jupyterhub/.ipynb_checkpoints/*
13
+ .ipynb_checkpoints/*
14
+
15
+ # Ignore all the iframe_figures in mitosheet and mito-ai
16
+ # These get generated when creating plotly graphs
17
+ mitosheet/iframe_figures/*
18
+ mito-ai/iframe_figures/*
19
+ mito-sql-cell
20
+
21
+ # Evals
22
+ evals/reports/
23
+
24
+ # Other
25
+ mito-ai-mcp/*.mcpb
26
+
27
+ ## General
28
+ .DS_Store
29
+ .AppleDouble
30
+ .LSOverride
31
+ .env
32
+
33
+ ## Icon must end with two \r
34
+ Icon
35
+
36
+ ## Thumbnails
37
+ ._*
38
+
39
+
40
+ ## Files that might appear in the root of a volume
41
+ .DocumentRevisions-V100
42
+ .fseventsd
43
+ .Spotlight-V100
44
+ .TemporaryItems
45
+ .Trashes
46
+ .VolumeIcon.icns
47
+ .com.apple.timemachine.donotpresent
48
+
49
+ ## Directories potentially created on remote AFP share
50
+ .AppleDB
51
+ .AppleDesktop
52
+ Network Trash Folder
53
+ Temporary Items
54
+ .apdisk
55
+
56
+ # Windows
57
+
58
+ ## Windows thumbnail cache files
59
+ Thumbs.db
60
+ Thumbs.db:encryptable
61
+ ehthumbs.db
62
+ ehthumbs_vista.db
63
+
64
+ ## Dump file
65
+ *.stackdump
66
+
67
+ ## Folder config file
68
+ [Dd]esktop.ini
69
+
70
+ ## Recycle Bin used on file shares
71
+ $RECYCLE.BIN/
72
+
73
+ ## Windows Installer files
74
+ *.cab
75
+ *.msi
76
+ *.msix
77
+ *.msm
78
+ *.msp
79
+
80
+ ## Windows shortcuts
81
+ *.lnk
82
+
83
+ ## Python
84
+
85
+ # Virtual enviornemnts
86
+ venv/
87
+
88
+ # Byte-compiled / optimized / DLL files
89
+ __pycache__/
90
+ *.py[cod]
91
+
92
+ # C extensions
93
+ *.so
94
+
95
+ # Distribution / packaging
96
+ bin/
97
+ build/
98
+ develop-eggs/
99
+ dist/
100
+ eggs/
101
+ lib/
102
+ lib64/
103
+ parts/
104
+ sdist/
105
+ var/
106
+ *.egg-info/
107
+ .installed.cfg
108
+ *.egg
109
+ node_modules/
110
+
111
+ # Installer logs
112
+ pip-log.txt
113
+ pip-delete-this-directory.txt
114
+
115
+ # Unit test / coverage reports
116
+ .tox/
117
+ .coverage
118
+ .cache
119
+ nosetests.xml
120
+ coverage.xml
121
+
122
+ # Translations
123
+ *.mo
124
+
125
+ # Mr Developer
126
+ .mr.developer.cfg
127
+ .project
128
+ .pydevproject
129
+
130
+ # Rope
131
+ .ropeproject
132
+
133
+ # Django stuff:
134
+ *.log
135
+ *.pot
136
+
137
+ # Sphinx documentation
138
+ docs/_build/
139
+ mito-sql-cell/junit.xml
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: mito-ai-python-tool-executor
3
+ Version: 0.1.1
4
+ Summary: In-process ipykernel-backed ToolExecutor for Mito AI (CLI and headless use).
5
+ Author: Saga Inc.
6
+ License-Expression: AGPL-3.0-only
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: ipykernel>=6.0
9
+ Requires-Dist: jupyter-client>=8.0
10
+ Requires-Dist: mito-ai-core
11
+ Requires-Dist: nbformat>=5.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == 'test'
14
+ Requires-Dist: pytest-asyncio; extra == 'test'
@@ -0,0 +1,15 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ from mito_ai_python_tool_executor.blacklisted_words import BlacklistResult, check_for_blacklisted_words
5
+ from mito_ai_python_tool_executor.executor import AskUserMode, PythonToolExecutor
6
+ from mito_ai_python_tool_executor.notebook_io import cells_to_notebook, save_notebook
7
+
8
+ __all__ = [
9
+ "BlacklistResult",
10
+ "AskUserMode",
11
+ "PythonToolExecutor",
12
+ "check_for_blacklisted_words",
13
+ "cells_to_notebook",
14
+ "save_notebook",
15
+ ]
@@ -0,0 +1,89 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ """Detect dangerous patterns in code before execution (parity with mito-ai blacklistedWords)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import List, NamedTuple, Optional, Pattern, Tuple
10
+
11
+ __all__ = ["BlacklistResult", "check_for_blacklisted_words"]
12
+
13
+
14
+ class BlacklistResult(NamedTuple):
15
+ safe: bool
16
+ reason: Optional[str] = None
17
+
18
+
19
+ # Keep in sync with mito-ai/src/utils/blacklistedWords.tsx
20
+ _BLACKLISTED_PATTERNS: List[Tuple[Pattern[str], str]] = [
21
+ (
22
+ re.compile(r"\brm\s+-rf\b"),
23
+ "This code contains a command (rm -rf) that could recursively delete files and directories from your system",
24
+ ),
25
+ (
26
+ re.compile(r"\bfs\.rmdir\b"),
27
+ "This code contains a Node.js command (fs.rmdir) that could delete directories from your system",
28
+ ),
29
+ (
30
+ re.compile(r"\bfs\.unlink\b"),
31
+ "This code contains a Node.js command (fs.unlink) that could delete files from your system",
32
+ ),
33
+ (
34
+ re.compile(r"\bshutil\.rmtree\b"),
35
+ "This code contains a Python command (shutil.rmtree) that could recursively delete directories and their contents",
36
+ ),
37
+ (
38
+ re.compile(r"\bos\.remove\b"),
39
+ "This code contains a Python command (os.remove) that could delete files from your system",
40
+ ),
41
+ (
42
+ re.compile(r"\bos\.rmdir\b"),
43
+ "This code contains a Python command (os.rmdir) that could delete directories from your system",
44
+ ),
45
+ (
46
+ re.compile(r"\bos\.unlink\b"),
47
+ "This code contains a Python command (os.unlink) that could delete files from your system",
48
+ ),
49
+ (
50
+ re.compile(r"\brmdir\b"),
51
+ "This code contains a command (rmdir) that could delete directories from your system",
52
+ ),
53
+ (
54
+ re.compile(r"\bunlink\b"),
55
+ "This code contains a function (unlink) that could delete files from your system",
56
+ ),
57
+ (
58
+ re.compile(r"\bdelete\s+from\b", re.IGNORECASE),
59
+ "This code contains an SQL DELETE command that could remove data from your database",
60
+ ),
61
+ (
62
+ re.compile(r"\bdrop\s+table\b", re.IGNORECASE),
63
+ "This code contains an SQL DROP TABLE command that could delete entire tables from your database",
64
+ ),
65
+ (
66
+ re.compile(r"\bdrop\s+database\b", re.IGNORECASE),
67
+ "This code contains an SQL DROP DATABASE command that could delete your entire database",
68
+ ),
69
+ (
70
+ re.compile(r"\bsystem\s*\("),
71
+ "This code contains a system() call that could execute arbitrary system commands, which is a security risk",
72
+ ),
73
+ (
74
+ re.compile(r"\beval\s*\("),
75
+ "This code contains an eval() function that could execute arbitrary code, which is a security risk",
76
+ ),
77
+ (
78
+ re.compile(r"\bexec\s*\("),
79
+ "This code contains an exec() function that could execute arbitrary code, which is a security risk",
80
+ ),
81
+ ]
82
+
83
+
84
+ def check_for_blacklisted_words(code: str) -> BlacklistResult:
85
+ """Return ``safe=False`` and a *reason* if *code* matches a blocked pattern."""
86
+ for pattern, message in _BLACKLISTED_PATTERNS:
87
+ if pattern.search(code):
88
+ return BlacklistResult(safe=False, reason=message)
89
+ return BlacklistResult(safe=True, reason=None)
@@ -0,0 +1,447 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ """In-process :class:`ToolExecutor` backed by an ipykernel session."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import uuid
11
+ from typing import Awaitable, Callable, List, Literal, Optional, Tuple
12
+
13
+ from mito_ai_core.agent.types import AgentContext, ToolResult
14
+ from mito_ai_core.completions.models import AIOptimizedCell, CellUpdate
15
+
16
+ from mito_ai_python_tool_executor.blacklisted_words import check_for_blacklisted_words
17
+ from mito_ai_python_tool_executor.kernel_session import KernelSession
18
+
19
+ AskUserMode = Literal["cli", "mcp_elicitation", "mcp_plaintext"]
20
+ AskUserHandler = Callable[[str, Optional[List[str]]], Awaitable[Optional[str]]]
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ASK_USER_QUESTION_DISABLED_MESSAGE = (
24
+ "The ask_user_question tool is disabled in this environment. "
25
+ "Please use your best judgement to assume the user's response and continue working."
26
+ )
27
+
28
+ STREAMLIT_FUNCTIONALITY_DISABLED_MESSAGE = (
29
+ "This Streamlit and app functionality is disabled in this environment. "
30
+ "Please use your best judgement on how to proceed. Either continue working in the notebook "
31
+ "or tell the user that this functionality is disabled."
32
+ )
33
+
34
+
35
+ def _blacklist_error(code: str) -> Optional[str]:
36
+ result = check_for_blacklisted_words(code)
37
+ return result.reason if not result.safe else None
38
+
39
+
40
+ def _default_cell_type(cell_update: CellUpdate) -> str:
41
+ return cell_update.cell_type or "code"
42
+
43
+
44
+ def _find_cell_index(cells: List[AIOptimizedCell], cell_id: str) -> Optional[int]:
45
+ for i, c in enumerate(cells):
46
+ if c.id == cell_id:
47
+ return i
48
+ return None
49
+
50
+
51
+ class PythonToolExecutor:
52
+ """Execute agent tools against a single long-lived ipykernel.
53
+
54
+ Mutates :class:`AgentContext` in place (``cells``, ``active_cell_id``,
55
+ ``variables``) and returns :class:`ToolResult` snapshots for the agent loop.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ ask_user_mode: AskUserMode = "cli",
62
+ ask_user_handler: Optional[AskUserHandler] = None,
63
+ kernel_cwd: str | None = None,
64
+ ) -> None:
65
+ self._session: Optional[KernelSession] = None
66
+ self._last_cell_text: dict[str, str] = {}
67
+ self._ask_user_mode: AskUserMode = ask_user_mode
68
+ self._ask_user_handler = ask_user_handler
69
+ self._kernel_cwd = kernel_cwd
70
+
71
+ def _ensure_session(self) -> KernelSession:
72
+ if self._session is None:
73
+ self._session = KernelSession(cwd=self._kernel_cwd)
74
+ return self._session
75
+
76
+ def shutdown(self) -> None:
77
+ """Stop the kernel if it was started."""
78
+ if self._session is not None:
79
+ self._session.shutdown()
80
+ self._session = None
81
+
82
+ async def execute_cell_update(
83
+ self,
84
+ ctx: AgentContext,
85
+ cell_update: CellUpdate,
86
+ message: str,
87
+ ) -> ToolResult:
88
+ def _sync() -> ToolResult:
89
+ session = self._ensure_session()
90
+ cells = list(ctx.cells)
91
+ new_cells, active_id, err = self._apply_cell_update(cells, cell_update)
92
+ if err:
93
+ return ToolResult(
94
+ success=False,
95
+ tool_name="cell_update",
96
+ error_message=err,
97
+ )
98
+ ctx.cells = new_cells
99
+ ctx.active_cell_id = active_id
100
+
101
+ idx = _find_cell_index(new_cells, active_id)
102
+ if idx is None:
103
+ vars_ = session.fetch_variables()
104
+ ctx.variables = vars_
105
+ return ToolResult(
106
+ success=True,
107
+ tool_name="cell_update",
108
+ cells=new_cells,
109
+ variables=vars_,
110
+ )
111
+
112
+ cell = new_cells[idx]
113
+ if cell.cell_type != "code":
114
+ vars_ = session.fetch_variables()
115
+ ctx.variables = vars_
116
+ return ToolResult(
117
+ success=True,
118
+ tool_name="cell_update",
119
+ cells=new_cells,
120
+ variables=vars_,
121
+ )
122
+
123
+ blocked = _blacklist_error(cell.code)
124
+ if blocked:
125
+ vars_ = session.fetch_variables()
126
+ ctx.variables = vars_
127
+ return ToolResult(
128
+ success=False,
129
+ tool_name="cell_update",
130
+ error_message=blocked,
131
+ cells=new_cells,
132
+ variables=vars_,
133
+ )
134
+
135
+ ok, out, exec_err = session.execute(cell.code)
136
+ self._last_cell_text[cell.id] = out if out else (exec_err or "")
137
+
138
+ vars_ = session.fetch_variables()
139
+ ctx.variables = vars_
140
+ if not ok:
141
+ return ToolResult(
142
+ success=False,
143
+ tool_name="cell_update",
144
+ error_message=exec_err or "Cell execution failed",
145
+ cells=new_cells,
146
+ variables=vars_,
147
+ )
148
+ return ToolResult(
149
+ success=True,
150
+ tool_name="cell_update",
151
+ cells=new_cells,
152
+ variables=vars_,
153
+ )
154
+
155
+ return await asyncio.to_thread(_sync)
156
+
157
+ def _apply_cell_update(
158
+ self,
159
+ cells: List[AIOptimizedCell],
160
+ cell_update: CellUpdate,
161
+ ) -> Tuple[List[AIOptimizedCell], str, Optional[str]]:
162
+ if cell_update.type == "modification":
163
+ if not cell_update.id:
164
+ return cells, "", "cell_update modification requires id"
165
+ idx = _find_cell_index(cells, cell_update.id)
166
+ if idx is None:
167
+ return cells, "", f"Cell id {cell_update.id!r} not found"
168
+ ct = _default_cell_type(cell_update)
169
+ updated = AIOptimizedCell(cell_type=ct, id=cell_update.id, code=cell_update.code)
170
+ new_cells = cells.copy()
171
+ new_cells[idx] = updated
172
+ return new_cells, cell_update.id, None
173
+
174
+ # new cell
175
+ after = cell_update.after_cell_id
176
+ if after is None:
177
+ return cells, "", "cell_update new requires after_cell_id"
178
+
179
+ ct = _default_cell_type(cell_update)
180
+ new_id = str(uuid.uuid4())
181
+ new_cell = AIOptimizedCell(cell_type=ct, id=new_id, code=cell_update.code)
182
+ new_cells = cells.copy()
183
+
184
+ if after == "new cell":
185
+ new_cells.insert(0, new_cell)
186
+ return new_cells, new_id, None
187
+
188
+ idx = _find_cell_index(new_cells, after)
189
+ if idx is None:
190
+ return cells, "", f"after_cell_id {after!r} not found"
191
+ new_cells.insert(idx + 1, new_cell)
192
+ return new_cells, new_id, None
193
+
194
+ async def run_all_cells(
195
+ self,
196
+ ctx: AgentContext,
197
+ message: str,
198
+ ) -> ToolResult:
199
+ def _sync() -> ToolResult:
200
+ session = self._ensure_session()
201
+ cells = list(ctx.cells)
202
+ for cell in cells:
203
+ if cell.cell_type != "code":
204
+ continue
205
+ blocked = _blacklist_error(cell.code)
206
+ if blocked:
207
+ vars_ = session.fetch_variables()
208
+ ctx.variables = vars_
209
+ ctx.cells = cells
210
+ return ToolResult(
211
+ success=False,
212
+ tool_name="run_all_cells",
213
+ error_message=blocked,
214
+ cells=cells,
215
+ variables=vars_,
216
+ )
217
+ ok, out, err = session.execute(cell.code)
218
+ self._last_cell_text[cell.id] = out if out else (err or "")
219
+ if not ok:
220
+ vars_ = session.fetch_variables()
221
+ ctx.variables = vars_
222
+ ctx.cells = cells
223
+ return ToolResult(
224
+ success=False,
225
+ tool_name="run_all_cells",
226
+ error_message=err or "Run-all failed",
227
+ cells=cells,
228
+ variables=vars_,
229
+ )
230
+ vars_ = session.fetch_variables()
231
+ ctx.variables = vars_
232
+ ctx.cells = cells
233
+ return ToolResult(
234
+ success=True,
235
+ tool_name="run_all_cells",
236
+ cells=cells,
237
+ variables=vars_,
238
+ )
239
+
240
+ return await asyncio.to_thread(_sync)
241
+
242
+ async def get_cell_output(
243
+ self,
244
+ ctx: AgentContext,
245
+ cell_id: str,
246
+ message: str,
247
+ ) -> ToolResult:
248
+ def _sync() -> ToolResult:
249
+ text = self._last_cell_text.get(cell_id)
250
+ if text is None:
251
+ return ToolResult(
252
+ success=True,
253
+ tool_name="get_cell_output",
254
+ output=None,
255
+ error_message=(
256
+ "[CLI] No captured text output for this cell yet. "
257
+ "Run the cell or use run_all_cells / scratchpad first."
258
+ ),
259
+ )
260
+ return ToolResult(
261
+ success=True,
262
+ tool_name="get_cell_output",
263
+ output=None,
264
+ error_message=f"Cell output (plain text):\n{text}",
265
+ )
266
+
267
+ return await asyncio.to_thread(_sync)
268
+
269
+ async def execute_scratchpad(
270
+ self,
271
+ ctx: AgentContext,
272
+ code: str,
273
+ summary: str,
274
+ message: str,
275
+ ) -> ToolResult:
276
+ def _sync() -> ToolResult:
277
+ session = self._ensure_session()
278
+ blocked = _blacklist_error(code)
279
+ if blocked:
280
+ vars_ = session.fetch_variables()
281
+ ctx.variables = vars_
282
+ return ToolResult(
283
+ success=False,
284
+ tool_name="scratchpad",
285
+ error_message=blocked,
286
+ variables=vars_,
287
+ )
288
+ ok, out, err = session.execute(code)
289
+ vars_ = session.fetch_variables()
290
+ ctx.variables = vars_
291
+ if not ok:
292
+ return ToolResult(
293
+ success=False,
294
+ tool_name="scratchpad",
295
+ error_message=err or "Scratchpad execution failed",
296
+ output=out,
297
+ variables=vars_,
298
+ )
299
+ return ToolResult(
300
+ success=True,
301
+ tool_name="scratchpad",
302
+ output=out,
303
+ variables=vars_,
304
+ )
305
+
306
+ return await asyncio.to_thread(_sync)
307
+
308
+ async def ask_user_question(
309
+ self,
310
+ ctx: AgentContext,
311
+ question: str,
312
+ message: str,
313
+ answers: Optional[List[str]] = None,
314
+ ) -> ToolResult:
315
+ logger.info(
316
+ "ask_user_question invoked with mode=%s, answers_provided=%s",
317
+ self._ask_user_mode,
318
+ bool(answers),
319
+ )
320
+
321
+ if self._ask_user_mode == "mcp_elicitation":
322
+ logger.info("Routing ask_user_question via MCP elicitation handler")
323
+ return await self._ask_user_question_via_mcp(ctx, question, answers)
324
+ if self._ask_user_mode == "mcp_plaintext":
325
+ logger.info("Routing ask_user_question via MCP plaintext fallback")
326
+ return await self._ask_user_question_disabled_response(ctx, question, answers)
327
+ if self._ask_user_mode != "cli":
328
+ logger.warning(
329
+ "Unknown ask_user_mode=%s; forcing plaintext fallback",
330
+ self._ask_user_mode,
331
+ )
332
+ return await self._ask_user_question_disabled_response(ctx, question, answers)
333
+
334
+ logger.warning("Routing ask_user_question via CLI stdin/stdout prompt path")
335
+ def _sync() -> ToolResult:
336
+ session = self._ensure_session()
337
+ print(question, flush=True)
338
+ if answers:
339
+ for i, a in enumerate(answers):
340
+ print(f" [{i}] {a}", flush=True)
341
+ raw = input("Enter a number or free text: ").strip()
342
+ if raw.isdigit():
343
+ j = int(raw)
344
+ if 0 <= j < len(answers):
345
+ answer = answers[j]
346
+ else:
347
+ answer = raw
348
+ else:
349
+ answer = raw
350
+ else:
351
+ answer = input("> ").strip()
352
+
353
+ vars_ = session.fetch_variables()
354
+ ctx.variables = vars_
355
+ return ToolResult(
356
+ success=True,
357
+ tool_name="ask_user_question",
358
+ output=answer,
359
+ variables=vars_,
360
+ )
361
+
362
+ return await asyncio.to_thread(_sync)
363
+
364
+ async def _ask_user_question_via_mcp(
365
+ self,
366
+ ctx: AgentContext,
367
+ question: str,
368
+ answers: Optional[List[str]],
369
+ ) -> ToolResult:
370
+ if self._ask_user_handler is None:
371
+ logger.warning(
372
+ "ask_user_question is in `mcp_elicitation` mode but no "
373
+ "elicitation handler is configured; returning disabled "
374
+ "plaintext message."
375
+ )
376
+ return await self._ask_user_question_disabled_response(ctx, question, answers)
377
+
378
+ try:
379
+ answer = await self._ask_user_handler(question, answers)
380
+ except Exception as exc:
381
+ logger.warning(
382
+ "MCP elicitation failed for ask_user_question; returning "
383
+ "disabled plaintext message. error=%s",
384
+ exc,
385
+ )
386
+ return await self._ask_user_question_disabled_response(ctx, question, answers)
387
+
388
+ session = self._ensure_session()
389
+ vars_ = session.fetch_variables()
390
+ ctx.variables = vars_
391
+ return ToolResult(
392
+ success=True,
393
+ tool_name="ask_user_question",
394
+ output=(answer or "").strip(),
395
+ variables=vars_,
396
+ )
397
+
398
+ async def _ask_user_question_disabled_response(
399
+ self,
400
+ ctx: AgentContext,
401
+ question: str,
402
+ answers: Optional[List[str]],
403
+ ) -> ToolResult:
404
+ del question, answers # Plaintext mode intentionally disables user prompting.
405
+ session = self._ensure_session()
406
+ vars_ = session.fetch_variables()
407
+ ctx.variables = vars_
408
+ return ToolResult(
409
+ success=True,
410
+ tool_name="ask_user_question",
411
+ output=ASK_USER_QUESTION_DISABLED_MESSAGE,
412
+ variables=vars_,
413
+ )
414
+
415
+ async def create_streamlit_app(
416
+ self,
417
+ ctx: AgentContext,
418
+ message: str,
419
+ streamlit_app_prompt: Optional[str] = None,
420
+ ) -> ToolResult:
421
+ del message, streamlit_app_prompt
422
+ session = self._ensure_session()
423
+ vars_ = session.fetch_variables()
424
+ ctx.variables = vars_
425
+ return ToolResult(
426
+ success=True,
427
+ tool_name="create_streamlit_app",
428
+ output=STREAMLIT_FUNCTIONALITY_DISABLED_MESSAGE,
429
+ variables=vars_,
430
+ )
431
+
432
+ async def edit_streamlit_app(
433
+ self,
434
+ ctx: AgentContext,
435
+ streamlit_app_prompt: str,
436
+ message: str,
437
+ ) -> ToolResult:
438
+ del streamlit_app_prompt, message
439
+ session = self._ensure_session()
440
+ vars_ = session.fetch_variables()
441
+ ctx.variables = vars_
442
+ return ToolResult(
443
+ success=True,
444
+ tool_name="edit_streamlit_app",
445
+ output=STREAMLIT_FUNCTIONALITY_DISABLED_MESSAGE,
446
+ variables=vars_,
447
+ )
@@ -0,0 +1,145 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ """One ipykernel session with execute + variable introspection."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from queue import Empty
11
+ from typing import List, Optional, Tuple
12
+
13
+ from jupyter_client import KernelManager
14
+
15
+ from mito_ai_core.completions.models import KernelVariable
16
+ from mito_ai_core.kernel_variable_inspection import get_kernel_variable_inspection_script
17
+
18
+ # Same script as JupyterLab VariableInspector / kernelVariableInspectionScript.ts
19
+ # (mito_ai_core/resources/kernel_variable_inspection.txt).
20
+ _VARIABLE_PROBE = get_kernel_variable_inspection_script()
21
+ _ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
22
+
23
+
24
+ class KernelSession:
25
+ """Starts a single kernel and exposes blocking execute + shutdown."""
26
+
27
+ def __init__(self, *, cwd: str | None = None) -> None:
28
+ self._km = KernelManager()
29
+ start_kwargs = {"cwd": cwd} if cwd else {}
30
+ self._km.start_kernel(**start_kwargs)
31
+ self._kc = self._km.client()
32
+ self._kc.start_channels()
33
+ self._kc.wait_for_ready(timeout=120)
34
+
35
+ def shutdown(self) -> None:
36
+ try:
37
+ self._kc.stop_channels()
38
+ finally:
39
+ try:
40
+ self._km.shutdown_kernel(now=True)
41
+ except Exception:
42
+ pass
43
+
44
+ def execute(
45
+ self, code: str, *, timeout_s: float = 300.0
46
+ ) -> Tuple[bool, str, Optional[str]]:
47
+ """Run *code* in the kernel.
48
+
49
+ Returns
50
+ -------
51
+ success, combined_text_output, error_message
52
+ *error_message* is ``None`` when execution finished without a traceback.
53
+ """
54
+ msg_id = self._kc.execute(code)
55
+ text_parts: List[str] = []
56
+ error_text: Optional[str] = None
57
+
58
+ while True:
59
+ try:
60
+ msg = self._kc.get_iopub_msg(timeout=timeout_s)
61
+ except Empty:
62
+ return False, "", "Timed out waiting for kernel iopub output"
63
+
64
+ if msg.get("parent_header", {}).get("msg_id") != msg_id:
65
+ continue
66
+
67
+ msg_type = msg["msg_type"]
68
+ content = msg["content"]
69
+
70
+ if msg_type == "stream":
71
+ text_parts.append(_strip_ansi_escape_sequences(content.get("text", "")))
72
+ elif msg_type == "error":
73
+ error_text = _strip_ansi_escape_sequences(
74
+ "\n".join(content.get("traceback", []))
75
+ )
76
+ elif msg_type == "execute_result":
77
+ data = content.get("data", {})
78
+ plain = data.get("text/plain")
79
+ if plain is not None:
80
+ text_parts.append(_strip_ansi_escape_sequences(plain))
81
+ elif msg_type == "display_data":
82
+ data = content.get("data", {})
83
+ plain = data.get("text/plain")
84
+ if plain is not None:
85
+ text_parts.append(_strip_ansi_escape_sequences(plain))
86
+ elif msg_type == "status" and content.get("execution_state") == "idle":
87
+ break
88
+
89
+ # Shell reply may contain error info even when iopub missed it.
90
+ try:
91
+ reply = self._kc.get_shell_msg(timeout=60)
92
+ except Empty:
93
+ reply = None
94
+
95
+ if reply is not None:
96
+ c = reply.get("content", {})
97
+ if c.get("status") == "error":
98
+ tb = c.get("traceback")
99
+ if tb:
100
+ shell_err = _strip_ansi_escape_sequences("\n".join(tb))
101
+ else:
102
+ shell_err = _strip_ansi_escape_sequences(
103
+ c.get("evalue", "Kernel error")
104
+ )
105
+ if not error_text:
106
+ error_text = shell_err
107
+
108
+ out = "".join(text_parts)
109
+ success = error_text is None
110
+ return success, out, error_text
111
+
112
+ def fetch_variables(self, *, timeout_s: float = 120.0) -> List[KernelVariable]:
113
+ """Return a simple name / type / repr view of user globals."""
114
+ ok, out, err = self.execute(_VARIABLE_PROBE, timeout_s=timeout_s)
115
+ if not ok or err:
116
+ return []
117
+ out = out.strip()
118
+ if not out:
119
+ return []
120
+ try:
121
+ raw = json.loads(out)
122
+ except json.JSONDecodeError:
123
+ return []
124
+ result: List[KernelVariable] = []
125
+ for item in raw:
126
+ if not isinstance(item, dict):
127
+ continue
128
+ name = item.get("variable_name")
129
+ typ = item.get("type")
130
+ if not isinstance(name, str) or not isinstance(typ, str):
131
+ continue
132
+ result.append(
133
+ KernelVariable(
134
+ variable_name=name,
135
+ type=typ,
136
+ value=item.get("value"),
137
+ )
138
+ )
139
+ return result
140
+
141
+
142
+ def _strip_ansi_escape_sequences(text: str) -> str:
143
+ if not isinstance(text, str) or not text:
144
+ return text
145
+ return _ANSI_ESCAPE_PATTERN.sub("", text)
@@ -0,0 +1,31 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ """Serialize :class:`AIOptimizedCell` lists to Jupyter notebook files."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import List
9
+
10
+ import nbformat
11
+ from nbformat.v4 import new_code_cell, new_markdown_cell, new_notebook
12
+ from nbformat.notebooknode import NotebookNode
13
+
14
+ from mito_ai_core.completions.models import AIOptimizedCell
15
+
16
+
17
+ def cells_to_notebook(cells: List[AIOptimizedCell]) -> NotebookNode:
18
+ """Build an ``nbformat`` v4 notebook from agent cells (no outputs)."""
19
+ nb = new_notebook()
20
+ for cell in cells:
21
+ if cell.cell_type == "markdown":
22
+ nb.cells.append(new_markdown_cell(source=cell.code))
23
+ else:
24
+ nb.cells.append(new_code_cell(source=cell.code))
25
+ return nb
26
+
27
+
28
+ def save_notebook(nb: NotebookNode, path: str) -> None:
29
+ """Write *nb* to *path* as ``.ipynb`` (UTF-8)."""
30
+ with open(path, "w", encoding="utf-8") as f:
31
+ nbformat.write(nb, f)
@@ -0,0 +1,29 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ [build-system]
5
+ requires = ["hatchling"]
6
+ build-backend = "hatchling.build"
7
+
8
+ [project]
9
+ name = "mito-ai-python-tool-executor"
10
+ version = "0.1.1"
11
+ description = "In-process ipykernel-backed ToolExecutor for Mito AI (CLI and headless use)."
12
+ license = "AGPL-3.0-only"
13
+ requires-python = ">=3.9"
14
+ authors = [{ name = "Saga Inc." }]
15
+ dependencies = [
16
+ "mito-ai-core",
17
+ "nbformat>=5.0",
18
+ "jupyter_client>=8.0",
19
+ "ipykernel>=6.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ test = [
24
+ "pytest",
25
+ "pytest-asyncio",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["mito_ai_python_tool_executor"]
@@ -0,0 +1,34 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ """Tests for blacklisted word patterns (parity with mito-ai blacklistedWords.tsx)."""
5
+
6
+ from mito_ai_python_tool_executor.blacklisted_words import check_for_blacklisted_words
7
+
8
+
9
+ def test_safe_snippets() -> None:
10
+ assert check_for_blacklisted_words("import pandas as pd\ndf.head()").safe is True
11
+ assert check_for_blacklisted_words("os.path.join('a', 'b')").safe is True
12
+
13
+
14
+ def test_blocks_rm_rf() -> None:
15
+ r = check_for_blacklisted_words("!rm -rf /tmp/x")
16
+ assert r.safe is False
17
+ assert r.reason is not None
18
+ assert "rm -rf" in r.reason
19
+
20
+
21
+ def test_blocks_shutil_rmtree() -> None:
22
+ r = check_for_blacklisted_words("import shutil\nshutil.rmtree('foo')")
23
+ assert r.safe is False
24
+ assert "shutil.rmtree" in (r.reason or "")
25
+
26
+
27
+ def test_blocks_sql_delete() -> None:
28
+ r = check_for_blacklisted_words("DELETE FROM users WHERE 1=1")
29
+ assert r.safe is False
30
+
31
+
32
+ def test_blocks_eval() -> None:
33
+ r = check_for_blacklisted_words("eval('1+1')")
34
+ assert r.safe is False
@@ -0,0 +1,149 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ import asyncio
5
+
6
+ import pytest
7
+
8
+ from mito_ai_core.agent.types import AgentContext
9
+ from mito_ai_python_tool_executor import executor as executor_module
10
+ from mito_ai_python_tool_executor.executor import (
11
+ ASK_USER_QUESTION_DISABLED_MESSAGE,
12
+ PythonToolExecutor,
13
+ STREAMLIT_FUNCTIONALITY_DISABLED_MESSAGE,
14
+ )
15
+
16
+
17
+ class _FakeSession:
18
+ def fetch_variables(self):
19
+ return []
20
+
21
+
22
+ def _build_context() -> AgentContext:
23
+ return AgentContext(
24
+ thread_id="thread-id",
25
+ notebook_id="notebook-id",
26
+ notebook_path="notebook-path.ipynb",
27
+ is_chrome_browser=False,
28
+ )
29
+
30
+
31
+ def test_ask_user_question_plaintext_mode_returns_disabled_message() -> None:
32
+ executor = PythonToolExecutor(ask_user_mode="mcp_plaintext")
33
+ executor._ensure_session = lambda: _FakeSession() # type: ignore[method-assign]
34
+ ctx = _build_context()
35
+
36
+ result = asyncio.run(
37
+ executor.ask_user_question(
38
+ ctx,
39
+ question="How should I proceed?",
40
+ message="Need clarification",
41
+ answers=["Option A", "Option B"],
42
+ )
43
+ )
44
+
45
+ assert result.success is True
46
+ assert result.tool_name == "ask_user_question"
47
+ assert result.output == ASK_USER_QUESTION_DISABLED_MESSAGE
48
+
49
+
50
+ def test_executor_forwards_kernel_cwd_when_starting_session(
51
+ monkeypatch: pytest.MonkeyPatch,
52
+ ) -> None:
53
+ captured: dict[str, str | None] = {"cwd": None}
54
+
55
+ class _KernelSessionStub:
56
+ def __init__(self, *, cwd: str | None = None) -> None:
57
+ captured["cwd"] = cwd
58
+
59
+ monkeypatch.setattr(executor_module, "KernelSession", _KernelSessionStub)
60
+
61
+ executor = PythonToolExecutor(kernel_cwd="/tmp/mcp-root")
62
+ executor._ensure_session()
63
+
64
+ assert captured["cwd"] == "/tmp/mcp-root"
65
+
66
+
67
+ def test_ask_user_question_elicitation_without_handler_falls_back_to_disabled_message() -> None:
68
+ executor = PythonToolExecutor(ask_user_mode="mcp_elicitation", ask_user_handler=None)
69
+ executor._ensure_session = lambda: _FakeSession() # type: ignore[method-assign]
70
+ ctx = _build_context()
71
+
72
+ result = asyncio.run(
73
+ executor.ask_user_question(
74
+ ctx,
75
+ question="What would you like to do?",
76
+ message="Need user input",
77
+ answers=["Proceed", "Cancel"],
78
+ )
79
+ )
80
+
81
+ assert result.success is True
82
+ assert result.tool_name == "ask_user_question"
83
+ assert result.output == ASK_USER_QUESTION_DISABLED_MESSAGE
84
+ assert result.error_message is None
85
+
86
+
87
+ def test_ask_user_question_elicitation_exception_falls_back_to_disabled_message() -> None:
88
+ async def _failing_handler(question: str, answers: list[str] | None) -> str:
89
+ del question, answers
90
+ raise RuntimeError("elicitation transport failed")
91
+
92
+ executor = PythonToolExecutor(
93
+ ask_user_mode="mcp_elicitation",
94
+ ask_user_handler=_failing_handler,
95
+ )
96
+ executor._ensure_session = lambda: _FakeSession() # type: ignore[method-assign]
97
+ ctx = _build_context()
98
+
99
+ result = asyncio.run(
100
+ executor.ask_user_question(
101
+ ctx,
102
+ question="Pick one",
103
+ message="Need user input",
104
+ answers=["A", "B"],
105
+ )
106
+ )
107
+
108
+ assert result.success is True
109
+ assert result.tool_name == "ask_user_question"
110
+ assert result.output == ASK_USER_QUESTION_DISABLED_MESSAGE
111
+ assert result.error_message is None
112
+
113
+
114
+ def test_create_streamlit_app_returns_not_implemented_message() -> None:
115
+ executor = PythonToolExecutor()
116
+ executor._ensure_session = lambda: _FakeSession() # type: ignore[method-assign]
117
+ ctx = _build_context()
118
+
119
+ result = asyncio.run(
120
+ executor.create_streamlit_app(
121
+ ctx,
122
+ message="Create a streamlit app",
123
+ streamlit_app_prompt="Simple dashboard",
124
+ )
125
+ )
126
+
127
+ assert result.success is True
128
+ assert result.tool_name == "create_streamlit_app"
129
+ assert result.output == STREAMLIT_FUNCTIONALITY_DISABLED_MESSAGE
130
+ assert result.error_message is None
131
+
132
+
133
+ def test_edit_streamlit_app_returns_not_implemented_message() -> None:
134
+ executor = PythonToolExecutor()
135
+ executor._ensure_session = lambda: _FakeSession() # type: ignore[method-assign]
136
+ ctx = _build_context()
137
+
138
+ result = asyncio.run(
139
+ executor.edit_streamlit_app(
140
+ ctx,
141
+ streamlit_app_prompt="Add date filters",
142
+ message="Edit the streamlit app",
143
+ )
144
+ )
145
+
146
+ assert result.success is True
147
+ assert result.tool_name == "edit_streamlit_app"
148
+ assert result.output == STREAMLIT_FUNCTIONALITY_DISABLED_MESSAGE
149
+ assert result.error_message is None
@@ -0,0 +1,13 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ from mito_ai_python_tool_executor.kernel_session import _strip_ansi_escape_sequences
5
+
6
+
7
+ def test_strip_ansi_escape_sequences_removes_terminal_color_codes() -> None:
8
+ raw = (
9
+ "\x1b[31mFileNotFoundError\x1b[39m: [Errno 2] No such file or directory: "
10
+ "'transactions.csv'"
11
+ )
12
+ cleaned = _strip_ansi_escape_sequences(raw)
13
+ assert cleaned == "FileNotFoundError: [Errno 2] No such file or directory: 'transactions.csv'"