deepanalysts 0.7.2__tar.gz → 0.7.3__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.7.2 → deepanalysts-0.7.3}/PKG-INFO +1 -1
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/composite.py +39 -9
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/supabase_storage.py +8 -2
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/filesystem.py +10 -4
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/PKG-INFO +1 -1
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/pyproject.toml +1 -1
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_composite_backend.py +89 -4
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_filesystem_middleware.py +35 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_supabase_storage_backend.py +35 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/README.md +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/__init__.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/_messages_reducer.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/__init__.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/basement.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/e2b.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/filesystem.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/protocol.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/sandbox.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/state.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/store.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/utils.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/clients/__init__.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/clients/basement.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/__init__.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/_state.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/_utils.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/memory.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/patch_tool_calls.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/skills.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/subagents.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/summarization.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/tool_errors.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/state.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/streaming.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/utils/__init__.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/utils/retry.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/SOURCES.txt +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/dependency_links.txt +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/requires.txt +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/top_level.txt +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/setup.cfg +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_basement.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_e2b_backend.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_e2b_integration.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_messages_reducer.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_prompt_sections.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_protocol_bridge.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_files_delta.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_fs_backport.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_middleware_tools.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_new_api.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_subagents.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_tier1_backports.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_sandbox_backend.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_sandbox_hardening.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_sandbox_hardening_integration.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_skills_middleware.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_store_backend.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_streaming_transformer.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_summarization_middleware.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_supabase_storage_integration.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_tool_errors.py +0 -0
- {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepanalysts
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
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
|
|
@@ -9,7 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
from collections import defaultdict
|
|
12
|
-
from
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from typing import TypeVar, cast
|
|
13
14
|
|
|
14
15
|
from deepanalysts.backends.protocol import (
|
|
15
16
|
BackendProtocol,
|
|
@@ -49,6 +50,29 @@ def _remap_file_info_path(fi: FileInfo, route_prefix: str) -> FileInfo:
|
|
|
49
50
|
)
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
_ResultT = TypeVar("_ResultT", WriteResult, EditResult)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _remap_result_paths(res: _ResultT, route_prefix: str | None) -> _ResultT:
|
|
57
|
+
"""Re-prepend a matched route prefix to a write/edit result's paths.
|
|
58
|
+
|
|
59
|
+
Child backends receive route-stripped paths, so the ``path`` (and
|
|
60
|
+
``files_update`` keys) they return are relative to the route root. Tool
|
|
61
|
+
confirmations echo ``res.path`` back to the model — leaving it stripped
|
|
62
|
+
hands the model a path that routes to a *different* backend on the next
|
|
63
|
+
call (e.g. ``/shared/x.md`` written, ``/x.md`` echoed, follow-up read of
|
|
64
|
+
``/x.md`` hits the default sandbox and fails).
|
|
65
|
+
"""
|
|
66
|
+
if route_prefix is None:
|
|
67
|
+
return res
|
|
68
|
+
prefix = route_prefix.rstrip("/")
|
|
69
|
+
path = f"{prefix}{res.path}" if res.path else res.path
|
|
70
|
+
files_update = res.files_update
|
|
71
|
+
if files_update is not None:
|
|
72
|
+
files_update = {f"{prefix}{k}": v for k, v in files_update.items()}
|
|
73
|
+
return replace(res, path=path, files_update=files_update)
|
|
74
|
+
|
|
75
|
+
|
|
52
76
|
def _strip_route_from_pattern(pattern: str, route_prefix: str) -> str:
|
|
53
77
|
"""Strip a route prefix from a glob pattern when the pattern targets that route.
|
|
54
78
|
|
|
@@ -163,8 +187,8 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
163
187
|
return backend.read(backend_path, offset=offset, limit=limit)
|
|
164
188
|
|
|
165
189
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
166
|
-
backend, backend_path,
|
|
167
|
-
return backend.write(backend_path, content)
|
|
190
|
+
backend, backend_path, route_prefix = self._route(file_path)
|
|
191
|
+
return _remap_result_paths(backend.write(backend_path, content), route_prefix)
|
|
168
192
|
|
|
169
193
|
def edit(
|
|
170
194
|
self,
|
|
@@ -173,8 +197,11 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
173
197
|
new_string: str,
|
|
174
198
|
replace_all: bool = False,
|
|
175
199
|
) -> EditResult:
|
|
176
|
-
backend, backend_path,
|
|
177
|
-
return
|
|
200
|
+
backend, backend_path, route_prefix = self._route(file_path)
|
|
201
|
+
return _remap_result_paths(
|
|
202
|
+
backend.edit(backend_path, old_string, new_string, replace_all=replace_all),
|
|
203
|
+
route_prefix,
|
|
204
|
+
)
|
|
178
205
|
|
|
179
206
|
def grep(
|
|
180
207
|
self,
|
|
@@ -309,8 +336,8 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
309
336
|
return await backend.aread_result(backend_path, offset=offset, limit=limit)
|
|
310
337
|
|
|
311
338
|
async def awrite(self, file_path: str, content: str) -> WriteResult:
|
|
312
|
-
backend, backend_path,
|
|
313
|
-
return await backend.awrite(backend_path, content)
|
|
339
|
+
backend, backend_path, route_prefix = self._route(file_path)
|
|
340
|
+
return _remap_result_paths(await backend.awrite(backend_path, content), route_prefix)
|
|
314
341
|
|
|
315
342
|
async def aedit(
|
|
316
343
|
self,
|
|
@@ -319,8 +346,11 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
319
346
|
new_string: str,
|
|
320
347
|
replace_all: bool = False,
|
|
321
348
|
) -> EditResult:
|
|
322
|
-
backend, backend_path,
|
|
323
|
-
return
|
|
349
|
+
backend, backend_path, route_prefix = self._route(file_path)
|
|
350
|
+
return _remap_result_paths(
|
|
351
|
+
await backend.aedit(backend_path, old_string, new_string, replace_all=replace_all),
|
|
352
|
+
route_prefix,
|
|
353
|
+
)
|
|
324
354
|
|
|
325
355
|
async def agrep(
|
|
326
356
|
self,
|
|
@@ -337,6 +337,12 @@ class SupabaseStorageBackend(BackendProtocol):
|
|
|
337
337
|
except StorageListError as e:
|
|
338
338
|
return LsResult(error=f"Error: {e}")
|
|
339
339
|
|
|
340
|
+
# Entries must carry the full virtual path, not the bare object name —
|
|
341
|
+
# listing "/research/x/" with entries rendered as "/README.md" reads as
|
|
342
|
+
# a root-level file and the model then read_files the wrong path.
|
|
343
|
+
base = "/" + path.strip("/")
|
|
344
|
+
base = base if base == "/" else f"{base}/"
|
|
345
|
+
|
|
340
346
|
entries: list[FileInfo] = []
|
|
341
347
|
for item in items:
|
|
342
348
|
name = item.get("name", "")
|
|
@@ -344,12 +350,12 @@ class SupabaseStorageBackend(BackendProtocol):
|
|
|
344
350
|
continue
|
|
345
351
|
|
|
346
352
|
if item.get("id") is None:
|
|
347
|
-
entries.append(FileInfo(path=f"
|
|
353
|
+
entries.append(FileInfo(path=f"{base}{name}/", is_dir=True, size=0, modified_at=""))
|
|
348
354
|
else:
|
|
349
355
|
metadata = item.get("metadata") or {}
|
|
350
356
|
entries.append(
|
|
351
357
|
FileInfo(
|
|
352
|
-
path=f"
|
|
358
|
+
path=f"{base}{name}",
|
|
353
359
|
is_dir=False,
|
|
354
360
|
size=metadata.get("size", 0),
|
|
355
361
|
modified_at=item.get("updated_at", ""),
|
|
@@ -736,19 +736,22 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
736
736
|
res: WriteResult = resolved_backend.write(file_path, content)
|
|
737
737
|
if res.error:
|
|
738
738
|
return res.error
|
|
739
|
+
# Byte count lets the model verify a large write completed even
|
|
740
|
+
# after history compaction truncates the content argument.
|
|
741
|
+
confirmation = f"Updated file {res.path} ({len(content.encode('utf-8'))} bytes written)"
|
|
739
742
|
if res.files_update is not None:
|
|
740
743
|
return Command(
|
|
741
744
|
update={
|
|
742
745
|
"files": res.files_update,
|
|
743
746
|
"messages": [
|
|
744
747
|
ToolMessage(
|
|
745
|
-
content=
|
|
748
|
+
content=confirmation,
|
|
746
749
|
tool_call_id=runtime.tool_call_id,
|
|
747
750
|
)
|
|
748
751
|
],
|
|
749
752
|
}
|
|
750
753
|
)
|
|
751
|
-
return
|
|
754
|
+
return confirmation
|
|
752
755
|
|
|
753
756
|
async def async_write_file(
|
|
754
757
|
file_path: Annotated[str, "Absolute path to the file to write."],
|
|
@@ -763,19 +766,22 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
763
766
|
res: WriteResult = await resolved_backend.awrite(file_path, content)
|
|
764
767
|
if res.error:
|
|
765
768
|
return res.error
|
|
769
|
+
# Byte count lets the model verify a large write completed even
|
|
770
|
+
# after history compaction truncates the content argument.
|
|
771
|
+
confirmation = f"Updated file {res.path} ({len(content.encode('utf-8'))} bytes written)"
|
|
766
772
|
if res.files_update is not None:
|
|
767
773
|
return Command(
|
|
768
774
|
update={
|
|
769
775
|
"files": res.files_update,
|
|
770
776
|
"messages": [
|
|
771
777
|
ToolMessage(
|
|
772
|
-
content=
|
|
778
|
+
content=confirmation,
|
|
773
779
|
tool_call_id=runtime.tool_call_id,
|
|
774
780
|
)
|
|
775
781
|
],
|
|
776
782
|
}
|
|
777
783
|
)
|
|
778
|
-
return
|
|
784
|
+
return confirmation
|
|
779
785
|
|
|
780
786
|
return StructuredTool.from_function(
|
|
781
787
|
name="write_file",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepanalysts
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
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
|
|
@@ -44,7 +44,7 @@ class TestCompositeBackendRouting:
|
|
|
44
44
|
)
|
|
45
45
|
result = composite.write("/memories/user.md", "preferences")
|
|
46
46
|
assert result.error is None
|
|
47
|
-
assert result.path == "/user.md" #
|
|
47
|
+
assert result.path == "/memories/user.md" # Full virtual path echoed
|
|
48
48
|
|
|
49
49
|
def test_route_to_default(self):
|
|
50
50
|
"""Test unmatched paths use default backend."""
|
|
@@ -79,8 +79,8 @@ class TestCompositeBackendRouting:
|
|
|
79
79
|
# The /skills/trading/ should match before /skills/
|
|
80
80
|
result = composite.write("/skills/trading/fib.md", "content")
|
|
81
81
|
assert result.error is None
|
|
82
|
-
#
|
|
83
|
-
assert result.path == "/fib.md"
|
|
82
|
+
# Result path is the full virtual path, whichever route matched
|
|
83
|
+
assert result.path == "/skills/trading/fib.md"
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
class TestCompositeBackendExecution:
|
|
@@ -217,7 +217,7 @@ class TestCompositeBackendAsync:
|
|
|
217
217
|
|
|
218
218
|
result = await composite.awrite("/memories/new.md", "new content")
|
|
219
219
|
assert result.error is None
|
|
220
|
-
assert result.path == "/new.md" #
|
|
220
|
+
assert result.path == "/memories/new.md" # Full virtual path echoed
|
|
221
221
|
|
|
222
222
|
asyncio.run(run_test())
|
|
223
223
|
|
|
@@ -475,3 +475,88 @@ class TestPathHelpers:
|
|
|
475
475
|
def test_strip_route_leading_slash_agnostic(self):
|
|
476
476
|
result = _strip_route_from_pattern("memories/**/*.md", "/memories/")
|
|
477
477
|
assert result == "**/*.md"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
# Regression: write/edit results must echo the full virtual path
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class TestWriteEditResultPathRemap:
|
|
486
|
+
"""Regression for the /shared/ path-stripping bug.
|
|
487
|
+
|
|
488
|
+
Routed backends see route-stripped paths, so their results carried e.g.
|
|
489
|
+
``/research/x.md`` for a write to ``/shared/research/x.md``. The tool
|
|
490
|
+
confirmation echoed that stripped path, and a follow-up read of it routed
|
|
491
|
+
to the *default* backend and failed. Write/edit results must round-trip.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
def test_write_result_path_roundtrips_through_read(self):
|
|
495
|
+
composite = CompositeBackend(
|
|
496
|
+
default=make_store_backend(),
|
|
497
|
+
routes={"/shared/": make_store_backend()},
|
|
498
|
+
)
|
|
499
|
+
result = composite.write("/shared/research/x/README.md", "# hi")
|
|
500
|
+
assert result.error is None
|
|
501
|
+
assert result.path == "/shared/research/x/README.md"
|
|
502
|
+
# The echoed path must be readable as-is (this failed pre-fix: the
|
|
503
|
+
# stripped path routed to the default backend).
|
|
504
|
+
assert "hi" in composite.read(result.path)
|
|
505
|
+
|
|
506
|
+
def test_edit_result_path_is_full_virtual_path(self):
|
|
507
|
+
store = make_store_backend()
|
|
508
|
+
composite = CompositeBackend(
|
|
509
|
+
default=make_store_backend(),
|
|
510
|
+
routes={"/shared/": store},
|
|
511
|
+
)
|
|
512
|
+
store.write("/note.md", "hello world")
|
|
513
|
+
result = composite.edit("/shared/note.md", "hello", "goodbye")
|
|
514
|
+
assert result.error is None
|
|
515
|
+
assert result.path == "/shared/note.md"
|
|
516
|
+
|
|
517
|
+
def test_awrite_files_update_keys_are_remapped(self):
|
|
518
|
+
async def run_test():
|
|
519
|
+
composite = CompositeBackend(
|
|
520
|
+
default=make_store_backend(),
|
|
521
|
+
routes={"/memories/": make_store_backend()},
|
|
522
|
+
)
|
|
523
|
+
result = await composite.awrite("/memories/a/b.md", "content")
|
|
524
|
+
assert result.error is None
|
|
525
|
+
assert result.path == "/memories/a/b.md"
|
|
526
|
+
if result.files_update is not None:
|
|
527
|
+
assert all(k.startswith("/memories/") for k in result.files_update)
|
|
528
|
+
|
|
529
|
+
asyncio.run(run_test())
|
|
530
|
+
|
|
531
|
+
def test_aedit_result_path_is_full_virtual_path(self):
|
|
532
|
+
async def run_test():
|
|
533
|
+
store = make_store_backend()
|
|
534
|
+
composite = CompositeBackend(
|
|
535
|
+
default=make_store_backend(),
|
|
536
|
+
routes={"/shared/": store},
|
|
537
|
+
)
|
|
538
|
+
store.write("/x.md", "old text")
|
|
539
|
+
result = await composite.aedit("/shared/x.md", "old", "new")
|
|
540
|
+
assert result.error is None
|
|
541
|
+
assert result.path == "/shared/x.md"
|
|
542
|
+
|
|
543
|
+
asyncio.run(run_test())
|
|
544
|
+
|
|
545
|
+
def test_default_route_write_path_unchanged(self):
|
|
546
|
+
composite = CompositeBackend(
|
|
547
|
+
default=make_store_backend(),
|
|
548
|
+
routes={"/memories/": make_store_backend()},
|
|
549
|
+
)
|
|
550
|
+
result = composite.write("/scratch.txt", "tmp")
|
|
551
|
+
assert result.error is None
|
|
552
|
+
assert result.path == "/scratch.txt"
|
|
553
|
+
|
|
554
|
+
def test_error_result_passes_through(self):
|
|
555
|
+
store = make_store_backend()
|
|
556
|
+
composite = CompositeBackend(
|
|
557
|
+
default=make_store_backend(),
|
|
558
|
+
routes={"/shared/": store},
|
|
559
|
+
)
|
|
560
|
+
result = composite.edit("/shared/missing.md", "a", "b")
|
|
561
|
+
assert result.error is not None
|
|
562
|
+
assert result.path is None
|
|
@@ -29,6 +29,14 @@ def _make_store_backend() -> StoreBackend:
|
|
|
29
29
|
return StoreBackend(_make_mock_runtime())
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _invoke_write_file(middleware: FilesystemMiddleware, file_path: str, content: str):
|
|
33
|
+
"""Invoke the write_file tool's sync function directly (see _invoke_read_file)."""
|
|
34
|
+
write_file_tool = next(t for t in middleware.tools if t.name == "write_file")
|
|
35
|
+
runtime = MagicMock()
|
|
36
|
+
runtime.tool_call_id = "test-call-id"
|
|
37
|
+
return write_file_tool.func(file_path=file_path, content=content, runtime=runtime)
|
|
38
|
+
|
|
39
|
+
|
|
32
40
|
def _invoke_read_file(middleware: FilesystemMiddleware, file_path: str, offset: int = 0, limit: int = 500) -> str:
|
|
33
41
|
"""Invoke the read_file tool's sync function directly.
|
|
34
42
|
|
|
@@ -44,6 +52,33 @@ def _invoke_read_file(middleware: FilesystemMiddleware, file_path: str, offset:
|
|
|
44
52
|
return sync_fn(file_path=file_path, runtime=runtime, offset=offset, limit=limit)
|
|
45
53
|
|
|
46
54
|
|
|
55
|
+
class TestWriteFileConfirmation:
|
|
56
|
+
"""Regression: the confirmation must state how many bytes were written.
|
|
57
|
+
|
|
58
|
+
History compaction later truncates the write's content argument to a
|
|
59
|
+
20-char preview ("...(argument truncated)"), so without a byte count the
|
|
60
|
+
model cannot tell whether a large write completed and tends to retry it.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def test_confirmation_reports_byte_count(self):
|
|
64
|
+
backend = _make_store_backend()
|
|
65
|
+
middleware = FilesystemMiddleware(backend=backend)
|
|
66
|
+
content = "x" * 1234
|
|
67
|
+
result = _invoke_write_file(middleware, "/report.md", content)
|
|
68
|
+
# StoreBackend returns files_update, so the confirmation rides a Command
|
|
69
|
+
message = result.update["messages"][0].content if hasattr(result, "update") else result
|
|
70
|
+
assert "/report.md" in message
|
|
71
|
+
assert "1234 bytes written" in message
|
|
72
|
+
|
|
73
|
+
def test_byte_count_is_utf8_bytes_not_chars(self):
|
|
74
|
+
backend = _make_store_backend()
|
|
75
|
+
middleware = FilesystemMiddleware(backend=backend)
|
|
76
|
+
content = "é" * 10 # 10 chars, 20 UTF-8 bytes
|
|
77
|
+
result = _invoke_write_file(middleware, "/unicode.md", content)
|
|
78
|
+
message = result.update["messages"][0].content if hasattr(result, "update") else result
|
|
79
|
+
assert "20 bytes written" in message
|
|
80
|
+
|
|
81
|
+
|
|
47
82
|
class TestReadFileTruncation:
|
|
48
83
|
"""Tests for read_file tool truncation behavior."""
|
|
49
84
|
|
|
@@ -258,6 +258,41 @@ class TestAls:
|
|
|
258
258
|
|
|
259
259
|
asyncio.run(run())
|
|
260
260
|
|
|
261
|
+
def test_list_subdirectory_entries_carry_full_path(self):
|
|
262
|
+
"""Regression: entries were rendered as bare names ("/README.md"), so
|
|
263
|
+
listing a subdirectory looked like a listing of the tree root and the
|
|
264
|
+
model then read the wrong path."""
|
|
265
|
+
|
|
266
|
+
async def run():
|
|
267
|
+
b = make_backend()
|
|
268
|
+
items = [
|
|
269
|
+
{"name": "README.md", "id": "abc", "metadata": {"size": 10}, "updated_at": "2024-01-01"},
|
|
270
|
+
{"name": "data", "id": None}, # folder
|
|
271
|
+
]
|
|
272
|
+
with patch.object(b, "_list_objects", new_callable=AsyncMock, return_value=items):
|
|
273
|
+
result = await b.als("/research/ai-supply-chain/")
|
|
274
|
+
assert result.error is None
|
|
275
|
+
paths = [fi["path"] for fi in (result.entries or [])]
|
|
276
|
+
assert "/research/ai-supply-chain/README.md" in paths
|
|
277
|
+
assert "/research/ai-supply-chain/data/" in paths
|
|
278
|
+
assert "/README.md" not in paths
|
|
279
|
+
|
|
280
|
+
asyncio.run(run())
|
|
281
|
+
|
|
282
|
+
def test_list_subdirectory_without_trailing_slash(self):
|
|
283
|
+
async def run():
|
|
284
|
+
b = make_backend()
|
|
285
|
+
items = [
|
|
286
|
+
{"name": "notes.md", "id": "x", "metadata": {"size": 5}, "updated_at": "2024-01-01"},
|
|
287
|
+
]
|
|
288
|
+
with patch.object(b, "_list_objects", new_callable=AsyncMock, return_value=items):
|
|
289
|
+
result = await b.als("/research")
|
|
290
|
+
assert result.error is None
|
|
291
|
+
paths = [fi["path"] for fi in (result.entries or [])]
|
|
292
|
+
assert paths == ["/research/notes.md"]
|
|
293
|
+
|
|
294
|
+
asyncio.run(run())
|
|
295
|
+
|
|
261
296
|
|
|
262
297
|
class TestAgrep:
|
|
263
298
|
def test_grep_finds_matches(self):
|
|
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
|