cowork-dash 0.1.9__py3-none-any.whl → 0.2.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.
cowork_dash/tools.py CHANGED
@@ -12,6 +12,33 @@ from .config import WORKSPACE_ROOT, VIRTUAL_FS
12
12
  from .canvas import parse_canvas_object, generate_canvas_id
13
13
 
14
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
+
15
42
  # Memory limit for cell execution (in bytes)
16
43
  # Default: 512 MB - can be overridden via environment variable
17
44
  CELL_MEMORY_LIMIT_MB = int(os.environ.get("COWORK_CELL_MEMORY_LIMIT_MB", "512"))
@@ -203,37 +230,6 @@ except (ImportError, AttributeError):
203
230
  if VIRTUAL_FS:
204
231
  self._inject_virtual_fs_helpers()
205
232
 
206
- # Inject add_to_canvas function that captures items
207
- def _add_to_canvas_wrapper(content: Any) -> Dict[str, Any]:
208
- """Add content to the canvas for visualization.
209
-
210
- Supports: DataFrames, matplotlib figures, plotly figures,
211
- PIL images, and markdown strings.
212
- """
213
- try:
214
- # Use session's VirtualFilesystem in virtual FS mode, otherwise physical path
215
- if VIRTUAL_FS and self._session_id:
216
- from .virtual_fs import get_session_manager
217
- workspace_root = get_session_manager().get_filesystem(self._session_id)
218
- if workspace_root is None:
219
- raise RuntimeError(f"Session {self._session_id} not found")
220
- else:
221
- workspace_root = WORKSPACE_ROOT
222
-
223
- parsed = parse_canvas_object(content, workspace_root=workspace_root)
224
- self._canvas_items.append(parsed)
225
- return parsed
226
- except Exception as e:
227
- error_result = {
228
- "type": "error",
229
- "data": f"Failed to add to canvas: {str(e)}",
230
- "error": str(e)
231
- }
232
- self._canvas_items.append(error_result)
233
- return error_result
234
-
235
- self._namespace["add_to_canvas"] = _add_to_canvas_wrapper
236
-
237
233
  def _inject_virtual_fs_helpers(self):
238
234
  """Inject virtual filesystem helper functions into the namespace."""
239
235
  from .virtual_fs import get_session_manager
@@ -785,35 +781,6 @@ def reset_notebook() -> Dict[str, Any]:
785
781
  return _notebook_state.reset()
786
782
 
787
783
 
788
- def get_notebook_canvas_items() -> List[Dict[str, Any]]:
789
- """
790
- Get all canvas items generated during notebook cell execution.
791
-
792
- When code in cells calls add_to_canvas(), the items are collected here.
793
- Use this to retrieve visualizations generated by executed code.
794
-
795
- Returns:
796
- List of canvas item dictionaries with type and data
797
-
798
- Examples:
799
- # After executing cells that created charts
800
- items = get_notebook_canvas_items()
801
- for item in items:
802
- print(f"Type: {item['type']}")
803
- """
804
- return _notebook_state.get_canvas_items()
805
-
806
-
807
- def clear_notebook_canvas_items() -> Dict[str, Any]:
808
- """
809
- Clear all canvas items collected from notebook execution.
810
-
811
- Returns:
812
- Dictionary with count of cleared items
813
- """
814
- return _notebook_state.clear_canvas_items()
815
-
816
-
817
784
  # =============================================================================
818
785
  # CANVAS TOOLS
819
786
  # =============================================================================
@@ -941,6 +908,565 @@ def remove_canvas_item(item_id: str) -> Dict[str, Any]:
941
908
  }
942
909
 
943
910
 
911
+ # =============================================================================
912
+ # DISPLAY INLINE TOOL
913
+ # =============================================================================
914
+
915
+ def display_inline(
916
+ content: Any,
917
+ title: Optional[str] = None,
918
+ display_type: Optional[str] = None
919
+ ) -> str:
920
+ """Display content to the user in a rich, interactive format inline in the chat.
921
+
922
+ This tool renders content directly in the conversation for immediate visibility.
923
+ Use this when you want the user to see results right away without navigating to
924
+ the canvas. Supports images, HTML, Plotly charts, CSV/DataFrames, and more.
925
+
926
+ **Note:** Returns a JSON string that will be parsed by the UI for rendering.
927
+ This is necessary because LangGraph converts non-string tool results to strings,
928
+ and this ensures proper JSON serialization instead of Python repr().
929
+
930
+ Args:
931
+ content: The content to display. Can be:
932
+ - str: File path (image, HTML, CSV, JSON) or raw content
933
+ - bytes: Binary image data (PNG, JPEG, etc.)
934
+ - pd.DataFrame: Rendered as an interactive table
935
+ - dict: If it looks like Plotly JSON, rendered as a chart
936
+ - matplotlib.figure.Figure: Rendered as an image
937
+ - plotly.graph_objects.Figure: Rendered as an interactive chart
938
+ title: Optional title displayed above the content
939
+ display_type: Optional hint for how to render the content. One of:
940
+ - "image": Force image rendering (PNG, JPEG, GIF, etc.)
941
+ - "html": Force HTML rendering with preview
942
+ - "plotly": Force Plotly chart rendering
943
+ - "csv" or "dataframe": Force table rendering
944
+ - "json": Force JSON rendering
945
+ - "text": Force plain text rendering
946
+ Auto-detected if not provided.
947
+
948
+ Returns:
949
+ Dictionary with display metadata:
950
+ - type: "display_inline" (identifies this as inline display)
951
+ - display_type: The detected/specified display type
952
+ - title: The title (if provided)
953
+ - data: The processed data for rendering
954
+ - status: "success" or "error"
955
+
956
+ Examples:
957
+ # Show an image file
958
+ display_inline("analysis_results.png", title="Results Chart")
959
+
960
+ # Show a DataFrame
961
+ import pandas as pd
962
+ df = pd.read_csv("data.csv")
963
+ display_inline(df, title="Sales Data")
964
+
965
+ # Show a Plotly figure
966
+ import plotly.express as px
967
+ fig = px.bar(df, x="category", y="value")
968
+ display_inline(fig, title="Category Distribution")
969
+
970
+ # Show HTML content
971
+ display_inline("<h1>Hello</h1><p>World</p>", display_type="html")
972
+
973
+ # Show CSV file
974
+ display_inline("report.csv", title="Monthly Report")
975
+ """
976
+ # Call the implementation to get the display data
977
+ result_dict = _display_inline_impl(content, title, display_type)
978
+
979
+ # Add unique ID for tracking
980
+ import uuid
981
+ result_dict["_item_id"] = str(uuid.uuid4())[:8]
982
+
983
+ # Push to agent state for direct Dash rendering (bypasses LangGraph serialization)
984
+ _push_display_inline_to_state(result_dict)
985
+
986
+ # Return a simple confirmation message (this goes through LangGraph)
987
+ # The actual rich content is rendered directly by Dash via state["display_inline_items"]
988
+ display_type_str = result_dict.get("display_type", "content")
989
+ title_str = f" '{title}'" if title else ""
990
+ if result_dict.get("status") == "error":
991
+ return f"Error displaying{title_str}: {result_dict.get('error', 'Unknown error')}"
992
+ return f"Displayed {display_type_str}{title_str} inline."
993
+
994
+
995
+ def _display_inline_impl(
996
+ content: Any,
997
+ title: Optional[str] = None,
998
+ display_type: Optional[str] = None
999
+ ) -> Dict[str, Any]:
1000
+ """Internal implementation of display_inline that returns a dict."""
1001
+ import base64
1002
+ import io
1003
+ import json
1004
+ from pathlib import PurePath
1005
+
1006
+ result = {
1007
+ "type": "display_inline",
1008
+ "display_type": None,
1009
+ "title": title,
1010
+ "data": None,
1011
+ "preview": None, # For thumbnails
1012
+ "downloadable": False,
1013
+ "status": "success",
1014
+ "error": None
1015
+ }
1016
+
1017
+ try:
1018
+ # Get workspace root for file operations
1019
+ workspace_root = _get_workspace_root_for_context()
1020
+
1021
+ # Detect content type and process accordingly
1022
+ obj_type = type(content).__name__
1023
+ obj_module = type(content).__module__
1024
+
1025
+ # Handle file paths (strings that look like file paths)
1026
+ if isinstance(content, str) and not display_type:
1027
+ # Check if it's a file path
1028
+ if _is_file_path(content, workspace_root):
1029
+ return _process_file_for_display(content, workspace_root, title, display_type)
1030
+
1031
+ # If it looks like a file path but doesn't exist, try to find it
1032
+ from pathlib import PurePath
1033
+ ext = PurePath(content).suffix.lower()
1034
+ known_file_extensions = {
1035
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',
1036
+ '.html', '.htm', '.csv', '.tsv', '.json', '.pdf'
1037
+ }
1038
+ if ext in known_file_extensions and "\n" not in content and len(content) < 500:
1039
+ # Try to find the file - check if just filename was passed
1040
+ found_path = _find_file_in_workspace(content, workspace_root)
1041
+ if found_path:
1042
+ return _process_file_for_display(found_path, workspace_root, title, display_type)
1043
+ else:
1044
+ # Return error - file not found
1045
+ result["status"] = "error"
1046
+ result["display_type"] = "error"
1047
+ result["error"] = f"File not found: {content}"
1048
+ result["data"] = f"Could not find file '{content}' in workspace. Make sure the file exists and the path is correct."
1049
+ return result
1050
+
1051
+ # Otherwise, check for explicit display types or treat as text/HTML
1052
+ if content.strip().startswith("<") and ">" in content:
1053
+ result["display_type"] = "html"
1054
+ result["data"] = content
1055
+ result["preview"] = content[:500] + "..." if len(content) > 500 else content
1056
+ return result
1057
+
1058
+ # Handle explicit display_type for strings
1059
+ if isinstance(content, str) and display_type:
1060
+ if display_type == "html":
1061
+ # Check if it's a file path first
1062
+ if _is_file_path(content, workspace_root):
1063
+ return _process_file_for_display(content, workspace_root, title, "html")
1064
+ result["display_type"] = "html"
1065
+ result["data"] = content
1066
+ result["preview"] = content[:500] + "..." if len(content) > 500 else content
1067
+ return result
1068
+ elif display_type == "text":
1069
+ result["display_type"] = "text"
1070
+ result["data"] = content
1071
+ return result
1072
+ elif display_type in ("csv", "dataframe"):
1073
+ # Check if it's a file path first
1074
+ if _is_file_path(content, workspace_root):
1075
+ return _process_file_for_display(content, workspace_root, title, "csv")
1076
+ # Parse CSV string
1077
+ try:
1078
+ import pandas as pd
1079
+ df = pd.read_csv(io.StringIO(content))
1080
+ return _process_dataframe_for_display(df, title)
1081
+ except Exception as e:
1082
+ result["display_type"] = "text"
1083
+ result["data"] = content
1084
+ result["error"] = f"Could not parse as CSV: {e}"
1085
+ return result
1086
+ elif display_type == "json":
1087
+ # Check if it's a file path first
1088
+ if _is_file_path(content, workspace_root):
1089
+ return _process_file_for_display(content, workspace_root, title, "json")
1090
+ result["display_type"] = "json"
1091
+ try:
1092
+ result["data"] = json.loads(content) if isinstance(content, str) else content
1093
+ except json.JSONDecodeError:
1094
+ result["data"] = content
1095
+ return result
1096
+ elif display_type == "image":
1097
+ # Assume it's base64 or file path
1098
+ if _is_file_path(content, workspace_root):
1099
+ return _process_file_for_display(content, workspace_root, title, "image")
1100
+ result["display_type"] = "image"
1101
+ result["data"] = content # Assume base64
1102
+ return result
1103
+ elif display_type == "plotly":
1104
+ # Check if it's a file path first
1105
+ if _is_file_path(content, workspace_root):
1106
+ return _process_file_for_display(content, workspace_root, title, "plotly")
1107
+ result["display_type"] = "plotly"
1108
+ if isinstance(content, str):
1109
+ result["data"] = json.loads(content)
1110
+ else:
1111
+ result["data"] = content
1112
+ return result
1113
+
1114
+ # Handle bytes (binary data - likely image)
1115
+ if isinstance(content, bytes):
1116
+ result["display_type"] = "image"
1117
+ result["data"] = base64.b64encode(content).decode('utf-8')
1118
+ return result
1119
+
1120
+ # Handle pandas DataFrame
1121
+ if obj_module.startswith('pandas') and obj_type == 'DataFrame':
1122
+ return _process_dataframe_for_display(content, title)
1123
+
1124
+ # Handle matplotlib Figure
1125
+ if obj_module.startswith('matplotlib') and 'Figure' in obj_type:
1126
+ buf = io.BytesIO()
1127
+ content.savefig(buf, format='png', bbox_inches='tight', dpi=100)
1128
+ buf.seek(0)
1129
+ img_data = buf.read()
1130
+ buf.close()
1131
+
1132
+ result["display_type"] = "image"
1133
+ result["data"] = base64.b64encode(img_data).decode('utf-8')
1134
+ result["mime_type"] = "image/png"
1135
+ return result
1136
+
1137
+ # Handle Plotly Figure
1138
+ if obj_module.startswith('plotly') and 'Figure' in obj_type:
1139
+ result["display_type"] = "plotly"
1140
+ result["data"] = json.loads(content.to_json())
1141
+ return result
1142
+
1143
+ # Handle dict (check for Plotly JSON structure or serialization artifacts)
1144
+ if isinstance(content, dict):
1145
+ # Check if this looks like a serialized matplotlib/plotly reference (common mistake)
1146
+ if content.get('type') in ('matplotlib', 'plotly') and 'figure' in content:
1147
+ result["status"] = "error"
1148
+ result["display_type"] = "error"
1149
+ result["error"] = (
1150
+ "Cannot display figure objects directly. The figure was serialized to a reference. "
1151
+ "To display matplotlib figures, save them to a file first:\n"
1152
+ " fig.savefig('chart.png')\n"
1153
+ " display_inline('chart.png')\n"
1154
+ "Or use add_to_canvas(fig) inside a notebook cell."
1155
+ )
1156
+ result["data"] = str(content)
1157
+ return result
1158
+ # Check for Plotly JSON structure
1159
+ if 'data' in content and isinstance(content.get('data'), list):
1160
+ result["display_type"] = "plotly"
1161
+ result["data"] = content
1162
+ return result
1163
+ else:
1164
+ result["display_type"] = "json"
1165
+ result["data"] = content
1166
+ return result
1167
+
1168
+ # Handle PIL Image
1169
+ if obj_module.startswith('PIL') and 'Image' in obj_type:
1170
+ buf = io.BytesIO()
1171
+ content.save(buf, format='PNG')
1172
+ buf.seek(0)
1173
+ img_data = buf.read()
1174
+ buf.close()
1175
+
1176
+ result["display_type"] = "image"
1177
+ result["data"] = base64.b64encode(img_data).decode('utf-8')
1178
+ result["mime_type"] = "image/png"
1179
+ return result
1180
+
1181
+ # Handle list (could be data for table)
1182
+ if isinstance(content, list) and len(content) > 0:
1183
+ if isinstance(content[0], dict):
1184
+ # List of dicts - render as table
1185
+ try:
1186
+ import pandas as pd
1187
+ df = pd.DataFrame(content)
1188
+ return _process_dataframe_for_display(df, title)
1189
+ except Exception:
1190
+ result["display_type"] = "json"
1191
+ result["data"] = content
1192
+ return result
1193
+ else:
1194
+ result["display_type"] = "json"
1195
+ result["data"] = content
1196
+ return result
1197
+
1198
+ # Default: convert to string
1199
+ result["display_type"] = "text"
1200
+ result["data"] = str(content)
1201
+ return result
1202
+
1203
+ except Exception as e:
1204
+ result["status"] = "error"
1205
+ result["error"] = str(e)
1206
+ result["display_type"] = "error"
1207
+ result["data"] = f"Error processing content: {e}"
1208
+ return result
1209
+
1210
+
1211
+ def _is_file_path(content: str, workspace_root: Any) -> bool:
1212
+ """Check if a string looks like a file path that exists."""
1213
+ if not content or len(content) > 500: # Too long to be a path
1214
+ return False
1215
+ if "\n" in content: # Contains newlines - not a path
1216
+ return False
1217
+
1218
+ # Check for common file extensions
1219
+ from pathlib import Path, PurePath
1220
+ ext = PurePath(content).suffix.lower()
1221
+ known_extensions = {
1222
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',
1223
+ '.html', '.htm',
1224
+ '.csv', '.tsv',
1225
+ '.json',
1226
+ '.pdf'
1227
+ }
1228
+
1229
+ if ext in known_extensions:
1230
+ from .file_utils import _get_path
1231
+ from .virtual_fs import VirtualFilesystem
1232
+
1233
+ try:
1234
+ # For absolute paths with physical filesystem, check the path directly
1235
+ # This handles cases where an absolute path is passed that may be outside workspace
1236
+ if content.startswith('/') and not isinstance(workspace_root, VirtualFilesystem):
1237
+ abs_path = Path(content)
1238
+ return abs_path.exists() and abs_path.is_file()
1239
+
1240
+ # For relative paths or VirtualFilesystem, use _get_path
1241
+ full_path = _get_path(workspace_root, content)
1242
+ return full_path.exists()
1243
+ except Exception:
1244
+ return False
1245
+
1246
+ return False
1247
+
1248
+
1249
+ def _find_file_in_workspace(filename: str, workspace_root: Any) -> Optional[str]:
1250
+ """Try to find a file in the workspace by searching common locations.
1251
+
1252
+ Args:
1253
+ filename: The filename or path to search for
1254
+ workspace_root: The workspace root (Path or VirtualFilesystem)
1255
+
1256
+ Returns:
1257
+ The relative path to the file if found, None otherwise
1258
+ """
1259
+ from pathlib import PurePath
1260
+ from .file_utils import _get_path
1261
+
1262
+ # Extract just the filename if a path was provided
1263
+ basename = PurePath(filename).name
1264
+
1265
+ # Locations to search (in order of priority)
1266
+ search_paths = [
1267
+ filename, # As provided
1268
+ basename, # Just the filename in root
1269
+ f".canvas/{basename}", # In canvas folder
1270
+ f"output/{basename}", # Common output folder
1271
+ f"outputs/{basename}",
1272
+ f"results/{basename}",
1273
+ ]
1274
+
1275
+ # Also try the exact path as-is first
1276
+ for path in search_paths:
1277
+ try:
1278
+ full_path = _get_path(workspace_root, path)
1279
+ if full_path.exists() and full_path.is_file():
1280
+ return path
1281
+ except Exception:
1282
+ continue
1283
+
1284
+ # Try recursive search for the filename (limited depth)
1285
+ try:
1286
+ from .virtual_fs import VirtualFilesystem
1287
+ if isinstance(workspace_root, VirtualFilesystem):
1288
+ # For VirtualFilesystem, check all files
1289
+ for file_path in workspace_root.glob("**/*"):
1290
+ if file_path.name == basename and file_path.is_file():
1291
+ # Return relative path
1292
+ return str(file_path).lstrip("/")
1293
+ else:
1294
+ # For physical filesystem
1295
+ import os
1296
+ for root, dirs, files in os.walk(workspace_root):
1297
+ # Limit depth to 3 levels
1298
+ rel_root = os.path.relpath(root, workspace_root)
1299
+ if rel_root.count(os.sep) > 3:
1300
+ continue
1301
+ # Skip hidden directories
1302
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
1303
+ if basename in files:
1304
+ return os.path.relpath(os.path.join(root, basename), workspace_root)
1305
+ except Exception:
1306
+ pass
1307
+
1308
+ return None
1309
+
1310
+
1311
+ def _process_file_for_display(
1312
+ file_path: str,
1313
+ workspace_root: Any,
1314
+ title: Optional[str],
1315
+ display_type: Optional[str]
1316
+ ) -> Dict[str, Any]:
1317
+ """Process a file path and return display data."""
1318
+ import base64
1319
+ import json
1320
+ from pathlib import Path, PurePath
1321
+ from .file_utils import _get_path, read_file_content, get_file_download_data
1322
+ from .virtual_fs import VirtualFilesystem
1323
+
1324
+ result = {
1325
+ "type": "display_inline",
1326
+ "display_type": None,
1327
+ "title": title,
1328
+ "data": None,
1329
+ "preview": None,
1330
+ "filename": PurePath(file_path).name,
1331
+ "file_path": file_path,
1332
+ "downloadable": True,
1333
+ "status": "success",
1334
+ "error": None
1335
+ }
1336
+
1337
+ ext = PurePath(file_path).suffix.lower()
1338
+
1339
+ # For absolute paths with physical filesystem, use the path directly
1340
+ is_absolute_physical = file_path.startswith('/') and not isinstance(workspace_root, VirtualFilesystem)
1341
+ if is_absolute_physical:
1342
+ full_path = Path(file_path)
1343
+ else:
1344
+ full_path = _get_path(workspace_root, file_path)
1345
+
1346
+ if not full_path.exists():
1347
+ result["status"] = "error"
1348
+ result["error"] = f"File not found: {file_path}"
1349
+ return result
1350
+
1351
+ # Image files
1352
+ image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'}
1353
+ if ext in image_exts or display_type == "image":
1354
+ b64, filename, mime = get_file_download_data(workspace_root, file_path)
1355
+ if b64:
1356
+ result["display_type"] = "image"
1357
+ result["data"] = b64
1358
+ result["mime_type"] = mime
1359
+ return result
1360
+
1361
+ # HTML files
1362
+ if ext in {'.html', '.htm'} or display_type == "html":
1363
+ content, is_text, error = read_file_content(workspace_root, file_path)
1364
+ if content:
1365
+ result["display_type"] = "html"
1366
+ result["data"] = content
1367
+ result["preview"] = content[:1000] + "..." if len(content) > 1000 else content
1368
+ return result
1369
+
1370
+ # CSV files
1371
+ if ext in {'.csv', '.tsv'} or display_type in ("csv", "dataframe"):
1372
+ content, is_text, error = read_file_content(workspace_root, file_path)
1373
+ if content:
1374
+ try:
1375
+ import pandas as pd
1376
+ import io
1377
+ sep = '\t' if ext == '.tsv' else ','
1378
+ df = pd.read_csv(io.StringIO(content), sep=sep)
1379
+ df_result = _process_dataframe_for_display(df, title)
1380
+ df_result["filename"] = result["filename"]
1381
+ df_result["file_path"] = file_path
1382
+ df_result["downloadable"] = True
1383
+ return df_result
1384
+ except Exception as e:
1385
+ result["display_type"] = "text"
1386
+ result["data"] = content
1387
+ result["error"] = f"Could not parse as CSV: {e}"
1388
+ return result
1389
+
1390
+ # JSON files (check for Plotly) or explicit plotly display_type
1391
+ if ext == '.json' or display_type in ("json", "plotly"):
1392
+ content, is_text, error = read_file_content(workspace_root, file_path)
1393
+ if content:
1394
+ try:
1395
+ data = json.loads(content)
1396
+ # Force plotly if display_type is explicitly set, otherwise auto-detect
1397
+ if display_type == "plotly":
1398
+ result["display_type"] = "plotly"
1399
+ result["data"] = data
1400
+ # Check if it's Plotly JSON (auto-detect)
1401
+ elif isinstance(data, dict) and 'data' in data and isinstance(data.get('data'), list):
1402
+ result["display_type"] = "plotly"
1403
+ result["data"] = data
1404
+ else:
1405
+ result["display_type"] = "json"
1406
+ result["data"] = data
1407
+ return result
1408
+ except json.JSONDecodeError:
1409
+ result["display_type"] = "text"
1410
+ result["data"] = content
1411
+ return result
1412
+
1413
+ # PDF files
1414
+ if ext == '.pdf':
1415
+ b64, filename, mime = get_file_download_data(workspace_root, file_path)
1416
+ if b64:
1417
+ result["display_type"] = "pdf"
1418
+ result["data"] = b64
1419
+ result["mime_type"] = mime
1420
+ return result
1421
+
1422
+ # Default: try to read as text
1423
+ content, is_text, error = read_file_content(workspace_root, file_path)
1424
+ if content:
1425
+ result["display_type"] = "text"
1426
+ result["data"] = content
1427
+ return result
1428
+
1429
+ result["status"] = "error"
1430
+ result["error"] = error or "Could not read file"
1431
+ return result
1432
+
1433
+
1434
+ def _process_dataframe_for_display(df: Any, title: Optional[str]) -> Dict[str, Any]:
1435
+ """Process a pandas DataFrame for display."""
1436
+ result = {
1437
+ "type": "display_inline",
1438
+ "display_type": "dataframe",
1439
+ "title": title,
1440
+ "data": None,
1441
+ "preview": None,
1442
+ "downloadable": True,
1443
+ "status": "success",
1444
+ "error": None
1445
+ }
1446
+
1447
+ # Create preview (first 10 rows)
1448
+ preview_df = df.head(10)
1449
+ result["preview"] = {
1450
+ "html": preview_df.to_html(index=False, classes="dataframe-table"),
1451
+ "rows_shown": len(preview_df),
1452
+ "total_rows": len(df),
1453
+ "columns": list(df.columns)
1454
+ }
1455
+
1456
+ # Full data
1457
+ result["data"] = {
1458
+ "html": df.to_html(index=False, classes="dataframe-table"),
1459
+ "records": df.to_dict('records'),
1460
+ "columns": list(df.columns),
1461
+ "shape": list(df.shape)
1462
+ }
1463
+
1464
+ # CSV for download
1465
+ result["csv"] = df.to_csv(index=False)
1466
+
1467
+ return result
1468
+
1469
+
944
1470
  # =============================================================================
945
1471
  # BASH TOOL
946
1472
  # =============================================================================
@@ -951,7 +1477,8 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
951
1477
  Runs the command in the workspace directory. Use this for file operations,
952
1478
  git commands, installing packages, or any shell operations.
953
1479
 
954
- Note: This tool is disabled in virtual filesystem mode for security reasons.
1480
+ In virtual filesystem mode (Linux only), commands run in a bubblewrap sandbox
1481
+ with network disabled for security.
955
1482
 
956
1483
  Args:
957
1484
  command: The bash command to execute
@@ -977,15 +1504,9 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
977
1504
  # Run a script
978
1505
  bash("python script.py")
979
1506
  """
980
- # Disable bash in virtual filesystem mode for security
1507
+ # In virtual filesystem mode, use sandboxed execution
981
1508
  if VIRTUAL_FS:
982
- return {
983
- "stdout": "",
984
- "stderr": "Bash commands are disabled in virtual filesystem mode for security reasons. "
985
- "Use the built-in file tools (read_file, write_file, list_directory) instead.",
986
- "return_code": 1,
987
- "status": "error"
988
- }
1509
+ return _bash_sandboxed(command, timeout)
989
1510
 
990
1511
  try:
991
1512
  result = subprocess.run(
@@ -1018,3 +1539,69 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
1018
1539
  "return_code": -1,
1019
1540
  "status": "error"
1020
1541
  }
1542
+
1543
+
1544
+ def _bash_sandboxed(command: str, timeout: int = 60) -> Dict[str, Any]:
1545
+ """Execute bash command in sandboxed environment for virtual FS mode.
1546
+
1547
+ Uses bubblewrap (Linux) for sandboxing with:
1548
+ - No network access
1549
+ - Isolated PID namespace
1550
+ - Read-only system directories
1551
+ - Writable workspace directory synced with VirtualFilesystem
1552
+ """
1553
+ from .sandbox import get_executor_for_session, get_available_sandbox
1554
+ from .virtual_fs import get_session_manager
1555
+
1556
+ # Get current session context
1557
+ session_id = get_tool_session_context()
1558
+ if not session_id:
1559
+ return {
1560
+ "stdout": "",
1561
+ "stderr": "No session context available for sandboxed execution",
1562
+ "return_code": 1,
1563
+ "status": "error"
1564
+ }
1565
+
1566
+ # Check if sandbox is available
1567
+ sandbox = get_available_sandbox()
1568
+ if sandbox is None:
1569
+ return {
1570
+ "stdout": "",
1571
+ "stderr": "Bash commands require bubblewrap (bwrap) or Docker in virtual filesystem mode. "
1572
+ "Install bubblewrap: apt-get install bubblewrap",
1573
+ "return_code": 1,
1574
+ "status": "error"
1575
+ }
1576
+
1577
+ # Get the virtual filesystem for this session
1578
+ fs = get_session_manager().get_filesystem(session_id)
1579
+ if fs is None:
1580
+ return {
1581
+ "stdout": "",
1582
+ "stderr": "Session filesystem not found",
1583
+ "return_code": 1,
1584
+ "status": "error"
1585
+ }
1586
+
1587
+ # Get or create executor for this session
1588
+ executor = get_executor_for_session(session_id, fs)
1589
+
1590
+ # Execute command in sandbox
1591
+ return executor.execute(command, timeout=timeout)
1592
+
1593
+
1594
+ # Add a think tool
1595
+ def think_tool(reflection: str) -> str:
1596
+ """A tool to reflect on your actions and reasoning.
1597
+
1598
+ This tool allows you to pause and think about your next steps,
1599
+ evaluate your current state, or reconsider your approach. Use
1600
+ this tool to generate internal reflections that the user can see.
1601
+
1602
+ Args:
1603
+ reflection: The reflection text
1604
+ Returns:
1605
+ str: The recorded reflection
1606
+ """
1607
+ return reflection