diff --git a/.changeset/light-cars-report.md b/.changeset/light-cars-report.md new file mode 100644 index 000000000..2bcfd80cf --- /dev/null +++ b/.changeset/light-cars-report.md @@ -0,0 +1,5 @@ +--- +"deepagents": patch +--- + +fix(deepagents): CompositeBackend grepRaw/globInfo respect path→backend mapping (#241) diff --git a/libs/deepagents/src/backends/composite.test.ts b/libs/deepagents/src/backends/composite.test.ts index a30c78f6f..2dbfce539 100644 --- a/libs/deepagents/src/backends/composite.test.ts +++ b/libs/deepagents/src/backends/composite.test.ts @@ -213,8 +213,7 @@ describe("CompositeBackend", () => { expect(memPaths).not.toContain("/temp.txt"); expect(memPaths).not.toContain("/archive/old.log"); - // grep across all backends with literal text search - // Note: All written content contains 'e' character + // grep at "/" includes all routes since they are under "/" const allMatches = (await composite.grepRaw("e", "/")) as GrepMatch[]; expect(Array.isArray(allMatches)).toBe(true); const pathsWithContent = allMatches.map((m) => m.path); @@ -337,6 +336,49 @@ describe("CompositeBackend", () => { ); }); + it("should not search routed backends when path does not match any route", async () => { + const { stateAndStore } = makeConfig(); + + const memoryBackend = new StoreBackend(stateAndStore); + const skillsBackend = new StoreBackend(stateAndStore); + const grepMemSpy = vi.spyOn(memoryBackend, "grepRaw"); + const grepSkillsSpy = vi.spyOn(skillsBackend, "grepRaw"); + const globMemSpy = vi.spyOn(memoryBackend, "globInfo"); + const globSkillsSpy = vi.spyOn(skillsBackend, "globInfo"); + + const composite = new CompositeBackend(new StateBackend(stateAndStore), { + "/memory/": memoryBackend, + "/skills/": skillsBackend, + }); + + // grep at a non-matching path should NOT query routed backends + await composite.grepRaw("hello", "/workspace"); + expect(grepMemSpy).not.toHaveBeenCalled(); + expect(grepSkillsSpy).not.toHaveBeenCalled(); + + // glob at a non-matching path should NOT query routed backends + await composite.globInfo("**/*.md", "/workspace"); + expect(globMemSpy).not.toHaveBeenCalled(); + expect(globSkillsSpy).not.toHaveBeenCalled(); + + // grep at "/" should query routed backends (routes are under "/") + await composite.grepRaw("hello", "/"); + expect(grepMemSpy).toHaveBeenCalledTimes(1); + expect(grepSkillsSpy).toHaveBeenCalledTimes(1); + + // glob at "/" should query routed backends + await composite.globInfo("**/*.md", "/"); + expect(globMemSpy).toHaveBeenCalledTimes(1); + expect(globSkillsSpy).toHaveBeenCalledTimes(1); + + // grep targeting a specific route should only search that route + grepMemSpy.mockClear(); + grepSkillsSpy.mockClear(); + await composite.grepRaw("hello", "/memory/"); + expect(grepMemSpy).toHaveBeenCalledTimes(1); + expect(grepSkillsSpy).not.toHaveBeenCalled(); + }); + it("should handle large tool result interception with default route", async () => { const { config } = makeConfig(); const { createFilesystemMiddleware } = await import("../middleware/fs.js"); diff --git a/libs/deepagents/src/backends/composite.ts b/libs/deepagents/src/backends/composite.ts index 33b5e7e03..07f071735 100644 --- a/libs/deepagents/src/backends/composite.ts +++ b/libs/deepagents/src/backends/composite.ts @@ -26,7 +26,6 @@ import { isSandboxBackend } from "./protocol.js"; */ export class CompositeBackend implements BackendProtocol { private default: BackendProtocol; - private routes: Record; private sortedRoutes: Array<[string, BackendProtocol]>; constructor( @@ -34,7 +33,6 @@ export class CompositeBackend implements BackendProtocol { routes: Record, ) { this.default = defaultBackend; - this.routes = routes; // Sort routes by length (longest first) for correct prefix matching this.sortedRoutes = Object.entries(routes).sort( @@ -170,7 +168,8 @@ export class CompositeBackend implements BackendProtocol { } } - // Otherwise, search default and all routed backends and merge + // Path doesn't match any specific route - search default backend + // and only routes whose prefix falls under the search path const allMatches: GrepMatch[] = []; const rawDefault = await this.default.grepRaw(pattern, path, glob); @@ -180,21 +179,22 @@ export class CompositeBackend implements BackendProtocol { allMatches.push(...rawDefault); - // Search all routes - for (const [routePrefix, backend] of Object.entries(this.routes)) { - const raw = await backend.grepRaw(pattern, "/", glob); + const normalizedPath = path.endsWith("/") ? path : path + "/"; + for (const [routePrefix, backend] of this.sortedRoutes) { + if (routePrefix.startsWith(normalizedPath)) { + const raw = await backend.grepRaw(pattern, "/", glob); - if (typeof raw === "string") { - return raw; - } + if (typeof raw === "string") { + return raw; + } - // Add route prefix back - allMatches.push( - ...raw.map((m) => ({ - ...m, - path: routePrefix.slice(0, -1) + m.path, - })), - ); + allMatches.push( + ...raw.map((m) => ({ + ...m, + path: routePrefix.slice(0, -1) + m.path, + })), + ); + } } return allMatches; @@ -204,8 +204,6 @@ export class CompositeBackend implements BackendProtocol { * Structured glob matching returning FileInfo objects. */ async globInfo(pattern: string, path: string = "/"): Promise { - const results: FileInfo[] = []; - // Route based on path, not pattern for (const [routePrefix, backend] of this.sortedRoutes) { if (path.startsWith(routePrefix.replace(/\/$/, ""))) { @@ -220,21 +218,25 @@ export class CompositeBackend implements BackendProtocol { } } - // Path doesn't match any specific route - search default backend AND all routed backends + // Path doesn't match any specific route - search default backend + // and only routes whose prefix falls under the search path + const results: FileInfo[] = []; const defaultInfos = await this.default.globInfo(pattern, path); results.push(...defaultInfos); - for (const [routePrefix, backend] of Object.entries(this.routes)) { - const infos = await backend.globInfo(pattern, "/"); - results.push( - ...infos.map((fi) => ({ - ...fi, - path: routePrefix.slice(0, -1) + fi.path, - })), - ); + const normalizedPath = path.endsWith("/") ? path : path + "/"; + for (const [routePrefix, backend] of this.sortedRoutes) { + if (routePrefix.startsWith(normalizedPath)) { + const infos = await backend.globInfo(pattern, "/"); + results.push( + ...infos.map((fi) => ({ + ...fi, + path: routePrefix.slice(0, -1) + fi.path, + })), + ); + } } - // Deterministic ordering results.sort((a, b) => a.path.localeCompare(b.path)); return results; }