diff --git a/libs/langchain/src/agents/middleware/anthropicTools/CommandHandler.ts b/libs/langchain/src/agents/middleware/anthropicTools/CommandHandler.ts new file mode 100644 index 000000000000..38a0da253adc --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/CommandHandler.ts @@ -0,0 +1,168 @@ +import { FileSystem } from "./FileSystem.js"; + +/** + * Executes text editor and memory tool commands against a FileSystem. + * + * This class provides the command execution layer for Anthropic's text_editor and + * memory tools, translating high-level commands (view, create, str_replace, etc.) + * into FileSystem operations. It serves as an abstraction between the tool schemas + * and the underlying storage implementation. + * + * The CommandHandler is used by both state-based (LangGraph) and filesystem-based + * middleware implementations, enabling consistent command handling across different + * storage backends. + * + * ## Supported Commands + * + * ### Text Editor and Memory Tools + * - **view**: Display file contents with line numbers or list directory contents + * - **create**: Create a new file or overwrite an existing file + * - **str_replace**: Replace a string occurrence within a file + * - **insert**: Insert text at a specific line number + * + * ### Memory Tool Only + * - **delete**: Delete a file or directory + * - **rename**: Rename or move a file/directory to a new path + * + * @example + * ```ts + * const fileSystem = new StateFileSystem(files, allowedPrefixes, onUpdate); + * const handler = new CommandHandler(fileSystem); + * + * // View file contents + * const contents = await handler.handleViewCommand("/path/to/file.txt"); + * + * // Replace string in file + * await handler.handleStrReplaceCommand( + * "/path/to/file.txt", + * "old text", + * "new text" + * ); + * ``` + * + * @see {@link FileSystem} for the underlying storage interface + * @see {@link TextEditorCommandSchema} for text editor command schemas + * @see {@link MemoryCommandSchema} for memory command schemas + */ +export class CommandHandler { + /** + * Creates a new CommandHandler instance. + * @param fileSystem - The FileSystem implementation to execute commands against + */ + constructor(private fileSystem: FileSystem) {} + + /** + * Handle view command - shows file contents or directory listing. + */ + async handleViewCommand(path: string): Promise { + const normalizedPath = this.fileSystem.validatePath(path); + const fileData = await this.fileSystem.readFile(normalizedPath); + + if (!fileData) { + // Try listing as directory + const matching = await this.fileSystem.listDirectory(normalizedPath); + if (matching.length > 0) { + return matching.join("\n"); + } + throw new Error(`File not found: ${path}`); + } + + const lines = fileData.content.split("\n"); + const formattedLines = lines.map((line, i) => `${i + 1}|${line}`); + return formattedLines.join("\n"); + } + + /** + * Handle create command - creates or overwrites a file. + */ + async handleCreateCommand(path: string, fileText: string): Promise { + const normalizedPath = this.fileSystem.validatePath(path); + const existing = await this.fileSystem.readFile(normalizedPath); + const now = new Date().toISOString(); + + await this.fileSystem.writeFile(normalizedPath, { + content: fileText, + created_at: existing ? existing.created_at : now, + modified_at: now, + }); + + return `File created: ${path}`; + } + + /** + * Handle str_replace command - replaces a string in a file. + */ + async handleStrReplaceCommand( + path: string, + oldStr: string, + newStr: string + ): Promise { + const normalizedPath = this.fileSystem.validatePath(path); + const fileData = await this.fileSystem.readFile(normalizedPath); + if (!fileData) throw new Error(`File not found: ${path}`); + + if (!fileData.content.includes(oldStr)) { + throw new Error(`String not found in file: ${oldStr}`); + } + + const newContent = fileData.content.replace(oldStr, newStr); + await this.fileSystem.writeFile(normalizedPath, { + content: newContent, + created_at: fileData.created_at, + modified_at: new Date().toISOString(), + }); + + return `String replaced in file: ${path}`; + } + + /** + * Handle insert command - inserts text at a specific line. + */ + async handleInsertCommand( + path: string, + insertLine: number, + textToInsert: string + ): Promise { + const normalizedPath = this.fileSystem.validatePath(path); + const fileData = await this.fileSystem.readFile(normalizedPath); + if (!fileData) throw new Error(`File not found: ${path}`); + + const lines = fileData.content.split("\n"); + const newLines = textToInsert.split("\n"); + const updatedLines = [ + ...lines.slice(0, insertLine), + ...newLines, + ...lines.slice(insertLine), + ]; + + await this.fileSystem.writeFile(normalizedPath, { + content: updatedLines.join("\n"), + created_at: fileData.created_at, + modified_at: new Date().toISOString(), + }); + + return `Text inserted in file: ${path}`; + } + + /** + * Handle delete command - deletes a file or directory. + */ + async handleDeleteCommand(path: string): Promise { + const normalizedPath = this.fileSystem.validatePath(path); + await this.fileSystem.deleteFile(normalizedPath); + return `File deleted: ${path}`; + } + + /** + * Handle rename command - renames/moves a file or directory. + */ + async handleRenameCommand(oldPath: string, newPath: string): Promise { + const normalizedOld = this.fileSystem.validatePath(oldPath); + const normalizedNew = this.fileSystem.validatePath(newPath); + const fileData = await this.fileSystem.readFile(normalizedOld); + if (!fileData) throw new Error(`File not found: ${oldPath}`); + + await this.fileSystem.renameFile(normalizedOld, normalizedNew, fileData); + return `File renamed: ${oldPath} -> ${newPath}`; + } +} diff --git a/libs/langchain/src/agents/middleware/anthropicTools/FileData.ts b/libs/langchain/src/agents/middleware/anthropicTools/FileData.ts new file mode 100644 index 000000000000..d991388fb775 --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/FileData.ts @@ -0,0 +1,12 @@ +import z from "zod"; + +/** + * Zod schema for file data. + */ +export const FileDataSchema = z.object({ + content: z.string(), + created_at: z.string(), + modified_at: z.string(), +}); + +export type FileData = z.infer; diff --git a/libs/langchain/src/agents/middleware/anthropicTools/FileSystem.ts b/libs/langchain/src/agents/middleware/anthropicTools/FileSystem.ts new file mode 100644 index 000000000000..1f02f3d67c3b --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/FileSystem.ts @@ -0,0 +1,57 @@ +import { FileData } from "./FileData.js"; + +/** + * Abstract interface for file system operations. + * Supports both state-based (LangGraph) and physical filesystem implementations. + */ +export interface FileSystem { + /** + * Read a file's contents and metadata. + * @param path - Normalized path to the file + * @returns FileData if file exists, null if not found or is a directory + */ + readFile(path: string): Promise; + + /** + * List files in a directory. + * @param path - Normalized path to the directory + * @returns Array of file paths in the directory + */ + listDirectory(path: string): Promise; + + /** + * Write a file with content and metadata. + * @param path - Normalized path to write + * @param data - File data to write + * @returns Result with message and optional state updates + */ + writeFile(path: string, data: FileData): Promise; + + /** + * Delete a file or directory. + * @param path - Normalized path to delete + * @returns Result with message and optional state updates + */ + deleteFile(path: string): Promise; + + /** + * Rename/move a file or directory. + * @param oldPath - Normalized source path + * @param newPath - Normalized destination path + * @param existingData - File data to move + * @returns Result with message and optional state updates + */ + renameFile( + oldPath: string, + newPath: string, + existingData: FileData + ): Promise; + + /** + * Validate and normalize a file path. + * @param path - Path to validate + * @returns Normalized path + * @throws Error if path is invalid + */ + validatePath(path: string): string; +} diff --git a/libs/langchain/src/agents/middleware/anthropicTools/PhysicalFileSystem.ts b/libs/langchain/src/agents/middleware/anthropicTools/PhysicalFileSystem.ts new file mode 100644 index 000000000000..5fc9bc5dba61 --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/PhysicalFileSystem.ts @@ -0,0 +1,202 @@ +import { + mkdir, + readFile, + readdir, + rename, + rm, + stat, + unlink, + writeFile, +} from "fs/promises"; +import { existsSync, mkdirSync } from "fs"; +import * as path from "path"; +import { FileData } from "./FileData.js"; +import { FileSystem } from "./FileSystem.js"; + +/** + * Physical filesystem implementation. + * Uses Node.js fs module for actual file I/O. + */ +export class PhysicalFileSystem implements FileSystem { + private resolvedRootPath: string; + private maxFileSizeBytes: number; + + constructor( + rootPath: string, + private allowedPrefixes: string[], + maxFileSizeMb: number + ) { + this.resolvedRootPath = path.resolve(rootPath); + this.maxFileSizeBytes = maxFileSizeMb * 1024 * 1024; + + // Create root directory if it doesn't exist + if (!existsSync(this.resolvedRootPath)) { + mkdirSync(this.resolvedRootPath, { recursive: true }); + } + } + + async readFile(virtualPath: string): Promise { + const fullPath = this.resolveVirtualPath(virtualPath); + + try { + const stats = await stat(fullPath); + + if (!stats.isFile()) { + return null; + } + + if (stats.size > this.maxFileSizeBytes) { + const maxMb = this.maxFileSizeBytes / 1024 / 1024; + throw new Error(`File too large: ${virtualPath} exceeds ${maxMb}MB`); + } + + const content = await readFile(fullPath, "utf8"); + + return { + content, + created_at: stats.birthtime.toISOString(), + modified_at: stats.mtime.toISOString(), + }; + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return null; + } + throw error; + } + } + + async listDirectory(virtualPath: string): Promise { + const fullPath = this.resolveVirtualPath(virtualPath); + + try { + const stats = await stat(fullPath); + + if (!stats.isDirectory()) { + return []; + } + + // This is a simple implementation - could be enhanced to match state behavior + const entries = await readdir(fullPath); + return entries.map((name) => { + const vPath = virtualPath.endsWith("/") + ? `${virtualPath}${name}` + : `${virtualPath}/${name}`; + return vPath; + }); + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } + } + + async writeFile(virtualPath: string, data: FileData): Promise { + const fullPath = this.resolveVirtualPath(virtualPath); + + const dir = path.dirname(fullPath); + await mkdir(dir, { recursive: true }); + + // Ensure content ends with newline (Unix text file convention) + const contentToWrite = data.content.endsWith("\n") + ? data.content + : `${data.content}\n`; + await writeFile(fullPath, contentToWrite, "utf8"); + } + + async deleteFile(virtualPath: string): Promise { + const fullPath = this.resolveVirtualPath(virtualPath); + + try { + const stats = await stat(fullPath); + + if (stats.isFile()) { + await unlink(fullPath); + } else if (stats.isDirectory()) { + await rm(fullPath, { recursive: true }); + } + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + // File doesn't exist, nothing to delete + return; + } + throw error; + } + } + + async renameFile( + oldVirtualPath: string, + newVirtualPath: string, + _existingData: FileData + ): Promise { + const oldFull = this.resolveVirtualPath(oldVirtualPath); + const newFull = this.resolveVirtualPath(newVirtualPath); + + // Ensure the old file exists + const stats = await stat(oldFull); + if (!stats.isFile() && !stats.isDirectory()) { + throw new Error(`File not found: ${oldVirtualPath}`); + } + + // Create parent directory for the new path if it doesn't exist + const dir = path.dirname(newFull); + await mkdir(dir, { recursive: true }); + + await rename(oldFull, newFull); + } + + validatePath(virtualPath: string): string { + // Just normalize the virtual path + let normalized = virtualPath; + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + + // Check for path traversal + if (normalized.includes("..") || normalized.includes("~")) { + throw new Error("Path traversal not allowed"); + } + + // Check allowed prefixes + const allowed = this.allowedPrefixes.some((prefix) => + normalized.startsWith(prefix) + ); + if (!allowed) { + throw new Error( + `Path must start with one of: ${JSON.stringify(this.allowedPrefixes)}` + ); + } + + return normalized; + } + + /** + * Convert virtual path to filesystem path and validate. + */ + private resolveVirtualPath(virtualPath: string): string { + const relative = virtualPath.slice(1); // Remove leading / + const fullPath = path.resolve(this.resolvedRootPath, relative); + + // Ensure path is within root + if (!fullPath.startsWith(this.resolvedRootPath)) { + throw new Error(`Path outside root directory: ${virtualPath}`); + } + + return fullPath; + } +} diff --git a/libs/langchain/src/agents/middleware/anthropicTools/StateFileSystem.ts b/libs/langchain/src/agents/middleware/anthropicTools/StateFileSystem.ts new file mode 100644 index 000000000000..626db406e8da --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/StateFileSystem.ts @@ -0,0 +1,92 @@ +import { FileData } from "./FileData.js"; +import { FileSystem } from "./FileSystem.js"; +import { normalize } from "node:path"; + +/** + * State-based file system implementation. + * Uses LangGraph state for storage. + */ +export class StateFileSystem implements FileSystem { + constructor( + private files: Record, + private allowedPrefixes: string[] | undefined, + private updateFiles: (files: Record) => void + ) {} + + async readFile(path: string): Promise { + return this.files[path] || null; + } + + async listDirectory(path: string): Promise { + // Ensure path ends with / for directory matching + const dir = path.endsWith("/") ? path : `${path}/`; + + const matchingFiles: string[] = []; + for (const filePath of Object.keys(this.files)) { + if (filePath.startsWith(dir)) { + // Get relative path from directory + const relative = filePath.slice(dir.length); + // Only include direct children (no subdirectories) + if (!relative.includes("/")) { + matchingFiles.push(filePath); + } + } + } + + return matchingFiles.sort(); + } + + async writeFile(path: string, data: FileData): Promise { + this.updateFiles({ [path]: data }); + } + + async deleteFile(path: string): Promise { + this.updateFiles({ [path]: null }); + } + + async renameFile( + oldPath: string, + newPath: string, + existingData: FileData + ): Promise { + const now = new Date().toISOString(); + this.updateFiles({ + [oldPath]: null, + [newPath]: { ...existingData, modified_at: now }, + }); + } + + validatePath(path: string): string { + // Reject paths with traversal attempts + if (path.includes("..") || path.startsWith("~")) { + throw new Error(`Path traversal not allowed: ${path}`); + } + + // Normalize path (resolve ., //, etc.) + let normalized = normalize(path); + + // Convert to forward slashes for consistency + normalized = normalized.replace(/\\/g, "/"); + + // Ensure path starts with / + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + + // Check allowed prefixes if specified + if (this.allowedPrefixes !== undefined && this.allowedPrefixes.length > 0) { + const allowed = this.allowedPrefixes.some((prefix) => + normalized.startsWith(prefix) + ); + if (!allowed) { + throw new Error( + `Path must start with one of ${JSON.stringify( + this.allowedPrefixes + )}: ${path}` + ); + } + } + + return normalized; + } +} diff --git a/libs/langchain/src/agents/middleware/anthropicTools/anthropicCommandSchemas.ts b/libs/langchain/src/agents/middleware/anthropicTools/anthropicCommandSchemas.ts new file mode 100644 index 000000000000..b93a229d2b47 --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/anthropicCommandSchemas.ts @@ -0,0 +1,80 @@ +import z from "zod"; + +const ViewCommandSchema = z.object({ + command: z.literal("view"), + path: z.string().describe("Path to the file or directory to view"), + view_range: z + .tuple([z.number(), z.number()]) + .optional() + .describe( + "Optional line range to view [start, end]. Only applies to files, not directories." + ), +}); + +const CreateCommandSchema = z.object({ + command: z.literal("create"), + path: z.string().describe("Path where the new file should be created"), + file_text: z.string().describe("Content to write to the new file"), +}); + +const StrReplaceCommandSchema = z.object({ + command: z.literal("str_replace"), + path: z.string().describe("Path to the file to modify"), + old_str: z + .string() + .describe("Text to replace (must match exactly, including whitespace)"), + new_str: z.string().describe("New text to insert in place of old text"), +}); + +const TextEditorInsertCommandSchema = z.object({ + command: z.literal("insert"), + path: z.string().describe("Path to the file to modify"), + insert_line: z + .number() + .describe("Line number after which to insert text (0 for beginning)"), + new_str: z.string().describe("Text to insert"), +}); + +const MemoryInsertCommandSchema = z.object({ + command: z.literal("insert"), + path: z.string().describe("Path to the file to modify"), + insert_line: z + .number() + .describe("Line number after which to insert text (0 for beginning)"), + insert_text: z.string().describe("Text to insert"), +}); + +const DeleteCommandSchema = z.object({ + command: z.literal("delete"), + path: z.string().describe("Path to the file or directory to delete"), +}); + +const RenameCommandSchema = z.object({ + command: z.literal("rename"), + old_path: z.string().describe("Current path of the file/directory"), + new_path: z.string().describe("New path for the file/directory"), +}); + +/** + * Text editor tool commands (text_editor_20250728). + * Supports: view, create, str_replace, insert + */ +export const TextEditorCommandSchema = z.discriminatedUnion("command", [ + ViewCommandSchema, + CreateCommandSchema, + StrReplaceCommandSchema, + TextEditorInsertCommandSchema, +]); + +/** + * Memory tool commands (memory_20250818). + * Supports: view, create, str_replace, insert, delete, rename + */ +export const MemoryCommandSchema = z.discriminatedUnion("command", [ + ViewCommandSchema, + CreateCommandSchema, + StrReplaceCommandSchema, + MemoryInsertCommandSchema, + DeleteCommandSchema, + RenameCommandSchema, +]); diff --git a/libs/langchain/src/agents/middleware/anthropicTools/index.ts b/libs/langchain/src/agents/middleware/anthropicTools/index.ts new file mode 100644 index 000000000000..9d9c9fad0338 --- /dev/null +++ b/libs/langchain/src/agents/middleware/anthropicTools/index.ts @@ -0,0 +1,532 @@ +/** + * Anthropic text editor and memory tool middleware. + * + * This module provides client-side implementations of Anthropic's text editor and + * memory tools using proper tool definitions with providerToolDefinition. + */ + +import { ToolMessage } from "@langchain/core/messages"; +import { tool } from "@langchain/core/tools"; +import { Command, getCurrentTaskInput } from "@langchain/langgraph"; +import * as path from "node:path"; +import { z } from "zod"; +import { createMiddleware } from "../../index.js"; +import { CommandHandler } from "./CommandHandler.js"; +import { FileData, FileDataSchema } from "./FileData.js"; +import { PhysicalFileSystem } from "./PhysicalFileSystem.js"; +import { StateFileSystem } from "./StateFileSystem.js"; +import { + TextEditorCommandSchema, + MemoryCommandSchema, +} from "./anthropicCommandSchemas.js"; +import { ModelRequest } from "../../nodes/types.js"; +import { AgentBuiltInState } from "../../runtime.js"; + +// Tool type constants +export const TEXT_EDITOR_TOOL_TYPE = "text_editor_20250728"; +export const TEXT_EDITOR_TOOL_NAME = "str_replace_based_edit_tool"; +export const MEMORY_TOOL_TYPE = "memory_20250818"; +export const MEMORY_TOOL_NAME = "memory"; + +export const MEMORY_SYSTEM_PROMPT = `IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE \ +DOING ANYTHING ELSE. +MEMORY PROTOCOL: +1. Use the \`view\` command of your \`memory\` tool to check for earlier progress. +2. ... (work on the task) ... + - As you make progress, record status / progress / thoughts etc in your memory. +ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk \ +losing any progress that is not recorded in your memory directory.`; + +/** + * Custom reducer that merges file updates. + * @param left - Existing files dict + * @param right - New files dict to merge (null values delete files) + * @returns Merged dict where right overwrites left for matching keys + */ +export function filesReducer( + left: Record, + right: Record +): Record { + // Merge, filtering out null values (deletions) + const result = { ...left }; + for (const [k, v] of Object.entries(right)) { + if (v === null) { + delete result[k]; + } else { + result[k] = v; + } + } + return result; +} + +/** + * State schema for Anthropic text editor and memory tools. + */ +export interface AnthropicToolsState { + /** Virtual file system for text editor tools */ + text_editor_files?: Record; + /** Virtual file system for memory tools */ + memory_files?: Record; +} + +/** + * Re-export FileData type for external use + */ +export type { FileData } from "./FileData.js"; + +/** + * Zod state schemas for middleware with registered reducers. + * Uses withLangGraph to attach filesReducer (JavaScript equivalent of Python's Annotated). + * The reducer handles file updates and deletions (null values remove files). + */ +const TextEditorStateSchema = z.object({ + text_editor_files: z.record(z.string(), FileDataSchema).default(() => ({})), +}); + +const MemoryStateSchema = z.object({ + memory_files: z.record(z.string(), FileDataSchema).default(() => ({})), +}); + +/** + * Handle text editor tool commands. + * Supports: view, create, str_replace, insert + */ +async function handleTextEditorCommand( + commandHandler: CommandHandler, + args: z.infer +): Promise { + switch (args.command) { + case "view": + return commandHandler.handleViewCommand(args.path); + + case "create": + return commandHandler.handleCreateCommand(args.path, args.file_text); + + case "str_replace": + return commandHandler.handleStrReplaceCommand( + args.path, + args.old_str, + args.new_str + ); + + case "insert": + return commandHandler.handleInsertCommand( + args.path, + args.insert_line, + args.new_str + ); + + default: + throw new Error( + `Unknown command: ${(args as { command?: string }).command}` + ); + } +} + +/** + * Handle memory tool commands. + * Supports: view, create, str_replace, insert, delete, rename + */ +async function handleMemoryCommand( + commandHandler: CommandHandler, + args: z.infer +): Promise { + switch (args.command) { + case "view": + return commandHandler.handleViewCommand(args.path); + + case "create": + return commandHandler.handleCreateCommand(args.path, args.file_text); + + case "str_replace": + return commandHandler.handleStrReplaceCommand( + args.path, + args.old_str, + args.new_str + ); + + case "insert": + return commandHandler.handleInsertCommand( + args.path, + args.insert_line, + args.insert_text + ); + + case "delete": + return commandHandler.handleDeleteCommand(args.path); + + case "rename": + return commandHandler.handleRenameCommand(args.old_path, args.new_path); + + default: + throw new Error( + `Unknown command: ${(args as { command?: string }).command}` + ); + } +} + +/** + * State-based text editor tool middleware. + * + * Provides Anthropic's text_editor tool using LangGraph state for storage. + * Files persist for the conversation thread. + * + * @example + * ```ts + * import { createAgent } from "langchain/agents"; + * import { createStateClaudeTextEditorMiddleware } from "langchain/agents/middleware"; + * + * const agent = createAgent({ + * model, + * tools: [], + * middleware: [createStateClaudeTextEditorMiddleware()], + * }); + * ``` + */ +export function createStateClaudeTextEditorMiddleware(options?: { + allowedPathPrefixes?: string[]; +}) { + const allowedPrefixes = options?.allowedPathPrefixes || ["/"]; + + return createMiddleware({ + name: "StateClaudeTextEditorMiddleware", + stateSchema: TextEditorStateSchema, + tools: [ + tool( + async (args, c) => { + const state = + getCurrentTaskInput>(c); + try { + let files = state.text_editor_files; + const fileSystem = new StateFileSystem( + state.text_editor_files, + allowedPrefixes, + (update) => { + files = filesReducer(files, update); + } + ); + const commandHandler = new CommandHandler(fileSystem); + const message = await handleTextEditorCommand(commandHandler, args); + + return new Command({ + update: { + messages: [ + new ToolMessage({ + content: message, + tool_call_id: c.toolCall?.id, + name: TEXT_EDITOR_TOOL_NAME, + }), + ], + ...(files === state.text_editor_files + ? {} + : { text_editor_files: files }), + }, + }); + } catch (error) { + return new ToolMessage({ + content: String(error), + tool_call_id: c.toolCall?.id, + name: TEXT_EDITOR_TOOL_NAME, + status: "error", + }); + } + }, + { + name: TEXT_EDITOR_TOOL_NAME, + description: + "Anthropic text editor tool (client-side implementation)", + schema: TextEditorCommandSchema, + providerToolDefinition: { + type: TEXT_EDITOR_TOOL_TYPE, + name: TEXT_EDITOR_TOOL_NAME, + }, + } + ), + ], + }); +} + +/** + * State-based memory tool middleware. + * + * Provides Anthropic's memory tool using LangGraph state for storage. + * Files persist for the conversation thread. Enforces /memories prefix + * and injects Anthropic's recommended system prompt. + * + * @example + * ```ts + * import { createAgent } from "langchain/agents"; + * import { createStateClaudeMemoryMiddleware } from "langchain/agents/middleware"; + * + * const agent = createAgent({ + * model, + * tools: [], + * middleware: [createStateClaudeMemoryMiddleware()], + * }); + * ``` + */ +export function createStateClaudeMemoryMiddleware(options?: { + allowedPathPrefixes?: string[]; + systemPrompt?: string; +}) { + const allowedPrefixes = options?.allowedPathPrefixes || ["/memories"]; + const systemPrompt = + options?.systemPrompt !== undefined + ? options.systemPrompt + : MEMORY_SYSTEM_PROMPT; + + return createMiddleware({ + name: "StateClaudeMemoryMiddleware", + stateSchema: MemoryStateSchema, + tools: [ + tool( + async (args, c) => { + const state = + getCurrentTaskInput>(c); + try { + const updates: Record = {}; + const fileSystem = new StateFileSystem( + state.memory_files, + allowedPrefixes, + (files) => { + Object.assign(updates, files); + } + ); + const commandHandler = new CommandHandler(fileSystem); + const message = await handleMemoryCommand(commandHandler, args); + + return new Command({ + update: { + messages: [ + new ToolMessage({ + content: message, + tool_call_id: c.toolCall?.id, + name: MEMORY_TOOL_NAME, + }), + ], + memory_files: filesReducer(state.memory_files, updates), + }, + }); + } catch (error) { + return new ToolMessage({ + content: String(error), + tool_call_id: c.toolCall?.id, + name: MEMORY_TOOL_NAME, + status: "error", + }); + } + }, + { + name: MEMORY_TOOL_NAME, + description: "Anthropic memory tool (client-side implementation)", + schema: MemoryCommandSchema, + providerToolDefinition: { + type: MEMORY_TOOL_TYPE, + name: MEMORY_TOOL_NAME, + }, + } + ), + ], + wrapModelCall: async (request, handler) => { + return handler(updateMemoryRequest(systemPrompt, request)); + }, + }); +} + +/** + * Filesystem-based text editor tool middleware. + * + * Provides Anthropic's text_editor tool using local filesystem for storage. + * User handles persistence via volumes, git, or other mechanisms. + * + * @example + * ```ts + * import { createAgent } from "langchain/agents"; + * import { createFilesystemClaudeTextEditorMiddleware } from "langchain/agents/middleware"; + * + * const agent = createAgent({ + * model, + * tools: [], + * middleware: [ + * createFilesystemClaudeTextEditorMiddleware({ rootPath: "/workspace" }) + * ], + * }); + * ``` + */ +export function createFilesystemClaudeTextEditorMiddleware(options: { + rootPath: string; + allowedPrefixes?: string[]; + maxFileSizeMb?: number; +}) { + const resolvedRootPath = path.resolve(options.rootPath); + const maxFileSizeMb = options.maxFileSizeMb || 10; + const allowedPrefixes = options.allowedPrefixes || ["/"]; + + const fileSystem = new PhysicalFileSystem( + resolvedRootPath, + allowedPrefixes, + maxFileSizeMb + ); + + return createMiddleware({ + name: "FilesystemClaudeTextEditorMiddleware", + stateSchema: undefined, + tools: [ + tool( + async (args, c) => { + try { + const commandHandler = new CommandHandler(fileSystem); + const message = await handleTextEditorCommand(commandHandler, args); + + return new Command({ + update: { + messages: [ + new ToolMessage({ + content: message, + tool_call_id: c.toolCall?.id, + name: TEXT_EDITOR_TOOL_NAME, + }), + ], + }, + }); + } catch (error) { + return new ToolMessage({ + content: String(error), + tool_call_id: c.toolCall?.id, + name: TEXT_EDITOR_TOOL_NAME, + status: "error", + }); + } + }, + { + name: TEXT_EDITOR_TOOL_NAME, + description: "Anthropic text editor tool (filesystem-based)", + schema: TextEditorCommandSchema, + providerToolDefinition: { + type: TEXT_EDITOR_TOOL_TYPE, + name: TEXT_EDITOR_TOOL_NAME, + }, + } + ), + ], + }); +} + +/** + * Filesystem-based memory tool middleware. + * + * Provides Anthropic's memory tool using local filesystem for storage. + * User handles persistence via volumes, git, or other mechanisms. + * Enforces /memories prefix and injects Anthropic's recommended system prompt. + * + * @example + * ```ts + * import { createAgent } from "langchain/agents"; + * import { createFilesystemClaudeMemoryMiddleware } from "langchain/agents/middleware"; + * + * const agent = createAgent({ + * model, + * tools: [], + * middleware: [ + * createFilesystemClaudeMemoryMiddleware({ rootPath: "/workspace" }) + * ], + * }); + * ``` + */ +export function createFilesystemClaudeMemoryMiddleware(options: { + rootPath: string; + allowedPrefixes?: string[]; + maxFileSizeMb?: number; + systemPrompt?: string; +}) { + const resolvedRootPath = path.resolve(options.rootPath); + const maxFileSizeMb = options.maxFileSizeMb || 10; + const allowedPrefixes = options.allowedPrefixes || ["/memories"]; + const systemPrompt = + options.systemPrompt !== undefined + ? options.systemPrompt + : MEMORY_SYSTEM_PROMPT; + + const fileSystem = new PhysicalFileSystem( + resolvedRootPath, + allowedPrefixes, + maxFileSizeMb + ); + + return createMiddleware({ + name: "FilesystemClaudeMemoryMiddleware", + stateSchema: undefined, + tools: [ + tool( + async (args, c) => { + try { + const commandHandler = new CommandHandler(fileSystem); + const message = await handleMemoryCommand(commandHandler, args); + + return new Command({ + update: { + messages: [ + new ToolMessage({ + content: message, + tool_call_id: c.toolCall?.id, + name: MEMORY_TOOL_NAME, + }), + ], + }, + }); + } catch (error) { + return new ToolMessage({ + content: String(error), + tool_call_id: c.toolCall?.id, + name: MEMORY_TOOL_NAME, + status: "error", + }); + } + }, + { + name: MEMORY_TOOL_NAME, + description: "Anthropic memory tool (filesystem-based)", + schema: MemoryCommandSchema, + providerToolDefinition: { + type: MEMORY_TOOL_TYPE, + name: MEMORY_TOOL_NAME, + }, + } + ), + ], + wrapModelCall: async (request, handler) => { + return handler(updateMemoryRequest(systemPrompt, request)); + }, + }); +} + +/** + * Update memory request with the system prompt and headers. + * + * @param systemPrompt The system prompt to inject + * @param request The request to modify + * @returns Modified request + */ +function updateMemoryRequest( + systemPrompt: string, + request: ModelRequest +): ModelRequest { + return { + ...request, + + // Inject system prompt if provided + systemPrompt: systemPrompt + ? request.systemPrompt + ? `${request.systemPrompt}\n\n${systemPrompt}` + : systemPrompt + : request.systemPrompt, + + modelSettings: { + ...request.modelSettings, + headers: { + ...(request.modelSettings?.headers || {}), + "anthropic-beta": "context-management-2025-06-27", + }, + }, + }; +} diff --git a/libs/langchain/src/agents/middleware/index.ts b/libs/langchain/src/agents/middleware/index.ts index a0ead3042302..f6f32f496ce9 100644 --- a/libs/langchain/src/agents/middleware/index.ts +++ b/libs/langchain/src/agents/middleware/index.ts @@ -44,3 +44,17 @@ export { export { modelFallbackMiddleware } from "./modelFallback.js"; export { type AgentMiddleware } from "./types.js"; export { countTokensApproximately } from "./utils.js"; +export { + createStateClaudeTextEditorMiddleware, + createStateClaudeMemoryMiddleware, + createFilesystemClaudeTextEditorMiddleware, + createFilesystemClaudeMemoryMiddleware, + TEXT_EDITOR_TOOL_TYPE, + TEXT_EDITOR_TOOL_NAME, + MEMORY_TOOL_TYPE, + MEMORY_TOOL_NAME, + MEMORY_SYSTEM_PROMPT, + filesReducer, + type FileData, + type AnthropicToolsState, +} from "./anthropicTools/index.js"; diff --git a/libs/langchain/src/agents/middleware/tests/anthropicTools.int.test.ts b/libs/langchain/src/agents/middleware/tests/anthropicTools.int.test.ts new file mode 100644 index 000000000000..67275e17c280 --- /dev/null +++ b/libs/langchain/src/agents/middleware/tests/anthropicTools.int.test.ts @@ -0,0 +1,276 @@ +/** + * Integration tests for Anthropic text editor and memory tool middleware. + * These tests use real API calls to Claude Sonnet 4.5. + */ +import { describe, it, expect } from "vitest"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { HumanMessage } from "@langchain/core/messages"; + +import { createAgent } from "../../index.js"; +import { + type AnthropicToolsState, + createStateClaudeMemoryMiddleware, + createStateClaudeTextEditorMiddleware, +} from "../anthropicTools/index.js"; + +describe("StateClaudeTextEditorMiddleware integration", () => { + it("should create and view a file", async () => { + const model = new ChatAnthropic({ + model: "claude-sonnet-4-5-20250929", + temperature: 0, + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + // Create a file + const result = await agent.invoke({ + messages: [ + new HumanMessage("Create a file /hello.py that prints 'Hello, World!'"), + ], + }); + + // Verify tool was called and file was created + const state = result as unknown as AnthropicToolsState; + const toolMessages = result.messages.filter((msg) => msg.type === "tool"); + + expect(toolMessages.length).toBeGreaterThan(0); + expect(state.text_editor_files).toBeDefined(); + + const fileKeys = Object.keys(state.text_editor_files || {}); + expect(fileKeys.length).toBeGreaterThan(0); + + // Verify file contains expected content + const createdFile = fileKeys.find((k) => k.includes("hello")); + expect(createdFile).toBeDefined(); + const content = state.text_editor_files![createdFile!].content; + expect(content.toLowerCase()).toContain("print"); + expect(content.toLowerCase()).toContain("hello"); + }, 60000); + + it("should modify existing file with str_replace", async () => { + const model = new ChatAnthropic({ + model: "claude-sonnet-4-5-20250929", + temperature: 0, + }); + + // Start with an existing file + const initialState: AnthropicToolsState = { + text_editor_files: { + "/config.txt": { + content: "api_key=old_value\ntimeout=30", + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + }, + }, + }; + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [ + new HumanMessage( + "In the /config.txt file, change 'old_value' to 'new_secret_key'" + ), + ], + ...initialState, + }); + + // Verify file was modified + const state = result as unknown as AnthropicToolsState; + const toolMessages = result.messages.filter((msg) => msg.type === "tool"); + + expect(toolMessages.length).toBeGreaterThan(0); + expect(state.text_editor_files!["/config.txt"]).toBeDefined(); + + const content = state.text_editor_files!["/config.txt"].content; + expect(content).toContain("new_secret_key"); + expect(content).not.toContain("old_value"); + }, 60000); + + it("should delete files and apply reducer correctly", async () => { + const model = new ChatAnthropic({ + model: "claude-sonnet-4-5-20250929", + temperature: 0, + clientOptions: { + defaultHeaders: { + "anthropic-beta": "context-management-2025-06-27", + }, + }, + }); + + // Start with two memory files + const initialState: AnthropicToolsState = { + memory_files: { + "/memories/keep.txt": { + content: "This file should remain", + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + }, + "/memories/delete.txt": { + content: "This file should be deleted", + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + }, + }, + }; + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + const result = await agent.invoke({ + messages: [ + new HumanMessage( + "Delete the file /memories/delete.txt using the memory tool" + ), + ], + ...initialState, + }); + + // Verify the deleted file is removed from state (not present with null value) + const state = result as unknown as AnthropicToolsState; + expect(state.memory_files).toBeDefined(); + expect(state.memory_files!["/memories/keep.txt"]).toBeDefined(); + + // This is the key test: the file should be REMOVED from state, not set to null + expect(state.memory_files).not.toHaveProperty("/memories/delete.txt"); + + // Only one file should remain + expect(Object.keys(state.memory_files!)).toEqual(["/memories/keep.txt"]); + }, 60000); +}); + +describe("StateClaudeMemoryMiddleware integration", () => { + it("should store and retrieve information using memory tool", async () => { + const model = new ChatAnthropic({ + model: "claude-sonnet-4-5", + temperature: 0, + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + // First invocation: store information in memory + const result1 = await agent.invoke({ + messages: [ + new HumanMessage( + "Please save to your memory that my favorite programming language is TypeScript and I work at a company called Acme Corp." + ), + ], + }); + + const state1 = result1 as unknown as AnthropicToolsState; + const toolMessages1 = result1.messages.filter((msg) => msg.type === "tool"); + + // Verify memory tool was called + expect(toolMessages1.length).toBeGreaterThan(0); + expect(state1.memory_files).toBeDefined(); + + // Verify at least one memory file was created + const memoryFileKeys = Object.keys(state1.memory_files || {}); + expect(memoryFileKeys.length).toBeGreaterThan(0); + + // Verify memory files are stored under /memories/ prefix + memoryFileKeys.forEach((key) => { + expect(key).toMatch(/^\/memories\//); + }); + + // Check that the content includes the stored information + const memoryContents = memoryFileKeys.map( + (key) => state1.memory_files![key].content + ); + const combinedMemoryText = memoryContents.join(" ").toLowerCase(); + expect(combinedMemoryText).toContain("typescript"); + expect(combinedMemoryText).toContain("acme corp"); + + // Second invocation: retrieve stored information + const result2 = await agent.invoke({ + messages: [ + new HumanMessage( + "What's my favorite programming language and where do I work?" + ), + ], + ...state1, // Pass state from first invocation + }); + + // Find the AI response + const aiMessages = result2.messages.filter((msg) => msg.type === "ai"); + expect(aiMessages.length).toBeGreaterThan(0); + + // Extract text from all AI messages (handle both string and array content) + const responseText = aiMessages + .map((msg) => { + if (typeof msg.content === "string") { + return msg.content; + } else if (Array.isArray(msg.content)) { + return msg.content + .filter( + (block: { + type?: string; + }): block is { type: "text"; text: string } => + block.type === "text" + ) + .map((block) => block.text) + .join(" "); + } + return ""; + }) + .join(" ") + .toLowerCase(); + + expect(responseText).toBeTruthy(); + + // Verify Claude can recall the stored information + expect(responseText).toContain("typescript"); + expect(responseText).toContain("acme"); + }, 60000); +}); + +describe("Combined middleware integration", () => { + it("should work with both text editor and memory middleware", async () => { + const model = new ChatAnthropic({ + model: "claude-sonnet-4-5", + temperature: 0, + }); + + const agent = createAgent({ + model, + middleware: [ + createStateClaudeTextEditorMiddleware(), + createStateClaudeMemoryMiddleware(), + ], + }); + + const result = await agent.invoke({ + messages: [ + new HumanMessage( + "Create a file /note.txt with 'Meeting at 3pm' and remember this in your memory" + ), + ], + }); + + const state = result as unknown as AnthropicToolsState; + const toolMessages = result.messages.filter((msg) => msg.type === "tool"); + + // At least one tool should have been called + expect(toolMessages.length).toBeGreaterThan(0); + + // At least one of the file systems should have files + const hasTextEditorFiles = + state.text_editor_files && + Object.keys(state.text_editor_files).length > 0; + const hasMemoryFiles = + state.memory_files && Object.keys(state.memory_files).length > 0; + + expect(hasTextEditorFiles || hasMemoryFiles).toBe(true); + }, 60000); +}); diff --git a/libs/langchain/src/agents/middleware/tests/anthropicTools.test.ts b/libs/langchain/src/agents/middleware/tests/anthropicTools.test.ts new file mode 100644 index 000000000000..913755500480 --- /dev/null +++ b/libs/langchain/src/agents/middleware/tests/anthropicTools.test.ts @@ -0,0 +1,1364 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Tests for Anthropic text editor and memory tool middleware. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { HumanMessage, ToolMessage } from "@langchain/core/messages"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { + createStateClaudeTextEditorMiddleware, + createStateClaudeMemoryMiddleware, + createFilesystemClaudeTextEditorMiddleware, + createFilesystemClaudeMemoryMiddleware, + TEXT_EDITOR_TOOL_NAME, + MEMORY_TOOL_NAME, + filesReducer, + type AnthropicToolsState, +} from "../anthropicTools/index.js"; +import { StateFileSystem } from "../anthropicTools/StateFileSystem.js"; +import { createAgent } from "../../index.js"; +import { FakeToolCallingModel } from "../../tests/utils.js"; + +describe("filesReducer", () => { + it("should initialize from empty object with non-null values", () => { + const result = filesReducer({}, { + "/file1.txt": { + content: "line1", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + "/file2.txt": null, + }); + + expect(result).toEqual({ + "/file1.txt": { + content: "line1", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }); + }); + + it("should merge and delete files", () => { + const left = { + "/file1.txt": { + content: "old content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + "/file2.txt": { + content: "keep me", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }; + + const result = filesReducer(left, { + "/file1.txt": { + content: "new content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-02T00:00:00Z", + }, + "/file2.txt": null, // Delete this file + "/file3.txt": { + content: "added", + created_at: "2024-01-02T00:00:00Z", + modified_at: "2024-01-02T00:00:00Z", + }, + }); + + expect(result).toEqual({ + "/file1.txt": { + content: "new content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-02T00:00:00Z", + }, + "/file3.txt": { + content: "added", + created_at: "2024-01-02T00:00:00Z", + modified_at: "2024-01-02T00:00:00Z", + }, + }); + }); +}); + +describe("validatePath", () => { + // Helper to create a StateFileSystem instance for testing path validation + const createFs = (allowedPrefixes?: string[]) => + new StateFileSystem({}, allowedPrefixes, () => {}); + + describe("basic path normalization", () => { + it("should return normalized absolute path", () => { + const fs = createFs(); + expect(fs.validatePath("/foo/bar")).toBe("/foo/bar"); + }); + + it("should add leading slash to relative paths", () => { + const fs = createFs(); + expect(fs.validatePath("foo/bar")).toBe("/foo/bar"); + }); + + it("should normalize double slashes", () => { + const fs = createFs(); + expect(fs.validatePath("/foo//bar")).toBe("/foo/bar"); + }); + + it("should normalize dot segments", () => { + const fs = createFs(); + expect(fs.validatePath("/foo/./bar")).toBe("/foo/bar"); + }); + }); + + describe("path traversal protection", () => { + it("should block .. in absolute paths", () => { + const fs = createFs(); + expect(() => fs.validatePath("/foo/../etc/passwd")).toThrow( + "Path traversal not allowed" + ); + }); + + it("should block .. in relative paths", () => { + const fs = createFs(); + expect(() => fs.validatePath("../etc/passwd")).toThrow( + "Path traversal not allowed" + ); + }); + + it("should block tilde paths", () => { + const fs = createFs(); + expect(() => fs.validatePath("~/.ssh/id_rsa")).toThrow( + "Path traversal not allowed" + ); + }); + }); + + describe("allowed prefix validation", () => { + it("should allow paths with correct prefix", () => { + const fs = createFs(["/workspace"]); + expect(fs.validatePath("/workspace/file.txt")).toBe( + "/workspace/file.txt" + ); + }); + + it("should reject paths without allowed prefix", () => { + const fs = createFs(["/workspace"]); + expect(() => fs.validatePath("/etc/passwd")).toThrow( + "Path must start with" + ); + }); + + it("should reject paths that only partially match prefix", () => { + // This test catches the edge case where /workspacemalicious starts with /workspace + const fs = createFs(["/workspace/"]); + expect(() => fs.validatePath("/workspacemalicious/file.txt")).toThrow( + "Path must start with" + ); + }); + }); + + describe("memories prefix validation", () => { + it("should allow /memories paths with /memories prefix", () => { + const fs = createFs(["/memories"]); + expect(fs.validatePath("/memories/notes.txt")).toBe( + "/memories/notes.txt" + ); + }); + + it("should reject non-/memories paths when /memories prefix required", () => { + const fs = createFs(["/memories"]); + expect(() => fs.validatePath("/other/notes.txt")).toThrow( + "Path must start with" + ); + }); + }); +}); + +describe("StateClaudeTextEditorMiddleware", () => { + describe("initialization", () => { + it("should initialize with correct defaults", () => { + const mw = createStateClaudeTextEditorMiddleware(); + expect(mw.name).toBe("StateClaudeTextEditorMiddleware"); + // Test that middleware has the text editor tool + expect(mw.tools).toBeDefined(); + expect(mw.tools!).toHaveLength(1); + expect(mw.tools![0].name).toBe(TEXT_EDITOR_TOOL_NAME); + // Verify the tool has the provider definition + expect((mw.tools![0] as any).providerToolDefinition).toEqual({ + type: "text_editor_20250728", + name: TEXT_EDITOR_TOOL_NAME, + }); + }); + + it("should initialize with custom allowed path prefixes", () => { + const mw = createStateClaudeTextEditorMiddleware({ + allowedPathPrefixes: ["/workspace"], + }); + expect(mw.name).toBe("StateClaudeTextEditorMiddleware"); + }); + }); + + describe("tool execution - view", () => { + it("should view file with line numbers", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/test.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View the file")], + text_editor_files: { + "/test.txt": { + content: "line 1\nline 2\nline 3", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage).toBeDefined(); + expect(toolMessage.content).toBe("1|line 1\n2|line 2\n3|line 3"); + }); + + it("should list directory when viewing non-existent file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/dir", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View directory")], + text_editor_files: { + "/dir/file1.txt": { + content: "content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + "/dir/file2.txt": { + content: "content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.content).toBe("/dir/file1.txt\n/dir/file2.txt"); + }); + + it("should return error for non-existent file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/nonexistent.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + text_editor_files: {}, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("File not found"); + }); + }); + + describe("tool execution - create", () => { + it("should create new file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "create", + path: "/new.txt", + file_text: "line 1\nline 2", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Create file")], + text_editor_files: {}, + }); + + const state = result as unknown as AnthropicToolsState; + expect(state.text_editor_files).toBeDefined(); + expect(state.text_editor_files!["/new.txt"]).toBeDefined(); + expect(state.text_editor_files!["/new.txt"].content).toBe( + "line 1\nline 2" + ); + expect(state.text_editor_files!["/new.txt"].created_at).toBeDefined(); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.content).toBe("File created: /new.txt"); + }); + }); + + describe("tool execution - str_replace", () => { + it("should replace string in file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "str_replace", + path: "/test.txt", + old_str: "hello world", + new_str: "goodbye world", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Replace text")], + text_editor_files: { + "/test.txt": { + content: "hello world\nhello universe", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const state = result as unknown as AnthropicToolsState; + expect(state.text_editor_files!["/test.txt"].content).toBe( + "goodbye world\nhello universe" + ); + }); + + it("should replace only first occurrence", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "str_replace", + path: "/test.txt", + old_str: "hello", + new_str: "goodbye", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Replace text")], + text_editor_files: { + "/test.txt": { + content: "hello\nhello again", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const state = result as unknown as AnthropicToolsState; + // Should replace first occurrence of "hello" with "goodbye" + // Content is "hello\nhello again" becomes "goodbye\nhello again" + expect(state.text_editor_files!["/test.txt"].content).toBe( + "goodbye\nhello again" + ); + }); + + it("should return error if string not found", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "str_replace", + path: "/test.txt", + old_str: "nonexistent", + new_str: "replacement", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Replace text")], + text_editor_files: { + "/test.txt": { + content: "hello world", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("String not found"); + }); + }); + + describe("tool execution - insert", () => { + it("should insert text at specified line", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "insert", + path: "/test.txt", + insert_line: 1, + new_str: "inserted line", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Insert text")], + text_editor_files: { + "/test.txt": { + content: "line 1\nline 2\nline 3", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const state = result as unknown as AnthropicToolsState; + expect(state.text_editor_files!["/test.txt"].content).toBe( + "line 1\ninserted line\nline 2\nline 3" + ); + }); + }); + + describe("path validation", () => { + it("should reject path traversal with ..", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/../etc/passwd", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + text_editor_files: {}, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("Path traversal not allowed"); + }); + + it("should reject path traversal with ~", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "~/file.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeTextEditorMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + text_editor_files: {}, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("Path traversal not allowed"); + }); + + it("should enforce allowed prefixes", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/etc/passwd", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createStateClaudeTextEditorMiddleware({ + allowedPathPrefixes: ["/workspace"], + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + text_editor_files: {}, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("Path must start with"); + }); + }); +}); + +describe("StateClaudeMemoryMiddleware", () => { + describe("initialization", () => { + it("should initialize with correct defaults", () => { + const mw = createStateClaudeMemoryMiddleware(); + expect(mw.name).toBe("StateClaudeMemoryMiddleware"); + // Memory middleware has both tools and wrapModelCall (for system prompt) + expect(mw.tools).toBeDefined(); + expect(mw.tools!).toHaveLength(1); + expect(mw.tools![0].name).toBe(MEMORY_TOOL_NAME); + expect(mw.wrapModelCall).toBeDefined(); + }); + + it("should initialize with custom system prompt", () => { + const customPrompt = "Custom memory instructions"; + const mw = createStateClaudeMemoryMiddleware({ + systemPrompt: customPrompt, + }); + expect(mw.name).toBe("StateClaudeMemoryMiddleware"); + }); + }); + + describe("system prompt injection", () => { + it("should inject memory system prompt", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [[]], // No tool calls + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Hello")], + }); + + // The model response should contain the memory system prompt + // FakeToolCallingModel concatenates message content, so the system prompt + // will be included in the AI response content + const aiMessage = result.messages.find((m: any) => + m._getType() === "ai" + ); + expect(aiMessage).toBeDefined(); + expect(aiMessage!.content).toContain("MEMORY PROTOCOL"); + expect(aiMessage!.content).toContain( + "IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE" + ); + }); + + it("should append to existing system prompt", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [[]], // No tool calls + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + systemPrompt: "Existing prompt.", + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Hello")], + }); + + // Verify both prompts are present in the AI message content + // FakeToolCallingModel concatenates message content, so both the existing + // prompt and memory prompt will be in the response + const aiMessage = result.messages.find((m: any) => + m._getType() === "ai" + ); + expect(aiMessage).toBeDefined(); + expect(aiMessage!.content).toContain("Existing prompt."); + expect(aiMessage!.content).toContain("MEMORY PROTOCOL"); + expect(aiMessage!.content).toContain( + "IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE" + ); + }); + }); + + describe("path prefix enforcement", () => { + it("should enforce /memories prefix by default", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "view", + path: "/etc/passwd", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + memory_files: {}, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("Path must start with"); + }); + + it("should allow paths with /memories prefix", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "view", + path: "/memories/progress.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View memory")], + memory_files: { + "/memories/progress.txt": { + content: "task 1 done", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.content).toBe("1|task 1 done"); + }); + }); + + describe("tool execution - delete", () => { + it("should delete file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "delete", + path: "/memories/test.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Delete file")], + memory_files: { + "/memories/test.txt": { + content: "content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const state = result as unknown as AnthropicToolsState; + // After filesReducer, null values are removed + expect(state.memory_files).not.toHaveProperty("/memories/test.txt"); + }); + }); + + describe("tool execution - rename", () => { + it("should rename file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "rename", + old_path: "/memories/old.txt", + new_path: "/memories/new.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [createStateClaudeMemoryMiddleware()], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Rename file")], + memory_files: { + "/memories/old.txt": { + content: "content", + created_at: "2024-01-01T00:00:00Z", + modified_at: "2024-01-01T00:00:00Z", + }, + }, + }); + + const state = result as unknown as AnthropicToolsState; + // After filesReducer, null values are removed + expect(state.memory_files).not.toHaveProperty("/memories/old.txt"); + expect(state.memory_files!["/memories/new.txt"]).toBeDefined(); + expect(state.memory_files!["/memories/new.txt"].content).toBe("content"); + }); + }); +}); + +describe("FilesystemClaudeTextEditorMiddleware", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-editor-")); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("tool execution - view", () => { + it("should view file with line numbers", async () => { + const filePath = path.join(tempDir, "test.txt"); + fs.writeFileSync(filePath, "line 1\nline 2\nline 3"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/test.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.content).toBe("1|line 1\n2|line 2\n3|line 3"); + }); + + it("should return error for non-existent file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/nonexistent.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("File not found"); + }); + }); + + describe("tool execution - create", () => { + it("should create new file", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "create", + path: "/new.txt", + file_text: "line 1\nline 2", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("Create file")], + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.content).toBe("File created: /new.txt"); + + const filePath = path.join(tempDir, "new.txt"); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, "utf8")).toBe("line 1\nline 2\n"); + }); + + it("should create parent directories", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "create", + path: "/subdir/new.txt", + file_text: "content", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + await agent.invoke({ + messages: [new HumanMessage("Create file")], + }); + + const filePath = path.join(tempDir, "subdir", "new.txt"); + expect(fs.existsSync(filePath)).toBe(true); + }); + }); + + describe("tool execution - str_replace", () => { + it("should replace string in file", async () => { + const filePath = path.join(tempDir, "test.txt"); + fs.writeFileSync(filePath, "hello world\nhello universe"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "str_replace", + path: "/test.txt", + old_str: "hello world", + new_str: "goodbye world", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + await agent.invoke({ + messages: [new HumanMessage("Replace text")], + }); + + const content = fs.readFileSync(filePath, "utf8"); + expect(content).toBe("goodbye world\nhello universe\n"); + }); + }); + + describe("tool execution - insert", () => { + it("should insert text at specified line", async () => { + const filePath = path.join(tempDir, "test.txt"); + fs.writeFileSync(filePath, "line 1\nline 2\nline 3\n"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "insert", + path: "/test.txt", + insert_line: 1, + new_str: "inserted line", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + await agent.invoke({ + messages: [new HumanMessage("Insert text")], + }); + + const content = fs.readFileSync(filePath, "utf8"); + expect(content).toBe("line 1\ninserted line\nline 2\nline 3\n"); + }); + }); + + describe("path security", () => { + it("should prevent escaping root directory", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: TEXT_EDITOR_TOOL_NAME, + args: { + command: "view", + path: "/../etc/passwd", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeTextEditorMiddleware({ + rootPath: tempDir, + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("Path traversal not allowed"); + }); + }); +}); + +describe("FilesystemClaudeMemoryMiddleware", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-memory-")); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("path prefix enforcement", () => { + it("should enforce /memories prefix by default", async () => { + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "view", + path: "/etc/passwd", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeMemoryMiddleware({ + rootPath: tempDir, + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.status).toBe("error"); + expect(toolMessage.content).toContain("Path must start with"); + }); + + it("should allow paths with /memories prefix", async () => { + const memoriesDir = path.join(tempDir, "memories"); + fs.mkdirSync(memoriesDir, { recursive: true }); + const filePath = path.join(memoriesDir, "progress.txt"); + fs.writeFileSync(filePath, "task 1 done"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "view", + path: "/memories/progress.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeMemoryMiddleware({ + rootPath: tempDir, + }), + ], + }); + + const result = await agent.invoke({ + messages: [new HumanMessage("View file")], + }); + + const toolMessage = result.messages.find((m) => + ToolMessage.isInstance(m) + ) as ToolMessage; + expect(toolMessage.content).toBe("1|task 1 done"); + }); + }); + + describe("tool execution - delete", () => { + it("should delete file", async () => { + const memoriesDir = path.join(tempDir, "memories"); + fs.mkdirSync(memoriesDir, { recursive: true }); + const filePath = path.join(memoriesDir, "test.txt"); + fs.writeFileSync(filePath, "content"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "delete", + path: "/memories/test.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeMemoryMiddleware({ + rootPath: tempDir, + }), + ], + }); + + await agent.invoke({ + messages: [new HumanMessage("Delete file")], + }); + + expect(fs.existsSync(filePath)).toBe(false); + }); + + it("should delete directory recursively", async () => { + const memoriesDir = path.join(tempDir, "memories"); + fs.mkdirSync(memoriesDir, { recursive: true }); + const subdirPath = path.join(memoriesDir, "subdir"); + fs.mkdirSync(subdirPath); + fs.writeFileSync(path.join(subdirPath, "file.txt"), "content"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "delete", + path: "/memories/subdir", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeMemoryMiddleware({ + rootPath: tempDir, + }), + ], + }); + + await agent.invoke({ + messages: [new HumanMessage("Delete directory")], + }); + + expect(fs.existsSync(subdirPath)).toBe(false); + }); + }); + + describe("tool execution - rename", () => { + it("should rename file", async () => { + const memoriesDir = path.join(tempDir, "memories"); + fs.mkdirSync(memoriesDir, { recursive: true }); + const oldPath = path.join(memoriesDir, "old.txt"); + fs.writeFileSync(oldPath, "content"); + + const model = new FakeToolCallingModel({ + toolCalls: [ + [ + { + id: "call_1", + name: MEMORY_TOOL_NAME, + args: { + command: "rename", + old_path: "/memories/old.txt", + new_path: "/memories/new.txt", + }, + }, + ], + ], + }); + + const agent = createAgent({ + model, + middleware: [ + createFilesystemClaudeMemoryMiddleware({ + rootPath: tempDir, + }), + ], + }); + + await agent.invoke({ + messages: [new HumanMessage("Rename file")], + }); + + expect(fs.existsSync(oldPath)).toBe(false); + const newPath = path.join(memoriesDir, "new.txt"); + expect(fs.existsSync(newPath)).toBe(true); + expect(fs.readFileSync(newPath, "utf8")).toBe("content"); + }); + }); +});