Skip to content

feat(js): add agents backend interface#5451

Draft
Ehesp wants to merge 1 commit into
pj/agents-useChatfrom
eh/agents-backend
Draft

feat(js): add agents backend interface#5451
Ehesp wants to merge 1 commit into
pj/agents-useChatfrom
eh/agents-backend

Conversation

@Ehesp

@Ehesp Ehesp commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

This PR is a POC example of an implementation for RFC #5439. Note, it's demonstrative implementation only for discussion, vs ready code.

This PR introduce a "backend" concept to the agents primitive. How it works:

  1. A new /backends sub-export defines a AgentBackend interface, providing some initial abstractions (ls, read, write etc).
  2. The LocalFilesystemBackend implements this, implementation via Node fs.
  3. The Session interface adds attachBackend and getBackend methods.
  4. When defineAgent is invoked, the session attaches a LocalFilesystemBackend, with a working directory of the current working directory.
  5. The existing middleware filesystem and skills then pulls in the current backend via session context.
  6. Those middlewares then do not depend on Node fs, and instead invoke the backend implementations methods.

Note; this is currently intended to be a drop-in implementation, with no public api being modified.

Potential breaking change

Currently, a user can provide an agent filesystem access (filesystem({ rootDirectory: "/tmp" })). In this PR, the rootDirectory becomes relative to the backend:

new LocalFilesystemBackend({ cwd: '/foo' });

filesystem({ rootDirectory: "/tmp" }) // ---> `/foo/tmp`
skills({ skillPaths: ['./skills'] }) // ---> `/foo/skills`

In my opinion, this is better DX - you define the working directory once for your agent, and subsequent paths are resolved from the parent, vs duplicating the same path across the tools.

Follow up

This PR could be followed up with further PRs to expand functionality:

  1. Customise local fs directory via a backend factory:
ai.defineAgent({
  backend: async (ctx) => new LocalFilesystemBackend({ cwd: process.cwd() + `/.sandbox/${ctx.sessionId}` }),
  // ...
});

Then middleware such as filesystem and skills all use "process.cwd() + /.sandbox/${ctx.sessionId}" as the base directory, allowing for isolation per agent.

  1. Example external sandbox provider (e.g. Daytona) (dummy api):
ai.defineAgent({
  backend: async (ctx) => {
    const daytona = new Daytona();
    const existing = await daytona.get(ctx.sessionId).catch(() => null);
    
    if (existing) {
      await existing.start();
      return new DaytonaBackend({ sandbox: existing, home: '/usr/daytona' })
    }

    const sandbox = await daytona.create();
    return new DaytonaBackend({ sandbox: existing, home: '/usr/daytona' })
  },
  // ...
});

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a pluggable backend abstraction (AgentBackend and LocalFilesystemBackend) for agent file operations, refactoring the filesystem and skills middleware to use this new interface instead of direct Node.js fs calls. Feedback on these changes highlights a cross-platform path resolution issue on Windows where absolute paths with drive letters are not correctly stripped, a potential runtime TypeError in read_file.ts if result.content is undefined, and silent error handling in skills.ts when backend.ls() fails.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +70 to +86
const rootDirectory =
config.rootDirectory.replace(/^[/\\]+/, '') || '.';
const resolveBackendPath = (requestedPath: string) => {
const normalizedPath = requestedPath.replace(/^[/\\]+/, '');
const backendPath = path.normalize(
path.join(rootDirectory, normalizedPath)
);
const relativeToRoot = path.relative(rootDirectory, backendPath);
if (
relativeToRoot === '..' ||
relativeToRoot.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeToRoot)
) {
throw new Error('Access denied: Path is outside of root directory.');
}
return p;
}
return backendPath;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On Windows, absolute paths (e.g., C:\\tmp) are not stripped of their drive letters by replace(/^[/\]+/, ''). This leaves rootDirectory as an absolute path, which subsequently causes LocalFilesystemBackend.resolvePath to throw an 'Access denied' error because the resolved path does not start with the backend's cwd.

Using path.parse to extract and strip the root component provides a robust, cross-platform solution for both Unix and Windows absolute paths.

      const parsedRoot = path.parse(config.rootDirectory).root;
      const rootDirectory =
        (parsedRoot
          ? config.rootDirectory.substring(parsedRoot.length)
          : config.rootDirectory) || '.';
      const resolveBackendPath = (requestedPath: string) => {
        const parsedRequested = path.parse(requestedPath);
        const normalizedPath = parsedRequested.root
          ? requestedPath.substring(parsedRequested.root.length)
          : requestedPath;
        const backendPath = path.normalize(
          path.join(rootDirectory, normalizedPath)
        );
        const relativeToRoot = path.relative(rootDirectory, backendPath);
        if (
          relativeToRoot === '..'
          || relativeToRoot.startsWith('..' + path.sep)
          || path.isAbsolute(relativeToRoot)
        ) {
          throw new Error('Access denied: Path is outside of root directory.');
        }
        return backendPath;
      };
References
  1. In Node.js, use path.sep to split file paths for cross-platform compatibility.


if (isImage && mimeType) {
const buffer = await fs.readFile(targetFile);
const buffer = Buffer.from(result.content as Uint8Array);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If result.content is undefined (for example, if the file is empty or there is an issue), calling Buffer.from(result.content as Uint8Array) will throw a runtime TypeError. Using a fallback like ?? '' ensures safe handling.

Suggested change
const buffer = Buffer.from(result.content as Uint8Array);
const buffer = Buffer.from(result.content ?? '');

Comment on lines +79 to +80
const result = await backend.ls(p);
const files = result.files ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If backend.ls(p) fails, it returns an object containing an error property instead of throwing. Silently ignoring this error will result in skills failing to load without any indication. Throwing an error when result.error is present ensures it is caught by the surrounding try/catch block, preserving the original error-handling behavior.

                const result = await backend.ls(p);
                if (result.error) {
                  throw new Error(result.error);
                }
                const files = result.files ?? [];

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant