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.
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/PKG-INFO +1 -1
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/e2b.py +129 -33
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/filesystem.py +43 -15
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/PKG-INFO +1 -1
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/SOURCES.txt +2 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/pyproject.toml +1 -1
- deepanalysts-0.9.4/tests/test_e2b_batch_upload.py +268 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_integration.py +65 -0
- deepanalysts-0.9.4/tests/test_regression_media_tool_results.py +65 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/README.md +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/__init__.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/_messages_reducer.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/__init__.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/basement.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/composite.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/filesystem.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/protocol.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/sandbox.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/state.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/store.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/supabase_storage.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/backends/utils.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/clients/__init__.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/clients/basement.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/__init__.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/_state.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/_utils.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/memory.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/patch_tool_calls.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/skills.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/subagents.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/summarization.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/middleware/tool_errors.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/state.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/streaming.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/utils/__init__.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts/utils/retry.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/dependency_links.txt +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/requires.txt +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/deepanalysts.egg-info/top_level.txt +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/setup.cfg +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_basement.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_composite_backend.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_backend.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_watch.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_e2b_watch_live.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_filesystem_middleware.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_messages_reducer.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_prompt_sections.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_protocol_bridge.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_files_delta.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_fs_backport.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_middleware_tools.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_new_api.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_prompt_contracts.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_storage_stale_reads.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_subagents.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_regression_tier1_backports.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_backend.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_hardening.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_hardening_integration.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_sandbox_scan_bounds.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_skills_middleware.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_store_backend.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_streaming_transformer.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_summarization_middleware.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_supabase_storage_backend.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_supabase_storage_integration.py +0 -0
- {deepanalysts-0.9.2 → deepanalysts-0.9.4}/tests/test_tool_errors.py +0 -0
- {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.
|
|
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.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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)
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|