deepanalysts 0.9.2__tar.gz → 0.9.4__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.
Files changed (70) hide show
  1. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/PKG-INFO +1 -1
  2. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/e2b.py +129 -33
  3. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/filesystem.py +43 -15
  4. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/PKG-INFO +1 -1
  5. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/SOURCES.txt +2 -0
  6. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/pyproject.toml +1 -1
  7. deepanalysts-0.9.4/tests/test_e2b_batch_upload.py +268 -0
  8. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_integration.py +65 -0
  9. deepanalysts-0.9.4/tests/test_regression_media_tool_results.py +65 -0
  10. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/README.md +0 -0
  11. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/__init__.py +0 -0
  12. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/_messages_reducer.py +0 -0
  13. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/__init__.py +0 -0
  14. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/basement.py +0 -0
  15. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/composite.py +0 -0
  16. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/filesystem.py +0 -0
  17. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/protocol.py +0 -0
  18. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/sandbox.py +0 -0
  19. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/state.py +0 -0
  20. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/store.py +0 -0
  21. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/supabase_storage.py +0 -0
  22. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/utils.py +0 -0
  23. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/clients/__init__.py +0 -0
  24. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/clients/basement.py +0 -0
  25. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/__init__.py +0 -0
  26. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/_state.py +0 -0
  27. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/_utils.py +0 -0
  28. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/memory.py +0 -0
  29. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/patch_tool_calls.py +0 -0
  30. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/skills.py +0 -0
  31. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/subagents.py +0 -0
  32. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/summarization.py +0 -0
  33. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/tool_errors.py +0 -0
  34. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/state.py +0 -0
  35. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/streaming.py +0 -0
  36. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/utils/__init__.py +0 -0
  37. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/utils/retry.py +0 -0
  38. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/dependency_links.txt +0 -0
  39. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/requires.txt +0 -0
  40. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/top_level.txt +0 -0
  41. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/setup.cfg +0 -0
  42. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_basement.py +0 -0
  43. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_composite_backend.py +0 -0
  44. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_backend.py +0 -0
  45. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_watch.py +0 -0
  46. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_watch_live.py +0 -0
  47. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_filesystem_middleware.py +0 -0
  48. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_messages_reducer.py +0 -0
  49. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_prompt_sections.py +0 -0
  50. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_protocol_bridge.py +0 -0
  51. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_files_delta.py +0 -0
  52. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_fs_backport.py +0 -0
  53. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_middleware_tools.py +0 -0
  54. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_new_api.py +0 -0
  55. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_prompt_contracts.py +0 -0
  56. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_storage_stale_reads.py +0 -0
  57. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_subagents.py +0 -0
  58. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_tier1_backports.py +0 -0
  59. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_backend.py +0 -0
  60. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_hardening.py +0 -0
  61. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_hardening_integration.py +0 -0
  62. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_scan_bounds.py +0 -0
  63. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_skills_middleware.py +0 -0
  64. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_store_backend.py +0 -0
  65. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_streaming_transformer.py +0 -0
  66. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_summarization_middleware.py +0 -0
  67. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_supabase_storage_backend.py +0 -0
  68. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_supabase_storage_integration.py +0 -0
  69. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_tool_errors.py +0 -0
  70. {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import inspect
27
27
  import logging
28
+ import threading
28
29
  import time
29
30
  from collections.abc import Callable, Sequence
30
31
  from dataclasses import dataclass
@@ -71,6 +72,15 @@ Verified against e2b 2.31.0 (`e2b/envd/rpc.py`, `e2b/exceptions.py`)."""
71
72
  _DEFAULT_WORKDIR = "/workspace"
72
73
  """Where the workspace bridge mounts agent files inside the sandbox."""
73
74
 
75
+ _UPLOAD_BATCH_MAX_FILES = 100
76
+ """Files per `files.write_files` multipart request in `upload_files`."""
77
+
78
+ _UPLOAD_BATCH_MAX_BYTES = 8 * 1024 * 1024
79
+ """Payload ceiling per `files.write_files` request. The SDK buffers a
80
+ multipart body in memory and applies one request timeout to it, so a chunk
81
+ stays small enough to finish well inside that timeout. A single file larger
82
+ than this still goes alone in its own request."""
83
+
74
84
 
75
85
  _EGRESS_FLAG = "allow_internet_access"
76
86
 
@@ -114,6 +124,23 @@ def _ctor_accepts_egress_flag(ctor: Callable[..., Any]) -> bool:
114
124
  return False
115
125
 
116
126
 
127
+ def _upload_chunks(files: Sequence[tuple[str, bytes]]) -> list[list[tuple[str, bytes]]]:
128
+ """Split uploads into request-sized chunks (by count and by bytes), in order."""
129
+ chunks: list[list[tuple[str, bytes]]] = []
130
+ current: list[tuple[str, bytes]] = []
131
+ current_bytes = 0
132
+ for path, content in files:
133
+ size = len(content)
134
+ if current and (len(current) >= _UPLOAD_BATCH_MAX_FILES or current_bytes + size > _UPLOAD_BATCH_MAX_BYTES):
135
+ chunks.append(current)
136
+ current, current_bytes = [], 0
137
+ current.append((path, content))
138
+ current_bytes += size
139
+ if current:
140
+ chunks.append(current)
141
+ return chunks
142
+
143
+
117
144
  def _classify_file_error(error: Exception) -> FileOperationError:
118
145
  """Map an E2B SDK exception to a structured per-file error code.
119
146
 
@@ -225,6 +252,12 @@ class E2BSandboxBackend(BaseSandbox):
225
252
  self._sandbox_class = sandbox_class
226
253
  self._closed = False
227
254
  self._expired = False
255
+ # Serializes lazy creation: two threads hitting an unstarted session
256
+ # (a host's background upload + a tool call) must not boot two VMs.
257
+ self._create_lock = threading.Lock()
258
+ # Makes "publish the new VM" and close() atomic with respect to each
259
+ # other, so a boot that finishes after close() is killed, not leaked.
260
+ self._state_lock = threading.Lock()
228
261
  # glob/grep scans are clamped to these trees (BaseSandbox scan safety);
229
262
  # a full-VM walk from "/" would run until the execute timeout kills it.
230
263
  # Default: the workdir. Pass additional read-only trees (e.g. baked
@@ -388,28 +421,31 @@ class E2BSandboxBackend(BaseSandbox):
388
421
  if self._expired:
389
422
  raise E2BSandboxError(self._expired_output())
390
423
  if self._sandbox is None:
391
- self._sandbox = self._create_sandbox()
392
- self._ttl_refreshed_at = time.monotonic()
393
- # Materialize the workdir so cwd exists from command one. Root-
394
- # level dirs like /workspace need root on the base template
395
- # (commands run as `user` — live-verified: plain mkdir gets
396
- # "Permission denied" and every later cwd= call then fails);
397
- # chown hands the dir back to the command user.
424
+ self._boot_sandbox()
425
+ sandbox = self._sandbox
426
+ if sandbox is None: # closed by another thread right after the boot
427
+ raise E2BSandboxError("This E2B sandbox session is closed.")
428
+ self._maybe_refresh_ttl()
429
+ return sandbox
430
+
431
+ def _bootstrap_workdir(self, sandbox: Any) -> None:
432
+ """Materialize the workdir so cwd exists from command one. Root-level
433
+ dirs like /workspace need root on the base template (commands run as
434
+ `user` — live-verified: plain mkdir gets "Permission denied" and every
435
+ later cwd= call then fails); chown hands the dir back to the user."""
436
+ try:
437
+ sandbox.commands.run(
438
+ f"mkdir -p {self._workdir} && chown user:user {self._workdir}",
439
+ user="root",
440
+ )
441
+ except TypeError:
442
+ # SDK without the `user` kwarg — best effort as default user.
398
443
  try:
399
- self._sandbox.commands.run(
400
- f"mkdir -p {self._workdir} && chown user:user {self._workdir}",
401
- user="root",
402
- )
403
- except TypeError:
404
- # SDK without the `user` kwarg — best effort as default user.
405
- try:
406
- self._sandbox.commands.run(f"mkdir -p {self._workdir}")
407
- except Exception:
408
- logger.warning("e2b workdir bootstrap failed", exc_info=True)
444
+ sandbox.commands.run(f"mkdir -p {self._workdir}")
409
445
  except Exception:
410
446
  logger.warning("e2b workdir bootstrap failed", exc_info=True)
411
- self._maybe_refresh_ttl()
412
- return self._sandbox
447
+ except Exception:
448
+ logger.warning("e2b workdir bootstrap failed", exc_info=True)
413
449
 
414
450
  @property
415
451
  def started(self) -> bool:
@@ -417,19 +453,42 @@ class E2BSandboxBackend(BaseSandbox):
417
453
  return self._sandbox is not None and not self._closed
418
454
 
419
455
  def close(self) -> None:
420
- """Kill the microVM. Idempotent; safe when never started."""
421
- if self._sandbox is not None and not self._closed:
422
- try:
423
- self._sandbox.kill()
424
- logger.info(
425
- "e2b sandbox killed id=%s",
426
- getattr(self._sandbox, "sandbox_id", "?"),
427
- )
428
- except Exception:
429
- # the session_ttl reaps the VM server-side regardless.
430
- logger.warning("e2b sandbox kill failed", exc_info=True)
431
- self._closed = True
432
- self._sandbox = None
456
+ """Kill the microVM. Idempotent; safe when never started, and safe
457
+ while another thread is still booting it (that boot kills its own VM
458
+ when it sees the session closed)."""
459
+ with self._state_lock:
460
+ sandbox, self._sandbox = self._sandbox, None
461
+ self._closed = True
462
+ if sandbox is not None:
463
+ self._kill(sandbox)
464
+
465
+ @staticmethod
466
+ def _kill(sandbox: Any) -> None:
467
+ try:
468
+ sandbox.kill()
469
+ logger.info("e2b sandbox killed id=%s", getattr(sandbox, "sandbox_id", "?"))
470
+ except Exception:
471
+ # the session_ttl reaps the VM server-side regardless.
472
+ logger.warning("e2b sandbox kill failed", exc_info=True)
473
+
474
+ def _boot_sandbox(self) -> None:
475
+ """Create the session's VM once, even under concurrent first use."""
476
+ with self._create_lock:
477
+ if self._sandbox is not None:
478
+ return # another thread booted it while we waited
479
+ if self._closed:
480
+ raise E2BSandboxError("This E2B sandbox session is closed.")
481
+ sandbox = self._create_sandbox()
482
+ if not self._closed:
483
+ self._bootstrap_workdir(sandbox)
484
+ with self._state_lock:
485
+ closed_meanwhile = self._closed
486
+ if not closed_meanwhile:
487
+ self._sandbox = sandbox
488
+ self._ttl_refreshed_at = time.monotonic()
489
+ if closed_meanwhile:
490
+ self._kill(sandbox)
491
+ raise E2BSandboxError("This E2B sandbox session is closed.")
433
492
 
434
493
  # ------------------------------------------------------------------
435
494
  # SandboxBackendProtocol
@@ -507,7 +566,18 @@ class E2BSandboxBackend(BaseSandbox):
507
566
  return _classify_file_error(exc)
508
567
 
509
568
  def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
510
- """Write files into the sandbox filesystem (workspace bridge down-sync)."""
569
+ """Write files into the sandbox filesystem (workspace bridge down-sync).
570
+
571
+ Files go up in chunks through the SDK's multipart `files.write_files`
572
+ (one HTTP request per chunk, not per file). Chunks are sent one after
573
+ another, never concurrently: concurrent writes against one live
574
+ microVM failed per file in prod (2026-09-15). `write_files` is
575
+ all-or-nothing, so a chunk that fails is retried file by file to keep
576
+ the per-file partial-success contract, unless the sandbox itself is
577
+ gone, which latches and fails the rest fast.
578
+ """
579
+ if not files:
580
+ return []
511
581
  try:
512
582
  sbx = self._ensure_sandbox()
513
583
  except E2BSandboxError:
@@ -515,6 +585,32 @@ class E2BSandboxBackend(BaseSandbox):
515
585
  code: FileOperationError = "sandbox_unavailable" if self._expired else "permission_denied"
516
586
  return [FileUploadResponse(path=p, error=code) for p, _ in files]
517
587
 
588
+ write_files = getattr(sbx.files, "write_files", None)
589
+ if not callable(write_files):
590
+ return self._upload_one_by_one(sbx, files)
591
+
592
+ responses: list[FileUploadResponse] = []
593
+ for chunk in _upload_chunks(files):
594
+ if self._expired:
595
+ responses.extend(FileUploadResponse(path=p, error="sandbox_unavailable") for p, _ in chunk)
596
+ continue
597
+ try:
598
+ write_files([{"path": path, "data": content} for path, content in chunk])
599
+ responses.extend(FileUploadResponse(path=p) for p, _ in chunk)
600
+ except Exception as exc:
601
+ if self._is_sandbox_gone_error(exc) and self._confirm_gone(exc):
602
+ self._mark_expired()
603
+ responses.extend(FileUploadResponse(path=p, error="sandbox_unavailable") for p, _ in chunk)
604
+ continue
605
+ logger.warning(
606
+ "e2b batched upload of %d file(s) failed; retrying one by one: %s",
607
+ len(chunk),
608
+ exc,
609
+ )
610
+ responses.extend(self._upload_one_by_one(sbx, chunk))
611
+ return responses
612
+
613
+ def _upload_one_by_one(self, sbx: Any, files: Sequence[tuple[str, bytes]]) -> list[FileUploadResponse]:
518
614
  responses: list[FileUploadResponse] = []
519
615
  for path, content in files:
520
616
  if self._expired:
@@ -520,6 +520,13 @@ def _supports_execution(backend: BackendProtocol) -> bool:
520
520
  return isinstance(backend, SandboxBackendProtocol) or hasattr(backend, "execute")
521
521
 
522
522
 
523
+ _MEDIA_BLOCK_TYPES = frozenset({"image", "image_url", "audio", "video", "file", "input_image"})
524
+
525
+
526
+ def _is_media_block(block: object) -> bool:
527
+ return isinstance(block, dict) and block.get("type") in _MEDIA_BLOCK_TYPES
528
+
529
+
523
530
  TOO_LARGE_TOOL_MSG = """Tool result too large. The full result of this tool call {tool_call_id} was saved to the filesystem at: {file_path}
524
531
  Read it back with `read_file`, one slice at a time, using `offset` and `limit`
525
532
  (for example offset=0, limit=100 for the first 100 lines). Do not read the whole
@@ -1157,18 +1164,33 @@ class FilesystemMiddleware(AgentMiddleware):
1157
1164
  return await handler(request)
1158
1165
 
1159
1166
  def _extract_text_content(self, content: object) -> str:
1160
- """Extract text from message content, handling multimodal content."""
1167
+ """Extract text from message content, handling multimodal content.
1168
+
1169
+ Media blocks (image/audio/video/file) never count as text: a chart tool
1170
+ returning ``[image, text]`` used to be stringified whole, so its base64
1171
+ crossed the eviction limit and the model never saw the image.
1172
+ """
1161
1173
  if isinstance(content, str):
1162
1174
  return content
1163
- if isinstance(content, list) and len(content) == 1:
1164
- item = content[0]
1165
- if isinstance(item, str):
1166
- return item
1167
- if isinstance(item, dict) and item.get("type") == "text":
1168
- return item.get("text", "")
1175
+ if isinstance(content, list):
1176
+ parts: list[str] = []
1177
+ for item in content:
1178
+ if isinstance(item, str):
1179
+ parts.append(item)
1180
+ elif isinstance(item, dict) and item.get("type") == "text":
1181
+ parts.append(item.get("text", ""))
1182
+ elif not _is_media_block(item):
1183
+ parts.append(str(item))
1184
+ return "\n".join(parts)
1169
1185
  # Fallback: stringify complex content
1170
1186
  return str(content)
1171
1187
 
1188
+ @staticmethod
1189
+ def _evicted_content(message: ToolMessage, notice: str) -> str | list:
1190
+ """Replacement content: the eviction notice, plus any media blocks kept as-is."""
1191
+ media = [b for b in message.content if _is_media_block(b)] if isinstance(message.content, list) else []
1192
+ return [*media, {"type": "text", "text": notice}] if media else notice
1193
+
1172
1194
  def _process_large_message(
1173
1195
  self,
1174
1196
  message: ToolMessage,
@@ -1186,10 +1208,13 @@ class FilesystemMiddleware(AgentMiddleware):
1186
1208
  # Use smart preview showing head and tail instead of just first lines
1187
1209
  content_sample = create_content_preview(content)
1188
1210
  processed_message = ToolMessage(
1189
- TOO_LARGE_TOOL_MSG.format(
1190
- tool_call_id=message.tool_call_id,
1191
- file_path=file_path,
1192
- content_sample=content_sample,
1211
+ self._evicted_content(
1212
+ message,
1213
+ TOO_LARGE_TOOL_MSG.format(
1214
+ tool_call_id=message.tool_call_id,
1215
+ file_path=file_path,
1216
+ content_sample=content_sample,
1217
+ ),
1193
1218
  ),
1194
1219
  tool_call_id=message.tool_call_id,
1195
1220
  )
@@ -1273,10 +1298,13 @@ class FilesystemMiddleware(AgentMiddleware):
1273
1298
  # Use smart preview showing head and tail instead of just first lines
1274
1299
  content_sample = create_content_preview(content)
1275
1300
  processed_message = ToolMessage(
1276
- TOO_LARGE_TOOL_MSG.format(
1277
- tool_call_id=message.tool_call_id,
1278
- file_path=file_path,
1279
- content_sample=content_sample,
1301
+ self._evicted_content(
1302
+ message,
1303
+ TOO_LARGE_TOOL_MSG.format(
1304
+ tool_call_id=message.tool_call_id,
1305
+ file_path=file_path,
1306
+ content_sample=content_sample,
1307
+ ),
1280
1308
  ),
1281
1309
  tool_call_id=message.tool_call_id,
1282
1310
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -37,6 +37,7 @@ deepanalysts/utils/retry.py
37
37
  tests/test_basement.py
38
38
  tests/test_composite_backend.py
39
39
  tests/test_e2b_backend.py
40
+ tests/test_e2b_batch_upload.py
40
41
  tests/test_e2b_integration.py
41
42
  tests/test_e2b_watch.py
42
43
  tests/test_e2b_watch_live.py
@@ -46,6 +47,7 @@ tests/test_prompt_sections.py
46
47
  tests/test_protocol_bridge.py
47
48
  tests/test_regression_files_delta.py
48
49
  tests/test_regression_fs_backport.py
50
+ tests/test_regression_media_tool_results.py
49
51
  tests/test_regression_middleware_tools.py
50
52
  tests/test_regression_new_api.py
51
53
  tests/test_regression_prompt_contracts.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepanalysts"
3
- version = "0.9.2"
3
+ version = "0.9.4"
4
4
  description = "LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,268 @@
1
+ """Regression tests: E2BSandboxBackend.upload_files batches its writes.
2
+
3
+ Prod 2026-10-09: an agent run's workspace checkout uploaded 548 files with
4
+ one `sbx.files.write` HTTP request EACH, sequentially (~0.31 s per file), and
5
+ the run sat ~170 s before its first model call. The SDK's
6
+ `files.write_files([...])` sends many files in one multipart request; these
7
+ tests pin that upload_files uses it in bounded chunks while keeping the
8
+ per-file partial-success contract callers rely on.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from deepanalysts.backends.e2b import (
14
+ _UPLOAD_BATCH_MAX_BYTES,
15
+ _UPLOAD_BATCH_MAX_FILES,
16
+ E2BSandboxBackend,
17
+ )
18
+
19
+
20
+ class TimeoutException(Exception):
21
+ """Name-matched like e2b's TimeoutException."""
22
+
23
+
24
+ _DEAD_SANDBOX_MSG = (
25
+ "sandbox is probably not running anymore: This error is likely due to sandbox "
26
+ "timeout. You can modify the sandbox timeout by passing 'timeout' when starting "
27
+ "the sandbox or calling '.set_timeout' on the sandbox with the desired timeout."
28
+ )
29
+
30
+
31
+ class _Commands:
32
+ def run(self, command, timeout=None, cwd=None, user=None):
33
+ class _R:
34
+ stdout, stderr, exit_code = "", "", 0
35
+
36
+ return _R()
37
+
38
+
39
+ class BatchingFakeSandbox:
40
+ """SDK-shaped fake exposing BOTH `files.write` and `files.write_files`.
41
+
42
+ Like the real SDK, `write_files` is all-or-nothing: if any entry would
43
+ fail, the whole request raises and nothing from that request lands.
44
+ Every call is recorded so tests can count HTTP requests.
45
+ """
46
+
47
+ def __init__(self):
48
+ self.sandbox_id = "fake-batch"
49
+ self.store: dict[str, bytes] = {}
50
+ self.write_errors: dict[str, Exception] = {}
51
+ self.batch_calls: list[list[str]] = []
52
+ self.single_calls: list[str] = []
53
+ self.alive = True
54
+ self.commands = _Commands()
55
+ self.files = self._Files(self)
56
+
57
+ class _Files:
58
+ def __init__(self, outer):
59
+ self._outer = outer
60
+
61
+ def write(self, path, data):
62
+ self._outer.single_calls.append(path)
63
+ if path in self._outer.write_errors:
64
+ raise self._outer.write_errors[path]
65
+ self._outer.store[path] = data
66
+
67
+ def write_files(self, files):
68
+ paths = [entry["path"] for entry in files]
69
+ self._outer.batch_calls.append(paths)
70
+ for path in paths:
71
+ if path in self._outer.write_errors:
72
+ raise self._outer.write_errors[path]
73
+ for entry in files:
74
+ self._outer.store[entry["path"]] = entry["data"]
75
+ return [{"path": p} for p in paths]
76
+
77
+ def read(self, path, format="text"):
78
+ if path not in self._outer.store:
79
+ raise FileNotFoundError(f"file not found: {path}")
80
+ return self._outer.store[path]
81
+
82
+ def is_running(self, request_timeout=None):
83
+ return self.alive
84
+
85
+ def kill(self):
86
+ pass
87
+
88
+
89
+ def _backend() -> tuple[E2BSandboxBackend, BatchingFakeSandbox]:
90
+ fake = BatchingFakeSandbox()
91
+ return E2BSandboxBackend(sandbox=fake), fake
92
+
93
+
94
+ def _files(n: int, size: int = 10) -> list[tuple[str, bytes]]:
95
+ return [(f"/workspace/shared/f{i:04d}.md", bytes([65 + i % 26]) * size) for i in range(n)]
96
+
97
+
98
+ class TestBatchedUpload:
99
+ def test_many_files_use_few_requests_not_one_per_file(self):
100
+ """The prod repro: 548 files must not cost 548 sequential requests."""
101
+ backend, fake = _backend()
102
+ files = _files(548)
103
+
104
+ responses = backend.upload_files(files)
105
+
106
+ assert [r.error for r in responses] == [None] * 548
107
+ assert fake.single_calls == []
108
+ expected_requests = -(-548 // _UPLOAD_BATCH_MAX_FILES) # ceil
109
+ assert len(fake.batch_calls) == expected_requests
110
+ assert all(fake.store[p] == c for p, c in files)
111
+
112
+ def test_chunks_are_bounded_by_bytes(self):
113
+ backend, fake = _backend()
114
+ big = _UPLOAD_BATCH_MAX_BYTES // 2 + 1 # two of these never share a request
115
+ files = _files(3, size=big)
116
+
117
+ responses = backend.upload_files(files)
118
+
119
+ assert [r.error for r in responses] == [None, None, None]
120
+ assert [len(call) for call in fake.batch_calls] == [1, 1, 1]
121
+
122
+ def test_single_oversized_file_still_uploads_alone(self):
123
+ backend, fake = _backend()
124
+ files = _files(1, size=_UPLOAD_BATCH_MAX_BYTES + 5)
125
+
126
+ responses = backend.upload_files(files)
127
+
128
+ assert responses[0].error is None
129
+ assert fake.batch_calls == [[files[0][0]]]
130
+
131
+ def test_responses_keep_input_order(self):
132
+ backend, _fake = _backend()
133
+ files = _files(_UPLOAD_BATCH_MAX_FILES + 7)
134
+
135
+ responses = backend.upload_files(files)
136
+
137
+ assert [r.path for r in responses] == [p for p, _ in files]
138
+
139
+ def test_empty_upload_makes_no_request(self):
140
+ backend, fake = _backend()
141
+ assert backend.upload_files([]) == []
142
+ assert fake.batch_calls == [] and fake.single_calls == []
143
+
144
+
145
+ class TestBatchFailureKeepsPerFileContract:
146
+ def test_one_bad_path_fails_only_that_file(self):
147
+ """write_files is all-or-nothing; a failed chunk is retried per file
148
+ so one EACCES path doesn't fail its 99 neighbours."""
149
+ backend, fake = _backend()
150
+ files = _files(5)
151
+ bad = files[2][0]
152
+ fake.write_errors[bad] = PermissionError("permission denied")
153
+
154
+ responses = backend.upload_files(files)
155
+
156
+ assert [r.error for r in responses] == [None, None, "permission_denied", None, None]
157
+ assert bad not in fake.store
158
+ assert all(fake.store[p] == c for p, c in files if p != bad)
159
+
160
+ def test_failure_in_one_chunk_does_not_degrade_other_chunks(self):
161
+ backend, fake = _backend()
162
+ files = _files(_UPLOAD_BATCH_MAX_FILES * 2)
163
+ bad = files[_UPLOAD_BATCH_MAX_FILES + 3][0] # second chunk
164
+ fake.write_errors[bad] = PermissionError("permission denied")
165
+
166
+ backend.upload_files(files)
167
+
168
+ # chunk 1 and chunk 2 batched; only chunk 2 falls back per file
169
+ assert len(fake.batch_calls) == 2
170
+ assert len(fake.single_calls) == _UPLOAD_BATCH_MAX_FILES
171
+ assert set(fake.single_calls) == {p for p, _ in files[_UPLOAD_BATCH_MAX_FILES:]}
172
+
173
+ def test_dead_sandbox_latches_without_per_file_retries(self):
174
+ backend, fake = _backend()
175
+ fake.alive = False
176
+ files = _files(_UPLOAD_BATCH_MAX_FILES + 3)
177
+ fake.write_errors[files[0][0]] = TimeoutException(_DEAD_SANDBOX_MSG)
178
+
179
+ responses = backend.upload_files(files)
180
+
181
+ assert {r.error for r in responses} == {"sandbox_unavailable"}
182
+ assert backend.expired is True
183
+ assert len(fake.batch_calls) == 1 # later chunks short-circuited
184
+ assert fake.single_calls == [] # no 100 doomed per-file retries
185
+
186
+
187
+ class TestSdkWithoutBatchWrite:
188
+ def test_falls_back_to_per_file_writes(self):
189
+ """Older SDKs / fakes exposing only files.write keep working."""
190
+ backend, fake = _backend()
191
+
192
+ class _OnlyWrite:
193
+ def write(self, path, data):
194
+ fake.single_calls.append(path)
195
+ fake.store[path] = data
196
+
197
+ fake.files = _OnlyWrite()
198
+ files = _files(3)
199
+
200
+ responses = backend.upload_files(files)
201
+
202
+ assert [r.error for r in responses] == [None, None, None]
203
+ assert all(fake.store[p] == c for p, c in files)
204
+
205
+
206
+ class TestCloseDuringBoot:
207
+ """Regression (code review 2026-10-09): park now boots the VM in a
208
+ background preparation thread. If the run ends while that boot is still
209
+ in flight, close() runs first and finds no sandbox to kill; the boot then
210
+ completes and the microVM ran unowned until its TTL reaped it."""
211
+
212
+ def test_vm_finishing_boot_after_close_is_killed(self):
213
+ booted: list[BatchingFakeSandbox] = []
214
+ backend_ref: list[E2BSandboxBackend] = []
215
+
216
+ def factory():
217
+ vm = BatchingFakeSandbox()
218
+ vm.killed = False
219
+ vm.kill = lambda: setattr(vm, "killed", True)
220
+ booted.append(vm)
221
+ backend_ref[0].close() # the run ended while the VM was booting
222
+ return vm
223
+
224
+ backend = E2BSandboxBackend(sandbox_factory=factory)
225
+ backend_ref.append(backend)
226
+
227
+ responses = backend.upload_files([("/workspace/a.md", b"x")])
228
+
229
+ assert len(booted) == 1
230
+ assert booted[0].killed is True
231
+ assert responses[0].error is not None
232
+ assert backend.started is False
233
+
234
+ def test_concurrent_first_use_boots_one_vm(self):
235
+ import threading
236
+ import time
237
+
238
+ booted: list[BatchingFakeSandbox] = []
239
+
240
+ def factory():
241
+ time.sleep(0.05) # a boot takes a while; let the other thread arrive
242
+ vm = BatchingFakeSandbox()
243
+ booted.append(vm)
244
+ return vm
245
+
246
+ backend = E2BSandboxBackend(sandbox_factory=factory)
247
+ barrier = threading.Barrier(4)
248
+
249
+ def use():
250
+ barrier.wait()
251
+ backend.upload_files([(f"/workspace/{threading.get_ident()}.md", b"x")])
252
+
253
+ threads = [threading.Thread(target=use) for _ in range(4)]
254
+ for t in threads:
255
+ t.start()
256
+ for t in threads:
257
+ t.join()
258
+
259
+ assert len(booted) == 1
260
+
261
+ def test_close_after_boot_kills_once(self):
262
+ backend, fake = _backend()
263
+ kills: list[bool] = []
264
+ fake.kill = lambda: kills.append(True)
265
+ backend.upload_files([("/workspace/a.md", b"x")])
266
+ backend.close()
267
+ backend.close()
268
+ assert kills == [True]
@@ -59,6 +59,43 @@ def test_files_roundtrip(backend):
59
59
  assert missing[0].error == "file_not_found"
60
60
 
61
61
 
62
+ def test_batched_upload_many_nested_files(backend):
63
+ """Regression (prod 2026-10-09): a workspace checkout of hundreds of files
64
+ goes up in a few multipart requests and every byte lands, nested dirs
65
+ included (write_files creates parents)."""
66
+ import time
67
+
68
+ from deepanalysts.backends.e2b import _UPLOAD_BATCH_MAX_FILES
69
+
70
+ files = [(f"/workspace/batch/d{i % 7}/sub/f{i:03d}.md", f"file {i}\n".encode()) for i in range(250)]
71
+ started = time.monotonic()
72
+ up = backend.upload_files(files)
73
+ elapsed = time.monotonic() - started
74
+
75
+ assert [r.error for r in up] == [None] * len(files)
76
+ down = backend.download_files([p for p, _ in files[:: _UPLOAD_BATCH_MAX_FILES // 2]])
77
+ assert [d.content for d in down] == [c for _, c in files[:: _UPLOAD_BATCH_MAX_FILES // 2]]
78
+ count = backend.execute("find /workspace/batch -type f | wc -l")
79
+ assert count.output.strip() == "250"
80
+ # One request per file took ~0.3 s each in prod (75 s here); batched is a few requests.
81
+ assert elapsed < 20, f"250-file upload took {elapsed:.1f}s — batching regressed?"
82
+
83
+
84
+ def test_batched_upload_failure_isolated_to_bad_path(backend):
85
+ """write_files is all-or-nothing; one root-owned path must fail alone."""
86
+ files = [
87
+ ("/workspace/iso/ok1.md", b"one"),
88
+ ("/proc/forbidden/x.md", b"nope"),
89
+ ("/workspace/iso/ok2.md", b"two"),
90
+ ]
91
+ up = backend.upload_files(files)
92
+ assert up[0].error is None
93
+ assert up[1].error is not None
94
+ assert up[2].error is None
95
+ down = backend.download_files(["/workspace/iso/ok1.md", "/workspace/iso/ok2.md"])
96
+ assert [d.content for d in down] == [b"one", b"two"]
97
+
98
+
62
99
  def test_base_sandbox_file_helpers(backend):
63
100
  """ls/read derive from execute() — prove they work inside the microVM."""
64
101
  backend.upload_files([("/workspace/notes/hello.md", b"# hi\nline2\n")])
@@ -162,3 +199,31 @@ def test_reaped_sandbox_classified_expired_and_fails_fast():
162
199
  assert down[0].error == "sandbox_unavailable"
163
200
  finally:
164
201
  b.close()
202
+
203
+
204
+ def test_close_during_boot_kills_the_late_vm():
205
+ """Regression (code review 2026-10-09): a host closing the session while
206
+ a background thread is still booting it must not leave a running VM."""
207
+ import threading
208
+
209
+ from e2b import Sandbox
210
+
211
+ from deepanalysts.backends.e2b import E2BSandboxBackend
212
+
213
+ booted: list = []
214
+ backend_ref: list = []
215
+
216
+ def factory():
217
+ vm = Sandbox.create(api_key=os.environ["E2B_API_KEY"], timeout=120)
218
+ booted.append(vm)
219
+ backend_ref[0].close() # the run ended while this VM was booting
220
+ return vm
221
+
222
+ backend = E2BSandboxBackend(sandbox_factory=factory)
223
+ backend_ref.append(backend)
224
+ worker = threading.Thread(target=backend.upload_files, args=([("/workspace/a.md", b"x")],))
225
+ worker.start()
226
+ worker.join(timeout=120)
227
+
228
+ assert len(booted) == 1
229
+ assert booted[0].is_running() is False
@@ -0,0 +1,65 @@
1
+ """Regression: tool results carrying images were evicted as text (2026-10-13).
2
+
3
+ `_extract_text_content` fell back to `str(content)` for any multi-block result,
4
+ so a chart tool returning `[image, text]` counted its base64 PNG (~300K chars)
5
+ as text, crossed the eviction limit, and the model got a file path + a base64
6
+ preview instead of the image — no agent ever saw a chart.
7
+
8
+ Locks:
9
+ 1. Only text blocks count toward the eviction size; media blocks never do.
10
+ 2. A small text part next to an image is NOT evicted; the message is untouched.
11
+ 3. When the text part itself is oversized, only the text is evicted and the
12
+ media blocks are kept on the replacement message.
13
+ """
14
+
15
+ import pytest
16
+ from langchain_core.messages import ToolMessage
17
+
18
+ from deepanalysts.backends.state import StateBackend
19
+ from deepanalysts.middleware.filesystem import FilesystemMiddleware
20
+
21
+ pytestmark = pytest.mark.anyio
22
+
23
+ B64 = "iVBORw0KGgo" + "A" * 400_000
24
+ IMAGE = {"type": "image", "base64": B64, "mime_type": "image/png"}
25
+
26
+
27
+ @pytest.fixture
28
+ def rt(state_runtime):
29
+ return state_runtime()
30
+
31
+
32
+ def _mw():
33
+ return FilesystemMiddleware(backend=lambda r: StateBackend(r), tool_token_limit_before_evict=5000)
34
+
35
+
36
+ def _chart(text: str) -> ToolMessage:
37
+ return ToolMessage(content=[IMAGE, {"type": "text", "text": text}], tool_call_id="c1", name="view_chart")
38
+
39
+
40
+ def test_media_blocks_do_not_count_as_text(rt):
41
+ assert _mw()._extract_text_content(_chart("Chart: BTC/USD 4h").content) == "Chart: BTC/USD 4h"
42
+
43
+
44
+ def test_small_text_with_image_is_not_evicted(rt):
45
+ msg = _chart("Chart: BTC/USD 4h")
46
+ out = _mw()._intercept_large_tool_result(msg, rt)
47
+ assert out is msg
48
+
49
+
50
+ def test_oversized_text_is_evicted_but_image_is_kept(rt):
51
+ msg = _chart("x" * 30_000)
52
+ out = _mw()._intercept_large_tool_result(msg, rt)
53
+ replacement = out.update["messages"][0] if hasattr(out, "update") else out
54
+ blocks = replacement.content
55
+ assert isinstance(blocks, list)
56
+ assert any(isinstance(b, dict) and b.get("type") == "image" for b in blocks)
57
+ text = " ".join(b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text")
58
+ assert "/large_tool_results/" in text
59
+ assert B64 not in text
60
+
61
+
62
+ async def test_async_path_keeps_small_chart_results(rt):
63
+ msg = _chart("Chart: BTC/USD 4h")
64
+ out = await _mw()._aintercept_large_tool_result(msg, rt)
65
+ assert out is msg
File without changes
File without changes