sandbox: add CompositeBackend with prefix-based routing

Routes paths to sub-backends by longest matching prefix and translates
paths/FilesUpdate keys across the boundary. Wires SANDBOX_TYPE=composite
into the factory and includes it under hasStatefulSmartAgent so state
persistence still applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Jackie Chui
2026-05-06 11:01:32 -07:00
co-authored by Claude Opus 4.7
parent 5cba8029c4
commit 68e88d8a2c
5 changed files with 560 additions and 1 deletions
@@ -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)
})
})
@@ -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<string, BackendProtocol> = {}) {
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<LsResult> {
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<ReadResult> {
const { backend, prefix } = this.resolve(filePath)
return backend.read(this.translateIn(filePath, prefix), offset, limit)
}
async readRaw(filePath: string): Promise<ReadRawResult> {
const { backend, prefix } = this.resolve(filePath)
return backend.readRaw(this.translateIn(filePath, prefix))
}
async write(filePath: string, content: string | Uint8Array): Promise<WriteResult> {
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<EditResult> {
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<GlobResult> {
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<GrepResult> {
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
}
}
}
@@ -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')
})
})
@@ -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}`)
}
+5 -1
View File
@@ -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[]) ?? []