cowork-dash 0.1.9__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
@@ -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"))
@@ -785,35 +812,6 @@ def reset_notebook() -> Dict[str, Any]:
785
812
  return _notebook_state.reset()
786
813
 
787
814
 
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
815
  # =============================================================================
818
816
  # CANVAS TOOLS
819
817
  # =============================================================================
@@ -941,6 +939,549 @@ def remove_canvas_item(item_id: str) -> Dict[str, Any]:
941
939
  }
942
940
 
943
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
+
944
1485
  # =============================================================================
945
1486
  # BASH TOOL
946
1487
  # =============================================================================
@@ -951,7 +1492,8 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
951
1492
  Runs the command in the workspace directory. Use this for file operations,
952
1493
  git commands, installing packages, or any shell operations.
953
1494
 
954
- 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.
955
1497
 
956
1498
  Args:
957
1499
  command: The bash command to execute
@@ -977,15 +1519,9 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
977
1519
  # Run a script
978
1520
  bash("python script.py")
979
1521
  """
980
- # Disable bash in virtual filesystem mode for security
1522
+ # In virtual filesystem mode, use sandboxed execution
981
1523
  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
- }
1524
+ return _bash_sandboxed(command, timeout)
989
1525
 
990
1526
  try:
991
1527
  result = subprocess.run(
@@ -1018,3 +1554,69 @@ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
1018
1554
  "return_code": -1,
1019
1555
  "status": "error"
1020
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