Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-cars-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"deepagents": patch
---

fix(deepagents): CompositeBackend grepRaw/globInfo respect path→backend mapping (#241)
46 changes: 44 additions & 2 deletions libs/deepagents/src/backends/composite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
58 changes: 30 additions & 28 deletions libs/deepagents/src/backends/composite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,13 @@ import { isSandboxBackend } from "./protocol.js";
*/
export class CompositeBackend implements BackendProtocol {
private default: BackendProtocol;
private routes: Record<string, BackendProtocol>;
private sortedRoutes: Array<[string, BackendProtocol]>;

constructor(
defaultBackend: BackendProtocol,
routes: Record<string, BackendProtocol>,
) {
this.default = defaultBackend;
this.routes = routes;

// Sort routes by length (longest first) for correct prefix matching
this.sortedRoutes = Object.entries(routes).sort(
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand All @@ -204,8 +204,6 @@ export class CompositeBackend implements BackendProtocol {
* Structured glob matching returning FileInfo objects.
*/
async globInfo(pattern: string, path: string = "/"): Promise<FileInfo[]> {
const results: FileInfo[] = [];

// Route based on path, not pattern
for (const [routePrefix, backend] of this.sortedRoutes) {
if (path.startsWith(routePrefix.replace(/\/$/, ""))) {
Expand All @@ -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;
}
Expand Down
Loading