diff --git a/packages/components/nodes/agentflow/SmartAgent/sandbox/backends/CompositeBackend.test.ts b/packages/components/nodes/agentflow/SmartAgent/sandbox/backends/CompositeBackend.test.ts new file mode 100644 index 000000000..6d566c562 --- /dev/null +++ b/packages/components/nodes/agentflow/SmartAgent/sandbox/backends/CompositeBackend.test.ts @@ -0,0 +1,402 @@ +import { CompositeBackend } from './CompositeBackend' +import { StateBackend } from './StateBackend' +import { BackendProtocol } from '../BackendProtocol' + +describe('CompositeBackend constructor', () => { + it('rejects route prefix of "/"', () => { + expect(() => new CompositeBackend(new StateBackend(), { '/': new StateBackend() })).toThrow() + }) + + it('rejects empty-string route prefix', () => { + expect(() => new CompositeBackend(new StateBackend(), { '': new StateBackend() })).toThrow() + }) + + it('normalizes route prefixes to end with trailing slash', async () => { + // Register without trailing slash; path under that mount should still route to the mount. + const inner = new StateBackend({ '/x.md': { content: 'hi', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } }) + const composite = new CompositeBackend(new StateBackend(), { '/a': inner }) + const result = await composite.ls('/a/') + expect('files' in result && result.files.map((f) => f.name)).toEqual(['x.md']) + }) +}) + +describe('CompositeBackend.resolve (via ls)', () => { + it('routes to default when no routes registered', async () => { + const def = new StateBackend({ + '/x.md': { content: 'hi', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, {}) + const result = await composite.ls('/') + expect('files' in result && result.files.map((f) => f.name)).toEqual(['x.md']) + }) + + it('routes path under a single registered prefix to that mount', async () => { + const def = new StateBackend() + const inner = new StateBackend({ + '/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/a/': inner }) + const result = await composite.ls('/a/') + expect('files' in result && result.files.map((f) => f.name)).toEqual(['inside.md']) + }) + + it('selects the longest matching prefix when nested routes match', async () => { + const def = new StateBackend() + const outer = new StateBackend({ + '/wrong.md': { content: 'wrong', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const inner = new StateBackend({ + '/right.md': { content: 'right', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/a/': outer, '/a/b/': inner }) + const result = await composite.ls('/a/b/') + expect('files' in result && result.files.map((f) => f.name)).toEqual(['right.md']) + }) + + it('does not match when prefix would cross a non-slash boundary', async () => { + // Route '/foo/' should NOT match '/foobar/x.md' — slash boundary is required. + const def = new StateBackend({ + '/foobar/x.md': { content: 'def', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const inner = new StateBackend({ + '/x.md': { content: 'inner', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/foo/': inner }) + const result = await composite.ls('/foobar/') + expect('files' in result && result.files.map((f) => f.name)).toEqual(['x.md']) + }) + + it('matches a bare path equal to the prefix without trailing slash', async () => { + // ls('/a') should still route to the '/a/' mount. + const def = new StateBackend() + const inner = new StateBackend({ + '/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/a/': inner }) + const result = await composite.ls('/a') + expect('files' in result && result.files.map((f) => f.name)).toEqual(['inside.md']) + }) +}) + +describe('CompositeBackend.read', () => { + it('passes path through unchanged when routing to default', async () => { + const def = new StateBackend({ + '/x.md': { content: 'hello', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const spy = jest.spyOn(def, 'read') + const composite = new CompositeBackend(def, {}) + const result = await composite.read('/x.md') + expect(spy).toHaveBeenCalledWith('/x.md', undefined, undefined) + expect('content' in result && result.content).toBe('hello') + }) + + it('strips the matched prefix when routing to a mount', async () => { + const inner = new StateBackend({ + '/inside.md': { content: 'mounted', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const spy = jest.spyOn(inner, 'read') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.read('/a/inside.md') + expect(spy).toHaveBeenCalledWith('/inside.md', undefined, undefined) + expect('content' in result && result.content).toBe('mounted') + }) + + it('forwards offset and limit to the sub-backend', async () => { + const inner = new StateBackend({ + '/x.md': { content: 'a\nb\nc\nd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const spy = jest.spyOn(inner, 'read') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + await composite.read('/a/x.md', 1, 2) + expect(spy).toHaveBeenCalledWith('/x.md', 1, 2) + }) + + it('passes error results through unchanged', async () => { + const composite = new CompositeBackend(new StateBackend(), {}) + const result = await composite.read('/missing.md') + expect(result).toMatchObject({ error: expect.stringContaining('/missing.md') }) + }) +}) + +describe('CompositeBackend.readRaw', () => { + it('strips the matched prefix when routing to a mount', async () => { + const inner = new StateBackend({ + '/x.md': { content: 'm', mimeType: 'text/markdown', created_at: 5, modified_at: 6 } + }) + const spy = jest.spyOn(inner, 'readRaw') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.readRaw('/a/x.md') + expect(spy).toHaveBeenCalledWith('/x.md') + expect('data' in result && result.data.content).toBe('m') + }) + + it('passes path through unchanged when routing to default', async () => { + const def = new StateBackend({ + '/x.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const spy = jest.spyOn(def, 'readRaw') + const composite = new CompositeBackend(def, {}) + await composite.readRaw('/x.md') + expect(spy).toHaveBeenCalledWith('/x.md') + }) +}) + +describe('CompositeBackend.write', () => { + it('writes to default and returns the original virtual path', async () => { + const def = new StateBackend() + const composite = new CompositeBackend(def, {}) + const result = await composite.write('/x.md', 'hi') + expect('path' in result && result.path).toBe('/x.md') + // FilesUpdate keys for default come back unchanged + expect('filesUpdate' in result && result.filesUpdate).toMatchObject({ '/x.md': expect.any(Object) }) + }) + + it('strips prefix on dispatch and re-prepends prefix on result.path', async () => { + const inner = new StateBackend() + const writeSpy = jest.spyOn(inner, 'write') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.write('/a/x.md', 'hi') + expect(writeSpy).toHaveBeenCalledWith('/x.md', 'hi') + expect('path' in result && result.path).toBe('/a/x.md') + }) + + it('re-keys FilesUpdate entries with the mount prefix when sub-backend returns a state-shaped update', async () => { + const inner = new StateBackend() + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.write('/a/x.md', 'hi') + expect('filesUpdate' in result).toBe(true) + if ('filesUpdate' in result && result.filesUpdate) { + const keys = Object.keys(result.filesUpdate) + expect(keys).toEqual(['/a/x.md']) + expect(result.filesUpdate['/a/x.md']).toMatchObject({ content: 'hi' }) + } + }) + + it('passes filesUpdate through as null for local-style backends', async () => { + // Stub backend that mimics LocalBackend's null filesUpdate. + const inner: BackendProtocol = { + write: async (p) => ({ path: p, filesUpdate: null }), + read: async () => ({ error: 'unused' }), + readRaw: async () => ({ error: 'unused' }), + edit: async () => ({ error: 'unused' }), + ls: async () => ({ files: [] }), + glob: async () => ({ files: [], truncated: false }), + grep: async () => ({ matches: [], truncated: false }) + } + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.write('/a/x.md', 'hi') + expect('path' in result && result.path).toBe('/a/x.md') + expect('filesUpdate' in result && result.filesUpdate).toBeNull() + }) + + it('passes error results through unchanged', async () => { + const def = new StateBackend({ + '/x.md': { content: 'existing', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, {}) + const result = await composite.write('/x.md', 'second') + expect('error' in result).toBe(true) + }) +}) + +describe('CompositeBackend.edit', () => { + it('strips prefix on dispatch and re-prepends prefix on result.path', async () => { + const inner = new StateBackend({ + '/x.md': { content: 'hello world', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const editSpy = jest.spyOn(inner, 'edit') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.edit('/a/x.md', 'world', 'there') + expect(editSpy).toHaveBeenCalledWith('/x.md', 'world', 'there', false) + expect('path' in result && result.path).toBe('/a/x.md') + expect('occurrences' in result && result.occurrences).toBe(1) + }) + + it('re-keys FilesUpdate entries with the mount prefix', async () => { + const inner = new StateBackend({ + '/x.md': { content: 'hello world', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.edit('/a/x.md', 'world', 'there') + if ('filesUpdate' in result && result.filesUpdate) { + expect(Object.keys(result.filesUpdate)).toEqual(['/a/x.md']) + } else { + throw new Error('expected filesUpdate to be populated') + } + }) + + it('forwards replaceAll flag to the sub-backend', async () => { + const inner = new StateBackend({ + '/x.md': { content: 'a a a', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const editSpy = jest.spyOn(inner, 'edit') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + await composite.edit('/a/x.md', 'a', 'b', true) + expect(editSpy).toHaveBeenCalledWith('/x.md', 'a', 'b', true) + }) + + it('passes error results through unchanged', async () => { + const def = new StateBackend({ + '/x.md': { content: 'no match here', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, {}) + const result = await composite.edit('/x.md', 'absent', 'nope') + expect('error' in result).toBe(true) + }) +}) + +describe('CompositeBackend.ls path translation', () => { + it('re-prepends mount prefix to FileInfo.path entries when listing inside a mount', async () => { + const inner = new StateBackend({ + '/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.ls('/a/') + if (!('files' in result)) throw new Error('expected files') + expect(result.files.map((f) => f.path)).toEqual(['/a/inside.md']) + }) + + it('does NOT synthesize directory entries for registered mounts when listing /', async () => { + const def = new StateBackend({ + '/workspace/foo.md': { content: 'w', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const inner = new StateBackend({ + '/x.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/a/': inner, '/b/': inner }) + const result = await composite.ls('/') + if (!('files' in result)) throw new Error('expected files') + // Only the default backend's root entries — no /a/ or /b/ synthesized. + expect(result.files.map((f) => f.name).sort()).toEqual(['workspace']) + }) + + it('passes default-backend ls results through unchanged', async () => { + const def = new StateBackend({ + '/foo/x.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, {}) + const result = await composite.ls('/foo/') + if (!('files' in result)) throw new Error('expected files') + expect(result.files.map((f) => f.path)).toEqual(['/foo/x.md']) + }) +}) + +describe('CompositeBackend.glob', () => { + it('routes to mount on basePath match and re-prepends prefix to result paths', async () => { + const inner = new StateBackend({ + '/x.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }, + '/y.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const globSpy = jest.spyOn(inner, 'glob') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.glob('*.md', '/a/') + expect(globSpy).toHaveBeenCalledWith('*.md', '/') + if (!('files' in result)) throw new Error('expected files') + expect(result.files.map((f) => f.path).sort()).toEqual(['/a/x.md', '/a/y.md']) + }) + + it('passes default-backend glob through unchanged', async () => { + const def = new StateBackend({ + '/foo.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, {}) + const result = await composite.glob('*.md', '/') + if (!('files' in result)) throw new Error('expected files') + expect(result.files.map((f) => f.path)).toEqual(['/foo.md']) + }) + + it('does NOT cross mount boundaries when globbing from /', async () => { + const def = new StateBackend({ + '/default.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const inner = new StateBackend({ + '/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/a/': inner }) + const result = await composite.glob('**/*.md', '/') + if (!('files' in result)) throw new Error('expected files') + // Only default's match; the /a/ mount's inside.md is invisible. + expect(result.files.map((f) => f.path)).toEqual(['/default.md']) + }) + + it('preserves the truncated flag from the sub-backend', async () => { + const inner: BackendProtocol = { + read: async () => ({ error: 'unused' }), + readRaw: async () => ({ error: 'unused' }), + write: async () => ({ error: 'unused' }), + edit: async () => ({ error: 'unused' }), + ls: async () => ({ files: [] }), + glob: async () => ({ files: [], truncated: true }), + grep: async () => ({ matches: [], truncated: false }) + } + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.glob('*', '/a/') + expect('truncated' in result && result.truncated).toBe(true) + }) + + it('passes error results through unchanged', async () => { + const inner: BackendProtocol = { + read: async () => ({ error: 'unused' }), + readRaw: async () => ({ error: 'unused' }), + write: async () => ({ error: 'unused' }), + edit: async () => ({ error: 'unused' }), + ls: async () => ({ files: [] }), + glob: async () => ({ error: 'boom' }), + grep: async () => ({ matches: [], truncated: false }) + } + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.glob('*', '/a/') + expect('error' in result).toBe(true) + }) +}) + +describe('CompositeBackend.grep', () => { + it('routes to mount on dirPath match and re-prepends prefix to match paths', async () => { + const inner = new StateBackend({ + '/notes.md': { content: 'first line\nfind me\nthird line', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const grepSpy = jest.spyOn(inner, 'grep') + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + const result = await composite.grep('find', '/a/', null) + expect(grepSpy).toHaveBeenCalledWith('find', '/', null) + if (!('matches' in result)) throw new Error('expected matches') + expect(result.matches.map((m) => m.path)).toEqual(['/a/notes.md']) + }) + + it('forwards the glob filter argument to the sub-backend', async () => { + const inner = new StateBackend() + const grepSpy = jest.spyOn(inner, 'grep').mockResolvedValue({ matches: [], truncated: false }) + const composite = new CompositeBackend(new StateBackend(), { '/a/': inner }) + await composite.grep('foo', '/a/', '*.md') + expect(grepSpy).toHaveBeenCalledWith('foo', '/', '*.md') + }) + + it('does NOT cross mount boundaries when grepping from /', async () => { + const def = new StateBackend({ + '/default.md': { content: 'find me', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const inner = new StateBackend({ + '/mounted.md': { content: 'find me', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const composite = new CompositeBackend(def, { '/a/': inner }) + const result = await composite.grep('find', '/', null) + if (!('matches' in result)) throw new Error('expected matches') + expect(result.matches.map((m) => m.path)).toEqual(['/default.md']) + }) + + it('routes to default when dirPath is null', async () => { + const def = new StateBackend({ + '/x.md': { content: 'find me', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + }) + const grepSpy = jest.spyOn(def, 'grep') + const composite = new CompositeBackend(def, { '/a/': new StateBackend() }) + await composite.grep('find', null, null) + expect(grepSpy).toHaveBeenCalledWith('find', '/', null) + }) + + it('passes error results through unchanged', async () => { + const composite = new CompositeBackend(new StateBackend(), {}) + const result = await composite.grep('[invalid', '/', null) + expect('error' in result).toBe(true) + }) +}) diff --git a/packages/components/nodes/agentflow/SmartAgent/sandbox/backends/CompositeBackend.ts b/packages/components/nodes/agentflow/SmartAgent/sandbox/backends/CompositeBackend.ts new file mode 100644 index 000000000..676187b72 --- /dev/null +++ b/packages/components/nodes/agentflow/SmartAgent/sandbox/backends/CompositeBackend.ts @@ -0,0 +1,123 @@ +import { + BackendProtocol, + EditResult, + FilesUpdate, + GlobResult, + GrepResult, + LsResult, + ReadRawResult, + ReadResult, + WriteResult +} from '../BackendProtocol' + +interface Route { + prefix: string + backend: BackendProtocol +} + +export class CompositeBackend implements BackendProtocol { + private default_: BackendProtocol + private routes: Route[] + + constructor(default_: BackendProtocol, routes: Record = {}) { + this.default_ = default_ + this.routes = Object.entries(routes) + .map(([rawPrefix, backend]) => { + if (rawPrefix === '' || rawPrefix === '/') { + throw new Error(`Invalid route prefix: ${JSON.stringify(rawPrefix)}`) + } + const prefix = rawPrefix.endsWith('/') ? rawPrefix : rawPrefix + '/' + return { prefix, backend } + }) + .sort((a, b) => b.prefix.length - a.prefix.length) + } + + private resolve(filePath: string): { backend: BackendProtocol; prefix: string } { + for (const r of this.routes) { + if (filePath.startsWith(r.prefix) || filePath === r.prefix.slice(0, -1)) { + return { backend: r.backend, prefix: r.prefix } + } + } + return { backend: this.default_, prefix: '' } + } + + private translateIn(filePath: string, prefix: string): string { + if (!prefix) return filePath + return '/' + filePath.slice(prefix.length) + } + + private translateOut(subPath: string, prefix: string): string { + if (!prefix) return subPath + return prefix + subPath.slice(1) + } + + private rekeyFilesUpdate(update: FilesUpdate | null, prefix: string): FilesUpdate | null { + if (update === null || !prefix) return update + const rekeyed: FilesUpdate = {} + for (const [key, value] of Object.entries(update)) { + rekeyed[this.translateOut(key, prefix)] = value + } + return rekeyed + } + + async ls(dirPath: string): Promise { + const { backend, prefix } = this.resolve(dirPath) + const result = await backend.ls(this.translateIn(dirPath, prefix)) + if ('error' in result) return result + return { + files: result.files.map((f) => ({ ...f, path: this.translateOut(f.path, prefix) })) + } + } + + async read(filePath: string, offset?: number, limit?: number): Promise { + const { backend, prefix } = this.resolve(filePath) + return backend.read(this.translateIn(filePath, prefix), offset, limit) + } + + async readRaw(filePath: string): Promise { + const { backend, prefix } = this.resolve(filePath) + return backend.readRaw(this.translateIn(filePath, prefix)) + } + + async write(filePath: string, content: string | Uint8Array): Promise { + const { backend, prefix } = this.resolve(filePath) + const result = await backend.write(this.translateIn(filePath, prefix), content) + if ('error' in result) return result + return { + path: this.translateOut(result.path, prefix), + filesUpdate: this.rekeyFilesUpdate(result.filesUpdate, prefix) + } + } + + async edit(filePath: string, oldStr: string, newStr: string, replaceAll = false): Promise { + const { backend, prefix } = this.resolve(filePath) + const result = await backend.edit(this.translateIn(filePath, prefix), oldStr, newStr, replaceAll) + if ('error' in result) return result + return { + path: this.translateOut(result.path, prefix), + occurrences: result.occurrences, + filesUpdate: this.rekeyFilesUpdate(result.filesUpdate, prefix) + } + } + + async glob(pattern: string, basePath = '/'): Promise { + const { backend, prefix } = this.resolve(basePath) + const result = await backend.glob(pattern, this.translateIn(basePath, prefix)) + if ('error' in result) return result + return { + files: result.files.map((f) => ({ ...f, path: this.translateOut(f.path, prefix) })), + truncated: result.truncated + } + } + + async grep(pattern: string, dirPath: string | null = '/', glob: string | null = null): Promise { + const basePath = dirPath ?? '/' + const { backend, prefix } = this.resolve(basePath) + const result = await backend.grep(pattern, this.translateIn(basePath, prefix), glob) + if ('error' in result) return result + return { + matches: result.matches.map((m) => ({ ...m, path: this.translateOut(m.path, prefix) })), + truncated: result.truncated + } + } +} diff --git a/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.test.ts b/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.test.ts index 5b33a8936..2a4190fc3 100644 --- a/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.test.ts +++ b/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.test.ts @@ -203,3 +203,30 @@ describe('createBackend — isolation', () => { if ('content' in readBack) expect(readBack.content).toBe('persisted across messages') }) }) + +describe('createBackend with SANDBOX_TYPE=composite', () => { + afterEach(() => { + // Reset to unset so we don't leak SANDBOX_TYPE into other suites. + // Avoid `process.env.SANDBOX_TYPE = undefined` — that stores the literal string "undefined". + delete process.env.SANDBOX_TYPE + }) + + it('returns a working backend with empty routes (default behaves like StateBackend)', async () => { + process.env.SANDBOX_TYPE = 'composite' + const backend = await createBackend() + const writeResult = await backend.write('/workspace/notes.md', 'hello') + expect('path' in writeResult && writeResult.path).toBe('/workspace/notes.md') + const readResult = await backend.read('/workspace/notes.md') + expect('content' in readResult && readResult.content).toBe('hello') + }) + + it('seeds the default StateBackend from initialFiles', async () => { + process.env.SANDBOX_TYPE = 'composite' + const initial = { + '/workspace/seed.md': { content: 'seeded', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } + } + const backend = await createBackend(initial) + const result = await backend.read('/workspace/seed.md') + expect('content' in result && result.content).toBe('seeded') + }) +}) diff --git a/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.ts b/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.ts index 46be22062..53baf9a4c 100644 --- a/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.ts +++ b/packages/components/nodes/agentflow/SmartAgent/sandbox/factory.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { homedir } from 'node:os' import { randomBytes } from 'node:crypto' import { BackendProtocol, FileData } from './BackendProtocol' +import { CompositeBackend } from './backends/CompositeBackend' import { LocalBackend } from './backends/LocalBackend' import { StateBackend } from './backends/StateBackend' @@ -37,6 +38,8 @@ export async function createBackend( const root = path.join(base, ...buildScopeSegments(scope)) return new LocalBackend(root) } + case 'composite': + return new CompositeBackend(new StateBackend(initialFiles), {}) default: throw new Error(`Unknown SANDBOX_TYPE: ${type}`) } diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index 357ab9740..6c2412c17 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -1620,8 +1620,12 @@ export const executeAgentFlow = async ({ // If the state is persistent, get the state from the previous execution. // SmartAgent with SANDBOX_TYPE=state always requires state persistence for its sandbox filesystem. const startPersistState = nodes.find((node) => node.data.name === 'startAgentflow')?.data.inputs?.startPersistState + const sandboxType = process.env.SANDBOX_TYPE || 'state' + + // NOTE / TODO: currently both sandboxType state and composite needed here so that both are able to have + // persistence on executionData state. But we might remove composite later depending on the usage. const hasStatefulSmartAgent = - nodes.some((node) => node.data.name === 'smartAgentAgentflow') && (process.env.SANDBOX_TYPE || 'state') === 'state' + nodes.some((node) => node.data.name === 'smartAgentAgentflow') && (sandboxType === 'state' || sandboxType === 'composite') if ((startPersistState === true || hasStatefulSmartAgent) && previousExecution) { const previousExecutionData = (JSON.parse(previousExecution.executionData) as IAgentflowExecutedData[]) ?? []