deepanalysts 0.9.3__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.3 → deepanalysts-0.9.4}/PKG-INFO +1 -1
  2. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/filesystem.py +43 -15
  3. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts.egg-info/PKG-INFO +1 -1
  4. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts.egg-info/SOURCES.txt +1 -0
  5. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/pyproject.toml +1 -1
  6. deepanalysts-0.9.4/tests/test_regression_media_tool_results.py +65 -0
  7. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/README.md +0 -0
  8. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/__init__.py +0 -0
  9. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/_messages_reducer.py +0 -0
  10. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/__init__.py +0 -0
  11. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/basement.py +0 -0
  12. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/composite.py +0 -0
  13. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/e2b.py +0 -0
  14. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/filesystem.py +0 -0
  15. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/protocol.py +0 -0
  16. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/sandbox.py +0 -0
  17. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/state.py +0 -0
  18. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/store.py +0 -0
  19. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/supabase_storage.py +0 -0
  20. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/backends/utils.py +0 -0
  21. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/clients/__init__.py +0 -0
  22. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/clients/basement.py +0 -0
  23. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/__init__.py +0 -0
  24. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/_state.py +0 -0
  25. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/_utils.py +0 -0
  26. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/memory.py +0 -0
  27. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/patch_tool_calls.py +0 -0
  28. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/skills.py +0 -0
  29. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/subagents.py +0 -0
  30. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/summarization.py +0 -0
  31. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/middleware/tool_errors.py +0 -0
  32. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/state.py +0 -0
  33. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/streaming.py +0 -0
  34. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/utils/__init__.py +0 -0
  35. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts/utils/retry.py +0 -0
  36. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts.egg-info/dependency_links.txt +0 -0
  37. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts.egg-info/requires.txt +0 -0
  38. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/deepanalysts.egg-info/top_level.txt +0 -0
  39. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/setup.cfg +0 -0
  40. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_basement.py +0 -0
  41. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_composite_backend.py +0 -0
  42. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_e2b_backend.py +0 -0
  43. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_e2b_batch_upload.py +0 -0
  44. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_e2b_integration.py +0 -0
  45. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_e2b_watch.py +0 -0
  46. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_e2b_watch_live.py +0 -0
  47. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_filesystem_middleware.py +0 -0
  48. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_messages_reducer.py +0 -0
  49. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_prompt_sections.py +0 -0
  50. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_protocol_bridge.py +0 -0
  51. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_files_delta.py +0 -0
  52. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_fs_backport.py +0 -0
  53. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_middleware_tools.py +0 -0
  54. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_new_api.py +0 -0
  55. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_prompt_contracts.py +0 -0
  56. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_storage_stale_reads.py +0 -0
  57. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_subagents.py +0 -0
  58. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_regression_tier1_backports.py +0 -0
  59. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_sandbox_backend.py +0 -0
  60. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_sandbox_hardening.py +0 -0
  61. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_sandbox_hardening_integration.py +0 -0
  62. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_sandbox_scan_bounds.py +0 -0
  63. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_skills_middleware.py +0 -0
  64. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_store_backend.py +0 -0
  65. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_streaming_transformer.py +0 -0
  66. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_summarization_middleware.py +0 -0
  67. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_supabase_storage_backend.py +0 -0
  68. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_supabase_storage_integration.py +0 -0
  69. {deepanalysts-0.9.3 → deepanalysts-0.9.4}/tests/test_tool_errors.py +0 -0
  70. {deepanalysts-0.9.3 → 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
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
@@ -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.3
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
@@ -47,6 +47,7 @@ tests/test_prompt_sections.py
47
47
  tests/test_protocol_bridge.py
48
48
  tests/test_regression_files_delta.py
49
49
  tests/test_regression_fs_backport.py
50
+ tests/test_regression_media_tool_results.py
50
51
  tests/test_regression_middleware_tools.py
51
52
  tests/test_regression_new_api.py
52
53
  tests/test_regression_prompt_contracts.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepanalysts"
3
- version = "0.9.3"
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,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