diff --git a/.changeset/tidy-buckets-delete.md b/.changeset/tidy-buckets-delete.md new file mode 100644 index 000000000..d16699638 --- /dev/null +++ b/.changeset/tidy-buckets-delete.md @@ -0,0 +1,7 @@ +--- +"deepagents": minor +--- + +feat(backends): add delete protocol support + +Adds a `DeleteResult` type and optional backend `delete` method, preserves delete through backend protocol adaptation, and implements StateBackend deletion through Pregel file-state updates. diff --git a/libs/deepagents/src/backends/index.ts b/libs/deepagents/src/backends/index.ts index 50116ae31..ee98d2d8e 100644 --- a/libs/deepagents/src/backends/index.ts +++ b/libs/deepagents/src/backends/index.ts @@ -22,6 +22,7 @@ export type { GlobResult, WriteResult, EditResult, + DeleteResult, StateAndStore, // Sandbox execution types ExecuteResponse, diff --git a/libs/deepagents/src/backends/protocol.ts b/libs/deepagents/src/backends/protocol.ts index 5ed3f6290..112f8454e 100644 --- a/libs/deepagents/src/backends/protocol.ts +++ b/libs/deepagents/src/backends/protocol.ts @@ -215,6 +215,16 @@ export interface EditResult { metadata?: Record; } +/** + * Result from backend delete operations. + */ +export interface DeleteResult { + /** Error message on failure, undefined on success */ + error?: string; + /** File path of deleted file, undefined on failure */ + path?: string; +} + /** * Result of code execution. * Simplified schema optimized for LLM consumption. diff --git a/libs/deepagents/src/backends/state.test.ts b/libs/deepagents/src/backends/state.test.ts index 5ad7eadba..871f2adfd 100644 --- a/libs/deepagents/src/backends/state.test.ts +++ b/libs/deepagents/src/backends/state.test.ts @@ -14,15 +14,17 @@ vi.mock("@langchain/langgraph", async (importOriginal) => { function makeConfig(files: Record = {}) { const state = { messages: [], files: { ...files } }; - const pendingFilesSends: Record[] = []; + const pendingFilesSends: Record[] = []; const sendSpy = vi .fn() - .mockImplementation((sends: [string, Record][]) => { - for (const [channel, update] of sends) { - if (channel === "files") pendingFilesSends.push(update); - } - }); + .mockImplementation( + (sends: [string, Record][]) => { + for (const [channel, update] of sends) { + if (channel === "files") pendingFilesSends.push(update); + } + }, + ); const readSpy = vi .fn() @@ -30,7 +32,15 @@ function makeConfig(files: Record = {}) { if (channel !== "files") return undefined; if (!fresh || pendingFilesSends.length === 0) return state.files; const merged = { ...state.files }; - for (const update of pendingFilesSends) Object.assign(merged, update); + for (const update of pendingFilesSends) { + for (const [path, fileData] of Object.entries(update)) { + if (fileData === null) { + delete merged[path]; + } else { + merged[path] = fileData; + } + } + } return merged; }); @@ -40,7 +50,15 @@ function makeConfig(files: Record = {}) { // Simulate Pregel committing task.writes to state and clearing the buffer. function commitSends() { - for (const update of pendingFilesSends) Object.assign(state.files, update); + for (const update of pendingFilesSends) { + for (const [path, fileData] of Object.entries(update)) { + if (fileData === null) { + delete state.files[path]; + } else { + state.files[path] = fileData; + } + } + } pendingFilesSends.length = 0; } @@ -109,6 +127,49 @@ describe("StateBackend", () => { expect(infos.files!.some((i) => i.path === "/notes.txt")).toBe(true); }); + it("should delete files through Pregel send in zero-arg mode", () => { + const { sendSpy, commitSends } = makeConfig(); + const backend = new StateBackend(); + + const writeRes = backend.write("/drop.txt", "bye"); + expect(writeRes.error).toBeUndefined(); + commitSends(); + expect(backend.read("/drop.txt").error).toBeUndefined(); + + const deleteRes = backend.delete("/drop.txt"); + expect(deleteRes.error).toBeUndefined(); + expect(deleteRes.path).toBe("/drop.txt"); + expect(sendSpy).toHaveBeenLastCalledWith([ + ["files", { "/drop.txt": null }], + ]); + + commitSends(); + expect(backend.read("/drop.txt").error).toContain("not found"); + }); + + it("should return an error when deleting a missing file", () => { + makeConfig(); + const backend = new StateBackend(); + + const result = backend.delete("/missing.txt"); + + expect(result.path).toBeUndefined(); + expect(result.error).toContain("not found"); + }); + + it("should return an explicit error for delete in legacy mode", () => { + const { state, runtime } = makeConfig(); + const backend = new StateBackend(runtime); + const writeRes = backend.write("/drop.txt", "bye"); + Object.assign(state.files, writeRes.filesUpdate); + + const result = backend.delete("/drop.txt"); + + expect(result.path).toBeUndefined(); + expect(result.error).toContain("zero-argument StateBackend"); + expect(backend.read("/drop.txt").error).toBeUndefined(); + }); + it("should handle errors correctly", () => { const { state, runtime } = makeConfig(); const backend = new StateBackend(runtime); diff --git a/libs/deepagents/src/backends/state.ts b/libs/deepagents/src/backends/state.ts index 4048ccd5c..efdad8914 100644 --- a/libs/deepagents/src/backends/state.ts +++ b/libs/deepagents/src/backends/state.ts @@ -3,6 +3,7 @@ */ import type { + DeleteResult, EditResult, FileData, FileDownloadResponse, @@ -118,9 +119,10 @@ export class StateBackend implements BackendProtocolV2 { * In legacy mode, this is a no-op — the caller uses `filesUpdate` * from the return value instead. * - * @param update - Map of file paths to their updated {@link FileData} + * @param update - Map of file paths to their updated {@link FileData}, + * or null deletion markers. */ - private sendFilesUpdate(update: Record): void { + private sendFilesUpdate(update: Record): void { if (this.isLegacy) { return; } @@ -325,6 +327,27 @@ export class StateBackend implements BackendProtocolV2 { }; } + /** + * Delete a file from state by sending a null deletion marker through Pregel. + */ + delete(filePath: string): DeleteResult { + const files = this.files; + + if (!(filePath in files)) { + return { error: `Error: File '${filePath}' not found` }; + } + + if (this.isLegacy) { + return { + error: + "StateBackend.delete requires a zero-argument StateBackend in a LangGraph execution context.", + }; + } + + this.sendFilesUpdate({ [filePath]: null }); + return { path: filePath }; + } + /** * Search file contents for a literal text pattern. * Binary files are skipped. diff --git a/libs/deepagents/src/backends/utils.ts b/libs/deepagents/src/backends/utils.ts index 26e2c5560..ebabb382a 100644 --- a/libs/deepagents/src/backends/utils.ts +++ b/libs/deepagents/src/backends/utils.ts @@ -930,6 +930,7 @@ export function adaptBackendProtocol( write: (filePath, content) => backend.write(filePath, content), edit: (filePath, oldString, newString, replaceAll) => backend.edit(filePath, oldString, newString, replaceAll), + delete: backend.delete?.bind(backend), uploadFiles: backend.uploadFiles ? (files) => backend.uploadFiles!(files) : undefined, diff --git a/libs/deepagents/src/backends/v1/protocol.ts b/libs/deepagents/src/backends/v1/protocol.ts index f0d237dad..0f77a458c 100644 --- a/libs/deepagents/src/backends/v1/protocol.ts +++ b/libs/deepagents/src/backends/v1/protocol.ts @@ -7,6 +7,7 @@ */ import type { + DeleteResult, EditResult, ExecuteResponse, FileData, @@ -111,6 +112,14 @@ export interface BackendProtocolV1 { replaceAll?: boolean, ): MaybePromise; + /** + * Delete a single file. + * + * @param filePath - Absolute path to the file to delete + * @returns DeleteResult with path on success or error on failure + */ + delete?(filePath: string): MaybePromise; + /** * Upload multiple files. * Optional - backends that don't support file upload can omit this. diff --git a/libs/deepagents/src/backends/v2/protocol.ts b/libs/deepagents/src/backends/v2/protocol.ts index 68de11fbb..23ba351a9 100644 --- a/libs/deepagents/src/backends/v2/protocol.ts +++ b/libs/deepagents/src/backends/v2/protocol.ts @@ -7,6 +7,7 @@ import type { BackendProtocolV1 } from "../v1/protocol.js"; import type { + DeleteResult, ExecuteResponse, GlobResult, GrepResult, @@ -93,6 +94,15 @@ export interface BackendProtocolV2 extends Omit< * @returns GlobResult with list of FileInfo objects matching the pattern on success or error on failure */ glob(pattern: string, path?: string): MaybePromise; + + /** + * Delete a single file. + * Optional - backends that don't support file deletion can omit this. + * + * @param filePath - Absolute path to the file to delete + * @returns DeleteResult with path on success or error on failure + */ + delete?(filePath: string): MaybePromise; } /** diff --git a/libs/deepagents/src/browser.ts b/libs/deepagents/src/browser.ts index aafd59ab0..4cb4e575d 100644 --- a/libs/deepagents/src/browser.ts +++ b/libs/deepagents/src/browser.ts @@ -117,6 +117,7 @@ export type { GlobResult, WriteResult, EditResult, + DeleteResult, StateAndStore, // Sandbox execution types ExecuteResponse, diff --git a/libs/deepagents/src/index.ts b/libs/deepagents/src/index.ts index 7b4467367..92915cc4b 100644 --- a/libs/deepagents/src/index.ts +++ b/libs/deepagents/src/index.ts @@ -148,6 +148,7 @@ export { type ReadRawResult, type WriteResult, type EditResult, + type DeleteResult, // Sandbox execution types type ExecuteResponse, type FileData,