cowork-dash 0.1.8__py3-none-any.whl → 0.2.0__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.
cowork_dash/tools.py CHANGED
@@ -1,15 +1,88 @@
1
1
  from typing import Any, Dict, List, Optional
2
2
  import sys
3
3
  import io
4
+ import os
4
5
  import traceback
5
6
  import subprocess
6
7
  import threading
7
- from contextlib import redirect_stdout, redirect_stderr
8
+ import platform
9
+ from contextlib import redirect_stdout, redirect_stderr, contextmanager
8
10
 
9
11
  from .config import WORKSPACE_ROOT, VIRTUAL_FS
10
12
  from .canvas import parse_canvas_object, generate_canvas_id
11
13
 
12
14
 
15
+ # =============================================================================
16
+ # DISPLAY INLINE - PUSH TO AGENT STATE
17
+ # =============================================================================
18
+ # Instead of returning data through LangGraph (which has serialization limitations),
19
+ # display_inline pushes items directly to the agent state. The Dash polling callback
20
+ # picks them up from state["display_inline_items"] and renders them directly.
21
+
22
+
23
+ def _push_display_inline_to_state(item: Dict[str, Any]) -> None:
24
+ """Push a display_inline item to the agent state.
25
+
26
+ Uses the current session's state via push_display_inline_item from app.py.
27
+
28
+ Args:
29
+ item: The display result dict with type, display_type, data, etc.
30
+ """
31
+ # Import here to avoid circular import
32
+ from .app import push_display_inline_item
33
+
34
+ # Get session_id from tool context (set during agent execution)
35
+ session_id = get_tool_session_context()
36
+ push_display_inline_item(item, session_id)
37
+
38
+
39
+ # =============================================================================
40
+
41
+
42
+ # Memory limit for cell execution (in bytes)
43
+ # Default: 512 MB - can be overridden via environment variable
44
+ CELL_MEMORY_LIMIT_MB = int(os.environ.get("COWORK_CELL_MEMORY_LIMIT_MB", "512"))
45
+ CELL_MEMORY_LIMIT_BYTES = CELL_MEMORY_LIMIT_MB * 1024 * 1024
46
+
47
+
48
+ @contextmanager
49
+ def memory_limit(max_bytes: int = CELL_MEMORY_LIMIT_BYTES):
50
+ """Context manager to set memory limits for code execution on Linux.
51
+
52
+ On Linux, uses resource.setrlimit() to set soft memory limit.
53
+ On other platforms (macOS, Windows), this is a no-op as they don't
54
+ support RLIMIT_AS in the same way or at all.
55
+
56
+ The limit applies to the virtual address space (RLIMIT_AS) which
57
+ will cause MemoryError when exceeded rather than OOM kill.
58
+
59
+ Args:
60
+ max_bytes: Maximum memory in bytes. Default from CELL_MEMORY_LIMIT_BYTES.
61
+ """
62
+ if platform.system() != "Linux":
63
+ # Memory limits via resource module only work reliably on Linux
64
+ yield
65
+ return
66
+
67
+ try:
68
+ import resource
69
+ except ImportError:
70
+ yield
71
+ return
72
+
73
+ # Get current limits
74
+ soft, hard = resource.getrlimit(resource.RLIMIT_AS)
75
+
76
+ try:
77
+ # Set new soft limit (don't exceed hard limit)
78
+ new_soft = min(max_bytes, hard) if hard != resource.RLIM_INFINITY else max_bytes
79
+ resource.setrlimit(resource.RLIMIT_AS, (new_soft, hard))
80
+ yield
81
+ finally:
82
+ # Restore original limits
83
+ resource.setrlimit(resource.RLIMIT_AS, (soft, hard))
84
+
85
+
13
86
  # Thread-local storage for current session context
14
87
  # This allows tools to know which session they're operating in
15
88
  _tool_context = threading.local()
@@ -383,47 +456,54 @@ except (ImportError, AttributeError):
383
456
  }
384
457
 
385
458
  try:
386
- # Try IPython first for better execution handling
387
- ipython = self._get_ipython()
388
-
389
- if ipython is not None:
390
- # Use IPython's run_cell for magic commands support
391
- with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
392
- exec_result = ipython.run_cell(cell["source"], store_history=True)
393
-
394
- result["stdout"] = stdout_capture.getvalue()
395
- result["stderr"] = stderr_capture.getvalue()
396
-
397
- if exec_result.success:
398
- if exec_result.result is not None:
399
- result["result"] = repr(exec_result.result)
459
+ # Apply memory limit on Linux to prevent OOM kills
460
+ with memory_limit():
461
+ # Try IPython first for better execution handling
462
+ ipython = self._get_ipython()
463
+
464
+ if ipython is not None:
465
+ # Use IPython's run_cell for magic commands support
466
+ with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
467
+ exec_result = ipython.run_cell(cell["source"], store_history=True)
468
+
469
+ result["stdout"] = stdout_capture.getvalue()
470
+ result["stderr"] = stderr_capture.getvalue()
471
+
472
+ if exec_result.success:
473
+ if exec_result.result is not None:
474
+ result["result"] = repr(exec_result.result)
475
+ else:
476
+ if exec_result.error_in_exec:
477
+ result["error"] = str(exec_result.error_in_exec)
478
+ result["status"] = "error"
479
+ elif exec_result.error_before_exec:
480
+ result["error"] = str(exec_result.error_before_exec)
481
+ result["status"] = "error"
400
482
  else:
401
- if exec_result.error_in_exec:
402
- result["error"] = str(exec_result.error_in_exec)
403
- result["status"] = "error"
404
- elif exec_result.error_before_exec:
405
- result["error"] = str(exec_result.error_before_exec)
406
- result["status"] = "error"
407
- else:
408
- # Fallback to exec() if IPython is not available
409
- with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
410
- # Compile to check for expression vs statement
411
- code = cell["source"].strip()
412
-
413
- # Try to evaluate as expression first (to get return value)
414
- try:
415
- # Check if it's a simple expression
416
- compiled = compile(code, "<cell>", "eval")
417
- exec_result = eval(compiled, self._namespace)
418
- if exec_result is not None:
419
- result["result"] = repr(exec_result)
420
- except SyntaxError:
421
- # It's a statement, execute it
422
- exec(code, self._namespace)
423
-
424
- result["stdout"] = stdout_capture.getvalue()
425
- result["stderr"] = stderr_capture.getvalue()
426
-
483
+ # Fallback to exec() if IPython is not available
484
+ with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
485
+ # Compile to check for expression vs statement
486
+ code = cell["source"].strip()
487
+
488
+ # Try to evaluate as expression first (to get return value)
489
+ try:
490
+ # Check if it's a simple expression
491
+ compiled = compile(code, "<cell>", "eval")
492
+ exec_result = eval(compiled, self._namespace)
493
+ if exec_result is not None:
494
+ result["result"] = repr(exec_result)
495
+ except SyntaxError:
496
+ # It's a statement, execute it
497
+ exec(code, self._namespace)
498
+
499
+ result["stdout"] = stdout_capture.getvalue()
500
+ result["stderr"] = stderr_capture.getvalue()
501
+
502
+ except MemoryError:
503
+ result["error"] = f"MemoryError: Cell execution exceeded memory limit ({CELL_MEMORY_LIMIT_MB} MB). Try processing data in smaller chunks."
504
+ result["status"] = "error"
505
+ result["stdout"] = stdout_capture.getvalue()
506
+ result["stderr"] = stderr_capture.getvalue()
427
507
  except Exception:
428
508
  result["error"] = traceback.format_exc()
429
509
  result["status"] = "error"
@@ -732,35 +812,6 @@ def reset_notebook() -> Dict[str, Any]:
732
812
  return _notebook_state.reset()
733
813
 
734
814
 
735
- def get_notebook_canvas_items() -> List[Dict[str, Any]]:
736
- """
737
- Get all canvas items generated during notebook cell execution.
738
-
739
- When code in cells calls add_to_canvas(), the items are collected here.
740
- Use this to retrieve visualizations generated by executed code.
741
-
742
- Returns:
743
- List of canvas item dictionaries with type and data
744
-
745
- Examples:
746
- # After executing cells that created charts
747
- items = get_notebook_canvas_items()
748
- for item in items:
749
- print(f"Type: {item['type']}")
750
- """
751
- return _notebook_state.get_canvas_items()
752
-
753
-
754
- def clear_notebook_canvas_items() -> Dict[str, Any]:
755
- """
756
- Clear all canvas items collected from notebook execution.
757
-
758
- Returns:
759
- Dictionary with count of cleared items
760
- """
761
- return _notebook_state.clear_canvas_items()
762
-
763
-
764
815
  # =============================================================================
765
816
  # CANVAS TOOLS
766
817
  # =============================================================================
@@ -888,6 +939,549 @@ def remove_canvas_item(item_id: str) -> Dict[str, Any]:
888
939
  }
889
940
 
890
941
 
942
+ # =============================================================================
943
+ # DISPLAY INLINE TOOL
944
+ # =============================================================================
945
+
946
+ def display_inline(
947
+ content: Any,
948
+ title: Optional[str] = None,
949
+ display_type: Optional[str] = None
950
+ ) -> str:
951
+ """Display content to the user in a rich, interactive format inline in the chat.
952
+
953
+ This tool renders content directly in the conversation for immediate visibility.
954
+ Use this when you want the user to see results right away without navigating to
955
+ the canvas. Supports images, HTML, Plotly charts, CSV/DataFrames, and more.
956
+
957
+ **Note:** Returns a JSON string that will be parsed by the UI for rendering.
958
+ This is necessary because LangGraph converts non-string tool results to strings,
959
+ and this ensures proper JSON serialization instead of Python repr().
960
+
961
+ Args:
962
+ content: The content to display. Can be:
963
+ - str: File path (image, HTML, CSV, JSON) or raw content
964
+ - bytes: Binary image data (PNG, JPEG, etc.)
965
+ - pd.DataFrame: Rendered as an interactive table
966
+ - dict: If it looks like Plotly JSON, rendered as a chart
967
+ - matplotlib.figure.Figure: Rendered as an image
968
+ - plotly.graph_objects.Figure: Rendered as an interactive chart
969
+ title: Optional title displayed above the content
970
+ display_type: Optional hint for how to render the content. One of:
971
+ - "image": Force image rendering (PNG, JPEG, GIF, etc.)
972
+ - "html": Force HTML rendering with preview
973
+ - "plotly": Force Plotly chart rendering
974
+ - "csv" or "dataframe": Force table rendering
975
+ - "json": Force JSON rendering
976
+ - "text": Force plain text rendering
977
+ Auto-detected if not provided.
978
+
979
+ Returns:
980
+ Dictionary with display metadata:
981
+ - type: "display_inline" (identifies this as inline display)
982
+ - display_type: The detected/specified display type
983
+ - title: The title (if provided)
984
+ - data: The processed data for rendering
985
+ - status: "success" or "error"
986
+
987
+ Examples:
988
+ # Show an image file
989
+ display_inline("analysis_results.png", title="Results Chart")
990
+
991
+ # Show a DataFrame
992
+ import pandas as pd
993
+ df = pd.read_csv("data.csv")
994
+ display_inline(df, title="Sales Data")
995
+
996
+ # Show a Plotly figure
997
+ import plotly.express as px
998
+ fig = px.bar(df, x="category", y="value")
999
+ display_inline(fig, title="Category Distribution")
1000
+
1001
+ # Show HTML content
1002
+ display_inline("<h1>Hello</h1><p>World</p>", display_type="html")
1003
+
1004
+ # Show CSV file
1005
+ display_inline("report.csv", title="Monthly Report")
1006
+ """
1007
+ # Call the implementation to get the display data
1008
+ result_dict = _display_inline_impl(content, title, display_type)
1009
+
1010
+ # Add unique ID for tracking
1011
+ import uuid
1012
+ result_dict["_item_id"] = str(uuid.uuid4())[:8]
1013
+
1014
+ # Push to agent state for direct Dash rendering (bypasses LangGraph serialization)
1015
+ _push_display_inline_to_state(result_dict)
1016
+
1017
+ # Return a simple confirmation message (this goes through LangGraph)
1018
+ # The actual rich content is rendered directly by Dash via state["display_inline_items"]
1019
+ display_type_str = result_dict.get("display_type", "content")
1020
+ title_str = f" '{title}'" if title else ""
1021
+ if result_dict.get("status") == "error":
1022
+ return f"Error displaying{title_str}: {result_dict.get('error', 'Unknown error')}"
1023
+ return f"Displayed {display_type_str}{title_str} inline."
1024
+
1025
+
1026
+ def _display_inline_impl(
1027
+ content: Any,
1028
+ title: Optional[str] = None,
1029
+ display_type: Optional[str] = None
1030
+ ) -> Dict[str, Any]:
1031
+ """Internal implementation of display_inline that returns a dict."""
1032
+ import base64
1033
+ import io
1034
+ import json
1035
+ from pathlib import PurePath
1036
+
1037
+ result = {
1038
+ "type": "display_inline",
1039
+ "display_type": None,
1040
+ "title": title,
1041
+ "data": None,
1042
+ "preview": None, # For thumbnails
1043
+ "downloadable": False,
1044
+ "status": "success",
1045
+ "error": None
1046
+ }
1047
+
1048
+ try:
1049
+ # Get workspace root for file operations
1050
+ workspace_root = _get_workspace_root_for_context()
1051
+
1052
+ # Detect content type and process accordingly
1053
+ obj_type = type(content).__name__
1054
+ obj_module = type(content).__module__
1055
+
1056
+ # Handle file paths (strings that look like file paths)
1057
+ if isinstance(content, str) and not display_type:
1058
+ # Check if it's a file path
1059
+ if _is_file_path(content, workspace_root):
1060
+ return _process_file_for_display(content, workspace_root, title, display_type)
1061
+
1062
+ # If it looks like a file path but doesn't exist, try to find it
1063
+ from pathlib import PurePath
1064
+ ext = PurePath(content).suffix.lower()
1065
+ known_file_extensions = {
1066
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',
1067
+ '.html', '.htm', '.csv', '.tsv', '.json', '.pdf'
1068
+ }
1069
+ if ext in known_file_extensions and "\n" not in content and len(content) < 500:
1070
+ # Try to find the file - check if just filename was passed
1071
+ found_path = _find_file_in_workspace(content, workspace_root)
1072
+ if found_path:
1073
+ return _process_file_for_display(found_path, workspace_root, title, display_type)
1074
+ else:
1075
+ # Return error - file not found
1076
+ result["status"] = "error"
1077
+ result["display_type"] = "error"
1078
+ result["error"] = f"File not found: {content}"
1079
+ result["data"] = f"Could not find file '{content}' in workspace. Make sure the file exists and the path is correct."
1080
+ return result
1081
+
1082
+ # Otherwise, check for explicit display types or treat as text/HTML
1083
+ if content.strip().startswith("<") and ">" in content:
1084
+ result["display_type"] = "html"
1085
+ result["data"] = content
1086
+ result["preview"] = content[:500] + "..." if len(content) > 500 else content
1087
+ return result
1088
+
1089
+ # Handle explicit display_type for strings
1090
+ if isinstance(content, str) and display_type:
1091
+ if display_type == "html":
1092
+ result["display_type"] = "html"
1093
+ result["data"] = content
1094
+ result["preview"] = content[:500] + "..." if len(content) > 500 else content
1095
+ return result
1096
+ elif display_type == "text":
1097
+ result["display_type"] = "text"
1098
+ result["data"] = content
1099
+ return result
1100
+ elif display_type in ("csv", "dataframe"):
1101
+ # Parse CSV string
1102
+ try:
1103
+ import pandas as pd
1104
+ df = pd.read_csv(io.StringIO(content))
1105
+ return _process_dataframe_for_display(df, title)
1106
+ except Exception as e:
1107
+ result["display_type"] = "text"
1108
+ result["data"] = content
1109
+ result["error"] = f"Could not parse as CSV: {e}"
1110
+ return result
1111
+ elif display_type == "json":
1112
+ result["display_type"] = "json"
1113
+ try:
1114
+ result["data"] = json.loads(content) if isinstance(content, str) else content
1115
+ except json.JSONDecodeError:
1116
+ result["data"] = content
1117
+ return result
1118
+ elif display_type == "image":
1119
+ # Assume it's base64 or file path
1120
+ if _is_file_path(content, workspace_root):
1121
+ return _process_file_for_display(content, workspace_root, title, "image")
1122
+ result["display_type"] = "image"
1123
+ result["data"] = content # Assume base64
1124
+ return result
1125
+ elif display_type == "plotly":
1126
+ result["display_type"] = "plotly"
1127
+ if isinstance(content, str):
1128
+ result["data"] = json.loads(content)
1129
+ else:
1130
+ result["data"] = content
1131
+ return result
1132
+
1133
+ # Handle bytes (binary data - likely image)
1134
+ if isinstance(content, bytes):
1135
+ result["display_type"] = "image"
1136
+ result["data"] = base64.b64encode(content).decode('utf-8')
1137
+ return result
1138
+
1139
+ # Handle pandas DataFrame
1140
+ if obj_module.startswith('pandas') and obj_type == 'DataFrame':
1141
+ return _process_dataframe_for_display(content, title)
1142
+
1143
+ # Handle matplotlib Figure
1144
+ if obj_module.startswith('matplotlib') and 'Figure' in obj_type:
1145
+ buf = io.BytesIO()
1146
+ content.savefig(buf, format='png', bbox_inches='tight', dpi=100)
1147
+ buf.seek(0)
1148
+ img_data = buf.read()
1149
+ buf.close()
1150
+
1151
+ result["display_type"] = "image"
1152
+ result["data"] = base64.b64encode(img_data).decode('utf-8')
1153
+ result["mime_type"] = "image/png"
1154
+ return result
1155
+
1156
+ # Handle Plotly Figure
1157
+ if obj_module.startswith('plotly') and 'Figure' in obj_type:
1158
+ result["display_type"] = "plotly"
1159
+ result["data"] = json.loads(content.to_json())
1160
+ return result
1161
+
1162
+ # Handle dict (check for Plotly JSON structure or serialization artifacts)
1163
+ if isinstance(content, dict):
1164
+ # Check if this looks like a serialized matplotlib/plotly reference (common mistake)
1165
+ if content.get('type') in ('matplotlib', 'plotly') and 'figure' in content:
1166
+ result["status"] = "error"
1167
+ result["display_type"] = "error"
1168
+ result["error"] = (
1169
+ "Cannot display figure objects directly. The figure was serialized to a reference. "
1170
+ "To display matplotlib figures, save them to a file first:\n"
1171
+ " fig.savefig('chart.png')\n"
1172
+ " display_inline('chart.png')\n"
1173
+ "Or use add_to_canvas(fig) inside a notebook cell."
1174
+ )
1175
+ result["data"] = str(content)
1176
+ return result
1177
+ # Check for Plotly JSON structure
1178
+ if 'data' in content and isinstance(content.get('data'), list):
1179
+ result["display_type"] = "plotly"
1180
+ result["data"] = content
1181
+ return result
1182
+ else:
1183
+ result["display_type"] = "json"
1184
+ result["data"] = content
1185
+ return result
1186
+
1187
+ # Handle PIL Image
1188
+ if obj_module.startswith('PIL') and 'Image' in obj_type:
1189
+ buf = io.BytesIO()
1190
+ content.save(buf, format='PNG')
1191
+ buf.seek(0)
1192
+ img_data = buf.read()
1193
+ buf.close()
1194
+
1195
+ result["display_type"] = "image"
1196
+ result["data"] = base64.b64encode(img_data).decode('utf-8')
1197
+ result["mime_type"] = "image/png"
1198
+ return result
1199
+
1200
+ # Handle list (could be data for table)
1201
+ if isinstance(content, list) and len(content) > 0:
1202
+ if isinstance(content[0], dict):
1203
+ # List of dicts - render as table
1204
+ try:
1205
+ import pandas as pd
1206
+ df = pd.DataFrame(content)
1207
+ return _process_dataframe_for_display(df, title)
1208
+ except Exception:
1209
+ result["display_type"] = "json"
1210
+ result["data"] = content
1211
+ return result
1212
+ else:
1213
+ result["display_type"] = "json"
1214
+ result["data"] = content
1215
+ return result
1216
+
1217
+ # Default: convert to string
1218
+ result["display_type"] = "text"
1219
+ result["data"] = str(content)
1220
+ return result
1221
+
1222
+ except Exception as e:
1223
+ result["status"] = "error"
1224
+ result["error"] = str(e)
1225
+ result["display_type"] = "error"
1226
+ result["data"] = f"Error processing content: {e}"
1227
+ return result
1228
+
1229
+
1230
+ def _is_file_path(content: str, workspace_root: Any) -> bool:
1231
+ """Check if a string looks like a file path that exists."""
1232
+ if not content or len(content) > 500: # Too long to be a path
1233
+ return False
1234
+ if "\n" in content: # Contains newlines - not a path
1235
+ return False
1236
+
1237
+ # Check for common file extensions
1238
+ from pathlib import Path, PurePath
1239
+ ext = PurePath(content).suffix.lower()
1240
+ known_extensions = {
1241
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',
1242
+ '.html', '.htm',
1243
+ '.csv', '.tsv',
1244
+ '.json',
1245
+ '.pdf'
1246
+ }
1247
+
1248
+ if ext in known_extensions:
1249
+ from .file_utils import _get_path
1250
+ from .virtual_fs import VirtualFilesystem
1251
+
1252
+ try:
1253
+ # For absolute paths with physical filesystem, check the path directly
1254
+ # This handles cases where an absolute path is passed that may be outside workspace
1255
+ if content.startswith('/') and not isinstance(workspace_root, VirtualFilesystem):
1256
+ abs_path = Path(content)
1257
+ return abs_path.exists() and abs_path.is_file()
1258
+
1259
+ # For relative paths or VirtualFilesystem, use _get_path
1260
+ full_path = _get_path(workspace_root, content)
1261
+ return full_path.exists()
1262
+ except Exception:
1263
+ return False
1264
+
1265
+ return False
1266
+
1267
+
1268
+ def _find_file_in_workspace(filename: str, workspace_root: Any) -> Optional[str]:
1269
+ """Try to find a file in the workspace by searching common locations.
1270
+
1271
+ Args:
1272
+ filename: The filename or path to search for
1273
+ workspace_root: The workspace root (Path or VirtualFilesystem)
1274
+
1275
+ Returns:
1276
+ The relative path to the file if found, None otherwise
1277
+ """
1278
+ from pathlib import PurePath
1279
+ from .file_utils import _get_path
1280
+
1281
+ # Extract just the filename if a path was provided
1282
+ basename = PurePath(filename).name
1283
+
1284
+ # Locations to search (in order of priority)
1285
+ search_paths = [
1286
+ filename, # As provided
1287
+ basename, # Just the filename in root
1288
+ f".canvas/{basename}", # In canvas folder
1289
+ f"output/{basename}", # Common output folder
1290
+ f"outputs/{basename}",
1291
+ f"results/{basename}",
1292
+ ]
1293
+
1294
+ # Also try the exact path as-is first
1295
+ for path in search_paths:
1296
+ try:
1297
+ full_path = _get_path(workspace_root, path)
1298
+ if full_path.exists() and full_path.is_file():
1299
+ return path
1300
+ except Exception:
1301
+ continue
1302
+
1303
+ # Try recursive search for the filename (limited depth)
1304
+ try:
1305
+ from .virtual_fs import VirtualFilesystem
1306
+ if isinstance(workspace_root, VirtualFilesystem):
1307
+ # For VirtualFilesystem, check all files
1308
+ for file_path in workspace_root.glob("**/*"):
1309
+ if file_path.name == basename and file_path.is_file():
1310
+ # Return relative path
1311
+ return str(file_path).lstrip("/")
1312
+ else:
1313
+ # For physical filesystem
1314
+ import os
1315
+ for root, dirs, files in os.walk(workspace_root):
1316
+ # Limit depth to 3 levels
1317
+ rel_root = os.path.relpath(root, workspace_root)
1318
+ if rel_root.count(os.sep) > 3:
1319
+ continue
1320
+ # Skip hidden directories
1321
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
1322
+ if basename in files:
1323
+ return os.path.relpath(os.path.join(root, basename), workspace_root)
1324
+ except Exception:
1325
+ pass
1326
+
1327
+ return None
1328
+
1329
+
1330
+ def _process_file_for_display(
1331
+ file_path: str,
1332
+ workspace_root: Any,
1333
+ title: Optional[str],
1334
+ display_type: Optional[str]
1335
+ ) -> Dict[str, Any]:
1336
+ """Process a file path and return display data."""
1337
+ import base64
1338
+ import json
1339
+ from pathlib import Path, PurePath
1340
+ from .file_utils import _get_path, read_file_content, get_file_download_data
1341
+ from .virtual_fs import VirtualFilesystem
1342
+
1343
+ result = {
1344
+ "type": "display_inline",
1345
+ "display_type": None,
1346
+ "title": title,
1347
+ "data": None,
1348
+ "preview": None,
1349
+ "filename": PurePath(file_path).name,
1350
+ "file_path": file_path,
1351
+ "downloadable": True,
1352
+ "status": "success",
1353
+ "error": None
1354
+ }
1355
+
1356
+ ext = PurePath(file_path).suffix.lower()
1357
+
1358
+ # For absolute paths with physical filesystem, use the path directly
1359
+ is_absolute_physical = file_path.startswith('/') and not isinstance(workspace_root, VirtualFilesystem)
1360
+ if is_absolute_physical:
1361
+ full_path = Path(file_path)
1362
+ else:
1363
+ full_path = _get_path(workspace_root, file_path)
1364
+
1365
+ if not full_path.exists():
1366
+ result["status"] = "error"
1367
+ result["error"] = f"File not found: {file_path}"
1368
+ return result
1369
+
1370
+ # Image files
1371
+ image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'}
1372
+ if ext in image_exts or display_type == "image":
1373
+ b64, filename, mime = get_file_download_data(workspace_root, file_path)
1374
+ if b64:
1375
+ result["display_type"] = "image"
1376
+ result["data"] = b64
1377
+ result["mime_type"] = mime
1378
+ return result
1379
+
1380
+ # HTML files
1381
+ if ext in {'.html', '.htm'} or display_type == "html":
1382
+ content, is_text, error = read_file_content(workspace_root, file_path)
1383
+ if content:
1384
+ result["display_type"] = "html"
1385
+ result["data"] = content
1386
+ result["preview"] = content[:1000] + "..." if len(content) > 1000 else content
1387
+ return result
1388
+
1389
+ # CSV files
1390
+ if ext in {'.csv', '.tsv'} or display_type in ("csv", "dataframe"):
1391
+ content, is_text, error = read_file_content(workspace_root, file_path)
1392
+ if content:
1393
+ try:
1394
+ import pandas as pd
1395
+ import io
1396
+ sep = '\t' if ext == '.tsv' else ','
1397
+ df = pd.read_csv(io.StringIO(content), sep=sep)
1398
+ df_result = _process_dataframe_for_display(df, title)
1399
+ df_result["filename"] = result["filename"]
1400
+ df_result["file_path"] = file_path
1401
+ df_result["downloadable"] = True
1402
+ return df_result
1403
+ except Exception as e:
1404
+ result["display_type"] = "text"
1405
+ result["data"] = content
1406
+ result["error"] = f"Could not parse as CSV: {e}"
1407
+ return result
1408
+
1409
+ # JSON files (check for Plotly)
1410
+ if ext == '.json' or display_type == "json":
1411
+ content, is_text, error = read_file_content(workspace_root, file_path)
1412
+ if content:
1413
+ try:
1414
+ data = json.loads(content)
1415
+ # Check if it's Plotly JSON
1416
+ if isinstance(data, dict) and 'data' in data and isinstance(data.get('data'), list):
1417
+ result["display_type"] = "plotly"
1418
+ result["data"] = data
1419
+ else:
1420
+ result["display_type"] = "json"
1421
+ result["data"] = data
1422
+ return result
1423
+ except json.JSONDecodeError:
1424
+ result["display_type"] = "text"
1425
+ result["data"] = content
1426
+ return result
1427
+
1428
+ # PDF files
1429
+ if ext == '.pdf':
1430
+ b64, filename, mime = get_file_download_data(workspace_root, file_path)
1431
+ if b64:
1432
+ result["display_type"] = "pdf"
1433
+ result["data"] = b64
1434
+ result["mime_type"] = mime
1435
+ return result
1436
+
1437
+ # Default: try to read as text
1438
+ content, is_text, error = read_file_content(workspace_root, file_path)
1439
+ if content:
1440
+ result["display_type"] = "text"
1441
+ result["data"] = content
1442
+ return result
1443
+
1444
+ result["status"] = "error"
1445
+ result["error"] = error or "Could not read file"
1446
+ return result
1447
+
1448
+
1449
+ def _process_dataframe_for_display(df: Any, title: Optional[str]) -> Dict[str, Any]:
1450
+ """Process a pandas DataFrame for display."""
1451
+ result = {
1452
+ "type": "display_inline",
1453
+ "display_type": "dataframe",
1454
+ "title": title,
1455
+ "data": None,
1456
+ "preview": None,
1457
+ "downloadable": True,
1458
+ "status": "success",
1459
+ "error": None
1460
+ }
1461
+
1462
+ # Create preview (first 10 rows)
1463
+ preview_df = df.head(10)
1464
+ result["preview"] = {
1465
+ "html": preview_df.to_html(index=False, classes="dataframe-table"),
1466
+ "rows_shown": len(preview_df),
1467
+ "total_rows": len(df),
1468
+ "columns": list(df.columns)
1469
+ }
1470
+
1471
+ # Full data
1472
+ result["data"] = {
1473
+ "html": df.to_html(index=False, classes="dataframe-table"),
1474
+ "records": df.to_dict('records'),
1475
+ "columns": list(df.columns),
1476
+ "shape": list(df.shape)
1477
+ }
1478
+
1479
+ # CSV for download
1480
+ result["csv"] = df.to_csv(index=False)
1481
+
1482
+ return result
1483
+
1484
+
891
1485
  # =============================================================================
892
1486
  # BASH TOOL
893
1487
  # =============================================================================
@@ -898,7 +1492,8 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
898
1492
  Runs the command in the workspace directory. Use this for file operations,
899
1493
  git commands, installing packages, or any shell operations.
900
1494
 
901
- Note: This tool is disabled in virtual filesystem mode for security reasons.
1495
+ In virtual filesystem mode (Linux only), commands run in a bubblewrap sandbox
1496
+ with network disabled for security.
902
1497
 
903
1498
  Args:
904
1499
  command: The bash command to execute
@@ -924,15 +1519,9 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
924
1519
  # Run a script
925
1520
  bash("python script.py")
926
1521
  """
927
- # Disable bash in virtual filesystem mode for security
1522
+ # In virtual filesystem mode, use sandboxed execution
928
1523
  if VIRTUAL_FS:
929
- return {
930
- "stdout": "",
931
- "stderr": "Bash commands are disabled in virtual filesystem mode for security reasons. "
932
- "Use the built-in file tools (read_file, write_file, list_directory) instead.",
933
- "return_code": 1,
934
- "status": "error"
935
- }
1524
+ return _bash_sandboxed(command, timeout)
936
1525
 
937
1526
  try:
938
1527
  result = subprocess.run(
@@ -965,3 +1554,69 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
965
1554
  "return_code": -1,
966
1555
  "status": "error"
967
1556
  }
1557
+
1558
+
1559
+ def _bash_sandboxed(command: str, timeout: int = 60) -> Dict[str, Any]:
1560
+ """Execute bash command in sandboxed environment for virtual FS mode.
1561
+
1562
+ Uses bubblewrap (Linux) for sandboxing with:
1563
+ - No network access
1564
+ - Isolated PID namespace
1565
+ - Read-only system directories
1566
+ - Writable workspace directory synced with VirtualFilesystem
1567
+ """
1568
+ from .sandbox import get_executor_for_session, get_available_sandbox
1569
+ from .virtual_fs import get_session_manager
1570
+
1571
+ # Get current session context
1572
+ session_id = get_tool_session_context()
1573
+ if not session_id:
1574
+ return {
1575
+ "stdout": "",
1576
+ "stderr": "No session context available for sandboxed execution",
1577
+ "return_code": 1,
1578
+ "status": "error"
1579
+ }
1580
+
1581
+ # Check if sandbox is available
1582
+ sandbox = get_available_sandbox()
1583
+ if sandbox is None:
1584
+ return {
1585
+ "stdout": "",
1586
+ "stderr": "Bash commands require bubblewrap (bwrap) or Docker in virtual filesystem mode. "
1587
+ "Install bubblewrap: apt-get install bubblewrap",
1588
+ "return_code": 1,
1589
+ "status": "error"
1590
+ }
1591
+
1592
+ # Get the virtual filesystem for this session
1593
+ fs = get_session_manager().get_filesystem(session_id)
1594
+ if fs is None:
1595
+ return {
1596
+ "stdout": "",
1597
+ "stderr": "Session filesystem not found",
1598
+ "return_code": 1,
1599
+ "status": "error"
1600
+ }
1601
+
1602
+ # Get or create executor for this session
1603
+ executor = get_executor_for_session(session_id, fs)
1604
+
1605
+ # Execute command in sandbox
1606
+ return executor.execute(command, timeout=timeout)
1607
+
1608
+
1609
+ # Add a think tool
1610
+ def think_tool(reflection: str) -> str:
1611
+ """A tool to reflect on your actions and reasoning.
1612
+
1613
+ This tool allows you to pause and think about your next steps,
1614
+ evaluate your current state, or reconsider your approach. Use
1615
+ this tool to generate internal reflections that the user can see.
1616
+
1617
+ Args:
1618
+ reflection: The reflection text
1619
+ Returns:
1620
+ str: The recorded reflection
1621
+ """
1622
+ return reflection