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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
}
},
"engines": {
"node": "^20",
"node": ">=20.12.0 <21.0.0",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I purposely bumped this so we can use some fs functionality such as fs.readdir(..., { recursive: true }) using recursive and using Dirent.parentPath.

Let me know if we shouldn't do this and I can create a manual function to manually walk the recursive directories instead

"pnpm": "^10.26.0"
},
"resolutions": {
Expand Down
52 changes: 47 additions & 5 deletions packages/components/nodes/agentflow/SmartAgent/SmartAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import zodToJsonSchema from 'zod-to-json-schema'
import { z } from 'zod'
import { PlanningTool, Todo } from './planning/PlanningTool'
import { buildSystemPrompt } from './context/SystemPromptBuilder'
import { createBackend } from './sandbox/factory'
import { buildExecuteTool, buildFsTools } from './sandbox/tools/fs'
import { FileData, ShellBackendProtocol } from './sandbox/BackendProtocol'
import { getErrorMessage } from '../../../src/error'
import { DataSource } from 'typeorm'
import { randomBytes } from 'crypto'
Expand All @@ -35,6 +38,7 @@ import {
revertBase64ImagesToFileRefs,
normalizeMessagesForStorage,
replaceInlineDataWithFileReferences,
rewriteBinaryToolResults,
updateFlowState
} from '../utils'
import {
Expand Down Expand Up @@ -1035,6 +1039,41 @@ class SmartAgent_Agentflow implements INode {
toolNode: { label: 'Planning', name: 'write_todos' }
})

// For StateBackend, runtimeState.files is the persisted snapshot — saved into executionData by the
// agentflow engine when startPersistState=true.
const runtimeState = options.agentflowRuntime?.state as ICommonObject | undefined
const initialFiles = (runtimeState?.files as Record<string, FileData> | undefined) ?? {}
const backend = await createBackend(initialFiles, {
orgId: options.orgId as string | undefined,
chatflowid: options.chatflowid as string | undefined,
chatId: options.chatId as string | undefined
})
const fsTools = buildFsTools(backend, (update) => {
if (!runtimeState) return

// keep runtimeState.files in sync with the backend after each mutation
const existing = (runtimeState.files as Record<string, FileData> | undefined) ?? {}
const merged = { ...existing }
for (const [path, data] of Object.entries(update)) {
if (data === null) {
delete merged[path]
} else {
merged[path] = data
}
}

runtimeState.files = merged
})

toolsInstance.push(...(fsTools as unknown as Tool[]))

// Bind the execute tool when the backend supports it (duck-typed).
const executeEnabled = 'execute' in backend && typeof (backend as { execute?: unknown }).execute === 'function'
if (executeEnabled) {
const executeTool = buildExecuteTool(backend as ShellBackendProtocol)
toolsInstance.push(executeTool as unknown as Tool)
}

if (llmNodeInstance && toolsInstance.length > 0) {
if (llmNodeInstance.bindTools === undefined) {
throw new Error(`Agent needs to have a function calling capable models.`)
Expand Down Expand Up @@ -1076,7 +1115,8 @@ class SmartAgent_Agentflow implements INode {
const systemPrompt = buildSystemPrompt({
todoListPrompt: planner.getSystemPrompt(),
skillsEnabled: false, // TODO: wire to node input
filesystemEnabled: false, // TODO: wire to node input
filesystemEnabled: true,
executeEnabled,
subagentEnabled: false, // TODO: wire to node input
asyncSubagentEnabled: false, // TODO: wire to node input
userSystemPrompt: userSystemParts.join('\n\n') || undefined
Expand Down Expand Up @@ -1233,7 +1273,7 @@ class SmartAgent_Agentflow implements INode {
isLastNode
)
} else {
response = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
response = await llmNodeInstance.invoke(rewriteBinaryToolResults(messages), { signal: abortController?.signal })
}
}

Expand Down Expand Up @@ -1932,7 +1972,9 @@ class SmartAgent_Agentflow implements INode {
let sentLastThinkingEvent = false

try {
for await (const chunk of await llmNodeInstance.stream(messages, { signal: abortController?.signal })) {
for await (const chunk of await llmNodeInstance.stream(rewriteBinaryToolResults(messages), {
signal: abortController?.signal
})) {
if (sseStreamer && !isStructuredOutput) {
let content = ''

Expand Down Expand Up @@ -2481,7 +2523,7 @@ class SmartAgent_Agentflow implements INode {
isLastNode
)
} else {
newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
newResponse = await llmNodeInstance.invoke(rewriteBinaryToolResults(messages), { signal: abortController?.signal })

// Stream non-streaming response if this is the last node
if (isLastNode && sseStreamer && !isStructuredOutput) {
Expand Down Expand Up @@ -2875,7 +2917,7 @@ class SmartAgent_Agentflow implements INode {
isLastNode
)
} else {
newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
newResponse = await llmNodeInstance.invoke(rewriteBinaryToolResults(messages), { signal: abortController?.signal })

// Stream non-streaming response if this is the last node
if (isLastNode && sseStreamer && !isStructuredOutput) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { buildSystemPrompt } from './SystemPromptBuilder'

describe('buildSystemPrompt — executeEnabled gate', () => {
const baseOpts = {
todoListPrompt: '## TODOS_PLACEHOLDER',
filesystemEnabled: true
}

it('omits the Execute Tool block when executeEnabled is false', () => {
const prompt = buildSystemPrompt({ ...baseOpts, executeEnabled: false })
expect(prompt).not.toContain('## Execute Tool')
})

it('omits the Execute Tool block when executeEnabled is undefined', () => {
const prompt = buildSystemPrompt({ ...baseOpts })
expect(prompt).not.toContain('## Execute Tool')
})

it('includes the Execute Tool block when executeEnabled is true', () => {
const prompt = buildSystemPrompt({ ...baseOpts, executeEnabled: true })
expect(prompt).toContain('## Execute Tool')
})

it('orders Filesystem Tools before Execute Tool when both are enabled', () => {
const prompt = buildSystemPrompt({ ...baseOpts, executeEnabled: true })
const fsIdx = prompt.indexOf('## Filesystem Tools')
const execIdx = prompt.indexOf('## Execute Tool')
expect(fsIdx).toBeGreaterThan(-1)
expect(execIdx).toBeGreaterThan(-1)
expect(fsIdx).toBeLessThan(execIdx)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,35 @@ const SKILLS_PROMPT = `## Skills

// Part 4: Filesystem tool prompt
const FILESYSTEM_TOOL_PROMPT = `## Filesystem Tools
// TODO: ls, read_file, write_file usage guidance`

You have access to a sandbox filesystem. All paths must be absolute.

**Path conventions:**
- \`/workspace/\` — working area for files you create or process during this task
- \`/artifacts/\` — outputs intended to be surfaced to the user (reports, generated files, etc.)

**Available tools:**
- \`read_file\` — read a text file; supports \`offset\` and \`limit\` for pagination of large files
- \`write_file\` — create a new text file; **create-only** — errors if the file already exists
- \`edit_file\` — replace a specific string in an existing file; errors if the string appears more than once unless \`replace_all\` is true
- \`list_files\` — list files and directories at a given path
- \`glob_files\` — find files matching a glob pattern (e.g. \`**/*.ts\`) within a base directory
- \`grep_files\` — search file contents for a regex pattern; returns matching lines with file path and line number

**Usage guidance:**
- Prefer \`edit_file\` over re-writing a whole file when making targeted changes — it's faster and less error-prone.
- Use \`glob_files\` to discover files before reading them; use \`grep_files\` to locate specific content across many files.
- Large files: use \`offset\` + \`limit\` on \`read_file\` to paginate rather than reading the entire file at once.`

const EXECUTE_TOOL_PROMPT = `## Execute Tool

You have access to \`execute\` to run shell commands in the sandbox.

- Returns combined stdout and stderr (stderr lines prefixed \`[stderr] \`); \`Exit code: N\` is appended when the command exits non-zero
- Output is truncated at ~100 KB
- Each command times out after 30 seconds
- Chain commands with \`&&\` for sequential steps (e.g. \`cd /workspace && npm i && npm test\`)
- **Paths:** \`/workspace/\`, \`/artifacts/\`, \`/memories/\`, \`/large_tool_results/\`, and \`/conversation_history/\` refer to the sandbox filesystem (same paths the filesystem tools use). Other absolute paths refer to the host.`

// Part 5: Subagent prompt
const SUBAGENT_PROMPT = `## Subagent Delegation
Expand Down Expand Up @@ -116,6 +144,7 @@ export interface SystemPromptOptions {
todoListPrompt: string // Part 2: from PlanningTool
skillsEnabled?: boolean // Part 3
filesystemEnabled?: boolean // Part 4
executeEnabled?: boolean // Part 4b: only when backend supports execute (LocalShellBackend, future remote sandboxes)
subagentEnabled?: boolean // Part 5
asyncSubagentEnabled?: boolean // Part 6
userSystemPrompt?: string // Part 7: user-specified system message / memory (AGENTS.md)
Expand Down Expand Up @@ -150,6 +179,11 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string {
parts.push(FILESYSTEM_TOOL_PROMPT)
}

// Part 4b: Execute tool prompt — when the active backend supports it
if (opts.executeEnabled) {
parts.push(EXECUTE_TOOL_PROMPT)
}

// Part 5: Subagent prompt
if (opts.subagentEnabled) {
parts.push(SUBAGENT_PROMPT)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export interface FileInfo {
name: string
path: string
size: number
isDirectory: boolean
mimeType?: string
}

export interface FileData {
/**
* String-encoded file content. Text files use utf-8; binary files use base64.
* Backends decode base64 to Uint8Array at the read() boundary (see decodeFileContent in utils.ts).
*/
content: string
mimeType: string
created_at: number
modified_at: number
}

// FilesUpdate: returned by StateBackend's write/edit so the tool layer can splice
// changes into SmartAgent graph state. `null` means "externally persisted — skip splice."
export type FilesUpdate = Record<string, FileData | null>

export interface GrepMatch {
path: string
line: number
content: string
}

export type LsResult = { files: FileInfo[] } | { error: string }
export type ReadResult =
| { content: string; mimeType: string; truncated: boolean }
| { content: Uint8Array; mimeType: string }
| { error: string }
export type ReadRawResult = { data: FileData } | { error: string }
export type WriteResult = { path: string; filesUpdate: FilesUpdate | null } | { error: string }
export type EditResult = { path: string; occurrences: number; filesUpdate: FilesUpdate | null } | { error: string }
export type GrepResult = { matches: GrepMatch[]; truncated: boolean } | { error: string }
export type GlobResult = { files: FileInfo[]; truncated: boolean } | { error: string }

export interface BackendProtocol {
ls(path: string): Promise<LsResult>
read(path: string, offset?: number, limit?: number): Promise<ReadResult>
readRaw(path: string): Promise<ReadRawResult>
write(path: string, content: string | Uint8Array): Promise<WriteResult>
edit(path: string, oldStr: string, newStr: string, replaceAll?: boolean): Promise<EditResult>
grep(pattern: string, path?: string | null, glob?: string | null): Promise<GrepResult>
glob(pattern: string, path?: string): Promise<GlobResult>
}

// Default line cap for read() when the LLM doesn't pass one. Backends use this to
// paginate large text files so a single read can't blow the model's context window
// (~500 lines ≈ a few thousand tokens). Matches Claude Code's Read tool default.
export const DEFAULT_READ_LIMIT = 500

// Cap on results returned by listing operations (glob, grep) across all backends.
export const MAX_LIST_OBJECTS = 1_000

export type ExecuteResult = { output: string; exitCode: number; truncated: boolean }

/**
* Extends BackendProtocol with shell command execution.
*/
export interface ShellBackendProtocol extends BackendProtocol {
execute(command: string): Promise<ExecuteResult>
}

// Cap on combined stdout/stderr bytes returned from execute(). Beyond this, the
// output is truncated and ExecuteResult.truncated is set.
export const MAX_OUTPUT_BYTES = 100_000

// Default per-command timeout for LocalShellBackend.execute(). Override via the
// SANDBOX_LOCAL_SHELL_TIMEOUT_MS env var.
export const LOCAL_SHELL_TIMEOUT_DEFAULT_MS = 30_000
Loading