diff --git a/README.md b/README.md index 87918b48..5d66c646 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ in the same way you would any LangGraph agent. **Context Management** - File system tools (`ls`, `read_file`, `write_file`, `edit_file`) allow agents to offload large context to memory, preventing context window overflow and enabling work with variable-length tool results. + File system tools (`ls`, `read_file`, `write_file`, `edit_file`, `delete_file`) allow agents to offload large context to memory, preventing context window overflow and enabling work with variable-length tool results. **Subagent Spawning** @@ -375,11 +375,12 @@ agent = create_agent( ### FilesystemMiddleware Context engineering is one of the main challenges in building effective agents. This can be particularly hard when using tools that can return variable length results (ex. web_search, rag), as long ToolResults can quickly fill up your context window. -**FilesystemMiddleware** provides four tools to your agent to interact with both short-term and long-term memory. +**FilesystemMiddleware** provides five tools to your agent to interact with both short-term and long-term memory. - **ls**: List the files in your filesystem - **read_file**: Read an entire file, or a certain number of lines from a file - **write_file**: Write a new file to your filesystem - **edit_file**: Edit an existing file in your filesystem +- **delete_file**: Delete a file from your filesystem ```python from langchain.agents import create_agent @@ -509,4 +510,4 @@ async def main(): chunk["messages"][-1].pretty_print() asyncio.run(main()) -``` \ No newline at end of file +``` diff --git a/src/deepagents/middleware/filesystem.py b/src/deepagents/middleware/filesystem.py index 4dbb399a..3b3ca8d4 100644 --- a/src/deepagents/middleware/filesystem.py +++ b/src/deepagents/middleware/filesystem.py @@ -411,7 +411,18 @@ class FilesystemState(AgentState): f"\n- file_paths prefixed with the {MEMORIES_PREFIX} path will be written to the longterm filesystem." ) -FILESYSTEM_SYSTEM_PROMPT = """## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file` +DELETE_FILE_TOOL_DESCRIPTION = """Deletes a file from the filesystem. + +Usage: +- The file_path parameter must be an absolute path, not a relative path +- The delete_file tool will permanently remove the file. +- Use this tool when you need to clean up temporary files or remove files that are no longer needed. +- The file must exist in the filesystem to be deleted.""" +DELETE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( + f"\n- file_paths prefixed with the {MEMORIES_PREFIX} path will be deleted from the longterm filesystem." +) + +FILESYSTEM_SYSTEM_PROMPT = """## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file`, `delete_file` You have access to a filesystem which you can interact with using these tools. All file paths must start with a /. @@ -419,7 +430,8 @@ class FilesystemState(AgentState): - ls: list all files in the filesystem - read_file: read a file from the filesystem - write_file: write to a file in the filesystem -- edit_file: edit a file in the filesystem""" +- edit_file: edit a file in the filesystem +- delete_file: delete a file from the filesystem""" FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT = f""" You also have access to a longterm filesystem in which you can store files that you want to keep around for longer than the current conversation. @@ -888,11 +900,86 @@ def edit_file( return edit_file +def _delete_file_tool_generator(custom_description: str | None = None, *, long_term_memory: bool) -> BaseTool: + """Generate the delete_file tool. + + Args: + custom_description: Optional custom description for the tool. + long_term_memory: Whether to enable longterm memory support. + + Returns: + Configured delete_file tool that deletes files from state or longterm store. + """ + tool_description = DELETE_FILE_TOOL_DESCRIPTION + if custom_description: + tool_description = custom_description + elif long_term_memory: + tool_description += DELETE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + + def _delete_file_from_state(state: FilesystemState, tool_call_id: str, file_path: str) -> Command | str: + """Delete a file from the filesystem state. + + Args: + state: The current filesystem state. + tool_call_id: ID of the tool call for generating ToolMessage. + file_path: The path of the file to delete. + + Returns: + Command to update state with file deletion, or error string if file doesn't exist. + """ + mock_filesystem = state.get("files", {}) + if file_path not in mock_filesystem: + return f"Error: File '{file_path}' not found" + return Command( + update={ + "files": {file_path: None}, # None triggers deletion via reducer + "messages": [ToolMessage(f"Deleted file {file_path}", tool_call_id=tool_call_id)], + } + ) + + if long_term_memory: + + @tool(description=tool_description) + def delete_file( + file_path: str, + runtime: ToolRuntime[None, FilesystemState], + ) -> Command | str: + file_path = _validate_path(file_path) + if not runtime.tool_call_id: + value_error_msg = "Tool call ID is required for delete_file invocation" + raise ValueError(value_error_msg) + if _has_memories_prefix(file_path): + stripped_file_path = _strip_memories_prefix(file_path) + store = _get_store(runtime) + namespace = _get_namespace() + if store.get(namespace, stripped_file_path) is None: + return f"Error: File '{file_path}' not found" + store.delete(namespace, stripped_file_path) + return f"Deleted longterm memories file {file_path}" + return _delete_file_from_state(runtime.state, runtime.tool_call_id, file_path) + + else: + + @tool(description=tool_description) + def delete_file( + file_path: str, + runtime: ToolRuntime[None, FilesystemState], + ) -> Command | str: + file_path = _validate_path(file_path) + if not runtime.tool_call_id: + value_error_msg = "Tool call ID is required for delete_file invocation" + raise ValueError(value_error_msg) + return _delete_file_from_state(runtime.state, runtime.tool_call_id, file_path) + + return delete_file + + TOOL_GENERATORS = { "ls": _ls_tool_generator, "read_file": _read_file_tool_generator, "write_file": _write_file_tool_generator, "edit_file": _edit_file_tool_generator, + "delete_file": _delete_file_tool_generator, } @@ -904,7 +991,7 @@ def _get_filesystem_tools(custom_tool_descriptions: dict[str, str] | None = None long_term_memory: Whether to enable longterm memory support. Returns: - List of configured filesystem tools (ls, read_file, write_file, edit_file). + List of configured filesystem tools (ls, read_file, write_file, edit_file, delete_file). """ if custom_tool_descriptions is None: custom_tool_descriptions = {} @@ -928,8 +1015,8 @@ def _get_filesystem_tools(custom_tool_descriptions: dict[str, str] | None = None class FilesystemMiddleware(AgentMiddleware): """Middleware for providing filesystem tools to an agent. - This middleware adds four filesystem tools to the agent: ls, read_file, write_file, - and edit_file. Files can be stored in two locations: + This middleware adds five filesystem tools to the agent: ls, read_file, write_file, + edit_file, and delete_file. Files can be stored in two locations: - Short-term: In the agent's state (ephemeral, lasts only for the conversation) - Long-term: In a persistent store (persists across conversations when enabled) diff --git a/tests/integration_tests/test_filesystem_middleware.py b/tests/integration_tests/test_filesystem_middleware.py index cacf5e75..c55b08ad 100644 --- a/tests/integration_tests/test_filesystem_middleware.py +++ b/tests/integration_tests/test_filesystem_middleware.py @@ -583,6 +583,126 @@ def test_command_with_tool_call_existing_state(self): assert "/test.txt" in response["files"].keys() assert "research" in response + def test_delete_file_shortterm(self): + checkpointer = MemorySaver() + agent = create_agent( + model=ChatAnthropic(model="claude-sonnet-4-20250514"), + middleware=[ + FilesystemMiddleware( + long_term_memory=False, + ) + ], + checkpointer=checkpointer, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + # First create a file + response = agent.invoke( + { + "messages": [HumanMessage(content="Write a haiku about Pikachu to /pikachu.txt")], + "files": {}, + }, + config=config, + ) + assert "/pikachu.txt" in response["files"] + + # Now delete the file + response = agent.invoke( + {"messages": [HumanMessage(content="Delete the file /pikachu.txt")]}, + config=config, + ) + messages = response["messages"] + delete_file_message = next(message for message in messages if message.type == "tool" and message.name == "delete_file") + assert delete_file_message is not None + assert "Deleted file /pikachu.txt" in delete_file_message.content + assert "/pikachu.txt" not in response["files"] + + def test_delete_file_longterm(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/pikachu.txt", + { + "content": ["Thunder shock!"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-sonnet-4-20250514"), + middleware=[ + FilesystemMiddleware( + long_term_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [HumanMessage(content="Delete the file /pikachu.txt from longterm memory")], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + delete_file_message = next(message for message in messages if message.type == "tool" and message.name == "delete_file") + assert delete_file_message is not None + assert "Deleted longterm memories file /memories/pikachu.txt" in delete_file_message.content + # Verify the file was actually deleted from store + assert store.get(("filesystem",), "/pikachu.txt") is None + + def test_delete_file_not_found_shortterm(self): + checkpointer = MemorySaver() + agent = create_agent( + model=ChatAnthropic(model="claude-sonnet-4-20250514"), + middleware=[ + FilesystemMiddleware( + long_term_memory=False, + ) + ], + checkpointer=checkpointer, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [HumanMessage(content="Delete the file /nonexistent.txt")], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + delete_file_message = next(message for message in messages if message.type == "tool" and message.name == "delete_file") + assert delete_file_message is not None + assert "Error: File '/nonexistent.txt' not found" in delete_file_message.content + + def test_delete_file_not_found_longterm(self): + checkpointer = MemorySaver() + store = InMemoryStore() + agent = create_agent( + model=ChatAnthropic(model="claude-sonnet-4-20250514"), + middleware=[ + FilesystemMiddleware( + long_term_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [HumanMessage(content="Delete the file /nonexistent.txt from longterm memory")], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + delete_file_message = next(message for message in messages if message.type == "tool" and message.name == "delete_file") + assert delete_file_message is not None + assert "Error: File '/memories/nonexistent.txt' not found" in delete_file_message.content + # Take actions on multiple threads to test longterm memory def assert_longterm_mem_tools(agent, store): @@ -640,6 +760,18 @@ def assert_longterm_mem_tools(agent, store): read_file_message = next(message for message in messages if message.type == "tool" and message.name == "read_file") assert "ember" in read_file_message.content or "Ember" in read_file_message.content + # Delete the longterm memory file + config6 = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + {"messages": [HumanMessage(content="Delete the haiku about Charmander from longterm memory at /charmander.txt")]}, + config=config6, + ) + messages = response["messages"] + delete_file_message = next(message for message in messages if message.type == "tool" and message.name == "delete_file") + assert delete_file_message is not None + file_item = store.get(("filesystem",), "/charmander.txt") + assert file_item is None + def assert_shortterm_mem_tools(agent): # Write a shortterm memory file @@ -686,3 +818,13 @@ def assert_shortterm_mem_tools(agent): messages = response["messages"] read_file_message = next(message for message in reversed(messages) if message.type == "tool" and message.name == "read_file") assert "ember" in read_file_message.content or "Ember" in read_file_message.content + + # Delete the shortterm memory file + response = agent.invoke( + {"messages": [HumanMessage(content="Delete the haiku about Charmander at /charmander.txt")]}, + config=config, + ) + messages = response["messages"] + delete_file_message = next(message for message in messages if message.type == "tool" and message.name == "delete_file") + assert delete_file_message is not None + assert "/charmander.txt" not in response["files"]