Skip to content

Commit ef821a7

Browse files
feat(deepagents): align JS implementation with Python deepagents (#139)
* feat(deepagents): align JS implementation with Python deepagents This commit addresses divergences between the JavaScript and Python implementations of the deepagents library, porting several features from Python and adding comprehensive test coverage. Changes: Deprecation: - Mark `createAgentMemoryMiddleware` as deprecated in favor of `createMemoryMiddleware` which uses BackendProtocol abstraction Filesystem middleware (fs.ts): - Port comprehensive tool descriptions from Python's filesystem.py - Add TOOLS_EXCLUDED_FROM_EVICTION constant for eviction control - Add NUM_CHARS_PER_TOKEN constant for token estimation - Add createContentPreview() for showing head/tail of large results - Update eviction logic to skip excluded tools and show content preview Path validation (backends/utils.ts): - Add validateFilePath() with security checks for path traversal - Reject Windows absolute paths and tilde expansion - Support allowedPrefixes validation Skills middleware (skills.ts): - Show "(higher priority)" indicator for last source in locations - Display allowedTools in skill list when specified Summarization middleware (summarization.ts): - Port SummarizationMiddleware from Python with backend offloading - Support conversation history persistence to filesystem - Add tool argument truncation for old messages - Re-export base summarizationMiddleware from langchain Middleware utilities (utils.ts): - Add appendToSystemMessage() helper - Add prependToSystemMessage() helper * format * fix test
1 parent 73b4ada commit ef821a7

File tree

15 files changed

+2277
-122
lines changed

15 files changed

+2277
-122
lines changed

libs/deepagents/src/backends/composite.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class MockSandboxBackend implements SandboxBackendProtocol {
3434
read() {
3535
return "";
3636
}
37+
readRaw() {
38+
return { content: [], created_at: "", modified_at: "" };
39+
}
3740
grepRaw() {
3841
return [];
3942
}

libs/deepagents/src/backends/composite.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,10 @@ export class CompositeBackend implements BackendProtocol {
297297
async uploadFiles(
298298
files: Array<[string, Uint8Array]>,
299299
): Promise<FileUploadResponse[]> {
300-
const results: Array<FileUploadResponse | null> = new Array(
301-
files.length,
302-
).fill(null);
300+
const results: Array<FileUploadResponse | null> = Array.from(
301+
{ length: files.length },
302+
() => null,
303+
);
303304
const batchesByBackend = new Map<
304305
BackendProtocol,
305306
Array<{ idx: number; path: string; content: Uint8Array }>
@@ -344,9 +345,10 @@ export class CompositeBackend implements BackendProtocol {
344345
* @returns List of FileDownloadResponse objects, one per input path
345346
*/
346347
async downloadFiles(paths: string[]): Promise<FileDownloadResponse[]> {
347-
const results: Array<FileDownloadResponse | null> = new Array(
348-
paths.length,
349-
).fill(null);
348+
const results: Array<FileDownloadResponse | null> = Array.from(
349+
{ length: paths.length },
350+
() => null,
351+
);
350352
const batchesByBackend = new Map<
351353
BackendProtocol,
352354
Array<{ idx: number; path: string }>
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
validatePath,
4+
validateFilePath,
5+
sanitizeToolCallId,
6+
formatContentWithLineNumbers,
7+
createFileData,
8+
updateFileData,
9+
fileDataToString,
10+
checkEmptyContent,
11+
performStringReplacement,
12+
truncateIfTooLong,
13+
TOOL_RESULT_TOKEN_LIMIT,
14+
} from "./utils.js";
15+
16+
describe("validatePath", () => {
17+
it("should add leading slash if missing", () => {
18+
expect(validatePath("foo/bar")).toBe("/foo/bar/");
19+
});
20+
21+
it("should add trailing slash if missing", () => {
22+
expect(validatePath("/foo/bar")).toBe("/foo/bar/");
23+
});
24+
25+
it("should handle root path", () => {
26+
expect(validatePath("/")).toBe("/");
27+
});
28+
29+
it("should handle null path", () => {
30+
expect(validatePath(null)).toBe("/");
31+
});
32+
33+
it("should handle undefined path", () => {
34+
expect(validatePath(undefined)).toBe("/");
35+
});
36+
37+
it("should handle empty string", () => {
38+
expect(validatePath("")).toBe("/");
39+
});
40+
});
41+
42+
describe("validateFilePath", () => {
43+
it("should normalize paths without leading slash", () => {
44+
expect(validateFilePath("foo/bar")).toBe("/foo/bar");
45+
});
46+
47+
it("should normalize paths with redundant slashes", () => {
48+
expect(validateFilePath("/foo//bar")).toBe("/foo/bar");
49+
});
50+
51+
it("should remove dot components", () => {
52+
expect(validateFilePath("/./foo/./bar")).toBe("/foo/bar");
53+
});
54+
55+
it("should reject path traversal with ..", () => {
56+
expect(() => validateFilePath("../etc/passwd")).toThrow(
57+
"Path traversal not allowed",
58+
);
59+
});
60+
61+
it("should reject path traversal with .. in middle", () => {
62+
expect(() => validateFilePath("/foo/../bar")).toThrow(
63+
"Path traversal not allowed",
64+
);
65+
});
66+
67+
it("should reject tilde paths", () => {
68+
expect(() => validateFilePath("~/secret")).toThrow(
69+
"Path traversal not allowed",
70+
);
71+
});
72+
73+
it("should reject Windows absolute paths with backslash", () => {
74+
expect(() => validateFilePath("C:\\Users\\file.txt")).toThrow(
75+
"Windows absolute paths are not supported",
76+
);
77+
});
78+
79+
it("should reject Windows absolute paths with forward slash", () => {
80+
expect(() => validateFilePath("C:/Users/file.txt")).toThrow(
81+
"Windows absolute paths are not supported",
82+
);
83+
});
84+
85+
it("should reject lowercase Windows paths", () => {
86+
expect(() => validateFilePath("c:/users/file.txt")).toThrow(
87+
"Windows absolute paths are not supported",
88+
);
89+
});
90+
91+
it("should normalize backslashes to forward slashes", () => {
92+
expect(validateFilePath("/foo\\bar")).toBe("/foo/bar");
93+
});
94+
95+
it("should validate allowed prefixes when provided", () => {
96+
expect(validateFilePath("/data/file.txt", ["/data/"])).toBe(
97+
"/data/file.txt",
98+
);
99+
});
100+
101+
it("should reject paths not starting with allowed prefixes", () => {
102+
expect(() => validateFilePath("/etc/passwd", ["/data/"])).toThrow(
103+
'Path must start with one of ["/data/"]',
104+
);
105+
});
106+
107+
it("should accept any of multiple allowed prefixes", () => {
108+
expect(validateFilePath("/data/file.txt", ["/tmp/", "/data/"])).toBe(
109+
"/data/file.txt",
110+
);
111+
expect(validateFilePath("/tmp/file.txt", ["/tmp/", "/data/"])).toBe(
112+
"/tmp/file.txt",
113+
);
114+
});
115+
116+
it("should handle root path", () => {
117+
expect(validateFilePath("/")).toBe("/");
118+
});
119+
});
120+
121+
describe("sanitizeToolCallId", () => {
122+
it("should replace dots with underscores", () => {
123+
expect(sanitizeToolCallId("call.123")).toBe("call_123");
124+
});
125+
126+
it("should replace forward slashes with underscores", () => {
127+
expect(sanitizeToolCallId("call/123")).toBe("call_123");
128+
});
129+
130+
it("should replace backslashes with underscores", () => {
131+
expect(sanitizeToolCallId("call\\123")).toBe("call_123");
132+
});
133+
134+
it("should handle multiple replacements", () => {
135+
expect(sanitizeToolCallId("call.foo/bar\\baz")).toBe("call_foo_bar_baz");
136+
});
137+
138+
it("should leave safe strings unchanged", () => {
139+
expect(sanitizeToolCallId("call_123_abc")).toBe("call_123_abc");
140+
});
141+
});
142+
143+
describe("formatContentWithLineNumbers", () => {
144+
it("should format string content with line numbers", () => {
145+
const result = formatContentWithLineNumbers("line1\nline2");
146+
expect(result).toContain("1");
147+
expect(result).toContain("line1");
148+
expect(result).toContain("2");
149+
expect(result).toContain("line2");
150+
});
151+
152+
it("should format array content with line numbers", () => {
153+
const result = formatContentWithLineNumbers(["line1", "line2"]);
154+
expect(result).toContain("1");
155+
expect(result).toContain("line1");
156+
expect(result).toContain("2");
157+
expect(result).toContain("line2");
158+
});
159+
160+
it("should use custom start line", () => {
161+
const result = formatContentWithLineNumbers("line1", 10);
162+
expect(result).toContain("10");
163+
expect(result).toContain("line1");
164+
});
165+
166+
it("should handle empty trailing newline", () => {
167+
const result = formatContentWithLineNumbers("line1\nline2\n");
168+
const lines = result.split("\n");
169+
expect(lines.length).toBe(2);
170+
});
171+
});
172+
173+
describe("createFileData", () => {
174+
it("should create FileData with content split into lines", () => {
175+
const result = createFileData("line1\nline2");
176+
expect(result.content).toEqual(["line1", "line2"]);
177+
});
178+
179+
it("should set created_at and modified_at timestamps", () => {
180+
const result = createFileData("content");
181+
expect(result.created_at).toBeDefined();
182+
expect(result.modified_at).toBeDefined();
183+
expect(new Date(result.created_at).getTime()).toBeGreaterThan(0);
184+
});
185+
186+
it("should use provided createdAt timestamp", () => {
187+
const timestamp = "2023-01-01T00:00:00.000Z";
188+
const result = createFileData("content", timestamp);
189+
expect(result.created_at).toBe(timestamp);
190+
});
191+
});
192+
193+
describe("updateFileData", () => {
194+
it("should update content while preserving created_at", () => {
195+
const original = createFileData("old content");
196+
const originalCreatedAt = original.created_at;
197+
198+
const updated = updateFileData(original, "new content");
199+
expect(updated.content).toEqual(["new content"]);
200+
expect(updated.created_at).toBe(originalCreatedAt);
201+
});
202+
203+
it("should update modified_at timestamp", () => {
204+
const original = createFileData("old content");
205+
206+
// Small delay to ensure different timestamp
207+
const updated = updateFileData(original, "new content");
208+
expect(updated.modified_at).toBeDefined();
209+
});
210+
});
211+
212+
describe("fileDataToString", () => {
213+
it("should join lines with newlines", () => {
214+
const fileData = createFileData("line1\nline2\nline3");
215+
const result = fileDataToString(fileData);
216+
expect(result).toBe("line1\nline2\nline3");
217+
});
218+
});
219+
220+
describe("checkEmptyContent", () => {
221+
it("should return warning for empty string", () => {
222+
expect(checkEmptyContent("")).not.toBeNull();
223+
});
224+
225+
it("should return warning for whitespace-only string", () => {
226+
expect(checkEmptyContent(" \n\t ")).not.toBeNull();
227+
});
228+
229+
it("should return null for non-empty content", () => {
230+
expect(checkEmptyContent("hello")).toBeNull();
231+
});
232+
});
233+
234+
describe("performStringReplacement", () => {
235+
it("should replace string and return new content with occurrence count", () => {
236+
const result = performStringReplacement(
237+
"hello world",
238+
"world",
239+
"there",
240+
false,
241+
);
242+
expect(result).toEqual(["hello there", 1]);
243+
});
244+
245+
it("should return error if string not found", () => {
246+
const result = performStringReplacement("hello world", "foo", "bar", false);
247+
expect(typeof result).toBe("string");
248+
expect(result).toContain("not found");
249+
});
250+
251+
it("should return error if multiple occurrences and replaceAll is false", () => {
252+
const result = performStringReplacement("foo foo foo", "foo", "bar", false);
253+
expect(typeof result).toBe("string");
254+
expect(result).toContain("appears 3 times");
255+
});
256+
257+
it("should replace all occurrences when replaceAll is true", () => {
258+
const result = performStringReplacement("foo foo foo", "foo", "bar", true);
259+
expect(result).toEqual(["bar bar bar", 3]);
260+
});
261+
});
262+
263+
describe("truncateIfTooLong", () => {
264+
it("should return array unchanged if under limit", () => {
265+
const input = ["short", "lines"];
266+
expect(truncateIfTooLong(input)).toEqual(input);
267+
});
268+
269+
it("should return string unchanged if under limit", () => {
270+
const input = "short string";
271+
expect(truncateIfTooLong(input)).toBe(input);
272+
});
273+
274+
it("should truncate long strings", () => {
275+
const input = "x".repeat(TOOL_RESULT_TOKEN_LIMIT * 5);
276+
const result = truncateIfTooLong(input);
277+
expect(result.length).toBeLessThan(input.length);
278+
expect(result).toContain("truncated");
279+
});
280+
281+
it("should truncate long arrays", () => {
282+
const input = Array(1000).fill("a".repeat(100));
283+
const result = truncateIfTooLong(input) as string[];
284+
expect(result.length).toBeLessThan(input.length);
285+
expect(result[result.length - 1]).toContain("truncated");
286+
});
287+
});

0 commit comments

Comments
 (0)