From 62e061cb6726f76651d738cb39245a95e13194f2 Mon Sep 17 00:00:00 2001 From: DavinciDreams Date: Sat, 31 Jan 2026 00:27:14 -0500 Subject: [PATCH 1/2] feat: implement modularization with adapter pattern and framework submodule --- .gitmodules | 3 + MODULARIZATION_SUMMARY.md | 375 ++ apps/agents-of-empire/README.md | 2 +- apps/agents-of-empire/package.json | 8 +- .../src/examples/DeepAgentExample.ts | 53 +- libs/cli/README.md | 6 +- libs/cli/package.json | 8 +- libs/deepagents | 1 + libs/deepagents/CHANGELOG.md | 94 - libs/deepagents/README.md | 660 ---- libs/deepagents/package.json | 83 - libs/deepagents/src/agent.int.test.ts | 317 -- libs/deepagents/src/agent.test-d.ts | 272 -- libs/deepagents/src/agent.ts | 264 -- .../deepagents/src/backends/composite.test.ts | 720 ---- libs/deepagents/src/backends/composite.ts | 387 -- .../src/backends/filesystem.test.ts | 367 -- libs/deepagents/src/backends/filesystem.ts | 785 ---- libs/deepagents/src/backends/index.ts | 38 - libs/deepagents/src/backends/protocol.test.ts | 199 - libs/deepagents/src/backends/protocol.ts | 323 -- libs/deepagents/src/backends/sandbox.test.ts | 495 --- libs/deepagents/src/backends/sandbox.ts | 546 --- libs/deepagents/src/backends/state.test.ts | 363 -- libs/deepagents/src/backends/state.ts | 297 -- libs/deepagents/src/backends/store.test.ts | 363 -- libs/deepagents/src/backends/store.ts | 455 --- libs/deepagents/src/backends/utils.test.ts | 287 -- libs/deepagents/src/backends/utils.ts | 602 --- libs/deepagents/src/config.test.ts | 255 -- libs/deepagents/src/config.ts | 222 -- libs/deepagents/src/index.ts | 93 - .../src/middleware/agent-memory.test.ts | 372 -- .../deepagents/src/middleware/agent-memory.ts | 301 -- .../src/middleware/fs.eviction.test.ts | 134 - libs/deepagents/src/middleware/fs.int.test.ts | 1265 ------- libs/deepagents/src/middleware/fs.ts | 887 ----- .../src/middleware/hitl.int.test.ts | 331 -- libs/deepagents/src/middleware/index.test.ts | 406 -- libs/deepagents/src/middleware/index.ts | 45 - libs/deepagents/src/middleware/memory.test.ts | 263 -- libs/deepagents/src/middleware/memory.ts | 319 -- .../src/middleware/patch_tool_calls.ts | 85 - libs/deepagents/src/middleware/skills.test.ts | 484 --- libs/deepagents/src/middleware/skills.ts | 524 --- .../src/middleware/subagents-hitl.int.test.ts | 664 ---- .../src/middleware/subagents.int.test.ts | 441 --- libs/deepagents/src/middleware/subagents.ts | 493 --- .../src/middleware/summarization.test.ts | 444 --- .../src/middleware/summarization.ts | 666 ---- libs/deepagents/src/middleware/utils.test.ts | 96 - libs/deepagents/src/middleware/utils.ts | 96 - libs/deepagents/src/skills/index.int.test.ts | 294 -- libs/deepagents/src/skills/index.ts | 19 - libs/deepagents/src/skills/loader.test.ts | 349 -- libs/deepagents/src/skills/loader.ts | 390 -- libs/deepagents/src/testing/utils.ts | 277 -- libs/deepagents/src/types.ts | 389 -- libs/deepagents/tsconfig.json | 9 - libs/deepagents/tsdown.config.ts | 22 - libs/deepagents/vitest.config.ts | 40 - package.json | 8 +- plans/modular-architecture-plan.md | 3261 +++++++++++++++++ pnpm-lock.yaml | 65 +- 64 files changed, 3692 insertions(+), 18690 deletions(-) create mode 100644 .gitmodules create mode 100644 MODULARIZATION_SUMMARY.md create mode 160000 libs/deepagents delete mode 100644 libs/deepagents/CHANGELOG.md delete mode 100644 libs/deepagents/README.md delete mode 100644 libs/deepagents/package.json delete mode 100644 libs/deepagents/src/agent.int.test.ts delete mode 100644 libs/deepagents/src/agent.test-d.ts delete mode 100644 libs/deepagents/src/agent.ts delete mode 100644 libs/deepagents/src/backends/composite.test.ts delete mode 100644 libs/deepagents/src/backends/composite.ts delete mode 100644 libs/deepagents/src/backends/filesystem.test.ts delete mode 100644 libs/deepagents/src/backends/filesystem.ts delete mode 100644 libs/deepagents/src/backends/index.ts delete mode 100644 libs/deepagents/src/backends/protocol.test.ts delete mode 100644 libs/deepagents/src/backends/protocol.ts delete mode 100644 libs/deepagents/src/backends/sandbox.test.ts delete mode 100644 libs/deepagents/src/backends/sandbox.ts delete mode 100644 libs/deepagents/src/backends/state.test.ts delete mode 100644 libs/deepagents/src/backends/state.ts delete mode 100644 libs/deepagents/src/backends/store.test.ts delete mode 100644 libs/deepagents/src/backends/store.ts delete mode 100644 libs/deepagents/src/backends/utils.test.ts delete mode 100644 libs/deepagents/src/backends/utils.ts delete mode 100644 libs/deepagents/src/config.test.ts delete mode 100644 libs/deepagents/src/config.ts delete mode 100644 libs/deepagents/src/index.ts delete mode 100644 libs/deepagents/src/middleware/agent-memory.test.ts delete mode 100644 libs/deepagents/src/middleware/agent-memory.ts delete mode 100644 libs/deepagents/src/middleware/fs.eviction.test.ts delete mode 100644 libs/deepagents/src/middleware/fs.int.test.ts delete mode 100644 libs/deepagents/src/middleware/fs.ts delete mode 100644 libs/deepagents/src/middleware/hitl.int.test.ts delete mode 100644 libs/deepagents/src/middleware/index.test.ts delete mode 100644 libs/deepagents/src/middleware/index.ts delete mode 100644 libs/deepagents/src/middleware/memory.test.ts delete mode 100644 libs/deepagents/src/middleware/memory.ts delete mode 100644 libs/deepagents/src/middleware/patch_tool_calls.ts delete mode 100644 libs/deepagents/src/middleware/skills.test.ts delete mode 100644 libs/deepagents/src/middleware/skills.ts delete mode 100644 libs/deepagents/src/middleware/subagents-hitl.int.test.ts delete mode 100644 libs/deepagents/src/middleware/subagents.int.test.ts delete mode 100644 libs/deepagents/src/middleware/subagents.ts delete mode 100644 libs/deepagents/src/middleware/summarization.test.ts delete mode 100644 libs/deepagents/src/middleware/summarization.ts delete mode 100644 libs/deepagents/src/middleware/utils.test.ts delete mode 100644 libs/deepagents/src/middleware/utils.ts delete mode 100644 libs/deepagents/src/skills/index.int.test.ts delete mode 100644 libs/deepagents/src/skills/index.ts delete mode 100644 libs/deepagents/src/skills/loader.test.ts delete mode 100644 libs/deepagents/src/skills/loader.ts delete mode 100644 libs/deepagents/src/testing/utils.ts delete mode 100644 libs/deepagents/src/types.ts delete mode 100644 libs/deepagents/tsconfig.json delete mode 100644 libs/deepagents/tsdown.config.ts delete mode 100644 libs/deepagents/vitest.config.ts create mode 100644 plans/modular-architecture-plan.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..8a1c62223 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libs/deepagents"] + path = libs/deepagents + url = https://github.com/DavinciDreams/deepagentjs.git diff --git a/MODULARIZATION_SUMMARY.md b/MODULARIZATION_SUMMARY.md new file mode 100644 index 000000000..e72bf549f --- /dev/null +++ b/MODULARIZATION_SUMMARY.md @@ -0,0 +1,375 @@ +# Modularization Summary + +## Problem Statement + +This modularization effort was initiated to address critical architectural issues that led to an embarrassing situation where PR submissions were incorrectly directed to LangChain's repository instead of the intended DavinciDreams repository. The root causes were: + +1. **Incorrect Repository References**: All `package.json` files referenced `langchain-ai/deepagentsjs` instead of `DavinciDreams/deepagentsjs`, causing PR submissions to LangChain's repository. + +2. **Tight Coupling**: The `agents-of-empire` application had direct dependencies on LangChain packages (`@langchain/anthropic`, `@langchain/openai`, `langchain`, etc.), violating the principle that applications should only depend on the `deepagents` library abstraction. + +3. **Lack of Abstraction Layer**: No adapter pattern existed between the deepagents framework and LangChain, making future framework swaps difficult. + +4. **Monolithic Structure**: Framework code was mixed with application code in a single repository, making version management and independent development challenging. + +## Phases Overview + +The modularization was completed across 6 phases: + +- **Phase 1**: Repository Rebranding - Fixed all repository URLs and metadata +- **Phase 2**: Decouple App from LangChain - Removed direct LangChain dependencies from the app +- **Phase 3**: Create Adapter Pattern for LangChain - Implemented abstraction layer +- **Phase 4**: Model Provider Abstraction - Unified interface for model providers +- **Phase 5**: Submodule Migration - Extracted framework as git submodule +- **Phase 6**: Add Peer Dependencies - Optimized dependency management + +## Detailed Changes + +### Phase 1: Repository Rebranding + +**Files Modified:** +- `package.json` (root) +- `libs/deepagents/package.json` +- `libs/cli/package.json` +- `libs/deepagents/README.md` +- `libs/cli/README.md` + +**Changes:** +- Updated repository URLs from `git+https://github.com/langchain-ai/deepagentsjs.git` to `git+https://github.com/DavinciDreams/deepagentsjs.git` +- Updated author field to `DavinciDreams` +- Updated bugs URL to `https://github.com/DavinciDreams/deepagentsjs/issues` +- Updated homepage URLs to point to DavinciDreams repository + +### Phase 2: Decouple App from LangChain + +**Files Modified:** +- `apps/agents-of-empire/package.json` +- `apps/agents-of-empire/src/examples/DeepAgentExample.ts` +- `pnpm-lock.yaml` + +**Changes:** +- Removed direct LangChain dependencies from app: + - `@langchain/anthropic` + - `@langchain/core` + - `@langchain/langgraph` + - `@langchain/openai` + - `langchain` + - `langsmith` +- Updated imports to use deepagents exports instead of direct LangChain imports +- App now only depends on `deepagents` library + +### Phase 3: Create Adapter Pattern for LangChain + +**Files Created:** +- `libs/deepagents/src/adapters/types.ts` - Core abstraction interfaces +- `libs/deepagents/src/adapters/tool-adapter.ts` - Tool creation abstraction +- `libs/deepagents/src/adapters/providers/anthropic.ts` - Anthropic provider +- `libs/deepagents/src/adapters/providers/openai.ts` - OpenAI provider +- `libs/deepagents/src/adapters/providers/index.ts` - Provider registry +- `libs/deepagents/src/adapters/index.ts` - Adapter exports + +**Files Modified:** +- `libs/deepagents/src/index.ts` - Added adapter exports + +**Changes:** +- Created `ToolDefinition` interface for tool creation +- Created `ModelProvider` interface for model abstraction +- Implemented `AnthropicProvider` and `OpenAIProvider` classes +- Created `createTool()` function that wraps LangChain's tool function +- Created provider registry with `getProvider()` function + +### Phase 4: Model Provider Abstraction + +**Files Modified:** +- `libs/deepagents/src/adapters/providers/anthropic.ts` +- `libs/deepagents/src/adapters/providers/openai.ts` +- `libs/deepagents/src/adapters/providers/index.ts` + +**Changes:** +- Unified configuration interface via `ModelConfig` +- Consistent API across all providers +- Provider registry for easy provider lookup + +### Phase 5: Submodule Migration + +**Files Created:** +- `.gitmodules` - Git submodule configuration + +**Files Modified:** +- `libs/deepagents` - Now a git submodule pointing to `https://github.com/DavinciDreams/deepagentjs.git` + +**Changes:** +- Framework code extracted to separate repository +- Submodule reference properly tracked +- `.gitmodules` file created with submodule configuration + +### Phase 6: Add Peer Dependencies + +**Files Modified:** +- `libs/deepagents/package.json` + +**Changes:** +- Moved model provider packages to `peerDependencies`: + - `@langchain/anthropic` (optional) + - `@langchain/openai` (optional) + - `@langchain/core` (required) + - `@langchain/langgraph` (required) + - `langchain` (required) +- Added `peerDependenciesMeta` to mark optional dependencies +- Reduced bundle size by allowing consumers to control versions + +## Architecture Changes + +### Before Modularization + +``` +deepagentsjs/ +├── apps/ +│ └── agents-of-empire/ +│ ├── package.json (direct LangChain deps) +│ └── src/ +│ └── examples/ +│ └── DeepAgentExample.ts (direct LangChain imports) +├── libs/ +│ ├── deepagents/ +│ │ ├── src/ +│ │ │ ├── agent.ts +│ │ │ ├── types.ts +│ │ │ └── ... +│ │ └── package.json (incorrect repo URL) +│ └── cli/ +│ └── package.json (incorrect repo URL) +├── package.json (incorrect repo URL) +└── .git/ +``` + +**Key Issues:** +- App directly imports from LangChain packages +- No abstraction layer +- Incorrect repository URLs +- Monolithic structure + +### After Modularization + +``` +deepagentsjs/ +├── apps/ +│ └── agents-of-empire/ +│ ├── package.json (only deepagents dep) +│ └── src/ +│ └── examples/ +│ └── DeepAgentExample.ts (uses deepagents exports) +├── libs/ +│ └── deepagents/ (git submodule) +│ ├── src/ +│ │ ├── adapters/ +│ │ │ ├── types.ts +│ │ │ ├── tool-adapter.ts +│ │ │ ├── providers/ +│ │ │ │ ├── anthropic.ts +│ │ │ │ ├── openai.ts +│ │ │ │ └── index.ts +│ │ │ └── index.ts +│ │ ├── agent.ts +│ │ ├── types.ts +│ │ └── index.ts (exports adapters) +│ └── package.json (correct repo URL, peer deps) +├── .gitmodules +├── package.json (correct repo URL) +└── .git/ +``` + +**Key Improvements:** +- Clean adapter abstraction layer +- App only depends on deepagents +- Correct repository URLs +- Framework as separate submodule +- Peer dependencies for flexibility + +## Migration Guide + +### For Application Developers + +#### Installing Dependencies + +```bash +# Install deepagents framework +npm install deepagents + +# Install model providers you plan to use +npm install @langchain/anthropic # for Claude models +npm install @langchain/openai # for GPT models +``` + +#### Creating Tools + +**Before (Direct LangChain):** +```typescript +import { tool } from "langchain"; +import { z } from "zod"; + +const searchTool = tool( + async ({ query }) => { /* ... */ }, + { + name: "search", + description: "Search for information", + schema: z.object({ + query: z.string().describe("The search query"), + }), + } +); +``` + +**After (Using deepagents):** +```typescript +import { createTool } from "deepagents"; +import { z } from "zod"; + +const searchTool = createTool({ + name: "search", + description: "Search for information", + schema: z.object({ + query: z.string().describe("The search query"), + }), + execute: async ({ query }) => { /* ... */ }, +}); +``` + +#### Creating Models + +**Before (Direct LangChain):** +```typescript +import { ChatAnthropic } from "@langchain/anthropic"; + +const model = new ChatAnthropic({ + model: "claude-3-5-sonnet-20241022", + temperature: 0, +}); +``` + +**After (Using deepagents):** +```typescript +import { AnthropicProvider } from "deepagents"; + +const provider = new AnthropicProvider(); +const model = provider.createModel({ + modelName: "claude-3-5-sonnet-20241022", + temperature: 0, +}); +``` + +#### Creating Agents + +```typescript +import { createDeepAgent, createTool, AnthropicProvider } from "deepagents"; +import { z } from "zod"; + +// Create a tool +const searchTool = createTool({ + name: "search", + description: "Search for information", + schema: z.object({ + query: z.string().describe("The search query"), + }), + execute: async ({ query }) => { + return `Results for "${query}"`; + }, +}); + +// Create a model +const provider = new AnthropicProvider(); +const model = provider.createModel({ + modelName: "claude-3-5-sonnet-20241022", + temperature: 0, +}); + +// Create the agent +const agent = createDeepAgent({ + model, + tools: [searchTool], + systemPrompt: "You are a helpful assistant.", +}); +``` + +### For Framework Contributors + +#### Cloning with Submodules + +```bash +git clone --recurse-submodules https://github.com/DavinciDreams/deepagentsjs.git +cd deepagentsjs +``` + +#### Updating Submodules + +```bash +# Update to latest framework version +git submodule update --remote + +# Update to specific version +cd libs/deepagents +git checkout v2.0.0 +cd ../.. +git add libs/deepagents +git commit -m "Update framework to v2.0.0" +``` + +## Breaking Changes + +### For Application Developers + +1. **Direct LangChain Imports No Longer Work**: Applications must now use the deepagents exports instead of importing directly from LangChain packages. + +2. **Model Provider Dependencies**: Model providers (`@langchain/anthropic`, `@langchain/openai`) are now peer dependencies and must be installed separately. + +3. **Tool Creation API Change**: The `tool()` function from LangChain is replaced with `createTool()` from deepagents with a slightly different API. + +### For Framework Consumers + +1. **Peer Dependencies**: The following packages are now peer dependencies and must be installed by consumers: + - `@langchain/core` (required) + - `@langchain/langgraph` (required) + - `langchain` (required) + - `@langchain/anthropic` (optional, for Anthropic models) + - `@langchain/openai` (optional, for OpenAI models) + +2. **Repository URL Changes**: The repository URLs have changed from `langchain-ai/deepagentsjs` to `DavinciDreams/deepagentsjs`. + +3. **Submodule Structure**: The framework is now a git submodule at `libs/deepagents`, requiring `--recurse-submodules` when cloning. + +## Benefits + +1. **Correct Repository Management**: PR submissions now go to the correct DavinciDreams repository. + +2. **Clean Architecture**: Clear separation between framework and application concerns. + +3. **Framework Agnostic**: Adapter pattern allows future framework swaps without breaking applications. + +4. **Reduced Bundle Size**: Peer dependencies allow consumers to control versions and reduce bundle size. + +5. **Independent Versioning**: Framework can be versioned and released independently from applications. + +6. **Better Developer Experience**: Consistent API across all model providers. + +## Future Enhancements + +1. **Additional Model Providers**: Easy to add new providers (Google, Cohere, etc.) by implementing the `ModelProvider` interface. + +2. **Alternative Framework Support**: Adapter pattern allows swapping LangChain for other frameworks in the future. + +3. **Enhanced Tool Validation**: Add runtime validation for tool schemas. + +4. **Provider Discovery**: Automatic detection of available model providers. + +## Related Files + +- [Modular Architecture Plan](./plans/modular-architecture-plan.md) - Detailed technical specification +- [.gitmodules](./.gitmodules) - Git submodule configuration +- [libs/deepagents/package.json](./libs/deepagents/package.json) - Framework package configuration +- [apps/agents-of-empire/package.json](./apps/agents-of-empire/package.json) - App package configuration + +## Version Information + +- Framework version: 1.6.0 +- Modularization completed: 2026-01-31 +- Breaking changes: Yes diff --git a/apps/agents-of-empire/README.md b/apps/agents-of-empire/README.md index a1be153a7..5999be8dd 100644 --- a/apps/agents-of-empire/README.md +++ b/apps/agents-of-empire/README.md @@ -203,4 +203,4 @@ MIT Inspired by [ralv.ai](https://ralv.ai/) and [Ido Salomon's RTS interface](https://x.com/idosal1/status/2014748619480371707). -Built with [LangGraph Deep Agents](https://github.com/langchain-ai/deepagentsjs). +Built with [LangGraph Deep Agents](https://github.com/DavinciDreams/deepagentsjs). diff --git a/apps/agents-of-empire/package.json b/apps/agents-of-empire/package.json index 56eff73c1..5eb8c923f 100644 --- a/apps/agents-of-empire/package.json +++ b/apps/agents-of-empire/package.json @@ -10,19 +10,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@langchain/anthropic": "^1.3.11", - "@langchain/core": "^1.1.16", - "@langchain/langgraph": "^1.1.1", - "@langchain/openai": "^1.2.3", "@react-three/drei": "^9.121.4", "@react-three/fiber": "^9.1.2", "@react-three/postprocessing": "^3.0.4", - "deepagents": "^1.6.0", + "deepagents": "workspace:*", "dotenv": "^17.2.3", "framer-motion": "^12.0.6", "immer": "^10.0.0", - "langchain": "^1.2.12", - "langsmith": "^0.4.8", "openai": "^6.17.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/agents-of-empire/src/examples/DeepAgentExample.ts b/apps/agents-of-empire/src/examples/DeepAgentExample.ts index cc12ff1e3..6585d0896 100644 --- a/apps/agents-of-empire/src/examples/DeepAgentExample.ts +++ b/apps/agents-of-empire/src/examples/DeepAgentExample.ts @@ -6,44 +6,38 @@ */ import "dotenv/config"; -import { tool } from "langchain"; import { z } from "zod"; -import { createDeepAgent, type SubAgent } from "deepagents"; -import { ChatAnthropic } from "@langchain/anthropic"; +import { createDeepAgent, type SubAgent, createTool, AnthropicProvider } from "deepagents"; // ============================================================================ // Define Tools // ============================================================================ -const searchTool = tool( - async ({ query }: { query: string }) => { +const searchTool = createTool({ + name: "search", + description: "Search for information", + schema: z.object({ + query: z.string().describe("The search query"), + }), + execute: async ({ query }: { query: string }) => { console.log("Searching for:", query); // Simulate search return `Results for "${query}": Found 5 relevant documents.`; }, - { - name: "search", - description: "Search for information", - schema: z.object({ - query: z.string().describe("The search query"), - }), - } -); +}); -const writeFileTool = tool( - async ({ path, content }: { path: string; content: string }) => { +const writeFileTool = createTool({ + name: "write_file", + description: "Write content to a file", + schema: z.object({ + path: z.string().describe("The file path"), + content: z.string().describe("The file content"), + }), + execute: async ({ path, content }: { path: string; content: string }) => { console.log("Writing file:", path); return `Successfully wrote ${content.length} bytes to ${path}`; }, - { - name: "write_file", - description: "Write content to a file", - schema: z.object({ - path: z.string().describe("The file path"), - content: z.string().describe("The file content"), - }), - } -); +}); // ============================================================================ // Define Subagents @@ -67,11 +61,14 @@ const coderSubAgent: SubAgent = { // Create the Deep Agent // ============================================================================ +const anthropicProvider = new AnthropicProvider(); +const model = anthropicProvider.createModel({ + temperature: 0, + modelName: "claude-sonnet-4-20250514", +}); + const agent = createDeepAgent({ - model: new ChatAnthropic({ - model: "claude-sonnet-4-20250514", - temperature: 0, - }), + model, tools: [searchTool, writeFileTool], systemPrompt: `You are a strategic commander in the Agents of Empire game. diff --git a/libs/cli/README.md b/libs/cli/README.md index 314c535fa..bf0f55c49 100644 --- a/libs/cli/README.md +++ b/libs/cli/README.md @@ -5,7 +5,7 @@ > ⚠️ **Experimental**: This package is highly experimental and under active development. APIs, features, and behavior may change entirely in future releases without notice. -**DeepAgents CLI** - An AI coding assistant that runs in your terminal, powered by [DeepAgents](https://github.com/langchain-ai/deepagents). +**DeepAgents CLI** - An AI coding assistant that runs in your terminal, powered by [DeepAgents](https://github.com/DavinciDreams/deepagentsjs). This package wraps the Python [deepagents-cli](https://pypi.org/project/deepagents-cli/) as platform-specific binaries, allowing you to use the full-featured CLI without installing Python. @@ -125,7 +125,7 @@ npm install -g deepagents-cli ### Unsupported platform -If your platform is not supported, please [open an issue](https://github.com/langchain-ai/deepagentsjs/issues). +If your platform is not supported, please [open an issue](https://github.com/DavinciDreams/deepagentsjs/issues). ## Version Synchronization @@ -151,7 +151,7 @@ console.log(`Version: ${version}`); ## License -MIT - see [LICENSE](https://github.com/langchain-ai/deepagentsjs/blob/main/LICENSE) +MIT - see [LICENSE](https://github.com/DavinciDreams/deepagentsjs/blob/main/LICENSE) ## Related diff --git a/libs/cli/package.json b/libs/cli/package.json index a624e67b1..49fc0552f 100644 --- a/libs/cli/package.json +++ b/libs/cli/package.json @@ -40,7 +40,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/langchain-ai/deepagentsjs.git", + "url": "git+https://github.com/DavinciDreams/deepagentsjs.git", "directory": "libs/cli" }, "keywords": [ @@ -52,11 +52,11 @@ "langgraph", "terminal" ], - "author": "LangChain", + "author": "DavinciDreams", "bugs": { - "url": "https://github.com/langchain-ai/deepagentsjs/issues" + "url": "https://github.com/DavinciDreams/deepagentsjs/issues" }, - "homepage": "https://github.com/langchain-ai/deepagentsjs/tree/main/libs/cli#readme", + "homepage": "https://github.com/DavinciDreams/deepagentsjs/tree/main/libs/cli#readme", "engines": { "node": ">=18" }, diff --git a/libs/deepagents b/libs/deepagents new file mode 160000 index 000000000..76158aece --- /dev/null +++ b/libs/deepagents @@ -0,0 +1 @@ +Subproject commit 76158aece24f1a38a18e6a199a14faa7e87f3af5 diff --git a/libs/deepagents/CHANGELOG.md b/libs/deepagents/CHANGELOG.md deleted file mode 100644 index 9c1089029..000000000 --- a/libs/deepagents/CHANGELOG.md +++ /dev/null @@ -1,94 +0,0 @@ -# deepagents - -## 1.6.0 - -### Minor Changes - -- [`10c4e8b`](https://github.com/langchain-ai/deepagentsjs/commit/10c4e8b6f805cf682daf4227efc2a98372002fa0) Thanks [@christian-bromann](https://github.com/christian-bromann)! - feat(deepagents): align JS implementation with Python deepagents - -## 1.5.1 - -### Patch Changes - -- [#133](https://github.com/langchain-ai/deepagentsjs/pull/133) [`0fa85f6`](https://github.com/langchain-ai/deepagentsjs/commit/0fa85f61695af4ad6cdea4549c798e8219448bbb) Thanks [@christian-bromann](https://github.com/christian-bromann)! - chore(deepagents): update deps - -## 1.5.0 - -### Minor Changes - -- [`b3bb68b`](https://github.com/langchain-ai/deepagentsjs/commit/b3bb68bcaee21849ce55d32bc350c02f77b7d5dd) Thanks [@christian-bromann](https://github.com/christian-bromann)! - feat(deepagents): port backend agnostic skills - -- [`b3bb68b`](https://github.com/langchain-ai/deepagentsjs/commit/b3bb68bcaee21849ce55d32bc350c02f77b7d5dd) Thanks [@christian-bromann](https://github.com/christian-bromann)! - feat(deepagents): add MemoryMiddleware for AGENTS.md support - -### Patch Changes - -- [#125](https://github.com/langchain-ai/deepagentsjs/pull/125) [`06a2631`](https://github.com/langchain-ai/deepagentsjs/commit/06a2631b9e0eeefbcc40c637bad93c96f1c8a092) Thanks [@christian-bromann](https://github.com/christian-bromann)! - fix(deepagents): align with Python interfaces - -## 1.4.2 - -### Patch Changes - -- [`c77537a`](https://github.com/langchain-ai/deepagentsjs/commit/c77537abeb9d02104c938cdf13b3774cd8b1bd03) Thanks [@christian-bromann](https://github.com/christian-bromann)! - fix(deepagents): define type bag to better type extraction - -## 1.4.1 - -### Patch Changes - -- [#109](https://github.com/langchain-ai/deepagentsjs/pull/109) [`9043796`](https://github.com/langchain-ai/deepagentsjs/commit/90437968e7fddfe08601eec586f705b7b44e618f) Thanks [@christian-bromann](https://github.com/christian-bromann)! - fix(deepagents): improve type inference - -- [#109](https://github.com/langchain-ai/deepagentsjs/pull/109) [`9043796`](https://github.com/langchain-ai/deepagentsjs/commit/90437968e7fddfe08601eec586f705b7b44e618f) Thanks [@christian-bromann](https://github.com/christian-bromann)! - fix(deepagents): support SystemMessage as prompt - -- [#109](https://github.com/langchain-ai/deepagentsjs/pull/109) [`9043796`](https://github.com/langchain-ai/deepagentsjs/commit/90437968e7fddfe08601eec586f705b7b44e618f) Thanks [@christian-bromann](https://github.com/christian-bromann)! - fix(deepagents): use proper ToolMessage.isInstance - -## 1.4.0 - -### Minor Changes - -- [#98](https://github.com/langchain-ai/deepagentsjs/pull/98) [`321ecf3`](https://github.com/langchain-ai/deepagentsjs/commit/321ecf3193be01fd2173123307f43a41f8d2edf5) Thanks [@christian-bromann](https://github.com/christian-bromann)! - chore(deepagents): properly infer types from createAgent, also fix "Channel "files" already exists with a different type." bug - -## 1.3.1 - -### Patch Changes - -- 27c4211: Fix 'Channel "files" already exists with a different type.' error due to different schema identity - -## 1.3.0 - -### Minor Changes - -- 6b914ba: Add CompiledSubAgent back to `createDeepAgent` -- 94b71fb: Allow passing `metadata` to the resulting ToolMessage when editing or saving a file - -## 1.2.0 - -### Minor Changes - -- 73445c2: Add readRaw method to filesystem backend protocol - -### Patch Changes - -- c346110: Fix warnings being shown when creating deep agent -- 3b3e703: fix(store): make sure `getNamespace` can be overridden - -## 1.1.1 - -### Patch Changes - -- dbdef4c: thread config options to subagents - -## 1.1.0 - -### Minor Changes - -- 39c64e1: Bumping to 1.1.0 because there was an old published version of 1.0.0 which was deprecated - -## 1.0.0 - -### Major Changes - -- bd0d712: Bring deepagentsjs up to date with latest 1.0.0 versions of LangChain and LangGraph. Add pluggable backends as well. - - DeepagentsJS now relies on middleware instead of built in tools. - createDeepAgent's signature has been brought in line with createAgent's signature from LangChain 1.0. - - createDeepAgent now accepts a `backend` field in which users can specify custom backends for the deep agent filesystem. diff --git a/libs/deepagents/README.md b/libs/deepagents/README.md deleted file mode 100644 index 25154a071..000000000 --- a/libs/deepagents/README.md +++ /dev/null @@ -1,660 +0,0 @@ -# 🧠🤖 Deep Agents - -Using an LLM to call tools in a loop is the simplest form of an agent. -This architecture, however, can yield agents that are "shallow" and fail to plan and act over longer, more complex tasks. - -Applications like "Deep Research", "Manus", and "Claude Code" have gotten around this limitation by implementing a combination of four things: -a **planning tool**, **sub agents**, access to a **file system**, and a **detailed prompt**. - -> 💡 **Tip:** Looking for the Python version of this package? See [langchain-ai/deepagents](https://github.com/langchain-ai/deepagents) - -![Deep Agents](https://blog.langchain.com/content/images/2025/07/Screenshot-2025-07-30-at-9.08.32-AM.png) - -`deepagents` is a TypeScript package that implements these in a general purpose way so that you can easily create a Deep Agent for your application. - -**Acknowledgements: This project was primarily inspired by Claude Code, and initially was largely an attempt to see what made Claude Code general purpose, and make it even more so.** - -[![npm version](https://img.shields.io/npm/v/deepagents.svg)](https://www.npmjs.com/package/deepagents) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/) - -[Documentation](https://docs.langchain.com/oss/javascript/deepagents/overview) | [Examples](./examples) | [Report Bug](https://github.com/langchain-ai/deepagentsjs/issues) | [Request Feature](https://github.com/langchain-ai/deepagentsjs/issues) - -## 📖 Overview - -Using an LLM to call tools in a loop is the simplest form of an agent. However, this architecture can yield agents that are "shallow" and fail to plan and act over longer, more complex tasks. - -Applications like **Deep Research**, **Manus**, and **Claude Code** have overcome this limitation by implementing a combination of four key components: - -1. **Planning Tool** - Strategic task decomposition -2. **Sub-Agents** - Specialized agents for subtasks -3. **File System Access** - Persistent state and memory -4. **Detailed Prompts** - Context-rich instructions - -**Deep Agents** is a TypeScript package that implements these patterns in a general-purpose way, enabling you to easily create sophisticated agents for your applications. - -## ✨ Features - -- 🎯 **Task Planning & Decomposition** - Break complex tasks into manageable steps -- 🤖 **Sub-Agent Architecture** - Delegate specialized work to focused agents -- 💾 **File System Integration** - Persistent memory and state management -- 🌊 **Streaming Support** - Real-time updates, token streaming, and progress tracking -- 🔄 **LangGraph Powered** - Built on the robust LangGraph framework -- 📝 **TypeScript First** - Full type safety and IntelliSense support -- 🔌 **Extensible** - Easy to customize and extend for your use case - -## Installation - -```bash -# npm -npm install deepagents - -# yarn -yarn add deepagents - -# pnpm -pnpm add deepagents -``` - -## Usage - -(To run the example below, you will need to `npm install @langchain/tavily`). - -Make sure to set `TAVILY_API_KEY` in your environment. You can generate one [here](https://www.tavily.com/). - -```typescript -import { tool } from "langchain"; -import { TavilySearch } from "@langchain/tavily"; -import { createDeepAgent } from "deepagents"; -import { z } from "zod"; - -// Web search tool -const internetSearch = tool( - async ({ - query, - maxResults = 5, - topic = "general", - includeRawContent = false, - }: { - query: string; - maxResults?: number; - topic?: "general" | "news" | "finance"; - includeRawContent?: boolean; - }) => { - const tavilySearch = new TavilySearch({ - maxResults, - tavilyApiKey: process.env.TAVILY_API_KEY, - includeRawContent, - topic, - }); - return await tavilySearch._call({ query }); - }, - { - name: "internet_search", - description: "Run a web search", - schema: z.object({ - query: z.string().describe("The search query"), - maxResults: z - .number() - .optional() - .default(5) - .describe("Maximum number of results to return"), - topic: z - .enum(["general", "news", "finance"]) - .optional() - .default("general") - .describe("Search topic category"), - includeRawContent: z - .boolean() - .optional() - .default(false) - .describe("Whether to include raw content"), - }), - }, -); - -// System prompt to steer the agent to be an expert researcher -const researchInstructions = `You are an expert researcher. Your job is to conduct thorough research, and then write a polished report. - -You have access to an internet search tool as your primary means of gathering information. - -## \`internet_search\` - -Use this to run an internet search for a given query. You can specify the max number of results to return, the topic, and whether raw content should be included. -`; - -// Create the deep agent -const agent = createDeepAgent({ - tools: [internetSearch], - systemPrompt: researchInstructions, -}); - -// Invoke the agent -const result = await agent.invoke({ - messages: [{ role: "user", content: "What is langgraph?" }], -}); -``` - -See [examples/research/research-agent.ts](examples/research/research-agent.ts) for a more complex example. - -The agent created with `createDeepAgent` is just a LangGraph graph - so you can interact with it (streaming, human-in-the-loop, memory, studio) -in the same way you would any LangGraph agent. - -## Core Capabilities - -**Planning & Task Decomposition** - -Deep Agents include a built-in `write_todos` tool that enables agents to break down complex tasks into discrete steps, track progress, and adapt plans as new information emerges. - -**Context Management** - -File system tools (`ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`) allow agents to offload large context to memory, preventing context window overflow and enabling work with variable-length tool results. - -**Subagent Spawning** - -A built-in `task` tool enables agents to spawn specialized subagents for context isolation. This keeps the main agent's context clean while still going deep on specific subtasks. - -**Long-term Memory** - -Extend agents with persistent memory across threads using LangGraph's Store. Agents can save and retrieve information from previous conversations. - -## Customizing Deep Agents - -There are several parameters you can pass to `createDeepAgent` to create your own custom deep agent. - -### `model` - -By default, `deepagents` uses `"claude-sonnet-4-5-20250929"`. You can customize this by passing any [LangChain model object](https://js.langchain.com/docs/integrations/chat/). - -```typescript -import { ChatAnthropic } from "@langchain/anthropic"; -import { ChatOpenAI } from "@langchain/openai"; -import { createDeepAgent } from "deepagents"; - -// Using Anthropic -const agent = createDeepAgent({ - model: new ChatAnthropic({ - model: "claude-sonnet-4-20250514", - temperature: 0, - }), -}); - -// Using OpenAI -const agent2 = createDeepAgent({ - model: new ChatOpenAI({ - model: "gpt-5", - temperature: 0, - }), -}); -``` - -### `systemPrompt` - -Deep Agents come with a built-in system prompt. This is relatively detailed prompt that is heavily based on and inspired by [attempts](https://github.com/kn1026/cc/blob/main/claudecode.md) to [replicate](https://github.com/asgeirtj/system_prompts_leaks/blob/main/Anthropic/claude-code.md) -Claude Code's system prompt. It was made more general purpose than Claude Code's system prompt. The default prompt contains detailed instructions for how to use the built-in planning tool, file system tools, and sub agents. - -Each deep agent tailored to a use case should include a custom system prompt specific to that use case as well. The importance of prompting for creating a successful deep agent cannot be overstated. - -```typescript -import { createDeepAgent } from "deepagents"; - -const researchInstructions = `You are an expert researcher. Your job is to conduct thorough research, and then write a polished report.`; - -const agent = createDeepAgent({ - systemPrompt: researchInstructions, -}); -``` - -### `tools` - -Just like with tool-calling agents, you can provide a deep agent with a set of tools that it has access to. - -```typescript -import { tool } from "langchain"; -import { TavilySearch } from "@langchain/tavily"; -import { createDeepAgent } from "deepagents"; -import { z } from "zod"; - -const internetSearch = tool( - async ({ - query, - maxResults = 5, - topic = "general", - includeRawContent = false, - }: { - query: string; - maxResults?: number; - topic?: "general" | "news" | "finance"; - includeRawContent?: boolean; - }) => { - const tavilySearch = new TavilySearch({ - maxResults, - tavilyApiKey: process.env.TAVILY_API_KEY, - includeRawContent, - topic, - }); - return await tavilySearch._call({ query }); - }, - { - name: "internet_search", - description: "Run a web search", - schema: z.object({ - query: z.string().describe("The search query"), - maxResults: z.number().optional().default(5), - topic: z - .enum(["general", "news", "finance"]) - .optional() - .default("general"), - includeRawContent: z.boolean().optional().default(false), - }), - }, -); - -const agent = createDeepAgent({ - tools: [internetSearch], -}); -``` - -### `middleware` - -`createDeepAgent` is implemented with middleware that can be customized. You can provide additional middleware to extend functionality, add tools, or implement custom hooks. - -```typescript -import { tool } from "langchain"; -import { createDeepAgent } from "deepagents"; -import type { AgentMiddleware } from "langchain"; -import { z } from "zod"; - -const getWeather = tool( - async ({ city }: { city: string }) => { - return `The weather in ${city} is sunny.`; - }, - { - name: "get_weather", - description: "Get the weather in a city.", - schema: z.object({ - city: z.string().describe("The city to get weather for"), - }), - }, -); - -const getTemperature = tool( - async ({ city }: { city: string }) => { - return `The temperature in ${city} is 70 degrees Fahrenheit.`; - }, - { - name: "get_temperature", - description: "Get the temperature in a city.", - schema: z.object({ - city: z.string().describe("The city to get temperature for"), - }), - }, -); - -class WeatherMiddleware implements AgentMiddleware { - tools = [getWeather, getTemperature]; -} - -const agent = createDeepAgent({ - model: "claude-sonnet-4-20250514", - middleware: [new WeatherMiddleware()], -}); -``` - -### `subagents` - -A main feature of Deep Agents is their ability to spawn subagents. You can specify custom subagents that your agent can hand off work to in the subagents parameter. Sub agents are useful for context quarantine (to help not pollute the overall context of the main agent) as well as custom instructions. - -`subagents` should be a list of objects that follow the `SubAgent` interface: - -```typescript -interface SubAgent { - name: string; - description: string; - systemPrompt: string; - tools?: StructuredTool[]; - model?: LanguageModelLike | string; - middleware?: AgentMiddleware[]; - interruptOn?: Record; -} -``` - -**SubAgent fields:** - -- **name**: This is the name of the subagent, and how the main agent will call the subagent -- **description**: This is the description of the subagent that is shown to the main agent -- **systemPrompt**: This is the prompt used for the subagent -- **tools**: This is the list of tools that the subagent has access to. -- **model**: Optional model name or model instance. -- **middleware**: Additional middleware to attach to the subagent. See [here](https://docs.langchain.com/oss/typescript/langchain/middleware) for an introduction into middleware and how it works with createAgent. -- **interruptOn**: A custom interrupt config that specifies human-in-the-loop interactions for your tools. - -#### Using SubAgent - -```typescript -import { tool } from "langchain"; -import { TavilySearch } from "@langchain/tavily"; -import { createDeepAgent, type SubAgent } from "deepagents"; -import { z } from "zod"; - -const internetSearch = tool( - async ({ - query, - maxResults = 5, - topic = "general", - includeRawContent = false, - }: { - query: string; - maxResults?: number; - topic?: "general" | "news" | "finance"; - includeRawContent?: boolean; - }) => { - const tavilySearch = new TavilySearch({ - maxResults, - tavilyApiKey: process.env.TAVILY_API_KEY, - includeRawContent, - topic, - }); - return await tavilySearch._call({ query }); - }, - { - name: "internet_search", - description: "Run a web search", - schema: z.object({ - query: z.string(), - maxResults: z.number().optional().default(5), - topic: z - .enum(["general", "news", "finance"]) - .optional() - .default("general"), - includeRawContent: z.boolean().optional().default(false), - }), - }, -); - -const researchSubagent: SubAgent = { - name: "research-agent", - description: "Used to research more in depth questions", - systemPrompt: "You are a great researcher", - tools: [internetSearch], - model: "gpt-4o", // Optional override, defaults to main agent model -}; - -const subagents = [researchSubagent]; - -const agent = createDeepAgent({ - model: "claude-sonnet-4-20250514", - subagents: subagents, -}); -``` - -### `interruptOn` - -A common reality for agents is that some tool operations may be sensitive and require human approval before execution. Deep Agents supports human-in-the-loop workflows through LangGraph's interrupt capabilities. You can configure which tools require approval using a checkpointer. - -These tool configs are passed to our prebuilt [HITL middleware](https://docs.langchain.com/oss/typescript/langchain/middleware#human-in-the-loop) so that the agent pauses execution and waits for feedback from the user before executing configured tools. - -```typescript -import { tool } from "langchain"; -import { createDeepAgent } from "deepagents"; -import { z } from "zod"; - -const getWeather = tool( - async ({ city }: { city: string }) => { - return `The weather in ${city} is sunny.`; - }, - { - name: "get_weather", - description: "Get the weather in a city.", - schema: z.object({ - city: z.string(), - }), - }, -); - -const agent = createDeepAgent({ - model: "claude-sonnet-4-20250514", - tools: [getWeather], - interruptOn: { - get_weather: { - allowedDecisions: ["approve", "edit", "reject"], - }, - }, -}); -``` - -### `backend` - -Deep Agents use backends to manage file system operations and memory storage. You can configure different backends depending on your needs: - -```typescript -import { - createDeepAgent, - StateBackend, - StoreBackend, - FilesystemBackend, - CompositeBackend, -} from "deepagents"; -import { MemorySaver } from "@langchain/langgraph"; -import { InMemoryStore } from "@langchain/langgraph-checkpoint"; - -// Default: StateBackend (in-memory, ephemeral) -const agent1 = createDeepAgent({ - // No backend specified - uses StateBackend by default -}); - -// StoreBackend: Persistent storage using LangGraph Store -const agent2 = createDeepAgent({ - backend: (config) => new StoreBackend(config), - store: new InMemoryStore(), // Provide a store - checkpointer: new MemorySaver(), // Optional: for conversation persistence -}); - -// FilesystemBackend: Store files on actual filesystem -const agent3 = createDeepAgent({ - backend: (config) => new FilesystemBackend({ rootDir: "./agent-workspace" }), -}); - -// CompositeBackend: Combine multiple backends -const agent4 = createDeepAgent({ - backend: (config) => - new CompositeBackend({ - state: new StateBackend(config), - store: config.store ? new StoreBackend(config) : undefined, - }), - store: new InMemoryStore(), - checkpointer: new MemorySaver(), -}); -``` - -See [examples/backends/](examples/backends/) for detailed examples of each backend type. - -### Sandbox Execution - -For agents that need to run shell commands, you can create a sandbox backend by extending `BaseSandbox`. This enables the `execute` tool which allows agents to run arbitrary shell commands in an isolated environment. - -```typescript -import { - createDeepAgent, - BaseSandbox, - type ExecuteResponse, - type FileUploadResponse, - type FileDownloadResponse, -} from "deepagents"; -import { spawn } from "child_process"; - -// Create a concrete sandbox by extending BaseSandbox -class LocalShellSandbox extends BaseSandbox { - readonly id = "local-shell"; - private readonly workingDirectory: string; - - constructor(workingDirectory: string) { - super(); - this.workingDirectory = workingDirectory; - } - - // Only execute() is required - BaseSandbox implements all file operations - async execute(command: string): Promise { - return new Promise((resolve) => { - const child = spawn("/bin/bash", ["-c", command], { - cwd: this.workingDirectory, - }); - - const chunks: string[] = []; - child.stdout.on("data", (data) => chunks.push(data.toString())); - child.stderr.on("data", (data) => chunks.push(data.toString())); - - child.on("close", (exitCode) => { - resolve({ - output: chunks.join(""), - exitCode, - truncated: false, - }); - }); - }); - } - - async uploadFiles( - files: Array<[string, Uint8Array]>, - ): Promise { - // Implement file upload logic - return files.map(([path]) => ({ path, error: null })); - } - - async downloadFiles(paths: string[]): Promise { - // Implement file download logic - return paths.map((path) => ({ - path, - content: null, - error: "file_not_found", - })); - } -} - -// Use the sandbox with your agent -const sandbox = new LocalShellSandbox("./workspace"); - -const agent = createDeepAgent({ - backend: sandbox, - systemPrompt: "You can run shell commands using the execute tool.", -}); -``` - -When using a sandbox backend, the agent gains access to an `execute` tool that can run shell commands. The tool automatically returns the command output, exit code, and whether the output was truncated. - -See [examples/sandbox/local-sandbox.ts](examples/sandbox/local-sandbox.ts) for a complete implementation. - -## Deep Agents Middleware - -Deep Agents are built with a modular middleware architecture. As a reminder, Deep Agents have access to: - -- A planning tool -- A filesystem for storing context and long-term memories -- The ability to spawn subagents - -Each of these features is implemented as separate middleware. When you create a deep agent with `createDeepAgent`, we automatically attach **todoListMiddleware**, **FilesystemMiddleware** and **SubAgentMiddleware** to your agent. - -Middleware is a composable concept, and you can choose to add as many or as few middleware to an agent depending on your use case. That means that you can also use any of the aforementioned middleware independently! - -### TodoListMiddleware - -Planning is integral to solving complex problems. If you've used claude code recently, you'll notice how it writes out a To-Do list before tackling complex, multi-part tasks. You'll also notice how it can adapt and update this To-Do list on the fly as more information comes in. - -**todoListMiddleware** provides your agent with a tool specifically for updating this To-Do list. Before, and while it executes a multi-part task, the agent is prompted to use the write_todos tool to keep track of what its doing, and what still needs to be done. - -```typescript -import { createAgent, todoListMiddleware } from "langchain"; - -// todoListMiddleware is included by default in createDeepAgent -// You can customize it if building a custom agent -const agent = createAgent({ - model: "claude-sonnet-4-20250514", - middleware: [ - todoListMiddleware({ - // Optional: Custom addition to the system prompt - systemPrompt: "Use the write_todos tool to...", - }), - ], -}); -``` - -### 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 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 -- **glob**: Find files matching a pattern -- **grep**: Search for text within files -- **execute**: Run shell commands (only available when using a `SandboxBackendProtocol`) - -```typescript -import { createAgent } from "langchain"; -import { createFilesystemMiddleware } from "deepagents"; - -// FilesystemMiddleware is included by default in createDeepAgent -// You can customize it if building a custom agent -const agent = createAgent({ - model: "claude-sonnet-4-20250514", - middleware: [ - createFilesystemMiddleware({ - backend: ..., // Optional: customize storage backend - systemPrompt: "Write to the filesystem when...", // Optional custom system prompt override - customToolDescriptions: { - ls: "Use the ls tool when...", - read_file: "Use the read_file tool to...", - }, // Optional: Custom descriptions for filesystem tools - }), - ], -}); -``` - -### SubAgentMiddleware - -Handing off tasks to subagents is a great way to isolate context, keeping the context window of the main (supervisor) agent clean while still going deep on a task. The subagents middleware allows you supply subagents through a task tool. - -A subagent is defined with a name, description, system prompt, and tools. You can also provide a subagent with a custom model, or with additional middleware. This can be particularly useful when you want to give the subagent an additional state key to share with the main agent. - -```typescript -import { tool } from "langchain"; -import { createAgent } from "langchain"; -import { createSubAgentMiddleware, type SubAgent } from "deepagents"; -import { z } from "zod"; - -const getWeather = tool( - async ({ city }: { city: string }) => { - return `The weather in ${city} is sunny.`; - }, - { - name: "get_weather", - description: "Get the weather in a city.", - schema: z.object({ - city: z.string(), - }), - }, -); - -const weatherSubagent: SubAgent = { - name: "weather", - description: "This subagent can get weather in cities.", - systemPrompt: "Use the get_weather tool to get the weather in a city.", - tools: [getWeather], - model: "gpt-4o", - middleware: [], -}; - -const agent = createAgent({ - model: "claude-sonnet-4-20250514", - middleware: [ - createSubAgentMiddleware({ - defaultModel: "claude-sonnet-4-20250514", - defaultTools: [], - subagents: [weatherSubagent], - }), - ], -}); -``` diff --git a/libs/deepagents/package.json b/libs/deepagents/package.json deleted file mode 100644 index 44b5e4408..000000000 --- a/libs/deepagents/package.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "name": "deepagents", - "version": "1.6.0", - "description": "Deep Agents - a library for building controllable AI agents with LangGraph", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "type": "module", - "scripts": { - "build": "tsdown", - "clean": "rm -rf dist/ .tsdown/", - "dev": "tsc --watch", - "typecheck": "tsc --noEmit", - "prepublishOnly": "pnpm build", - "test": "vitest run", - "test:unit": "vitest run", - "test:int": "vitest run --mode int", - "test:watch": "vitest", - "test:ui": "vitest --ui", - "test:coverage": "vitest run --coverage" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/langchain-ai/deepagentsjs.git" - }, - "keywords": [ - "ai", - "agents", - "langgraph", - "langchain", - "typescript", - "llm" - ], - "author": "LangChain", - "license": "MIT", - "bugs": { - "url": "https://github.com/langchain-ai/deepagentsjs/issues" - }, - "homepage": "https://github.com/langchain-ai/deepagentsjs#readme", - "dependencies": { - "@langchain/anthropic": "^1.3.11", - "@langchain/core": "^1.1.16", - "@langchain/langgraph": "^1.1.1", - "fast-glob": "^3.3.3", - "langchain": "^1.2.12", - "micromatch": "^4.0.8", - "yaml": "^2.8.2", - "zod": "^4.3.5" - }, - "devDependencies": { - "@langchain/langgraph-checkpoint": "^1.0.0", - "@langchain/openai": "^1.2.3", - "@langchain/tavily": "^1.2.0", - "@tsconfig/recommended": "^1.0.13", - "@types/micromatch": "^4.0.10", - "@types/node": "^25.0.9", - "@types/uuid": "^11.0.0", - "@vitest/coverage-v8": "^4.0.17", - "@vitest/ui": "^4.0.17", - "dotenv": "^17.2.3", - "tsdown": "^0.19.0", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "uuid": "^13.0.0", - "vitest": "^4.0.17" - }, - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, - "./package.json": "./package.json" - }, - "files": [ - "dist/**/*" - ] -} diff --git a/libs/deepagents/src/agent.int.test.ts b/libs/deepagents/src/agent.int.test.ts deleted file mode 100644 index d15f8f76a..000000000 --- a/libs/deepagents/src/agent.int.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { AIMessage, HumanMessage } from "@langchain/core/messages"; -import { createDeepAgent } from "./index.js"; -import { - SAMPLE_MODEL, - TOY_BASKETBALL_RESEARCH, - ResearchMiddleware, - ResearchMiddlewareWithTools, - SampleMiddlewareWithTools, - SampleMiddlewareWithToolsAndState, - WeatherToolMiddleware, - assertAllDeepAgentQualities, - getSoccerScores, - getWeather, - sampleTool, - extractToolsFromAgent, -} from "./testing/utils.js"; - -describe("DeepAgents Integration Tests", () => { - it.concurrent("should create a base deep agent", () => { - const agent = createDeepAgent(); - assertAllDeepAgentQualities(agent); - }); - - it.concurrent("should create deep agent with tool", () => { - const agent = createDeepAgent({ tools: [sampleTool] }); - assertAllDeepAgentQualities(agent); - - const toolNames = Object.keys(extractToolsFromAgent(agent)); - expect(toolNames).toContain("sample_tool"); - }); - - it.concurrent("should create deep agent with middleware with tool", () => { - const agent = createDeepAgent({ middleware: [SampleMiddlewareWithTools] }); - assertAllDeepAgentQualities(agent); - - const toolNames = Object.keys(extractToolsFromAgent(agent)); - expect(toolNames).toContain("sample_tool"); - }); - - it.concurrent( - "should create deep agent with middleware with tool and state", - () => { - const agent = createDeepAgent({ - middleware: [SampleMiddlewareWithToolsAndState], - }); - assertAllDeepAgentQualities(agent); - - const toolNames = Object.keys(extractToolsFromAgent(agent)); - expect(toolNames).toContain("sample_tool"); - - expect(agent.graph.streamChannels).toContain("sample_input"); - }, - ); - - it.concurrent( - "should create deep agent with subagents", - { timeout: 90 * 1000 }, // 90s - async () => { - const subagents = [ - { - name: "weather_agent", - description: "Use this agent to get the weather", - systemPrompt: "You are a weather agent.", - tools: [getWeather], - model: SAMPLE_MODEL, - }, - ]; - const agent = createDeepAgent({ tools: [sampleTool], subagents }); - assertAllDeepAgentQualities(agent); - - const result = await agent.invoke({ - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && tc.args?.subagent_type === "weather_agent", - ), - ).toBe(true); - }, - ); - - it.concurrent( - "should create deep agent with subagents and general purpose", - { timeout: 90 * 1000 }, // 90s - async () => { - const subagents = [ - { - name: "weather_agent", - description: "Use this agent to get the weather", - systemPrompt: "You are a weather agent.", - tools: [getWeather], - model: SAMPLE_MODEL, - }, - ]; - const agent = createDeepAgent({ tools: [sampleTool], subagents }); - assertAllDeepAgentQualities(agent); - - const result = await agent.invoke({ - messages: [ - new HumanMessage( - "Use the general purpose subagent to call the sample tool", - ), - ], - }); - - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && tc.args?.subagent_type === "general-purpose", - ), - ).toBe(true); - }, - ); - - it.concurrent( - "should create deep agent with subagents with middleware", - { timeout: 90 * 1000 }, // 90s - async () => { - const subagents = [ - { - name: "weather_agent", - description: "Use this agent to get the weather", - systemPrompt: "You are a weather agent.", - tools: [], - model: SAMPLE_MODEL, - middleware: [WeatherToolMiddleware], - }, - ]; - const agent = createDeepAgent({ tools: [sampleTool], subagents }); - assertAllDeepAgentQualities(agent); - - const result = await agent.invoke({ - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && tc.args?.subagent_type === "weather_agent", - ), - ).toBe(true); - }, - ); - - it.concurrent( - "should create deep agent with custom subagents", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createDeepAgent({ - tools: [sampleTool], - subagents: [ - { - name: "weather_agent", - description: "Use this agent to get the weather", - systemPrompt: "You are a weather agent.", - tools: [getWeather], - model: SAMPLE_MODEL, - }, - { - name: "soccer_agent", - description: "Use this agent to get the latest soccer scores", - tools: [getSoccerScores], - model: SAMPLE_MODEL, - systemPrompt: "You are a soccer agent.", - }, - ], - }); - assertAllDeepAgentQualities(agent); - - const result = await agent.invoke({ - messages: [ - new HumanMessage( - "Look up the weather in Tokyo, and the latest scores for Manchester City!", - ), - ], - }); - - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && tc.args?.subagent_type === "weather_agent", - ), - ).toBe(true); - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && tc.args?.subagent_type === "soccer_agent", - ), - ).toBe(true); - }, - ); - - it.concurrent( - "should create deep agent with extended state and subagents", - { timeout: 90 * 1000 }, // 90s - async () => { - const subagents = [ - { - name: "basketball_info_agent", - description: - "Use this agent to get surface level info on any basketball topic", - systemPrompt: "You are a basketball info agent.", - middleware: [ResearchMiddlewareWithTools], - }, - ]; - const agent = createDeepAgent({ - tools: [sampleTool], - subagents, - middleware: [ResearchMiddleware], - }); - assertAllDeepAgentQualities(agent); - expect(agent.graph.streamChannels).toContain("research"); - - const result = await agent.invoke( - { - messages: [ - new HumanMessage("Get surface level info on lebron james"), - ], - }, - { recursionLimit: 100 }, - ); - - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && - tc.args?.subagent_type === "basketball_info_agent", - ), - ).toBe(true); - expect(result.research).toContain(TOY_BASKETBALL_RESEARCH); - }, - ); - - it.concurrent( - "should create deep agent with subagents no tools", - { timeout: 90 * 1000 }, // 90s - async () => { - const subagents = [ - { - name: "basketball_info_agent", - description: - "Use this agent to get surface level info on any basketball topic", - systemPrompt: "You are a basketball info agent.", - }, - ]; - const agent = createDeepAgent({ tools: [sampleTool], subagents }); - assertAllDeepAgentQualities(agent); - - const result = await agent.invoke( - { - messages: [ - new HumanMessage( - "Use the basketball info subagent to call the sample tool", - ), - ], - }, - { recursionLimit: 100 }, - ); - - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect( - toolCalls.some( - (tc: any) => - tc.name === "task" && - tc.args?.subagent_type === "basketball_info_agent", - ), - ).toBe(true); - }, - ); - - // Note: response_format with ToolStrategy is not yet available in LangChain TS v1 - // Skipping test_response_format_tool_strategy for now -}); diff --git a/libs/deepagents/src/agent.test-d.ts b/libs/deepagents/src/agent.test-d.ts deleted file mode 100644 index ee66afad8..000000000 --- a/libs/deepagents/src/agent.test-d.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Type tests for createDeepAgent - * - * These tests verify that the type inference works correctly for: - * - Custom middleware state schemas - * - Combined state from multiple middleware - * - * NOTE: These tests use actual invoke() calls to verify the runtime type inference - * is correct, not just ReturnType. - */ - -import { describe, it, expectTypeOf } from "vitest"; -import { - createAgent, - createMiddleware, - InferAgentMiddleware, - SystemMessage, -} from "langchain"; -import { z } from "zod/v4"; -import { createDeepAgent } from "./agent.js"; -import type { - MergedDeepAgentState, - InferSubagentByName, - InferDeepAgentSubagents, - InferCompiledSubagents, - InferRegularSubagents, -} from "./types.js"; -import type { FileData } from "./backends/protocol.js"; - -// Test middleware with research state -const ResearchStateSchema = z.object({ - research: z - .string() - .default("") - .meta({ - reducer: { - fn: (left: string, right: string | null) => right || left || "", - schema: z.string().nullable(), - }, - }), -}); - -const ResearchMiddleware = createMiddleware({ - name: "ResearchMiddleware", - stateSchema: ResearchStateSchema, -}); - -// Test middleware with counter state -const CounterStateSchema = z.object({ - counter: z - .number() - .default(0) - .meta({ - reducer: { - fn: (left: number, right: number | null) => - right !== null ? right : left, - schema: z.number().nullable(), - }, - }), -}); - -const CounterMiddleware = createMiddleware({ - name: "CounterMiddleware", - stateSchema: CounterStateSchema, -}); - -const MemoryStateSchema = z.object({ - memorySystem: z.string().default(""), -}); - -const MemoryMiddleware = createMiddleware({ - name: "MemoryMiddleware", - stateSchema: MemoryStateSchema, -}); - -describe("createDeepAgent types", () => { - it("should allow systemPrompt to be a string or SystemMessage", () => { - createDeepAgent({ - systemPrompt: "Hello, world!", - }); - createDeepAgent({ - systemPrompt: new SystemMessage({ - content: [ - { - type: "text", - text: "Hello, world!", - }, - ], - }), - }); - }); - - describe("MergedDeepAgentState helper type", () => { - it("should correctly merge middleware states", () => { - type TestMiddleware = readonly [ - typeof ResearchMiddleware, - typeof CounterMiddleware, - ]; - type TestSubagents = readonly []; - - type MergedState = MergedDeepAgentState; - - // Should include research from ResearchMiddleware - expectTypeOf().toHaveProperty("research"); - expectTypeOf().toEqualTypeOf(); - - // Should include counter from CounterMiddleware - expectTypeOf().toHaveProperty("counter"); - expectTypeOf().toEqualTypeOf(); - }); - }); - - describe("createDeepAgent return type using actual invoke", () => { - it("should infer state from custom middleware and subagents middleware", async () => { - const agent = createDeepAgent({ - middleware: [ResearchMiddleware], - subagents: [ - { - name: "Subagent1", - description: "Subagent1 description", - systemPrompt: "Subagent1 system prompt", - middleware: [CounterMiddleware], - }, - ], - }); - - // Use actual invoke call to check type inference - const result = await agent.invoke({ messages: [] }); - - // The result should include the research property typed as string - expectTypeOf(result).toHaveProperty("research"); - expectTypeOf(result.research).toEqualTypeOf(); - expectTypeOf(result).toHaveProperty("counter"); - expectTypeOf(result.counter).toEqualTypeOf(); - // should have built-in state - expectTypeOf(result).toHaveProperty("files"); - expectTypeOf(result.files).toEqualTypeOf>(); - expectTypeOf(result).toHaveProperty("todos"); - expectTypeOf(result.todos).toEqualTypeOf< - { - content: string; - status: "pending" | "in_progress" | "completed"; - }[] - >(); - - // Should also have messages - expectTypeOf(result).toHaveProperty("messages"); - }); - - it("should infer state from multiple middleware", async () => { - const agent = createDeepAgent({ - middleware: [ResearchMiddleware, CounterMiddleware], - }); - - const result = await agent.invoke({ messages: [] }); - - // Should have both research and counter with correct types - expectTypeOf(result).toHaveProperty("research"); - expectTypeOf(result.research).toEqualTypeOf(); - - expectTypeOf(result).toHaveProperty("counter"); - expectTypeOf(result.counter).toEqualTypeOf(); - }); - - it("should work with no custom middleware", async () => { - const agent = createDeepAgent({}); - - const result = await agent.invoke({ messages: [] }); - - // Should have messages - expectTypeOf(result).toHaveProperty("messages"); - }); - - it("should infer research as string not any", async () => { - const agent = createDeepAgent({ - middleware: [ResearchMiddleware], - }); - - const result = await agent.invoke({ messages: [] }); - - // Verify research is specifically string, not any - expectTypeOf(result.research).not.toBeAny(); - expectTypeOf(result.research).toBeString(); - }); - }); - - describe("DeepAgent type", () => { - it("should correctly infer the type of the agent", () => { - const agent = createDeepAgent({}); - expectTypeOf(agent).toHaveProperty("~deepAgentTypes"); - expectTypeOf(agent["~deepAgentTypes"]).toHaveProperty("Subagents"); - expectTypeOf(agent["~deepAgentTypes"].Subagents).toEqualTypeOf< - readonly [] - >(); - }); - - it("can infer the type of the subagent", () => { - const _agent = createDeepAgent({ - subagents: [ - { - name: "Subagent1", - description: "Subagent1 description", - systemPrompt: "Subagent1 system prompt", - middleware: [CounterMiddleware], - }, - ], - }); - const subagent1 = {} as InferSubagentByName; - expectTypeOf(subagent1).toHaveProperty("name"); - expectTypeOf(subagent1.name).toEqualTypeOf<"Subagent1">(); - expectTypeOf(subagent1).toHaveProperty("description"); - expectTypeOf( - subagent1.description, - ).toEqualTypeOf<"Subagent1 description">(); - expectTypeOf( - subagent1.systemPrompt, - ).toEqualTypeOf<"Subagent1 system prompt">(); - }); - - it("can infer the type of a createAgent sub agent", () => { - const _agent = createDeepAgent({ - subagents: [ - { - name: "Subagent2", - description: "Subagent2 description", - systemPrompt: "Subagent2 system prompt", - }, - { - name: "Subagent1", - description: "Subagent1 description", - runnable: createAgent({ - name: "Subagent1", - model: "claude-sonnet-4-20250514", - description: "Subagent1 description", - systemPrompt: "Subagent1 system prompt", - middleware: [MemoryMiddleware], - }), - }, - ], - }); - - const subagent1 = {} as InferSubagentByName; - expectTypeOf(subagent1).toHaveProperty("name"); - expectTypeOf(subagent1.name).toEqualTypeOf<"Subagent1">(); - expectTypeOf(subagent1).toHaveProperty("description"); - expectTypeOf( - subagent1.description, - ).toEqualTypeOf<"Subagent1 description">(); - - // InferDeepAgentSubagents returns the full subagents tuple - type AllSubagents = InferDeepAgentSubagents; - expectTypeOf().toHaveProperty("systemPrompt"); - expectTypeOf().toHaveProperty("runnable"); - - // InferCompiledSubagents extracts only subagents with `runnable` - type Compiled = InferCompiledSubagents; - expectTypeOf().toHaveProperty("runnable"); - expectTypeOf().toEqualTypeOf<"Subagent1">(); - - type CompiledMiddleware = InferAgentMiddleware; - expectTypeOf().toHaveProperty("stateSchema"); - expectTypeOf().toExtend< - typeof MemoryStateSchema | undefined - >(); - - // InferRegularSubagents extracts only subagents without `runnable` - type Regular = InferRegularSubagents; - expectTypeOf().toHaveProperty("systemPrompt"); - expectTypeOf().toEqualTypeOf<"Subagent2">(); - }); - }); -}); diff --git a/libs/deepagents/src/agent.ts b/libs/deepagents/src/agent.ts deleted file mode 100644 index 6018cb399..000000000 --- a/libs/deepagents/src/agent.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { - createAgent, - humanInTheLoopMiddleware, - anthropicPromptCachingMiddleware, - todoListMiddleware, - summarizationMiddleware, - SystemMessage, - type AgentMiddleware, - type ResponseFormat, -} from "langchain"; -import type { - ClientTool, - ServerTool, - StructuredTool, -} from "@langchain/core/tools"; -import type { BaseStore } from "@langchain/langgraph-checkpoint"; - -import { - createFilesystemMiddleware, - createSubAgentMiddleware, - createPatchToolCallsMiddleware, - createMemoryMiddleware, - createSkillsMiddleware, - type SubAgent, -} from "./middleware/index.js"; -import { StateBackend } from "./backends/index.js"; -import { InteropZodObject } from "@langchain/core/utils/types"; -import { CompiledSubAgent } from "./middleware/subagents.js"; -import type { - CreateDeepAgentParams, - DeepAgent, - DeepAgentTypeConfig, - FlattenSubAgentMiddleware, -} from "./types.js"; - -/** - * required for type inference - */ -import type * as _messages from "@langchain/core/messages"; -import type * as _Command from "@langchain/langgraph"; - -const BASE_PROMPT = `In order to complete the objective that the user asks of you, you have access to a number of standard tools.`; - -/** - * Create a Deep Agent with middleware-based architecture. - * - * Matches Python's create_deep_agent function, using middleware for all features: - * - Todo management (todoListMiddleware) - * - Filesystem tools (createFilesystemMiddleware) - * - Subagent delegation (createSubAgentMiddleware) - * - Conversation summarization (summarizationMiddleware) - * - Prompt caching (anthropicPromptCachingMiddleware) - * - Tool call patching (createPatchToolCallsMiddleware) - * - Human-in-the-loop (humanInTheLoopMiddleware) - optional - * - * @param params Configuration parameters for the agent - * @returns ReactAgent instance ready for invocation with properly inferred state types - * - * @example - * ```typescript - * // Middleware with custom state - * const ResearchMiddleware = createMiddleware({ - * name: "ResearchMiddleware", - * stateSchema: z.object({ research: z.string().default("") }), - * }); - * - * const agent = createDeepAgent({ - * middleware: [ResearchMiddleware], - * }); - * - * const result = await agent.invoke({ messages: [...] }); - * // result.research is properly typed as string - * ``` - */ -export function createDeepAgent< - TResponse extends ResponseFormat = ResponseFormat, - ContextSchema extends InteropZodObject = InteropZodObject, - const TMiddleware extends readonly AgentMiddleware[] = readonly [], - const TSubagents extends readonly (SubAgent | CompiledSubAgent)[] = - readonly [], - const TTools extends readonly (ClientTool | ServerTool)[] = readonly [], ->( - params: CreateDeepAgentParams< - TResponse, - ContextSchema, - TMiddleware, - TSubagents, - TTools - > = {} as CreateDeepAgentParams< - TResponse, - ContextSchema, - TMiddleware, - TSubagents, - TTools - >, -) { - const { - model = "claude-sonnet-4-5-20250929", - tools = [], - systemPrompt, - middleware: customMiddleware = [], - subagents = [], - responseFormat, - contextSchema, - checkpointer, - store, - backend, - interruptOn, - name, - memory, - skills, - } = params; - - // Combine system prompt with base prompt like Python implementation - const finalSystemPrompt = systemPrompt - ? typeof systemPrompt === "string" - ? `${systemPrompt}\n\n${BASE_PROMPT}` - : new SystemMessage({ - content: [ - { - type: "text", - text: BASE_PROMPT, - }, - ...(typeof systemPrompt.content === "string" - ? [{ type: "text", text: systemPrompt.content }] - : systemPrompt.content), - ], - }) - : BASE_PROMPT; - - // Create backend configuration for filesystem middleware - // If no backend is provided, use a factory that creates a StateBackend - const filesystemBackend = backend - ? backend - : (config: { state: unknown; store?: BaseStore }) => - new StateBackend(config); - - // Add skills middleware if skill sources provided - const skillsMiddleware = - skills != null && skills.length > 0 - ? [ - createSkillsMiddleware({ - backend: filesystemBackend, - sources: skills, - }), - ] - : []; - - // Built-in middleware array - const builtInMiddleware = [ - // Provides todo list management capabilities for tracking tasks - todoListMiddleware(), - // Add skills middleware if skill sources provided - ...skillsMiddleware, - // Enables filesystem operations and optional long-term memory storage - createFilesystemMiddleware({ backend: filesystemBackend }), - // Enables delegation to specialized subagents for complex tasks - createSubAgentMiddleware({ - defaultModel: model, - defaultTools: tools as StructuredTool[], - defaultMiddleware: [ - // Subagent middleware: Todo list management - todoListMiddleware(), - // Subagent middleware: Skills (if provided) - ...skillsMiddleware, - // Subagent middleware: Filesystem operations - createFilesystemMiddleware({ - backend: filesystemBackend, - }), - // Subagent middleware: Automatic conversation summarization when token limits are approached - summarizationMiddleware({ - model, - trigger: { tokens: 170_000 }, - keep: { messages: 6 }, - }), - // Subagent middleware: Anthropic prompt caching for improved performance - anthropicPromptCachingMiddleware({ - unsupportedModelBehavior: "ignore", - }), - // Subagent middleware: Patches tool calls for compatibility - createPatchToolCallsMiddleware(), - ], - defaultInterruptOn: interruptOn, - subagents: subagents as unknown as (SubAgent | CompiledSubAgent)[], - generalPurposeAgent: true, - }), - // Automatically summarizes conversation history when token limits are approached - summarizationMiddleware({ - model, - trigger: { tokens: 170_000 }, - keep: { messages: 6 }, - }), - // Enables Anthropic prompt caching for improved performance and reduced costs - anthropicPromptCachingMiddleware({ - unsupportedModelBehavior: "ignore", - }), - // Patches tool calls to ensure compatibility across different model providers - createPatchToolCallsMiddleware(), - // Add memory middleware if memory sources provided - ...(memory != null && memory.length > 0 - ? [ - createMemoryMiddleware({ - backend: filesystemBackend, - sources: memory, - }), - ] - : []), - ] as const; - - // Add human-in-the-loop middleware if interrupt config provided - if (interruptOn) { - // builtInMiddleware is typed as readonly to enable type inference - // however, we need to push to it to add the middleware, so let's ignore the type error - // @ts-expect-error - builtInMiddleware is readonly - builtInMiddleware.push(humanInTheLoopMiddleware({ interruptOn })); - } - - // Combine built-in middleware with custom middleware - // The custom middleware is typed as TMiddleware to preserve type information - const allMiddleware = [ - ...builtInMiddleware, - ...(customMiddleware as unknown as TMiddleware), - ] as const; - - // Note: Recursion limit of 1000 (matching Python behavior) should be passed - // at invocation time: agent.invoke(input, { recursionLimit: 1000 }) - const agent = createAgent({ - model, - systemPrompt: finalSystemPrompt, - tools: tools as StructuredTool[], - middleware: allMiddleware as unknown as AgentMiddleware[], - responseFormat: responseFormat as ResponseFormat, - contextSchema, - checkpointer, - store, - name, - }); - - // Combine custom middleware with flattened subagent middleware for complete type inference - // This ensures InferMiddlewareStates captures state from both sources - type AllMiddleware = readonly [ - ...typeof builtInMiddleware, - ...TMiddleware, - ...FlattenSubAgentMiddleware, - ]; - - // Return as DeepAgent with proper DeepAgentTypeConfig - // - Response: TResponse (from responseFormat parameter) - // - State: undefined (state comes from middleware) - // - Context: ContextSchema - // - Middleware: AllMiddleware (built-in + custom + subagent middleware for state inference) - // - Tools: TTools - // - Subagents: TSubagents (for type-safe streaming) - return agent as unknown as DeepAgent< - DeepAgentTypeConfig< - TResponse, - undefined, - ContextSchema, - AllMiddleware, - TTools, - TSubagents - > - >; -} diff --git a/libs/deepagents/src/backends/composite.test.ts b/libs/deepagents/src/backends/composite.test.ts deleted file mode 100644 index 90bacee03..000000000 --- a/libs/deepagents/src/backends/composite.test.ts +++ /dev/null @@ -1,720 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { CompositeBackend } from "./composite.js"; -import { StateBackend } from "./state.js"; -import { StoreBackend } from "./store.js"; -import { FilesystemBackend } from "./filesystem.js"; -import { InMemoryStore } from "@langchain/langgraph-checkpoint"; -import { getCurrentTaskInput } from "@langchain/langgraph"; -import * as fs from "fs/promises"; -import * as fsSync from "fs"; -import * as path from "path"; -import * as os from "os"; -import type { - ExecuteResponse, - FileDownloadResponse, - FileUploadResponse, - SandboxBackendProtocol, -} from "./protocol.js"; - -/** - * Mock sandbox backend for testing execute delegation - */ -class MockSandboxBackend implements SandboxBackendProtocol { - readonly id = "mock-sandbox"; - public lastCommand: string | null = null; - - async execute(command: string): Promise { - this.lastCommand = command; - return { output: `Executed: ${command}`, exitCode: 0, truncated: false }; - } - - lsInfo() { - return []; - } - read() { - return ""; - } - readRaw() { - return { content: [], created_at: "", modified_at: "" }; - } - grepRaw() { - return []; - } - globInfo() { - return []; - } - write() { - return { path: "" }; - } - edit() { - return { path: "" }; - } - uploadFiles(files: Array<[string, Uint8Array]>): FileUploadResponse[] { - return files.map(([path]) => ({ path, error: null })); - } - downloadFiles(paths: string[]): FileDownloadResponse[] { - return paths.map((path) => ({ - path, - content: new Uint8Array(), - error: null, - })); - } -} - -vi.mock("@langchain/langgraph", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...(actual as any), - getCurrentTaskInput: vi.fn(), - }; -}); - -/** - * Helper to create a unique temporary directory for each test - */ -function createTempDir(): string { - return fsSync.mkdtempSync(path.join(os.tmpdir(), "deepagents-composite-")); -} - -/** - * Helper to recursively remove a directory - */ -async function removeDir(dirPath: string) { - try { - await fs.rm(dirPath, { recursive: true, force: true }); - } catch { - // Ignore errors during cleanup - } -} - -/** - * Helper to write a file with automatic parent directory creation - */ -async function writeFile(filePath: string, content: string) { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, "utf-8"); -} - -/** - * Helper to create a mock config with state and store - */ -function makeConfig() { - const state = { - messages: [], - files: {}, - }; - const store = new InMemoryStore(); - - vi.mocked(getCurrentTaskInput).mockReturnValue(state); - - const stateAndStore = { - state, - store, - }; - - const config = { - store, - configurable: {}, - }; - - return { state, store, stateAndStore, config }; -} - -describe("CompositeBackend", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should route operations between StateBackend and StoreBackend", async () => { - const { state, stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }); - - const stateRes = await composite.write("/file.txt", "alpha"); - expect(stateRes.filesUpdate).toBeDefined(); - expect(stateRes.path).toBe("/file.txt"); - Object.assign(state.files, stateRes.filesUpdate!); - - const storeRes = await composite.write("/memories/readme.md", "beta"); - expect(storeRes.error).toBeUndefined(); - expect(storeRes.filesUpdate).toBeNull(); - - const infos = await composite.lsInfo("/"); - const paths = infos.map((i) => i.path); - expect(paths).toContain("/file.txt"); - expect(paths).toContain("/memories/"); - - const matches1 = await composite.grepRaw("alpha", "/"); - expect(Array.isArray(matches1)).toBe(true); - if (Array.isArray(matches1)) { - expect(matches1.some((m) => m.path === "/file.txt")).toBe(true); - } - - const matches2 = await composite.grepRaw("beta", "/"); - expect(Array.isArray(matches2)).toBe(true); - if (Array.isArray(matches2)) { - expect(matches2.some((m) => m.path === "/memories/readme.md")).toBe(true); - } - - const glob = await composite.globInfo("**/*.md", "/"); - expect(glob.some((i) => i.path === "/memories/readme.md")).toBe(true); - }); - - it("should handle multiple routes", async () => { - const { state, stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - "/archive/": new StoreBackend(stateAndStore), - "/cache/": new StoreBackend(stateAndStore), - }); - - const resState = await composite.write("/temp.txt", "ephemeral data"); - expect(resState.filesUpdate).toBeDefined(); - expect(resState.path).toBe("/temp.txt"); - Object.assign(state.files, resState.filesUpdate!); - - const resMem = await composite.write( - "/memories/important.md", - "long-term memory", - ); - expect(resMem.filesUpdate).toBeNull(); - expect(resMem.path).toBe("/important.md"); - - const resArchive = await composite.write( - "/archive/old.log", - "archived log", - ); - expect(resArchive.filesUpdate).toBeNull(); - expect(resArchive.path).toBe("/old.log"); - - const resCache = await composite.write( - "/cache/session.json", - "cached session", - ); - expect(resCache.filesUpdate).toBeNull(); - expect(resCache.path).toBe("/session.json"); - - const infos = await composite.lsInfo("/"); - const paths = infos.map((i) => i.path); - expect(paths).toContain("/temp.txt"); - expect(paths).toContain("/memories/"); - expect(paths).toContain("/archive/"); - expect(paths).toContain("/cache/"); - - const memInfos = await composite.lsInfo("/memories/"); - const memPaths = memInfos.map((i) => i.path); - expect(memPaths).toContain("/memories/important.md"); - expect(memPaths).not.toContain("/temp.txt"); - expect(memPaths).not.toContain("/archive/old.log"); - - const allMatches = await composite.grepRaw(".", "/"); - expect(Array.isArray(allMatches)).toBe(true); - if (Array.isArray(allMatches)) { - const pathsWithContent = allMatches.map((m) => m.path); - expect(pathsWithContent).toContain("/temp.txt"); - expect(pathsWithContent).toContain("/memories/important.md"); - expect(pathsWithContent).toContain("/archive/old.log"); - expect(pathsWithContent).toContain("/cache/session.json"); - } - - const globResults = await composite.globInfo("**/*.md", "/"); - expect(globResults.some((i) => i.path === "/memories/important.md")).toBe( - true, - ); - - const editRes = await composite.edit( - "/memories/important.md", - "long-term", - "persistent", - false, - ); - expect(editRes.error).toBeUndefined(); - expect(editRes.occurrences).toBe(1); - - const updatedContent = await composite.read("/memories/important.md"); - expect(updatedContent).toContain("persistent memory"); - }); - - it("should handle nested directories correctly", async () => { - const { state, stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - "/archive/": new StoreBackend(stateAndStore), - }); - - const stateFiles: Record = { - "/temp.txt": "temp", - "/work/file1.txt": "work file 1", - "/work/projects/proj1.txt": "project 1", - }; - - for (const [path, content] of Object.entries(stateFiles)) { - const res = await composite.write(path, content); - if (res.filesUpdate) { - Object.assign(state.files, res.filesUpdate); - } - } - - const memoryFiles: Record = { - "/memories/important.txt": "important", - "/memories/diary/entry1.txt": "diary entry", - }; - - for (const [path, content] of Object.entries(memoryFiles)) { - await composite.write(path, content); - } - - const archiveFiles: Record = { - "/archive/old.txt": "old", - "/archive/2023/log.txt": "2023 log", - }; - - for (const [path, content] of Object.entries(archiveFiles)) { - await composite.write(path, content); - } - - const rootListing = await composite.lsInfo("/"); - const rootPaths = rootListing.map((fi) => fi.path); - expect(rootPaths).toContain("/temp.txt"); - expect(rootPaths).toContain("/work/"); - expect(rootPaths).toContain("/memories/"); - expect(rootPaths).toContain("/archive/"); - expect(rootPaths).not.toContain("/work/file1.txt"); - expect(rootPaths).not.toContain("/memories/important.txt"); - - const workListing = await composite.lsInfo("/work/"); - const workPaths = workListing.map((fi) => fi.path); - expect(workPaths).toContain("/work/file1.txt"); - expect(workPaths).toContain("/work/projects/"); - expect(workPaths).not.toContain("/work/projects/proj1.txt"); - - const memListing = await composite.lsInfo("/memories/"); - const memPaths = memListing.map((fi) => fi.path); - expect(memPaths).toContain("/memories/important.txt"); - expect(memPaths).toContain("/memories/diary/"); - expect(memPaths).not.toContain("/memories/diary/entry1.txt"); - - const archListing = await composite.lsInfo("/archive/"); - const archPaths = archListing.map((fi) => fi.path); - expect(archPaths).toContain("/archive/old.txt"); - expect(archPaths).toContain("/archive/2023/"); - expect(archPaths).not.toContain("/archive/2023/log.txt"); - }); - - it("should handle trailing slashes in ls", async () => { - const { state, stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/store/": new StoreBackend(stateAndStore), - }); - - const res = await composite.write("/file.txt", "content"); - Object.assign(state.files, res.filesUpdate!); - - await composite.write("/store/item.txt", "store content"); - - const listing = await composite.lsInfo("/"); - const paths = listing.map((fi) => fi.path); - expect(paths).toEqual(paths.slice().sort()); - - const emptyListing1 = await composite.lsInfo("/store/nonexistent/"); - expect(emptyListing1).toEqual([]); - - const emptyListing2 = await composite.lsInfo("/nonexistent/"); - expect(emptyListing2).toEqual([]); - - const listing1 = await composite.lsInfo("/store/"); - const listing2 = await composite.lsInfo("/store"); - expect(listing1.map((fi) => fi.path)).toEqual( - listing2.map((fi) => fi.path), - ); - }); - - it("should handle large tool result interception with default route", async () => { - const { config } = makeConfig(); - const { createFilesystemMiddleware } = await import("../middleware/fs.js"); - const { ToolMessage } = await import("@langchain/core/messages"); - const { Command } = await import("@langchain/langgraph"); - - const middleware = createFilesystemMiddleware({ - backend: (stateAndStore) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - toolTokenLimitBeforeEvict: 1000, - }); - - const largeContent = "z".repeat(5000); - const toolMessage = new ToolMessage({ - content: largeContent, - tool_call_id: "test_789", - name: "test_tool", - }); - - const mockToolFn = async () => toolMessage; - const mockToolCall = { name: "test_tool", args: {}, id: "test_789" }; - - const result = await (middleware as any).wrapToolCall( - { - toolCall: mockToolCall, - config: config, - state: { files: {}, messages: [] }, - runtime: {}, - }, - mockToolFn, - ); - - expect(result).toBeInstanceOf(Command); - expect(result.update.files).toBeDefined(); - expect(result.update.files["/large_tool_results/test_789"]).toBeDefined(); - expect(result.update.files["/large_tool_results/test_789"].content).toEqual( - [largeContent], - ); - - expect(result.update.messages).toHaveLength(1); - expect(result.update.messages[0].content).toContain( - "Tool result too large", - ); - }); - - it("should handle large tool result interception routed to store", async () => { - const { config } = makeConfig(); - const { createFilesystemMiddleware } = await import("../middleware/fs.js"); - const { ToolMessage } = await import("@langchain/core/messages"); - - const middleware = createFilesystemMiddleware({ - backend: (stateAndStore) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/large_tool_results/": new StoreBackend(stateAndStore), - }), - toolTokenLimitBeforeEvict: 1000, - }); - - const largeContent = "w".repeat(5000); - const toolMessage = new ToolMessage({ - content: largeContent, - tool_call_id: "test_routed_123", - name: "test_tool", - }); - - const mockToolFn = async () => toolMessage; - const mockToolCall = { name: "test_tool", args: {}, id: "test_routed_123" }; - - const result = await (middleware as any).wrapToolCall( - { - toolCall: mockToolCall, - config: config, - state: { files: {}, messages: [] }, - runtime: {}, - }, - mockToolFn, - ); - - expect(result).toBeInstanceOf(ToolMessage); - expect(result.content).toContain("Tool result too large"); - expect(result.content).toContain("/large_tool_results/test_routed_123"); - - const storedContent = await config.store.get( - ["filesystem"], - "/test_routed_123", - ); - expect(storedContent).toBeDefined(); - expect((storedContent!.value as any).content).toEqual([largeContent]); - }); - - it("should work with FilesystemBackend as default and StoreBackend route", async () => { - const tmpDir = createTempDir(); - try { - const { stateAndStore } = makeConfig(); - - const fsBackend = new FilesystemBackend({ - rootDir: tmpDir, - virtualMode: true, - }); - const storeBackend = new StoreBackend(stateAndStore); - const composite = new CompositeBackend(fsBackend, { - "/memories/": storeBackend, - }); - - const r1 = await composite.write("/hello.txt", "hello"); - expect(r1.error).toBeUndefined(); - expect(r1.filesUpdate).toBeNull(); - - const r2 = await composite.write("/memories/notes.md", "note"); - expect(r2.error).toBeUndefined(); - expect(r2.filesUpdate).toBeNull(); // Store also returns null - - const infosRoot = await composite.lsInfo("/"); - expect(infosRoot.some((i) => i.path === "/hello.txt")).toBe(true); - expect(infosRoot.some((i) => i.path === "/memories/")).toBe(true); - - const infosMem = await composite.lsInfo("/memories/"); - expect(infosMem.some((i) => i.path === "/memories/notes.md")).toBe(true); - - const gm1 = await composite.grepRaw("hello", "/"); - expect(Array.isArray(gm1)).toBe(true); - if (Array.isArray(gm1)) { - expect(gm1.some((m) => m.path === "/hello.txt")).toBe(true); - } - - const gm2 = await composite.grepRaw("note", "/"); - expect(Array.isArray(gm2)).toBe(true); - if (Array.isArray(gm2)) { - expect(gm2.some((m) => m.path === "/memories/notes.md")).toBe(true); - } - - const gl = await composite.globInfo("*.md", "/"); - expect(gl.some((i) => i.path === "/memories/notes.md")).toBe(true); - } finally { - await removeDir(tmpDir); - } - }); - - it("should work with StoreBackend as default and another StoreBackend route", async () => { - const { stateAndStore } = makeConfig(); - - const defaultStore = new StoreBackend(stateAndStore); - const memoriesStore = new StoreBackend(stateAndStore); - - const composite = new CompositeBackend(defaultStore, { - "/memories/": memoriesStore, - }); - - const res1 = await composite.write("/notes.txt", "default store content"); - expect(res1.error).toBeUndefined(); - expect(res1.path).toBe("/notes.txt"); - - const res2 = await composite.write( - "/memories/important.txt", - "routed store content", - ); - expect(res2.error).toBeUndefined(); - expect(res2.path).toBe("/important.txt"); - - const content1 = await composite.read("/notes.txt"); - expect(content1).toContain("default store content"); - - const content2 = await composite.read("/memories/important.txt"); - expect(content2).toContain("routed store content"); - - const infos = await composite.lsInfo("/"); - const paths = infos.map((i) => i.path); - expect(paths).toContain("/notes.txt"); - expect(paths).toContain("/memories/"); - - const matches1 = await composite.grepRaw("default", "/"); - expect(Array.isArray(matches1)).toBe(true); - if (Array.isArray(matches1)) { - expect(matches1.some((m) => m.path === "/notes.txt")).toBe(true); - } - - const matches2 = await composite.grepRaw("routed", "/"); - expect(Array.isArray(matches2)).toBe(true); - if (Array.isArray(matches2)) { - expect(matches2.some((m) => m.path === "/memories/important.txt")).toBe( - true, - ); - } - }); - - it("should handle nested directories with FilesystemBackend and StoreBackend", async () => { - const tmpDir = createTempDir(); - try { - const { stateAndStore } = makeConfig(); - - const files: Record = { - [path.join(tmpDir, "local.txt")]: "local file", - [path.join(tmpDir, "src", "main.py")]: "code", - [path.join(tmpDir, "src", "utils", "helper.py")]: "utils", - }; - - for (const [filePath, content] of Object.entries(files)) { - await writeFile(filePath, content); - } - - const fsBackend = new FilesystemBackend({ - rootDir: tmpDir, - virtualMode: true, - }); - const storeBackend = new StoreBackend(stateAndStore); - const composite = new CompositeBackend(fsBackend, { - "/memories/": storeBackend, - }); - - await composite.write("/memories/note1.txt", "note 1"); - await composite.write("/memories/deep/note2.txt", "note 2"); - await composite.write("/memories/deep/nested/note3.txt", "note 3"); - - const rootListing = await composite.lsInfo("/"); - const rootPaths = rootListing.map((fi) => fi.path); - expect(rootPaths).toContain("/local.txt"); - expect(rootPaths).toContain("/src/"); - expect(rootPaths).toContain("/memories/"); - expect(rootPaths).not.toContain("/src/main.py"); - expect(rootPaths).not.toContain("/memories/note1.txt"); - - const srcListing = await composite.lsInfo("/src/"); - const srcPaths = srcListing.map((fi) => fi.path); - expect(srcPaths).toContain("/src/main.py"); - expect(srcPaths).toContain("/src/utils/"); - expect(srcPaths).not.toContain("/src/utils/helper.py"); - - const memListing = await composite.lsInfo("/memories/"); - const memPaths = memListing.map((fi) => fi.path); - expect(memPaths).toContain("/memories/note1.txt"); - expect(memPaths).toContain("/memories/deep/"); - expect(memPaths).not.toContain("/memories/deep/note2.txt"); - - const deepListing = await composite.lsInfo("/memories/deep/"); - const deepPaths = deepListing.map((fi) => fi.path); - expect(deepPaths).toContain("/memories/deep/note2.txt"); - expect(deepPaths).toContain("/memories/deep/nested/"); - expect(deepPaths).not.toContain("/memories/deep/nested/note3.txt"); - } finally { - await removeDir(tmpDir); - } - }); - - describe("execute", () => { - it("should delegate execute to default sandbox backend", async () => { - const mockSandbox = new MockSandboxBackend(); - const { stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(mockSandbox, { - "/store/": new StoreBackend(stateAndStore), - }); - - const result = await composite.execute("echo hello"); - expect(result.output).toBe("Executed: echo hello"); - expect(result.exitCode).toBe(0); - expect(mockSandbox.lastCommand).toBe("echo hello"); - }); - - it("should throw error when default backend is not sandbox", () => { - const { stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/store/": new StoreBackend(stateAndStore), - }); - - expect(() => composite.execute("echo hello")).toThrow( - "doesn't support command execution", - ); - }); - }); - - describe("uploadFiles", () => { - it("should route uploads to correct backend based on path", async () => { - const { stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/store/": new StoreBackend(stateAndStore), - }); - - const files: Array<[string, Uint8Array]> = [ - ["/local.txt", new TextEncoder().encode("local content")], - ["/store/remote.txt", new TextEncoder().encode("remote content")], - ]; - - const result = await composite.uploadFiles(files); - expect(result).toHaveLength(2); - expect(result[0].path).toBe("/local.txt"); - expect(result[0].error).toBeNull(); - expect(result[1].path).toBe("/store/remote.txt"); - expect(result[1].error).toBeNull(); - }); - - it("should batch uploads by backend", async () => { - const { stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/store/": new StoreBackend(stateAndStore), - }); - - const files: Array<[string, Uint8Array]> = [ - ["/a.txt", new TextEncoder().encode("a")], - ["/store/b.txt", new TextEncoder().encode("b")], - ["/c.txt", new TextEncoder().encode("c")], - ["/store/d.txt", new TextEncoder().encode("d")], - ]; - - const result = await composite.uploadFiles(files); - expect(result).toHaveLength(4); - - // Check all succeeded - for (const r of result) { - expect(r.error).toBeNull(); - } - - // Check original paths preserved - expect(result[0].path).toBe("/a.txt"); - expect(result[1].path).toBe("/store/b.txt"); - expect(result[2].path).toBe("/c.txt"); - expect(result[3].path).toBe("/store/d.txt"); - }); - }); - - describe("downloadFiles", () => { - it("should route downloads to correct backend based on path", async () => { - const { state, stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/store/": new StoreBackend(stateAndStore), - }); - - // Write to both backends - const writeRes = await composite.write("/local.txt", "local content"); - if (writeRes.filesUpdate) { - Object.assign(state.files, writeRes.filesUpdate); - } - await composite.write("/store/remote.txt", "remote content"); - - const result = await composite.downloadFiles([ - "/local.txt", - "/store/remote.txt", - ]); - expect(result).toHaveLength(2); - - expect(result[0].path).toBe("/local.txt"); - expect(result[0].error).toBeNull(); - expect(new TextDecoder().decode(result[0].content!)).toBe( - "local content", - ); - - expect(result[1].path).toBe("/store/remote.txt"); - expect(result[1].error).toBeNull(); - expect(new TextDecoder().decode(result[1].content!)).toBe( - "remote content", - ); - }); - - it("should handle mixed results across backends", async () => { - const { state, stateAndStore } = makeConfig(); - - const composite = new CompositeBackend(new StateBackend(stateAndStore), { - "/store/": new StoreBackend(stateAndStore), - }); - - // Only write to state backend - const writeRes = await composite.write("/exists.txt", "I exist"); - if (writeRes.filesUpdate) { - Object.assign(state.files, writeRes.filesUpdate); - } - - const result = await composite.downloadFiles([ - "/exists.txt", - "/missing.txt", - "/store/missing.txt", - ]); - expect(result).toHaveLength(3); - - expect(result[0].error).toBeNull(); - expect(result[1].error).toBe("file_not_found"); - expect(result[2].error).toBe("file_not_found"); - }); - }); -}); diff --git a/libs/deepagents/src/backends/composite.ts b/libs/deepagents/src/backends/composite.ts deleted file mode 100644 index 33b5e7e03..000000000 --- a/libs/deepagents/src/backends/composite.ts +++ /dev/null @@ -1,387 +0,0 @@ -/** - * CompositeBackend: Route operations to different backends based on path prefix. - */ - -import type { - BackendProtocol, - EditResult, - ExecuteResponse, - FileData, - FileDownloadResponse, - FileInfo, - FileUploadResponse, - GrepMatch, - WriteResult, -} from "./protocol.js"; -import { isSandboxBackend } from "./protocol.js"; - -/** - * Backend that routes file operations to different backends based on path prefix. - * - * This enables hybrid storage strategies like: - * - `/memories/` → StoreBackend (persistent, cross-thread) - * - Everything else → StateBackend (ephemeral, per-thread) - * - * The CompositeBackend handles path prefix stripping/re-adding transparently. - */ -export class CompositeBackend implements BackendProtocol { - private default: BackendProtocol; - private routes: Record; - private sortedRoutes: Array<[string, BackendProtocol]>; - - constructor( - defaultBackend: BackendProtocol, - routes: Record, - ) { - this.default = defaultBackend; - this.routes = routes; - - // Sort routes by length (longest first) for correct prefix matching - this.sortedRoutes = Object.entries(routes).sort( - (a, b) => b[0].length - a[0].length, - ); - } - - /** - * Determine which backend handles this key and strip prefix. - * - * @param key - Original file path - * @returns Tuple of [backend, stripped_key] where stripped_key has the route - * prefix removed (but keeps leading slash). - */ - private getBackendAndKey(key: string): [BackendProtocol, string] { - // Check routes in order of length (longest first) - for (const [prefix, backend] of this.sortedRoutes) { - if (key.startsWith(prefix)) { - // Strip full prefix and ensure a leading slash remains - // e.g., "/memories/notes.txt" → "/notes.txt"; "/memories/" → "/" - const suffix = key.substring(prefix.length); - const strippedKey = suffix ? "/" + suffix : "/"; - return [backend, strippedKey]; - } - } - - return [this.default, key]; - } - - /** - * List files and directories in the specified directory (non-recursive). - * - * @param path - Absolute path to directory - * @returns List of FileInfo objects with route prefixes added, for files and directories - * directly in the directory. Directories have a trailing / in their path and is_dir=true. - */ - async lsInfo(path: string): Promise { - // Check if path matches a specific route - for (const [routePrefix, backend] of this.sortedRoutes) { - if (path.startsWith(routePrefix.replace(/\/$/, ""))) { - // Query only the matching routed backend - const suffix = path.substring(routePrefix.length); - const searchPath = suffix ? "/" + suffix : "/"; - const infos = await backend.lsInfo(searchPath); - - // Add route prefix back to paths - const prefixed: FileInfo[] = []; - for (const fi of infos) { - prefixed.push({ - ...fi, - path: routePrefix.slice(0, -1) + fi.path, - }); - } - return prefixed; - } - } - - // At root, aggregate default and all routed backends - if (path === "/") { - const results: FileInfo[] = []; - const defaultInfos = await this.default.lsInfo(path); - results.push(...defaultInfos); - - // Add the route itself as a directory (e.g., /memories/) - for (const [routePrefix] of this.sortedRoutes) { - results.push({ - path: routePrefix, - is_dir: true, - size: 0, - modified_at: "", - }); - } - - results.sort((a, b) => a.path.localeCompare(b.path)); - return results; - } - - // Path doesn't match a route: query only default backend - return await this.default.lsInfo(path); - } - - /** - * Read file content, routing to appropriate backend. - * - * @param filePath - Absolute file path - * @param offset - Line offset to start reading from (0-indexed) - * @param limit - Maximum number of lines to read - * @returns Formatted file content with line numbers, or error message - */ - async read( - filePath: string, - offset: number = 0, - limit: number = 500, - ): Promise { - const [backend, strippedKey] = this.getBackendAndKey(filePath); - return await backend.read(strippedKey, offset, limit); - } - - /** - * Read file content as raw FileData. - * - * @param filePath - Absolute file path - * @returns Raw file content as FileData - */ - async readRaw(filePath: string): Promise { - const [backend, strippedKey] = this.getBackendAndKey(filePath); - return await backend.readRaw(strippedKey); - } - - /** - * Structured search results or error string for invalid input. - */ - async grepRaw( - pattern: string, - path: string = "/", - glob: string | null = null, - ): Promise { - // If path targets a specific route, search only that backend - for (const [routePrefix, backend] of this.sortedRoutes) { - if (path.startsWith(routePrefix.replace(/\/$/, ""))) { - const searchPath = path.substring(routePrefix.length - 1); - const raw = await backend.grepRaw(pattern, searchPath || "/", glob); - - if (typeof raw === "string") { - return raw; - } - - // Add route prefix back - return raw.map((m) => ({ - ...m, - path: routePrefix.slice(0, -1) + m.path, - })); - } - } - - // Otherwise, search default and all routed backends and merge - const allMatches: GrepMatch[] = []; - const rawDefault = await this.default.grepRaw(pattern, path, glob); - - if (typeof rawDefault === "string") { - return rawDefault; - } - - allMatches.push(...rawDefault); - - // Search all routes - for (const [routePrefix, backend] of Object.entries(this.routes)) { - const raw = await backend.grepRaw(pattern, "/", glob); - - if (typeof raw === "string") { - return raw; - } - - // Add route prefix back - allMatches.push( - ...raw.map((m) => ({ - ...m, - path: routePrefix.slice(0, -1) + m.path, - })), - ); - } - - return allMatches; - } - - /** - * Structured glob matching returning FileInfo objects. - */ - async globInfo(pattern: string, path: string = "/"): Promise { - const results: FileInfo[] = []; - - // Route based on path, not pattern - for (const [routePrefix, backend] of this.sortedRoutes) { - if (path.startsWith(routePrefix.replace(/\/$/, ""))) { - const searchPath = path.substring(routePrefix.length - 1); - const infos = await backend.globInfo(pattern, searchPath || "/"); - - // Add route prefix back - return infos.map((fi) => ({ - ...fi, - path: routePrefix.slice(0, -1) + fi.path, - })); - } - } - - // Path doesn't match any specific route - search default backend AND all routed backends - const defaultInfos = await this.default.globInfo(pattern, path); - results.push(...defaultInfos); - - for (const [routePrefix, backend] of Object.entries(this.routes)) { - const infos = await backend.globInfo(pattern, "/"); - results.push( - ...infos.map((fi) => ({ - ...fi, - path: routePrefix.slice(0, -1) + fi.path, - })), - ); - } - - // Deterministic ordering - results.sort((a, b) => a.path.localeCompare(b.path)); - return results; - } - - /** - * Create a new file, routing to appropriate backend. - * - * @param filePath - Absolute file path - * @param content - File content as string - * @returns WriteResult with path or error - */ - async write(filePath: string, content: string): Promise { - const [backend, strippedKey] = this.getBackendAndKey(filePath); - return await backend.write(strippedKey, content); - } - - /** - * Edit a file, routing to appropriate backend. - * - * @param filePath - Absolute file path - * @param oldString - String to find and replace - * @param newString - Replacement string - * @param replaceAll - If true, replace all occurrences - * @returns EditResult with path, occurrences, or error - */ - async edit( - filePath: string, - oldString: string, - newString: string, - replaceAll: boolean = false, - ): Promise { - const [backend, strippedKey] = this.getBackendAndKey(filePath); - return await backend.edit(strippedKey, oldString, newString, replaceAll); - } - - /** - * Execute a command via the default backend. - * Execution is not path-specific, so it always delegates to the default backend. - * - * @param command - Full shell command string to execute - * @returns ExecuteResponse with combined output, exit code, and truncation flag - * @throws Error if the default backend doesn't support command execution - */ - execute(command: string): Promise { - if (!isSandboxBackend(this.default)) { - throw new Error( - "Default backend doesn't support command execution (SandboxBackendProtocol). " + - "To enable execution, provide a default backend that implements SandboxBackendProtocol.", - ); - } - return Promise.resolve(this.default.execute(command)); - } - - /** - * Upload multiple files, batching by backend for efficiency. - * - * @param files - List of [path, content] tuples to upload - * @returns List of FileUploadResponse objects, one per input file - */ - async uploadFiles( - files: Array<[string, Uint8Array]>, - ): Promise { - const results: Array = Array.from( - { length: files.length }, - () => null, - ); - const batchesByBackend = new Map< - BackendProtocol, - Array<{ idx: number; path: string; content: Uint8Array }> - >(); - - for (let idx = 0; idx < files.length; idx++) { - const [path, content] = files[idx]; - const [backend, strippedPath] = this.getBackendAndKey(path); - - if (!batchesByBackend.has(backend)) { - batchesByBackend.set(backend, []); - } - batchesByBackend.get(backend)!.push({ idx, path: strippedPath, content }); - } - - for (const [backend, batch] of batchesByBackend) { - if (!backend.uploadFiles) { - throw new Error("Backend does not support uploadFiles"); - } - - const batchFiles = batch.map( - (b) => [b.path, b.content] as [string, Uint8Array], - ); - const batchResponses = await backend.uploadFiles(batchFiles); - - for (let i = 0; i < batch.length; i++) { - const originalIdx = batch[i].idx; - results[originalIdx] = { - path: files[originalIdx][0], // Original path - error: batchResponses[i]?.error ?? null, - }; - } - } - - return results as FileUploadResponse[]; - } - - /** - * Download multiple files, batching by backend for efficiency. - * - * @param paths - List of file paths to download - * @returns List of FileDownloadResponse objects, one per input path - */ - async downloadFiles(paths: string[]): Promise { - const results: Array = Array.from( - { length: paths.length }, - () => null, - ); - const batchesByBackend = new Map< - BackendProtocol, - Array<{ idx: number; path: string }> - >(); - - for (let idx = 0; idx < paths.length; idx++) { - const path = paths[idx]; - const [backend, strippedPath] = this.getBackendAndKey(path); - - if (!batchesByBackend.has(backend)) { - batchesByBackend.set(backend, []); - } - batchesByBackend.get(backend)!.push({ idx, path: strippedPath }); - } - - for (const [backend, batch] of batchesByBackend) { - if (!backend.downloadFiles) { - throw new Error("Backend does not support downloadFiles"); - } - - const batchPaths = batch.map((b) => b.path); - const batchResponses = await backend.downloadFiles(batchPaths); - - for (let i = 0; i < batch.length; i++) { - const originalIdx = batch[i].idx; - results[originalIdx] = { - path: paths[originalIdx], // Original path - content: batchResponses[i]?.content ?? null, - error: batchResponses[i]?.error ?? null, - }; - } - } - - return results as FileDownloadResponse[]; - } -} diff --git a/libs/deepagents/src/backends/filesystem.test.ts b/libs/deepagents/src/backends/filesystem.test.ts deleted file mode 100644 index 360d99d94..000000000 --- a/libs/deepagents/src/backends/filesystem.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import * as fs from "fs/promises"; -import * as fsSync from "fs"; -import * as path from "path"; -import * as os from "os"; -import { FilesystemBackend } from "./filesystem.js"; - -/** - * Helper to write a file with automatic parent directory creation - */ -async function writeFile(filePath: string, content: string) { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, "utf-8"); -} - -/** - * Helper to create a unique temporary directory for each test - */ -function createTempDir(): string { - return fsSync.mkdtempSync(path.join(os.tmpdir(), "deepagents-test-")); -} - -/** - * Helper to recursively remove a directory - */ -async function removeDir(dirPath: string) { - try { - await fs.rm(dirPath, { recursive: true, force: true }); - } catch { - // Ignore errors during cleanup - } -} - -describe("FilesystemBackend", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = createTempDir(); - }); - - afterEach(async () => { - await removeDir(tmpDir); - }); - - it("should work in normal mode with absolute paths", async () => { - const root = tmpDir; - const f1 = path.join(root, "a.txt"); - const f2 = path.join(root, "dir", "b.py"); - await writeFile(f1, "hello fs"); - await writeFile(f2, "print('x')\nhello"); - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const infos = await backend.lsInfo(root); - const paths = new Set(infos.map((i) => i.path)); - expect(paths.has(f1)).toBe(true); - expect(paths.has(f2)).toBe(false); - expect(paths.has(path.join(root, "dir") + path.sep)).toBe(true); - - const txt = await backend.read(f1); - expect(txt).toContain("hello fs"); - - const editMsg = await backend.edit(f1, "fs", "filesystem", false); - expect(editMsg).toBeDefined(); - expect(editMsg.error).toBeUndefined(); - expect(editMsg.occurrences).toBe(1); - - const writeMsg = await backend.write( - path.join(root, "new.txt"), - "new content", - ); - expect(writeMsg).toBeDefined(); - expect(writeMsg.error).toBeUndefined(); - expect(writeMsg.path).toContain("new.txt"); - - const matches = await backend.grepRaw("hello", root); - expect(Array.isArray(matches)).toBe(true); - if (Array.isArray(matches)) { - expect(matches.some((m) => m.path.endsWith("a.txt"))).toBe(true); - } - - const globResults = await backend.globInfo("**/*.py", root); - expect(globResults.some((i) => i.path === f2)).toBe(true); - }); - - it("should work in virtual mode with sandboxed paths", async () => { - const root = tmpDir; - const f1 = path.join(root, "a.txt"); - const f2 = path.join(root, "dir", "b.md"); - await writeFile(f1, "hello virtual"); - await writeFile(f2, "content"); - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: true, - }); - - const infos = await backend.lsInfo("/"); - const paths = new Set(infos.map((i) => i.path)); - expect(paths.has("/a.txt")).toBe(true); - expect(paths.has("/dir/b.md")).toBe(false); - expect(paths.has("/dir/")).toBe(true); - - const txt = await backend.read("/a.txt"); - expect(txt).toContain("hello virtual"); - - const editMsg = await backend.edit("/a.txt", "virtual", "virt", false); - expect(editMsg).toBeDefined(); - expect(editMsg.error).toBeUndefined(); - expect(editMsg.occurrences).toBe(1); - - const writeMsg = await backend.write("/new.txt", "x"); - expect(writeMsg).toBeDefined(); - expect(writeMsg.error).toBeUndefined(); - expect(fsSync.existsSync(path.join(root, "new.txt"))).toBe(true); - - const matches = await backend.grepRaw("virt", "/"); - expect(Array.isArray(matches)).toBe(true); - if (Array.isArray(matches)) { - expect(matches.some((m) => m.path === "/a.txt")).toBe(true); - } - - const globResults = await backend.globInfo("**/*.md", "/"); - expect(globResults.some((i) => i.path === "/dir/b.md")).toBe(true); - - const err = await backend.grepRaw("[", "/"); - expect(typeof err).toBe("string"); - - const traversalError = await backend.read("/../a.txt"); - expect(traversalError).toContain("Error"); - expect(traversalError).toContain("Path traversal not allowed"); - }); - - it("should list nested directories correctly in virtual mode", async () => { - const root = tmpDir; - - const files: Record = { - [path.join(root, "config.json")]: "config", - [path.join(root, "src", "main.py")]: "code", - [path.join(root, "src", "utils", "helper.py")]: "utils code", - [path.join(root, "src", "utils", "common.py")]: "common utils", - [path.join(root, "docs", "readme.md")]: "documentation", - [path.join(root, "docs", "api", "reference.md")]: "api docs", - }; - - for (const [filePath, content] of Object.entries(files)) { - await writeFile(filePath, content); - } - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: true, - }); - - const rootListing = await backend.lsInfo("/"); - const rootPaths = rootListing.map((fi) => fi.path); - expect(rootPaths).toContain("/config.json"); - expect(rootPaths).toContain("/src/"); - expect(rootPaths).toContain("/docs/"); - expect(rootPaths).not.toContain("/src/main.py"); - expect(rootPaths).not.toContain("/src/utils/helper.py"); - - const srcListing = await backend.lsInfo("/src/"); - const srcPaths = srcListing.map((fi) => fi.path); - expect(srcPaths).toContain("/src/main.py"); - expect(srcPaths).toContain("/src/utils/"); - expect(srcPaths).not.toContain("/src/utils/helper.py"); - - const utilsListing = await backend.lsInfo("/src/utils/"); - const utilsPaths = utilsListing.map((fi) => fi.path); - expect(utilsPaths).toContain("/src/utils/helper.py"); - expect(utilsPaths).toContain("/src/utils/common.py"); - expect(utilsPaths.length).toBe(2); - - const emptyListing = await backend.lsInfo("/nonexistent/"); - expect(emptyListing).toEqual([]); - }); - - it("should list nested directories correctly in normal mode", async () => { - const root = tmpDir; - - const files: Record = { - [path.join(root, "file1.txt")]: "content1", - [path.join(root, "subdir", "file2.txt")]: "content2", - [path.join(root, "subdir", "nested", "file3.txt")]: "content3", - }; - - for (const [filePath, content] of Object.entries(files)) { - await writeFile(filePath, content); - } - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const rootListing = await backend.lsInfo(root); - const rootPaths = rootListing.map((fi) => fi.path); - expect(rootPaths).toContain(path.join(root, "file1.txt")); - expect(rootPaths).toContain(path.join(root, "subdir") + path.sep); - expect(rootPaths).not.toContain(path.join(root, "subdir", "file2.txt")); - - const subdirListing = await backend.lsInfo(path.join(root, "subdir")); - const subdirPaths = subdirListing.map((fi) => fi.path); - expect(subdirPaths).toContain(path.join(root, "subdir", "file2.txt")); - expect(subdirPaths).toContain( - path.join(root, "subdir", "nested") + path.sep, - ); - expect(subdirPaths).not.toContain( - path.join(root, "subdir", "nested", "file3.txt"), - ); - }); - - it("should handle trailing slashes consistently", async () => { - const root = tmpDir; - - const files: Record = { - [path.join(root, "file.txt")]: "content", - [path.join(root, "dir", "nested.txt")]: "nested", - }; - - for (const [filePath, content] of Object.entries(files)) { - await writeFile(filePath, content); - } - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: true, - }); - - const listingWithSlash = await backend.lsInfo("/"); - expect(listingWithSlash.length).toBeGreaterThan(0); - - const listing = await backend.lsInfo("/"); - const paths = listing.map((fi) => fi.path); - expect(paths).toEqual([...paths].sort()); - - const listing1 = await backend.lsInfo("/dir/"); - const listing2 = await backend.lsInfo("/dir"); - expect(listing1.length).toBe(listing2.length); - expect(listing1.map((fi) => fi.path)).toEqual( - listing2.map((fi) => fi.path), - ); - - const empty = await backend.lsInfo("/nonexistent/"); - expect(empty).toEqual([]); - }); - - it("should handle large file writes correctly", async () => { - const root = tmpDir; - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: true, - }); - - const largeContent = "f".repeat(10000); - const writeResult = await backend.write("/large_file.txt", largeContent); - - expect(writeResult.error).toBeUndefined(); - expect(writeResult.path).toBe("/large_file.txt"); - - const readContent = await backend.read("/large_file.txt"); - expect(readContent).toContain(largeContent.substring(0, 100)); - - const savedFile = path.join(root, "large_file.txt"); - expect(fsSync.existsSync(savedFile)).toBe(true); - }); - - it("should read multiline content", async () => { - const root = tmpDir; - const filePath = path.join(root, "multiline.txt"); - await writeFile(filePath, "line1\nline2\nline3"); - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const txt = await backend.read(filePath); - expect(txt).toContain("line1"); - expect(txt).toContain("line2"); - expect(txt).toContain("line3"); - }); - - it("should handle empty files", async () => { - const root = tmpDir; - const filePath = path.join(root, "empty.txt"); - await writeFile(filePath, ""); - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const txt = await backend.read(filePath); - expect(txt).toContain("empty contents"); - }); - - it("should handle files with trailing newlines", async () => { - const root = tmpDir; - const filePath = path.join(root, "trailing.txt"); - await writeFile(filePath, "line1\nline2\n"); - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const txt = await backend.read(filePath); - expect(txt).toContain("line1"); - expect(txt).toContain("line2"); - }); - - it("should handle unicode content", async () => { - const root = tmpDir; - const filePath = path.join(root, "unicode.txt"); - await writeFile(filePath, "Hello 世界\n🚀 emoji\nΩ omega"); - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const txt = await backend.read(filePath); - expect(txt).toContain("Hello 世界"); - expect(txt).toContain("🚀 emoji"); - expect(txt).toContain("Ω omega"); - }); - - it("should handle non-existent files consistently", async () => { - const root = tmpDir; - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const nonexistentPath = path.join(root, "nonexistent.txt"); - - const readResult = await backend.read(nonexistentPath); - expect(readResult).toContain("Error"); - }); - - it("should handle symlinks securely", async () => { - const root = tmpDir; - const targetFile = path.join(root, "target.txt"); - const symlinkFile = path.join(root, "symlink.txt"); - - await writeFile(targetFile, "target content"); - try { - await fs.symlink(targetFile, symlinkFile); - } catch { - // Skip test if symlinks aren't supported (e.g., Windows without admin) - return; - } - - const backend = new FilesystemBackend({ - rootDir: root, - virtualMode: false, - }); - - const readResult = await backend.read(symlinkFile); - expect(readResult).toContain("Error"); - }); -}); diff --git a/libs/deepagents/src/backends/filesystem.ts b/libs/deepagents/src/backends/filesystem.ts deleted file mode 100644 index 95ae9a00a..000000000 --- a/libs/deepagents/src/backends/filesystem.ts +++ /dev/null @@ -1,785 +0,0 @@ -/** - * FilesystemBackend: Read and write files directly from the filesystem. - * - * Security and search upgrades: - * - Secure path resolution with root containment when in virtual_mode (sandboxed to cwd) - * - Prevent symlink-following on file I/O using O_NOFOLLOW when available - * - Ripgrep-powered grep with JSON parsing, plus regex fallback - * and optional glob include filtering, while preserving virtual path behavior - */ - -import fs from "node:fs/promises"; -import fsSync from "node:fs"; -import path from "node:path"; -import { spawn } from "node:child_process"; - -import fg from "fast-glob"; -import micromatch from "micromatch"; -import type { - BackendProtocol, - EditResult, - FileData, - FileDownloadResponse, - FileInfo, - FileUploadResponse, - GrepMatch, - WriteResult, -} from "./protocol.js"; -import { - checkEmptyContent, - formatContentWithLineNumbers, - performStringReplacement, -} from "./utils.js"; - -const SUPPORTS_NOFOLLOW = fsSync.constants.O_NOFOLLOW !== undefined; - -/** - * Backend that reads and writes files directly from the filesystem. - * - * Files are accessed using their actual filesystem paths. Relative paths are - * resolved relative to the current working directory. Content is read/written - * as plain text, and metadata (timestamps) are derived from filesystem stats. - */ -export class FilesystemBackend implements BackendProtocol { - private cwd: string; - private virtualMode: boolean; - private maxFileSizeBytes: number; - - constructor( - options: { - rootDir?: string; - virtualMode?: boolean; - maxFileSizeMb?: number; - } = {}, - ) { - const { rootDir, virtualMode = false, maxFileSizeMb = 10 } = options; - this.cwd = rootDir ? path.resolve(rootDir) : process.cwd(); - this.virtualMode = virtualMode; - this.maxFileSizeBytes = maxFileSizeMb * 1024 * 1024; - } - - /** - * Resolve a file path with security checks. - * - * When virtualMode=true, treat incoming paths as virtual absolute paths under - * this.cwd, disallow traversal (.., ~) and ensure resolved path stays within root. - * When virtualMode=false, preserve legacy behavior: absolute paths are allowed - * as-is; relative paths resolve under cwd. - * - * @param key - File path (absolute, relative, or virtual when virtualMode=true) - * @returns Resolved absolute path string - * @throws Error if path traversal detected or path outside root - */ - private resolvePath(key: string): string { - if (this.virtualMode) { - const vpath = key.startsWith("/") ? key : "/" + key; - if (vpath.includes("..") || vpath.startsWith("~")) { - throw new Error("Path traversal not allowed"); - } - const full = path.resolve(this.cwd, vpath.substring(1)); - const relative = path.relative(this.cwd, full); - if (relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error(`Path: ${full} outside root directory: ${this.cwd}`); - } - return full; - } - - if (path.isAbsolute(key)) { - return key; - } - return path.resolve(this.cwd, key); - } - - /** - * List files and directories in the specified directory (non-recursive). - * - * @param dirPath - Absolute directory path to list files from - * @returns List of FileInfo objects for files and directories directly in the directory. - * Directories have a trailing / in their path and is_dir=true. - */ - async lsInfo(dirPath: string): Promise { - try { - const resolvedPath = this.resolvePath(dirPath); - const stat = await fs.stat(resolvedPath); - - if (!stat.isDirectory()) { - return []; - } - - const entries = await fs.readdir(resolvedPath, { withFileTypes: true }); - const results: FileInfo[] = []; - - const cwdStr = this.cwd.endsWith(path.sep) - ? this.cwd - : this.cwd + path.sep; - - for (const entry of entries) { - const fullPath = path.join(resolvedPath, entry.name); - - try { - const entryStat = await fs.stat(fullPath); - const isFile = entryStat.isFile(); - const isDir = entryStat.isDirectory(); - - if (!this.virtualMode) { - // Non-virtual mode: use absolute paths - if (isFile) { - results.push({ - path: fullPath, - is_dir: false, - size: entryStat.size, - modified_at: entryStat.mtime.toISOString(), - }); - } else if (isDir) { - results.push({ - path: fullPath + path.sep, - is_dir: true, - size: 0, - modified_at: entryStat.mtime.toISOString(), - }); - } - } else { - let relativePath: string; - if (fullPath.startsWith(cwdStr)) { - relativePath = fullPath.substring(cwdStr.length); - } else if (fullPath.startsWith(this.cwd)) { - relativePath = fullPath - .substring(this.cwd.length) - .replace(/^[/\\]/, ""); - } else { - relativePath = fullPath; - } - - relativePath = relativePath.split(path.sep).join("/"); - const virtPath = "/" + relativePath; - - if (isFile) { - results.push({ - path: virtPath, - is_dir: false, - size: entryStat.size, - modified_at: entryStat.mtime.toISOString(), - }); - } else if (isDir) { - results.push({ - path: virtPath + "/", - is_dir: true, - size: 0, - modified_at: entryStat.mtime.toISOString(), - }); - } - } - } catch { - // Skip entries we can't stat - continue; - } - } - - results.sort((a, b) => a.path.localeCompare(b.path)); - return results; - } catch { - return []; - } - } - - /** - * Read file content with line numbers. - * - * @param filePath - Absolute or relative file path - * @param offset - Line offset to start reading from (0-indexed) - * @param limit - Maximum number of lines to read - * @returns Formatted file content with line numbers, or error message - */ - async read( - filePath: string, - offset: number = 0, - limit: number = 500, - ): Promise { - try { - const resolvedPath = this.resolvePath(filePath); - - let content: string; - - if (SUPPORTS_NOFOLLOW) { - const stat = await fs.stat(resolvedPath); - if (!stat.isFile()) { - return `Error: File '${filePath}' not found`; - } - const fd = await fs.open( - resolvedPath, - fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW, - ); - try { - content = await fd.readFile({ encoding: "utf-8" }); - } finally { - await fd.close(); - } - } else { - const stat = await fs.lstat(resolvedPath); - if (stat.isSymbolicLink()) { - return `Error: Symlinks are not allowed: ${filePath}`; - } - if (!stat.isFile()) { - return `Error: File '${filePath}' not found`; - } - content = await fs.readFile(resolvedPath, "utf-8"); - } - - const emptyMsg = checkEmptyContent(content); - if (emptyMsg) { - return emptyMsg; - } - - const lines = content.split("\n"); - const startIdx = offset; - const endIdx = Math.min(startIdx + limit, lines.length); - - if (startIdx >= lines.length) { - return `Error: Line offset ${offset} exceeds file length (${lines.length} lines)`; - } - - const selectedLines = lines.slice(startIdx, endIdx); - return formatContentWithLineNumbers(selectedLines, startIdx + 1); - } catch (e: any) { - return `Error reading file '${filePath}': ${e.message}`; - } - } - - /** - * Read file content as raw FileData. - * - * @param filePath - Absolute file path - * @returns Raw file content as FileData - */ - async readRaw(filePath: string): Promise { - const resolvedPath = this.resolvePath(filePath); - - let content: string; - let stat: fsSync.Stats; - - if (SUPPORTS_NOFOLLOW) { - stat = await fs.stat(resolvedPath); - if (!stat.isFile()) throw new Error(`File '${filePath}' not found`); - const fd = await fs.open( - resolvedPath, - fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW, - ); - try { - content = await fd.readFile({ encoding: "utf-8" }); - } finally { - await fd.close(); - } - } else { - stat = await fs.lstat(resolvedPath); - if (stat.isSymbolicLink()) { - throw new Error(`Symlinks are not allowed: ${filePath}`); - } - if (!stat.isFile()) throw new Error(`File '${filePath}' not found`); - content = await fs.readFile(resolvedPath, "utf-8"); - } - - return { - content: content.split("\n"), - created_at: stat.ctime.toISOString(), - modified_at: stat.mtime.toISOString(), - }; - } - - /** - * Create a new file with content. - * Returns WriteResult. External storage sets filesUpdate=null. - */ - async write(filePath: string, content: string): Promise { - try { - const resolvedPath = this.resolvePath(filePath); - - try { - const stat = await fs.lstat(resolvedPath); - if (stat.isSymbolicLink()) { - return { - error: `Cannot write to ${filePath} because it is a symlink. Symlinks are not allowed.`, - }; - } - return { - error: `Cannot write to ${filePath} because it already exists. Read and then make an edit, or write to a new path.`, - }; - } catch { - // File doesn't exist, good to proceed - } - - await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); - - if (SUPPORTS_NOFOLLOW) { - const flags = - fsSync.constants.O_WRONLY | - fsSync.constants.O_CREAT | - fsSync.constants.O_TRUNC | - fsSync.constants.O_NOFOLLOW; - - const fd = await fs.open(resolvedPath, flags, 0o644); - try { - await fd.writeFile(content, "utf-8"); - } finally { - await fd.close(); - } - } else { - await fs.writeFile(resolvedPath, content, "utf-8"); - } - - return { path: filePath, filesUpdate: null }; - } catch (e: any) { - return { error: `Error writing file '${filePath}': ${e.message}` }; - } - } - - /** - * Edit a file by replacing string occurrences. - * Returns EditResult. External storage sets filesUpdate=null. - */ - async edit( - filePath: string, - oldString: string, - newString: string, - replaceAll: boolean = false, - ): Promise { - try { - const resolvedPath = this.resolvePath(filePath); - - let content: string; - - if (SUPPORTS_NOFOLLOW) { - const stat = await fs.stat(resolvedPath); - if (!stat.isFile()) { - return { error: `Error: File '${filePath}' not found` }; - } - - const fd = await fs.open( - resolvedPath, - fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW, - ); - try { - content = await fd.readFile({ encoding: "utf-8" }); - } finally { - await fd.close(); - } - } else { - const stat = await fs.lstat(resolvedPath); - if (stat.isSymbolicLink()) { - return { error: `Error: Symlinks are not allowed: ${filePath}` }; - } - if (!stat.isFile()) { - return { error: `Error: File '${filePath}' not found` }; - } - content = await fs.readFile(resolvedPath, "utf-8"); - } - - const result = performStringReplacement( - content, - oldString, - newString, - replaceAll, - ); - - if (typeof result === "string") { - return { error: result }; - } - - const [newContent, occurrences] = result; - - // Write securely - if (SUPPORTS_NOFOLLOW) { - const flags = - fsSync.constants.O_WRONLY | - fsSync.constants.O_TRUNC | - fsSync.constants.O_NOFOLLOW; - - const fd = await fs.open(resolvedPath, flags); - try { - await fd.writeFile(newContent, "utf-8"); - } finally { - await fd.close(); - } - } else { - await fs.writeFile(resolvedPath, newContent, "utf-8"); - } - - return { path: filePath, filesUpdate: null, occurrences: occurrences }; - } catch (e: any) { - return { error: `Error editing file '${filePath}': ${e.message}` }; - } - } - - /** - * Structured search results or error string for invalid input. - */ - async grepRaw( - pattern: string, - dirPath: string = "/", - glob: string | null = null, - ): Promise { - // Validate regex - try { - new RegExp(pattern); - } catch (e: any) { - return `Invalid regex pattern: ${e.message}`; - } - - // Resolve base path - let baseFull: string; - try { - baseFull = this.resolvePath(dirPath || "."); - } catch { - return []; - } - - try { - await fs.stat(baseFull); - } catch { - return []; - } - - // Try ripgrep first, fallback to regex search - let results = await this.ripgrepSearch(pattern, baseFull, glob); - if (results === null) { - results = await this.pythonSearch(pattern, baseFull, glob); - } - - const matches: GrepMatch[] = []; - for (const [fpath, items] of Object.entries(results)) { - for (const [lineNum, lineText] of items) { - matches.push({ path: fpath, line: lineNum, text: lineText }); - } - } - return matches; - } - - /** - * Try to use ripgrep for fast searching. - * Returns null if ripgrep is not available or fails. - */ - private async ripgrepSearch( - pattern: string, - baseFull: string, - includeGlob: string | null, - ): Promise> | null> { - return new Promise((resolve) => { - const args = ["--json"]; - if (includeGlob) { - args.push("--glob", includeGlob); - } - args.push("--", pattern, baseFull); - - const proc = spawn("rg", args, { timeout: 30000 }); - const results: Record> = {}; - let output = ""; - - proc.stdout.on("data", (data) => { - output += data.toString(); - }); - - proc.on("close", (code) => { - if (code !== 0 && code !== 1) { - // Error (code 1 means no matches, which is ok) - resolve(null); - return; - } - - for (const line of output.split("\n")) { - if (!line.trim()) continue; - try { - const data = JSON.parse(line); - if (data.type !== "match") continue; - - const pdata = data.data || {}; - const ftext = pdata.path?.text; - if (!ftext) continue; - - let virtPath: string; - if (this.virtualMode) { - try { - const resolved = path.resolve(ftext); - const relative = path.relative(this.cwd, resolved); - if (relative.startsWith("..")) continue; - const normalizedRelative = relative.split(path.sep).join("/"); - virtPath = "/" + normalizedRelative; - } catch { - continue; - } - } else { - virtPath = ftext; - } - - const ln = pdata.line_number; - const lt = pdata.lines?.text?.replace(/\n$/, "") || ""; - if (ln === undefined) continue; - - if (!results[virtPath]) { - results[virtPath] = []; - } - results[virtPath].push([ln, lt]); - } catch { - // Skip invalid JSON - continue; - } - } - - resolve(results); - }); - - proc.on("error", () => { - resolve(null); - }); - }); - } - - /** - * Fallback regex search implementation. - */ - private async pythonSearch( - pattern: string, - baseFull: string, - includeGlob: string | null, - ): Promise>> { - let regex: RegExp; - try { - regex = new RegExp(pattern); - } catch { - return {}; - } - - const results: Record> = {}; - const stat = await fs.stat(baseFull); - const root = stat.isDirectory() ? baseFull : path.dirname(baseFull); - - // Use fast-glob to recursively find all files - const files = await fg("**/*", { - cwd: root, - absolute: true, - onlyFiles: true, - dot: true, - }); - - for (const fp of files) { - try { - // Filter by glob if provided - if ( - includeGlob && - !micromatch.isMatch(path.basename(fp), includeGlob) - ) { - continue; - } - - // Check file size - const stat = await fs.stat(fp); - if (stat.size > this.maxFileSizeBytes) { - continue; - } - - // Read and search - const content = await fs.readFile(fp, "utf-8"); - const lines = content.split("\n"); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (regex.test(line)) { - let virtPath: string; - if (this.virtualMode) { - try { - const relative = path.relative(this.cwd, fp); - if (relative.startsWith("..")) continue; - const normalizedRelative = relative.split(path.sep).join("/"); - virtPath = "/" + normalizedRelative; - } catch { - continue; - } - } else { - virtPath = fp; - } - - if (!results[virtPath]) { - results[virtPath] = []; - } - results[virtPath].push([i + 1, line]); - } - } - } catch { - // Skip files we can't read - continue; - } - } - - return results; - } - - /** - * Structured glob matching returning FileInfo objects. - */ - async globInfo( - pattern: string, - searchPath: string = "/", - ): Promise { - if (pattern.startsWith("/")) { - pattern = pattern.substring(1); - } - - const resolvedSearchPath = - searchPath === "/" ? this.cwd : this.resolvePath(searchPath); - - try { - const stat = await fs.stat(resolvedSearchPath); - if (!stat.isDirectory()) { - return []; - } - } catch { - return []; - } - - const results: FileInfo[] = []; - - try { - // Use fast-glob for pattern matching - const matches = await fg(pattern, { - cwd: resolvedSearchPath, - absolute: true, - onlyFiles: true, - dot: true, - }); - - for (const matchedPath of matches) { - try { - const stat = await fs.stat(matchedPath); - if (!stat.isFile()) continue; - - // Normalize fast-glob paths to platform separators - // fast-glob returns forward slashes on all platforms, but we need - // platform-native separators for path comparisons on Windows - const normalizedPath = matchedPath.split("/").join(path.sep); - - if (!this.virtualMode) { - results.push({ - path: normalizedPath, - is_dir: false, - size: stat.size, - modified_at: stat.mtime.toISOString(), - }); - } else { - const cwdStr = this.cwd.endsWith(path.sep) - ? this.cwd - : this.cwd + path.sep; - let relativePath: string; - - if (normalizedPath.startsWith(cwdStr)) { - relativePath = normalizedPath.substring(cwdStr.length); - } else if (normalizedPath.startsWith(this.cwd)) { - relativePath = normalizedPath - .substring(this.cwd.length) - .replace(/^[/\\]/, ""); - } else { - relativePath = normalizedPath; - } - - relativePath = relativePath.split(path.sep).join("/"); - const virt = "/" + relativePath; - results.push({ - path: virt, - is_dir: false, - size: stat.size, - modified_at: stat.mtime.toISOString(), - }); - } - } catch { - // Skip files we can't stat - continue; - } - } - } catch { - // Ignore glob errors - } - - results.sort((a, b) => a.path.localeCompare(b.path)); - return results; - } - - /** - * Upload multiple files to the filesystem. - * - * @param files - List of [path, content] tuples to upload - * @returns List of FileUploadResponse objects, one per input file - */ - async uploadFiles( - files: Array<[string, Uint8Array]>, - ): Promise { - const responses: FileUploadResponse[] = []; - - for (const [filePath, content] of files) { - try { - const resolvedPath = this.resolvePath(filePath); - - // Ensure parent directory exists - await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); - - // Write file - await fs.writeFile(resolvedPath, content); - responses.push({ path: filePath, error: null }); - } catch (e: any) { - if (e.code === "ENOENT") { - responses.push({ path: filePath, error: "file_not_found" }); - } else if (e.code === "EACCES") { - responses.push({ path: filePath, error: "permission_denied" }); - } else if (e.code === "EISDIR") { - responses.push({ path: filePath, error: "is_directory" }); - } else { - responses.push({ path: filePath, error: "invalid_path" }); - } - } - } - - return responses; - } - - /** - * Download multiple files from the filesystem. - * - * @param paths - List of file paths to download - * @returns List of FileDownloadResponse objects, one per input path - */ - async downloadFiles(paths: string[]): Promise { - const responses: FileDownloadResponse[] = []; - - for (const filePath of paths) { - try { - const resolvedPath = this.resolvePath(filePath); - const content = await fs.readFile(resolvedPath); - responses.push({ path: filePath, content, error: null }); - } catch (e: any) { - if (e.code === "ENOENT") { - responses.push({ - path: filePath, - content: null, - error: "file_not_found", - }); - } else if (e.code === "EACCES") { - responses.push({ - path: filePath, - content: null, - error: "permission_denied", - }); - } else if (e.code === "EISDIR") { - responses.push({ - path: filePath, - content: null, - error: "is_directory", - }); - } else { - responses.push({ - path: filePath, - content: null, - error: "invalid_path", - }); - } - } - } - - return responses; - } -} diff --git a/libs/deepagents/src/backends/index.ts b/libs/deepagents/src/backends/index.ts deleted file mode 100644 index bb0b23c98..000000000 --- a/libs/deepagents/src/backends/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Backends for pluggable file storage. - * - * Backends provide a uniform interface for file operations while allowing - * different storage mechanisms (state, store, filesystem, database, etc.). - */ - -export type { - BackendProtocol, - BackendFactory, - FileData, - FileInfo, - GrepMatch, - WriteResult, - EditResult, - StateAndStore, - // Sandbox execution types - ExecuteResponse, - FileOperationError, - FileDownloadResponse, - FileUploadResponse, - SandboxBackendProtocol, - MaybePromise, -} from "./protocol.js"; - -// Export type guard -export { isSandboxBackend } from "./protocol.js"; - -export { StateBackend } from "./state.js"; -export { StoreBackend } from "./store.js"; -export { FilesystemBackend } from "./filesystem.js"; -export { CompositeBackend } from "./composite.js"; - -// Export BaseSandbox abstract class -export { BaseSandbox } from "./sandbox.js"; - -// Re-export utils for convenience -export * from "./utils.js"; diff --git a/libs/deepagents/src/backends/protocol.test.ts b/libs/deepagents/src/backends/protocol.test.ts deleted file mode 100644 index 4871e448d..000000000 --- a/libs/deepagents/src/backends/protocol.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - isSandboxBackend, - type BackendProtocol, - type SandboxBackendProtocol, - type ExecuteResponse, - type FileOperationError, - type FileDownloadResponse, - type FileUploadResponse, -} from "./protocol.js"; - -describe("Protocol Types", () => { - describe("ExecuteResponse", () => { - it("should have correct shape", () => { - const response: ExecuteResponse = { - output: "hello world", - exitCode: 0, - truncated: false, - }; - - expect(response.output).toBe("hello world"); - expect(response.exitCode).toBe(0); - expect(response.truncated).toBe(false); - }); - - it("should allow null exitCode", () => { - const response: ExecuteResponse = { - output: "still running", - exitCode: null, - truncated: false, - }; - - expect(response.exitCode).toBeNull(); - }); - }); - - describe("FileOperationError", () => { - it("should allow valid error codes", () => { - const errors: FileOperationError[] = [ - "file_not_found", - "permission_denied", - "is_directory", - "invalid_path", - ]; - - expect(errors).toHaveLength(4); - }); - }); - - describe("FileDownloadResponse", () => { - it("should have correct shape for success", () => { - const response: FileDownloadResponse = { - path: "/test.txt", - content: new Uint8Array([1, 2, 3]), - error: null, - }; - - expect(response.path).toBe("/test.txt"); - expect(response.content).not.toBeNull(); - expect(response.error).toBeNull(); - }); - - it("should have correct shape for error", () => { - const response: FileDownloadResponse = { - path: "/missing.txt", - content: null, - error: "file_not_found", - }; - - expect(response.path).toBe("/missing.txt"); - expect(response.content).toBeNull(); - expect(response.error).toBe("file_not_found"); - }); - }); - - describe("FileUploadResponse", () => { - it("should have correct shape for success", () => { - const response: FileUploadResponse = { - path: "/uploaded.txt", - error: null, - }; - - expect(response.path).toBe("/uploaded.txt"); - expect(response.error).toBeNull(); - }); - - it("should have correct shape for error", () => { - const response: FileUploadResponse = { - path: "/readonly.txt", - error: "permission_denied", - }; - - expect(response.path).toBe("/readonly.txt"); - expect(response.error).toBe("permission_denied"); - }); - }); -}); - -describe("isSandboxBackend", () => { - it("should return true for backends with execute function and id string", () => { - const sandboxBackend: SandboxBackendProtocol = { - id: "test-sandbox", - execute: async () => ({ output: "", exitCode: 0, truncated: false }), - lsInfo: async () => [], - read: async () => "", - grepRaw: async () => [], - globInfo: async () => [], - write: async () => ({ path: "" }), - edit: async () => ({ path: "" }), - uploadFiles: async () => [], - downloadFiles: async () => [], - }; - - expect(isSandboxBackend(sandboxBackend)).toBe(true); - }); - - it("should return false for backends without execute", () => { - const nonSandboxBackend: BackendProtocol = { - lsInfo: async () => [], - read: async () => "", - grepRaw: async () => [], - globInfo: async () => [], - write: async () => ({ path: "" }), - edit: async () => ({ path: "" }), - uploadFiles: async () => [], - downloadFiles: async () => [], - }; - - expect(isSandboxBackend(nonSandboxBackend)).toBe(false); - }); - - it("should return false for backends with execute but no id", () => { - const backendWithExecute = { - execute: async () => ({ output: "", exitCode: 0, truncated: false }), - // Missing id - lsInfo: async () => [], - read: async () => "", - grepRaw: async () => [], - globInfo: async () => [], - write: async () => ({ path: "" }), - edit: async () => ({ path: "" }), - uploadFiles: async () => [], - downloadFiles: async () => [], - }; - - expect(isSandboxBackend(backendWithExecute as any)).toBe(false); - }); - - it("should return false for backends with id but no execute", () => { - const backendWithId = { - id: "test-backend", - // Missing execute - lsInfo: async () => [], - read: async () => "", - grepRaw: async () => [], - globInfo: async () => [], - write: async () => ({ path: "" }), - edit: async () => ({ path: "" }), - uploadFiles: async () => [], - downloadFiles: async () => [], - }; - - expect(isSandboxBackend(backendWithId as any)).toBe(false); - }); - - it("should handle execute as non-function", () => { - const backendWithBadExecute = { - id: "test-backend", - execute: "not a function", - lsInfo: async () => [], - read: async () => "", - grepRaw: async () => [], - globInfo: async () => [], - write: async () => ({ path: "" }), - edit: async () => ({ path: "" }), - uploadFiles: async () => [], - downloadFiles: async () => [], - }; - - expect(isSandboxBackend(backendWithBadExecute as any)).toBe(false); - }); - - it("should handle id as non-string", () => { - const backendWithBadId = { - id: 123, - execute: async () => ({ output: "", exitCode: 0, truncated: false }), - lsInfo: async () => [], - read: async () => "", - grepRaw: async () => [], - globInfo: async () => [], - write: async () => ({ path: "" }), - edit: async () => ({ path: "" }), - uploadFiles: async () => [], - downloadFiles: async () => [], - }; - - expect(isSandboxBackend(backendWithBadId as any)).toBe(false); - }); -}); diff --git a/libs/deepagents/src/backends/protocol.ts b/libs/deepagents/src/backends/protocol.ts deleted file mode 100644 index afaa5da34..000000000 --- a/libs/deepagents/src/backends/protocol.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Protocol definition for pluggable memory backends. - * - * This module defines the BackendProtocol that all backend implementations - * must follow. Backends can store files in different locations (state, filesystem, - * database, etc.) and provide a uniform interface for file operations. - */ - -import type { BaseStore } from "@langchain/langgraph-checkpoint"; - -export type MaybePromise = T | Promise; - -/** - * Structured file listing info. - * - * Minimal contract used across backends. Only "path" is required. - * Other fields are best-effort and may be absent depending on backend. - */ -export interface FileInfo { - /** File path */ - path: string; - /** Whether this is a directory */ - is_dir?: boolean; - /** File size in bytes (approximate) */ - size?: number; - /** ISO 8601 timestamp of last modification */ - modified_at?: string; -} - -/** - * Structured grep match entry. - */ -export interface GrepMatch { - /** File path where match was found */ - path: string; - /** Line number (1-indexed) */ - line: number; - /** The matching line text */ - text: string; -} - -/** - * File data structure used by backends. - * - * All file data is represented as objects with this structure: - */ -export interface FileData { - /** Lines of text content */ - content: string[]; - /** ISO format timestamp of creation */ - created_at: string; - /** ISO format timestamp of last modification */ - modified_at: string; -} - -/** - * Result from backend write operations. - * - * Checkpoint backends populate filesUpdate with {file_path: file_data} for LangGraph state. - * External backends set filesUpdate to null (already persisted to disk/S3/database/etc). - */ -export interface WriteResult { - /** Error message on failure, undefined on success */ - error?: string; - /** File path of written file, undefined on failure */ - path?: string; - /** - * State update dict for checkpoint backends, null for external storage. - * Checkpoint backends populate this with {file_path: file_data} for LangGraph state. - * External backends set null (already persisted to disk/S3/database/etc). - */ - filesUpdate?: Record | null; - /** Metadata for the write operation, attached to the ToolMessage */ - metadata?: Record; -} - -/** - * Result from backend edit operations. - * - * Checkpoint backends populate filesUpdate with {file_path: file_data} for LangGraph state. - * External backends set filesUpdate to null (already persisted to disk/S3/database/etc). - */ -export interface EditResult { - /** Error message on failure, undefined on success */ - error?: string; - /** File path of edited file, undefined on failure */ - path?: string; - /** - * State update dict for checkpoint backends, null for external storage. - * Checkpoint backends populate this with {file_path: file_data} for LangGraph state. - * External backends set null (already persisted to disk/S3/database/etc). - */ - filesUpdate?: Record | null; - /** Number of replacements made, undefined on failure */ - occurrences?: number; - /** Metadata for the edit operation, attached to the ToolMessage */ - metadata?: Record; -} - -/** - * Result of code execution. - * Simplified schema optimized for LLM consumption. - */ -export interface ExecuteResponse { - /** Combined stdout and stderr output of the executed command */ - output: string; - /** The process exit code. 0 indicates success, non-zero indicates failure */ - exitCode: number | null; - /** Whether the output was truncated due to backend limitations */ - truncated: boolean; -} - -/** - * Standardized error codes for file upload/download operations. - */ -export type FileOperationError = - | "file_not_found" - | "permission_denied" - | "is_directory" - | "invalid_path"; - -/** - * Result of a single file download operation. - */ -export interface FileDownloadResponse { - /** The file path that was requested */ - path: string; - /** File contents as Uint8Array on success, null on failure */ - content: Uint8Array | null; - /** Standardized error code on failure, null on success */ - error: FileOperationError | null; -} - -/** - * Result of a single file upload operation. - */ -export interface FileUploadResponse { - /** The file path that was requested */ - path: string; - /** Standardized error code on failure, null on success */ - error: FileOperationError | null; -} - -/** - * Protocol for pluggable memory backends (single, unified). - * - * Backends can store files in different locations (state, filesystem, database, etc.) - * and provide a uniform interface for file operations. - * - * All file data is represented as objects with the FileData structure. - * - * Methods can return either direct values or Promises, allowing both - * synchronous and asynchronous implementations. - */ -export interface BackendProtocol { - /** - * Structured listing with file metadata. - * - * Lists files and directories in the specified directory (non-recursive). - * Directories have a trailing / in their path and is_dir=true. - * - * @param path - Absolute path to directory - * @returns List of FileInfo objects for files and directories directly in the directory - */ - lsInfo(path: string): MaybePromise; - - /** - * Read file content with line numbers or an error string. - * - * @param filePath - Absolute file path - * @param offset - Line offset to start reading from (0-indexed), default 0 - * @param limit - Maximum number of lines to read, default 500 - * @returns Formatted file content with line numbers, or error message - */ - read(filePath: string, offset?: number, limit?: number): MaybePromise; - - /** - * Read file content as raw FileData. - * - * @param filePath - Absolute file path - * @returns Raw file content as FileData - */ - readRaw(filePath: string): MaybePromise; - - /** - * Structured search results or error string for invalid input. - * - * Searches file contents for a regex pattern. - * - * @param pattern - Regex pattern to search for - * @param path - Base path to search from (default: null) - * @param glob - Optional glob pattern to filter files (e.g., "*.py") - * @returns List of GrepMatch objects or error string for invalid regex - */ - grepRaw( - pattern: string, - path?: string | null, - glob?: string | null, - ): MaybePromise; - - /** - * Structured glob matching returning FileInfo objects. - * - * @param pattern - Glob pattern (e.g., `*.py`, `**\/*.ts`) - * @param path - Base path to search from (default: "/") - * @returns List of FileInfo objects matching the pattern - */ - globInfo(pattern: string, path?: string): MaybePromise; - - /** - * Create a new file. - * - * @param filePath - Absolute file path - * @param content - File content as string - * @returns WriteResult with error populated on failure - */ - write(filePath: string, content: string): MaybePromise; - - /** - * Edit a file by replacing string occurrences. - * - * @param filePath - Absolute file path - * @param oldString - String to find and replace - * @param newString - Replacement string - * @param replaceAll - If true, replace all occurrences (default: false) - * @returns EditResult with error, path, filesUpdate, and occurrences - */ - edit( - filePath: string, - oldString: string, - newString: string, - replaceAll?: boolean, - ): MaybePromise; - - /** - * Upload multiple files. - * Optional - backends that don't support file upload can omit this. - * - * @param files - List of [path, content] tuples to upload - * @returns List of FileUploadResponse objects, one per input file - */ - uploadFiles?( - files: Array<[string, Uint8Array]>, - ): MaybePromise; - - /** - * Download multiple files. - * Optional - backends that don't support file download can omit this. - * - * @param paths - List of file paths to download - * @returns List of FileDownloadResponse objects, one per input path - */ - downloadFiles?(paths: string[]): MaybePromise; -} - -/** - * Protocol for sandboxed backends with isolated runtime. - * Sandboxed backends run in isolated environments (e.g., containers) - * and communicate via defined interfaces. - */ -export interface SandboxBackendProtocol extends BackendProtocol { - /** - * Execute a command in the sandbox. - * - * @param command - Full shell command string to execute - * @returns ExecuteResponse with combined output, exit code, and truncation flag - */ - execute(command: string): MaybePromise; - - /** Unique identifier for the sandbox backend instance */ - readonly id: string; -} - -/** - * Type guard to check if a backend supports execution. - * - * @param backend - Backend instance to check - * @returns True if the backend implements SandboxBackendProtocol - */ -export function isSandboxBackend( - backend: BackendProtocol, -): backend is SandboxBackendProtocol { - return ( - typeof (backend as SandboxBackendProtocol).execute === "function" && - typeof (backend as SandboxBackendProtocol).id === "string" - ); -} - -/** - * State and store container for backend initialization. - * - * This provides a clean interface for what backends need to access: - * - state: Current agent state (with files, messages, etc.) - * - store: Optional persistent store for cross-conversation data - * - * Different contexts build this differently: - * - Tools: Extract state via getCurrentTaskInput(config) - * - Middleware: Use request.state directly - */ -export interface StateAndStore { - /** Current agent state with files, messages, etc. */ - state: unknown; - /** Optional BaseStore for persistent cross-conversation storage */ - store?: BaseStore; - /** Optional assistant ID for per-assistant isolation in store */ - assistantId?: string; -} - -/** - * Factory function type for creating backend instances. - * - * Backends receive StateAndStore which contains the current state - * and optional store, extracted from the execution context. - * - * @example - * ```typescript - * // Using in middleware - * const middleware = createFilesystemMiddleware({ - * backend: (stateAndStore) => new StateBackend(stateAndStore) - * }); - * ``` - */ -export type BackendFactory = (stateAndStore: StateAndStore) => BackendProtocol; diff --git a/libs/deepagents/src/backends/sandbox.test.ts b/libs/deepagents/src/backends/sandbox.test.ts deleted file mode 100644 index 03c0f865a..000000000 --- a/libs/deepagents/src/backends/sandbox.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { BaseSandbox } from "./sandbox.js"; -import type { - ExecuteResponse, - FileDownloadResponse, - FileUploadResponse, -} from "./protocol.js"; - -/** - * Mock implementation of BaseSandbox for testing. - * Simulates command execution by parsing the command and returning appropriate responses. - */ -class MockSandbox extends BaseSandbox { - readonly id = "mock-sandbox-1"; - - // Store for simulating file operations - private files: Map = new Map(); - - // Track executed commands for assertions - public executedCommands: string[] = []; - - async execute(command: string): Promise { - this.executedCommands.push(command); - - // Simulate ls command - if (command.includes("fs.readdirSync")) { - const files = Array.from(this.files.keys()); - const output = files - .map((f) => - JSON.stringify({ - path: f, - size: this.files.get(f)!.length, - mtime: Date.now(), - isDir: false, - }), - ) - .join("\n"); - return { output, exitCode: 0, truncated: false }; - } - - // Simulate read command - if ( - command.includes("fs.readFileSync") && - command.includes("split('\\\\n')") - ) { - const pathMatch = command.match(/atob\('([^']+)'\)/); - if (pathMatch) { - const filePath = atob(pathMatch[1]); - const content = this.files.get(filePath); - if (!content) { - return { - output: "Error: File not found", - exitCode: 1, - truncated: false, - }; - } - const lines = content.split("\n"); - const output = lines - .map((line, i) => ` ${i + 1}\t${line}`) - .join("\n"); - return { output, exitCode: 0, truncated: false }; - } - } - - // Simulate write command - if ( - command.includes("fs.writeFileSync") && - command.includes("fs.existsSync") - ) { - const matches = command.match(/atob\('([^']+)'\)/g); - if (matches && matches.length >= 2) { - const filePath = atob(matches[0].match(/atob\('([^']+)'\)/)![1]); - const content = atob(matches[1].match(/atob\('([^']+)'\)/)![1]); - - if (this.files.has(filePath)) { - return { - output: "Error: File already exists", - exitCode: 1, - truncated: false, - }; - } - - this.files.set(filePath, content); - return { output: "OK", exitCode: 0, truncated: false }; - } - } - - // Simulate edit command - if ( - command.includes("fs.writeFileSync") && - command.includes("replaceAll") - ) { - const matches = command.match(/atob\('([^']+)'\)/g); - if (matches && matches.length >= 3) { - const filePath = atob(matches[0].match(/atob\('([^']+)'\)/)![1]); - const oldStr = atob(matches[1].match(/atob\('([^']+)'\)/)![1]); - const newStr = atob(matches[2].match(/atob\('([^']+)'\)/)![1]); - - const content = this.files.get(filePath); - if (!content) { - return { output: "", exitCode: 3, truncated: false }; - } - - const count = content.split(oldStr).length - 1; - if (count === 0) { - return { output: "", exitCode: 1, truncated: false }; - } - - const replaceAll = command.includes("replaceAll = true"); - if (count > 1 && !replaceAll) { - return { output: "", exitCode: 2, truncated: false }; - } - - const newContent = content.split(oldStr).join(newStr); - this.files.set(filePath, newContent); - return { output: String(count), exitCode: 0, truncated: false }; - } - } - - // Simulate glob command - if (command.includes("globMatch") && command.includes("walkDir")) { - const matches = command.match(/atob\('([^']+)'\)/g); - if (matches && matches.length >= 2) { - const pattern = atob(matches[1].match(/atob\('([^']+)'\)/)![1]); - const regex = new RegExp( - pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"), - ); - - const matchingFiles = Array.from(this.files.keys()).filter((f) => - regex.test(f), - ); - const output = matchingFiles - .map((f) => - JSON.stringify({ - path: f, - size: this.files.get(f)!.length, - mtime: Date.now(), - isDir: false, - }), - ) - .join("\n"); - return { output, exitCode: 0, truncated: false }; - } - } - - // Simulate grep command - if ( - command.includes("new RegExp(pattern)") && - command.includes("walkDir") - ) { - const matches = command.match(/atob\('([^']+)'\)/g); - if (matches) { - const pattern = atob(matches[0].match(/atob\('([^']+)'\)/)![1]); - const regex = new RegExp(pattern); - - const results: string[] = []; - for (const [filePath, content] of this.files) { - const lines = content.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (regex.test(lines[i])) { - results.push( - JSON.stringify({ - path: filePath, - line: i + 1, - text: lines[i], - }), - ); - } - } - } - return { output: results.join("\n"), exitCode: 0, truncated: false }; - } - } - - // Default response for unknown commands - return { output: "", exitCode: 0, truncated: false }; - } - - async uploadFiles( - files: Array<[string, Uint8Array]>, - ): Promise { - const responses: FileUploadResponse[] = []; - for (const [path, content] of files) { - try { - const contentStr = new TextDecoder().decode(content); - this.files.set(path, contentStr); - responses.push({ path, error: null }); - } catch { - responses.push({ path, error: "invalid_path" }); - } - } - return responses; - } - - async downloadFiles(paths: string[]): Promise { - const responses: FileDownloadResponse[] = []; - for (const path of paths) { - const content = this.files.get(path); - if (!content) { - responses.push({ path, content: null, error: "file_not_found" }); - } else { - const bytes = new TextEncoder().encode(content); - responses.push({ path, content: bytes, error: null }); - } - } - return responses; - } - - // Helper to add files for testing - addFile(path: string, content: string) { - this.files.set(path, content); - } - - // Helper to get file content - getFile(path: string): string | undefined { - return this.files.get(path); - } -} - -describe("BaseSandbox", () => { - describe("isSandboxBackend type guard", () => { - it("should return true for sandbox backends", async () => { - const { isSandboxBackend } = await import("./protocol.js"); - const sandbox = new MockSandbox(); - expect(isSandboxBackend(sandbox)).toBe(true); - }); - - it("should return false for non-sandbox backends", async () => { - const { isSandboxBackend } = await import("./protocol.js"); - const { StateBackend } = await import("./state.js"); - - const stateAndStore = { state: { files: {} }, store: undefined }; - const stateBackend = new StateBackend(stateAndStore); - expect(isSandboxBackend(stateBackend)).toBe(false); - }); - }); - - describe("lsInfo", () => { - it("should list files via execute", async () => { - const sandbox = new MockSandbox(); - sandbox.addFile("/test.txt", "content"); - sandbox.addFile("/dir/nested.txt", "nested"); - - await sandbox.lsInfo("/"); - expect(sandbox.executedCommands.length).toBeGreaterThan(0); - expect(sandbox.executedCommands[0]).toContain("node -e"); - }); - - it("should return empty array for non-existent directory", async () => { - const sandbox = new MockSandbox(); - // Mock execute to return error - sandbox.execute = vi.fn().mockResolvedValue({ - output: "Error", - exitCode: 1, - truncated: false, - }); - - const result = await sandbox.lsInfo("/nonexistent"); - expect(result).toEqual([]); - }); - }); - - describe("read", () => { - it("should read file via execute", async () => { - const sandbox = new MockSandbox(); - sandbox.addFile("/test.txt", "line1\nline2\nline3"); - - // Override execute for this test - sandbox.execute = vi.fn().mockResolvedValue({ - output: " 1\tline1\n 2\tline2\n 3\tline3", - exitCode: 0, - truncated: false, - }); - - const result = await sandbox.read("/test.txt"); - expect(result).toContain("line1"); - expect(result).toContain("line2"); - }); - - it("should return error for non-existent file", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "", - exitCode: 1, - truncated: false, - }); - - const result = await sandbox.read("/nonexistent.txt"); - expect(result).toContain("Error"); - expect(result).toContain("not found"); - }); - }); - - describe("write", () => { - it("should write file via execute", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "OK", - exitCode: 0, - truncated: false, - }); - - const result = await sandbox.write("/new.txt", "new content"); - expect(result.error).toBeUndefined(); - expect(result.path).toBe("/new.txt"); - expect(result.filesUpdate).toBeNull(); // External storage - }); - - it("should return error if file already exists", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "Error: File already exists", - exitCode: 1, - truncated: false, - }); - - const result = await sandbox.write("/existing.txt", "content"); - expect(result.error).toBeDefined(); - expect(result.error).toContain("already exists"); - }); - }); - - describe("edit", () => { - it("should edit file via execute", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "1", - exitCode: 0, - truncated: false, - }); - - const result = await sandbox.edit("/test.txt", "old", "new", false); - expect(result.error).toBeUndefined(); - expect(result.occurrences).toBe(1); - expect(result.filesUpdate).toBeNull(); - }); - - it("should return error when string not found", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "", - exitCode: 1, - truncated: false, - }); - - const result = await sandbox.edit("/test.txt", "notfound", "new", false); - expect(result.error).toContain("not found"); - }); - - it("should return error for multiple occurrences without replaceAll", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "", - exitCode: 2, - truncated: false, - }); - - const result = await sandbox.edit("/test.txt", "multi", "new", false); - expect(result.error).toContain("Multiple occurrences"); - expect(result.error).toContain("replaceAll"); - }); - - it("should return error when file not found", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "", - exitCode: 3, - truncated: false, - }); - - const result = await sandbox.edit("/nonexistent.txt", "a", "b", false); - expect(result.error).toContain("not found"); - }); - }); - - describe("grepRaw", () => { - it("should search files via execute", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: JSON.stringify({ - path: "/test.txt", - line: 1, - text: "hello world", - }), - exitCode: 0, - truncated: false, - }); - - const result = await sandbox.grepRaw("hello", "/"); - expect(Array.isArray(result)).toBe(true); - if (Array.isArray(result)) { - expect(result.length).toBe(1); - expect(result[0].path).toBe("/test.txt"); - expect(result[0].text).toBe("hello world"); - } - }); - - it("should return error string for invalid regex", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "Invalid regex: [", - exitCode: 1, - truncated: false, - }); - - const result = await sandbox.grepRaw("[", "/"); - expect(typeof result).toBe("string"); - expect(result).toContain("Invalid regex"); - }); - }); - - describe("globInfo", () => { - it("should find matching files via execute", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: [ - JSON.stringify({ - path: "test.py", - size: 100, - mtime: Date.now(), - isDir: false, - }), - JSON.stringify({ - path: "main.py", - size: 200, - mtime: Date.now(), - isDir: false, - }), - ].join("\n"), - exitCode: 0, - truncated: false, - }); - - const result = await sandbox.globInfo("*.py", "/"); - expect(result.length).toBe(2); - expect(result.some((f) => f.path === "test.py")).toBe(true); - expect(result.some((f) => f.path === "main.py")).toBe(true); - }); - - it("should return empty array for no matches", async () => { - const sandbox = new MockSandbox(); - sandbox.execute = vi.fn().mockResolvedValue({ - output: "", - exitCode: 0, - truncated: false, - }); - - const result = await sandbox.globInfo("*.nonexistent", "/"); - expect(result).toEqual([]); - }); - }); - - describe("uploadFiles", () => { - it("should upload files successfully", async () => { - const sandbox = new MockSandbox(); - const files: Array<[string, Uint8Array]> = [ - ["/file1.txt", new TextEncoder().encode("content1")], - ["/file2.txt", new TextEncoder().encode("content2")], - ]; - - const result = await sandbox.uploadFiles(files); - expect(result).toHaveLength(2); - expect(result[0].path).toBe("/file1.txt"); - expect(result[0].error).toBeNull(); - expect(result[1].path).toBe("/file2.txt"); - expect(result[1].error).toBeNull(); - }); - }); - - describe("downloadFiles", () => { - it("should download existing files", async () => { - const sandbox = new MockSandbox(); - sandbox.addFile("/test.txt", "test content"); - - const result = await sandbox.downloadFiles(["/test.txt"]); - expect(result).toHaveLength(1); - expect(result[0].path).toBe("/test.txt"); - expect(result[0].error).toBeNull(); - expect(result[0].content).not.toBeNull(); - - const content = new TextDecoder().decode(result[0].content!); - expect(content).toBe("test content"); - }); - - it("should return error for missing files", async () => { - const sandbox = new MockSandbox(); - - const result = await sandbox.downloadFiles(["/nonexistent.txt"]); - expect(result).toHaveLength(1); - expect(result[0].path).toBe("/nonexistent.txt"); - expect(result[0].content).toBeNull(); - expect(result[0].error).toBe("file_not_found"); - }); - }); -}); diff --git a/libs/deepagents/src/backends/sandbox.ts b/libs/deepagents/src/backends/sandbox.ts deleted file mode 100644 index d9d544098..000000000 --- a/libs/deepagents/src/backends/sandbox.ts +++ /dev/null @@ -1,546 +0,0 @@ -/** - * BaseSandbox: Abstract base class for sandbox backends with command execution. - * - * This class provides default implementations for all SandboxBackendProtocol - * methods using shell commands executed via execute(). Concrete implementations - * only need to implement the execute() method. - * - * Requires Node.js 20+ on the sandbox host. - */ - -import type { - EditResult, - ExecuteResponse, - FileData, - FileDownloadResponse, - FileInfo, - FileUploadResponse, - GrepMatch, - MaybePromise, - SandboxBackendProtocol, - WriteResult, -} from "./protocol.js"; - -/** - * Node.js command template for glob operations. - * Uses web-standard atob() for base64 decoding. - */ -function buildGlobCommand(searchPath: string, pattern: string): string { - const pathB64 = btoa(searchPath); - const patternB64 = btoa(pattern); - - return `node -e " -const fs = require('fs'); -const path = require('path'); - -const searchPath = atob('${pathB64}'); -const pattern = atob('${patternB64}'); - -function globMatch(relativePath, pattern) { - const regexPattern = pattern - .replace(/\\*\\*/g, '<<>>') - .replace(/\\*/g, '[^/]*') - .replace(/\\?/g, '.') - .replace(/<<>>/g, '.*'); - return new RegExp('^' + regexPattern + '$').test(relativePath); -} - -function walkDir(dir, baseDir, results) { - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - const relativePath = path.relative(baseDir, fullPath); - if (entry.isDirectory()) { - walkDir(fullPath, baseDir, results); - } else if (globMatch(relativePath, pattern)) { - const stat = fs.statSync(fullPath); - console.log(JSON.stringify({ - path: relativePath, - size: stat.size, - mtime: stat.mtimeMs, - isDir: false - })); - } - } - } catch (e) { - // Silent failure for non-existent paths - } -} - -try { - process.chdir(searchPath); - walkDir('.', '.', []); -} catch (e) { - // Silent failure for non-existent paths -} -"`; -} - -/** - * Node.js command template for listing directory contents. - */ -function buildLsCommand(dirPath: string): string { - const pathB64 = btoa(dirPath); - - return `node -e " -const fs = require('fs'); -const path = require('path'); - -const dirPath = atob('${pathB64}'); - -try { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - const stat = fs.statSync(fullPath); - console.log(JSON.stringify({ - path: entry.isDirectory() ? fullPath + '/' : fullPath, - size: stat.size, - mtime: stat.mtimeMs, - isDir: entry.isDirectory() - })); - } -} catch (e) { - console.error('Error: ' + e.message); - process.exit(1); -} -"`; -} - -/** - * Node.js command template for reading files. - */ -function buildReadCommand( - filePath: string, - offset: number, - limit: number, -): string { - const pathB64 = btoa(filePath); - // Coerce offset and limit to safe non-negative integers before embedding in the shell command. - const safeOffset = - Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0; - const safeLimit = - Number.isFinite(limit) && limit > 0 && limit < Number.MAX_SAFE_INTEGER - ? Math.floor(limit) - : 0; - - return `node -e " -const fs = require('fs'); - -const filePath = atob('${pathB64}'); -const offset = ${safeOffset}; -const limit = ${safeLimit}; - -if (!fs.existsSync(filePath)) { - console.log('Error: File not found'); - process.exit(1); -} - -const stat = fs.statSync(filePath); -if (stat.size === 0) { - console.log('System reminder: File exists but has empty contents'); - process.exit(0); -} - -const content = fs.readFileSync(filePath, 'utf-8'); -const lines = content.split('\\n'); -const selected = lines.slice(offset, offset + limit); - -for (let i = 0; i < selected.length; i++) { - const lineNum = offset + i + 1; - console.log(String(lineNum).padStart(6) + '\\t' + selected[i]); -} -"`; -} - -/** - * Node.js command template for writing files. - */ -function buildWriteCommand(filePath: string, content: string): string { - const pathB64 = btoa(filePath); - const contentB64 = btoa(content); - - return `node -e " -const fs = require('fs'); -const path = require('path'); - -const filePath = atob('${pathB64}'); -const content = atob('${contentB64}'); - -if (fs.existsSync(filePath)) { - console.error('Error: File already exists'); - process.exit(1); -} - -const parentDir = path.dirname(filePath) || '.'; -fs.mkdirSync(parentDir, { recursive: true }); - -fs.writeFileSync(filePath, content, 'utf-8'); -console.log('OK'); -"`; -} - -/** - * Node.js command template for editing files. - */ -function buildEditCommand( - filePath: string, - oldStr: string, - newStr: string, - replaceAll: boolean, -): string { - const pathB64 = btoa(filePath); - const oldB64 = btoa(oldStr); - const newB64 = btoa(newStr); - - return `node -e " -const fs = require('fs'); - -const filePath = atob('${pathB64}'); -const oldStr = atob('${oldB64}'); -const newStr = atob('${newB64}'); -const replaceAll = ${Boolean(replaceAll)}; - -let text; -try { - text = fs.readFileSync(filePath, 'utf-8'); -} catch (e) { - process.exit(3); -} - -const count = text.split(oldStr).length - 1; - -if (count === 0) { - process.exit(1); -} -if (count > 1 && !replaceAll) { - process.exit(2); -} - -const result = text.split(oldStr).join(newStr); -fs.writeFileSync(filePath, result, 'utf-8'); -console.log(count); -"`; -} - -/** - * Node.js command template for grep operations. - */ -function buildGrepCommand( - pattern: string, - searchPath: string, - globPattern: string | null, -): string { - const patternB64 = btoa(pattern); - const pathB64 = btoa(searchPath); - const globB64 = globPattern ? btoa(globPattern) : ""; - - return `node -e " -const fs = require('fs'); -const path = require('path'); - -const pattern = atob('${patternB64}'); -const searchPath = atob('${pathB64}'); -const globPattern = ${globPattern ? `atob('${globB64}')` : "null"}; - -let regex; -try { - regex = new RegExp(pattern); -} catch (e) { - console.error('Invalid regex: ' + e.message); - process.exit(1); -} - -function globMatch(filePath, pattern) { - if (!pattern) return true; - const regexPattern = pattern - .replace(/\\*\\*/g, '<<>>') - .replace(/\\*/g, '[^/]*') - .replace(/\\?/g, '.') - .replace(/<<>>/g, '.*'); - return new RegExp('^' + regexPattern + '$').test(filePath); -} - -function walkDir(dir, results) { - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkDir(fullPath, results); - } else { - const relativePath = path.relative(searchPath, fullPath); - if (globMatch(relativePath, globPattern)) { - try { - const content = fs.readFileSync(fullPath, 'utf-8'); - const lines = content.split('\\n'); - for (let i = 0; i < lines.length; i++) { - if (regex.test(lines[i])) { - console.log(JSON.stringify({ - path: fullPath, - line: i + 1, - text: lines[i] - })); - } - } - } catch (e) { - // Skip unreadable files - } - } - } - } - } catch (e) { - // Skip unreadable directories - } -} - -try { - walkDir(searchPath, []); -} catch (e) { - // Silent failure -} -"`; -} - -/** - * Base sandbox implementation with execute() as the only abstract method. - * - * This class provides default implementations for all SandboxBackendProtocol - * methods using shell commands executed via execute(). Concrete implementations - * only need to implement the execute() method. - * - * Requires Node.js 20+ on the sandbox host. - */ -export abstract class BaseSandbox implements SandboxBackendProtocol { - /** Unique identifier for the sandbox backend */ - abstract readonly id: string; - - /** - * Execute a command in the sandbox. - * This is the only method concrete implementations must provide. - */ - abstract execute(command: string): MaybePromise; - - /** - * Upload multiple files to the sandbox. - * Implementations must support partial success. - */ - abstract uploadFiles( - files: Array<[string, Uint8Array]>, - ): MaybePromise; - - /** - * Download multiple files from the sandbox. - * Implementations must support partial success. - */ - abstract downloadFiles(paths: string[]): MaybePromise; - - /** - * List files and directories in the specified directory (non-recursive). - * - * @param path - Absolute path to directory - * @returns List of FileInfo objects for files and directories directly in the directory. - */ - async lsInfo(path: string): Promise { - const command = buildLsCommand(path); - const result = await this.execute(command); - - if (result.exitCode !== 0) { - return []; - } - - const infos: FileInfo[] = []; - const lines = result.output.trim().split("\n").filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - infos.push({ - path: parsed.path, - is_dir: parsed.isDir, - size: parsed.size, - modified_at: parsed.mtime - ? new Date(parsed.mtime).toISOString() - : undefined, - }); - } catch { - // Skip invalid JSON lines - } - } - - return infos; - } - - /** - * Read file content with line numbers. - * - * @param filePath - Absolute file path - * @param offset - Line offset to start reading from (0-indexed) - * @param limit - Maximum number of lines to read - * @returns Formatted file content with line numbers, or error message - */ - async read( - filePath: string, - offset: number = 0, - limit: number = 500, - ): Promise { - const command = buildReadCommand(filePath, offset, limit); - const result = await this.execute(command); - - if (result.exitCode !== 0) { - return `Error: File '${filePath}' not found`; - } - - return result.output; - } - - /** - * Read file content as raw FileData. - * - * @param filePath - Absolute file path - * @returns Raw file content as FileData - */ - async readRaw(filePath: string): Promise { - const command = buildReadCommand(filePath, 0, Number.MAX_SAFE_INTEGER); - const result = await this.execute(command); - - if (result.exitCode !== 0) { - throw new Error(`File '${filePath}' not found`); - } - - // Parse the line-numbered output back to content - const lines: string[] = []; - for (const line of result.output.split("\n")) { - // Format is " 123\tContent" - const tabIndex = line.indexOf("\t"); - if (tabIndex !== -1) { - lines.push(line.substring(tabIndex + 1)); - } - } - - const now = new Date().toISOString(); - return { - content: lines, - created_at: now, - modified_at: now, - }; - } - - /** - * Structured search results or error string for invalid input. - */ - async grepRaw( - pattern: string, - path: string = "/", - glob: string | null = null, - ): Promise { - const command = buildGrepCommand(pattern, path, glob); - const result = await this.execute(command); - - if (result.exitCode === 1) { - // Check if it's a regex error - if (result.output.includes("Invalid regex:")) { - return result.output.trim(); - } - } - - const matches: GrepMatch[] = []; - const lines = result.output.trim().split("\n").filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - matches.push({ - path: parsed.path, - line: parsed.line, - text: parsed.text, - }); - } catch { - // Skip invalid JSON lines - } - } - - return matches; - } - - /** - * Structured glob matching returning FileInfo objects. - */ - async globInfo(pattern: string, path: string = "/"): Promise { - const command = buildGlobCommand(path, pattern); - const result = await this.execute(command); - - const infos: FileInfo[] = []; - const lines = result.output.trim().split("\n").filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - infos.push({ - path: parsed.path, - is_dir: parsed.isDir, - size: parsed.size, - modified_at: parsed.mtime - ? new Date(parsed.mtime).toISOString() - : undefined, - }); - } catch { - // Skip invalid JSON lines - } - } - - return infos; - } - - /** - * Create a new file with content. - */ - async write(filePath: string, content: string): Promise { - const command = buildWriteCommand(filePath, content); - const result = await this.execute(command); - - if (result.exitCode !== 0) { - return { - error: `Cannot write to ${filePath} because it already exists. Read and then make an edit, or write to a new path.`, - }; - } - - return { path: filePath, filesUpdate: null }; - } - - /** - * Edit a file by replacing string occurrences. - */ - async edit( - filePath: string, - oldString: string, - newString: string, - replaceAll: boolean = false, - ): Promise { - const command = buildEditCommand( - filePath, - oldString, - newString, - replaceAll, - ); - const result = await this.execute(command); - - switch (result.exitCode) { - case 0: { - const occurrences = parseInt(result.output.trim(), 10) || 1; - return { path: filePath, filesUpdate: null, occurrences }; - } - case 1: - return { error: `String not found in file '${filePath}'` }; - case 2: - return { - error: `Multiple occurrences found in '${filePath}'. Use replaceAll=true to replace all.`, - }; - case 3: - return { error: `Error: File '${filePath}' not found` }; - default: - return { error: `Unknown error editing file '${filePath}'` }; - } - } -} diff --git a/libs/deepagents/src/backends/state.test.ts b/libs/deepagents/src/backends/state.test.ts deleted file mode 100644 index 678c11503..000000000 --- a/libs/deepagents/src/backends/state.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { StateBackend } from "./state.js"; -import type { FileData } from "./protocol.js"; -import { getCurrentTaskInput, Command } from "@langchain/langgraph"; -import { ToolMessage } from "@langchain/core/messages"; - -vi.mock("@langchain/langgraph", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...(actual as any), - getCurrentTaskInput: vi.fn(), - }; -}); - -/** - * Helper to create a mock config with state - */ -function makeConfig(files: Record = {}) { - const state = { - messages: [], - files, - }; - vi.mocked(getCurrentTaskInput).mockReturnValue(state); - return { - state, - stateAndStore: { state, store: undefined }, - config: {}, - }; -} - -describe("StateBackend", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should write, read, edit, ls, grep, and glob", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const writeRes = backend.write("/notes.txt", "hello world"); - expect(writeRes).toBeDefined(); - expect(writeRes.error).toBeUndefined(); - expect(writeRes.filesUpdate).toBeDefined(); - - Object.assign(state.files, writeRes.filesUpdate); - - const content = backend.read("/notes.txt"); - expect(content).toContain("hello world"); - - const editRes = backend.edit("/notes.txt", "hello", "hi", false); - expect(editRes).toBeDefined(); - expect(editRes.error).toBeUndefined(); - expect(editRes.filesUpdate).toBeDefined(); - Object.assign(state.files, editRes.filesUpdate); - - const content2 = backend.read("/notes.txt"); - expect(content2).toContain("hi world"); - - const listing = backend.lsInfo("/"); - expect(listing.some((fi) => fi.path === "/notes.txt")).toBe(true); - - const matches = backend.grepRaw("hi", "/"); - expect(Array.isArray(matches)).toBe(true); - if (Array.isArray(matches)) { - expect(matches.some((m) => m.path === "/notes.txt")).toBe(true); - } - - const err = backend.grepRaw("[", "/"); - expect(typeof err).toBe("string"); - - const infos = backend.globInfo("*.txt", "/"); - expect(infos.some((i) => i.path === "/notes.txt")).toBe(true); - }); - - it("should handle errors correctly", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const editErr = backend.edit("/missing.txt", "a", "b"); - expect(editErr.error).toBeDefined(); - expect(editErr.error).toContain("not found"); - - const writeRes = backend.write("/dup.txt", "x"); - expect(writeRes.filesUpdate).toBeDefined(); - Object.assign(state.files, writeRes.filesUpdate); - - const dupErr = backend.write("/dup.txt", "y"); - expect(dupErr.error).toBeDefined(); - expect(dupErr.error).toContain("already exists"); - }); - - it("should list nested directories correctly", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const files: Record = { - "/src/main.py": "main code", - "/src/utils/helper.py": "helper code", - "/src/utils/common.py": "common code", - "/docs/readme.md": "readme", - "/docs/api/reference.md": "api reference", - "/config.json": "config", - }; - - for (const [path, content] of Object.entries(files)) { - const res = backend.write(path, content); - expect(res.error).toBeUndefined(); - Object.assign(state.files, res.filesUpdate!); - } - - const rootListing = backend.lsInfo("/"); - const rootPaths = rootListing.map((fi) => fi.path); - expect(rootPaths).toContain("/config.json"); - expect(rootPaths).toContain("/src/"); - expect(rootPaths).toContain("/docs/"); - expect(rootPaths).not.toContain("/src/main.py"); - expect(rootPaths).not.toContain("/src/utils/helper.py"); - - const srcListing = backend.lsInfo("/src/"); - const srcPaths = srcListing.map((fi) => fi.path); - expect(srcPaths).toContain("/src/main.py"); - expect(srcPaths).toContain("/src/utils/"); - expect(srcPaths).not.toContain("/src/utils/helper.py"); - - const utilsListing = backend.lsInfo("/src/utils/"); - const utilsPaths = utilsListing.map((fi) => fi.path); - expect(utilsPaths).toContain("/src/utils/helper.py"); - expect(utilsPaths).toContain("/src/utils/common.py"); - expect(utilsPaths).toHaveLength(2); - - const emptyListing = backend.lsInfo("/nonexistent/"); - expect(emptyListing).toEqual([]); - }); - - it("should handle trailing slashes in ls", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const files: Record = { - "/file.txt": "content", - "/dir/nested.txt": "nested", - }; - - for (const [path, content] of Object.entries(files)) { - const res = backend.write(path, content); - expect(res.error).toBeUndefined(); - Object.assign(state.files, res.filesUpdate!); - } - - const listingWithSlash = backend.lsInfo("/"); - expect(listingWithSlash).toHaveLength(2); - const rootPaths = listingWithSlash.map((fi) => fi.path); - expect(rootPaths).toContain("/file.txt"); - expect(rootPaths).toContain("/dir/"); - - const listingFromDir = backend.lsInfo("/dir/"); - expect(listingFromDir).toHaveLength(1); - expect(listingFromDir[0].path).toBe("/dir/nested.txt"); - }); - - it("should handle read with offset and limit", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const content = "line1\nline2\nline3\nline4\nline5"; - const writeRes = backend.write("/multiline.txt", content); - Object.assign(state.files, writeRes.filesUpdate!); - - const readWithOffset = backend.read("/multiline.txt", 2, 2); - expect(readWithOffset).toContain("line3"); - expect(readWithOffset).toContain("line4"); - expect(readWithOffset).not.toContain("line1"); - expect(readWithOffset).not.toContain("line5"); - }); - - it("should handle edit with replace_all", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const writeRes = backend.write("/repeat.txt", "foo bar foo baz foo"); - Object.assign(state.files, writeRes.filesUpdate!); - - const editSingle = backend.edit("/repeat.txt", "foo", "qux", false); - expect(editSingle.error).toBeDefined(); - expect(editSingle.error).toContain("appears 3 times"); - - const editAll = backend.edit("/repeat.txt", "foo", "qux", true); - expect(editAll.error).toBeUndefined(); - expect(editAll.occurrences).toBe(3); - Object.assign(state.files, editAll.filesUpdate!); - - const readAfter = backend.read("/repeat.txt"); - expect(readAfter).toContain("qux bar qux baz qux"); - expect(readAfter).not.toContain("foo"); - }); - - it("should handle grep with glob filter", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const files: Record = { - "/test.py": "import os", - "/test.js": "import fs", - "/readme.md": "import guide", - }; - - for (const [path, content] of Object.entries(files)) { - const res = backend.write(path, content); - Object.assign(state.files, res.filesUpdate!); - } - - const matches = backend.grepRaw("import", "/", "*.py"); - expect(Array.isArray(matches)).toBe(true); - if (Array.isArray(matches)) { - expect(matches).toHaveLength(1); - expect(matches[0].path).toBe("/test.py"); - } - }); - - it("should return empty content warning for empty files", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const writeRes = backend.write("/empty.txt", ""); - Object.assign(state.files, writeRes.filesUpdate!); - - const content = backend.read("/empty.txt"); - expect(content).toContain( - "System reminder: File exists but has empty contents", - ); - }); - - describe("uploadFiles", () => { - it("should upload files and return filesUpdate", () => { - const { stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const files: Array<[string, Uint8Array]> = [ - ["/file1.txt", new TextEncoder().encode("content1")], - ["/file2.txt", new TextEncoder().encode("content2")], - ]; - - const result = backend.uploadFiles(files); - expect(result).toHaveLength(2); - expect(result[0].path).toBe("/file1.txt"); - expect(result[0].error).toBeNull(); - expect(result[1].path).toBe("/file2.txt"); - expect(result[1].error).toBeNull(); - - // Check filesUpdate is attached - expect((result as any).filesUpdate).toBeDefined(); - expect((result as any).filesUpdate["/file1.txt"]).toBeDefined(); - expect((result as any).filesUpdate["/file2.txt"]).toBeDefined(); - }); - - it("should handle binary content", () => { - const { stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const binaryContent = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - const files: Array<[string, Uint8Array]> = [ - ["/hello.txt", binaryContent], - ]; - - const result = backend.uploadFiles(files); - expect(result[0].error).toBeNull(); - expect((result as any).filesUpdate["/hello.txt"].content).toEqual([ - "Hello", - ]); - }); - }); - - describe("downloadFiles", () => { - it("should download existing files as Uint8Array", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const writeRes = backend.write("/test.txt", "test content"); - Object.assign(state.files, writeRes.filesUpdate); - - const result = backend.downloadFiles(["/test.txt"]); - expect(result).toHaveLength(1); - expect(result[0].path).toBe("/test.txt"); - expect(result[0].error).toBeNull(); - expect(result[0].content).not.toBeNull(); - - const content = new TextDecoder().decode(result[0].content!); - expect(content).toBe("test content"); - }); - - it("should return file_not_found for missing files", () => { - const { stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const result = backend.downloadFiles(["/nonexistent.txt"]); - expect(result).toHaveLength(1); - expect(result[0].path).toBe("/nonexistent.txt"); - expect(result[0].content).toBeNull(); - expect(result[0].error).toBe("file_not_found"); - }); - - it("should handle multiple files with mixed results", () => { - const { state, stateAndStore } = makeConfig(); - const backend = new StateBackend(stateAndStore); - - const writeRes = backend.write("/exists.txt", "I exist"); - Object.assign(state.files, writeRes.filesUpdate); - - const result = backend.downloadFiles(["/exists.txt", "/missing.txt"]); - expect(result).toHaveLength(2); - - expect(result[0].error).toBeNull(); - expect(result[0].content).not.toBeNull(); - - expect(result[1].error).toBe("file_not_found"); - expect(result[1].content).toBeNull(); - }); - }); - - it("should handle large tool result interception via middleware", async () => { - const { config } = makeConfig(); - const { createFilesystemMiddleware } = await import("../middleware/fs.js"); - - const middleware = createFilesystemMiddleware({ - toolTokenLimitBeforeEvict: 1000, - }); - - const largeContent = "x".repeat(5000); - const toolMessage = new ToolMessage({ - content: largeContent, - tool_call_id: "test_123", - name: "test_tool", - }); - - const mockToolFn = async () => toolMessage; - const mockToolCall = { name: "test_tool", args: {}, id: "test_123" }; - - const result = await (middleware as any).wrapToolCall( - { - toolCall: mockToolCall, - config: config, - state: { files: {}, messages: [] }, - runtime: {}, - }, - mockToolFn, - ); - - expect(result).toBeInstanceOf(Command); - expect(result.update.files).toBeDefined(); - expect(result.update.files["/large_tool_results/test_123"]).toBeDefined(); - expect(result.update.files["/large_tool_results/test_123"].content).toEqual( - [largeContent], - ); - - expect(result.update.messages).toHaveLength(1); - expect(result.update.messages[0].content).toContain( - "Tool result too large", - ); - expect(result.update.messages[0].content).toContain( - "/large_tool_results/test_123", - ); - }); -}); diff --git a/libs/deepagents/src/backends/state.ts b/libs/deepagents/src/backends/state.ts deleted file mode 100644 index 2530f7be1..000000000 --- a/libs/deepagents/src/backends/state.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * StateBackend: Store files in LangGraph agent state (ephemeral). - */ - -import type { - BackendProtocol, - EditResult, - FileData, - FileDownloadResponse, - FileInfo, - FileUploadResponse, - GrepMatch, - StateAndStore, - WriteResult, -} from "./protocol.js"; -import { - createFileData, - fileDataToString, - formatReadResponse, - globSearchFiles, - grepMatchesFromFiles, - performStringReplacement, - updateFileData, -} from "./utils.js"; - -/** - * Backend that stores files in agent state (ephemeral). - * - * Uses LangGraph's state management and checkpointing. Files persist within - * a conversation thread but not across threads. State is automatically - * checkpointed after each agent step. - * - * Special handling: Since LangGraph state must be updated via Command objects - * (not direct mutation), operations return filesUpdate in WriteResult/EditResult - * for the middleware to apply via Command. - */ -export class StateBackend implements BackendProtocol { - private stateAndStore: StateAndStore; - - constructor(stateAndStore: StateAndStore) { - this.stateAndStore = stateAndStore; - } - - /** - * Get files from current state. - */ - private getFiles(): Record { - return ( - ((this.stateAndStore.state as any).files as Record) || - {} - ); - } - - /** - * List files and directories in the specified directory (non-recursive). - * - * @param path - Absolute path to directory - * @returns List of FileInfo objects for files and directories directly in the directory. - * Directories have a trailing / in their path and is_dir=true. - */ - lsInfo(path: string): FileInfo[] { - const files = this.getFiles(); - const infos: FileInfo[] = []; - const subdirs = new Set(); - - // Normalize path to have trailing slash for proper prefix matching - const normalizedPath = path.endsWith("/") ? path : path + "/"; - - for (const [k, fd] of Object.entries(files)) { - // Check if file is in the specified directory or a subdirectory - if (!k.startsWith(normalizedPath)) { - continue; - } - - // Get the relative path after the directory - const relative = k.substring(normalizedPath.length); - - // If relative path contains '/', it's in a subdirectory - if (relative.includes("/")) { - // Extract the immediate subdirectory name - const subdirName = relative.split("/")[0]; - subdirs.add(normalizedPath + subdirName + "/"); - continue; - } - - // This is a file directly in the current directory - const size = fd.content.join("\n").length; - infos.push({ - path: k, - is_dir: false, - size: size, - modified_at: fd.modified_at, - }); - } - - // Add directories to the results - for (const subdir of Array.from(subdirs).sort()) { - infos.push({ - path: subdir, - is_dir: true, - size: 0, - modified_at: "", - }); - } - - infos.sort((a, b) => a.path.localeCompare(b.path)); - return infos; - } - - /** - * Read file content with line numbers. - * - * @param filePath - Absolute file path - * @param offset - Line offset to start reading from (0-indexed) - * @param limit - Maximum number of lines to read - * @returns Formatted file content with line numbers, or error message - */ - read(filePath: string, offset: number = 0, limit: number = 500): string { - const files = this.getFiles(); - const fileData = files[filePath]; - - if (!fileData) { - return `Error: File '${filePath}' not found`; - } - - return formatReadResponse(fileData, offset, limit); - } - - /** - * Read file content as raw FileData. - * - * @param filePath - Absolute file path - * @returns Raw file content as FileData - */ - readRaw(filePath: string): FileData { - const files = this.getFiles(); - const fileData = files[filePath]; - - if (!fileData) throw new Error(`File '${filePath}' not found`); - return fileData; - } - - /** - * Create a new file with content. - * Returns WriteResult with filesUpdate to update LangGraph state. - */ - write(filePath: string, content: string): WriteResult { - const files = this.getFiles(); - - if (filePath in files) { - return { - error: `Cannot write to ${filePath} because it already exists. Read and then make an edit, or write to a new path.`, - }; - } - - const newFileData = createFileData(content); - return { - path: filePath, - filesUpdate: { [filePath]: newFileData }, - }; - } - - /** - * Edit a file by replacing string occurrences. - * Returns EditResult with filesUpdate and occurrences. - */ - edit( - filePath: string, - oldString: string, - newString: string, - replaceAll: boolean = false, - ): EditResult { - const files = this.getFiles(); - const fileData = files[filePath]; - - if (!fileData) { - return { error: `Error: File '${filePath}' not found` }; - } - - const content = fileDataToString(fileData); - const result = performStringReplacement( - content, - oldString, - newString, - replaceAll, - ); - - if (typeof result === "string") { - return { error: result }; - } - - const [newContent, occurrences] = result; - const newFileData = updateFileData(fileData, newContent); - return { - path: filePath, - filesUpdate: { [filePath]: newFileData }, - occurrences: occurrences, - }; - } - - /** - * Structured search results or error string for invalid input. - */ - grepRaw( - pattern: string, - path: string = "/", - glob: string | null = null, - ): GrepMatch[] | string { - const files = this.getFiles(); - return grepMatchesFromFiles(files, pattern, path, glob); - } - - /** - * Structured glob matching returning FileInfo objects. - */ - globInfo(pattern: string, path: string = "/"): FileInfo[] { - const files = this.getFiles(); - const result = globSearchFiles(files, pattern, path); - - if (result === "No files found") { - return []; - } - - const paths = result.split("\n"); - const infos: FileInfo[] = []; - for (const p of paths) { - const fd = files[p]; - const size = fd ? fd.content.join("\n").length : 0; - infos.push({ - path: p, - is_dir: false, - size: size, - modified_at: fd?.modified_at || "", - }); - } - return infos; - } - - /** - * Upload multiple files. - * - * Note: Since LangGraph state must be updated via Command objects, - * the caller must apply filesUpdate via Command after calling this method. - * - * @param files - List of [path, content] tuples to upload - * @returns List of FileUploadResponse objects, one per input file - */ - uploadFiles( - files: Array<[string, Uint8Array]>, - ): FileUploadResponse[] & { filesUpdate?: Record } { - const responses: FileUploadResponse[] = []; - const updates: Record = {}; - - for (const [path, content] of files) { - try { - const contentStr = new TextDecoder().decode(content); - const fileData = createFileData(contentStr); - updates[path] = fileData; - responses.push({ path, error: null }); - } catch { - responses.push({ path, error: "invalid_path" }); - } - } - - // Attach filesUpdate for the caller to apply via Command - const result = responses as FileUploadResponse[] & { - filesUpdate?: Record; - }; - result.filesUpdate = updates; - return result; - } - - /** - * Download multiple files. - * - * @param paths - List of file paths to download - * @returns List of FileDownloadResponse objects, one per input path - */ - downloadFiles(paths: string[]): FileDownloadResponse[] { - const files = this.getFiles(); - const responses: FileDownloadResponse[] = []; - - for (const path of paths) { - const fileData = files[path]; - if (!fileData) { - responses.push({ path, content: null, error: "file_not_found" }); - continue; - } - - const contentStr = fileDataToString(fileData); - const content = new TextEncoder().encode(contentStr); - responses.push({ path, content, error: null }); - } - - return responses; - } -} diff --git a/libs/deepagents/src/backends/store.test.ts b/libs/deepagents/src/backends/store.test.ts deleted file mode 100644 index 6c068bef1..000000000 --- a/libs/deepagents/src/backends/store.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { StoreBackend } from "./store.js"; -import { InMemoryStore } from "@langchain/langgraph-checkpoint"; - -/** - * Helper to create a mock config with InMemoryStore - */ -function makeConfig() { - const store = new InMemoryStore(); - const stateAndStore = { - state: { files: {}, messages: [] }, - store, - }; - const config = { - store, - configurable: {}, - }; - - return { store, stateAndStore, config }; -} - -describe("StoreBackend", () => { - it("should handle CRUD and search operations", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const writeResult = await backend.write("/docs/readme.md", "hello store"); - expect(writeResult).toBeDefined(); - expect(writeResult.error).toBeUndefined(); - expect(writeResult.path).toBe("/docs/readme.md"); - expect(writeResult.filesUpdate).toBeNull(); - - const content = await backend.read("/docs/readme.md"); - expect(content).toContain("hello store"); - - const editResult = await backend.edit( - "/docs/readme.md", - "hello", - "hi", - false, - ); - expect(editResult).toBeDefined(); - expect(editResult.error).toBeUndefined(); - expect(editResult.occurrences).toBe(1); - - const infos = await backend.lsInfo("/docs/"); - expect(infos.some((i) => i.path === "/docs/readme.md")).toBe(true); - - const matches = await backend.grepRaw("hi", "/"); - expect(Array.isArray(matches)).toBe(true); - if (Array.isArray(matches)) { - expect(matches.some((m) => m.path === "/docs/readme.md")).toBe(true); - } - - const glob1 = await backend.globInfo("*.md", "/"); - expect(glob1.length).toBe(0); - - const glob2 = await backend.globInfo("**/*.md", "/"); - expect(glob2.some((i) => i.path === "/docs/readme.md")).toBe(true); - }); - - it("should list nested directories correctly", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const files: Record = { - "/src/main.py": "main code", - "/src/utils/helper.py": "helper code", - "/src/utils/common.py": "common code", - "/docs/readme.md": "readme", - "/docs/api/reference.md": "api reference", - "/config.json": "config", - }; - - for (const [path, content] of Object.entries(files)) { - const res = await backend.write(path, content); - expect(res.error).toBeUndefined(); - } - - const rootListing = await backend.lsInfo("/"); - const rootPaths = rootListing.map((fi) => fi.path); - expect(rootPaths).toContain("/config.json"); - expect(rootPaths).toContain("/src/"); - expect(rootPaths).toContain("/docs/"); - expect(rootPaths).not.toContain("/src/main.py"); - expect(rootPaths).not.toContain("/src/utils/helper.py"); - expect(rootPaths).not.toContain("/docs/readme.md"); - expect(rootPaths).not.toContain("/docs/api/reference.md"); - - const srcListing = await backend.lsInfo("/src/"); - const srcPaths = srcListing.map((fi) => fi.path); - expect(srcPaths).toContain("/src/main.py"); - expect(srcPaths).toContain("/src/utils/"); - expect(srcPaths).not.toContain("/src/utils/helper.py"); - - const utilsListing = await backend.lsInfo("/src/utils/"); - const utilsPaths = utilsListing.map((fi) => fi.path); - expect(utilsPaths).toContain("/src/utils/helper.py"); - expect(utilsPaths).toContain("/src/utils/common.py"); - expect(utilsPaths).toHaveLength(2); - - const emptyListing = await backend.lsInfo("/nonexistent/"); - expect(emptyListing).toEqual([]); - }); - - it("should handle trailing slashes in ls", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const files: Record = { - "/file.txt": "content", - "/dir/nested.txt": "nested", - }; - - for (const [path, content] of Object.entries(files)) { - const res = await backend.write(path, content); - expect(res.error).toBeUndefined(); - } - - const listingFromRoot = await backend.lsInfo("/"); - expect(listingFromRoot.length).toBeGreaterThan(0); - - const listing1 = await backend.lsInfo("/dir/"); - const listing2 = await backend.lsInfo("/dir"); - expect(listing1.length).toBe(listing2.length); - expect(listing1.map((fi) => fi.path)).toEqual( - listing2.map((fi) => fi.path), - ); - }); - - it("should handle errors correctly", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const editErr = await backend.edit("/missing.txt", "a", "b"); - expect(editErr.error).toBeDefined(); - expect(editErr.error).toContain("not found"); - - const writeRes = await backend.write("/dup.txt", "x"); - expect(writeRes.error).toBeUndefined(); - - const dupErr = await backend.write("/dup.txt", "y"); - expect(dupErr.error).toBeDefined(); - expect(dupErr.error).toContain("already exists"); - }); - - it("should handle read with offset and limit", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const content = "line1\nline2\nline3\nline4\nline5"; - await backend.write("/multiline.txt", content); - - const readWithOffset = await backend.read("/multiline.txt", 2, 2); - expect(readWithOffset).toContain("line3"); - expect(readWithOffset).toContain("line4"); - expect(readWithOffset).not.toContain("line1"); - expect(readWithOffset).not.toContain("line5"); - }); - - it("should handle edit with replace_all", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - await backend.write("/repeat.txt", "foo bar foo baz foo"); - - const editSingle = await backend.edit("/repeat.txt", "foo", "qux", false); - expect(editSingle.error).toBeDefined(); - expect(editSingle.error).toContain("appears 3 times"); - - const editAll = await backend.edit("/repeat.txt", "foo", "qux", true); - expect(editAll.error).toBeUndefined(); - expect(editAll.occurrences).toBe(3); - - const readAfter = await backend.read("/repeat.txt"); - expect(readAfter).toContain("qux bar qux baz qux"); - expect(readAfter).not.toContain("foo"); - }); - - it("should handle grep with glob filter", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const files: Record = { - "/test.py": "import os", - "/test.js": "import fs", - "/readme.md": "import guide", - }; - - for (const [path, content] of Object.entries(files)) { - await backend.write(path, content); - } - - const matches = await backend.grepRaw("import", "/", "*.py"); - expect(Array.isArray(matches)).toBe(true); - if (Array.isArray(matches)) { - expect(matches).toHaveLength(1); - expect(matches[0].path).toBe("/test.py"); - } - }); - - it("should return empty content warning for empty files", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - await backend.write("/empty.txt", ""); - - const content = await backend.read("/empty.txt"); - expect(content).toContain( - "System reminder: File exists but has empty contents", - ); - }); - - it("should use custom namespace when assistant_id is provided", async () => { - const { store } = makeConfig(); - const stateAndStoreWithAssistant = { - state: { files: {}, messages: [] }, - store, - assistantId: "test-assistant", - }; - - const backend = new StoreBackend(stateAndStoreWithAssistant); - - await backend.write("/test.txt", "content"); - - const items = await store.search(["test-assistant", "filesystem"]); - expect(items.some((item) => item.key === "/test.txt")).toBe(true); - - const defaultItems = await store.search(["filesystem"]); - expect(defaultItems.some((item) => item.key === "/test.txt")).toBe(false); - }); - - describe("uploadFiles", () => { - it("should upload files to store", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const files: Array<[string, Uint8Array]> = [ - ["/file1.txt", new TextEncoder().encode("content1")], - ["/file2.txt", new TextEncoder().encode("content2")], - ]; - - const result = await backend.uploadFiles(files); - expect(result).toHaveLength(2); - expect(result[0].path).toBe("/file1.txt"); - expect(result[0].error).toBeNull(); - expect(result[1].path).toBe("/file2.txt"); - expect(result[1].error).toBeNull(); - - // Verify files are stored - const content1 = await backend.read("/file1.txt"); - expect(content1).toContain("content1"); - const content2 = await backend.read("/file2.txt"); - expect(content2).toContain("content2"); - }); - - it("should handle binary content", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const binaryContent = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - const files: Array<[string, Uint8Array]> = [ - ["/hello.txt", binaryContent], - ]; - - const result = await backend.uploadFiles(files); - expect(result[0].error).toBeNull(); - - const content = await backend.read("/hello.txt"); - expect(content).toContain("Hello"); - }); - }); - - describe("downloadFiles", () => { - it("should download existing files as Uint8Array", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - await backend.write("/test.txt", "test content"); - - const result = await backend.downloadFiles(["/test.txt"]); - expect(result).toHaveLength(1); - expect(result[0].path).toBe("/test.txt"); - expect(result[0].error).toBeNull(); - expect(result[0].content).not.toBeNull(); - - const content = new TextDecoder().decode(result[0].content!); - expect(content).toBe("test content"); - }); - - it("should return file_not_found for missing files", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - const result = await backend.downloadFiles(["/nonexistent.txt"]); - expect(result).toHaveLength(1); - expect(result[0].path).toBe("/nonexistent.txt"); - expect(result[0].content).toBeNull(); - expect(result[0].error).toBe("file_not_found"); - }); - - it("should handle multiple files with mixed results", async () => { - const { stateAndStore } = makeConfig(); - const backend = new StoreBackend(stateAndStore); - - await backend.write("/exists.txt", "I exist"); - - const result = await backend.downloadFiles([ - "/exists.txt", - "/missing.txt", - ]); - expect(result).toHaveLength(2); - - expect(result[0].error).toBeNull(); - expect(result[0].content).not.toBeNull(); - - expect(result[1].error).toBe("file_not_found"); - expect(result[1].content).toBeNull(); - }); - }); - - it("should handle large tool result interception via middleware", async () => { - const { store, config } = makeConfig(); - const { createFilesystemMiddleware } = await import("../middleware/fs.js"); - const { ToolMessage } = await import("@langchain/core/messages"); - - const middleware = createFilesystemMiddleware({ - backend: (stateAndStore) => new StoreBackend(stateAndStore), - toolTokenLimitBeforeEvict: 1000, - }); - - const largeContent = "y".repeat(5000); - const toolMessage = new ToolMessage({ - content: largeContent, - tool_call_id: "test_456", - name: "test_tool", - }); - - const mockToolFn = async () => toolMessage; - const mockToolCall = { name: "test_tool", args: {}, id: "test_456" }; - - const result = await (middleware as any).wrapToolCall( - { - toolCall: mockToolCall, - config: config, - state: { files: {}, messages: [] }, - runtime: {}, - }, - mockToolFn, - ); - - expect(result).toBeInstanceOf(ToolMessage); - expect(result.content).toContain("Tool result too large"); - expect(result.content).toContain("/large_tool_results/test_456"); - - const storedContent = await store.get( - ["filesystem"], - "/large_tool_results/test_456", - ); - expect(storedContent).toBeDefined(); - expect((storedContent!.value as any).content).toEqual([largeContent]); - }); -}); diff --git a/libs/deepagents/src/backends/store.ts b/libs/deepagents/src/backends/store.ts deleted file mode 100644 index 51fde8953..000000000 --- a/libs/deepagents/src/backends/store.ts +++ /dev/null @@ -1,455 +0,0 @@ -/** - * StoreBackend: Adapter for LangGraph's BaseStore (persistent, cross-thread). - */ - -import type { Item } from "@langchain/langgraph"; -import type { - BackendProtocol, - EditResult, - FileData, - FileDownloadResponse, - FileInfo, - FileUploadResponse, - GrepMatch, - StateAndStore, - WriteResult, -} from "./protocol.js"; -import { - createFileData, - fileDataToString, - formatReadResponse, - globSearchFiles, - grepMatchesFromFiles, - performStringReplacement, - updateFileData, -} from "./utils.js"; - -/** - * Backend that stores files in LangGraph's BaseStore (persistent). - * - * Uses LangGraph's Store for persistent, cross-conversation storage. - * Files are organized via namespaces and persist across all threads. - * - * The namespace can include an optional assistant_id for multi-agent isolation. - */ -export class StoreBackend implements BackendProtocol { - private stateAndStore: StateAndStore; - - constructor(stateAndStore: StateAndStore) { - this.stateAndStore = stateAndStore; - } - - /** - * Get the store instance. - * - * @returns BaseStore instance - * @throws Error if no store is available - */ - private getStore() { - const store = this.stateAndStore.store; - if (!store) { - throw new Error("Store is required but not available in StateAndStore"); - } - return store; - } - - /** - * Get the namespace for store operations. - * - * If an assistant_id is available in stateAndStore, return - * [assistant_id, "filesystem"] to provide per-assistant isolation. - * Otherwise return ["filesystem"]. - */ - protected getNamespace(): string[] { - const namespace = "filesystem"; - const assistantId = this.stateAndStore.assistantId; - - if (assistantId) { - return [assistantId, namespace]; - } - - return [namespace]; - } - - /** - * Convert a store Item to FileData format. - * - * @param storeItem - The store Item containing file data - * @returns FileData object - * @throws Error if required fields are missing or have incorrect types - */ - private convertStoreItemToFileData(storeItem: Item): FileData { - const value = storeItem.value as any; - - if ( - !value.content || - !Array.isArray(value.content) || - typeof value.created_at !== "string" || - typeof value.modified_at !== "string" - ) { - throw new Error( - `Store item does not contain valid FileData fields. Got keys: ${Object.keys(value).join(", ")}`, - ); - } - - return { - content: value.content, - created_at: value.created_at, - modified_at: value.modified_at, - }; - } - - /** - * Convert FileData to a value suitable for store.put(). - * - * @param fileData - The FileData to convert - * @returns Object with content, created_at, and modified_at fields - */ - private convertFileDataToStoreValue(fileData: FileData): Record { - return { - content: fileData.content, - created_at: fileData.created_at, - modified_at: fileData.modified_at, - }; - } - - /** - * Search store with automatic pagination to retrieve all results. - * - * @param store - The store to search - * @param namespace - Hierarchical path prefix to search within - * @param options - Optional query, filter, and page_size - * @returns List of all items matching the search criteria - */ - private async searchStorePaginated( - store: any, - namespace: string[], - options: { - query?: string; - filter?: Record; - pageSize?: number; - } = {}, - ): Promise { - const { query, filter, pageSize = 100 } = options; - const allItems: Item[] = []; - let offset = 0; - - while (true) { - const pageItems = await store.search(namespace, { - query, - filter, - limit: pageSize, - offset, - }); - - if (!pageItems || pageItems.length === 0) { - break; - } - - allItems.push(...pageItems); - - if (pageItems.length < pageSize) { - break; - } - - offset += pageSize; - } - - return allItems; - } - - /** - * List files and directories in the specified directory (non-recursive). - * - * @param path - Absolute path to directory - * @returns List of FileInfo objects for files and directories directly in the directory. - * Directories have a trailing / in their path and is_dir=true. - */ - async lsInfo(path: string): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - - // Retrieve all items and filter by path prefix locally to avoid - // coupling to store-specific filter semantics - const items = await this.searchStorePaginated(store, namespace); - const infos: FileInfo[] = []; - const subdirs = new Set(); - - // Normalize path to have trailing slash for proper prefix matching - const normalizedPath = path.endsWith("/") ? path : path + "/"; - - for (const item of items) { - const itemKey = String(item.key); - - // Check if file is in the specified directory or a subdirectory - if (!itemKey.startsWith(normalizedPath)) { - continue; - } - - // Get the relative path after the directory - const relative = itemKey.substring(normalizedPath.length); - - // If relative path contains '/', it's in a subdirectory - if (relative.includes("/")) { - // Extract the immediate subdirectory name - const subdirName = relative.split("/")[0]; - subdirs.add(normalizedPath + subdirName + "/"); - continue; - } - - // This is a file directly in the current directory - try { - const fd = this.convertStoreItemToFileData(item); - const size = fd.content.join("\n").length; - infos.push({ - path: itemKey, - is_dir: false, - size: size, - modified_at: fd.modified_at, - }); - } catch { - // Skip invalid items - continue; - } - } - - // Add directories to the results - for (const subdir of Array.from(subdirs).sort()) { - infos.push({ - path: subdir, - is_dir: true, - size: 0, - modified_at: "", - }); - } - - infos.sort((a, b) => a.path.localeCompare(b.path)); - return infos; - } - - /** - * Read file content with line numbers. - * - * @param filePath - Absolute file path - * @param offset - Line offset to start reading from (0-indexed) - * @param limit - Maximum number of lines to read - * @returns Formatted file content with line numbers, or error message - */ - async read( - filePath: string, - offset: number = 0, - limit: number = 500, - ): Promise { - try { - const fileData = await this.readRaw(filePath); - return formatReadResponse(fileData, offset, limit); - } catch (e: any) { - return `Error: ${e.message}`; - } - } - - /** - * Read file content as raw FileData. - * - * @param filePath - Absolute file path - * @returns Raw file content as FileData - */ - async readRaw(filePath: string): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - const item = await store.get(namespace, filePath); - - if (!item) throw new Error(`File '${filePath}' not found`); - return this.convertStoreItemToFileData(item); - } - - /** - * Create a new file with content. - * Returns WriteResult. External storage sets filesUpdate=null. - */ - async write(filePath: string, content: string): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - - // Check if file exists - const existing = await store.get(namespace, filePath); - if (existing) { - return { - error: `Cannot write to ${filePath} because it already exists. Read and then make an edit, or write to a new path.`, - }; - } - - // Create new file - const fileData = createFileData(content); - const storeValue = this.convertFileDataToStoreValue(fileData); - await store.put(namespace, filePath, storeValue); - return { path: filePath, filesUpdate: null }; - } - - /** - * Edit a file by replacing string occurrences. - * Returns EditResult. External storage sets filesUpdate=null. - */ - async edit( - filePath: string, - oldString: string, - newString: string, - replaceAll: boolean = false, - ): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - - // Get existing file - const item = await store.get(namespace, filePath); - if (!item) { - return { error: `Error: File '${filePath}' not found` }; - } - - try { - const fileData = this.convertStoreItemToFileData(item); - const content = fileDataToString(fileData); - const result = performStringReplacement( - content, - oldString, - newString, - replaceAll, - ); - - if (typeof result === "string") { - return { error: result }; - } - - const [newContent, occurrences] = result; - const newFileData = updateFileData(fileData, newContent); - - // Update file in store - const storeValue = this.convertFileDataToStoreValue(newFileData); - await store.put(namespace, filePath, storeValue); - return { path: filePath, filesUpdate: null, occurrences: occurrences }; - } catch (e: any) { - return { error: `Error: ${e.message}` }; - } - } - - /** - * Structured search results or error string for invalid input. - */ - async grepRaw( - pattern: string, - path: string = "/", - glob: string | null = null, - ): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - const items = await this.searchStorePaginated(store, namespace); - - const files: Record = {}; - for (const item of items) { - try { - files[item.key] = this.convertStoreItemToFileData(item); - } catch { - // Skip invalid items - continue; - } - } - - return grepMatchesFromFiles(files, pattern, path, glob); - } - - /** - * Structured glob matching returning FileInfo objects. - */ - async globInfo(pattern: string, path: string = "/"): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - const items = await this.searchStorePaginated(store, namespace); - - const files: Record = {}; - for (const item of items) { - try { - files[item.key] = this.convertStoreItemToFileData(item); - } catch { - // Skip invalid items - continue; - } - } - - const result = globSearchFiles(files, pattern, path); - if (result === "No files found") { - return []; - } - - const paths = result.split("\n"); - const infos: FileInfo[] = []; - for (const p of paths) { - const fd = files[p]; - const size = fd ? fd.content.join("\n").length : 0; - infos.push({ - path: p, - is_dir: false, - size: size, - modified_at: fd?.modified_at || "", - }); - } - return infos; - } - - /** - * Upload multiple files. - * - * @param files - List of [path, content] tuples to upload - * @returns List of FileUploadResponse objects, one per input file - */ - async uploadFiles( - files: Array<[string, Uint8Array]>, - ): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - const responses: FileUploadResponse[] = []; - - for (const [path, content] of files) { - try { - const contentStr = new TextDecoder().decode(content); - const fileData = createFileData(contentStr); - const storeValue = this.convertFileDataToStoreValue(fileData); - await store.put(namespace, path, storeValue); - responses.push({ path, error: null }); - } catch { - responses.push({ path, error: "invalid_path" }); - } - } - - return responses; - } - - /** - * Download multiple files. - * - * @param paths - List of file paths to download - * @returns List of FileDownloadResponse objects, one per input path - */ - async downloadFiles(paths: string[]): Promise { - const store = this.getStore(); - const namespace = this.getNamespace(); - const responses: FileDownloadResponse[] = []; - - for (const path of paths) { - try { - const item = await store.get(namespace, path); - if (!item) { - responses.push({ path, content: null, error: "file_not_found" }); - continue; - } - - const fileData = this.convertStoreItemToFileData(item); - const contentStr = fileDataToString(fileData); - const content = new TextEncoder().encode(contentStr); - responses.push({ path, content, error: null }); - } catch { - responses.push({ path, content: null, error: "file_not_found" }); - } - } - - return responses; - } -} diff --git a/libs/deepagents/src/backends/utils.test.ts b/libs/deepagents/src/backends/utils.test.ts deleted file mode 100644 index 46f61b9a8..000000000 --- a/libs/deepagents/src/backends/utils.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - validatePath, - validateFilePath, - sanitizeToolCallId, - formatContentWithLineNumbers, - createFileData, - updateFileData, - fileDataToString, - checkEmptyContent, - performStringReplacement, - truncateIfTooLong, - TOOL_RESULT_TOKEN_LIMIT, -} from "./utils.js"; - -describe("validatePath", () => { - it("should add leading slash if missing", () => { - expect(validatePath("foo/bar")).toBe("/foo/bar/"); - }); - - it("should add trailing slash if missing", () => { - expect(validatePath("/foo/bar")).toBe("/foo/bar/"); - }); - - it("should handle root path", () => { - expect(validatePath("/")).toBe("/"); - }); - - it("should handle null path", () => { - expect(validatePath(null)).toBe("/"); - }); - - it("should handle undefined path", () => { - expect(validatePath(undefined)).toBe("/"); - }); - - it("should handle empty string", () => { - expect(validatePath("")).toBe("/"); - }); -}); - -describe("validateFilePath", () => { - it("should normalize paths without leading slash", () => { - expect(validateFilePath("foo/bar")).toBe("/foo/bar"); - }); - - it("should normalize paths with redundant slashes", () => { - expect(validateFilePath("/foo//bar")).toBe("/foo/bar"); - }); - - it("should remove dot components", () => { - expect(validateFilePath("/./foo/./bar")).toBe("/foo/bar"); - }); - - it("should reject path traversal with ..", () => { - expect(() => validateFilePath("../etc/passwd")).toThrow( - "Path traversal not allowed", - ); - }); - - it("should reject path traversal with .. in middle", () => { - expect(() => validateFilePath("/foo/../bar")).toThrow( - "Path traversal not allowed", - ); - }); - - it("should reject tilde paths", () => { - expect(() => validateFilePath("~/secret")).toThrow( - "Path traversal not allowed", - ); - }); - - it("should reject Windows absolute paths with backslash", () => { - expect(() => validateFilePath("C:\\Users\\file.txt")).toThrow( - "Windows absolute paths are not supported", - ); - }); - - it("should reject Windows absolute paths with forward slash", () => { - expect(() => validateFilePath("C:/Users/file.txt")).toThrow( - "Windows absolute paths are not supported", - ); - }); - - it("should reject lowercase Windows paths", () => { - expect(() => validateFilePath("c:/users/file.txt")).toThrow( - "Windows absolute paths are not supported", - ); - }); - - it("should normalize backslashes to forward slashes", () => { - expect(validateFilePath("/foo\\bar")).toBe("/foo/bar"); - }); - - it("should validate allowed prefixes when provided", () => { - expect(validateFilePath("/data/file.txt", ["/data/"])).toBe( - "/data/file.txt", - ); - }); - - it("should reject paths not starting with allowed prefixes", () => { - expect(() => validateFilePath("/etc/passwd", ["/data/"])).toThrow( - 'Path must start with one of ["/data/"]', - ); - }); - - it("should accept any of multiple allowed prefixes", () => { - expect(validateFilePath("/data/file.txt", ["/tmp/", "/data/"])).toBe( - "/data/file.txt", - ); - expect(validateFilePath("/tmp/file.txt", ["/tmp/", "/data/"])).toBe( - "/tmp/file.txt", - ); - }); - - it("should handle root path", () => { - expect(validateFilePath("/")).toBe("/"); - }); -}); - -describe("sanitizeToolCallId", () => { - it("should replace dots with underscores", () => { - expect(sanitizeToolCallId("call.123")).toBe("call_123"); - }); - - it("should replace forward slashes with underscores", () => { - expect(sanitizeToolCallId("call/123")).toBe("call_123"); - }); - - it("should replace backslashes with underscores", () => { - expect(sanitizeToolCallId("call\\123")).toBe("call_123"); - }); - - it("should handle multiple replacements", () => { - expect(sanitizeToolCallId("call.foo/bar\\baz")).toBe("call_foo_bar_baz"); - }); - - it("should leave safe strings unchanged", () => { - expect(sanitizeToolCallId("call_123_abc")).toBe("call_123_abc"); - }); -}); - -describe("formatContentWithLineNumbers", () => { - it("should format string content with line numbers", () => { - const result = formatContentWithLineNumbers("line1\nline2"); - expect(result).toContain("1"); - expect(result).toContain("line1"); - expect(result).toContain("2"); - expect(result).toContain("line2"); - }); - - it("should format array content with line numbers", () => { - const result = formatContentWithLineNumbers(["line1", "line2"]); - expect(result).toContain("1"); - expect(result).toContain("line1"); - expect(result).toContain("2"); - expect(result).toContain("line2"); - }); - - it("should use custom start line", () => { - const result = formatContentWithLineNumbers("line1", 10); - expect(result).toContain("10"); - expect(result).toContain("line1"); - }); - - it("should handle empty trailing newline", () => { - const result = formatContentWithLineNumbers("line1\nline2\n"); - const lines = result.split("\n"); - expect(lines.length).toBe(2); - }); -}); - -describe("createFileData", () => { - it("should create FileData with content split into lines", () => { - const result = createFileData("line1\nline2"); - expect(result.content).toEqual(["line1", "line2"]); - }); - - it("should set created_at and modified_at timestamps", () => { - const result = createFileData("content"); - expect(result.created_at).toBeDefined(); - expect(result.modified_at).toBeDefined(); - expect(new Date(result.created_at).getTime()).toBeGreaterThan(0); - }); - - it("should use provided createdAt timestamp", () => { - const timestamp = "2023-01-01T00:00:00.000Z"; - const result = createFileData("content", timestamp); - expect(result.created_at).toBe(timestamp); - }); -}); - -describe("updateFileData", () => { - it("should update content while preserving created_at", () => { - const original = createFileData("old content"); - const originalCreatedAt = original.created_at; - - const updated = updateFileData(original, "new content"); - expect(updated.content).toEqual(["new content"]); - expect(updated.created_at).toBe(originalCreatedAt); - }); - - it("should update modified_at timestamp", () => { - const original = createFileData("old content"); - - // Small delay to ensure different timestamp - const updated = updateFileData(original, "new content"); - expect(updated.modified_at).toBeDefined(); - }); -}); - -describe("fileDataToString", () => { - it("should join lines with newlines", () => { - const fileData = createFileData("line1\nline2\nline3"); - const result = fileDataToString(fileData); - expect(result).toBe("line1\nline2\nline3"); - }); -}); - -describe("checkEmptyContent", () => { - it("should return warning for empty string", () => { - expect(checkEmptyContent("")).not.toBeNull(); - }); - - it("should return warning for whitespace-only string", () => { - expect(checkEmptyContent(" \n\t ")).not.toBeNull(); - }); - - it("should return null for non-empty content", () => { - expect(checkEmptyContent("hello")).toBeNull(); - }); -}); - -describe("performStringReplacement", () => { - it("should replace string and return new content with occurrence count", () => { - const result = performStringReplacement( - "hello world", - "world", - "there", - false, - ); - expect(result).toEqual(["hello there", 1]); - }); - - it("should return error if string not found", () => { - const result = performStringReplacement("hello world", "foo", "bar", false); - expect(typeof result).toBe("string"); - expect(result).toContain("not found"); - }); - - it("should return error if multiple occurrences and replaceAll is false", () => { - const result = performStringReplacement("foo foo foo", "foo", "bar", false); - expect(typeof result).toBe("string"); - expect(result).toContain("appears 3 times"); - }); - - it("should replace all occurrences when replaceAll is true", () => { - const result = performStringReplacement("foo foo foo", "foo", "bar", true); - expect(result).toEqual(["bar bar bar", 3]); - }); -}); - -describe("truncateIfTooLong", () => { - it("should return array unchanged if under limit", () => { - const input = ["short", "lines"]; - expect(truncateIfTooLong(input)).toEqual(input); - }); - - it("should return string unchanged if under limit", () => { - const input = "short string"; - expect(truncateIfTooLong(input)).toBe(input); - }); - - it("should truncate long strings", () => { - const input = "x".repeat(TOOL_RESULT_TOKEN_LIMIT * 5); - const result = truncateIfTooLong(input); - expect(result.length).toBeLessThan(input.length); - expect(result).toContain("truncated"); - }); - - it("should truncate long arrays", () => { - const input = Array(1000).fill("a".repeat(100)); - const result = truncateIfTooLong(input) as string[]; - expect(result.length).toBeLessThan(input.length); - expect(result[result.length - 1]).toContain("truncated"); - }); -}); diff --git a/libs/deepagents/src/backends/utils.ts b/libs/deepagents/src/backends/utils.ts deleted file mode 100644 index dc98998d4..000000000 --- a/libs/deepagents/src/backends/utils.ts +++ /dev/null @@ -1,602 +0,0 @@ -/** - * Shared utility functions for memory backend implementations. - * - * This module contains both user-facing string formatters and structured - * helpers used by backends and the composite router. Structured helpers - * enable composition without fragile string parsing. - */ - -import micromatch from "micromatch"; -import { basename } from "path"; -import type { FileData, GrepMatch } from "./protocol.js"; - -// Constants -export const EMPTY_CONTENT_WARNING = - "System reminder: File exists but has empty contents"; -export const MAX_LINE_LENGTH = 10000; -export const LINE_NUMBER_WIDTH = 6; -export const TOOL_RESULT_TOKEN_LIMIT = 20000; // Same threshold as eviction -export const TRUNCATION_GUIDANCE = - "... [results truncated, try being more specific with your parameters]"; - -/** - * Sanitize tool_call_id to prevent path traversal and separator issues. - * - * Replaces dangerous characters (., /, \) with underscores. - */ -export function sanitizeToolCallId(toolCallId: string): string { - return toolCallId.replace(/\./g, "_").replace(/\//g, "_").replace(/\\/g, "_"); -} - -/** - * Format file content with line numbers (cat -n style). - * - * Chunks lines longer than MAX_LINE_LENGTH with continuation markers (e.g., 5.1, 5.2). - * - * @param content - File content as string or list of lines - * @param startLine - Starting line number (default: 1) - * @returns Formatted content with line numbers and continuation markers - */ -export function formatContentWithLineNumbers( - content: string | string[], - startLine: number = 1, -): string { - let lines: string[]; - if (typeof content === "string") { - lines = content.split("\n"); - if (lines.length > 0 && lines[lines.length - 1] === "") { - lines = lines.slice(0, -1); - } - } else { - lines = content; - } - - const resultLines: string[] = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineNum = i + startLine; - - if (line.length <= MAX_LINE_LENGTH) { - resultLines.push( - `${lineNum.toString().padStart(LINE_NUMBER_WIDTH)}\t${line}`, - ); - } else { - // Split long line into chunks with continuation markers - const numChunks = Math.ceil(line.length / MAX_LINE_LENGTH); - for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { - const start = chunkIdx * MAX_LINE_LENGTH; - const end = Math.min(start + MAX_LINE_LENGTH, line.length); - const chunk = line.substring(start, end); - if (chunkIdx === 0) { - // First chunk: use normal line number - resultLines.push( - `${lineNum.toString().padStart(LINE_NUMBER_WIDTH)}\t${chunk}`, - ); - } else { - // Continuation chunks: use decimal notation (e.g., 5.1, 5.2) - const continuationMarker = `${lineNum}.${chunkIdx}`; - resultLines.push( - `${continuationMarker.padStart(LINE_NUMBER_WIDTH)}\t${chunk}`, - ); - } - } - } - } - - return resultLines.join("\n"); -} - -/** - * Check if content is empty and return warning message. - * - * @param content - Content to check - * @returns Warning message if empty, null otherwise - */ -export function checkEmptyContent(content: string): string | null { - if (!content || content.trim() === "") { - return EMPTY_CONTENT_WARNING; - } - return null; -} - -/** - * Convert FileData to plain string content. - * - * @param fileData - FileData object with 'content' key - * @returns Content as string with lines joined by newlines - */ -export function fileDataToString(fileData: FileData): string { - return fileData.content.join("\n"); -} - -/** - * Create a FileData object with timestamps. - * - * @param content - File content as string - * @param createdAt - Optional creation timestamp (ISO format) - * @returns FileData object with content and timestamps - */ -export function createFileData(content: string, createdAt?: string): FileData { - const lines = typeof content === "string" ? content.split("\n") : content; - const now = new Date().toISOString(); - - return { - content: lines, - created_at: createdAt || now, - modified_at: now, - }; -} - -/** - * Update FileData with new content, preserving creation timestamp. - * - * @param fileData - Existing FileData object - * @param content - New content as string - * @returns Updated FileData object - */ -export function updateFileData(fileData: FileData, content: string): FileData { - const lines = typeof content === "string" ? content.split("\n") : content; - const now = new Date().toISOString(); - - return { - content: lines, - created_at: fileData.created_at, - modified_at: now, - }; -} - -/** - * Format file data for read response with line numbers. - * - * @param fileData - FileData object - * @param offset - Line offset (0-indexed) - * @param limit - Maximum number of lines - * @returns Formatted content or error message - */ -export function formatReadResponse( - fileData: FileData, - offset: number, - limit: number, -): string { - const content = fileDataToString(fileData); - const emptyMsg = checkEmptyContent(content); - if (emptyMsg) { - return emptyMsg; - } - - const lines = content.split("\n"); - const startIdx = offset; - const endIdx = Math.min(startIdx + limit, lines.length); - - if (startIdx >= lines.length) { - return `Error: Line offset ${offset} exceeds file length (${lines.length} lines)`; - } - - const selectedLines = lines.slice(startIdx, endIdx); - return formatContentWithLineNumbers(selectedLines, startIdx + 1); -} - -/** - * Perform string replacement with occurrence validation. - * - * @param content - Original content - * @param oldString - String to replace - * @param newString - Replacement string - * @param replaceAll - Whether to replace all occurrences - * @returns Tuple of [new_content, occurrences] on success, or error message string - */ -export function performStringReplacement( - content: string, - oldString: string, - newString: string, - replaceAll: boolean, -): [string, number] | string { - // Use split to count occurrences (simpler than regex) - const occurrences = content.split(oldString).length - 1; - - if (occurrences === 0) { - return `Error: String not found in file: '${oldString}'`; - } - - if (occurrences > 1 && !replaceAll) { - return `Error: String '${oldString}' appears ${occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context.`; - } - - // Python's str.replace() replaces ALL occurrences - // Use split/join for consistent behavior - const newContent = content.split(oldString).join(newString); - - return [newContent, occurrences]; -} - -/** - * Truncate list or string result if it exceeds token limit (rough estimate: 4 chars/token). - */ -export function truncateIfTooLong( - result: string[] | string, -): string[] | string { - if (Array.isArray(result)) { - const totalChars = result.reduce((sum, item) => sum + item.length, 0); - if (totalChars > TOOL_RESULT_TOKEN_LIMIT * 4) { - const truncateAt = Math.floor( - (result.length * TOOL_RESULT_TOKEN_LIMIT * 4) / totalChars, - ); - return [...result.slice(0, truncateAt), TRUNCATION_GUIDANCE]; - } - return result; - } - // string - if (result.length > TOOL_RESULT_TOKEN_LIMIT * 4) { - return ( - result.substring(0, TOOL_RESULT_TOKEN_LIMIT * 4) + - "\n" + - TRUNCATION_GUIDANCE - ); - } - return result; -} - -/** - * Validate and normalize a directory path. - * - * Ensures paths are safe to use by preventing directory traversal attacks - * and enforcing consistent formatting. All paths are normalized to use - * forward slashes and start with a leading slash. - * - * This function is designed for virtual filesystem paths and rejects - * Windows absolute paths (e.g., C:/..., F:/...) to maintain consistency - * and prevent path format ambiguity. - * - * @param path - Path to validate - * @returns Normalized path starting with / and ending with / - * @throws Error if path is invalid - * - * @example - * ```typescript - * validatePath("foo/bar") // Returns: "/foo/bar/" - * validatePath("/./foo//bar") // Returns: "/foo/bar/" - * validatePath("../etc/passwd") // Throws: Path traversal not allowed - * validatePath("C:\\Users\\file") // Throws: Windows absolute paths not supported - * ``` - */ -export function validatePath(path: string | null | undefined): string { - const pathStr = path || "/"; - if (!pathStr || pathStr.trim() === "") { - throw new Error("Path cannot be empty"); - } - - let normalized = pathStr.startsWith("/") ? pathStr : "/" + pathStr; - - if (!normalized.endsWith("/")) { - normalized += "/"; - } - - return normalized; -} - -/** - * Validate and normalize a file path for security. - * - * Ensures paths are safe to use by preventing directory traversal attacks - * and enforcing consistent formatting. All paths are normalized to use - * forward slashes and start with a leading slash. - * - * This function is designed for virtual filesystem paths and rejects - * Windows absolute paths (e.g., C:/..., F:/...) to maintain consistency - * and prevent path format ambiguity. - * - * @param path - The path to validate and normalize. - * @param allowedPrefixes - Optional list of allowed path prefixes. If provided, - * the normalized path must start with one of these prefixes. - * @returns Normalized canonical path starting with `/` and using forward slashes. - * @throws Error if path contains traversal sequences (`..` or `~`), is a Windows - * absolute path (e.g., C:/...), or does not start with an allowed prefix - * when `allowedPrefixes` is specified. - * - * @example - * ```typescript - * validateFilePath("foo/bar") // Returns: "/foo/bar" - * validateFilePath("/./foo//bar") // Returns: "/foo/bar" - * validateFilePath("../etc/passwd") // Throws: Path traversal not allowed - * validateFilePath("C:\\Users\\file.txt") // Throws: Windows absolute paths not supported - * validateFilePath("/data/file.txt", ["/data/"]) // Returns: "/data/file.txt" - * validateFilePath("/etc/file.txt", ["/data/"]) // Throws: Path must start with... - * ``` - */ -export function validateFilePath( - path: string, - allowedPrefixes?: string[], -): string { - // Check for path traversal - if (path.includes("..") || path.startsWith("~")) { - throw new Error(`Path traversal not allowed: ${path}`); - } - - // Reject Windows absolute paths (e.g., C:\..., D:/...) - // This maintains consistency in virtual filesystem paths - if (/^[a-zA-Z]:/.test(path)) { - throw new Error( - `Windows absolute paths are not supported: ${path}. Please use virtual paths starting with / (e.g., /workspace/file.txt)`, - ); - } - - // Normalize path separators and remove redundant slashes - let normalized = path.replace(/\\/g, "/"); - - // Remove redundant path components (./foo becomes foo, foo//bar becomes foo/bar) - const parts: string[] = []; - for (const part of normalized.split("/")) { - if (part === "." || part === "") { - continue; - } - parts.push(part); - } - normalized = "/" + parts.join("/"); - - // Check allowed prefixes if provided - if ( - allowedPrefixes && - !allowedPrefixes.some((prefix) => normalized.startsWith(prefix)) - ) { - throw new Error( - `Path must start with one of ${JSON.stringify(allowedPrefixes)}: ${path}`, - ); - } - - return normalized; -} - -/** - * Search files dict for paths matching glob pattern. - * - * @param files - Dictionary of file paths to FileData - * @param pattern - Glob pattern (e.g., `*.py`, `**\/*.ts`) - * @param path - Base path to search from - * @returns Newline-separated file paths, sorted by modification time (most recent first). - * Returns "No files found" if no matches. - * - * @example - * ```typescript - * const files = {"/src/main.py": FileData(...), "/test.py": FileData(...)}; - * globSearchFiles(files, "*.py", "/"); - * // Returns: "/test.py\n/src/main.py" (sorted by modified_at) - * ``` - */ -export function globSearchFiles( - files: Record, - pattern: string, - path: string = "/", -): string { - let normalizedPath: string; - try { - normalizedPath = validatePath(path); - } catch { - return "No files found"; - } - - const filtered = Object.fromEntries( - Object.entries(files).filter(([fp]) => fp.startsWith(normalizedPath)), - ); - - // Respect standard glob semantics: - // - Patterns without path separators (e.g., "*.py") match only in the current - // directory (non-recursive) relative to `path`. - // - Use "**" explicitly for recursive matching. - const effectivePattern = pattern; - - const matches: Array<[string, string]> = []; - for (const [filePath, fileData] of Object.entries(filtered)) { - let relative = filePath.substring(normalizedPath.length); - if (relative.startsWith("/")) { - relative = relative.substring(1); - } - if (!relative) { - const parts = filePath.split("/"); - relative = parts[parts.length - 1] || ""; - } - - if ( - micromatch.isMatch(relative, effectivePattern, { - dot: true, - nobrace: false, - }) - ) { - matches.push([filePath, fileData.modified_at]); - } - } - - matches.sort((a, b) => b[1].localeCompare(a[1])); // Sort by modified_at descending - - if (matches.length === 0) { - return "No files found"; - } - - return matches.map(([fp]) => fp).join("\n"); -} - -/** - * Format grep search results based on output mode. - * - * @param results - Dictionary mapping file paths to list of [line_num, line_content] tuples - * @param outputMode - Output format - "files_with_matches", "content", or "count" - * @returns Formatted string output - */ -export function formatGrepResults( - results: Record>, - outputMode: "files_with_matches" | "content" | "count", -): string { - if (outputMode === "files_with_matches") { - return Object.keys(results).sort().join("\n"); - } - if (outputMode === "count") { - const lines: string[] = []; - for (const filePath of Object.keys(results).sort()) { - const count = results[filePath].length; - lines.push(`${filePath}: ${count}`); - } - return lines.join("\n"); - } - // content mode - const lines: string[] = []; - for (const filePath of Object.keys(results).sort()) { - lines.push(`${filePath}:`); - for (const [lineNum, line] of results[filePath]) { - lines.push(` ${lineNum}: ${line}`); - } - } - return lines.join("\n"); -} - -/** - * Search file contents for regex pattern. - * - * @param files - Dictionary of file paths to FileData - * @param pattern - Regex pattern to search for - * @param path - Base path to search from - * @param glob - Optional glob pattern to filter files (e.g., "*.py") - * @param outputMode - Output format - "files_with_matches", "content", or "count" - * @returns Formatted search results. Returns "No matches found" if no results. - * - * @example - * ```typescript - * const files = {"/file.py": FileData({content: ["import os", "print('hi')"], ...})}; - * grepSearchFiles(files, "import", "/"); - * // Returns: "/file.py" (with output_mode="files_with_matches") - * ``` - */ -export function grepSearchFiles( - files: Record, - pattern: string, - path: string | null = null, - glob: string | null = null, - outputMode: "files_with_matches" | "content" | "count" = "files_with_matches", -): string { - let regex: RegExp; - try { - regex = new RegExp(pattern); - } catch (e: any) { - return `Invalid regex pattern: ${e.message}`; - } - - let normalizedPath: string; - try { - normalizedPath = validatePath(path); - } catch { - return "No matches found"; - } - - let filtered = Object.fromEntries( - Object.entries(files).filter(([fp]) => fp.startsWith(normalizedPath)), - ); - - if (glob) { - filtered = Object.fromEntries( - Object.entries(filtered).filter(([fp]) => - micromatch.isMatch(basename(fp), glob, { dot: true, nobrace: false }), - ), - ); - } - - const results: Record> = {}; - for (const [filePath, fileData] of Object.entries(filtered)) { - for (let i = 0; i < fileData.content.length; i++) { - const line = fileData.content[i]; - const lineNum = i + 1; - if (regex.test(line)) { - if (!results[filePath]) { - results[filePath] = []; - } - results[filePath].push([lineNum, line]); - } - } - } - - if (Object.keys(results).length === 0) { - return "No matches found"; - } - return formatGrepResults(results, outputMode); -} - -// -------- Structured helpers for composition -------- - -/** - * Return structured grep matches from an in-memory files mapping. - * - * Returns a list of GrepMatch on success, or a string for invalid inputs - * (e.g., invalid regex). We deliberately do not raise here to keep backends - * non-throwing in tool contexts and preserve user-facing error messages. - */ -export function grepMatchesFromFiles( - files: Record, - pattern: string, - path: string | null = null, - glob: string | null = null, -): GrepMatch[] | string { - let regex: RegExp; - try { - regex = new RegExp(pattern); - } catch (e: any) { - return `Invalid regex pattern: ${e.message}`; - } - - let normalizedPath: string; - try { - normalizedPath = validatePath(path); - } catch { - return []; - } - - let filtered = Object.fromEntries( - Object.entries(files).filter(([fp]) => fp.startsWith(normalizedPath)), - ); - - if (glob) { - filtered = Object.fromEntries( - Object.entries(filtered).filter(([fp]) => - micromatch.isMatch(basename(fp), glob, { dot: true, nobrace: false }), - ), - ); - } - - const matches: GrepMatch[] = []; - for (const [filePath, fileData] of Object.entries(filtered)) { - for (let i = 0; i < fileData.content.length; i++) { - const line = fileData.content[i]; - const lineNum = i + 1; - if (regex.test(line)) { - matches.push({ path: filePath, line: lineNum, text: line }); - } - } - } - - return matches; -} - -/** - * Group structured matches into the legacy dict form used by formatters. - */ -export function buildGrepResultsDict( - matches: GrepMatch[], -): Record> { - const grouped: Record> = {}; - for (const m of matches) { - if (!grouped[m.path]) { - grouped[m.path] = []; - } - grouped[m.path].push([m.line, m.text]); - } - return grouped; -} - -/** - * Format structured grep matches using existing formatting logic. - */ -export function formatGrepMatches( - matches: GrepMatch[], - outputMode: "files_with_matches" | "content" | "count", -): string { - if (matches.length === 0) { - return "No matches found"; - } - return formatGrepResults(buildGrepResultsDict(matches), outputMode); -} diff --git a/libs/deepagents/src/config.test.ts b/libs/deepagents/src/config.test.ts deleted file mode 100644 index 8b6209c0e..000000000 --- a/libs/deepagents/src/config.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { createSettings, findProjectRoot, type Settings } from "./config.js"; - -describe("Config Module", () => { - let tempDir: string; - let originalCwd: () => string; - - beforeEach(() => { - // Create a temporary directory for testing - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepagents-test-")); - originalCwd = process.cwd; - }); - - afterEach(() => { - // Cleanup temp directory - fs.rmSync(tempDir, { recursive: true, force: true }); - process.cwd = originalCwd; - }); - - describe("findProjectRoot", () => { - it("should find .git directory", () => { - // Create a .git directory - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - const result = findProjectRoot(tempDir); - expect(result).toBe(tempDir); - }); - - it("should find .git directory in parent", () => { - // Create a .git directory in root - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - // Create a nested directory - const nestedDir = path.join(tempDir, "nested", "deep"); - fs.mkdirSync(nestedDir, { recursive: true }); - - const result = findProjectRoot(nestedDir); - expect(result).toBe(tempDir); - }); - - it("should return null when no .git found", () => { - // No .git directory - const result = findProjectRoot(tempDir); - expect(result).toBeNull(); - }); - - it("should use cwd when startPath is not provided", () => { - // Create .git in cwd - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - // Mock process.cwd - process.cwd = () => tempDir; - - const result = findProjectRoot(); - expect(result).toBe(tempDir); - }); - }); - - describe("createSettings", () => { - it("should return correct paths", () => { - const settings = createSettings({ startPath: tempDir }); - - expect(settings.userDeepagentsDir).toBe( - path.join(os.homedir(), ".deepagents"), - ); - expect(settings.projectRoot).toBeNull(); - expect(settings.hasProject).toBe(false); - }); - - it("should detect project root when .git exists", () => { - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - const settings = createSettings({ startPath: tempDir }); - - expect(settings.projectRoot).toBe(tempDir); - expect(settings.hasProject).toBe(true); - }); - - describe("getAgentDir", () => { - let settings: Settings; - - beforeEach(() => { - settings = createSettings({ startPath: tempDir }); - }); - - it("should return correct path for valid agent name", () => { - const result = settings.getAgentDir("my-agent"); - expect(result).toBe(path.join(os.homedir(), ".deepagents", "my-agent")); - }); - - it("should accept alphanumeric names", () => { - const result = settings.getAgentDir("Agent123"); - expect(result).toBe(path.join(os.homedir(), ".deepagents", "Agent123")); - }); - - it("should accept names with hyphens and underscores", () => { - const result = settings.getAgentDir("my_agent-name"); - expect(result).toBe( - path.join(os.homedir(), ".deepagents", "my_agent-name"), - ); - }); - - it("should accept names with spaces", () => { - const result = settings.getAgentDir("My Agent"); - expect(result).toBe(path.join(os.homedir(), ".deepagents", "My Agent")); - }); - - it("should throw for invalid names with special characters", () => { - expect(() => settings.getAgentDir("agent@name")).toThrow( - /Invalid agent name/, - ); - }); - - it("should throw for empty name", () => { - expect(() => settings.getAgentDir("")).toThrow(/Invalid agent name/); - }); - - it("should throw for whitespace-only name", () => { - expect(() => settings.getAgentDir(" ")).toThrow(/Invalid agent name/); - }); - }); - - describe("ensureAgentDir", () => { - it("should create directory if not exists", () => { - const settings = createSettings({ startPath: tempDir }); - const agentName = "test-agent"; - const result = settings.ensureAgentDir(agentName); - - // Should end with the agent path - expect(result).toContain(".deepagents"); - expect(result).toContain(agentName); - expect(fs.existsSync(result)).toBe(true); - }); - - it("should return existing directory", () => { - const settings = createSettings({ startPath: tempDir }); - const agentName = "test-agent"; - - // Create directory first time - const firstResult = settings.ensureAgentDir(agentName); - expect(fs.existsSync(firstResult)).toBe(true); - - // Call again - should return the same path - const secondResult = settings.ensureAgentDir(agentName); - expect(secondResult).toBe(firstResult); - }); - }); - - describe("getUserAgentMdPath", () => { - it("should return correct path", () => { - const settings = createSettings({ startPath: tempDir }); - const result = settings.getUserAgentMdPath("my-agent"); - expect(result).toBe( - path.join(os.homedir(), ".deepagents", "my-agent", "agent.md"), - ); - }); - }); - - describe("getProjectAgentMdPath", () => { - it("should return null when not in project", () => { - const settings = createSettings({ startPath: tempDir }); - expect(settings.getProjectAgentMdPath()).toBeNull(); - }); - - it("should return correct path when in project", () => { - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - const settings = createSettings({ startPath: tempDir }); - const result = settings.getProjectAgentMdPath(); - expect(result).toBe(path.join(tempDir, ".deepagents", "agent.md")); - }); - }); - - describe("getUserSkillsDir", () => { - it("should return correct path", () => { - const settings = createSettings({ startPath: tempDir }); - const result = settings.getUserSkillsDir("my-agent"); - expect(result).toBe( - path.join(os.homedir(), ".deepagents", "my-agent", "skills"), - ); - }); - }); - - describe("ensureUserSkillsDir", () => { - it("should create skills directory", () => { - const settings = createSettings({ startPath: tempDir }); - const result = settings.ensureUserSkillsDir("my-agent"); - - // Should end with skills path - expect(result).toContain(".deepagents"); - expect(result).toContain("my-agent"); - expect(result).toContain("skills"); - expect(fs.existsSync(result)).toBe(true); - }); - }); - - describe("getProjectSkillsDir", () => { - it("should return null when not in project", () => { - const settings = createSettings({ startPath: tempDir }); - expect(settings.getProjectSkillsDir()).toBeNull(); - }); - - it("should return correct path when in project", () => { - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - const settings = createSettings({ startPath: tempDir }); - const result = settings.getProjectSkillsDir(); - expect(result).toBe(path.join(tempDir, ".deepagents", "skills")); - }); - }); - - describe("ensureProjectSkillsDir", () => { - it("should return null when not in project", () => { - const settings = createSettings({ startPath: tempDir }); - expect(settings.ensureProjectSkillsDir()).toBeNull(); - }); - - it("should create directory when in project", () => { - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - const settings = createSettings({ startPath: tempDir }); - const result = settings.ensureProjectSkillsDir(); - expect(result).toBe(path.join(tempDir, ".deepagents", "skills")); - expect(fs.existsSync(result!)).toBe(true); - }); - }); - - describe("ensureProjectDeepagentsDir", () => { - it("should return null when not in project", () => { - const settings = createSettings({ startPath: tempDir }); - expect(settings.ensureProjectDeepagentsDir()).toBeNull(); - }); - - it("should create directory when in project", () => { - const gitDir = path.join(tempDir, ".git"); - fs.mkdirSync(gitDir); - - const settings = createSettings({ startPath: tempDir }); - const result = settings.ensureProjectDeepagentsDir(); - expect(result).toBe(path.join(tempDir, ".deepagents")); - expect(fs.existsSync(result!)).toBe(true); - }); - }); - }); -}); diff --git a/libs/deepagents/src/config.ts b/libs/deepagents/src/config.ts deleted file mode 100644 index 281433c15..000000000 --- a/libs/deepagents/src/config.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Configuration and settings for deepagents. - * - * Provides project detection, path management, and environment configuration - * for skills and agent memory middleware. - */ - -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -/** - * Options for creating a Settings instance. - */ -export interface SettingsOptions { - /** Starting directory for project detection (defaults to cwd) */ - startPath?: string; -} - -/** - * Settings interface for project detection and path management. - * - * Provides access to: - * - Project root detection (via .git directory) - * - User-level deepagents directory (~/.deepagents) - * - Agent-specific directories and files - * - Skills directories (user and project level) - */ -export interface Settings { - /** Detected project root directory, or null if not in a git project */ - readonly projectRoot: string | null; - - /** Base user-level .deepagents directory (~/.deepagents) */ - readonly userDeepagentsDir: string; - - /** Check if currently in a git project */ - readonly hasProject: boolean; - - /** - * Get the agent directory path. - * @param agentName - Name of the agent - * @returns Path to ~/.deepagents/{agentName} - * @throws Error if agent name is invalid - */ - getAgentDir(agentName: string): string; - - /** - * Ensure agent directory exists and return path. - * @param agentName - Name of the agent - * @returns Path to ~/.deepagents/{agentName} - * @throws Error if agent name is invalid - */ - ensureAgentDir(agentName: string): string; - - /** - * Get user-level agent.md path for a specific agent. - * @param agentName - Name of the agent - * @returns Path to ~/.deepagents/{agentName}/agent.md - */ - getUserAgentMdPath(agentName: string): string; - - /** - * Get project-level agent.md path. - * @returns Path to {projectRoot}/.deepagents/agent.md, or null if not in a project - */ - getProjectAgentMdPath(): string | null; - - /** - * Get user-level skills directory path for a specific agent. - * @param agentName - Name of the agent - * @returns Path to ~/.deepagents/{agentName}/skills/ - */ - getUserSkillsDir(agentName: string): string; - - /** - * Ensure user-level skills directory exists and return path. - * @param agentName - Name of the agent - * @returns Path to ~/.deepagents/{agentName}/skills/ - */ - ensureUserSkillsDir(agentName: string): string; - - /** - * Get project-level skills directory path. - * @returns Path to {projectRoot}/.deepagents/skills/, or null if not in a project - */ - getProjectSkillsDir(): string | null; - - /** - * Ensure project-level skills directory exists and return path. - * @returns Path to {projectRoot}/.deepagents/skills/, or null if not in a project - */ - ensureProjectSkillsDir(): string | null; - - /** - * Ensure project .deepagents directory exists. - * @returns Path to {projectRoot}/.deepagents/, or null if not in a project - */ - ensureProjectDeepagentsDir(): string | null; -} - -/** - * Find the project root by looking for .git directory. - * - * Walks up the directory tree from startPath (or cwd) looking for a .git - * directory, which indicates the project root. - * - * @param startPath - Directory to start searching from. Defaults to current working directory. - * @returns Path to the project root if found, null otherwise. - */ -export function findProjectRoot(startPath?: string): string | null { - let current = path.resolve(startPath || process.cwd()); - - // Walk up the directory tree - while (current !== path.dirname(current)) { - const gitDir = path.join(current, ".git"); - if (fs.existsSync(gitDir)) { - return current; - } - current = path.dirname(current); - } - - // Check root directory as well - const rootGitDir = path.join(current, ".git"); - if (fs.existsSync(rootGitDir)) { - return current; - } - - return null; -} - -/** - * Validate agent name to prevent invalid filesystem paths and security issues. - * - * @param agentName - The agent name to validate - * @returns True if valid, false otherwise - */ -function isValidAgentName(agentName: string): boolean { - if (!agentName || !agentName.trim()) { - return false; - } - // Allow only alphanumeric, hyphens, underscores, and whitespace - return /^[a-zA-Z0-9_\-\s]+$/.test(agentName); -} - -/** - * Create a Settings instance with detected environment. - * - * @param options - Configuration options - * @returns Settings instance with project detection and path management - */ -export function createSettings(options: SettingsOptions = {}): Settings { - const projectRoot = findProjectRoot(options.startPath); - const userDeepagentsDir = path.join(os.homedir(), ".deepagents"); - - return { - projectRoot, - userDeepagentsDir, - hasProject: projectRoot !== null, - - getAgentDir(agentName: string): string { - if (!isValidAgentName(agentName)) { - throw new Error( - `Invalid agent name: ${JSON.stringify(agentName)}. ` + - "Agent names can only contain letters, numbers, hyphens, underscores, and spaces.", - ); - } - return path.join(userDeepagentsDir, agentName); - }, - - ensureAgentDir(agentName: string): string { - const agentDir = this.getAgentDir(agentName); - fs.mkdirSync(agentDir, { recursive: true }); - return agentDir; - }, - - getUserAgentMdPath(agentName: string): string { - return path.join(this.getAgentDir(agentName), "agent.md"); - }, - - getProjectAgentMdPath(): string | null { - if (!projectRoot) { - return null; - } - return path.join(projectRoot, ".deepagents", "agent.md"); - }, - - getUserSkillsDir(agentName: string): string { - return path.join(this.getAgentDir(agentName), "skills"); - }, - - ensureUserSkillsDir(agentName: string): string { - const skillsDir = this.getUserSkillsDir(agentName); - fs.mkdirSync(skillsDir, { recursive: true }); - return skillsDir; - }, - - getProjectSkillsDir(): string | null { - if (!projectRoot) { - return null; - } - return path.join(projectRoot, ".deepagents", "skills"); - }, - - ensureProjectSkillsDir(): string | null { - const skillsDir = this.getProjectSkillsDir(); - if (!skillsDir) { - return null; - } - fs.mkdirSync(skillsDir, { recursive: true }); - return skillsDir; - }, - - ensureProjectDeepagentsDir(): string | null { - if (!projectRoot) { - return null; - } - const deepagentsDir = path.join(projectRoot, ".deepagents"); - fs.mkdirSync(deepagentsDir, { recursive: true }); - return deepagentsDir; - }, - }; -} diff --git a/libs/deepagents/src/index.ts b/libs/deepagents/src/index.ts deleted file mode 100644 index b9f38b884..000000000 --- a/libs/deepagents/src/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Deep Agents TypeScript Implementation - * - * A TypeScript port of the Python Deep Agents library for building controllable AI agents with LangGraph. - * This implementation maintains 1:1 compatibility with the Python version. - */ - -export { createDeepAgent } from "./agent.js"; -export type { - CreateDeepAgentParams, - MergedDeepAgentState, - // DeepAgent type bag and helper types - DeepAgent, - DeepAgentTypeConfig, - DefaultDeepAgentTypeConfig, - ResolveDeepAgentTypeConfig, - InferDeepAgentType, - InferDeepAgentSubagents, - InferSubagentByName, - InferSubagentReactAgentType, - // Subagent middleware extraction types - ExtractSubAgentMiddleware, - FlattenSubAgentMiddleware, - InferSubAgentMiddlewareStates, -} from "./types.js"; - -// Export config -export { - createSettings, - findProjectRoot, - type Settings, - type SettingsOptions, -} from "./config.js"; - -// Export middleware (matches Python's interface) -export { - createFilesystemMiddleware, - createSubAgentMiddleware, - createPatchToolCallsMiddleware, - createMemoryMiddleware, - // Skills middleware - matches Python's SkillsMiddleware interface - createSkillsMiddleware, - type SkillsMiddlewareOptions, - type SkillMetadata, - // Skills constants - MAX_SKILL_FILE_SIZE, - MAX_SKILL_NAME_LENGTH, - MAX_SKILL_DESCRIPTION_LENGTH, - // Other middleware types - type FilesystemMiddlewareOptions, - type SubAgentMiddlewareOptions, - type MemoryMiddlewareOptions, - type SubAgent, - type CompiledSubAgent, - type FileData, -} from "./middleware/index.js"; - -// Export agent memory middleware -export { - createAgentMemoryMiddleware, - type AgentMemoryMiddlewareOptions, -} from "./middleware/agent-memory.js"; - -// Export skills loader (utility functions for direct filesystem access) -export { - listSkills, - parseSkillMetadata, - type SkillMetadata as LoaderSkillMetadata, - type ListSkillsOptions, -} from "./skills/index.js"; - -// Export backends -export { - StateBackend, - StoreBackend, - FilesystemBackend, - CompositeBackend, - BaseSandbox, - isSandboxBackend, - type BackendProtocol, - type BackendFactory, - type FileInfo, - type GrepMatch, - type WriteResult, - type EditResult, - // Sandbox execution types - type ExecuteResponse, - type FileOperationError, - type FileDownloadResponse, - type FileUploadResponse, - type SandboxBackendProtocol, - type MaybePromise, -} from "./backends/index.js"; diff --git a/libs/deepagents/src/middleware/agent-memory.test.ts b/libs/deepagents/src/middleware/agent-memory.test.ts deleted file mode 100644 index eaf75a445..000000000 --- a/libs/deepagents/src/middleware/agent-memory.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { createAgentMemoryMiddleware } from "./agent-memory.js"; -import type { Settings } from "../config.js"; - -describe("Agent Memory Middleware", () => { - let tempDir: string; - let mockSettings: Settings; - let userAgentDir: string; - let projectAgentDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync( - path.join(os.tmpdir(), "deepagents-memory-mw-test-"), - ); - - // Create user and project agent directories - userAgentDir = path.join(tempDir, ".deepagents", "test-agent"); - projectAgentDir = path.join(tempDir, "project", ".deepagents"); - fs.mkdirSync(userAgentDir, { recursive: true }); - fs.mkdirSync(projectAgentDir, { recursive: true }); - - // Create .git in project for detection - fs.mkdirSync(path.join(tempDir, "project", ".git"), { recursive: true }); - - // Mock settings that point to our test directories - mockSettings = { - projectRoot: path.join(tempDir, "project"), - userDeepagentsDir: path.join(tempDir, ".deepagents"), - hasProject: true, - getAgentDir: (name: string) => path.join(tempDir, ".deepagents", name), - ensureAgentDir: (name: string) => { - const dir = path.join(tempDir, ".deepagents", name); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - getUserAgentMdPath: (name: string) => - path.join(tempDir, ".deepagents", name, "agent.md"), - getProjectAgentMdPath: () => - path.join(tempDir, "project", ".deepagents", "agent.md"), - getUserSkillsDir: (name: string) => - path.join(tempDir, ".deepagents", name, "skills"), - ensureUserSkillsDir: (name: string) => { - const dir = path.join(tempDir, ".deepagents", name, "skills"); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - getProjectSkillsDir: () => - path.join(tempDir, "project", ".deepagents", "skills"), - ensureProjectSkillsDir: () => { - const dir = path.join(tempDir, "project", ".deepagents", "skills"); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - ensureProjectDeepagentsDir: () => { - const dir = path.join(tempDir, "project", ".deepagents"); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - }; - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("createAgentMemoryMiddleware", () => { - it("should create middleware with correct name", () => { - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - expect(middleware.name).toBe("AgentMemoryMiddleware"); - }); - - it("should have beforeAgent and wrapModelCall hooks", () => { - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - expect(middleware.beforeAgent).toBeDefined(); - expect(middleware.wrapModelCall).toBeDefined(); - }); - }); - - describe("beforeAgent hook", () => { - it("should load user memory from agent.md", () => { - const userMemoryContent = - "# User Preferences\n\n- Be concise\n- Use TypeScript"; - fs.writeFileSync(path.join(userAgentDir, "agent.md"), userMemoryContent); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const result = middleware.beforeAgent!({}); - - expect(result).toBeDefined(); - expect(result!.userMemory).toBe(userMemoryContent); - }); - - it("should load project memory from agent.md", () => { - const projectMemoryContent = - "# Project Instructions\n\n- Use FastAPI\n- Write tests"; - fs.writeFileSync( - path.join(projectAgentDir, "agent.md"), - projectMemoryContent, - ); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const result = middleware.beforeAgent!({}); - - expect(result).toBeDefined(); - expect(result!.projectMemory).toBe(projectMemoryContent); - }); - - it("should load both user and project memory", () => { - const userMemoryContent = "User memory content"; - const projectMemoryContent = "Project memory content"; - - fs.writeFileSync(path.join(userAgentDir, "agent.md"), userMemoryContent); - fs.writeFileSync( - path.join(projectAgentDir, "agent.md"), - projectMemoryContent, - ); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const result = middleware.beforeAgent!({}); - - expect(result!.userMemory).toBe(userMemoryContent); - expect(result!.projectMemory).toBe(projectMemoryContent); - }); - - it("should handle missing user memory gracefully", () => { - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const result = middleware.beforeAgent!({}); - - expect(result).toBeUndefined(); - }); - - it("should handle missing project memory gracefully", () => { - const userMemoryContent = "User memory only"; - fs.writeFileSync(path.join(userAgentDir, "agent.md"), userMemoryContent); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const result = middleware.beforeAgent!({}); - - expect(result!.userMemory).toBe(userMemoryContent); - expect(result!.projectMemory).toBeUndefined(); - }); - - it("should not reload memory if already in state", () => { - const userMemoryContent = "Original user memory"; - fs.writeFileSync(path.join(userAgentDir, "agent.md"), userMemoryContent); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - // First call loads memory - const existingState = { - userMemory: "Already loaded", - projectMemory: "Already loaded project", - }; - - const result = middleware.beforeAgent!(existingState); - - // Should return undefined since memory already exists in state - expect(result).toBeUndefined(); - }); - }); - - describe("wrapModelCall hook", () => { - it("should inject user memory content into system prompt", async () => { - const userMemoryContent = "User preferences here"; - fs.writeFileSync(path.join(userAgentDir, "agent.md"), userMemoryContent); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const stateUpdate = middleware.beforeAgent!({}); - - let capturedRequest: any; - const handler = vi.fn((request: any) => { - capturedRequest = request; - return Promise.resolve({ messages: [] }); - }); - - await middleware.wrapModelCall!( - { - systemPrompt: "", - state: stateUpdate, - }, - handler, - ); - - expect(capturedRequest.systemPrompt).toContain(""); - expect(capturedRequest.systemPrompt).toContain(userMemoryContent); - expect(capturedRequest.systemPrompt).toContain(""); - }); - - it("should inject project memory content into system prompt", async () => { - const projectMemoryContent = "Project instructions here"; - fs.writeFileSync( - path.join(projectAgentDir, "agent.md"), - projectMemoryContent, - ); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - const stateUpdate = middleware.beforeAgent!({}); - - let capturedRequest: any; - const handler = vi.fn((request: any) => { - capturedRequest = request; - return Promise.resolve({ messages: [] }); - }); - - await middleware.wrapModelCall!( - { - systemPrompt: "", - state: stateUpdate, - }, - handler, - ); - - expect(capturedRequest.systemPrompt).toContain(""); - expect(capturedRequest.systemPrompt).toContain(projectMemoryContent); - expect(capturedRequest.systemPrompt).toContain(""); - }); - - it("should inject long-term memory documentation", async () => { - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - let capturedRequest: any; - const handler = vi.fn((request: any) => { - capturedRequest = request; - return Promise.resolve({ messages: [] }); - }); - - await middleware.wrapModelCall!( - { - systemPrompt: "", - state: {}, - }, - handler, - ); - - expect(capturedRequest.systemPrompt).toContain("## Long-term Memory"); - expect(capturedRequest.systemPrompt).toContain( - "When to CHECK/READ memories", - ); - expect(capturedRequest.systemPrompt).toContain("When to update memories"); - }); - - it("should preserve base system prompt", async () => { - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - let capturedRequest: any; - const handler = vi.fn((request: any) => { - capturedRequest = request; - return Promise.resolve({ messages: [] }); - }); - - await middleware.wrapModelCall!( - { - systemPrompt: "You are a helpful coding assistant.", - state: {}, - }, - handler, - ); - - expect(capturedRequest.systemPrompt).toContain( - "You are a helpful coding assistant.", - ); - }); - - it("should show placeholder when no memory files exist", async () => { - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - let capturedRequest: any; - const handler = vi.fn((request: any) => { - capturedRequest = request; - return Promise.resolve({ messages: [] }); - }); - - await middleware.wrapModelCall!( - { - systemPrompt: "", - state: {}, - }, - handler, - ); - - expect(capturedRequest.systemPrompt).toContain("(No user agent.md)"); - expect(capturedRequest.systemPrompt).toContain("(No project agent.md)"); - }); - - it("should use custom system prompt template if provided", async () => { - const customTemplate = "USER: {user_memory}\nPROJECT: {project_memory}"; - const userMemoryContent = "My preferences"; - const projectMemoryContent = "My project"; - - fs.writeFileSync(path.join(userAgentDir, "agent.md"), userMemoryContent); - fs.writeFileSync( - path.join(projectAgentDir, "agent.md"), - projectMemoryContent, - ); - - const middleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - systemPromptTemplate: customTemplate, - }); - - const stateUpdate = middleware.beforeAgent!({}); - - let capturedRequest: any; - const handler = vi.fn((request: any) => { - capturedRequest = request; - return Promise.resolve({ messages: [] }); - }); - - await middleware.wrapModelCall!( - { - systemPrompt: "", - state: stateUpdate, - }, - handler, - ); - - expect(capturedRequest.systemPrompt).toContain("USER: My preferences"); - expect(capturedRequest.systemPrompt).toContain("PROJECT: My project"); - }); - }); -}); diff --git a/libs/deepagents/src/middleware/agent-memory.ts b/libs/deepagents/src/middleware/agent-memory.ts deleted file mode 100644 index 533fc6790..000000000 --- a/libs/deepagents/src/middleware/agent-memory.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Middleware for loading agent-specific long-term memory into the system prompt. - * - * This middleware loads the agent's long-term memory from agent.md files - * and injects it into the system prompt. Memory is loaded from: - * - User memory: ~/.deepagents/{agent_name}/agent.md - * - Project memory: {project_root}/.deepagents/agent.md - * - * @deprecated Use `createMemoryMiddleware` from `./memory.js` instead. - * This middleware uses direct filesystem access (Node.js fs module) which is not - * portable across backends. The `createMemoryMiddleware` function uses the - * `BackendProtocol` abstraction and follows the AGENTS.md specification. - * - * Migration example: - * ```typescript - * // Before (deprecated): - * import { createAgentMemoryMiddleware } from "./agent-memory.js"; - * const middleware = createAgentMemoryMiddleware({ settings, assistantId }); - * - * // After (recommended): - * import { createMemoryMiddleware } from "./memory.js"; - * import { FilesystemBackend } from "../backends/filesystem.js"; - * - * const middleware = createMemoryMiddleware({ - * backend: new FilesystemBackend({ rootDir: "/" }), - * sources: [ - * `~/.deepagents/${assistantId}/AGENTS.md`, - * `${projectRoot}/.deepagents/AGENTS.md`, - * ], - * }); - * ``` - */ - -import fs from "node:fs"; -import { z } from "zod"; -import { - createMiddleware, - /** - * required for type inference - */ - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; - -import type { Settings } from "../config.js"; - -/** - * Options for the agent memory middleware. - */ -export interface AgentMemoryMiddlewareOptions { - /** Settings instance with project detection and paths */ - settings: Settings; - - /** The agent identifier */ - assistantId: string; - - /** Optional custom template for injecting agent memory into system prompt */ - systemPromptTemplate?: string; -} - -/** - * State schema for agent memory middleware. - */ -const AgentMemoryStateSchema = z.object({ - /** Personal preferences from ~/.deepagents/{agent}/ (applies everywhere) */ - userMemory: z.string().optional(), - - /** Project-specific context (loaded from project root) */ - projectMemory: z.string().optional(), -}); - -/** - * Default template for memory injection. - */ -const DEFAULT_MEMORY_TEMPLATE = ` -{user_memory} - - - -{project_memory} -`; - -/** - * Long-term Memory Documentation system prompt. - */ -const LONGTERM_MEMORY_SYSTEM_PROMPT = ` - -## Long-term Memory - -Your long-term memory is stored in files on the filesystem and persists across sessions. - -**User Memory Location**: \`{agent_dir_absolute}\` (displays as \`{agent_dir_display}\`) -**Project Memory Location**: {project_memory_info} - -Your system prompt is loaded from TWO sources at startup: -1. **User agent.md**: \`{agent_dir_absolute}/agent.md\` - Your personal preferences across all projects -2. **Project agent.md**: Loaded from project root if available - Project-specific instructions - -Project-specific agent.md is loaded from these locations (both combined if both exist): -- \`[project-root]/.deepagents/agent.md\` (preferred) -- \`[project-root]/agent.md\` (fallback, but also included if both exist) - -**When to CHECK/READ memories (CRITICAL - do this FIRST):** -- **At the start of ANY new session**: Check both user and project memories - - User: \`ls {agent_dir_absolute}\` - - Project: \`ls {project_deepagents_dir}\` (if in a project) -- **BEFORE answering questions**: If asked "what do you know about X?" or "how do I do Y?", check project memories FIRST, then user -- **When user asks you to do something**: Check if you have project-specific guides or examples -- **When user references past work**: Search project memory files for related context - -**Memory-first response pattern:** -1. User asks a question → Check project directory first: \`ls {project_deepagents_dir}\` -2. If relevant files exist → Read them with \`read_file '{project_deepagents_dir}/[filename]'\` -3. Check user memory if needed → \`ls {agent_dir_absolute}\` -4. Base your answer on saved knowledge supplemented by general knowledge - -**When to update memories:** -- **IMMEDIATELY when the user describes your role or how you should behave** -- **IMMEDIATELY when the user gives feedback on your work** - Update memories to capture what was wrong and how to do it better -- When the user explicitly asks you to remember something -- When patterns or preferences emerge (coding styles, conventions, workflows) -- After significant work where context would help in future sessions - -**Learning from feedback:** -- When user says something is better/worse, capture WHY and encode it as a pattern -- Each correction is a chance to improve permanently - don't just fix the immediate issue, update your instructions -- When user says "you should remember X" or "be careful about Y", treat this as HIGH PRIORITY - update memories IMMEDIATELY -- Look for the underlying principle behind corrections, not just the specific mistake - -## Deciding Where to Store Memory - -When writing or updating agent memory, decide whether each fact, configuration, or behavior belongs in: - -### User Agent File: \`{agent_dir_absolute}/agent.md\` -→ Describes the agent's **personality, style, and universal behavior** across all projects. - -**Store here:** -- Your general tone and communication style -- Universal coding preferences (formatting, comment style, etc.) -- General workflows and methodologies you follow -- Tool usage patterns that apply everywhere -- Personal preferences that don't change per-project - -**Examples:** -- "Be concise and direct in responses" -- "Always use type hints in Python" -- "Prefer functional programming patterns" - -### Project Agent File: \`{project_deepagents_dir}/agent.md\` -→ Describes **how this specific project works** and **how the agent should behave here only.** - -**Store here:** -- Project-specific architecture and design patterns -- Coding conventions specific to this codebase -- Project structure and organization -- Testing strategies for this project -- Deployment processes and workflows -- Team conventions and guidelines - -**Examples:** -- "This project uses FastAPI with SQLAlchemy" -- "Tests go in tests/ directory mirroring src/ structure" -- "All API changes require updating OpenAPI spec" - -### Project Memory Files: \`{project_deepagents_dir}/*.md\` -→ Use for **project-specific reference information** and structured notes. - -**Store here:** -- API design documentation -- Architecture decisions and rationale -- Deployment procedures -- Common debugging patterns -- Onboarding information - -**Examples:** -- \`{project_deepagents_dir}/api-design.md\` - REST API patterns used -- \`{project_deepagents_dir}/architecture.md\` - System architecture overview -- \`{project_deepagents_dir}/deployment.md\` - How to deploy this project - -### File Operations: - -**User memory:** -\`\`\` -ls {agent_dir_absolute} # List user memory files -read_file '{agent_dir_absolute}/agent.md' # Read user preferences -edit_file '{agent_dir_absolute}/agent.md' ... # Update user preferences -\`\`\` - -**Project memory (preferred for project-specific information):** -\`\`\` -ls {project_deepagents_dir} # List project memory files -read_file '{project_deepagents_dir}/agent.md' # Read project instructions -edit_file '{project_deepagents_dir}/agent.md' ... # Update project instructions -write_file '{project_deepagents_dir}/agent.md' ... # Create project memory file -\`\`\` - -**Important**: -- Project memory files are stored in \`.deepagents/\` inside the project root -- Always use absolute paths for file operations -- Check project memories BEFORE user when answering project-specific questions`; - -/** - * Create middleware for loading agent-specific long-term memory. - * - * This middleware loads the agent's long-term memory from a file (agent.md) - * and injects it into the system prompt. The memory is loaded once at the - * start of the conversation and stored in state. - * - * @param options - Configuration options - * @returns AgentMiddleware for memory loading and injection - * - * @deprecated Use `createMemoryMiddleware` from `./memory.js` instead. - * This function uses direct filesystem access which limits portability. - */ -export function createAgentMemoryMiddleware( - options: AgentMemoryMiddlewareOptions, -) { - const { settings, assistantId, systemPromptTemplate } = options; - - // Compute paths - const agentDir = settings.getAgentDir(assistantId); - const agentDirDisplay = `~/.deepagents/${assistantId}`; - const agentDirAbsolute = agentDir; - const projectRoot = settings.projectRoot; - - // Build project memory info for documentation - const projectMemoryInfo = projectRoot - ? `\`${projectRoot}\` (detected)` - : "None (not in a git project)"; - - // Build project deepagents directory path - const projectDeepagentsDir = projectRoot - ? `${projectRoot}/.deepagents` - : "[project-root]/.deepagents (not in a project)"; - - const template = systemPromptTemplate || DEFAULT_MEMORY_TEMPLATE; - - return createMiddleware({ - name: "AgentMemoryMiddleware", - stateSchema: AgentMemoryStateSchema as any, - - beforeAgent(state: any) { - const result: Record = {}; - - // Load user memory if not already in state - if (!("userMemory" in state)) { - const userPath = settings.getUserAgentMdPath(assistantId); - if (fs.existsSync(userPath)) { - try { - result.userMemory = fs.readFileSync(userPath, "utf-8"); - } catch { - // Ignore read errors - } - } - } - - // Load project memory if not already in state - if (!("projectMemory" in state)) { - const projectPath = settings.getProjectAgentMdPath(); - if (projectPath && fs.existsSync(projectPath)) { - try { - result.projectMemory = fs.readFileSync(projectPath, "utf-8"); - } catch { - // Ignore read errors - } - } - } - - return Object.keys(result).length > 0 ? result : undefined; - }, - - wrapModelCall(request: any, handler: any) { - // Extract memory from state - const userMemory = request.state?.userMemory; - const projectMemory = request.state?.projectMemory; - const baseSystemPrompt = request.systemPrompt || ""; - - // Format memory section with both memories - const memorySection = template - .replace("{user_memory}", userMemory || "(No user agent.md)") - .replace("{project_memory}", projectMemory || "(No project agent.md)"); - - // Format long-term memory documentation - const memoryDocs = LONGTERM_MEMORY_SYSTEM_PROMPT.replaceAll( - "{agent_dir_absolute}", - agentDirAbsolute, - ) - .replaceAll("{agent_dir_display}", agentDirDisplay) - .replaceAll("{project_memory_info}", projectMemoryInfo) - .replaceAll("{project_deepagents_dir}", projectDeepagentsDir); - - // Memory content at start, base prompt in middle, documentation at end - let systemPrompt = memorySection; - if (baseSystemPrompt) { - systemPrompt += "\n\n" + baseSystemPrompt; - } - systemPrompt += "\n\n" + memoryDocs; - - return handler({ ...request, systemPrompt }); - }, - }); -} diff --git a/libs/deepagents/src/middleware/fs.eviction.test.ts b/libs/deepagents/src/middleware/fs.eviction.test.ts deleted file mode 100644 index e9397088b..000000000 --- a/libs/deepagents/src/middleware/fs.eviction.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - createContentPreview, - TOOLS_EXCLUDED_FROM_EVICTION, - NUM_CHARS_PER_TOKEN, -} from "./fs.js"; - -describe("TOOLS_EXCLUDED_FROM_EVICTION", () => { - it("should contain the expected tools", () => { - expect(TOOLS_EXCLUDED_FROM_EVICTION).toContain("ls"); - expect(TOOLS_EXCLUDED_FROM_EVICTION).toContain("glob"); - expect(TOOLS_EXCLUDED_FROM_EVICTION).toContain("grep"); - expect(TOOLS_EXCLUDED_FROM_EVICTION).toContain("read_file"); - expect(TOOLS_EXCLUDED_FROM_EVICTION).toContain("edit_file"); - expect(TOOLS_EXCLUDED_FROM_EVICTION).toContain("write_file"); - }); - - it("should not contain execute tool", () => { - expect(TOOLS_EXCLUDED_FROM_EVICTION).not.toContain("execute"); - }); - - it("should be a readonly array", () => { - // This is a type-level check, but we can verify it's an array - expect(Array.isArray(TOOLS_EXCLUDED_FROM_EVICTION)).toBe(true); - expect(TOOLS_EXCLUDED_FROM_EVICTION.length).toBe(6); - }); -}); - -describe("NUM_CHARS_PER_TOKEN", () => { - it("should be 4", () => { - expect(NUM_CHARS_PER_TOKEN).toBe(4); - }); -}); - -describe("createContentPreview", () => { - it("should show all lines for small content", () => { - const content = "line1\nline2\nline3"; - const preview = createContentPreview(content, 5, 5); - - expect(preview).toContain("line1"); - expect(preview).toContain("line2"); - expect(preview).toContain("line3"); - expect(preview).not.toContain("truncated"); - }); - - it("should show head and tail with truncation marker for large content", () => { - const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`); - const content = lines.join("\n"); - const preview = createContentPreview(content, 5, 5); - - // Should contain head lines - expect(preview).toContain("line1"); - expect(preview).toContain("line5"); - - // Should contain truncation marker - expect(preview).toContain("truncated"); - expect(preview).toContain("10 lines truncated"); - - // Should contain tail lines - expect(preview).toContain("line16"); - expect(preview).toContain("line20"); - }); - - it("should use default head/tail values", () => { - const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`); - const content = lines.join("\n"); - const preview = createContentPreview(content); - - // Default is 5 head + 5 tail = 10, so 15 lines should show truncation - expect(preview).toContain("truncated"); - expect(preview).toContain("5 lines truncated"); - }); - - it("should truncate long lines to 1000 chars", () => { - const longLine = "x".repeat(2000); - const content = longLine; - const preview = createContentPreview(content, 5, 5); - - // Should be truncated - expect(preview.length).toBeLessThan(2000); - }); - - it("should include line numbers", () => { - const content = "line1\nline2\nline3"; - const preview = createContentPreview(content); - - // Line numbers are right-padded with tab - expect(preview).toMatch(/\d+\s+line1/); - expect(preview).toMatch(/\d+\s+line2/); - expect(preview).toMatch(/\d+\s+line3/); - }); - - it("should handle custom head and tail sizes", () => { - const lines = Array.from({ length: 30 }, (_, i) => `line${i + 1}`); - const content = lines.join("\n"); - const preview = createContentPreview(content, 3, 3); - - // Head should have 3 lines - expect(preview).toContain("line1"); - expect(preview).toContain("line3"); - - // Truncation should show 24 lines - expect(preview).toContain("24 lines truncated"); - - // Tail should have 3 lines - expect(preview).toContain("line28"); - expect(preview).toContain("line30"); - }); - - it("should handle exactly head + tail lines", () => { - const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`); - const content = lines.join("\n"); - const preview = createContentPreview(content, 5, 5); - - // Should show all lines without truncation - expect(preview).not.toContain("truncated"); - expect(preview).toContain("line1"); - expect(preview).toContain("line10"); - }); - - it("should handle empty content", () => { - const preview = createContentPreview(""); - // Empty string splits into a single empty line, which gets formatted with a line number - expect(preview).toContain("1"); - }); - - it("should handle single line content", () => { - const content = "single line"; - const preview = createContentPreview(content); - - expect(preview).toContain("single line"); - expect(preview).not.toContain("truncated"); - }); -}); diff --git a/libs/deepagents/src/middleware/fs.int.test.ts b/libs/deepagents/src/middleware/fs.int.test.ts deleted file mode 100644 index 997ad5ea5..000000000 --- a/libs/deepagents/src/middleware/fs.int.test.ts +++ /dev/null @@ -1,1265 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createAgent } from "langchain"; -import { HumanMessage, ToolMessage } from "@langchain/core/messages"; -import { InMemoryStore } from "@langchain/langgraph-checkpoint"; -import { MemorySaver } from "@langchain/langgraph"; -import { createDeepAgent } from "../index.js"; -import { - createFilesystemMiddleware, - WRITE_FILE_TOOL_DESCRIPTION, -} from "./fs.js"; -import { - StateBackend, - StoreBackend, - CompositeBackend, -} from "../backends/index.js"; -import { v4 as uuidv4 } from "uuid"; -import { - SAMPLE_MODEL, - getPremierLeagueStandings, - getLaLigaStandings, - getNbaStandings, -} from "../testing/utils.js"; - -describe("Filesystem Middleware Integration Tests", () => { - it.concurrent.each([ - { useComposite: false, label: "StateBackend" }, - { useComposite: true, label: "CompositeBackend" }, - ])( - "should override filesystem system prompt ($label)", - { timeout: 90 * 1000 }, // 90s - async ({ useComposite }) => { - const checkpointer = useComposite ? new MemorySaver() : undefined; - const store = useComposite ? new InMemoryStore() : undefined; - - const backend = useComposite - ? (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }) - : undefined; // Use default StateBackend - - const filesystemMiddleware = createFilesystemMiddleware({ - backend, - systemPrompt: - "In every single response, you must say the word 'pokemon'! You love it!", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [filesystemMiddleware], - checkpointer, - store, - }); - - const config = useComposite - ? { configurable: { thread_id: uuidv4() } } - : undefined; - const response = await agent.invoke( - { - messages: [new HumanMessage("What do you like?")], - }, - config, - ); - - const lastMessage = response.messages[response.messages.length - 1]; - expect(lastMessage.content.toString().toLowerCase()).toContain("pokemon"); - }, - ); - - it.concurrent.each([ - { useComposite: false, label: "StateBackend" }, - { useComposite: true, label: "CompositeBackend" }, - ])( - "should override filesystem tool descriptions ($label)", - { timeout: 90 * 1000 }, // 90s - async ({ useComposite }) => { - const checkpointer = useComposite ? new MemorySaver() : undefined; - const store = useComposite ? new InMemoryStore() : undefined; - - const backend = useComposite - ? (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }) - : undefined; - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend, - customToolDescriptions: { - ls: "Charmander", - read_file: "Bulbasaur", - edit_file: "Squirtle", - }, - }), - ] as const, - tools: [], - checkpointer, - store, - }); - - const toolsArray = (agent as any).graph?.nodes?.tools?.bound?.tools || []; - const tools: Record = {}; - for (const tool of toolsArray) { - tools[tool.name] = tool; - } - - expect(tools).toMatchObject({ - ls: { description: "Charmander" }, - read_file: { description: "Bulbasaur" }, - write_file: { - description: WRITE_FILE_TOOL_DESCRIPTION, - }, - edit_file: { - description: "Squirtle", - }, - }); - }, - ); - - it.concurrent( - "should list longterm memory files without path", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/test.txt", { - content: ["Hello world"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/pokemon/charmander.txt", { - content: ["Ember"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ] as const, - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("List all of your files")], - files: { - "/pizza.txt": { - content: ["Hello world"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/pokemon/squirtle.txt": { - content: ["Splash"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const lsMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "ls", - ); - - expect(lsMessage).toBeDefined(); - const lsContent = lsMessage!.content.toString(); - expect(lsContent).toContain("/pizza.txt"); - expect(lsContent).toContain("/pokemon/"); - expect(lsContent).toContain("/memories/"); - }, - ); - - it.concurrent( - "should list longterm memory files with path filter", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/test.txt", { - content: ["Hello world"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/pokemon/charmander.txt", { - content: ["Ember"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ] as const, - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("List all files in /pokemon")], - files: { - "/pizza.txt": { - content: ["Hello world"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/pokemon/squirtle.txt": { - content: ["Splash"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const lsMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "ls", - ); - - expect(lsMessage).toBeDefined(); - const lsContent = lsMessage!.content.toString(); - expect(lsContent).toContain("/pokemon/squirtle.txt"); - expect(lsContent).not.toContain("/memories/pokemon/charmander.txt"); - expect(lsContent).not.toContain("/pizza.txt"); - }, - ); - - it.concurrent( - "should read longterm memory local file", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ] as const, - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("Read the file /pizza.txt")], - files: { - "/pizza.txt": { - content: ["Pepperoni is the best"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const readMessage = messages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "read_file", - ); - - expect(readMessage).toBeDefined(); - expect(readMessage!.content.toString()).toContain( - "Pepperoni is the best", - ); - }, - ); - - it.concurrent( - "should read longterm memory store file", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/test.txt", { - content: ["Hello from store"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("Read the file /memories/test.txt")], - }, - config, - ); - - const messages = response.messages; - const readMessage = messages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "read_file", - ); - - expect(readMessage).toBeDefined(); - expect(readMessage!.content.toString()).toContain("Hello from store"); - }, - ); - - it.concurrent( - "should write to longterm memory", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - await agent.invoke( - { - messages: [ - new HumanMessage( - "Write 'persistent data' to /memories/persistent.txt", - ), - ], - }, - config, - ); - - // Verify file was written to store - const items = await store.search(["filesystem"]); - const persistentFile = items.find( - (item) => item.key === "/persistent.txt", - ); - - expect(persistentFile).toBeDefined(); - expect((persistentFile!.value as any).content).toContain( - "persistent data", - ); - }, - ); - - it.concurrent( - "should fail to write to existing store file", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/existing.txt", { - content: ["Already exists"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [ - new HumanMessage("Write 'new data' to /memories/existing.txt"), - ], - }, - config, - ); - - const messages = response.messages; - const writeMessage = messages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "write_file", - ); - - expect(writeMessage).toBeDefined(); - expect(writeMessage!.content.toString()).toContain("already exists"); - }, - ); - - it.concurrent( - "should edit longterm memory file", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/editable.txt", { - content: ["Line 1", "Line 2", "Line 3"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - await agent.invoke( - { - messages: [ - new HumanMessage( - "Edit /memories/editable.txt: replace 'Line 2' with 'Modified Line 2'", - ), - ], - }, - config, - ); - - // Verify file was edited in store - const items = await store.search(["filesystem"]); - const editedFile = items.find((item) => item.key === "/editable.txt"); - - expect(editedFile).toBeDefined(); - expect((editedFile!.value as any).content).toContain("Modified Line 2"); - }, - ); - - it.concurrent( - "should handle tool results exceeding token limit", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - tools: [getNbaStandings], - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("Get NBA standings")], - }, - config, - ); - - const files = (response as any).files || {}; - const largeResultFiles = Object.keys(files).filter((f) => - f.includes("/large_tool_results/"), - ); - - expect(largeResultFiles.length).toBeGreaterThan(0); - }, - ); - - it.concurrent( - "should handle tool results with custom token limit", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - tools: [getNbaStandings], - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - toolTokenLimitBeforeEvict: 10000, // Low limit to trigger eviction - }), - ], - checkpointer, - store, - }); - - const config = { - configurable: { thread_id: uuidv4() }, - recursionLimit: 1000, - }; - const response = await agent.invoke( - { - messages: [ - new HumanMessage( - "Get NBA standings, if the information from the tool is not good, then just return, only try reading file 1 time max.", - ), - ], - }, - config, - ); - - // Check if result was evicted with custom limit - const files = (response as any).files || {}; - const largeResultFiles = Object.keys(files).filter((f) => - f.includes("/large_tool_results/"), - ); - - expect(largeResultFiles.length).toBeGreaterThan(0); - }, - ); - - it.concurrent( - "should handle Command return with tool call", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createDeepAgent({ - tools: [getPremierLeagueStandings], - model: SAMPLE_MODEL, - }); - - const response = await agent.invoke({ - messages: [new HumanMessage("Get premier league standings")], - }); - - // Command returns files and research state - expect(response.files).toBeDefined(); - expect(response.files["/test.txt"]).toBeDefined(); - }, - ); - - it.concurrent( - "should handle Command with existing state", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createDeepAgent({ - tools: [getLaLigaStandings], - model: SAMPLE_MODEL, - }); - - const response = await agent.invoke({ - messages: [new HumanMessage("Get la liga standings")], - files: { - "/existing.txt": { - content: ["Existing file"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - }); - - // Existing files should be preserved - expect(response.files["/existing.txt"]).toBeDefined(); - expect(response.files["/existing.txt"].content).toContain( - "Existing file", - ); - }, - ); - - it.concurrent( - "should fail to write to existing local file", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("Write 'new content' to /existing.txt")], - files: { - "/existing.txt": { - content: ["Already exists"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const writeMessage = messages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "write_file", - ); - - expect(writeMessage).toBeDefined(); - expect(writeMessage!.content.toString()).toContain("already exists"); - }, - ); - - it.concurrent( - "should perform glob search in shortterm memory only", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [createFilesystemMiddleware()], - checkpointer, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("Use glob to find all Python files")], - files: { - "/test.py": { - content: ["import os"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/main.py": { - content: ["def main(): pass"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/readme.txt": { - content: ["Documentation"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const globMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "glob", - ); - - expect(globMessage).toBeDefined(); - const globContent = globMessage!.content.toString(); - expect(globContent).toContain("/test.py"); - expect(globContent).toContain("/main.py"); - expect(globContent).not.toContain("/readme.txt"); - }, - ); - - it.concurrent( - "should perform glob search in longterm memory only", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/config.py", { - content: ["DEBUG = True"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/settings.py", { - content: ["SECRET_KEY = 'abc'"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/notes.txt", { - content: ["Important notes"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [ - new HumanMessage("Use glob to find all Python files in /memories"), - ], - files: {}, - } as any, - config, - ); - - const messages = response.messages; - const globMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "glob", - ); - - expect(globMessage).toBeDefined(); - const globContent = globMessage!.content.toString(); - expect(globContent).toContain("/memories/config.py"); - expect(globContent).toContain("/memories/settings.py"); - expect(globContent).not.toContain("/memories/notes.txt"); - }, - ); - - it.concurrent( - "should perform glob search across mixed memory", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/longterm.py", { - content: ["# Longterm file"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/longterm.txt", { - content: ["Text file"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [new HumanMessage("Use glob to find all Python files")], - files: { - "/shortterm.py": { - content: ["# Shortterm file"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/shortterm.txt": { - content: ["Another text file"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const globMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "glob", - ); - - expect(globMessage).toBeDefined(); - const globContent = globMessage!.content.toString(); - expect(globContent).toContain("/shortterm.py"); - expect(globContent).toContain("/memories/longterm.py"); - expect(globContent).not.toContain("/shortterm.txt"); - expect(globContent).not.toContain("/memories/longterm.txt"); - }, - ); - - it.concurrent( - "should perform grep search in shortterm memory only", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [createFilesystemMiddleware()], - checkpointer, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [ - new HumanMessage( - "Use grep to find all files containing the word 'import'", - ), - ], - files: { - "/test.py": { - content: ["import os", "import sys"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/main.py": { - content: ["def main(): pass"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/helper.py": { - content: ["import json"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const grepMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "grep", - ); - - expect(grepMessage).toBeDefined(); - const grepContent = grepMessage!.content.toString(); - expect(grepContent).toContain("/test.py"); - expect(grepContent).toContain("/helper.py"); - expect(grepContent).not.toContain("/main.py"); - }, - ); - - it.concurrent( - "should perform grep search in longterm memory only", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/pokemon/charmander.txt", { - content: ["Charmander is a fire type", "It evolves into Charmeleon"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/pokemon/squirtle.txt", { - content: ["Squirtle is a water type", "It evolves into Wartortle"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/pokemon/bulbasaur.txt", { - content: ["Bulbasaur is a grass type"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [ - new HumanMessage( - "Use grep to find all files in the memories directory containing the word 'fire'", - ), - ], - files: {}, - } as any, - config, - ); - - const messages = response.messages; - const grepMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "grep", - ); - - expect(grepMessage).toBeDefined(); - const grepContent = grepMessage!.content.toString(); - expect(grepContent).toContain("/memories/pokemon/charmander.txt"); - expect(grepContent).not.toContain("/memories/pokemon/squirtle.txt"); - expect(grepContent).not.toContain("/memories/pokemon/bulbasaur.txt"); - }, - ); - - it.concurrent( - "should perform grep search across mixed memory", - { timeout: 90 * 1000 }, // 90s - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - await store.put(["filesystem"], "/longterm_config.py", { - content: ["DEBUG = True", "TESTING = False"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - await store.put(["filesystem"], "/longterm_settings.py", { - content: ["SECRET_KEY = 'abc'"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [ - createFilesystemMiddleware({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - }), - ], - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const response = await agent.invoke( - { - messages: [ - new HumanMessage("Use grep to find all files containing 'DEBUG'"), - ], - files: { - "/shortterm_config.py": { - content: ["DEBUG = False", "VERBOSE = True"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - "/shortterm_main.py": { - content: ["def main(): pass"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - } as any, - config, - ); - - const messages = response.messages; - const grepMessage = messages.find( - (msg) => ToolMessage.isInstance(msg) && msg.name === "grep", - ); - - expect(grepMessage).toBeDefined(); - const grepContent = grepMessage!.content.toString(); - expect(grepContent).toContain("/shortterm_config.py"); - expect(grepContent).toContain("/memories/longterm_config.py"); - expect(grepContent).not.toContain("/shortterm_main.py"); - expect(grepContent).not.toContain("/memories/longterm_settings.py"); - }, - ); - - it.concurrent( - "should use default backend when no backend specified", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [createFilesystemMiddleware()], - checkpointer, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - - const response = await agent.invoke( - { - messages: [new HumanMessage("Write 'Hello World' to /test.txt")], - }, - config, - ); - - expect((response as any).files).toBeDefined(); - expect((response as any).files["/test.txt"]).toBeDefined(); - expect((response as any).files["/test.txt"].content).toContain( - "Hello World", - ); - - const response2 = await agent.invoke( - { - messages: [new HumanMessage("Read /test.txt")], - }, - config, - ); - - const messages = response2.messages; - const readMessage = messages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "read_file", - ); - expect(readMessage).toBeDefined(); - expect(readMessage!.content.toString()).toContain("Hello World"); - }, - ); - - it.concurrent( - "should handle longterm memory CRUD across multiple threads", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - // Pre-populate the store with a test file - await store.put(["filesystem"], "/pokemon.txt", { - content: ["Charmander is a fire-type Pokemon"], - created_at: new Date().toISOString(), - modified_at: new Date().toISOString(), - }); - - const agent = createDeepAgent({ - backend: (stateAndStore: any) => - new CompositeBackend(new StateBackend(stateAndStore), { - "/memories/": new StoreBackend(stateAndStore), - }), - checkpointer, - store, - }); - - // Read from one thread - const config1 = { configurable: { thread_id: uuidv4() } }; - const readResponse = await agent.invoke( - { - messages: [new HumanMessage("Read /memories/pokemon.txt")], - }, - config1, - ); - - const readMessages = readResponse.messages; - const readMessage = readMessages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "read_file", - ); - expect(readMessage).toBeDefined(); - expect(readMessage!.content.toString()).toContain("Charmander"); - - // List from another thread - const config2 = { configurable: { thread_id: uuidv4() } }; - const listResponse = await agent.invoke( - { - messages: [new HumanMessage("List files in /memories")], - }, - config2, - ); - - const listMessages = listResponse.messages; - const lsMessage = listMessages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "ls", - ); - expect(lsMessage).toBeDefined(); - expect(lsMessage!.content.toString()).toContain("/memories/pokemon.txt"); - - // Edit from yet another thread - const config3 = { configurable: { thread_id: uuidv4() } }; - const editResponse = await agent.invoke( - { - messages: [ - new HumanMessage( - "Edit /memories/pokemon.txt: replace 'fire' with 'blazing'", - ), - ], - }, - config3, - ); - - const editMessages = editResponse.messages; - const editMessage = editMessages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "edit_file", - ); - expect(editMessage).toBeDefined(); - - // Verify the edit persisted in the store - const updatedFile = await store.get(["filesystem"], "/pokemon.txt"); - expect(updatedFile).toBeDefined(); - const content = (updatedFile!.value as any).content.join("\n"); - expect(content).toContain("blazing"); - }, - ); - - it.concurrent( - "should handle shortterm memory CRUD in single thread", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - const store = new InMemoryStore(); - - const agent = createDeepAgent({ - backend: (stateAndStore: any) => new StateBackend(stateAndStore), - checkpointer, - store, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - - // Write a shortterm memory file - const writeResponse = await agent.invoke( - { - messages: [ - new HumanMessage( - "Write a haiku about Charmander to /charmander.txt, use the word 'fiery'", - ), - ], - }, - config, - ); - - const files = writeResponse.files || {}; - expect(files["/charmander.txt"]).toBeDefined(); - - // Read the shortterm memory file - const readResponse = await agent.invoke( - { - messages: [ - new HumanMessage( - "Read the haiku about Charmander from /charmander.txt", - ), - ], - }, - config, - ); - - const readMessages = readResponse.messages; - const readMessage = [...readMessages] - .reverse() - .find( - (msg: any) => msg._getType() === "tool" && msg.name === "read_file", - ); - expect(readMessage).toBeDefined(); - expect( - readMessage!.content.toString().toLowerCase().includes("fiery"), - ).toBe(true); - - // List all files in shortterm memory - const listResponse = await agent.invoke( - { - messages: [ - new HumanMessage("List all of the files in your filesystem"), - ], - }, - config, - ); - - const listMessages = listResponse.messages; - const lsMessage = listMessages.find( - (msg: any) => msg._getType() === "tool" && msg.name === "ls", - ); - expect(lsMessage).toBeDefined(); - expect(lsMessage!.content.toString()).toContain("/charmander.txt"); - - // Edit the shortterm memory file - const editResponse = await agent.invoke( - { - messages: [ - new HumanMessage( - "Edit the haiku about Charmander to use the word 'ember'", - ), - ], - }, - config, - ); - - const editedFiles = editResponse.files || {}; - expect(editedFiles["/charmander.txt"]).toBeDefined(); - const content = editedFiles["/charmander.txt"].content.join("\n"); - expect(content.toLowerCase().includes("ember")).toBe(true); - - // Read again to verify edit - const verifyResponse = await agent.invoke( - { - messages: [ - new HumanMessage( - "Read the haiku about Charmander at /charmander.txt", - ), - ], - }, - config, - ); - - const verifyMessages = verifyResponse.messages; - const verifyReadMessage = [...verifyMessages] - .reverse() - .find( - (msg: any) => msg._getType() === "tool" && msg.name === "read_file", - ); - expect(verifyReadMessage).toBeDefined(); - expect( - verifyReadMessage!.content.toString().toLowerCase().includes("ember"), - ).toBe(true); - }, - ); -}); diff --git a/libs/deepagents/src/middleware/fs.ts b/libs/deepagents/src/middleware/fs.ts deleted file mode 100644 index 6b1eb9d5f..000000000 --- a/libs/deepagents/src/middleware/fs.ts +++ /dev/null @@ -1,887 +0,0 @@ -/** - * Middleware for providing filesystem tools to an agent. - * - * Provides ls, read_file, write_file, edit_file, glob, and grep tools with support for: - * - Pluggable backends (StateBackend, StoreBackend, FilesystemBackend, CompositeBackend) - * - Tool result eviction for large outputs - */ - -import { - createMiddleware, - tool, - ToolMessage, - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; -import { Command, isCommand, getCurrentTaskInput } from "@langchain/langgraph"; -import { z } from "zod/v4"; -import type { - BackendProtocol, - BackendFactory, - FileData, - StateAndStore, -} from "../backends/protocol.js"; -import { isSandboxBackend } from "../backends/protocol.js"; -import { StateBackend } from "../backends/state.js"; -import { - sanitizeToolCallId, - formatContentWithLineNumbers, -} from "../backends/utils.js"; - -/** - * Tools that should be excluded from the large result eviction logic. - * - * This array contains tools that should NOT have their results evicted to the filesystem - * when they exceed token limits. Tools are excluded for different reasons: - * - * 1. Tools with built-in truncation (ls, glob, grep): - * These tools truncate their own output when it becomes too large. When these tools - * produce truncated output due to many matches, it typically indicates the query - * needs refinement rather than full result preservation. In such cases, the truncated - * matches are potentially more like noise and the LLM should be prompted to narrow - * its search criteria instead. - * - * 2. Tools with problematic truncation behavior (read_file): - * read_file is tricky to handle as the failure mode here is single long lines - * (e.g., imagine a jsonl file with very long payloads on each line). If we try to - * truncate the result of read_file, the agent may then attempt to re-read the - * truncated file using read_file again, which won't help. - * - * 3. Tools that never exceed limits (edit_file, write_file): - * These tools return minimal confirmation messages and are never expected to produce - * output large enough to exceed token limits, so checking them would be unnecessary. - */ -export const TOOLS_EXCLUDED_FROM_EVICTION = [ - "ls", - "glob", - "grep", - "read_file", - "edit_file", - "write_file", -] as const; - -/** - * Approximate number of characters per token for truncation calculations. - * Using 4 chars per token as a conservative approximation (actual ratio varies by content) - * This errs on the high side to avoid premature eviction of content that might fit. - */ -export const NUM_CHARS_PER_TOKEN = 4; - -/** - * Message template for evicted tool results. - */ -const TOO_LARGE_TOOL_MSG = `Tool result too large, the result of this tool call {tool_call_id} was saved in the filesystem at this path: {file_path} -You can read the result from the filesystem by using the read_file tool, but make sure to only read part of the result at a time. -You can do this by specifying an offset and limit in the read_file tool call. -For example, to read the first 100 lines, you can use the read_file tool with offset=0 and limit=100. - -Here is a preview showing the head and tail of the result (lines of the form -... [N lines truncated] ... -indicate omitted lines in the middle of the content): - -{content_sample}`; - -/** - * Create a preview of content showing head and tail with truncation marker. - * - * @param contentStr - The full content string to preview. - * @param headLines - Number of lines to show from the start (default: 5). - * @param tailLines - Number of lines to show from the end (default: 5). - * @returns Formatted preview string with line numbers. - */ -export function createContentPreview( - contentStr: string, - headLines: number = 5, - tailLines: number = 5, -): string { - const lines = contentStr.split("\n"); - - if (lines.length <= headLines + tailLines) { - // If file is small enough, show all lines - const previewLines = lines.map((line) => line.substring(0, 1000)); - return formatContentWithLineNumbers(previewLines, 1); - } - - // Show head and tail with truncation marker - const head = lines.slice(0, headLines).map((line) => line.substring(0, 1000)); - const tail = lines.slice(-tailLines).map((line) => line.substring(0, 1000)); - - const headSample = formatContentWithLineNumbers(head, 1); - const truncationNotice = `\n... [${lines.length - headLines - tailLines} lines truncated] ...\n`; - const tailSample = formatContentWithLineNumbers( - tail, - lines.length - tailLines + 1, - ); - - return headSample + truncationNotice + tailSample; -} - -/** - * required for type inference - */ -import type * as _zodTypes from "@langchain/core/utils/types"; -import type * as _zodMeta from "@langchain/langgraph/zod"; -import type * as _messages from "@langchain/core/messages"; - -/** - * Zod v3 schema for FileData (re-export from backends) - */ -const FileDataSchema = z.object({ - content: z.array(z.string()), - created_at: z.string(), - modified_at: z.string(), -}); - -export type { FileData }; - -/** - * Merge file updates with support for deletions. - */ -function fileDataReducer( - left: Record | undefined, - right: Record, -): Record { - if (left === undefined) { - const result: Record = {}; - for (const [key, value] of Object.entries(right)) { - if (value !== null) { - result[key] = value; - } - } - return result; - } - - const result = { ...left }; - for (const [key, value] of Object.entries(right)) { - if (value === null) { - delete result[key]; - } else { - result[key] = value; - } - } - return result; -} - -/** - * Shared filesystem state schema. - * Defined at module level to ensure the same object identity is used across all agents, - * preventing "Channel already exists with different type" errors when multiple agents - * use createFilesystemMiddleware. - */ -const FilesystemStateSchema = z.object({ - files: z - .record(z.string(), FileDataSchema) - .default({}) - .meta({ - reducer: { - fn: fileDataReducer, - schema: z.record(z.string(), FileDataSchema.nullable()), - }, - }), -}); - -/** - * Resolve backend from factory or instance. - * - * @param backend - Backend instance or factory function - * @param stateAndStore - State and store container for backend initialization - */ -function getBackend( - backend: BackendProtocol | BackendFactory, - stateAndStore: StateAndStore, -): BackendProtocol { - if (typeof backend === "function") { - return backend(stateAndStore); - } - return backend; -} - -// System prompts -const FILESYSTEM_SYSTEM_PROMPT = `## Filesystem Tools \`ls\`, \`read_file\`, \`write_file\`, \`edit_file\`, \`glob\`, \`grep\` - -You have access to a filesystem which you can interact with using these tools. -All file paths must start with a /. - -- ls: list files in a directory (requires absolute path) -- 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 -- glob: find files matching a pattern (e.g., "**/*.py") -- grep: search for text within files`; - -// Tool descriptions - ported from Python for comprehensive LLM guidance -export const LS_TOOL_DESCRIPTION = `Lists all files in a directory. - -This is useful for exploring the filesystem and finding the right file to read or edit. -You should almost ALWAYS use this tool before using the read_file or edit_file tools.`; - -export const READ_FILE_TOOL_DESCRIPTION = `Reads a file from the filesystem. - -Assume this tool is able to read all files. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. - -Usage: -- By default, it reads up to 500 lines starting from the beginning of the file -- **IMPORTANT for large files and codebase exploration**: Use pagination with offset and limit parameters to avoid context overflow - - First scan: read_file(path, limit=100) to see file structure - - Read more sections: read_file(path, offset=100, limit=200) for next 200 lines - - Only omit limit (read full file) when necessary for editing -- Specify offset and limit: read_file(path, offset=0, limit=100) reads first 100 lines -- Results are returned using cat -n format, with line numbers starting at 1 -- Lines longer than 10,000 characters will be split into multiple lines with continuation markers (e.g., 5.1, 5.2, etc.). When you specify a limit, these continuation lines count towards the limit. -- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. -- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. -- You should ALWAYS make sure a file has been read before editing it.`; - -export const WRITE_FILE_TOOL_DESCRIPTION = `Writes to a new file in the filesystem. - -Usage: -- The write_file tool will create a new file. -- Prefer to edit existing files (with the edit_file tool) over creating new ones when possible.`; - -export const EDIT_FILE_TOOL_DESCRIPTION = `Performs exact string replacements in files. - -Usage: -- You must read the file before editing. This tool will error if you attempt an edit without reading the file first. -- When editing, preserve the exact indentation (tabs/spaces) from the read output. Never include line number prefixes in old_string or new_string. -- ALWAYS prefer editing existing files over creating new ones. -- Only use emojis if the user explicitly requests it.`; - -export const GLOB_TOOL_DESCRIPTION = `Find files matching a glob pattern. - -Supports standard glob patterns: \`*\` (any characters), \`**\` (any directories), \`?\` (single character). -Returns a list of absolute file paths that match the pattern. - -Examples: -- \`**/*.py\` - Find all Python files -- \`*.txt\` - Find all text files in root -- \`/subdir/**/*.md\` - Find all markdown files under /subdir`; - -export const GREP_TOOL_DESCRIPTION = `Search for a text pattern across files. - -Searches for literal text (not regex) and returns matching files or content based on output_mode. - -Examples: -- Search all files: \`grep(pattern="TODO")\` -- Search Python files only: \`grep(pattern="import", glob="*.py")\` -- Show matching lines: \`grep(pattern="error", output_mode="content")\``; -export const EXECUTE_TOOL_DESCRIPTION = `Executes a shell command in an isolated sandbox environment. - -Usage: -Executes a given command in the sandbox environment with proper handling and security measures. -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use the ls tool to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use ls to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") - - Examples of proper quoting: - - cd "/Users/name/My Documents" (correct) - - cd /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command - - Capture the output of the command - -Usage notes: - - Commands run in an isolated sandbox environment - - Returns combined stdout/stderr output with exit code - - If the output is very large, it may be truncated - - VERY IMPORTANT: You MUST avoid using search commands like find and grep. Instead use the grep, glob tools to search. You MUST avoid read tools like cat, head, tail, and use read_file to read files. - - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings) - - Use '&&' when commands depend on each other (e.g., "mkdir dir && cd dir") - - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of cd - -Examples: - Good examples: - - execute(command="pytest /foo/bar/tests") - - execute(command="python /path/to/script.py") - - execute(command="npm install && npm test") - - Bad examples (avoid these): - - execute(command="cd /foo/bar && pytest tests") # Use absolute path instead - - execute(command="cat file.txt") # Use read_file tool instead - - execute(command="find . -name '*.py'") # Use glob tool instead - - execute(command="grep -r 'pattern' .") # Use grep tool instead - -Note: This tool is only available if the backend supports execution (SandboxBackendProtocol). -If execution is not supported, the tool will return an error message.`; - -// System prompt for execution capability -export const EXECUTION_SYSTEM_PROMPT = `## Execute Tool \`execute\` - -You have access to an \`execute\` tool for running shell commands in a sandboxed environment. -Use this tool to run commands, scripts, tests, builds, and other shell operations. - -- execute: run a shell command in the sandbox (returns output and exit code)`; - -/** - * Create ls tool using backend. - */ -function createLsTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const path = input.path || "/"; - const infos = await resolvedBackend.lsInfo(path); - - if (infos.length === 0) { - return `No files found in ${path}`; - } - - // Format output - const lines: string[] = []; - for (const info of infos) { - if (info.is_dir) { - lines.push(`${info.path} (directory)`); - } else { - const size = info.size ? ` (${info.size} bytes)` : ""; - lines.push(`${info.path}${size}`); - } - } - return lines.join("\n"); - }, - { - name: "ls", - description: customDescription || LS_TOOL_DESCRIPTION, - schema: z.object({ - path: z - .string() - .optional() - .default("/") - .describe("Directory path to list (default: /)"), - }), - }, - ); -} - -/** - * Create read_file tool using backend. - */ -function createReadFileTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const { file_path, offset = 0, limit = 500 } = input; - return await resolvedBackend.read(file_path, offset, limit); - }, - { - name: "read_file", - description: customDescription || READ_FILE_TOOL_DESCRIPTION, - schema: z.object({ - file_path: z.string().describe("Absolute path to the file to read"), - offset: z.coerce - .number() - .optional() - .default(0) - .describe("Line offset to start reading from (0-indexed)"), - limit: z.coerce - .number() - .optional() - .default(500) - .describe("Maximum number of lines to read"), - }), - }, - ); -} - -/** - * Create write_file tool using backend. - */ -function createWriteFileTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const { file_path, content } = input; - const result = await resolvedBackend.write(file_path, content); - - if (result.error) { - return result.error; - } - - // If filesUpdate is present, return Command to update state - const message = new ToolMessage({ - content: `Successfully wrote to '${file_path}'`, - tool_call_id: config.toolCall?.id as string, - name: "write_file", - metadata: result.metadata, - }); - - if (result.filesUpdate) { - return new Command({ - update: { files: result.filesUpdate, messages: [message] }, - }); - } - - return message; - }, - { - name: "write_file", - description: customDescription || WRITE_FILE_TOOL_DESCRIPTION, - schema: z.object({ - file_path: z.string().describe("Absolute path to the file to write"), - content: z.string().describe("Content to write to the file"), - }), - }, - ); -} - -/** - * Create edit_file tool using backend. - */ -function createEditFileTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const { file_path, old_string, new_string, replace_all = false } = input; - const result = await resolvedBackend.edit( - file_path, - old_string, - new_string, - replace_all, - ); - - if (result.error) { - return result.error; - } - - const message = new ToolMessage({ - content: `Successfully replaced ${result.occurrences} occurrence(s) in '${file_path}'`, - tool_call_id: config.toolCall?.id as string, - name: "edit_file", - metadata: result.metadata, - }); - - // If filesUpdate is present, return Command to update state - if (result.filesUpdate) { - return new Command({ - update: { files: result.filesUpdate, messages: [message] }, - }); - } - - // External storage (filesUpdate is null) - return message; - }, - { - name: "edit_file", - description: customDescription || EDIT_FILE_TOOL_DESCRIPTION, - schema: z.object({ - file_path: z.string().describe("Absolute path to the file to edit"), - old_string: z - .string() - .describe("String to be replaced (must match exactly)"), - new_string: z.string().describe("String to replace with"), - replace_all: z - .boolean() - .optional() - .default(false) - .describe("Whether to replace all occurrences"), - }), - }, - ); -} - -/** - * Create glob tool using backend. - */ -function createGlobTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const { pattern, path = "/" } = input; - const infos = await resolvedBackend.globInfo(pattern, path); - - if (infos.length === 0) { - return `No files found matching pattern '${pattern}'`; - } - - return infos.map((info) => info.path).join("\n"); - }, - { - name: "glob", - description: customDescription || GLOB_TOOL_DESCRIPTION, - schema: z.object({ - pattern: z.string().describe("Glob pattern (e.g., '*.py', '**/*.ts')"), - path: z - .string() - .optional() - .default("/") - .describe("Base path to search from (default: /)"), - }), - }, - ); -} - -/** - * Create grep tool using backend. - */ -function createGrepTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const { pattern, path = "/", glob = null } = input; - const result = await resolvedBackend.grepRaw(pattern, path, glob); - - // If string, it's an error - if (typeof result === "string") { - return result; - } - - if (result.length === 0) { - return `No matches found for pattern '${pattern}'`; - } - - // Format output: group by file - const lines: string[] = []; - let currentFile: string | null = null; - for (const match of result) { - if (match.path !== currentFile) { - currentFile = match.path; - lines.push(`\n${currentFile}:`); - } - lines.push(` ${match.line}: ${match.text}`); - } - - return lines.join("\n"); - }, - { - name: "grep", - description: customDescription || GREP_TOOL_DESCRIPTION, - schema: z.object({ - pattern: z.string().describe("Regex pattern to search for"), - path: z - .string() - .optional() - .default("/") - .describe("Base path to search from (default: /)"), - glob: z - .string() - .optional() - .nullable() - .describe("Optional glob pattern to filter files (e.g., '*.py')"), - }), - }, - ); -} - -/** - * Create execute tool using backend. - */ -function createExecuteTool( - backend: BackendProtocol | BackendFactory, - options: { customDescription: string | undefined }, -) { - const { customDescription } = options; - return tool( - async (input, config) => { - const stateAndStore: StateAndStore = { - state: getCurrentTaskInput(config), - store: (config as any).store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - - // Runtime check - fail gracefully if not supported - if (!isSandboxBackend(resolvedBackend)) { - return ( - "Error: Execution not available. This agent's backend " + - "does not support command execution (SandboxBackendProtocol). " + - "To use the execute tool, provide a backend that implements SandboxBackendProtocol." - ); - } - - const result = await resolvedBackend.execute(input.command); - - // Format output for LLM consumption - const parts = [result.output]; - - if (result.exitCode !== null) { - const status = result.exitCode === 0 ? "succeeded" : "failed"; - parts.push(`\n[Command ${status} with exit code ${result.exitCode}]`); - } - - if (result.truncated) { - parts.push("\n[Output was truncated due to size limits]"); - } - - return parts.join(""); - }, - { - name: "execute", - description: customDescription || EXECUTE_TOOL_DESCRIPTION, - schema: z.object({ - command: z.string().describe("The shell command to execute"), - }), - }, - ); -} - -/** - * Options for creating filesystem middleware. - */ -export interface FilesystemMiddlewareOptions { - /** Backend instance or factory (default: StateBackend) */ - backend?: BackendProtocol | BackendFactory; - /** Optional custom system prompt override */ - systemPrompt?: string | null; - /** Optional custom tool descriptions override */ - customToolDescriptions?: Record | null; - /** Optional token limit before evicting a tool result to the filesystem (default: 20000 tokens, ~80KB) */ - toolTokenLimitBeforeEvict?: number | null; -} - -/** - * Create filesystem middleware with all tools and features. - */ -export function createFilesystemMiddleware( - options: FilesystemMiddlewareOptions = {}, -) { - const { - backend = (stateAndStore: StateAndStore) => new StateBackend(stateAndStore), - systemPrompt: customSystemPrompt = null, - customToolDescriptions = null, - toolTokenLimitBeforeEvict = 20000, - } = options; - - const baseSystemPrompt = customSystemPrompt || FILESYSTEM_SYSTEM_PROMPT; - - // All tools including execute (execute will be filtered at runtime if backend doesn't support it) - const allTools = [ - createLsTool(backend, { - customDescription: customToolDescriptions?.ls, - }), - createReadFileTool(backend, { - customDescription: customToolDescriptions?.read_file, - }), - createWriteFileTool(backend, { - customDescription: customToolDescriptions?.write_file, - }), - createEditFileTool(backend, { - customDescription: customToolDescriptions?.edit_file, - }), - createGlobTool(backend, { - customDescription: customToolDescriptions?.glob, - }), - createGrepTool(backend, { - customDescription: customToolDescriptions?.grep, - }), - createExecuteTool(backend, { - customDescription: customToolDescriptions?.execute, - }), - ]; - - return createMiddleware({ - name: "FilesystemMiddleware", - stateSchema: FilesystemStateSchema, - tools: allTools, - wrapModelCall: async (request, handler) => { - // Check if backend supports execution - const stateAndStore: StateAndStore = { - state: request.state || {}, - // @ts-expect-error - request.config is incorrect typed - store: request.config?.store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const supportsExecution = isSandboxBackend(resolvedBackend); - - // Filter tools based on backend capabilities - let tools = request.tools; - if (!supportsExecution) { - tools = tools.filter((t: { name: string }) => t.name !== "execute"); - } - - // Build system prompt - add execution instructions if available - let systemPrompt = baseSystemPrompt; - if (supportsExecution) { - systemPrompt = `${systemPrompt}\n\n${EXECUTION_SYSTEM_PROMPT}`; - } - - // Combine with existing system prompt - const currentSystemPrompt = request.systemPrompt || ""; - const newSystemPrompt = currentSystemPrompt - ? `${currentSystemPrompt}\n\n${systemPrompt}` - : systemPrompt; - - return handler({ ...request, tools, systemPrompt: newSystemPrompt }); - }, - wrapToolCall: async (request, handler) => { - // Return early if eviction is disabled - if (!toolTokenLimitBeforeEvict) { - return handler(request); - } - - // Check if this tool is excluded from eviction - const toolName = request.toolCall?.name; - if ( - toolName && - TOOLS_EXCLUDED_FROM_EVICTION.includes( - toolName as (typeof TOOLS_EXCLUDED_FROM_EVICTION)[number], - ) - ) { - return handler(request); - } - - const result = await handler(request); - - async function processToolMessage( - msg: ToolMessage, - toolTokenLimitBeforeEvict: number, - ) { - if ( - typeof msg.content === "string" && - msg.content.length > toolTokenLimitBeforeEvict * NUM_CHARS_PER_TOKEN - ) { - // Build StateAndStore from request - const stateAndStore: StateAndStore = { - state: request.state || {}, - // @ts-expect-error - request.config is incorrect typed - store: request.config?.store, - }; - const resolvedBackend = getBackend(backend, stateAndStore); - const sanitizedId = sanitizeToolCallId( - request.toolCall?.id || msg.tool_call_id, - ); - const evictPath = `/large_tool_results/${sanitizedId}`; - - const writeResult = await resolvedBackend.write( - evictPath, - msg.content, - ); - - if (writeResult.error) { - return { message: msg, filesUpdate: null }; - } - - // Create preview showing head and tail of the result - const contentSample = createContentPreview(msg.content); - const replacementText = TOO_LARGE_TOOL_MSG.replace( - "{tool_call_id}", - msg.tool_call_id, - ) - .replace("{file_path}", evictPath) - .replace("{content_sample}", contentSample); - - const truncatedMessage = new ToolMessage({ - content: replacementText, - tool_call_id: msg.tool_call_id, - name: msg.name, - }); - - return { - message: truncatedMessage, - filesUpdate: writeResult.filesUpdate, - }; - } - return { message: msg, filesUpdate: null }; - } - - if (ToolMessage.isInstance(result)) { - const processed = await processToolMessage( - result, - toolTokenLimitBeforeEvict, - ); - - if (processed.filesUpdate) { - return new Command({ - update: { - files: processed.filesUpdate, - messages: [processed.message], - }, - }); - } - - return processed.message; - } - - if (isCommand(result)) { - const update = result.update as any; - if (!update?.messages) { - return result; - } - - let hasLargeResults = false; - const accumulatedFiles: Record = update.files - ? { ...update.files } - : {}; - const processedMessages: ToolMessage[] = []; - - for (const msg of update.messages) { - if (ToolMessage.isInstance(msg)) { - const processed = await processToolMessage( - msg, - toolTokenLimitBeforeEvict, - ); - processedMessages.push(processed.message); - - if (processed.filesUpdate) { - hasLargeResults = true; - Object.assign(accumulatedFiles, processed.filesUpdate); - } - } else { - processedMessages.push(msg); - } - } - - if (hasLargeResults) { - return new Command({ - update: { - ...update, - messages: processedMessages, - files: accumulatedFiles, - }, - }); - } - } - - return result; - }, - }); -} diff --git a/libs/deepagents/src/middleware/hitl.int.test.ts b/libs/deepagents/src/middleware/hitl.int.test.ts deleted file mode 100644 index c92aab099..000000000 --- a/libs/deepagents/src/middleware/hitl.int.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { v4 as uuidv4 } from "uuid"; - -import { MemorySaver, Command } from "@langchain/langgraph"; -import { - AIMessage, - HITLRequest, - HumanMessage, - ToolMessage, - type InterruptOnConfig, -} from "langchain"; - -import { createDeepAgent } from "../index.js"; -import { - assertAllDeepAgentQualities, - sampleTool, - getWeather, - getSoccerScores, -} from "../testing/utils.js"; - -const SAMPLE_TOOL_CONFIG: Record = { - sample_tool: true, - get_weather: false, - get_soccer_scores: { allowedDecisions: ["approve", "reject"] }, -}; - -describe("Human-in-the-Loop (HITL) Integration Tests", () => { - it.concurrent( - "should interrupt agent execution for tool approval", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - const agent = createDeepAgent({ - tools: [sampleTool, getWeather, getSoccerScores], - interruptOn: SAMPLE_TOOL_CONFIG, - checkpointer, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - assertAllDeepAgentQualities(agent); - - // First invocation - should interrupt - const result = await agent.invoke( - { - messages: [ - { - role: "user", - content: - "Call the sample tool, get the weather in New York and get scores for the latest soccer games in parallel", - }, - ], - }, - config, - ); - - // Check tool calls were made - const agentMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - - expect(toolCalls.some((tc: any) => tc.name === "sample_tool")).toBe(true); - expect(toolCalls.some((tc: any) => tc.name === "get_weather")).toBe(true); - expect(toolCalls.some((tc: any) => tc.name === "get_soccer_scores")).toBe( - true, - ); - - // Check interrupts - expect(result.__interrupt__).toBeDefined(); - expect(result.__interrupt__).toHaveLength(1); - - const interrupts = result.__interrupt__?.[0].value as HITLRequest; - const actionRequests = interrupts.actionRequests; - - expect(actionRequests).toHaveLength(2); - expect(actionRequests.some((ar: any) => ar.name === "sample_tool")).toBe( - true, - ); - expect( - actionRequests.some((ar: any) => ar.name === "get_soccer_scores"), - ).toBe(true); - - // Check review configs - const reviewConfigs = interrupts.reviewConfigs; - expect( - reviewConfigs.some( - (rc) => - rc.actionName === "sample_tool" && - rc.allowedDecisions.includes("approve") && - rc.allowedDecisions.includes("edit") && - rc.allowedDecisions.includes("reject"), - ), - ).toBe(true); - expect( - reviewConfigs.some( - (rc) => - rc.actionName === "get_soccer_scores" && - rc.allowedDecisions.includes("approve") && - rc.allowedDecisions.includes("reject"), - ), - ).toBe(true); - - // Resume with approvals - const result2 = await agent.invoke( - new Command({ - resume: { - decisions: [{ type: "approve" }, { type: "approve" }], - }, - }), - config, - ); - - // Check tool results are present - const toolResults = result2.messages.filter( - (msg: any) => msg._getType() === "tool", - ); - expect(toolResults.some((tr: any) => tr.name === "sample_tool")).toBe( - true, - ); - expect(toolResults.some((tr: any) => tr.name === "get_weather")).toBe( - true, - ); - expect( - toolResults.some((tr: any) => tr.name === "get_soccer_scores"), - ).toBe(true); - - // No more interrupts - expect(result2.__interrupt__).toBeUndefined(); - }, - ); - - it.concurrent( - "should handle HITL with subagents", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - const agent = createDeepAgent({ - tools: [sampleTool, getWeather, getSoccerScores], - interruptOn: SAMPLE_TOOL_CONFIG, - checkpointer, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - assertAllDeepAgentQualities(agent); - - // First invocation - use subagent which should also interrupt - const result = await agent.invoke( - { - messages: [ - { - role: "user", - content: - "Use the task tool to kick off the general-purpose subagent. Tell it to call the sample tool, get the weather in New York and get scores for the latest soccer games in parallel", - }, - ], - }, - config, - ); - - // Check that task tool was called - const agentMessages = result.messages.filter( - (msg: any) => msg._getType() === "ai", - ); - const toolCalls = agentMessages.flatMap( - (msg: any) => msg.tool_calls || [], - ); - expect(toolCalls.some((tc: any) => tc.name === "task")).toBe(true); - - // Subagent should have interrupts too - expect(result.__interrupt__).toBeDefined(); - - // Resume with approvals - const toolResultNames: string[] = []; - - for await (const chunk of await agent.graph.stream( - new Command({ - resume: { decisions: [{ type: "approve" }, { type: "approve" }] }, - }), - { - ...config, - streamMode: ["updates"], - subgraphs: true, - }, - )) { - const update = chunk[2] ?? {}; - if (!("tools" in update)) continue; - - const tools = update.tools as { messages: ToolMessage[] }; - toolResultNames.push(...tools.messages.map((msg: any) => msg.name)); - } - - expect(toolResultNames).toContain("sample_tool"); - expect(toolResultNames).toContain("get_weather"); - expect(toolResultNames).toContain("get_soccer_scores"); - }, - ); - - it.concurrent( - "should use custom interrupt_on config for subagents", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - const agent = createDeepAgent({ - tools: [sampleTool, getWeather, getSoccerScores], - interruptOn: SAMPLE_TOOL_CONFIG, - checkpointer, - subagents: [ - { - name: "custom_weather_agent", - description: "Agent that gets weather with custom interrupt config", - systemPrompt: "Use get_weather tool to get weather information", - tools: [getWeather], - // Different config for subagent - interruptOn: { get_weather: true }, - }, - ], - }); - - const config = { configurable: { thread_id: uuidv4() } }; - const result = await agent.invoke( - { - messages: [ - new HumanMessage( - "Use the custom_weather_agent subagent to get weather in Tokyo", - ), - ], - }, - config, - ); - - // Check that task tool was called - expect( - result.messages - .filter((msg: any) => AIMessage.isInstance(msg)) - .flatMap((msg: any) => msg.tool_calls || []), - ).toMatchObject([ - { name: "task", args: { subagent_type: "custom_weather_agent" } }, - ]); - - // Subagent should have different interrupt config - // The get_weather tool should now trigger an interrupt in the subagent - expect(result.__interrupt__).toBeDefined(); - - await agent.invoke( - new Command({ - resume: { - decisions: [{ type: "approve" }], - }, - }), - config, - ); - expect(result.messages.length).toBeGreaterThan(0); - }, - ); - - it.concurrent( - "should properly propagate HITL interrupts from subagents without TypeError", - { timeout: 120000 }, - async () => { - // This test specifically verifies the fix for the issue where - // GraphInterrupt.interrupts was undefined when propagating from subagents, - // causing "Cannot read properties of undefined (reading 'length')" error - - const checkpointer = new MemorySaver(); - const agent = createDeepAgent({ - tools: [sampleTool], - interruptOn: { sample_tool: true }, - checkpointer, - }); - - const config = { configurable: { thread_id: uuidv4() } }; - - // Invoke with a task that will use the subagent which has HITL - // The subagent should interrupt, and this interrupt should propagate - // properly to the parent graph without causing a TypeError - const result = await agent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool with the general-purpose subagent to call the sample_tool", - ), - ], - }, - config, - ); - - // Verify the agent called the task tool - const aiMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = aiMessages.flatMap((msg: any) => msg.tool_calls || []); - expect(toolCalls.some((tc: any) => tc.name === "task")).toBe(true); - - // Verify interrupt was properly propagated from the subagent - expect(result.__interrupt__).toBeDefined(); - expect(result.__interrupt__).toHaveLength(1); - - // Verify the interrupt has the correct HITL structure - const interrupt = result.__interrupt__?.[0]; - expect(interrupt).toBeDefined(); - expect(interrupt!.value).toBeDefined(); - - const hitlRequest = interrupt!.value as HITLRequest; - expect(hitlRequest.actionRequests).toBeDefined(); - expect(hitlRequest.actionRequests.length).toBeGreaterThan(0); - expect(hitlRequest.reviewConfigs).toBeDefined(); - expect(hitlRequest.reviewConfigs.length).toBeGreaterThan(0); - - // Verify we can resume successfully - const resumeResult = await agent.invoke( - new Command({ - resume: { - decisions: [{ type: "approve" }], - }, - }), - config, - ); - - // After resume, there should be no more interrupts - expect(resumeResult.__interrupt__).toBeUndefined(); - - // The tool should have been executed - const toolMessages = resumeResult.messages.filter( - (msg: any) => msg._getType() === "tool", - ); - expect(toolMessages.length).toBeGreaterThan(0); - }, - ); -}); diff --git a/libs/deepagents/src/middleware/index.test.ts b/libs/deepagents/src/middleware/index.test.ts deleted file mode 100644 index 784c1baee..000000000 --- a/libs/deepagents/src/middleware/index.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createAgent } from "langchain"; -import { - createFilesystemMiddleware, - createSubAgentMiddleware, - createPatchToolCallsMiddleware, -} from "../index.js"; -import { - SystemMessage, - HumanMessage, - AIMessage, - ToolMessage, -} from "@langchain/core/messages"; -import { messagesStateReducer as addMessages } from "@langchain/langgraph"; - -import { SAMPLE_MODEL } from "../testing/utils.js"; - -describe("Middleware Integration", () => { - it("should add filesystem middleware to agent", () => { - const middleware = [createFilesystemMiddleware()]; - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware, - tools: [], - }); - const channels = Object.keys((agent as any).graph?.channels || {}); - expect(channels).toContain("files"); - const tools = (agent as any).graph?.nodes?.tools?.bound?.tools || []; - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("ls"); - expect(toolNames).toContain("read_file"); - expect(toolNames).toContain("write_file"); - expect(toolNames).toContain("edit_file"); - }); - - it("should add subagent middleware to agent", () => { - const middleware = [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [], - }), - ]; - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware, - tools: [], - }); - - const tools = (agent as any).graph?.nodes?.tools?.bound?.tools || []; - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("task"); - }); - - it("should add multiple middleware to agent", () => { - const middleware = [ - createFilesystemMiddleware(), - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [], - }), - ]; - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware, - tools: [], - }); - const channels = Object.keys((agent as any).graph?.channels || {}); - expect(channels).toContain("files"); - const tools = (agent as any).graph?.nodes?.tools?.bound?.tools || []; - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("ls"); - expect(toolNames).toContain("read_file"); - expect(toolNames).toContain("write_file"); - expect(toolNames).toContain("edit_file"); - expect(toolNames).toContain("task"); - }); -}); - -describe("FilesystemMiddleware", () => { - it("should initialize with default backend (StateBackend)", () => { - const middleware = createFilesystemMiddleware(); - expect(middleware).toBeDefined(); - expect(middleware.name).toBe("FilesystemMiddleware"); - const tools = middleware.tools || []; - expect(tools.length).toBeGreaterThanOrEqual(6); // ls, read, write, edit, glob, grep - expect(tools.map((t) => t.name)).toContain("ls"); - expect(tools.map((t) => t.name)).toContain("read_file"); - expect(tools.map((t) => t.name)).toContain("write_file"); - expect(tools.map((t) => t.name)).toContain("edit_file"); - expect(tools.map((t) => t.name)).toContain("glob"); - expect(tools.map((t) => t.name)).toContain("grep"); - }); - - it("should include execute tool in tools list", () => { - const middleware = createFilesystemMiddleware(); - const tools = middleware.tools || []; - expect(tools.map((t) => t.name)).toContain("execute"); - }); - - it("should initialize with custom backend", () => { - const middleware = createFilesystemMiddleware({ - backend: undefined, // Will use default StateBackend - }); - expect(middleware).toBeDefined(); - expect(middleware.name).toBe("FilesystemMiddleware"); - const tools = middleware.tools || []; - expect(tools.length).toBeGreaterThanOrEqual(6); - }); - - it("should use custom tool descriptions", () => { - const customDesc = "Custom ls tool description"; - const middleware = createFilesystemMiddleware({ - customToolDescriptions: { - ls: customDesc, - }, - }); - expect(middleware).toBeDefined(); - const tools = middleware.tools || []; - const lsTool = tools.find((t) => t.name === "ls"); - expect(lsTool).toBeDefined(); - expect(lsTool?.description).toBe(customDesc); - }); - - it("should use custom tool descriptions with backend factory", () => { - const customDesc = "Custom ls tool description"; - const middleware = createFilesystemMiddleware({ - backend: undefined, // Will use default - customToolDescriptions: { - ls: customDesc, - }, - }); - expect(middleware).toBeDefined(); - const tools = middleware.tools || []; - const lsTool = tools.find((t) => t.name === "ls"); - expect(lsTool).toBeDefined(); - expect(lsTool?.description).toBe(customDesc); - }); -}); - -describe("SubAgentMiddleware", () => { - it("should initialize with default settings", () => { - const middleware = createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - }); - expect(middleware).toBeDefined(); - expect(middleware.name).toBe("subAgentMiddleware"); - const tools = middleware.tools || []; - expect(tools).toHaveLength(1); - expect(tools[0]?.name).toBe("task"); - expect(tools[0]?.description).toContain("general-purpose"); - }); - - it("should initialize with default tools", () => { - const middleware = createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - }); - expect(middleware).toBeDefined(); - const tools = middleware.tools || []; - expect(tools).toHaveLength(1); - expect(tools[0]?.name).toBe("task"); - }); -}); - -describe("Execute Tool", () => { - it("should include execute tool description", () => { - const middleware = createFilesystemMiddleware(); - const tools = middleware.tools || []; - const executeTool = tools.find((t) => t.name === "execute"); - expect(executeTool).toBeDefined(); - expect(executeTool?.description).toContain("sandbox"); - expect(executeTool?.description).toContain("command"); - }); - - it("should export EXECUTE_TOOL_DESCRIPTION constant", async () => { - const { EXECUTE_TOOL_DESCRIPTION } = await import("./fs.js"); - expect(EXECUTE_TOOL_DESCRIPTION).toBeDefined(); - expect(EXECUTE_TOOL_DESCRIPTION).toContain("sandbox"); - }); - - it("should export EXECUTION_SYSTEM_PROMPT constant", async () => { - const { EXECUTION_SYSTEM_PROMPT } = await import("./fs.js"); - expect(EXECUTION_SYSTEM_PROMPT).toBeDefined(); - expect(EXECUTION_SYSTEM_PROMPT).toContain("execute"); - }); -}); - -describe("isSandboxBackend type guard", () => { - it("should return true for backends with execute and id", async () => { - const { isSandboxBackend } = await import("../backends/protocol.js"); - - const mockSandbox = { - execute: () => ({ output: "", exitCode: 0, truncated: false }), - id: "test-sandbox", - lsInfo: () => [], - read: () => "", - grepRaw: () => [], - globInfo: () => [], - write: () => ({}), - edit: () => ({}), - uploadFiles: () => [], - downloadFiles: () => [], - }; - - expect(isSandboxBackend(mockSandbox)).toBe(true); - }); - - it("should return false for backends without execute", async () => { - const { isSandboxBackend } = await import("../backends/protocol.js"); - const { StateBackend } = await import("../backends/state.js"); - - const stateAndStore = { state: { files: {} }, store: undefined }; - const stateBackend = new StateBackend(stateAndStore); - - expect(isSandboxBackend(stateBackend)).toBe(false); - }); - - it("should return false for backends without id", async () => { - const { isSandboxBackend } = await import("../backends/protocol.js"); - - const mockBackend = { - execute: () => ({ output: "", exitCode: 0, truncated: false }), - // Missing id - lsInfo: () => [], - read: () => "", - grepRaw: () => [], - globInfo: () => [], - write: () => ({}), - edit: () => ({}), - uploadFiles: () => [], - downloadFiles: () => [], - }; - - expect(isSandboxBackend(mockBackend as any)).toBe(false); - }); -}); - -describe("PatchToolCallsMiddleware", () => { - it("should pass through messages without tool calls", async () => { - const inputMessages = [ - new SystemMessage({ content: "You are a helpful assistant.", id: "1" }), - new HumanMessage({ content: "Hello, how are you?", id: "2" }), - ]; - const middleware = createPatchToolCallsMiddleware(); - const beforeAgentHook = (middleware as any).beforeAgent; - const stateUpdate = await beforeAgentHook({ - messages: inputMessages, - }); - expect(stateUpdate).toBeDefined(); - expect(stateUpdate.messages).toHaveLength(3); - expect(stateUpdate.messages[0]._getType()).toBe("remove"); - expect(stateUpdate.messages[1].content).toBe( - "You are a helpful assistant.", - ); - expect(stateUpdate.messages[2].content).toBe("Hello, how are you?"); - }); - - it("should patch a single missing tool call", async () => { - const inputMessages = [ - new SystemMessage({ content: "You are a helpful assistant.", id: "1" }), - new HumanMessage({ content: "Hello, how are you?", id: "2" }), - new AIMessage({ - content: "I'm doing well, thank you!", - tool_calls: [ - { - id: "123", - name: "get_events_for_days", - args: { date_str: "2025-01-01" }, - }, - ], - id: "3", - }), - new HumanMessage({ content: "What is the weather in Tokyo?", id: "4" }), - ]; - - const middleware = createPatchToolCallsMiddleware(); - const beforeAgentHook = (middleware as any).beforeAgent; - const stateUpdate = await beforeAgentHook({ - messages: inputMessages, - }); - expect(stateUpdate).toBeDefined(); - expect(stateUpdate.messages).toHaveLength(6); - expect(stateUpdate.messages[0]._getType()).toBe("remove"); - expect(stateUpdate.messages[1]).toBe(inputMessages[0]); - expect(stateUpdate.messages[2]).toBe(inputMessages[1]); - expect(stateUpdate.messages[3]).toBe(inputMessages[2]); - expect(stateUpdate.messages[4]._getType()).toBe("tool"); - expect((stateUpdate.messages[4] as any).tool_call_id).toBe("123"); - expect((stateUpdate.messages[4] as any).name).toBe("get_events_for_days"); - expect((stateUpdate.messages[4] as any).content).toContain("cancelled"); - expect(stateUpdate.messages[5]).toBe(inputMessages[3]); - - const updatedMessages = addMessages(inputMessages, stateUpdate.messages); - expect(updatedMessages).toHaveLength(5); - expect(updatedMessages[0]).toBe(inputMessages[0]); - expect(updatedMessages[1]).toBe(inputMessages[1]); - expect(updatedMessages[2]).toBe(inputMessages[2]); - expect(updatedMessages[3]._getType()).toBe("tool"); - expect((updatedMessages[3] as any).tool_call_id).toBe("123"); - expect(updatedMessages[4]).toBe(inputMessages[3]); - }); - - it("should not patch when tool message exists", async () => { - const inputMessages = [ - new SystemMessage({ content: "You are a helpful assistant.", id: "1" }), - new HumanMessage({ content: "Hello, how are you?", id: "2" }), - new AIMessage({ - content: "I'm doing well, thank you!", - tool_calls: [ - { - id: "123", - name: "get_events_for_days", - args: { date_str: "2025-01-01" }, - }, - ], - id: "3", - }), - new ToolMessage({ - content: "I have no events for that date.", - tool_call_id: "123", - id: "4", - }), - new HumanMessage({ content: "What is the weather in Tokyo?", id: "5" }), - ]; - - const middleware = createPatchToolCallsMiddleware(); - const beforeAgentHook = (middleware as any).beforeAgent; - const stateUpdate = await beforeAgentHook({ - messages: inputMessages, - }); - - expect(stateUpdate).toBeDefined(); - expect(stateUpdate.messages).toHaveLength(6); - expect(stateUpdate.messages[0]._getType()).toBe("remove"); - expect(stateUpdate.messages.slice(1)).toEqual(inputMessages); - - const updatedMessages = addMessages(inputMessages, stateUpdate.messages); - expect(updatedMessages).toHaveLength(5); - expect(updatedMessages).toEqual(inputMessages); - }); - - it("should patch multiple missing tool calls", async () => { - const inputMessages = [ - new SystemMessage({ content: "You are a helpful assistant.", id: "1" }), - new HumanMessage({ content: "Hello, how are you?", id: "2" }), - new AIMessage({ - content: "I'm doing well, thank you!", - tool_calls: [ - { - id: "123", - name: "get_events_for_days", - args: { date_str: "2025-01-01" }, - }, - ], - id: "3", - }), - new HumanMessage({ content: "What is the weather in Tokyo?", id: "4" }), - new AIMessage({ - content: "I'm doing well, thank you!", - tool_calls: [ - { - id: "456", - name: "get_events_for_days", - args: { date_str: "2025-01-01" }, - }, - ], - id: "5", - }), - new HumanMessage({ content: "What is the weather in Tokyo?", id: "6" }), - ]; - const middleware = createPatchToolCallsMiddleware(); - const beforeAgentHook = (middleware as any).beforeAgent; - const stateUpdate = await beforeAgentHook({ - messages: inputMessages, - }); - - expect(stateUpdate).toBeDefined(); - expect(stateUpdate.messages).toHaveLength(9); - expect(stateUpdate.messages[0]._getType()).toBe("remove"); - expect(stateUpdate.messages[1]).toBe(inputMessages[0]); - expect(stateUpdate.messages[2]).toBe(inputMessages[1]); - expect(stateUpdate.messages[3]).toBe(inputMessages[2]); - expect(stateUpdate.messages[4]._getType()).toBe("tool"); - expect((stateUpdate.messages[4] as any).tool_call_id).toBe("123"); - expect(stateUpdate.messages[5]).toBe(inputMessages[3]); - expect(stateUpdate.messages[6]).toBe(inputMessages[4]); - expect(stateUpdate.messages[7]._getType()).toBe("tool"); - expect((stateUpdate.messages[7] as any).tool_call_id).toBe("456"); - expect(stateUpdate.messages[8]).toBe(inputMessages[5]); - - const updatedMessages = addMessages(inputMessages, stateUpdate.messages); - expect(updatedMessages).toHaveLength(8); - expect(updatedMessages[0]).toBe(inputMessages[0]); - expect(updatedMessages[1]).toBe(inputMessages[1]); - expect(updatedMessages[2]).toBe(inputMessages[2]); - expect(updatedMessages[3].type).toBe("tool"); - expect((updatedMessages[3] as any).tool_call_id).toBe("123"); - expect(updatedMessages[4]).toBe(inputMessages[3]); - expect(updatedMessages[5]).toBe(inputMessages[4]); - expect(updatedMessages[6].type).toBe("tool"); - expect((updatedMessages[6] as any).tool_call_id).toBe("456"); - expect(updatedMessages[7]).toBe(inputMessages[5]); - }); -}); diff --git a/libs/deepagents/src/middleware/index.ts b/libs/deepagents/src/middleware/index.ts deleted file mode 100644 index 587f9c1c3..000000000 --- a/libs/deepagents/src/middleware/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -export { - createFilesystemMiddleware, - type FilesystemMiddlewareOptions, - type FileData, - // Eviction constants - TOOLS_EXCLUDED_FROM_EVICTION, - NUM_CHARS_PER_TOKEN, - createContentPreview, -} from "./fs.js"; -export { - createSubAgentMiddleware, - type SubAgentMiddlewareOptions, - type SubAgent, - type CompiledSubAgent, -} from "./subagents.js"; -export { createPatchToolCallsMiddleware } from "./patch_tool_calls.js"; -export { - createMemoryMiddleware, - type MemoryMiddlewareOptions, -} from "./memory.js"; - -// Skills middleware - backend-agnostic (matches Python's SkillsMiddleware interface) -export { - createSkillsMiddleware, - type SkillsMiddlewareOptions, - type SkillMetadata, - // Constants - MAX_SKILL_FILE_SIZE, - MAX_SKILL_NAME_LENGTH, - MAX_SKILL_DESCRIPTION_LENGTH, -} from "./skills.js"; - -// Middleware utilities -export { appendToSystemMessage, prependToSystemMessage } from "./utils.js"; - -// Summarization middleware -export { - // Backend-aware summarization middleware with history offloading - createSummarizationMiddleware, - type SummarizationMiddlewareOptions, - type ContextSize, - type TruncateArgsSettings, - // Re-export base summarization middleware from langchain for users who don't need backend offloading - summarizationMiddleware, -} from "./summarization.js"; diff --git a/libs/deepagents/src/middleware/memory.test.ts b/libs/deepagents/src/middleware/memory.test.ts deleted file mode 100644 index 7c0f50ae4..000000000 --- a/libs/deepagents/src/middleware/memory.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { createMemoryMiddleware } from "./memory.js"; -import type { - BackendProtocol, - FileDownloadResponse, -} from "../backends/protocol.js"; - -// Mock backend that returns specified files -function createMockBackend( - files: Record, -): BackendProtocol { - return { - async downloadFiles(paths: string[]): Promise { - return paths.map((path) => { - const content = files[path]; - if (content === null || content === undefined) { - return { path, error: "file_not_found", content: null }; - } - return { - path, - content: new TextEncoder().encode(content), - error: null, - }; - }); - }, - // Implement other required methods as stubs - listDir: vi.fn(), - readFiles: vi.fn(), - writeFile: vi.fn(), - editFile: vi.fn(), - grep: vi.fn(), - } as unknown as BackendProtocol; -} - -describe("createMemoryMiddleware", () => { - describe("beforeAgent", () => { - it("should load memory content from configured sources", async () => { - const mockBackend = createMockBackend({ - "~/.deepagents/AGENTS.md": "# User Memory\n\nThis is user memory.", - "./.deepagents/AGENTS.md": - "# Project Memory\n\nThis is project memory.", - }); - - const middleware = createMemoryMiddleware({ - backend: mockBackend, - sources: ["~/.deepagents/AGENTS.md", "./.deepagents/AGENTS.md"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.memoryContents).toEqual({ - "~/.deepagents/AGENTS.md": "# User Memory\n\nThis is user memory.", - "./.deepagents/AGENTS.md": - "# Project Memory\n\nThis is project memory.", - }); - }); - - it("should skip missing files gracefully", async () => { - const mockBackend = createMockBackend({ - "~/.deepagents/AGENTS.md": "# User Memory", - // Project file doesn't exist - }); - - const middleware = createMemoryMiddleware({ - backend: mockBackend, - sources: ["~/.deepagents/AGENTS.md", "./.deepagents/AGENTS.md"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.memoryContents).toEqual({ - "~/.deepagents/AGENTS.md": "# User Memory", - }); - }); - - it("should return empty object when no files exist", async () => { - const mockBackend = createMockBackend({}); - - const middleware = createMemoryMiddleware({ - backend: mockBackend, - sources: ["~/.deepagents/AGENTS.md"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.memoryContents).toEqual({}); - }); - - it("should skip loading if memoryContents already in state", async () => { - const mockBackend = createMockBackend({ - "~/.deepagents/AGENTS.md": "Should not load this", - }); - - const middleware = createMemoryMiddleware({ - backend: mockBackend, - sources: ["~/.deepagents/AGENTS.md"], - }); - - const existingContents = { cached: "content" }; - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({ - memoryContents: existingContents, - }); - - // Should return undefined since already loaded - expect(result).toBeUndefined(); - }); - - it("should work with backend factory function", async () => { - const mockBackend = createMockBackend({ - "/memory/AGENTS.md": "# Factory Memory", - }); - - const backendFactory = vi.fn().mockReturnValue(mockBackend); - - const middleware = createMemoryMiddleware({ - backend: backendFactory, - sources: ["/memory/AGENTS.md"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(backendFactory).toHaveBeenCalled(); - expect(result?.memoryContents).toEqual({ - "/memory/AGENTS.md": "# Factory Memory", - }); - }); - }); - - describe("wrapModelCall", () => { - it("should inject memory content into system prompt", () => { - const middleware = createMemoryMiddleware({ - backend: createMockBackend({}), - sources: ["~/.deepagents/AGENTS.md", "./.deepagents/AGENTS.md"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request = { - systemPrompt: "Base prompt", - state: { - memoryContents: { - "~/.deepagents/AGENTS.md": "User memory content", - "./.deepagents/AGENTS.md": "Project memory content", - }, - }, - }; - - middleware.wrapModelCall!(request as any, mockHandler); - - expect(mockHandler).toHaveBeenCalled(); - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain(""); - expect(modifiedRequest.systemPrompt).toContain(""); - expect(modifiedRequest.systemPrompt).toContain(""); - expect(modifiedRequest.systemPrompt).toContain("User memory content"); - expect(modifiedRequest.systemPrompt).toContain("Project memory content"); - expect(modifiedRequest.systemPrompt).toContain("~/.deepagents/AGENTS.md"); - expect(modifiedRequest.systemPrompt).toContain("./.deepagents/AGENTS.md"); - }); - - it("should show (No memory loaded) when no content", () => { - const middleware = createMemoryMiddleware({ - backend: createMockBackend({}), - sources: ["~/.deepagents/AGENTS.md"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request = { - systemPrompt: "Base prompt", - state: { memoryContents: {} }, - }; - - middleware.wrapModelCall!(request as any, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("(No memory loaded)"); - }); - - it("should prepend memory section to existing system prompt", () => { - const middleware = createMemoryMiddleware({ - backend: createMockBackend({}), - sources: [], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request = { - systemPrompt: "Original system prompt content", - state: { memoryContents: {} }, - }; - - middleware.wrapModelCall!(request as any, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - // Memory section should come before the original prompt - const memoryIndex = modifiedRequest.systemPrompt.indexOf("Agent Memory"); - const originalIndex = modifiedRequest.systemPrompt.indexOf( - "Original system prompt content", - ); - expect(memoryIndex).toBeLessThan(originalIndex); - }); - - it("should work when state has no memoryContents", () => { - const middleware = createMemoryMiddleware({ - backend: createMockBackend({}), - sources: ["~/.deepagents/AGENTS.md"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request = { - systemPrompt: "Base prompt", - state: {}, - }; - - middleware.wrapModelCall!(request as any, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("(No memory loaded)"); - }); - }); - - describe("integration", () => { - it("should work end-to-end: load memory and inject into prompt", async () => { - const mockBackend = createMockBackend({ - "~/.deepagents/AGENTS.md": - "# User Agent Memory\n\nI prefer TypeScript.", - "./project/AGENTS.md": "# Project Memory\n\nThis is a React project.", - }); - - const middleware = createMemoryMiddleware({ - backend: mockBackend, - sources: ["~/.deepagents/AGENTS.md", "./project/AGENTS.md"], - }); - - // Step 1: Load memory - // @ts-expect-error - typing issue in LangChain - const stateUpdate = await middleware.beforeAgent?.({}); - expect(stateUpdate?.memoryContents).toBeDefined(); - - // Step 2: Use loaded memory in wrapModelCall - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "You are a helpful assistant.", - state: stateUpdate, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("I prefer TypeScript"); - expect(modifiedRequest.systemPrompt).toContain("This is a React project"); - expect(modifiedRequest.systemPrompt).toContain( - "You are a helpful assistant", - ); - }); - }); -}); diff --git a/libs/deepagents/src/middleware/memory.ts b/libs/deepagents/src/middleware/memory.ts deleted file mode 100644 index c802fc4c5..000000000 --- a/libs/deepagents/src/middleware/memory.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Middleware for loading agent memory/context from AGENTS.md files. - * - * This module implements support for the AGENTS.md specification (https://agents.md/), - * loading memory/context from configurable sources and injecting into the system prompt. - * - * ## Overview - * - * AGENTS.md files provide project-specific context and instructions to help AI agents - * work effectively. Unlike skills (which are on-demand workflows), memory is always - * loaded and provides persistent context. - * - * ## Usage - * - * ```typescript - * import { createMemoryMiddleware } from "@anthropic/deepagents"; - * import { FilesystemBackend } from "@anthropic/deepagents"; - * - * // Security: FilesystemBackend allows reading/writing from the entire filesystem. - * // Either ensure the agent is running within a sandbox OR add human-in-the-loop (HIL) - * // approval to file operations. - * const backend = new FilesystemBackend({ rootDir: "/" }); - * - * const middleware = createMemoryMiddleware({ - * backend, - * sources: [ - * "~/.deepagents/AGENTS.md", - * "./.deepagents/AGENTS.md", - * ], - * }); - * - * const agent = createDeepAgent({ middleware: [middleware] }); - * ``` - * - * ## Memory Sources - * - * Sources are simply paths to AGENTS.md files that are loaded in order and combined. - * Multiple sources are concatenated in order, with all content included. - * Later sources appear after earlier ones in the combined prompt. - * - * ## File Format - * - * AGENTS.md files are standard Markdown with no required structure. - * Common sections include: - * - Project overview - * - Build/test commands - * - Code style guidelines - * - Architecture notes - */ - -import { z } from "zod"; -import { - createMiddleware, - /** - * required for type inference - */ - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; - -import type { BackendProtocol, BackendFactory } from "../backends/protocol.js"; -import type { StateBackend } from "../backends/state.js"; -import type { BaseStore } from "@langchain/langgraph-checkpoint"; - -/** - * Options for the memory middleware. - */ -export interface MemoryMiddlewareOptions { - /** - * Backend instance or factory function for file operations. - * Use a factory for StateBackend since it requires runtime state. - */ - backend: - | BackendProtocol - | BackendFactory - | ((config: { state: unknown; store?: BaseStore }) => StateBackend); - - /** - * List of memory file paths to load (e.g., ["~/.deepagents/AGENTS.md", "./.deepagents/AGENTS.md"]). - * Display names are automatically derived from the paths. - * Sources are loaded in order. - */ - sources: string[]; -} - -/** - * State schema for memory middleware. - */ -const MemoryStateSchema = z.object({ - /** - * Dict mapping source paths to their loaded content. - * Marked as private so it's not included in the final agent state. - */ - memoryContents: z.record(z.string(), z.string()).optional(), -}); - -/** - * Default system prompt template for memory. - * Ported from Python's comprehensive memory guidelines. - */ -const MEMORY_SYSTEM_PROMPT = ` -{memory_contents} - - - - The above was loaded in from files in your filesystem. As you learn from your interactions with the user, you can save new knowledge by calling the \`edit_file\` tool. - - **Learning from feedback:** - - One of your MAIN PRIORITIES is to learn from your interactions with the user. These learnings can be implicit or explicit. This means that in the future, you will remember this important information. - - When you need to remember something, updating memory must be your FIRST, IMMEDIATE action - before responding to the user, before calling other tools, before doing anything else. Just update memory immediately. - - When user says something is better/worse, capture WHY and encode it as a pattern. - - Each correction is a chance to improve permanently - don't just fix the immediate issue, update your instructions. - - A great opportunity to update your memories is when the user interrupts a tool call and provides feedback. You should update your memories immediately before revising the tool call. - - Look for the underlying principle behind corrections, not just the specific mistake. - - The user might not explicitly ask you to remember something, but if they provide information that is useful for future use, you should update your memories immediately. - - **Asking for information:** - - If you lack context to perform an action (e.g. send a Slack DM, requires a user ID/email) you should explicitly ask the user for this information. - - It is preferred for you to ask for information, don't assume anything that you do not know! - - When the user provides information that is useful for future use, you should update your memories immediately. - - **When to update memories:** - - When the user explicitly asks you to remember something (e.g., "remember my email", "save this preference") - - When the user describes your role or how you should behave (e.g., "you are a web researcher", "always do X") - - When the user gives feedback on your work - capture what was wrong and how to improve - - When the user provides information required for tool use (e.g., slack channel ID, email addresses) - - When the user provides context useful for future tasks, such as how to use tools, or which actions to take in a particular situation - - When you discover new patterns or preferences (coding styles, conventions, workflows) - - **When to NOT update memories:** - - When the information is temporary or transient (e.g., "I'm running late", "I'm on my phone right now") - - When the information is a one-time task request (e.g., "Find me a recipe", "What's 25 * 4?") - - When the information is a simple question that doesn't reveal lasting preferences (e.g., "What day is it?", "Can you explain X?") - - When the information is an acknowledgment or small talk (e.g., "Sounds good!", "Hello", "Thanks for that") - - When the information is stale or irrelevant in future conversations - - Never store API keys, access tokens, passwords, or any other credentials in any file, memory, or system prompt. - - If the user asks where to put API keys or provides an API key, do NOT echo or save it. - - **Examples:** - Example 1 (remembering user information): - User: Can you connect to my google account? - Agent: Sure, I'll connect to your google account, what's your google account email? - User: john@example.com - Agent: Let me save this to my memory. - Tool Call: edit_file(...) -> remembers that the user's google account email is john@example.com - - Example 2 (remembering implicit user preferences): - User: Can you write me an example for creating a deep agent in LangChain? - Agent: Sure, I'll write you an example for creating a deep agent in LangChain - User: Can you do this in JavaScript - Agent: Let me save this to my memory. - Tool Call: edit_file(...) -> remembers that the user prefers to get LangChain code examples in JavaScript - Agent: Sure, here is the JavaScript example - - Example 3 (do not remember transient information): - User: I'm going to play basketball tonight so I will be offline for a few hours. - Agent: Okay I'll add a block to your calendar. - Tool Call: create_calendar_event(...) -> just calls a tool, does not commit anything to memory, as it is transient information -`; - -/** - * Format loaded memory contents for injection into prompt. - * Pairs memory locations with their contents for clarity. - */ -function formatMemoryContents( - contents: Record, - sources: string[], -): string { - if (Object.keys(contents).length === 0) { - return "(No memory loaded)"; - } - - const sections: string[] = []; - for (const path of sources) { - if (contents[path]) { - sections.push(`${path}\n${contents[path]}`); - } - } - - if (sections.length === 0) { - return "(No memory loaded)"; - } - - return sections.join("\n\n"); -} - -/** - * Load memory content from a backend path. - * - * @param backend - Backend to load from. - * @param path - Path to the AGENTS.md file. - * @returns File content if found, null otherwise. - */ -async function loadMemoryFromBackend( - backend: BackendProtocol, - path: string, -): Promise { - // Use downloadFiles if available, otherwise fall back to read - if (!backend.downloadFiles) { - const content = await backend.read(path); - if (content.startsWith("Error:")) { - return null; - } - return content; - } - - const results = await backend.downloadFiles([path]); - - // Should get exactly one response for one path - if (results.length !== 1) { - throw new Error( - `Expected 1 response for path ${path}, got ${results.length}`, - ); - } - const response = results[0]; - - if (response.error != null) { - // For now, memory files are treated as optional. file_not_found is expected - // and we skip silently to allow graceful degradation. - if (response.error === "file_not_found") { - return null; - } - // Other errors should be raised - throw new Error(`Failed to download ${path}: ${response.error}`); - } - - if (response.content != null) { - // Content is a Uint8Array, decode to string - return new TextDecoder().decode(response.content); - } - - return null; -} - -/** - * Create middleware for loading agent memory from AGENTS.md files. - * - * Loads memory content from configured sources and injects into the system prompt. - * Supports multiple sources that are combined together. - * - * @param options - Configuration options - * @returns AgentMiddleware for memory loading and injection - * - * @example - * ```typescript - * const middleware = createMemoryMiddleware({ - * backend: new FilesystemBackend({ rootDir: "/" }), - * sources: [ - * "~/.deepagents/AGENTS.md", - * "./.deepagents/AGENTS.md", - * ], - * }); - * ``` - */ -export function createMemoryMiddleware(options: MemoryMiddlewareOptions) { - const { backend, sources } = options; - - /** - * Resolve backend from instance or factory. - */ - function getBackend(state: unknown): BackendProtocol { - if (typeof backend === "function") { - // It's a factory - call it with state - return backend({ state }) as BackendProtocol; - } - return backend; - } - - return createMiddleware({ - name: "MemoryMiddleware", - stateSchema: MemoryStateSchema, - - async beforeAgent(state) { - // Skip if already loaded - if ("memoryContents" in state && state.memoryContents != null) { - return undefined; - } - - const resolvedBackend = getBackend(state); - const contents: Record = {}; - - for (const path of sources) { - try { - const content = await loadMemoryFromBackend(resolvedBackend, path); - if (content) { - contents[path] = content; - } - } catch (error) { - // Log but continue - memory is optional - // eslint-disable-next-line no-console - console.debug(`Failed to load memory from ${path}:`, error); - } - } - - return { memoryContents: contents }; - }, - - wrapModelCall(request, handler) { - // Get memory contents from state - const memoryContents: Record = - request.state?.memoryContents || {}; - - // Format memory section - const formattedContents = formatMemoryContents(memoryContents, sources); - - const memorySection = MEMORY_SYSTEM_PROMPT.replace( - "{memory_contents}", - formattedContents, - ); - - // Prepend memory section to system prompt - const currentSystemPrompt = request.systemPrompt || ""; - const newSystemPrompt = currentSystemPrompt - ? `${memorySection}\n\n${currentSystemPrompt}` - : memorySection; - - return handler({ ...request, systemPrompt: newSystemPrompt }); - }, - }); -} diff --git a/libs/deepagents/src/middleware/patch_tool_calls.ts b/libs/deepagents/src/middleware/patch_tool_calls.ts deleted file mode 100644 index 492b7ec4b..000000000 --- a/libs/deepagents/src/middleware/patch_tool_calls.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - createMiddleware, - ToolMessage, - AIMessage, - /** - * required for type inference - */ - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; -import { RemoveMessage } from "@langchain/core/messages"; -import { REMOVE_ALL_MESSAGES } from "@langchain/langgraph"; - -/** - * Create middleware that patches dangling tool calls in the messages history. - * - * When an AI message contains tool_calls but subsequent messages don't include - * the corresponding ToolMessage responses, this middleware adds synthetic - * ToolMessages saying the tool call was cancelled. - * - * @returns AgentMiddleware that patches dangling tool calls - * - * @example - * ```typescript - * import { createAgent } from "langchain"; - * import { createPatchToolCallsMiddleware } from "./middleware/patch_tool_calls"; - * - * const agent = createAgent({ - * model: "claude-sonnet-4-5-20250929", - * middleware: [createPatchToolCallsMiddleware()], - * }); - * ``` - */ -export function createPatchToolCallsMiddleware() { - return createMiddleware({ - name: "patchToolCallsMiddleware", - beforeAgent: async (state) => { - const messages = state.messages; - - if (!messages || messages.length === 0) { - return; - } - - const patchedMessages: any[] = []; - - // Iterate over the messages and add any dangling tool calls - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; - patchedMessages.push(msg); - - // Check if this is an AI message with tool calls - if (AIMessage.isInstance(msg) && msg.tool_calls != null) { - for (const toolCall of msg.tool_calls) { - // Look for a corresponding ToolMessage in the messages after this one - const correspondingToolMsg = messages - .slice(i) - .find( - (m: any) => - ToolMessage.isInstance(m) && m.tool_call_id === toolCall.id, - ); - - if (!correspondingToolMsg) { - // We have a dangling tool call which needs a ToolMessage - const toolMsg = `Tool call ${toolCall.name} with id ${toolCall.id} was cancelled - another message came in before it could be completed.`; - patchedMessages.push( - new ToolMessage({ - content: toolMsg, - name: toolCall.name, - tool_call_id: toolCall.id!, - }), - ); - } - } - } - } - - // Return state update with RemoveMessage followed by patched messages - return { - messages: [ - new RemoveMessage({ id: REMOVE_ALL_MESSAGES }), - ...patchedMessages, - ], - }; - }, - }); -} diff --git a/libs/deepagents/src/middleware/skills.test.ts b/libs/deepagents/src/middleware/skills.test.ts deleted file mode 100644 index c1ac0a328..000000000 --- a/libs/deepagents/src/middleware/skills.test.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { createSkillsMiddleware } from "./skills.js"; -import type { - BackendProtocol, - FileDownloadResponse, - FileInfo, -} from "../backends/protocol.js"; - -// Mock backend that returns specified files and directory listings -function createMockBackend(config: { - files: Record; - directories: Record< - string, - Array<{ name: string; type: "file" | "directory" }> - >; -}): BackendProtocol { - return { - async downloadFiles(paths: string[]): Promise { - return paths.map((path) => { - const content = config.files[path]; - if (content === null || content === undefined) { - return { path, error: "file_not_found", content: null }; - } - return { - path, - content: new TextEncoder().encode(content), - error: null, - }; - }); - }, - async lsInfo(dirPath: string): Promise { - const entries = config.directories[dirPath]; - if (!entries) { - throw new Error(`Directory not found: ${dirPath}`); - } - // Convert test format to FileInfo format - return entries.map((entry) => ({ - path: entry.name + (entry.type === "directory" ? "/" : ""), - is_dir: entry.type === "directory", - })); - }, - // Implement other required methods as stubs - readFiles: vi.fn(), - writeFile: vi.fn(), - editFile: vi.fn(), - grep: vi.fn(), - } as unknown as BackendProtocol; -} - -const VALID_SKILL_CONTENT = `--- -name: web-research -description: Structured approach to conducting thorough web research ---- - -# Web Research Skill - -## When to Use -- User asks you to research a topic -`; - -const VALID_SKILL_CONTENT_2 = `--- -name: code-review -description: Systematic code review process with best practices ---- - -# Code Review Skill - -## Steps -1. Check for bugs -2. Check for style -`; - -describe("createSkillsMiddleware", () => { - describe("beforeAgent", () => { - it("should load skills from configured sources", async () => { - const mockBackend = createMockBackend({ - files: { - "/skills/user/web-research/SKILL.md": VALID_SKILL_CONTENT, - }, - directories: { - "/skills/user/": [{ name: "web-research", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.skillsMetadata).toHaveLength(1); - expect(result?.skillsMetadata[0].name).toBe("web-research"); - expect(result?.skillsMetadata[0].description).toBe( - "Structured approach to conducting thorough web research", - ); - expect(result?.skillsMetadata[0].path).toBe( - "/skills/user/web-research/SKILL.md", - ); - }); - - it("should load skills from multiple sources", async () => { - const mockBackend = createMockBackend({ - files: { - "/skills/user/web-research/SKILL.md": VALID_SKILL_CONTENT, - "/skills/project/code-review/SKILL.md": VALID_SKILL_CONTENT_2, - }, - directories: { - "/skills/user/": [{ name: "web-research", type: "directory" }], - "/skills/project/": [{ name: "code-review", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/", "/skills/project/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.skillsMetadata).toHaveLength(2); - expect(result?.skillsMetadata.map((s: any) => s.name).sort()).toEqual([ - "code-review", - "web-research", - ]); - }); - - it("should override earlier sources with later sources (last wins)", async () => { - const userSkillContent = `--- -name: web-research -description: User version of web research ---- -# User Skill`; - - const projectSkillContent = `--- -name: web-research -description: Project version of web research ---- -# Project Skill`; - - const mockBackend = createMockBackend({ - files: { - "/skills/user/web-research/SKILL.md": userSkillContent, - "/skills/project/web-research/SKILL.md": projectSkillContent, - }, - directories: { - "/skills/user/": [{ name: "web-research", type: "directory" }], - "/skills/project/": [{ name: "web-research", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/", "/skills/project/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.skillsMetadata).toHaveLength(1); - expect(result?.skillsMetadata[0].description).toBe( - "Project version of web research", - ); - }); - - it("should handle empty sources gracefully", async () => { - const mockBackend = createMockBackend({ - files: {}, - directories: { - "/skills/empty/": [], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/empty/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result).toBeDefined(); - expect(result?.skillsMetadata).toEqual([]); - }); - - it("should skip skills without SKILL.md", async () => { - const mockBackend = createMockBackend({ - files: { - // No SKILL.md file - }, - directories: { - "/skills/user/": [{ name: "incomplete-skill", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result?.skillsMetadata).toEqual([]); - }); - - it("should skip skills with invalid frontmatter", async () => { - const invalidContent = `# No YAML frontmatter -This skill has no valid frontmatter.`; - - const mockBackend = createMockBackend({ - files: { - "/skills/user/invalid/SKILL.md": invalidContent, - }, - directories: { - "/skills/user/": [{ name: "invalid", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(result?.skillsMetadata).toEqual([]); - }); - - it("should skip if skillsMetadata already in state", async () => { - const mockBackend = createMockBackend({ - files: { - "/skills/user/web-research/SKILL.md": VALID_SKILL_CONTENT, - }, - directories: { - "/skills/user/": [{ name: "web-research", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/"], - }); - - const existingMetadata = [ - { name: "cached", description: "cached skill" }, - ]; - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({ - skillsMetadata: existingMetadata, - }); - - expect(result).toBeUndefined(); - }); - - it("should work with backend factory function", async () => { - const mockBackend = createMockBackend({ - files: { - "/skills/factory/web-research/SKILL.md": VALID_SKILL_CONTENT, - }, - directories: { - "/skills/factory/": [{ name: "web-research", type: "directory" }], - }, - }); - - const backendFactory = vi.fn().mockReturnValue(mockBackend); - - const middleware = createSkillsMiddleware({ - backend: backendFactory, - sources: ["/skills/factory/"], - }); - - // @ts-expect-error - typing issue in LangChain - const result = await middleware.beforeAgent?.({}); - - expect(backendFactory).toHaveBeenCalled(); - expect(result?.skillsMetadata).toHaveLength(1); - }); - }); - - describe("wrapModelCall", () => { - it("should inject skills into system prompt", () => { - const middleware = createSkillsMiddleware({ - backend: createMockBackend({ files: {}, directories: {} }), - sources: ["/skills/user/", "/skills/project/"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "Base prompt", - state: { - skillsMetadata: [ - { - name: "web-research", - description: "Research the web", - path: "/skills/user/web-research/SKILL.md", - }, - ], - }, - }; - - middleware.wrapModelCall!(request, mockHandler); - - expect(mockHandler).toHaveBeenCalled(); - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("Skills System"); - expect(modifiedRequest.systemPrompt).toContain("web-research"); - expect(modifiedRequest.systemPrompt).toContain("Research the web"); - expect(modifiedRequest.systemPrompt).toContain( - "/skills/user/web-research/SKILL.md", - ); - }); - - it("should show message when no skills available", () => { - const middleware = createSkillsMiddleware({ - backend: createMockBackend({ files: {}, directories: {} }), - sources: ["/skills/user/"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "Base prompt", - state: { skillsMetadata: [] }, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("No skills available yet"); - }); - - it("should show priority indicator for last source", () => { - const middleware = createSkillsMiddleware({ - backend: createMockBackend({ files: {}, directories: {} }), - sources: ["/skills/user/", "/skills/project/"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "Base prompt", - state: { skillsMetadata: [] }, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - // Last source should have "higher priority" indicator - expect(modifiedRequest.systemPrompt).toContain("(higher priority)"); - // Should show project source with priority - expect(modifiedRequest.systemPrompt).toContain("Project Skills"); - expect(modifiedRequest.systemPrompt).toContain("/skills/project/"); - }); - - it("should show allowed tools for skills that have them", () => { - const middleware = createSkillsMiddleware({ - backend: createMockBackend({ files: {}, directories: {} }), - sources: ["/skills/user/"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "Base prompt", - state: { - skillsMetadata: [ - { - name: "web-research", - description: "Research the web", - path: "/skills/user/web-research/SKILL.md", - allowedTools: ["search_web", "fetch_url"], - }, - ], - }, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("Allowed tools:"); - expect(modifiedRequest.systemPrompt).toContain("search_web"); - expect(modifiedRequest.systemPrompt).toContain("fetch_url"); - }); - - it("should not show allowed tools line if skill has no allowed tools", () => { - const middleware = createSkillsMiddleware({ - backend: createMockBackend({ files: {}, directories: {} }), - sources: ["/skills/user/"], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "Base prompt", - state: { - skillsMetadata: [ - { - name: "basic-skill", - description: "A basic skill", - path: "/skills/user/basic-skill/SKILL.md", - allowedTools: [], - }, - ], - }, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - // Should not have "Allowed tools:" line for skills without allowed tools - const allowedToolsCount = ( - modifiedRequest.systemPrompt.match(/Allowed tools:/g) || [] - ).length; - expect(allowedToolsCount).toBe(0); - }); - - it("should append skills section to existing system prompt", () => { - const middleware = createSkillsMiddleware({ - backend: createMockBackend({ files: {}, directories: {} }), - sources: [], - }); - - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "Original system prompt content", - state: { skillsMetadata: [] }, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - // Original prompt should come before skills section - const originalIndex = modifiedRequest.systemPrompt.indexOf( - "Original system prompt content", - ); - const skillsIndex = modifiedRequest.systemPrompt.indexOf("Skills System"); - expect(originalIndex).toBeLessThan(skillsIndex); - }); - }); - - describe("integration", () => { - it("should work end-to-end: load skills and inject into prompt", async () => { - const mockBackend = createMockBackend({ - files: { - "/skills/user/web-research/SKILL.md": VALID_SKILL_CONTENT, - "/skills/project/code-review/SKILL.md": VALID_SKILL_CONTENT_2, - }, - directories: { - "/skills/user/": [{ name: "web-research", type: "directory" }], - "/skills/project/": [{ name: "code-review", type: "directory" }], - }, - }); - - const middleware = createSkillsMiddleware({ - backend: mockBackend, - sources: ["/skills/user/", "/skills/project/"], - }); - - // Step 1: Load skills - // @ts-expect-error - typing issue in LangChain - const stateUpdate = await middleware.beforeAgent?.({}); - expect(stateUpdate?.skillsMetadata).toHaveLength(2); - - // Step 2: Inject skills into prompt - const mockHandler = vi.fn().mockReturnValue({ response: "ok" }); - const request: any = { - systemPrompt: "You are a helpful assistant.", - state: stateUpdate, - }; - - middleware.wrapModelCall!(request, mockHandler); - - const modifiedRequest = mockHandler.mock.calls[0][0]; - expect(modifiedRequest.systemPrompt).toContain("web-research"); - expect(modifiedRequest.systemPrompt).toContain("code-review"); - expect(modifiedRequest.systemPrompt).toContain( - "You are a helpful assistant", - ); - }); - }); -}); diff --git a/libs/deepagents/src/middleware/skills.ts b/libs/deepagents/src/middleware/skills.ts deleted file mode 100644 index a5b6b3295..000000000 --- a/libs/deepagents/src/middleware/skills.ts +++ /dev/null @@ -1,524 +0,0 @@ -/* eslint-disable no-console */ -/** - * Backend-agnostic skills middleware for loading agent skills from any backend. - * - * This middleware implements Anthropic's agent skills pattern with progressive disclosure, - * loading skills from backend storage via configurable sources. - * - * ## Architecture - * - * Skills are loaded from one or more **sources** - paths in a backend where skills are - * organized. Sources are loaded in order, with later sources overriding earlier ones - * when skills have the same name (last one wins). This enables layering: base -> user - * -> project -> team skills. - * - * The middleware uses backend APIs exclusively (no direct filesystem access), making it - * portable across different storage backends (filesystem, state, remote storage, etc.). - * - * ## Usage - * - * ```typescript - * import { createSkillsMiddleware, FilesystemBackend } from "@anthropic/deepagents"; - * - * const middleware = createSkillsMiddleware({ - * backend: new FilesystemBackend({ rootDir: "/" }), - * sources: [ - * "/skills/user/", - * "/skills/project/", - * ], - * }); - * - * const agent = createDeepAgent({ middleware: [middleware] }); - * ``` - * - * Or use the `skills` parameter on createDeepAgent: - * - * ```typescript - * const agent = createDeepAgent({ - * skills: ["/skills/user/", "/skills/project/"], - * }); - * ``` - */ - -import { z } from "zod"; -import yaml from "yaml"; -import { - createMiddleware, - /** - * required for type inference - */ - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; - -import type { BackendProtocol, BackendFactory } from "../backends/protocol.js"; -import type { StateBackend } from "../backends/state.js"; -import type { BaseStore } from "@langchain/langgraph-checkpoint"; - -// Security: Maximum size for SKILL.md files to prevent DoS attacks (10MB) -export const MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024; - -// Agent Skills specification constraints (https://agentskills.io/specification) -export const MAX_SKILL_NAME_LENGTH = 64; -export const MAX_SKILL_DESCRIPTION_LENGTH = 1024; - -/** - * Metadata for a skill per Agent Skills specification. - */ -export interface SkillMetadata { - /** Skill identifier (max 64 chars, lowercase alphanumeric and hyphens) */ - name: string; - - /** What the skill does (max 1024 chars) */ - description: string; - - /** Path to the SKILL.md file in the backend */ - path: string; - - /** License name or reference to bundled license file */ - license?: string | null; - - /** Environment requirements (max 500 chars) */ - compatibility?: string | null; - - /** Arbitrary key-value mapping for additional metadata */ - metadata?: Record; - - /** List of pre-approved tools (experimental) */ - allowedTools?: string[]; -} - -/** - * Options for the skills middleware. - */ -export interface SkillsMiddlewareOptions { - /** - * Backend instance or factory function for file operations. - * Use a factory for StateBackend since it requires runtime state. - */ - backend: - | BackendProtocol - | BackendFactory - | ((config: { state: unknown; store?: BaseStore }) => StateBackend); - - /** - * List of skill source paths to load (e.g., ["/skills/user/", "/skills/project/"]). - * Paths must use POSIX conventions (forward slashes). - * Later sources override earlier ones for skills with the same name (last one wins). - */ - sources: string[]; -} - -/** - * State schema for skills middleware. - */ -const SkillsStateSchema = z.object({ - skillsMetadata: z - .array( - z.object({ - name: z.string(), - description: z.string(), - path: z.string(), - license: z.string().nullable().optional(), - compatibility: z.string().nullable().optional(), - metadata: z.record(z.string(), z.string()).optional(), - allowedTools: z.array(z.string()).optional(), - }), - ) - .optional(), -}); - -/** - * Skills System Documentation prompt template. - */ -const SKILLS_SYSTEM_PROMPT = ` -## Skills System - -You have access to a skills library that provides specialized capabilities and domain knowledge. - -{skills_locations} - -**Available Skills:** - -{skills_list} - -**How to Use Skills (Progressive Disclosure):** - -Skills follow a **progressive disclosure** pattern - you know they exist (name + description above), but you only read the full instructions when needed: - -1. **Recognize when a skill applies**: Check if the user's task matches any skill's description -2. **Read the skill's full instructions**: The skill list above shows the exact path to use with read_file -3. **Follow the skill's instructions**: SKILL.md contains step-by-step workflows, best practices, and examples -4. **Access supporting files**: Skills may include Python scripts, configs, or reference docs - use absolute paths - -**When to Use Skills:** -- When the user's request matches a skill's domain (e.g., "research X" → web-research skill) -- When you need specialized knowledge or structured workflows -- When a skill provides proven patterns for complex tasks - -**Skills are Self-Documenting:** -- Each SKILL.md tells you exactly what the skill does and how to use it -- The skill list above shows the full path for each skill's SKILL.md file - -**Executing Skill Scripts:** -Skills may contain Python scripts or other executable files. Always use absolute paths from the skill list. - -**Example Workflow:** - -User: "Can you research the latest developments in quantum computing?" - -1. Check available skills above → See "web-research" skill with its full path -2. Read the skill using the path shown in the list -3. Follow the skill's research workflow (search → organize → synthesize) -4. Use any helper scripts with absolute paths - -Remember: Skills are tools to make you more capable and consistent. When in doubt, check if a skill exists for the task! -`; - -/** - * Validate skill name per Agent Skills specification. - */ -function validateSkillName( - name: string, - directoryName: string, -): { valid: boolean; error: string } { - if (!name) { - return { valid: false, error: "name is required" }; - } - if (name.length > MAX_SKILL_NAME_LENGTH) { - return { valid: false, error: "name exceeds 64 characters" }; - } - // Pattern: lowercase alphanumeric, single hyphens between segments, no start/end hyphen - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) { - return { - valid: false, - error: "name must be lowercase alphanumeric with single hyphens only", - }; - } - if (name !== directoryName) { - return { - valid: false, - error: `name '${name}' must match directory name '${directoryName}'`, - }; - } - return { valid: true, error: "" }; -} - -/** - * Parse YAML frontmatter from SKILL.md content. - */ -function parseSkillMetadataFromContent( - content: string, - skillPath: string, - directoryName: string, -): SkillMetadata | null { - if (content.length > MAX_SKILL_FILE_SIZE) { - console.warn( - `Skipping ${skillPath}: content too large (${content.length} bytes)`, - ); - return null; - } - - // Match YAML frontmatter between --- delimiters - const frontmatterPattern = /^---\s*\n([\s\S]*?)\n---\s*\n/; - const match = content.match(frontmatterPattern); - - if (!match) { - console.warn(`Skipping ${skillPath}: no valid YAML frontmatter found`); - return null; - } - - const frontmatterStr = match[1]; - - // Parse YAML - let frontmatterData: Record; - try { - frontmatterData = yaml.parse(frontmatterStr); - } catch (e) { - console.warn(`Invalid YAML in ${skillPath}:`, e); - return null; - } - - if (!frontmatterData || typeof frontmatterData !== "object") { - console.warn(`Skipping ${skillPath}: frontmatter is not a mapping`); - return null; - } - - // Validate required fields - const name = frontmatterData.name as string | undefined; - const description = frontmatterData.description as string | undefined; - - if (!name || !description) { - console.warn( - `Skipping ${skillPath}: missing required 'name' or 'description'`, - ); - return null; - } - - // Validate name format per spec (warn but continue for backwards compatibility) - const validation = validateSkillName(String(name), directoryName); - if (!validation.valid) { - console.warn( - `Skill '${name}' in ${skillPath} does not follow Agent Skills specification: ${validation.error}. Consider renaming for spec compliance.`, - ); - } - - // Validate description length per spec (max 1024 chars) - let descriptionStr = String(description).trim(); - if (descriptionStr.length > MAX_SKILL_DESCRIPTION_LENGTH) { - console.warn( - `Description exceeds ${MAX_SKILL_DESCRIPTION_LENGTH} characters in ${skillPath}, truncating`, - ); - descriptionStr = descriptionStr.slice(0, MAX_SKILL_DESCRIPTION_LENGTH); - } - - // Parse allowed-tools - const allowedToolsStr = frontmatterData["allowed-tools"] as - | string - | undefined; - const allowedTools = allowedToolsStr ? allowedToolsStr.split(" ") : []; - - return { - name: String(name), - description: descriptionStr, - path: skillPath, - metadata: (frontmatterData.metadata as Record) || {}, - license: - typeof frontmatterData.license === "string" - ? frontmatterData.license.trim() || null - : null, - compatibility: - typeof frontmatterData.compatibility === "string" - ? frontmatterData.compatibility.trim() || null - : null, - allowedTools, - }; -} - -/** - * List all skills from a backend source. - */ -async function listSkillsFromBackend( - backend: BackendProtocol, - sourcePath: string, -): Promise { - const skills: SkillMetadata[] = []; - - // Normalize path to ensure it ends with / - const normalizedPath = sourcePath.endsWith("/") - ? sourcePath - : `${sourcePath}/`; - - // List directories in the source path using lsInfo - let fileInfos: { path: string; is_dir?: boolean }[]; - try { - fileInfos = await backend.lsInfo(normalizedPath); - } catch { - // Source path doesn't exist or can't be listed - return []; - } - - // Convert FileInfo[] to entries format - const entries = fileInfos.map((info) => ({ - name: info.path.replace(/\/$/, "").split("/").pop() || "", - type: (info.is_dir ? "directory" : "file") as "file" | "directory", - })); - - // Look for subdirectories containing SKILL.md - for (const entry of entries) { - if (entry.type !== "directory") { - continue; - } - - const skillMdPath = `${normalizedPath}${entry.name}/SKILL.md`; - - // Try to download the SKILL.md file - let content: string; - if (backend.downloadFiles) { - const results = await backend.downloadFiles([skillMdPath]); - if (results.length !== 1) { - continue; - } - - const response = results[0]; - if (response.error != null || response.content == null) { - continue; - } - - // Decode content - content = new TextDecoder().decode(response.content); - } else { - // Fall back to read if downloadFiles is not available - const readResult = await backend.read(skillMdPath); - if (readResult.startsWith("Error:")) { - continue; - } - content = readResult; - } - const metadata = parseSkillMetadataFromContent( - content, - skillMdPath, - entry.name, - ); - - if (metadata) { - skills.push(metadata); - } - } - - return skills; -} - -/** - * Format skills locations for display in system prompt. - * Shows priority indicator for the last source (highest priority). - */ -function formatSkillsLocations(sources: string[]): string { - if (sources.length === 0) { - return "**Skills Sources:** None configured"; - } - - const lines: string[] = []; - for (let i = 0; i < sources.length; i++) { - const sourcePath = sources[i]; - // Extract a friendly name from the path (last non-empty component) - const name = - sourcePath - .replace(/\/$/, "") - .split("/") - .filter(Boolean) - .pop() - ?.replace(/^./, (c) => c.toUpperCase()) || "Skills"; - const suffix = i === sources.length - 1 ? " (higher priority)" : ""; - lines.push(`**${name} Skills**: \`${sourcePath}\`${suffix}`); - } - return lines.join("\n"); -} - -/** - * Format skills metadata for display in system prompt. - * Shows allowed tools for each skill if specified. - */ -function formatSkillsList(skills: SkillMetadata[], sources: string[]): string { - if (skills.length === 0) { - const paths = sources.map((s) => `\`${s}\``).join(" or "); - return `(No skills available yet. You can create skills in ${paths})`; - } - - const lines: string[] = []; - for (const skill of skills) { - lines.push(`- **${skill.name}**: ${skill.description}`); - if (skill.allowedTools && skill.allowedTools.length > 0) { - lines.push(` → Allowed tools: ${skill.allowedTools.join(", ")}`); - } - lines.push(` → Read \`${skill.path}\` for full instructions`); - } - - return lines.join("\n"); -} - -/** - * Create backend-agnostic middleware for loading and exposing agent skills. - * - * This middleware loads skills from configurable backend sources and injects - * skill metadata into the system prompt. It implements the progressive disclosure - * pattern: skill names and descriptions are shown in the prompt, but the agent - * reads full SKILL.md content only when needed. - * - * @param options - Configuration options - * @returns AgentMiddleware for skills loading and injection - * - * @example - * ```typescript - * const middleware = createSkillsMiddleware({ - * backend: new FilesystemBackend({ rootDir: "/" }), - * sources: ["/skills/user/", "/skills/project/"], - * }); - * ``` - */ -export function createSkillsMiddleware(options: SkillsMiddlewareOptions) { - const { backend, sources } = options; - - // Closure variable to store loaded skills - wrapModelCall can access this - // directly since beforeAgent state updates aren't immediately available - let loadedSkills: SkillMetadata[] = []; - - /** - * Resolve backend from instance or factory. - */ - function getBackend(state: unknown): BackendProtocol { - if (typeof backend === "function") { - return backend({ state }) as BackendProtocol; - } - return backend; - } - - return createMiddleware({ - name: "SkillsMiddleware", - stateSchema: SkillsStateSchema, - - async beforeAgent(state) { - // Skip if already loaded (check both closure and state) - if (loadedSkills.length > 0) { - return undefined; - } - if ("skillsMetadata" in state && state.skillsMetadata != null) { - // Restore from state (e.g., after checkpoint restore) - loadedSkills = state.skillsMetadata as SkillMetadata[]; - return undefined; - } - - const resolvedBackend = getBackend(state); - const allSkills: Map = new Map(); - - // Load skills from each source in order (later sources override earlier) - for (const sourcePath of sources) { - try { - const skills = await listSkillsFromBackend( - resolvedBackend, - sourcePath, - ); - for (const skill of skills) { - allSkills.set(skill.name, skill); - } - } catch (error) { - // Log but continue - individual source failures shouldn't break everything - console.debug( - `[BackendSkillsMiddleware] Failed to load skills from ${sourcePath}:`, - error, - ); - } - } - - // Store in closure for immediate access by wrapModelCall - loadedSkills = Array.from(allSkills.values()); - - return { skillsMetadata: loadedSkills }; - }, - - wrapModelCall(request, handler) { - // Use closure variable which is populated by beforeAgent - // Fall back to state for checkpoint restore scenarios - const skillsMetadata: SkillMetadata[] = - loadedSkills.length > 0 - ? loadedSkills - : (request.state?.skillsMetadata as SkillMetadata[]) || []; - - // Format skills section - const skillsLocations = formatSkillsLocations(sources); - const skillsList = formatSkillsList(skillsMetadata, sources); - - const skillsSection = SKILLS_SYSTEM_PROMPT.replace( - "{skills_locations}", - skillsLocations, - ).replace("{skills_list}", skillsList); - - // Append to existing system prompt - const currentSystemPrompt = request.systemPrompt || ""; - const newSystemPrompt = currentSystemPrompt - ? `${currentSystemPrompt}\n\n${skillsSection}` - : skillsSection; - - return handler({ ...request, systemPrompt: newSystemPrompt }); - }, - }); -} diff --git a/libs/deepagents/src/middleware/subagents-hitl.int.test.ts b/libs/deepagents/src/middleware/subagents-hitl.int.test.ts deleted file mode 100644 index b7a116378..000000000 --- a/libs/deepagents/src/middleware/subagents-hitl.int.test.ts +++ /dev/null @@ -1,664 +0,0 @@ -/** - * Integration tests demonstrating the interrupt() primitive in compiled sub-agents. - * - * These tests show: - * 1. Using interrupt() directly inside a sub-agent tool - * 2. Parent agent invoking the sub-agent which triggers the interrupt - * 3. Using Command(resume=...) to provide data and resume execution - */ - -import { describe, it, expect } from "vitest"; -import { v4 as uuidv4 } from "uuid"; -import { z } from "zod/v3"; - -import { - MemorySaver, - Command, - interrupt, - StateGraph, - END, - START, - Annotation, -} from "@langchain/langgraph"; -import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages"; - -import { createAgent, tool } from "langchain"; - -import { createDeepAgent } from "../index.js"; -import { CompiledSubAgent } from "./subagents.js"; -import { SAMPLE_MODEL } from "../testing/utils.js"; - -// ============================================================================= -// Tools that use interrupt() directly -// ============================================================================= - -/** - * Request human approval before proceeding with an action. - * Uses the interrupt() primitive directly. - */ -const requestApproval = tool( - async (input: { action_description: string }) => { - // interrupt() pauses execution and returns the value passed to Command(resume=...) - const approval = interrupt({ - type: "approval_request", - action: input.action_description, - message: `Please approve or reject: ${input.action_description}`, - }) as { approved?: boolean; reason?: string }; - - if (approval?.approved) { - return `Action '${input.action_description}' was APPROVED. Proceeding...`; - } else { - return `Action '${input.action_description}' was REJECTED. Reason: ${approval?.reason || "No reason provided"}`; - } - }, - { - name: "request_approval", - description: - "Request human approval before proceeding with an action. Use this when you need explicit human confirmation.", - schema: z.object({ - action_description: z - .string() - .describe("Description of the action requiring approval"), - }), - }, -); - -/** - * Collect user input for a question. - * Uses interrupt() to pause and wait for user input. - */ -const askUser = tool( - async (input: { question: string }) => { - const response = interrupt({ - type: "user_input", - question: input.question, - }) as { answer?: string }; - - return `User responded: ${response?.answer || "No answer provided"}`; - }, - { - name: "ask_user", - description: - "Collect user input for a question. Use this to get information from the user.", - schema: z.object({ - question: z.string().describe("The question to ask the user"), - }), - }, -); - -/** - * Perform a multi-step operation that requires confirmation at each step. - * Demonstrates multiple interrupts in sequence. - */ -const multiStepOperation = tool( - async (input: { steps: string[] }) => { - const results: string[] = []; - - for (let i = 0; i < input.steps.length; i++) { - const step = input.steps[i]; - // Each step can trigger an interrupt - const confirmation = interrupt({ - type: "step_confirmation", - step_number: i + 1, - step_description: step, - message: `Confirm step ${i + 1}: ${step}`, - }) as { proceed?: boolean }; - - if (confirmation?.proceed) { - results.push(`Step ${i + 1} completed: ${step}`); - } else { - results.push(`Step ${i + 1} skipped: ${step}`); - break; // Stop if user doesn't want to proceed - } - } - - return results.join("\n"); - }, - { - name: "multi_step_operation", - description: - "Perform a multi-step operation that requires confirmation at each step.", - schema: z.object({ - steps: z.array(z.string()).describe("List of steps to perform"), - }), - }, -); - -describe("Subagent HITL Integration Tests - interrupt() primitive", () => { - // ============================================================================= - // Test 1: Basic interrupt() in a CompiledSubAgent - // ============================================================================= - /** - * skipping, expect to pass in the future - */ - it.concurrent.skip( - "should handle interrupt() in a CompiledSubAgent tool", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - - // Create a compiled sub-agent with tools that use interrupt() - const compiledSubagent = createAgent({ - model: SAMPLE_MODEL, - tools: [requestApproval, askUser], - systemPrompt: - "You are an approval agent. Use request_approval to get human approval for actions.", - }); - - // Create parent agent with the CompiledSubAgent - const parentAgent = createDeepAgent({ - checkpointer, - subagents: [ - { - name: "approval-agent", - description: - "An agent that can request approvals and ask user questions", - runnable: compiledSubagent, - } satisfies CompiledSubAgent, - ], - }); - - const threadId = uuidv4(); - const config = { configurable: { thread_id: threadId } }; - - // Step 1: Invoke agent - sub-agent will use request_approval tool - const result = await parentAgent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool to launch the approval-agent sub-agent. " + - "Tell it to use the request_approval tool to request approval for 'deploying to production'.", - ), - ], - }, - config, - ); - - // Check that task tool was called - const aiMessages = result.messages.filter((msg: any) => - AIMessage.isInstance(msg), - ); - const toolCalls = aiMessages.flatMap((msg: any) => msg.tool_calls || []); - expect(toolCalls.some((tc: any) => tc.name === "task")).toBe(true); - - // Step 2: Check for interrupt - expect(result.__interrupt__).toBeDefined(); - expect(result.__interrupt__).toHaveLength(1); - - const interruptValue = result.__interrupt__?.[0].value as Record< - string, - unknown - >; - expect(interruptValue.type).toBe("approval_request"); - expect(interruptValue.action).toBe("deploying to production"); - expect(interruptValue.message).toContain("deploying to production"); - - // Step 3: Resume with approval - const result2 = await parentAgent.invoke( - new Command({ - resume: { approved: true }, - }), - config, - ); - - // Step 4: Verify execution completed - expect(result2.__interrupt__).toBeUndefined(); - - // Find the tool response - const toolMsgs = result2.messages.filter((m: any) => - ToolMessage.isInstance(m), - ); - expect(toolMsgs.length).toBeGreaterThan(0); - - // At least one tool message should contain the approval result - const hasApprovalResult = toolMsgs.some( - (msg: any) => - typeof msg.content === "string" && - msg.content.toLowerCase().includes("approved"), - ); - expect(hasApprovalResult).toBe(true); - }, - ); - - // ============================================================================= - // Test 2: Custom StateGraph with interrupt() - // ============================================================================= - it.concurrent( - "should handle interrupt() in a custom StateGraph sub-agent", - { timeout: 120000 }, - async () => { - // Define state - MUST include 'messages' for CompiledSubAgent - const ReviewState = Annotation.Root({ - messages: Annotation({ - reducer: (left, right) => left.concat(right), - default: () => [], - }), - document: Annotation({ - reducer: (_, right) => right, - default: () => "", - }), - review_result: Annotation({ - reducer: (_, right) => right, - default: () => "", - }), - }); - - // Node that uses interrupt() to collect human review - const collectReview = (state: typeof ReviewState.State) => { - const document = state.document || "Unknown document"; - - // Use interrupt() to pause and collect review - const review = interrupt({ - type: "document_review", - document, - instructions: "Please review this document and provide feedback", - }) as { feedback?: string; approved?: boolean }; - - const feedback = review?.feedback || "No feedback"; - const approved = review?.approved || false; - - const resultText = `Document '${document}' reviewed. Approved: ${approved}. Feedback: ${feedback}`; - - return { - messages: [new AIMessage({ content: resultText })], - review_result: resultText, - }; - }; - - // Build the custom StateGraph - const graphBuilder = new StateGraph(ReviewState) - .addNode("review", collectReview) - .addEdge(START, "review") - .addEdge("review", END); - - const reviewGraph = graphBuilder.compile(); - - const checkpointer = new MemorySaver(); - - const parentAgent = createDeepAgent({ - checkpointer, - subagents: [ - { - name: "document-reviewer", - description: "Reviews documents and collects human feedback", - runnable: reviewGraph, - } satisfies CompiledSubAgent, - ], - }); - - const threadId = uuidv4(); - const config = { configurable: { thread_id: threadId } }; - - // Step 1: Invoke document-reviewer sub-agent - const result = await parentAgent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool to launch the document-reviewer sub-agent. " + - "Pass it the document 'Q4 Financial Report'.", - ), - ], - }, - config, - ); - - // Step 2: Check for interrupt - expect(result.__interrupt__).toBeDefined(); - expect(result.__interrupt__).toHaveLength(1); - - const interruptValue = result.__interrupt__?.[0].value as Record< - string, - unknown - >; - expect(interruptValue.type).toBe("document_review"); - expect(interruptValue.instructions).toContain("review this document"); - - // Step 3: Resume with review feedback - const result2 = await parentAgent.invoke( - new Command({ - resume: { - approved: true, - feedback: "Looks good! Minor typo on page 3.", - }, - }), - config, - ); - - // Step 4: Verify review completed - expect(result2.__interrupt__).toBeUndefined(); - - const toolMsgs = result2.messages.filter((m: any) => - ToolMessage.isInstance(m), - ); - expect(toolMsgs.length).toBeGreaterThan(0); - }, - ); - - // ============================================================================= - // Test 3: Dict-based sub-agent with interrupt() tools - // ============================================================================= - it.concurrent( - "should handle interrupt() in dict-based sub-agent tools", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - - // Create parent agent with dict-based sub-agent that has interrupt() tools - const parentAgent = createDeepAgent({ - checkpointer, - subagents: [ - { - name: "interactive-agent", - description: - "An interactive agent that can ask questions and request approvals", - systemPrompt: - "You are an interactive agent. Use ask_user to get information from users.", - tools: [askUser, requestApproval], - }, - ], - }); - - const threadId = uuidv4(); - const config = { configurable: { thread_id: threadId } }; - - // Step 1: Invoke interactive-agent to ask a question - const result = await parentAgent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool to launch the interactive-agent sub-agent. " + - "Tell it to use the ask_user tool to ask 'What is your favorite color?'", - ), - ], - }, - config, - ); - - // Step 2: Check for interrupt - expect(result.__interrupt__).toBeDefined(); - expect(result.__interrupt__).toHaveLength(1); - - const interruptValue = result.__interrupt__?.[0].value as Record< - string, - unknown - >; - expect(interruptValue.type).toBe("user_input"); - expect(interruptValue.question).toBe("What is your favorite color?"); - - // Step 3: Resume with user's answer - const result2 = await parentAgent.invoke( - new Command({ - resume: { answer: "Blue" }, - }), - config, - ); - - // Step 4: Verify answer was processed - expect(result2.__interrupt__).toBeUndefined(); - - const toolMsgs = result2.messages.filter((m: any) => - ToolMessage.isInstance(m), - ); - expect(toolMsgs.length).toBeGreaterThan(0); - - // Check that the response mentions the user's answer - const hasAnswerResult = toolMsgs.some( - (msg: any) => - typeof msg.content === "string" && - msg.content.toLowerCase().includes("blue"), - ); - expect(hasAnswerResult).toBe(true); - }, - ); - - // ============================================================================= - // Test 4: Multiple sequential interrupts - // ============================================================================= - it.concurrent( - "should handle multiple sequential interrupts in a subagent", - { timeout: 180000 }, - async () => { - const checkpointer = new MemorySaver(); - - // Create sub-agent with multi-step operation tool - const compiledSubagent = createAgent({ - model: SAMPLE_MODEL, - tools: [multiStepOperation], - systemPrompt: - "You are a workflow agent. Use multi_step_operation to execute workflows.", - }); - - const parentAgent = createDeepAgent({ - checkpointer, - subagents: [ - { - name: "workflow-agent", - description: - "Executes multi-step workflows with confirmation at each step", - runnable: compiledSubagent, - } satisfies CompiledSubAgent, - ], - }); - - const threadId = uuidv4(); - const config = { configurable: { thread_id: threadId } }; - - // Step 1: Start multi-step operation (3 steps) - const result = await parentAgent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool to launch the workflow-agent sub-agent. " + - "Tell it to use multi_step_operation with steps: ['Initialize database', 'Migrate schema', 'Seed data']", - ), - ], - }, - config, - ); - - let stepCount = 0; - let currentResult = result; - - // Process interrupts until complete - while (currentResult.__interrupt__) { - stepCount++; - const interruptValue = currentResult.__interrupt__[0].value as Record< - string, - unknown - >; - - expect(interruptValue.type).toBe("step_confirmation"); - expect(interruptValue.step_number).toBe(stepCount); - expect(typeof interruptValue.step_description).toBe("string"); - - // Confirm this step - currentResult = await parentAgent.invoke( - new Command({ - resume: { proceed: true }, - }), - config, - ); - - // Safety limit to prevent infinite loop - if (stepCount >= 5) { - break; - } - } - - // Verify we processed multiple steps - expect(stepCount).toBeGreaterThan(0); - expect(stepCount).toBeLessThanOrEqual(3); - - // Final result should have no more interrupts - expect(currentResult.__interrupt__).toBeUndefined(); - - const toolMsgs = currentResult.messages.filter((m: any) => - ToolMessage.isInstance(m), - ); - expect(toolMsgs.length).toBeGreaterThan(0); - }, - ); - - // ============================================================================= - // Test 5: Rejecting an interrupt - // ============================================================================= - it.concurrent( - "should properly handle rejected interrupts", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - - const compiledSubagent = createAgent({ - model: SAMPLE_MODEL, - tools: [requestApproval], - systemPrompt: - "You are an approval agent. Use request_approval to get human approval for actions.", - }); - - const parentAgent = createDeepAgent({ - checkpointer, - subagents: [ - { - name: "approval-agent", - description: "Requests approvals for actions", - runnable: compiledSubagent, - } satisfies CompiledSubAgent, - ], - }); - - const threadId = uuidv4(); - const config = { configurable: { thread_id: threadId } }; - - // Step 1: Request approval for a dangerous action - const result = await parentAgent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool to launch the approval-agent sub-agent. " + - "Tell it to request approval for 'delete all production data'.", - ), - ], - }, - config, - ); - - // Step 2: Check for interrupt - expect(result.__interrupt__).toBeDefined(); - expect(result.__interrupt__).toHaveLength(1); - - const interruptValue = result.__interrupt__?.[0].value as Record< - string, - unknown - >; - expect(interruptValue.type).toBe("approval_request"); - expect(interruptValue.action).toBe("delete all production data"); - - // Step 3: REJECT with reason - const result2 = await parentAgent.invoke( - new Command({ - resume: { - approved: false, - reason: "This action is too dangerous and not authorized.", - }, - }), - config, - ); - - // Step 4: Verify rejection was processed - expect(result2.__interrupt__).toBeUndefined(); - - const toolMsgs = result2.messages.filter((m: any) => - ToolMessage.isInstance(m), - ); - expect(toolMsgs.length).toBeGreaterThan(0); - - // Check that at least one tool message contains rejection info - const hasRejectionResult = toolMsgs.some( - (msg: any) => - typeof msg.content === "string" && - msg.content.toLowerCase().includes("rejected"), - ); - expect(hasRejectionResult).toBe(true); - }, - ); - - // ============================================================================= - // Test 6: HITL middleware + interrupt() in same subagent - // ============================================================================= - it.concurrent( - "should handle both HITL middleware and interrupt() in the same subagent", - { timeout: 120000 }, - async () => { - const checkpointer = new MemorySaver(); - - // Create a simple tool that requires HITL approval - const sensitiveOperation = tool( - (input: { operation: string }) => { - return `Sensitive operation '${input.operation}' executed successfully.`; - }, - { - name: "sensitive_operation", - description: - "Performs a sensitive operation that requires HITL approval.", - schema: z.object({ - operation: z.string().describe("The operation to perform"), - }), - }, - ); - - // Create parent agent with a subagent that has both: - // 1. A tool using interrupt() directly (askUser) - // 2. A tool that uses HITL middleware (sensitiveOperation via interruptOn) - const parentAgent = createDeepAgent({ - checkpointer, - subagents: [ - { - name: "mixed-hitl-agent", - description: - "Agent with both interrupt() tools and HITL middleware", - systemPrompt: - "You have access to ask_user (uses interrupt directly) and sensitive_operation (uses HITL middleware).", - tools: [askUser, sensitiveOperation], - interruptOn: { sensitive_operation: true }, - }, - ], - }); - - const threadId = uuidv4(); - const config = { configurable: { thread_id: threadId } }; - - // Test the interrupt() tool (askUser) - const result = await parentAgent.invoke( - { - messages: [ - new HumanMessage( - "Use the task tool to launch the mixed-hitl-agent sub-agent. " + - "Tell it to use ask_user to ask 'What is your name?'", - ), - ], - }, - config, - ); - - // Should interrupt from the ask_user tool - expect(result.__interrupt__).toBeDefined(); - const interruptValue = result.__interrupt__?.[0].value as Record< - string, - unknown - >; - expect(interruptValue.type).toBe("user_input"); - expect(interruptValue.question).toBe("What is your name?"); - - // Resume with answer - const result2 = await parentAgent.invoke( - new Command({ - resume: { answer: "Claude" }, - }), - config, - ); - - expect(result2.__interrupt__).toBeUndefined(); - expect(result2.messages.length).toBeGreaterThan(0); - }, - ); -}); diff --git a/libs/deepagents/src/middleware/subagents.int.test.ts b/libs/deepagents/src/middleware/subagents.int.test.ts deleted file mode 100644 index 16dd77730..000000000 --- a/libs/deepagents/src/middleware/subagents.int.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createAgent, createMiddleware, ReactAgent } from "langchain"; -import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages"; -import { createSubAgentMiddleware } from "../index.js"; -import { - SAMPLE_MODEL, - getWeather, - getSoccerScores, - extractToolsFromAgent, -} from "../testing/utils.js"; - -const WeatherToolMiddleware = createMiddleware({ - name: "weatherToolMiddleware", - tools: [getWeather], -}); - -/** - * Helper to extract all tool calls from agent response - */ -function extractAllToolCalls( - response: any, -): Array<{ name: string; args: Record; model?: string }> { - const messages = response.messages || []; - const aiMessages = messages.filter((msg: any) => AIMessage.isInstance(msg)); - return aiMessages.flatMap((msg: any) => - (msg.tool_calls || []).map((toolCall: any) => ({ - name: toolCall.name, - args: toolCall.args, - model: msg.response_metadata?.model_name || undefined, - })), - ); -} - -/** - * Helper to assert expected actions in subgraph - * This collects all tool calls from the agent execution - */ -async function assertExpectedSubgraphActions( - expectedToolCalls: Array<{ - name: string; - args?: Record; - model?: string; - }>, - agent: ReactAgent, - input: any, -) { - const actualToolCalls: Array<{ - name: string; - args: Record; - model?: string; - }> = []; - - for await (const chunk of await agent.graph.stream(input, { - streamMode: ["updates"], - subgraphs: true, - })) { - const update = chunk[2] ?? {}; - - if (!("model_request" in update)) continue; - const messages = update.model_request.messages as BaseMessage[]; - - const lastAiMessage = messages.filter(AIMessage.isInstance).at(-1); - - if (!lastAiMessage) continue; - - actualToolCalls.push( - ...(lastAiMessage.tool_calls ?? []).map((toolCall) => ({ - name: toolCall.name, - args: toolCall.args, - model: lastAiMessage.response_metadata?.model_name || undefined, - })), - ); - } - - expect(actualToolCalls).toMatchObject(expectedToolCalls); -} - -describe("Subagent Middleware Integration Tests", () => { - it.concurrent( - "should invoke general-purpose subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: - "Use the general-purpose subagent to get the weather in a city.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [getWeather] as any, - }), - ], - }); - - // Check that task tool is available - const tools = extractToolsFromAgent(agent); - expect(tools.task).toBeDefined(); - - const response = await agent.invoke({ - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - - const toolCalls = extractAllToolCalls(response); - const taskCall = toolCalls.find((tc) => tc.name === "task"); - - expect(taskCall).toBeDefined(); - expect(taskCall!.args.subagent_type).toBe("general-purpose"); - }, - ); - - it.concurrent( - "should invoke defined subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the task tool to call a subagent.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [ - { - name: "weather", - description: "This subagent can get weather in cities.", - systemPrompt: - "Use the get_weather tool to get the weather in a city.", - tools: [getWeather], - }, - ], - }), - ], - }); - - // Check that task tool is available - const tools = extractToolsFromAgent(agent); - expect(tools.task).toBeDefined(); - - const response = await agent.invoke({ - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - - const toolCalls = extractAllToolCalls(response); - const taskCall = toolCalls.find((tc) => tc.name === "task"); - - expect(taskCall).toBeDefined(); - expect(taskCall!.args.subagent_type).toBe("weather"); - }, - ); - - it.concurrent( - "should make tool calls within subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the task tool to call a subagent.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [ - { - name: "weather", - description: "This subagent can get weather in cities.", - systemPrompt: - "Use the get_weather tool to get the weather in a city.", - tools: [getWeather], - }, - ], - }), - ], - }); - - const expectedToolCalls = [ - { name: "task", args: { subagent_type: "weather" } }, - { name: "get_weather" }, - ]; - - await assertExpectedSubgraphActions(expectedToolCalls, agent, { - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - }, - ); - - it.concurrent( - "should use custom model in subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the task tool to call a subagent.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [ - { - name: "weather", - description: "This subagent can get weather in cities.", - systemPrompt: - "Use the get_weather tool to get the weather in a city.", - tools: [getWeather], - model: "gpt-4.1", // Custom model for subagent - }, - ], - }), - ], - }); - - const expectedToolCalls = [ - { name: "task", args: { subagent_type: "weather" } }, - { name: "get_weather" }, - ]; - - await assertExpectedSubgraphActions(expectedToolCalls, agent, { - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - }, - ); - - it.concurrent( - "should use custom middleware in subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the task tool to call a subagent.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [ - { - name: "weather", - description: "This subagent can get weather in cities.", - systemPrompt: - "Use the get_weather tool to get the weather in a city.", - tools: [], // No tools directly, only via middleware - model: "gpt-4.1", - middleware: [WeatherToolMiddleware], - }, - ], - }), - ], - }); - - const expectedToolCalls = [ - { name: "task", args: { subagent_type: "weather" } }, - { name: "get_weather" }, - ]; - - await assertExpectedSubgraphActions(expectedToolCalls, agent, { - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - }, - ); - - it.concurrent( - "should use pre-compiled subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const customSubagent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the get_weather tool to get the weather in a city.", - tools: [getWeather], - }); - - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the task tool to call a subagent.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [ - { - name: "weather", - description: "This subagent can get weather in cities.", - runnable: customSubagent, - }, - ], - }), - ], - }); - - const expectedToolCalls = [ - { name: "task", args: { subagent_type: "weather" } }, - { name: "get_weather" }, - ]; - - await assertExpectedSubgraphActions(expectedToolCalls, agent, { - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - }, - ); - - it.concurrent( - "should handle multiple subagents without middleware accumulation", - { timeout: 120000 }, - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the task tool to call subagents.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [ - { - name: "weather", - description: "Get weather information", - systemPrompt: "Use get_weather tool", - tools: [getWeather], - }, - { - name: "soccer", - description: "Get soccer scores", - systemPrompt: "Use get_soccer_scores tool", - tools: [getSoccerScores], - }, - ], - }), - ], - }); - - // Verify both subagents work independently - const response1 = await agent.invoke({ - messages: [new HumanMessage("What is the weather in Tokyo?")], - }); - - const toolCalls1 = extractAllToolCalls(response1); - const taskCall1 = toolCalls1.find((tc) => tc.name === "task"); - expect(taskCall1?.args.subagent_type).toBe("weather"); - - const response2 = await agent.invoke({ - messages: [ - new HumanMessage("What are the latest scores for Manchester United?"), - ], - }); - - const toolCalls2 = extractAllToolCalls(response2); - const taskCall2 = toolCalls2.find((tc) => tc.name === "task"); - expect(taskCall2?.args.subagent_type).toBe("soccer"); - }, - ); - - it.concurrent( - "should initialize subagent middleware with default settings", - { timeout: 90 * 1000 }, // 90s - async () => { - const middleware = createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - subagents: [], - }); - - expect(middleware).toBeDefined(); - expect(middleware.name).toBe("subAgentMiddleware"); - expect(middleware.tools).toBeDefined(); - expect(middleware.tools).toHaveLength(1); - expect(middleware.tools![0].name).toBe("task"); - - const agent = createAgent({ - model: SAMPLE_MODEL, - middleware: [middleware], - }); - - const tools = extractToolsFromAgent(agent); - expect(tools.task).toBeDefined(); - expect(tools.task.description).toContain("general-purpose"); - }, - ); - - it.concurrent( - "should initialize general-purpose subagent with default tools", - { timeout: 90 * 1000 }, // 90s - async () => { - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: "Use the general-purpose subagent to call tools.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [getWeather, getSoccerScores], - }), - ], - }); - - const response = await agent.invoke({ - messages: [ - new HumanMessage( - "Use the general-purpose subagent to get the weather in Tokyo", - ), - ], - }); - - const toolCalls = extractAllToolCalls(response); - const taskCall = toolCalls.find((tc) => tc.name === "task"); - - expect(taskCall).toBeDefined(); - expect(taskCall!.args.subagent_type).toBe("general-purpose"); - }, - ); - - it.concurrent( - "should use custom system prompt in general-purpose subagent", - { timeout: 90 * 1000 }, // 90s - async () => { - const customPrompt = - "You are a specialized assistant. In every response, you must include the word 'specialized'."; - - const agent = createAgent({ - model: SAMPLE_MODEL, - systemPrompt: - "Use the general-purpose subagent to answer the user's question.", - middleware: [ - createSubAgentMiddleware({ - defaultModel: SAMPLE_MODEL, - defaultTools: [], - systemPrompt: customPrompt, - }), - ], - }); - - const response = await agent.invoke({ - messages: [ - new HumanMessage( - "Use the general-purpose subagent to tell me about your capabilities", - ), - ], - }); - - const toolCalls = extractAllToolCalls(response); - const taskCall = toolCalls.find((tc) => tc.name === "task"); - expect(taskCall).toBeDefined(); - expect(taskCall!.args.subagent_type).toBe("general-purpose"); - expect(response.messages.length).toBeGreaterThan(0); - }, - ); -}); diff --git a/libs/deepagents/src/middleware/subagents.ts b/libs/deepagents/src/middleware/subagents.ts deleted file mode 100644 index a165c06d7..000000000 --- a/libs/deepagents/src/middleware/subagents.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { z } from "zod/v4"; -import { - createMiddleware, - createAgent, - AgentMiddleware, - tool, - ToolMessage, - humanInTheLoopMiddleware, - SystemMessage, - type InterruptOnConfig, - type ReactAgent, - StructuredTool, -} from "langchain"; -import { Command, getCurrentTaskInput } from "@langchain/langgraph"; -import type { LanguageModelLike } from "@langchain/core/language_models/base"; -import type { Runnable } from "@langchain/core/runnables"; -import { HumanMessage } from "@langchain/core/messages"; - -export type { AgentMiddleware }; - -// Constants -const DEFAULT_SUBAGENT_PROMPT = - "In order to complete the objective that the user asks of you, you have access to a number of standard tools."; - -// State keys that are excluded when passing state to subagents and when returning -// updates from subagents. -// When returning updates: -// 1. The messages key is handled explicitly to ensure only the final message is included -// 2. The todos and structuredResponse keys are excluded as they do not have a defined reducer -// and no clear meaning for returning them from a subagent to the main agent. -// 3. The files key is excluded to prevent concurrent subagents from writing to the files -// channel simultaneously (which causes LastValue errors in LangGraph). -const EXCLUDED_STATE_KEYS = [ - "messages", - "todos", - "structuredResponse", - "files", -] as const; - -const DEFAULT_GENERAL_PURPOSE_DESCRIPTION = - "General-purpose agent for researching complex questions, searching for files and content, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. This agent has access to all tools as the main agent."; - -// Comprehensive task tool description from Python -function getTaskToolDescription(subagentDescriptions: string[]): string { - return ` -Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. - -Available agent types and the tools they have access to: -${subagentDescriptions.join("\n")} - -When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. - -## Usage notes: -1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses -2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. -3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. -4. The agent's outputs should generally be trusted -5. Clearly tell the agent whether you expect it to create content, perform analysis, or just do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent -6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. -7. When only the general-purpose agent is provided, you should use it for all tasks. It is great for isolating context and token usage, and completing specific, complex tasks, as it has all the same capabilities as the main agent. - -### Example usage of the general-purpose agent: - - -"general-purpose": use this agent for general purpose tasks, it has access to all tools as the main agent. - - - -User: "I want to conduct research on the accomplishments of Lebron James, Michael Jordan, and Kobe Bryant, and then compare them." -Assistant: *Uses the task tool in parallel to conduct isolated research on each of the three players* -Assistant: *Synthesizes the results of the three isolated research tasks and responds to the User* - -Research is a complex, multi-step task in it of itself. -The research of each individual player is not dependent on the research of the other players. -The assistant uses the task tool to break down the complex objective into three isolated tasks. -Each research task only needs to worry about context and tokens about one player, then returns synthesized information about each player as the Tool Result. -This means each research task can dive deep and spend tokens and context deeply researching each player, but the final result is synthesized information, and saves us tokens in the long run when comparing the players to each other. - - - - -User: "Analyze a single large code repository for security vulnerabilities and generate a report." -Assistant: *Launches a single \`task\` subagent for the repository analysis* -Assistant: *Receives report and integrates results into final summary* - -Subagent is used to isolate a large, context-heavy task, even though there is only one. This prevents the main thread from being overloaded with details. -If the user then asks followup questions, we have a concise report to reference instead of the entire history of analysis and tool calls, which is good and saves us time and money. - - - - -User: "Schedule two meetings for me and prepare agendas for each." -Assistant: *Calls the task tool in parallel to launch two \`task\` subagents (one per meeting) to prepare agendas* -Assistant: *Returns final schedules and agendas* - -Tasks are simple individually, but subagents help silo agenda preparation. -Each subagent only needs to worry about the agenda for one meeting. - - - - -User: "I want to order a pizza from Dominos, order a burger from McDonald's, and order a salad from Subway." -Assistant: *Calls tools directly in parallel to order a pizza from Dominos, a burger from McDonald's, and a salad from Subway* - -The assistant did not use the task tool because the objective is super simple and clear and only requires a few trivial tool calls. -It is better to just complete the task directly and NOT use the \`task\`tool. - - - -### Example usage with custom agents: - - -"content-reviewer": use this agent after you are done creating significant content or documents -"greeting-responder": use this agent when to respond to user greetings with a friendly joke -"research-analyst": use this agent to conduct thorough research on complex topics - - - -user: "Please write a function that checks if a number is prime" -assistant: Sure let me write a function that checks if a number is prime -assistant: First let me use the Write tool to write a function that checks if a number is prime -assistant: I'm going to use the Write tool to write the following code: - -function isPrime(n) { - if (n <= 1) return false - for (let i = 2; i * i <= n; i++) { - if (n % i === 0) return false - } - return true -} - - -Since significant content was created and the task was completed, now use the content-reviewer agent to review the work - -assistant: Now let me use the content-reviewer agent to review the code -assistant: Uses the Task tool to launch with the content-reviewer agent - - - -user: "Can you help me research the environmental impact of different renewable energy sources and create a comprehensive report?" - -This is a complex research task that would benefit from using the research-analyst agent to conduct thorough analysis - -assistant: I'll help you research the environmental impact of renewable energy sources. Let me use the research-analyst agent to conduct comprehensive research on this topic. -assistant: Uses the Task tool to launch with the research-analyst agent, providing detailed instructions about what research to conduct and what format the report should take - - - -user: "Hello" - -Since the user is greeting, use the greeting-responder agent to respond with a friendly joke - -assistant: "I'm going to use the Task tool to launch with the greeting-responder agent" - - `.trim(); -} - -const TASK_SYSTEM_PROMPT = `## \`task\` (subagent spawner) - -You have access to a \`task\` tool to launch short-lived subagents that handle isolated tasks. These agents are ephemeral — they live only for the duration of the task and return a single result. - -When to use the task tool: -- When a task is complex and multi-step, and can be fully delegated in isolation -- When a task is independent of other tasks and can run in parallel -- When a task requires focused reasoning or heavy token/context usage that would bloat the orchestrator thread -- When sandboxing improves reliability (e.g. code execution, structured searches, data formatting) -- When you only care about the output of the subagent, and not the intermediate steps (ex. performing a lot of research and then returned a synthesized report, performing a series of computations or lookups to achieve a concise, relevant answer.) - -Subagent lifecycle: -1. **Spawn** → Provide clear role, instructions, and expected output -2. **Run** → The subagent completes the task autonomously -3. **Return** → The subagent provides a single structured result -4. **Reconcile** → Incorporate or synthesize the result into the main thread - -When NOT to use the task tool: -- If you need to see the intermediate reasoning or steps after the subagent has completed (the task tool hides them) -- If the task is trivial (a few tool calls or simple lookup) -- If delegating does not reduce token usage, complexity, or context switching -- If splitting would add latency without benefit - -## Important Task Tool Usage Notes to Remember -- Whenever possible, parallelize the work that you do. This is true for both tool_calls, and for tasks. Whenever you have independent steps to complete - make tool_calls, or kick off tasks (subagents) in parallel to accomplish them faster. This saves time for the user, which is incredibly important. -- Remember to use the \`task\` tool to silo independent tasks within a multi-part objective. -- You should use the \`task\` tool whenever you have a complex task that will take multiple steps, and is independent from other tasks that the agent needs to complete. These agents are highly competent and efficient.`; - -/** - * Type definitions for pre-compiled agents. - * - * @typeParam TRunnable - The type of the runnable (ReactAgent or Runnable). - * When using `createAgent`, this preserves the middleware types for type inference. - */ -export interface CompiledSubAgent< - TRunnable extends ReactAgent | Runnable = ReactAgent | Runnable, -> { - /** The name of the agent */ - name: string; - /** The description of the agent */ - description: string; - /** The agent instance */ - runnable: TRunnable; -} - -/** - * Type definitions for subagents - */ -export interface SubAgent { - /** The name of the agent */ - name: string; - /** The description of the agent */ - description: string; - /** The system prompt to use for the agent */ - systemPrompt: string; - /** The tools to use for the agent (tool instances, not names). Defaults to defaultTools */ - tools?: StructuredTool[]; - /** The model for the agent. Defaults to default_model */ - model?: LanguageModelLike | string; - /** Additional middleware to append after default_middleware */ - middleware?: readonly AgentMiddleware[]; - /** The tool configs to use for the agent */ - interruptOn?: Record; -} - -/** - * Filter state to exclude certain keys when passing to subagents - */ -function filterStateForSubagent( - state: Record, -): Record { - const filtered: Record = {}; - for (const [key, value] of Object.entries(state)) { - if (!EXCLUDED_STATE_KEYS.includes(key as never)) { - filtered[key] = value; - } - } - return filtered; -} - -/** - * Create Command with filtered state update from subagent result - */ -function returnCommandWithStateUpdate( - result: Record, - toolCallId: string, -): Command { - const stateUpdate = filterStateForSubagent(result); - const messages = result.messages as Array<{ content: string }>; - const lastMessage = messages?.[messages.length - 1]; - - return new Command({ - update: { - ...stateUpdate, - messages: [ - new ToolMessage({ - content: lastMessage?.content || "Task completed", - tool_call_id: toolCallId, - name: "task", - }), - ], - }, - }); -} - -/** - * Create subagent instances from specifications - */ -function getSubagents(options: { - defaultModel: LanguageModelLike | string; - defaultTools: StructuredTool[]; - defaultMiddleware: AgentMiddleware[] | null; - defaultInterruptOn: Record | null; - subagents: (SubAgent | CompiledSubAgent)[]; - generalPurposeAgent: boolean; -}): { - agents: Record; - descriptions: string[]; -} { - const { - defaultModel, - defaultTools, - defaultMiddleware, - defaultInterruptOn, - subagents, - generalPurposeAgent, - } = options; - - const defaultSubagentMiddleware = defaultMiddleware || []; - const agents: Record = {}; - const subagentDescriptions: string[] = []; - - // Create general-purpose agent if enabled - if (generalPurposeAgent) { - const generalPurposeMiddleware = [...defaultSubagentMiddleware]; - if (defaultInterruptOn) { - generalPurposeMiddleware.push( - humanInTheLoopMiddleware({ interruptOn: defaultInterruptOn }), - ); - } - - const generalPurposeSubagent = createAgent({ - model: defaultModel, - systemPrompt: DEFAULT_SUBAGENT_PROMPT, - tools: defaultTools as any, - middleware: generalPurposeMiddleware, - }); - - agents["general-purpose"] = generalPurposeSubagent; - subagentDescriptions.push( - `- general-purpose: ${DEFAULT_GENERAL_PURPOSE_DESCRIPTION}`, - ); - } - - // Process custom subagents - for (const agentParams of subagents) { - subagentDescriptions.push( - `- ${agentParams.name}: ${agentParams.description}`, - ); - - if ("runnable" in agentParams) { - agents[agentParams.name] = agentParams.runnable; - } else { - const middleware = agentParams.middleware - ? [...defaultSubagentMiddleware, ...agentParams.middleware] - : [...defaultSubagentMiddleware]; - - const interruptOn = agentParams.interruptOn || defaultInterruptOn; - if (interruptOn) - middleware.push(humanInTheLoopMiddleware({ interruptOn })); - - agents[agentParams.name] = createAgent({ - model: agentParams.model ?? defaultModel, - systemPrompt: agentParams.systemPrompt, - tools: agentParams.tools ?? defaultTools, - middleware, - }); - } - } - - return { agents, descriptions: subagentDescriptions }; -} - -/** - * Create the task tool for invoking subagents - */ -function createTaskTool(options: { - defaultModel: LanguageModelLike | string; - defaultTools: StructuredTool[]; - defaultMiddleware: AgentMiddleware[] | null; - defaultInterruptOn: Record | null; - subagents: (SubAgent | CompiledSubAgent)[]; - generalPurposeAgent: boolean; - taskDescription: string | null; -}) { - const { - defaultModel, - defaultTools, - defaultMiddleware, - defaultInterruptOn, - subagents, - generalPurposeAgent, - taskDescription, - } = options; - - const { agents: subagentGraphs, descriptions: subagentDescriptions } = - getSubagents({ - defaultModel, - defaultTools, - defaultMiddleware, - defaultInterruptOn, - subagents, - generalPurposeAgent, - }); - - const finalTaskDescription = taskDescription - ? taskDescription - : getTaskToolDescription(subagentDescriptions); - - return tool( - async ( - input: { description: string; subagent_type: string }, - config, - ): Promise => { - const { description, subagent_type } = input; - - // Validate subagent type - if (!(subagent_type in subagentGraphs)) { - const allowedTypes = Object.keys(subagentGraphs) - .map((k) => `\`${k}\``) - .join(", "); - throw new Error( - `Error: invoked agent of type ${subagent_type}, the only allowed types are ${allowedTypes}`, - ); - } - - const subagent = subagentGraphs[subagent_type]; - - // Get current state and filter it for subagent - const currentState = getCurrentTaskInput>(); - const subagentState = filterStateForSubagent(currentState); - subagentState.messages = [new HumanMessage({ content: description })]; - - // Invoke the subagent - const result = (await subagent.invoke(subagentState, config)) as Record< - string, - unknown - >; - - // Return command with filtered state update - if (!config.toolCall?.id) { - throw new Error("Tool call ID is required for subagent invocation"); - } - - return returnCommandWithStateUpdate(result, config.toolCall.id); - }, - { - name: "task", - description: finalTaskDescription, - schema: z.object({ - description: z - .string() - .describe("The task to execute with the selected agent"), - subagent_type: z - .string() - .describe( - `Name of the agent to use. Available: ${Object.keys(subagentGraphs).join(", ")}`, - ), - }), - }, - ); -} - -/** - * Options for creating subagent middleware - */ -export interface SubAgentMiddlewareOptions { - /** The model to use for subagents */ - defaultModel: LanguageModelLike | string; - /** The tools to use for the default general-purpose subagent */ - defaultTools?: StructuredTool[]; - /** Default middleware to apply to all subagents */ - defaultMiddleware?: AgentMiddleware[] | null; - /** The tool configs for the default general-purpose subagent */ - defaultInterruptOn?: Record | null; - /** A list of additional subagents to provide to the agent */ - subagents?: (SubAgent | CompiledSubAgent)[]; - /** Full system prompt override */ - systemPrompt?: string | null; - /** Whether to include the general-purpose agent */ - generalPurposeAgent?: boolean; - /** Custom description for the task tool */ - taskDescription?: string | null; -} - -/** - * Create subagent middleware with task tool - */ -export function createSubAgentMiddleware(options: SubAgentMiddlewareOptions) { - const { - defaultModel, - defaultTools = [], - defaultMiddleware = null, - defaultInterruptOn = null, - subagents = [], - systemPrompt = TASK_SYSTEM_PROMPT, - generalPurposeAgent = true, - taskDescription = null, - } = options; - - const taskTool = createTaskTool({ - defaultModel, - defaultTools, - defaultMiddleware, - defaultInterruptOn, - subagents, - generalPurposeAgent, - taskDescription, - }); - - return createMiddleware({ - name: "subAgentMiddleware", - tools: [taskTool], - wrapModelCall: async (request, handler) => { - if (systemPrompt !== null) { - return handler({ - ...request, - systemMessage: request.systemMessage.concat( - new SystemMessage({ content: systemPrompt }), - ), - }); - } - return handler(request); - }, - }); -} diff --git a/libs/deepagents/src/middleware/summarization.test.ts b/libs/deepagents/src/middleware/summarization.test.ts deleted file mode 100644 index 19e944b27..000000000 --- a/libs/deepagents/src/middleware/summarization.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { HumanMessage, AIMessage } from "@langchain/core/messages"; -import { createSummarizationMiddleware } from "./summarization.js"; -import type { - BackendProtocol, - FileDownloadResponse, - WriteResult, - EditResult, -} from "../backends/protocol.js"; - -// Mock the OpenAI module with a class constructor -vi.mock("@langchain/openai", () => { - return { - ChatOpenAI: class MockChatOpenAI { - constructor(_config: any) {} - async invoke(_messages: any) { - return { - content: "This is a summary of the conversation.", - }; - } - }, - }; -}); - -// Create a mock backend -function createMockBackend( - options: { - files?: Record; - writeError?: string; - } = {}, -): BackendProtocol { - const { files = {}, writeError } = options; - const writtenFiles: Record = { ...files }; - - return { - async downloadFiles(paths: string[]): Promise { - return paths.map((path) => { - const content = writtenFiles[path]; - if (content === undefined) { - return { path, error: "file_not_found", content: null }; - } - return { - path, - content: new TextEncoder().encode(content), - error: null, - }; - }); - }, - async write(path: string, content: string): Promise { - if (writeError) { - return { error: writeError }; - } - writtenFiles[path] = content; - return { path }; - }, - async edit( - path: string, - _oldString: string, - newString: string, - ): Promise { - if (writeError) { - return { error: writeError }; - } - writtenFiles[path] = newString; - return { path, occurrences: 1 }; - }, - } as unknown as BackendProtocol; -} - -describe("createSummarizationMiddleware", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("basic functionality", () => { - it("should return undefined when no messages", async () => { - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: createMockBackend(), - trigger: { type: "messages", value: 5 }, - }); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages: [] }); - expect(result).toBeUndefined(); - }); - - it("should return undefined when under trigger threshold", async () => { - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: createMockBackend(), - trigger: { type: "messages", value: 10 }, - }); - - const messages = [ - new HumanMessage({ content: "Hello" }), - new AIMessage({ content: "Hi there!" }), - ]; - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - expect(result).toBeUndefined(); - }); - - it("should not summarize when no trigger configured", async () => { - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: createMockBackend(), - // No trigger configured - }); - - const messages = Array.from( - { length: 100 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - expect(result).toBeUndefined(); - }); - }); - - describe("message count trigger", () => { - it("should trigger summarization when message count exceeds threshold", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - expect(result).toBeDefined(); - expect(result?.messages).toBeDefined(); - // Should have summary message + 2 preserved messages - expect(result?.messages.length).toBe(3); - // First message should be the summary - expect(result?.messages[0]).toBeInstanceOf(HumanMessage); - expect(result?.messages[0].content).toContain("summary"); - }); - }); - - describe("token count trigger", () => { - it("should trigger summarization when token count exceeds threshold", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "tokens", value: 100 }, // Low threshold for testing - keep: { type: "messages", value: 2 }, - }); - - // Create messages with enough content to exceed token threshold - const messages = Array.from( - { length: 10 }, - (_, i) => - new HumanMessage({ - content: `Message ${i} with some extra content to increase token count`, - }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - expect(result).toBeDefined(); - expect(result?.messages).toBeDefined(); - }); - }); - - describe("keep policy", () => { - it("should preserve specified number of recent messages", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 3 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - expect(result).toBeDefined(); - // Summary message (1) + preserved messages (3) = 4 - expect(result?.messages.length).toBe(4); - // Last 3 messages should be preserved (Message 7, 8, 9) - expect(result?.messages[1].content).toBe("Message 7"); - expect(result?.messages[2].content).toBe("Message 8"); - expect(result?.messages[3].content).toBe("Message 9"); - }); - }); - - describe("backend offloading", () => { - it("should write conversation history to backend", async () => { - const writtenContent: string[] = []; - const mockBackend = { - ...createMockBackend(), - async write(path: string, content: string): Promise { - writtenContent.push(content); - return { path }; - }, - } as unknown as BackendProtocol; - - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - await middleware.beforeModel?.({ messages }); - - expect(writtenContent.length).toBe(1); - expect(writtenContent[0]).toContain("Summarized at"); - // Should contain the older messages that were offloaded - expect(writtenContent[0]).toContain("Message 0"); - }); - - it("should not proceed with summarization if backend write fails", async () => { - const mockBackend = createMockBackend({ writeError: "Write failed" }); - - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - // Should return undefined if offloading fails - expect(result).toBeUndefined(); - }); - }); - - describe("summary message", () => { - it("should include file path reference in summary message", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - expect(result).toBeDefined(); - const summaryMessage = result?.messages[0]; - expect(summaryMessage.content).toContain("/conversation_history/"); - expect(summaryMessage.content).toContain("saved to"); - }); - - it("should mark summary message with lc_source", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - const summaryMessage = result?.messages[0]; - expect(summaryMessage.additional_kwargs?.lc_source).toBe("summarization"); - }); - }); - - describe("argument truncation", () => { - it("should truncate large tool call arguments", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 20 }, // High threshold so we only test truncation - truncateArgsSettings: { - trigger: { type: "messages", value: 3 }, - keep: { type: "messages", value: 1 }, - maxLength: 50, - truncationText: "...(truncated)", - }, - }); - - const largeContent = "x".repeat(100); - const messages = [ - new HumanMessage({ content: "Write a file" }), - new AIMessage({ - content: "", - tool_calls: [ - { - id: "call_1", - name: "write_file", - args: { path: "/test.txt", content: largeContent }, - }, - ], - }), - new HumanMessage({ content: "Done" }), - new HumanMessage({ content: "Recent message" }), - ]; - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - expect(result).toBeDefined(); - expect(result?.messages).toBeDefined(); - // The truncated AI message should have truncated content - const aiMessage = result?.messages.find((m: any) => - AIMessage.isInstance(m), - ); - if (aiMessage) { - expect(aiMessage.tool_calls[0].args.content).toContain( - "...(truncated)", - ); - expect(aiMessage.tool_calls[0].args.content.length).toBeLessThan( - largeContent.length, - ); - } - }); - }); - - describe("multiple triggers", () => { - it("should support array of triggers", async () => { - const mockBackend = createMockBackend(); - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: [ - { type: "messages", value: 100 }, // Won't trigger - { type: "tokens", value: 50 }, // Should trigger (low threshold) - ], - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => - new HumanMessage({ content: `Message ${i} with some content` }), - ); - - // @ts-expect-error - typing issue - const result = await middleware.beforeModel?.({ messages }); - - expect(result).toBeDefined(); - }); - }); - - describe("backend factory", () => { - it("should work with backend factory function", async () => { - const mockBackend = createMockBackend(); - const backendFactory = vi.fn().mockReturnValue(mockBackend); - - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: backendFactory, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - await middleware.beforeModel?.({ messages }); - - expect(backendFactory).toHaveBeenCalled(); - }); - }); - - describe("custom history path", () => { - it("should use custom history path prefix", async () => { - let writtenPath = ""; - const mockBackend = { - ...createMockBackend(), - async write(path: string, _content: string): Promise { - writtenPath = path; - return { path }; - }, - async downloadFiles(): Promise { - return [ - { path: writtenPath, error: "file_not_found", content: null }, - ]; - }, - } as unknown as BackendProtocol; - - const middleware = createSummarizationMiddleware({ - model: "gpt-4o-mini", - backend: mockBackend, - trigger: { type: "messages", value: 5 }, - keep: { type: "messages", value: 2 }, - historyPathPrefix: "/custom/history", - }); - - const messages = Array.from( - { length: 10 }, - (_, i) => new HumanMessage({ content: `Message ${i}` }), - ); - - // @ts-expect-error - typing issue - await middleware.beforeModel?.({ messages }); - - expect(writtenPath).toContain("/custom/history/"); - }); - }); -}); diff --git a/libs/deepagents/src/middleware/summarization.ts b/libs/deepagents/src/middleware/summarization.ts deleted file mode 100644 index e5764449a..000000000 --- a/libs/deepagents/src/middleware/summarization.ts +++ /dev/null @@ -1,666 +0,0 @@ -/* eslint-disable no-console */ -/** - * Summarization middleware with backend support for conversation history offloading. - * - * This module extends the base LangChain summarization middleware with additional - * backend-based features for persisting conversation history before summarization. - * - * ## Usage - * - * ```typescript - * import { createSummarizationMiddleware } from "@anthropic/deepagents"; - * import { FilesystemBackend } from "@anthropic/deepagents"; - * - * const backend = new FilesystemBackend({ rootDir: "/data" }); - * - * const middleware = createSummarizationMiddleware({ - * model: "gpt-4o-mini", - * backend, - * trigger: { type: "fraction", value: 0.85 }, - * keep: { type: "fraction", value: 0.10 }, - * }); - * - * const agent = createDeepAgent({ middleware: [middleware] }); - * ``` - * - * ## Storage - * - * Offloaded messages are stored as markdown at `/conversation_history/{thread_id}.md`. - * - * Each summarization event appends a new section to this file, creating a running log - * of all evicted messages. - * - * ## Relationship to LangChain Summarization Middleware - * - * The base `summarizationMiddleware` from `langchain` provides core summarization - * functionality. This middleware adds: - * - Backend-based conversation history offloading - * - Tool argument truncation for old messages - * - * For simple use cases without backend offloading, use `summarizationMiddleware` - * from `langchain` directly. - */ - -import { z } from "zod"; -import { v4 as uuidv4 } from "uuid"; -import { - createMiddleware, - countTokensApproximately, - HumanMessage, - AIMessage, - BaseMessage, - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; -import { getBufferString } from "@langchain/core/messages"; -import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; -import { ChatOpenAI } from "@langchain/openai"; - -import type { BackendProtocol, BackendFactory } from "../backends/protocol.js"; -import type { StateBackend } from "../backends/state.js"; -import type { BaseStore } from "@langchain/langgraph-checkpoint"; - -// Re-export the base summarization middleware from langchain for users who don't need backend offloading -export { summarizationMiddleware } from "langchain"; - -/** - * Context size specification for summarization triggers and retention policies. - */ -export interface ContextSize { - /** Type of context measurement */ - type: "messages" | "tokens" | "fraction"; - /** Threshold value */ - value: number; -} - -/** - * Settings for truncating large tool arguments in old messages. - */ -export interface TruncateArgsSettings { - /** - * Threshold to trigger argument truncation. - * If not provided, truncation is disabled. - */ - trigger?: ContextSize; - - /** - * Context retention policy for message truncation. - * Defaults to keeping last 20 messages. - */ - keep?: ContextSize; - - /** - * Maximum character length for tool arguments before truncation. - * Defaults to 2000. - */ - maxLength?: number; - - /** - * Text to replace truncated arguments with. - * Defaults to "...(argument truncated)". - */ - truncationText?: string; -} - -/** - * Options for the summarization middleware. - */ -export interface SummarizationMiddlewareOptions { - /** - * The language model to use for generating summaries. - * Can be a model string (e.g., "gpt-4o-mini") or a BaseChatModel instance. - */ - model: string | BaseChatModel; - - /** - * Backend instance or factory for persisting conversation history. - */ - backend: - | BackendProtocol - | BackendFactory - | ((config: { state: unknown; store?: BaseStore }) => StateBackend); - - /** - * Threshold(s) that trigger summarization. - * Can be a single ContextSize or an array for multiple triggers. - */ - trigger?: ContextSize | ContextSize[]; - - /** - * Context retention policy after summarization. - * Defaults to keeping last 20 messages. - */ - keep?: ContextSize; - - /** - * Prompt template for generating summaries. - */ - summaryPrompt?: string; - - /** - * Max tokens to include when generating summary. - * Defaults to 4000. - */ - trimTokensToSummarize?: number; - - /** - * Path prefix for storing conversation history. - * Defaults to "/conversation_history". - */ - historyPathPrefix?: string; - - /** - * Settings for truncating large tool arguments in old messages. - * If not provided, argument truncation is disabled. - */ - truncateArgsSettings?: TruncateArgsSettings; -} - -// Default values -const DEFAULT_MESSAGES_TO_KEEP = 20; -const DEFAULT_TRIM_TOKEN_LIMIT = 4000; -const DEFAULT_SUMMARY_PROMPT = `You are a conversation summarizer. Your task is to create a concise summary of the conversation that captures: -1. The main topics discussed -2. Key decisions or conclusions reached -3. Any important context that would be needed for continuing the conversation - -Keep the summary focused and informative. Do not include unnecessary details. - -Conversation to summarize: -{conversation} - -Summary:`; - -/** - * State schema for summarization middleware. - */ -const SummarizationStateSchema = z.object({ - /** Session ID for history file naming */ - _summarizationSessionId: z.string().optional(), -}); - -/** - * Check if a message is a previous summarization message. - * Summary messages are HumanMessage objects with lc_source='summarization' in additional_kwargs. - */ -function isSummaryMessage(msg: BaseMessage): boolean { - if (!HumanMessage.isInstance(msg)) { - return false; - } - return msg.additional_kwargs?.lc_source === "summarization"; -} - -/** - * Create summarization middleware with backend support for conversation history offloading. - * - * This middleware: - * 1. Monitors conversation length against configured thresholds - * 2. When triggered, offloads old messages to backend storage - * 3. Generates a summary of offloaded messages - * 4. Replaces old messages with the summary, preserving recent context - * - * @param options - Configuration options - * @returns AgentMiddleware for summarization and history offloading - */ -export function createSummarizationMiddleware( - options: SummarizationMiddlewareOptions, -) { - const { - model, - backend, - trigger, - keep = { type: "messages", value: DEFAULT_MESSAGES_TO_KEEP }, - summaryPrompt = DEFAULT_SUMMARY_PROMPT, - trimTokensToSummarize = DEFAULT_TRIM_TOKEN_LIMIT, - historyPathPrefix = "/conversation_history", - truncateArgsSettings, - } = options; - - // Parse truncate settings - const truncateTrigger = truncateArgsSettings?.trigger; - const truncateKeep = truncateArgsSettings?.keep || { - type: "messages" as const, - value: 20, - }; - const maxArgLength = truncateArgsSettings?.maxLength || 2000; - const truncationText = - truncateArgsSettings?.truncationText || "...(argument truncated)"; - - // Session ID for this middleware instance (fallback if no thread_id) - let sessionId: string | null = null; - - /** - * Resolve backend from instance or factory. - */ - function getBackend(state: unknown): BackendProtocol { - if (typeof backend === "function") { - return backend({ state }) as BackendProtocol; - } - return backend; - } - - /** - * Get or create session ID for history file naming. - */ - function getSessionId(state: Record): string { - if (state._summarizationSessionId) { - return state._summarizationSessionId as string; - } - if (!sessionId) { - sessionId = `session_${uuidv4().substring(0, 8)}`; - } - return sessionId; - } - - /** - * Get the history file path. - */ - function getHistoryPath(state: Record): string { - const id = getSessionId(state); - return `${historyPathPrefix}/${id}.md`; - } - - /** - * Resolve the chat model. - */ - function getChatModel(): BaseChatModel { - if (typeof model === "string") { - return new ChatOpenAI({ modelName: model }); - } - return model; - } - - /** - * Check if summarization should be triggered. - */ - function shouldSummarize( - messages: BaseMessage[], - totalTokens: number, - maxInputTokens?: number, - ): boolean { - if (!trigger) { - return false; - } - - const triggers = Array.isArray(trigger) ? trigger : [trigger]; - - for (const t of triggers) { - if (t.type === "messages" && messages.length >= t.value) { - return true; - } - if (t.type === "tokens" && totalTokens >= t.value) { - return true; - } - if (t.type === "fraction" && maxInputTokens) { - const threshold = Math.floor(maxInputTokens * t.value); - if (totalTokens >= threshold) { - return true; - } - } - } - - return false; - } - - /** - * Determine cutoff index for messages to summarize. - * Messages at index < cutoff will be summarized. - * Messages at index >= cutoff will be preserved. - */ - function determineCutoffIndex( - messages: BaseMessage[], - maxInputTokens?: number, - ): number { - if (keep.type === "messages") { - if (messages.length <= keep.value) { - return 0; - } - return messages.length - keep.value; - } - - if (keep.type === "tokens" || keep.type === "fraction") { - const targetTokenCount = - keep.type === "fraction" && maxInputTokens - ? Math.floor(maxInputTokens * keep.value) - : keep.value; - - let tokensKept = 0; - for (let i = messages.length - 1; i >= 0; i--) { - const msgTokens = countTokensApproximately([messages[i]]); - if (tokensKept + msgTokens > targetTokenCount) { - return i + 1; - } - tokensKept += msgTokens; - } - return 0; - } - - return 0; - } - - /** - * Check if argument truncation should be triggered. - */ - function shouldTruncateArgs( - messages: BaseMessage[], - totalTokens: number, - maxInputTokens?: number, - ): boolean { - if (!truncateTrigger) { - return false; - } - - if (truncateTrigger.type === "messages") { - return messages.length >= truncateTrigger.value; - } - if (truncateTrigger.type === "tokens") { - return totalTokens >= truncateTrigger.value; - } - if (truncateTrigger.type === "fraction" && maxInputTokens) { - const threshold = Math.floor(maxInputTokens * truncateTrigger.value); - return totalTokens >= threshold; - } - - return false; - } - - /** - * Determine cutoff index for argument truncation. - */ - function determineTruncateCutoffIndex( - messages: BaseMessage[], - maxInputTokens?: number, - ): number { - if (truncateKeep.type === "messages") { - if (messages.length <= truncateKeep.value) { - return messages.length; - } - return messages.length - truncateKeep.value; - } - - if (truncateKeep.type === "tokens" || truncateKeep.type === "fraction") { - const targetTokenCount = - truncateKeep.type === "fraction" && maxInputTokens - ? Math.floor(maxInputTokens * truncateKeep.value) - : truncateKeep.value; - - let tokensKept = 0; - for (let i = messages.length - 1; i >= 0; i--) { - const msgTokens = countTokensApproximately([messages[i]]); - if (tokensKept + msgTokens > targetTokenCount) { - return i + 1; - } - tokensKept += msgTokens; - } - return 0; - } - - return messages.length; - } - - /** - * Truncate large tool arguments in old messages. - */ - function truncateArgs( - messages: BaseMessage[], - maxInputTokens?: number, - ): { messages: BaseMessage[]; modified: boolean } { - const totalTokens = countTokensApproximately(messages); - if (!shouldTruncateArgs(messages, totalTokens, maxInputTokens)) { - return { messages, modified: false }; - } - - const cutoffIndex = determineTruncateCutoffIndex(messages, maxInputTokens); - if (cutoffIndex >= messages.length) { - return { messages, modified: false }; - } - - const truncatedMessages: BaseMessage[] = []; - let modified = false; - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; - - if (i < cutoffIndex && AIMessage.isInstance(msg) && msg.tool_calls) { - const truncatedToolCalls = msg.tool_calls.map((toolCall) => { - const args = toolCall.args || {}; - const truncatedArgs: Record = {}; - let toolModified = false; - - for (const [key, value] of Object.entries(args)) { - if ( - typeof value === "string" && - value.length > maxArgLength && - (toolCall.name === "write_file" || toolCall.name === "edit_file") - ) { - truncatedArgs[key] = value.substring(0, 20) + truncationText; - toolModified = true; - } else { - truncatedArgs[key] = value; - } - } - - if (toolModified) { - modified = true; - return { ...toolCall, args: truncatedArgs }; - } - return toolCall; - }); - - if (modified) { - const truncatedMsg = new AIMessage({ - content: msg.content, - tool_calls: truncatedToolCalls, - additional_kwargs: msg.additional_kwargs, - }); - truncatedMessages.push(truncatedMsg); - } else { - truncatedMessages.push(msg); - } - } else { - truncatedMessages.push(msg); - } - } - - return { messages: truncatedMessages, modified }; - } - - /** - * Filter out previous summary messages. - */ - function filterSummaryMessages(messages: BaseMessage[]): BaseMessage[] { - return messages.filter((msg) => !isSummaryMessage(msg)); - } - - /** - * Offload messages to backend. - */ - async function offloadToBackend( - resolvedBackend: BackendProtocol, - messages: BaseMessage[], - state: Record, - ): Promise { - const path = getHistoryPath(state); - const filteredMessages = filterSummaryMessages(messages); - - const timestamp = new Date().toISOString(); - const newSection = `## Summarized at ${timestamp}\n\n${getBufferString(filteredMessages)}\n\n`; - - // Read existing content - let existingContent = ""; - try { - if (resolvedBackend.downloadFiles) { - const responses = await resolvedBackend.downloadFiles([path]); - if ( - responses.length > 0 && - responses[0].content && - !responses[0].error - ) { - existingContent = new TextDecoder().decode(responses[0].content); - } - } - } catch { - // File doesn't exist yet, that's fine - } - - const combinedContent = existingContent + newSection; - - try { - let result; - if (existingContent) { - result = await resolvedBackend.edit( - path, - existingContent, - combinedContent, - ); - } else { - result = await resolvedBackend.write(path, combinedContent); - } - - if (result.error) { - console.warn( - `Failed to offload conversation history to ${path}: ${result.error}`, - ); - return null; - } - - return path; - } catch (e) { - console.warn(`Exception offloading conversation history to ${path}:`, e); - return null; - } - } - - /** - * Create summary of messages. - */ - async function createSummary(messages: BaseMessage[]): Promise { - const chatModel = getChatModel(); - - // Trim messages if too long - let messagesToSummarize = messages; - const tokens = countTokensApproximately(messages); - if (tokens > trimTokensToSummarize) { - // Keep only recent messages that fit - let kept = 0; - const trimmedMessages: BaseMessage[] = []; - for (let i = messages.length - 1; i >= 0; i--) { - const msgTokens = countTokensApproximately([messages[i]]); - if (kept + msgTokens > trimTokensToSummarize) { - break; - } - trimmedMessages.unshift(messages[i]); - kept += msgTokens; - } - messagesToSummarize = trimmedMessages; - } - - const conversation = getBufferString(messagesToSummarize); - const prompt = summaryPrompt.replace("{conversation}", conversation); - - const response = await chatModel.invoke([ - new HumanMessage({ content: prompt }), - ]); - - return typeof response.content === "string" - ? response.content - : JSON.stringify(response.content); - } - - /** - * Build the summary message with file path reference. - */ - function buildSummaryMessage( - summary: string, - filePath: string | null, - ): HumanMessage { - let content: string; - if (filePath) { - content = `You are in the middle of a conversation that has been summarized. - -The full conversation history has been saved to ${filePath} should you need to refer back to it for details. - -A condensed summary follows: - - -${summary} -`; - } else { - content = `Here is a summary of the conversation to date:\n\n${summary}`; - } - - return new HumanMessage({ - content, - additional_kwargs: { lc_source: "summarization" }, - }); - } - - return createMiddleware({ - name: "SummarizationMiddleware", - stateSchema: SummarizationStateSchema, - - async beforeModel(state) { - const messages = state.messages ?? []; - - if (messages.length === 0) { - return undefined; - } - - // Step 1: Truncate args if configured - const { messages: truncatedMessages, modified: argsWereTruncated } = - truncateArgs(messages); - - // Step 2: Check if summarization should happen - const totalTokens = countTokensApproximately(truncatedMessages); - const shouldDoSummarization = shouldSummarize( - truncatedMessages, - totalTokens, - ); - - // If only truncation happened (no summarization) - if (argsWereTruncated && !shouldDoSummarization) { - return { messages: truncatedMessages }; - } - - // If no truncation and no summarization - if (!shouldDoSummarization) { - return undefined; - } - - // Step 3: Perform summarization - const cutoffIndex = determineCutoffIndex(truncatedMessages); - if (cutoffIndex <= 0) { - if (argsWereTruncated) { - return { messages: truncatedMessages }; - } - return undefined; - } - - const messagesToSummarize = truncatedMessages.slice(0, cutoffIndex); - const preservedMessages = truncatedMessages.slice(cutoffIndex); - - // Offload to backend first - const resolvedBackend = getBackend(state); - const filePath = await offloadToBackend( - resolvedBackend, - messagesToSummarize, - state, - ); - - if (filePath === null) { - // Offloading failed - don't proceed with summarization - return undefined; - } - - // Generate summary - const summary = await createSummary(messagesToSummarize); - - // Build summary message - const summaryMessage = buildSummaryMessage(summary, filePath); - - return { - messages: [summaryMessage, ...preservedMessages], - _summarizationSessionId: getSessionId(state), - }; - }, - }); -} diff --git a/libs/deepagents/src/middleware/utils.test.ts b/libs/deepagents/src/middleware/utils.test.ts deleted file mode 100644 index 0ad2c0eac..000000000 --- a/libs/deepagents/src/middleware/utils.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { SystemMessage } from "@langchain/core/messages"; -import { appendToSystemMessage, prependToSystemMessage } from "./utils.js"; - -describe("appendToSystemMessage", () => { - it("should create a new SystemMessage when original is null", () => { - const result = appendToSystemMessage(null, "Hello world"); - expect(result).toBeInstanceOf(SystemMessage); - expect(result.content).toBe("Hello world"); - }); - - it("should create a new SystemMessage when original is undefined", () => { - const result = appendToSystemMessage(undefined, "Hello world"); - expect(result).toBeInstanceOf(SystemMessage); - expect(result.content).toBe("Hello world"); - }); - - it("should append text to string content with double newline", () => { - const original = new SystemMessage({ - content: "You are a helpful assistant.", - }); - const result = appendToSystemMessage(original, "Always be concise."); - expect(result.content).toBe( - "You are a helpful assistant.\n\nAlways be concise.", - ); - }); - - it("should handle empty original content", () => { - const original = new SystemMessage({ content: "" }); - const result = appendToSystemMessage(original, "New content"); - expect(result.content).toBe("New content"); - }); - - it("should handle array content by appending as text block", () => { - const original = new SystemMessage({ - content: [{ type: "text", text: "Original content" }], - }); - const result = appendToSystemMessage(original, "Appended content"); - expect(Array.isArray(result.content)).toBe(true); - expect((result.content as any[]).length).toBe(2); - }); - - it("should handle empty array content", () => { - const original = new SystemMessage({ content: [] }); - const result = appendToSystemMessage(original, "New content"); - expect(Array.isArray(result.content)).toBe(true); - expect((result.content as any[])[0]).toEqual({ - type: "text", - text: "New content", - }); - }); -}); - -describe("prependToSystemMessage", () => { - it("should create a new SystemMessage when original is null", () => { - const result = prependToSystemMessage(null, "Hello world"); - expect(result).toBeInstanceOf(SystemMessage); - expect(result.content).toBe("Hello world"); - }); - - it("should create a new SystemMessage when original is undefined", () => { - const result = prependToSystemMessage(undefined, "Hello world"); - expect(result).toBeInstanceOf(SystemMessage); - expect(result.content).toBe("Hello world"); - }); - - it("should prepend text to string content with double newline", () => { - const original = new SystemMessage({ content: "Always be concise." }); - const result = prependToSystemMessage( - original, - "You are a helpful assistant.", - ); - expect(result.content).toBe( - "You are a helpful assistant.\n\nAlways be concise.", - ); - }); - - it("should handle empty original content", () => { - const original = new SystemMessage({ content: "" }); - const result = prependToSystemMessage(original, "New content"); - expect(result.content).toBe("New content"); - }); - - it("should handle array content by prepending as text block", () => { - const original = new SystemMessage({ - content: [{ type: "text", text: "Original content" }], - }); - const result = prependToSystemMessage(original, "Prepended content"); - expect(Array.isArray(result.content)).toBe(true); - expect((result.content as any[]).length).toBe(2); - expect((result.content as any[])[0]).toEqual({ - type: "text", - text: "Prepended content\n\n", - }); - }); -}); diff --git a/libs/deepagents/src/middleware/utils.ts b/libs/deepagents/src/middleware/utils.ts deleted file mode 100644 index 1612954d9..000000000 --- a/libs/deepagents/src/middleware/utils.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Utility functions for middleware. - * - * This module provides shared helpers used across middleware implementations. - */ - -import { SystemMessage } from "@langchain/core/messages"; - -/** - * Append text to a system message. - * - * Creates a new SystemMessage with the text appended to the existing content. - * If the original message has content, the new text is separated by two newlines. - * - * @param systemMessage - Existing system message or null/undefined. - * @param text - Text to add to the system message. - * @returns New SystemMessage with the text appended. - * - * @example - * ```typescript - * const original = new SystemMessage({ content: "You are a helpful assistant." }); - * const updated = appendToSystemMessage(original, "Always be concise."); - * // Result: SystemMessage with content "You are a helpful assistant.\n\nAlways be concise." - * ``` - */ -export function appendToSystemMessage( - systemMessage: SystemMessage | null | undefined, - text: string, -): SystemMessage { - if (!systemMessage) { - return new SystemMessage({ content: text }); - } - - // Handle both string and array content formats - const existingContent = systemMessage.content; - - if (typeof existingContent === "string") { - const newContent = existingContent ? `${existingContent}\n\n${text}` : text; - return new SystemMessage({ content: newContent }); - } - - // For array content (content blocks), append as a new text block - if (Array.isArray(existingContent)) { - const newContent = [...existingContent]; - const textToAdd = newContent.length > 0 ? `\n\n${text}` : text; - newContent.push({ type: "text", text: textToAdd }); - return new SystemMessage({ content: newContent }); - } - - // Fallback for unknown content type - return new SystemMessage({ content: text }); -} - -/** - * Prepend text to a system message. - * - * Creates a new SystemMessage with the text prepended to the existing content. - * If the original message has content, the new text is separated by two newlines. - * - * @param systemMessage - Existing system message or null/undefined. - * @param text - Text to prepend to the system message. - * @returns New SystemMessage with the text prepended. - * - * @example - * ```typescript - * const original = new SystemMessage({ content: "Always be concise." }); - * const updated = prependToSystemMessage(original, "You are a helpful assistant."); - * // Result: SystemMessage with content "You are a helpful assistant.\n\nAlways be concise." - * ``` - */ -export function prependToSystemMessage( - systemMessage: SystemMessage | null | undefined, - text: string, -): SystemMessage { - if (!systemMessage) { - return new SystemMessage({ content: text }); - } - - // Handle both string and array content formats - const existingContent = systemMessage.content; - - if (typeof existingContent === "string") { - const newContent = existingContent ? `${text}\n\n${existingContent}` : text; - return new SystemMessage({ content: newContent }); - } - - // For array content (content blocks), prepend as a new text block - if (Array.isArray(existingContent)) { - const textToAdd = existingContent.length > 0 ? `${text}\n\n` : text; - const newContent = [{ type: "text", text: textToAdd }, ...existingContent]; - return new SystemMessage({ content: newContent }); - } - - // Fallback for unknown content type - return new SystemMessage({ content: text }); -} diff --git a/libs/deepagents/src/skills/index.int.test.ts b/libs/deepagents/src/skills/index.int.test.ts deleted file mode 100644 index 745085bdc..000000000 --- a/libs/deepagents/src/skills/index.int.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { createSettings } from "../config.js"; -import { listSkills } from "./loader.js"; -import { createSkillsMiddleware } from "../middleware/skills.js"; -import { createAgentMemoryMiddleware } from "../middleware/agent-memory.js"; -import { FilesystemBackend } from "../backends/filesystem.js"; - -describe("Skills Integration Tests", () => { - let tempDir: string; - let projectDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepagents-skills-int-")); - projectDir = path.join(tempDir, "project"); - - // Create project structure - fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); - fs.mkdirSync(path.join(projectDir, ".deepagents", "skills"), { - recursive: true, - }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("Full Skills Workflow", () => { - it("should create skill, load via middleware, and inject into prompt", async () => { - // Step 1: Create a skill directory - const skillDir = path.join( - projectDir, - ".deepagents", - "skills", - "my-skill", - ); - fs.mkdirSync(skillDir, { recursive: true }); - - // Step 2: Add SKILL.md with valid frontmatter - const skillContent = `--- -name: my-skill -description: A test skill for integration testing ---- - -# My Skill - -## Instructions - -Use this skill when the user asks about integration testing. -`; - fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillContent); - - // Step 3: Create settings and middleware with new backend-agnostic API - const settings = createSettings({ startPath: projectDir }); - expect(settings.hasProject).toBe(true); - - const projectSkillsDir = settings.getProjectSkillsDir()!; - const userSkillsDir = path.join(tempDir, "user-skills"); - fs.mkdirSync(userSkillsDir, { recursive: true }); - - const middleware = createSkillsMiddleware({ - backend: new FilesystemBackend({ rootDir: "/" }), - sources: [userSkillsDir, projectSkillsDir], - }); - - // Step 4: Load skills via beforeAgent (now async) - // @ts-expect-error - typing issue in LangChain - const stateUpdate = await middleware.beforeAgent?.({}); - expect(stateUpdate!.skillsMetadata).toHaveLength(1); - expect(stateUpdate!.skillsMetadata[0].name).toBe("my-skill"); - - // Step 5: Verify skills are injected into system prompt - let capturedPrompt = ""; - const mockHandler = (req: any) => { - capturedPrompt = req.systemPrompt; - return { response: "ok" }; - }; - middleware.wrapModelCall!( - { - systemPrompt: "Base prompt", - state: stateUpdate, - } as any, - mockHandler as any, - ); - - expect(capturedPrompt).toContain("my-skill"); - expect(capturedPrompt).toContain("A test skill for integration testing"); - // Check for the new format with priority indicator - expect(capturedPrompt).toContain("(higher priority)"); - }); - - it("should allow project skill to override user skill with same name", async () => { - // Create user skills directory - const userSkillsDir = path.join(tempDir, "user-skills"); - fs.mkdirSync(path.join(userSkillsDir, "shared-skill"), { - recursive: true, - }); - - // Create user skill - const userSkillContent = `--- -name: shared-skill -description: User version of shared skill ---- - -# User Version -`; - fs.writeFileSync( - path.join(userSkillsDir, "shared-skill", "SKILL.md"), - userSkillContent, - ); - - // Create project skill with same name - const projectSkillDir = path.join( - projectDir, - ".deepagents", - "skills", - "shared-skill", - ); - fs.mkdirSync(projectSkillDir, { recursive: true }); - - const projectSkillContent = `--- -name: shared-skill -description: Project version of shared skill ---- - -# Project Version -`; - fs.writeFileSync( - path.join(projectSkillDir, "SKILL.md"), - projectSkillContent, - ); - - // Load skills - const skills = listSkills({ - userSkillsDir, - projectSkillsDir: path.join(projectDir, ".deepagents", "skills"), - }); - - expect(skills).toHaveLength(1); - expect(skills[0].name).toBe("shared-skill"); - expect(skills[0].source).toBe("project"); - expect(skills[0].description).toBe("Project version of shared skill"); - }); - }); - - describe("Skills and Memory Middleware Together", () => { - it("should work together without conflicts", async () => { - // Create skill - const skillDir = path.join( - projectDir, - ".deepagents", - "skills", - "test-skill", - ); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - `--- -name: test-skill -description: Test skill ---- - -# Test Skill -`, - ); - - // Create project memory - fs.writeFileSync( - path.join(projectDir, ".deepagents", "agent.md"), - "# Project Memory\n\nTest project memory content.", - ); - - // Create settings pointing to temp dir for user files - const userDeepagentsDir = path.join(tempDir, ".deepagents"); - const userAgentDir = path.join(userDeepagentsDir, "test-agent"); - fs.mkdirSync(userAgentDir, { recursive: true }); - fs.writeFileSync( - path.join(userAgentDir, "agent.md"), - "# User Memory\n\nTest user memory content.", - ); - - // Create mock settings - const mockSettings = { - projectRoot: projectDir, - userDeepagentsDir, - hasProject: true, - getAgentDir: (name: string) => path.join(userDeepagentsDir, name), - ensureAgentDir: (name: string) => { - const dir = path.join(userDeepagentsDir, name); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - getUserAgentMdPath: (name: string) => - path.join(userDeepagentsDir, name, "agent.md"), - getProjectAgentMdPath: () => - path.join(projectDir, ".deepagents", "agent.md"), - getUserSkillsDir: (name: string) => - path.join(userDeepagentsDir, name, "skills"), - ensureUserSkillsDir: (name: string) => { - const dir = path.join(userDeepagentsDir, name, "skills"); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - getProjectSkillsDir: () => - path.join(projectDir, ".deepagents", "skills"), - ensureProjectSkillsDir: () => - path.join(projectDir, ".deepagents", "skills"), - ensureProjectDeepagentsDir: () => path.join(projectDir, ".deepagents"), - }; - - // Create user skills directory - const userSkillsDir = path.join( - userDeepagentsDir, - "test-agent", - "skills", - ); - fs.mkdirSync(userSkillsDir, { recursive: true }); - - // Create both middleware using new backend-agnostic API - const skillsMiddleware = createSkillsMiddleware({ - backend: new FilesystemBackend({ rootDir: "/" }), - sources: [userSkillsDir, mockSettings.getProjectSkillsDir()], - }); - - const memoryMiddleware = createAgentMemoryMiddleware({ - settings: mockSettings, - assistantId: "test-agent", - }); - - // Run beforeAgent for both (skills is now async) - // @ts-expect-error - typing issue in LangChain - const skillsState = await skillsMiddleware.beforeAgent?.({}); - // @ts-expect-error - typing issue in LangChain - const memoryState = memoryMiddleware.beforeAgent?.({}); - - // Combine states - const combinedState = { ...skillsState, ...memoryState }; - - // Run wrapModelCall for both in sequence - let finalPrompt = ""; - - // First, memory middleware - memoryMiddleware.wrapModelCall!( - { - systemPrompt: "Base prompt", - state: combinedState, - } as any, - ((req: any) => { - // Then, skills middleware - skillsMiddleware.wrapModelCall!( - { - systemPrompt: req.systemPrompt, - state: combinedState, - } as any, - ((innerReq: any) => { - finalPrompt = innerReq.systemPrompt; - return { response: "ok" }; - }) as any, - ); - return { response: "ok" }; - }) as any, - ); - - // Verify both are present - expect(finalPrompt).toContain("Base prompt"); - expect(finalPrompt).toContain("test-skill"); - expect(finalPrompt).toContain("Test user memory content"); - expect(finalPrompt).toContain("Test project memory content"); - expect(finalPrompt).toContain("Skills System"); - expect(finalPrompt).toContain("Long-term Memory"); - }); - }); - - describe("Example Skills Loading", () => { - it("should load example skills from examples directory", () => { - const examplesDir = path.join(process.cwd(), "examples", "skills"); - const skills = listSkills({ projectSkillsDir: examplesDir }); - - // Should find the example skills we created - const skillNames = skills.map((s) => s.name); - - if (skillNames.length > 0) { - // Check that example skills are valid - for (const skill of skills) { - expect(skill.name).toBeTruthy(); - expect(skill.description).toBeTruthy(); - expect(skill.path).toContain("SKILL.md"); - } - } - }); - }); -}); diff --git a/libs/deepagents/src/skills/index.ts b/libs/deepagents/src/skills/index.ts deleted file mode 100644 index 13c5c70a0..000000000 --- a/libs/deepagents/src/skills/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Skills module for deepagents. - * - * Public API: - * - listSkills: List skills from user and/or project directories - * - parseSkillMetadata: Parse metadata from a single SKILL.md file - * - SkillMetadata: Type for skill metadata - * - ListSkillsOptions: Type for listSkills options - */ - -export { - listSkills, - parseSkillMetadata, - MAX_SKILL_FILE_SIZE, - MAX_SKILL_NAME_LENGTH, - MAX_SKILL_DESCRIPTION_LENGTH, -} from "./loader.js"; - -export type { SkillMetadata, ListSkillsOptions } from "./loader.js"; diff --git a/libs/deepagents/src/skills/loader.test.ts b/libs/deepagents/src/skills/loader.test.ts deleted file mode 100644 index 27d796a24..000000000 --- a/libs/deepagents/src/skills/loader.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { listSkills, parseSkillMetadata } from "./loader.js"; - -describe("Skill Loader Module", () => { - let tempDir: string; - let userSkillsDir: string; - let projectSkillsDir: string; - - beforeEach(() => { - // Create temporary directories for testing - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepagents-skills-test-")); - userSkillsDir = path.join(tempDir, "user-skills"); - projectSkillsDir = path.join(tempDir, "project-skills"); - fs.mkdirSync(userSkillsDir, { recursive: true }); - fs.mkdirSync(projectSkillsDir, { recursive: true }); - }); - - afterEach(() => { - // Cleanup temp directory - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - /** - * Helper to create a skill directory with SKILL.md - */ - function createSkill( - baseDir: string, - skillName: string, - frontmatter: Record, - body = "# Skill Instructions\n\nUse this skill when...", - ): string { - const skillDir = path.join(baseDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - const yamlContent = Object.entries(frontmatter) - .map(([key, value]) => `${key}: ${value}`) - .join("\n"); - - const skillMd = `---\n${yamlContent}\n---\n\n${body}`; - fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillMd); - - return skillDir; - } - - describe("parseSkillMetadata", () => { - it("should parse valid skill with frontmatter", () => { - createSkill(userSkillsDir, "web-research", { - name: "web-research", - description: "Research the web for information", - }); - - const result = parseSkillMetadata( - path.join(userSkillsDir, "web-research", "SKILL.md"), - "user", - ); - - expect(result).not.toBeNull(); - expect(result!.name).toBe("web-research"); - expect(result!.description).toBe("Research the web for information"); - expect(result!.source).toBe("user"); - expect(result!.path).toContain(path.join("web-research", "SKILL.md")); - }); - - it("should return null for missing frontmatter", () => { - const skillDir = path.join(userSkillsDir, "no-frontmatter"); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - "# No Frontmatter\n\nJust content", - ); - - const result = parseSkillMetadata( - path.join(skillDir, "SKILL.md"), - "user", - ); - - expect(result).toBeNull(); - }); - - it("should return null when name is missing", () => { - createSkill(userSkillsDir, "missing-name", { - description: "A skill without a name", - }); - - const result = parseSkillMetadata( - path.join(userSkillsDir, "missing-name", "SKILL.md"), - "user", - ); - - expect(result).toBeNull(); - }); - - it("should return null when description is missing", () => { - createSkill(userSkillsDir, "missing-desc", { - name: "missing-desc", - }); - - const result = parseSkillMetadata( - path.join(userSkillsDir, "missing-desc", "SKILL.md"), - "user", - ); - - expect(result).toBeNull(); - }); - - it("should parse optional fields", () => { - const skillDir = path.join(userSkillsDir, "full-skill"); - fs.mkdirSync(skillDir, { recursive: true }); - - const skillMd = `--- -name: full-skill -description: A skill with all fields -license: MIT -compatibility: Node.js 18+ -allowed-tools: read_file write_file ---- - -# Full Skill -`; - fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillMd); - - const result = parseSkillMetadata( - path.join(skillDir, "SKILL.md"), - "project", - ); - - expect(result).not.toBeNull(); - expect(result!.license).toBe("MIT"); - expect(result!.compatibility).toBe("Node.js 18+"); - expect(result!.allowedTools).toBe("read_file write_file"); - expect(result!.source).toBe("project"); - }); - - it("should truncate long descriptions", () => { - const longDesc = "A".repeat(2000); - createSkill(userSkillsDir, "long-desc", { - name: "long-desc", - description: longDesc, - }); - - const result = parseSkillMetadata( - path.join(userSkillsDir, "long-desc", "SKILL.md"), - "user", - ); - - expect(result).not.toBeNull(); - expect(result!.description.length).toBe(1024); - }); - - it("should warn but parse when name doesn't match directory", () => { - createSkill(userSkillsDir, "my-skill", { - name: "different-name", - description: "Name doesn't match directory", - }); - - // Should still parse, just with a warning - const result = parseSkillMetadata( - path.join(userSkillsDir, "my-skill", "SKILL.md"), - "user", - ); - - expect(result).not.toBeNull(); - expect(result!.name).toBe("different-name"); - }); - }); - - describe("listSkills", () => { - it("should return empty array for empty directory", () => { - const result = listSkills({ userSkillsDir }); - expect(result).toEqual([]); - }); - - it("should return empty array for non-existent directory", () => { - const result = listSkills({ - userSkillsDir: "/non/existent/path", - }); - expect(result).toEqual([]); - }); - - it("should list skills from user directory", () => { - createSkill(userSkillsDir, "skill-one", { - name: "skill-one", - description: "First skill", - }); - createSkill(userSkillsDir, "skill-two", { - name: "skill-two", - description: "Second skill", - }); - - const result = listSkills({ userSkillsDir }); - - expect(result).toHaveLength(2); - expect(result.map((s) => s.name).sort()).toEqual([ - "skill-one", - "skill-two", - ]); - expect(result.every((s) => s.source === "user")).toBe(true); - }); - - it("should list skills from project directory", () => { - createSkill(projectSkillsDir, "project-skill", { - name: "project-skill", - description: "A project-specific skill", - }); - - const result = listSkills({ projectSkillsDir }); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe("project-skill"); - expect(result[0].source).toBe("project"); - }); - - it("should merge user and project skills", () => { - createSkill(userSkillsDir, "user-skill", { - name: "user-skill", - description: "User skill", - }); - createSkill(projectSkillsDir, "project-skill", { - name: "project-skill", - description: "Project skill", - }); - - const result = listSkills({ userSkillsDir, projectSkillsDir }); - - expect(result).toHaveLength(2); - expect(result.find((s) => s.name === "user-skill")?.source).toBe("user"); - expect(result.find((s) => s.name === "project-skill")?.source).toBe( - "project", - ); - }); - - it("should allow project skills to override user skills with same name", () => { - createSkill(userSkillsDir, "shared-skill", { - name: "shared-skill", - description: "User version", - }); - createSkill(projectSkillsDir, "shared-skill", { - name: "shared-skill", - description: "Project version", - }); - - const result = listSkills({ userSkillsDir, projectSkillsDir }); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe("shared-skill"); - expect(result[0].source).toBe("project"); - expect(result[0].description).toBe("Project version"); - }); - - it("should skip directories without SKILL.md", () => { - // Create valid skill - createSkill(userSkillsDir, "valid-skill", { - name: "valid-skill", - description: "Has SKILL.md", - }); - - // Create directory without SKILL.md - const invalidDir = path.join(userSkillsDir, "invalid-skill"); - fs.mkdirSync(invalidDir, { recursive: true }); - fs.writeFileSync(path.join(invalidDir, "README.md"), "# Not a skill"); - - const result = listSkills({ userSkillsDir }); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe("valid-skill"); - }); - - it("should skip skills with invalid frontmatter", () => { - // Create valid skill - createSkill(userSkillsDir, "valid-skill", { - name: "valid-skill", - description: "Valid skill", - }); - - // Create skill with invalid frontmatter - const invalidDir = path.join(userSkillsDir, "invalid-skill"); - fs.mkdirSync(invalidDir, { recursive: true }); - fs.writeFileSync( - path.join(invalidDir, "SKILL.md"), - "---\ninvalid: yaml: format:\n---\n", - ); - - const result = listSkills({ userSkillsDir }); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe("valid-skill"); - }); - - it("should skip non-directory entries", () => { - createSkill(userSkillsDir, "valid-skill", { - name: "valid-skill", - description: "Valid skill", - }); - - // Create a file (not directory) in skills dir - fs.writeFileSync(path.join(userSkillsDir, "not-a-dir.txt"), "content"); - - const result = listSkills({ userSkillsDir }); - - expect(result).toHaveLength(1); - }); - - it("should handle mixed valid and invalid skills", () => { - // Valid skill - createSkill(userSkillsDir, "valid-one", { - name: "valid-one", - description: "First valid", - }); - - // Invalid - no description - createSkill(userSkillsDir, "invalid-one", { - name: "invalid-one", - }); - - // Valid skill - createSkill(userSkillsDir, "valid-two", { - name: "valid-two", - description: "Second valid", - }); - - const result = listSkills({ userSkillsDir }); - - expect(result).toHaveLength(2); - expect(result.map((s) => s.name).sort()).toEqual([ - "valid-one", - "valid-two", - ]); - }); - - it("should load user skills first then project skills", () => { - createSkill(userSkillsDir, "alpha", { - name: "alpha", - description: "User alpha", - }); - createSkill(projectSkillsDir, "beta", { - name: "beta", - description: "Project beta", - }); - - const result = listSkills({ userSkillsDir, projectSkillsDir }); - - // Should contain both - expect(result).toHaveLength(2); - }); - }); -}); diff --git a/libs/deepagents/src/skills/loader.ts b/libs/deepagents/src/skills/loader.ts deleted file mode 100644 index 9a870d684..000000000 --- a/libs/deepagents/src/skills/loader.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Skill loader for parsing and loading agent skills from SKILL.md files. - * - * This module implements Anthropic's agent skills pattern with YAML frontmatter parsing. - * Each skill is a directory containing a SKILL.md file with: - * - YAML frontmatter (name, description required) - * - Markdown instructions for the agent - * - Optional supporting files (scripts, configs, etc.) - * - * @example - * ```markdown - * --- - * name: web-research - * description: Structured approach to conducting thorough web research - * --- - * - * # Web Research Skill - * - * ## When to Use - * - User asks you to research a topic - * ... - * ``` - * - * @see https://agentskills.io/specification - */ - -import fs from "node:fs"; -import path from "node:path"; -import yaml from "yaml"; - -/** Maximum size for SKILL.md files (10MB) */ -export const MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024; - -/** Agent Skills spec constraints */ -export const MAX_SKILL_NAME_LENGTH = 64; -export const MAX_SKILL_DESCRIPTION_LENGTH = 1024; - -/** Pattern for validating skill names per Agent Skills spec */ -const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; - -/** Pattern for extracting YAML frontmatter */ -const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*\n/; - -/** - * Metadata for a skill per Agent Skills spec. - * @see https://agentskills.io/specification - */ -export interface SkillMetadata { - /** Name of the skill (max 64 chars, lowercase alphanumeric and hyphens) */ - name: string; - - /** Description of what the skill does (max 1024 chars) */ - description: string; - - /** Absolute path to the SKILL.md file */ - path: string; - - /** Source of the skill ('user' or 'project') */ - source: "user" | "project"; - - /** Optional: License name or reference to bundled license file */ - license?: string; - - /** Optional: Environment requirements (max 500 chars) */ - compatibility?: string; - - /** Optional: Arbitrary key-value mapping for additional metadata */ - metadata?: Record; - - /** Optional: Space-delimited list of pre-approved tools */ - allowedTools?: string; -} - -/** - * Options for listing skills. - */ -export interface ListSkillsOptions { - /** Path to user-level skills directory */ - userSkillsDir?: string | null; - - /** Path to project-level skills directory */ - projectSkillsDir?: string | null; -} - -/** - * Result of skill name validation. - */ -interface ValidationResult { - valid: boolean; - error?: string; -} - -/** - * Check if a path is safely contained within base_dir. - * - * This prevents directory traversal attacks via symlinks or path manipulation. - * The function resolves both paths to their canonical form (following symlinks) - * and verifies that the target path is within the base directory. - * - * @param targetPath - The path to validate - * @param baseDir - The base directory that should contain the path - * @returns True if the path is safely within baseDir, false otherwise - */ -function isSafePath(targetPath: string, baseDir: string): boolean { - try { - // Resolve both paths to their canonical form (follows symlinks) - const resolvedPath = fs.realpathSync(targetPath); - const resolvedBase = fs.realpathSync(baseDir); - - // Check if the resolved path is within the base directory - return ( - resolvedPath.startsWith(resolvedBase + path.sep) || - resolvedPath === resolvedBase - ); - } catch { - // Error resolving paths (e.g., circular symlinks, too many levels) - return false; - } -} - -/** - * Validate skill name per Agent Skills spec. - * - * Requirements: - * - Max 64 characters - * - Lowercase alphanumeric and hyphens only (a-z, 0-9, -) - * - Cannot start or end with hyphen - * - No consecutive hyphens - * - Must match parent directory name - * - * @param name - The skill name from YAML frontmatter - * @param directoryName - The parent directory name - * @returns Validation result with error message if invalid - */ -function validateSkillName( - name: string, - directoryName: string, -): ValidationResult { - if (!name) { - return { valid: false, error: "name is required" }; - } - if (name.length > MAX_SKILL_NAME_LENGTH) { - return { valid: false, error: "name exceeds 64 characters" }; - } - // Pattern: lowercase alphanumeric, single hyphens between segments, no start/end hyphen - if (!SKILL_NAME_PATTERN.test(name)) { - return { - valid: false, - error: "name must be lowercase alphanumeric with single hyphens only", - }; - } - if (name !== directoryName) { - return { - valid: false, - error: `name '${name}' must match directory name '${directoryName}'`, - }; - } - return { valid: true }; -} - -/** - * Parse YAML frontmatter from content. - * - * @param content - The file content - * @returns Parsed frontmatter object, or null if parsing fails - */ -function parseFrontmatter(content: string): Record | null { - const match = content.match(FRONTMATTER_PATTERN); - if (!match) { - return null; - } - - try { - const parsed = yaml.parse(match[1]); - return typeof parsed === "object" && parsed !== null ? parsed : null; - } catch { - return null; - } -} - -/** - * Parse YAML frontmatter from a SKILL.md file per Agent Skills spec. - * - * @param skillMdPath - Path to the SKILL.md file - * @param source - Source of the skill ('user' or 'project') - * @returns SkillMetadata with all fields, or null if parsing fails - */ -export function parseSkillMetadata( - skillMdPath: string, - source: "user" | "project", -): SkillMetadata | null { - try { - // Security: Check file size to prevent DoS attacks - const stats = fs.statSync(skillMdPath); - if (stats.size > MAX_SKILL_FILE_SIZE) { - // eslint-disable-next-line no-console - console.warn( - `Skipping ${skillMdPath}: file too large (${stats.size} bytes)`, - ); - return null; - } - - const content = fs.readFileSync(skillMdPath, "utf-8"); - const frontmatter = parseFrontmatter(content); - - if (!frontmatter) { - // eslint-disable-next-line no-console - console.warn(`Skipping ${skillMdPath}: no valid YAML frontmatter found`); - return null; - } - - // Validate required fields - const name = frontmatter.name; - const description = frontmatter.description; - - if (!name || !description) { - // eslint-disable-next-line no-console - console.warn( - `Skipping ${skillMdPath}: missing required 'name' or 'description'`, - ); - return null; - } - - // Validate name format per spec (warn but still load for backwards compatibility) - const directoryName = path.basename(path.dirname(skillMdPath)); - const validation = validateSkillName(String(name), directoryName); - if (!validation.valid) { - // eslint-disable-next-line no-console - console.warn( - `Skill '${name}' in ${skillMdPath} does not follow Agent Skills spec: ${validation.error}. ` + - "Consider renaming to be spec-compliant.", - ); - } - - // Truncate description if too long (spec: max 1024 chars) - let descriptionStr = String(description); - if (descriptionStr.length > MAX_SKILL_DESCRIPTION_LENGTH) { - // eslint-disable-next-line no-console - console.warn( - `Description exceeds ${MAX_SKILL_DESCRIPTION_LENGTH} chars in ${skillMdPath}, truncating`, - ); - descriptionStr = descriptionStr.slice(0, MAX_SKILL_DESCRIPTION_LENGTH); - } - - return { - name: String(name), - description: descriptionStr, - path: skillMdPath, - source, - license: frontmatter.license ? String(frontmatter.license) : undefined, - compatibility: frontmatter.compatibility - ? String(frontmatter.compatibility) - : undefined, - metadata: - frontmatter.metadata && typeof frontmatter.metadata === "object" - ? (frontmatter.metadata as Record) - : undefined, - allowedTools: frontmatter["allowed-tools"] - ? String(frontmatter["allowed-tools"]) - : undefined, - }; - } catch (error) { - // eslint-disable-next-line no-console - console.warn(`Error reading ${skillMdPath}: ${error}`); - return null; - } -} - -/** - * List all skills from a single skills directory (internal helper). - * - * Scans the skills directory for subdirectories containing SKILL.md files, - * parses YAML frontmatter, and returns skill metadata. - * - * Skills are organized as: - * ``` - * skills/ - * ├── skill-name/ - * │ ├── SKILL.md # Required: instructions with YAML frontmatter - * │ ├── script.py # Optional: supporting files - * │ └── config.json # Optional: supporting files - * ``` - * - * @param skillsDir - Path to the skills directory - * @param source - Source of the skills ('user' or 'project') - * @returns List of skill metadata - */ -function listSkillsFromDir( - skillsDir: string, - source: "user" | "project", -): SkillMetadata[] { - // Check if skills directory exists - const expandedDir = skillsDir.startsWith("~") - ? path.join( - process.env.HOME || process.env.USERPROFILE || "", - skillsDir.slice(1), - ) - : skillsDir; - - if (!fs.existsSync(expandedDir)) { - return []; - } - - // Resolve base directory to canonical path for security checks - let resolvedBase: string; - try { - resolvedBase = fs.realpathSync(expandedDir); - } catch { - // Can't resolve base directory, fail safe - return []; - } - - const skills: SkillMetadata[] = []; - - // Iterate through subdirectories - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(resolvedBase, { withFileTypes: true }); - } catch { - return []; - } - - for (const entry of entries) { - const skillDir = path.join(resolvedBase, entry.name); - - // Security: Catch symlinks pointing outside the skills directory - if (!isSafePath(skillDir, resolvedBase)) { - continue; - } - - if (!entry.isDirectory()) { - continue; - } - - // Look for SKILL.md file - const skillMdPath = path.join(skillDir, "SKILL.md"); - if (!fs.existsSync(skillMdPath)) { - continue; - } - - // Security: Validate SKILL.md path is safe before reading - if (!isSafePath(skillMdPath, resolvedBase)) { - continue; - } - - // Parse metadata - const metadata = parseSkillMetadata(skillMdPath, source); - if (metadata) { - skills.push(metadata); - } - } - - return skills; -} - -/** - * List skills from user and/or project directories. - * - * When both directories are provided, project skills with the same name as - * user skills will override them. - * - * @param options - Options specifying which directories to search - * @returns Merged list of skill metadata from both sources, with project skills - * taking precedence over user skills when names conflict - */ -export function listSkills(options: ListSkillsOptions): SkillMetadata[] { - const allSkills: Map = new Map(); - - // Load user skills first (foundation) - if (options.userSkillsDir) { - const userSkills = listSkillsFromDir(options.userSkillsDir, "user"); - for (const skill of userSkills) { - allSkills.set(skill.name, skill); - } - } - - // Load project skills second (override/augment) - if (options.projectSkillsDir) { - const projectSkills = listSkillsFromDir( - options.projectSkillsDir, - "project", - ); - for (const skill of projectSkills) { - // Project skills override user skills with the same name - allSkills.set(skill.name, skill); - } - } - - return Array.from(allSkills.values()); -} diff --git a/libs/deepagents/src/testing/utils.ts b/libs/deepagents/src/testing/utils.ts deleted file mode 100644 index 3999dde4a..000000000 --- a/libs/deepagents/src/testing/utils.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { - tool, - createMiddleware, - ReactAgent, - StructuredTool, - ToolMessage, - type AgentMiddleware as _AgentMiddleware, -} from "langchain"; -import { Command } from "@langchain/langgraph"; -import { z } from "zod/v4"; - -/** - * required for type inference - */ -import type * as _zodTypes from "@langchain/core/utils/types"; -import type * as _zodMeta from "@langchain/langgraph/zod"; -import type * as _messages from "@langchain/core/messages"; -import type * as _tools from "@langchain/core/tools"; - -/** - * Assert that an agent has all the expected deep agent qualities - * Accepts any object with a graph property (compatible with ReactAgent and DeepAgent types) - */ -export function assertAllDeepAgentQualities(agent: { - graph: ReactAgent["graph"]; -}) { - // Check state channels - const channels = Object.keys(agent.graph?.channels || {}); - if (!channels.includes("todos")) { - throw new Error( - `Expected agent to have 'todos' channel, got: ${channels.join(", ")}`, - ); - } - if (!channels.includes("files")) { - throw new Error( - `Expected agent to have 'files' channel, got: ${channels.join(", ")}`, - ); - } - - // Check tools - const tools = (agent as any).graph?.nodes?.tools?.bound?.tools || []; - const toolNames = tools.map((t: any) => t.name); - - const expectedTools = [ - "write_todos", - "ls", - "read_file", - "write_file", - "edit_file", - "task", - ]; - for (const toolName of expectedTools) { - if (!toolNames.includes(toolName)) { - throw new Error( - `Expected agent to have '${toolName}' tool, got: ${toolNames.join(", ")}`, - ); - } - } -} - -/** - * Constants - */ -export const SAMPLE_MODEL = "claude-sonnet-4-5-20250929"; - -/** - * Mock tools for testing - */ - -export const getPremierLeagueStandings = tool( - async (_, config) => { - const longToolMsg = - "This is a long tool message that should be evicted to the filesystem.\n".repeat( - 300, - ); - return new Command({ - update: { - messages: [ - new ToolMessage({ - content: longToolMsg, - tool_call_id: config.toolCall?.id as string, - }), - ], - files: { - "/test.txt": { - content: ["Goodbye world"], - created_at: "2021-01-01", - modified_at: "2021-01-01", - }, - }, - }, - }); - }, - { - name: "get_premier_league_standings", - description: "Use this tool to get premier league standings", - schema: z.object({}), - }, -); - -export const getLaLigaStandings = tool( - async (_, config) => { - const longToolMsg = - "This is a long tool message that should be evicted to the filesystem.\n".repeat( - 300, - ); - return new Command({ - update: { - messages: [ - new ToolMessage({ - content: longToolMsg, - tool_call_id: config.toolCall?.id as string, - }), - ], - }, - }); - }, - { - name: "get_la_liga_standings", - description: "Use this tool to get la liga standings", - schema: z.object({}), - }, -); - -export const getNbaStandings = tool( - () => { - return "Sample text that is too long to fit in the token limit\n".repeat( - 10000, - ); - }, - { - name: "get_nba_standings", - description: - "Use this tool to get a comprehensive report on the NBA standings", - schema: z.object({}), - }, -); - -export const getNflStandings = tool( - () => { - return "Sample text that is too long to fit in the token limit\n".repeat( - 100, - ); - }, - { - name: "get_nfl_standings", - description: - "Use this tool to get a comprehensive report on the NFL standings", - schema: z.object({}), - }, -); - -export const getWeather = tool( - (input) => `The weather in ${input.location} is sunny.`, - { - name: "get_weather", - description: "Use this tool to get the weather", - schema: z.object({ location: z.string() }), - }, -); - -export const getSoccerScores = tool( - (input) => `The latest soccer scores for ${input.team} are 2-1.`, - { - name: "get_soccer_scores", - description: "Use this tool to get the latest soccer scores", - schema: z.object({ - team: z.string(), - }), - }, -); - -export const sampleTool = tool((input) => input.sample_input, { - name: "sample_tool", - description: "Sample tool", - schema: z.object({ - sample_input: z.string(), - }), -}); - -export const TOY_BASKETBALL_RESEARCH = - "Lebron James is the best basketball player of all time with over 40k points and 21 seasons in the NBA."; - -export const researchBasketball = tool( - async (input, config) => { - const state = (config as any).state || {}; - const currentResearch = state.research || ""; - const research = `${currentResearch}\n\nResearching on ${input.topic}... Done! ${TOY_BASKETBALL_RESEARCH}`; - return new Command({ - update: { - research, - messages: [ - new ToolMessage({ - content: research, - tool_call_id: config.toolCall?.id as string, - }), - ], - }, - }); - }, - { - name: "research_basketball", - description: - "Use this tool to conduct research into basketball and save it to state", - schema: z.object({ topic: z.string() }), - }, -); - -/** - * Middleware classes for testing - */ - -// Research state -const ResearchStateSchema = z.object({ - research: z - .string() - .default("") - .meta({ - reducer: { - fn: (left: string, right: string | null) => right || left || "", - schema: z.string().nullable(), - }, - }), -}); - -export const ResearchMiddleware = createMiddleware({ - name: "ResearchMiddleware", - stateSchema: ResearchStateSchema, -}); - -export const ResearchMiddlewareWithTools = createMiddleware({ - name: "ResearchMiddlewareWithTools", - stateSchema: ResearchStateSchema, - tools: [researchBasketball], -}); - -export const SampleMiddlewareWithTools = createMiddleware({ - name: "SampleMiddlewareWithTools", - tools: [sampleTool], -}); - -// Sample state -const SampleStateSchema = z.object({ - sample_input: z - .string() - .default("") - .meta({ - reducer: { - fn: (left: string, right: string | null) => right || left || "", - schema: z.string().nullable(), - }, - }), -}); - -export const SampleMiddlewareWithToolsAndState = createMiddleware({ - name: "SampleMiddlewareWithToolsAndState", - stateSchema: SampleStateSchema, - tools: [sampleTool], -}); - -export const WeatherToolMiddleware = createMiddleware({ - name: "WeatherToolMiddleware", - tools: [getWeather], -}); - -export function extractToolsFromAgent(agent: { - graph: ReactAgent["graph"]; -}) { - const graph = agent.graph; - const toolsNode = graph.nodes?.tools.bound as unknown as { - tools: StructuredTool[]; - }; - - return Object.fromEntries( - (toolsNode.tools ?? []).map((tool) => [tool.name, tool]), - ); -} diff --git a/libs/deepagents/src/types.ts b/libs/deepagents/src/types.ts deleted file mode 100644 index f41d58ae5..000000000 --- a/libs/deepagents/src/types.ts +++ /dev/null @@ -1,389 +0,0 @@ -import type { - AgentMiddleware, - InterruptOnConfig, - ReactAgent, - CreateAgentParams as _CreateAgentParams, - AgentTypeConfig, - InferMiddlewareStates, - ResponseFormat, - SystemMessage, - ResponseFormatUndefined, -} from "langchain"; -import type { - ClientTool, - ServerTool, - StructuredTool, -} from "@langchain/core/tools"; -import type { BaseLanguageModel } from "@langchain/core/language_models/base"; -import type { - BaseCheckpointSaver, - BaseStore, -} from "@langchain/langgraph-checkpoint"; - -import type { SubAgent } from "./middleware/index.js"; -import type { BackendProtocol } from "./backends/index.js"; -import type { InteropZodObject } from "@langchain/core/utils/types"; -import type { AnnotationRoot } from "@langchain/langgraph"; -import type { CompiledSubAgent } from "./middleware/subagents.js"; - -// LangChain uses AnyAnnotationRoot internally but doesn't export it -// We use AnnotationRoot as a compatible equivalent -type AnyAnnotationRoot = AnnotationRoot; - -/** - * Helper type to extract middleware from a SubAgent definition - * Handles both mutable and readonly middleware arrays - */ -export type ExtractSubAgentMiddleware = T extends { middleware?: infer M } - ? M extends readonly AgentMiddleware[] - ? M - : M extends AgentMiddleware[] - ? M - : readonly [] - : readonly []; - -/** - * Helper type to flatten and merge middleware from all subagents - */ -export type FlattenSubAgentMiddleware< - T extends readonly (SubAgent | CompiledSubAgent)[], -> = T extends readonly [] - ? readonly [] - : T extends readonly [infer First, ...infer Rest] - ? Rest extends readonly (SubAgent | CompiledSubAgent)[] - ? readonly [ - ...ExtractSubAgentMiddleware, - ...FlattenSubAgentMiddleware, - ] - : ExtractSubAgentMiddleware - : readonly []; - -/** - * Helper type to merge states from subagent middleware - */ -export type InferSubAgentMiddlewareStates< - T extends readonly (SubAgent | CompiledSubAgent)[], -> = InferMiddlewareStates>; - -/** - * Combined state type including custom middleware and subagent middleware states - */ -export type MergedDeepAgentState< - TMiddleware extends readonly AgentMiddleware[], - TSubagents extends readonly (SubAgent | CompiledSubAgent)[], -> = InferMiddlewareStates & - InferSubAgentMiddlewareStates; - -/** - * Type bag that extends AgentTypeConfig with subagent type information. - * - * This interface bundles all the generic type parameters used throughout the deep agent system - * including subagent types for type-safe streaming and delegation. - * - * @typeParam TResponse - The structured response type when using `responseFormat`. - * @typeParam TState - The custom state schema type. - * @typeParam TContext - The context schema type. - * @typeParam TMiddleware - The middleware array type. - * @typeParam TTools - The combined tools type. - * @typeParam TSubagents - The subagents array type for type-safe streaming. - * - * @example - * ```typescript - * const agent = createDeepAgent({ - * middleware: [ResearchMiddleware], - * subagents: [ - * { name: "researcher", description: "...", middleware: [CounterMiddleware] } - * ] as const, - * }); - * - * // Type inference for streaming - * type Types = InferDeepAgentType; - * ``` - */ -export interface DeepAgentTypeConfig< - TResponse extends Record | ResponseFormatUndefined = - | Record - | ResponseFormatUndefined, - TState extends AnyAnnotationRoot | InteropZodObject | undefined = - | AnyAnnotationRoot - | InteropZodObject - | undefined, - TContext extends AnyAnnotationRoot | InteropZodObject = - | AnyAnnotationRoot - | InteropZodObject, - TMiddleware extends readonly AgentMiddleware[] = readonly AgentMiddleware[], - TTools extends readonly (ClientTool | ServerTool)[] = readonly ( - | ClientTool - | ServerTool - )[], - TSubagents extends readonly (SubAgent | CompiledSubAgent)[] = readonly ( - | SubAgent - | CompiledSubAgent - )[], -> extends AgentTypeConfig { - /** The subagents array type for type-safe streaming */ - Subagents: TSubagents; -} - -/** - * Default type configuration for deep agents. - * Used when no explicit type parameters are provided. - */ -export interface DefaultDeepAgentTypeConfig extends DeepAgentTypeConfig { - Response: Record; - State: undefined; - Context: AnyAnnotationRoot; - Middleware: readonly AgentMiddleware[]; - Tools: readonly (ClientTool | ServerTool)[]; - Subagents: readonly (SubAgent | CompiledSubAgent)[]; -} - -/** - * DeepAgent extends ReactAgent with additional subagent type information. - * - * This type wraps ReactAgent but includes the DeepAgentTypeConfig which - * contains subagent types for type-safe streaming and delegation. - * - * @typeParam TTypes - The DeepAgentTypeConfig containing all type parameters - * - * @example - * ```typescript - * const agent: DeepAgent> = createDeepAgent({ ... }); - * - * // Access subagent types for streaming - * type Subagents = InferDeepAgentSubagents; - * ``` - */ -export type DeepAgent< - TTypes extends DeepAgentTypeConfig = DeepAgentTypeConfig, -> = ReactAgent & { - /** Type brand for DeepAgent type inference */ - readonly "~deepAgentTypes": TTypes; -}; - -/** - * Helper type to resolve a DeepAgentTypeConfig from either: - * - A DeepAgentTypeConfig directly - * - A DeepAgent instance (using `typeof agent`) - * - * @example - * ```typescript - * const agent = createDeepAgent({ ... }); - * type Types = ResolveDeepAgentTypeConfig; - * ``` - */ -export type ResolveDeepAgentTypeConfig = T extends { - "~deepAgentTypes": infer Types; -} - ? Types extends DeepAgentTypeConfig - ? Types - : never - : T extends DeepAgentTypeConfig - ? T - : never; - -/** - * Helper type to extract any property from a DeepAgentTypeConfig or DeepAgent. - * - * @typeParam T - The DeepAgentTypeConfig or DeepAgent to extract from - * @typeParam K - The property key to extract - * - * @example - * ```typescript - * const agent = createDeepAgent({ subagents: [...] }); - * type Subagents = InferDeepAgentType; - * ``` - */ -export type InferDeepAgentType< - T, - K extends keyof DeepAgentTypeConfig, -> = ResolveDeepAgentTypeConfig[K]; - -/** - * Shorthand helper to extract the Subagents type from a DeepAgentTypeConfig or DeepAgent. - * - * @example - * ```typescript - * const agent = createDeepAgent({ subagents: [subagent1, subagent2] }); - * type Subagents = InferDeepAgentSubagents; - * ``` - */ -export type InferDeepAgentSubagents = InferDeepAgentType; - -/** - * Helper type to extract CompiledSubAgent (subagents with `runnable`) from a DeepAgent. - * Uses Extract to filter for subagents that have a `runnable` property. - * - * @example - * ```typescript - * const agent = createDeepAgent({ subagents: [subagent1, compiledSubagent] }); - * type CompiledSubagents = InferCompiledSubagents; - * // Result: the subagent type that has `runnable` property - * ``` - */ -export type InferCompiledSubagents = Extract< - InferDeepAgentSubagents[number], - { runnable: unknown } ->; - -/** - * Helper type to extract SubAgent (subagents with `middleware`) from a DeepAgent. - * Uses Extract to filter for subagents that have a `middleware` property but no `runnable`. - * - * @example - * ```typescript - * const agent = createDeepAgent({ subagents: [subagent1, compiledSubagent] }); - * type RegularSubagents = InferRegularSubagents; - * // Result: the subagent type that has `middleware` property - * ``` - */ -export type InferRegularSubagents = Exclude< - InferDeepAgentSubagents[number], - { runnable: unknown } ->; - -/** - * Helper type to extract a subagent by name from a DeepAgent. - * - * @typeParam T - The DeepAgent to extract from - * @typeParam TName - The name of the subagent to extract - * - * @example - * ```typescript - * const agent = createDeepAgent({ - * subagents: [ - * { name: "researcher", description: "...", middleware: [ResearchMiddleware] } - * ] as const, - * }); - * - * type ResearcherAgent = InferSubagentByName; - * ``` - */ -export type InferSubagentByName = - InferDeepAgentSubagents extends readonly (infer SA)[] - ? SA extends { name: TName } - ? SA - : never - : never; - -/** - * Helper type to extract the ReactAgent type from a subagent definition. - * This is useful for type-safe streaming of subagent events. - * - * @typeParam TSubagent - The subagent definition - * - * @example - * ```typescript - * type SubagentMiddleware = ExtractSubAgentMiddleware; - * type SubagentState = InferMiddlewareStates; - * ``` - */ -export type InferSubagentReactAgentType< - TSubagent extends SubAgent | CompiledSubAgent, -> = TSubagent extends CompiledSubAgent - ? TSubagent["runnable"] - : TSubagent extends SubAgent - ? ReactAgent< - AgentTypeConfig< - ResponseFormatUndefined, - undefined, - AnyAnnotationRoot, - ExtractSubAgentMiddleware, - readonly [] - > - > - : never; - -/** - * Configuration parameters for creating a Deep Agent - * Matches Python's create_deep_agent parameters - * - * @typeParam TResponse - The structured response type when using responseFormat - * @typeParam ContextSchema - The context schema type - * @typeParam TMiddleware - The middleware array type for proper type inference - * @typeParam TSubagents - The subagents array type for extracting subagent middleware states - * @typeParam TTools - The tools array type - */ -export interface CreateDeepAgentParams< - TResponse extends ResponseFormat = ResponseFormat, - ContextSchema extends AnnotationRoot | InteropZodObject = - AnnotationRoot, - TMiddleware extends readonly AgentMiddleware[] = readonly AgentMiddleware[], - TSubagents extends readonly (SubAgent | CompiledSubAgent)[] = readonly ( - | SubAgent - | CompiledSubAgent - )[], - TTools extends readonly (ClientTool | ServerTool)[] = readonly ( - | ClientTool - | ServerTool - )[], -> { - /** The model to use (model name string or LanguageModelLike instance). Defaults to claude-sonnet-4-5-20250929 */ - model?: BaseLanguageModel | string; - /** Tools the agent should have access to */ - tools?: TTools | StructuredTool[]; - /** Custom system prompt for the agent. This will be combined with the base agent prompt */ - systemPrompt?: string | SystemMessage; - /** Custom middleware to apply after standard middleware */ - middleware?: TMiddleware; - /** List of subagent specifications for task delegation */ - subagents?: TSubagents; - /** Structured output response format for the agent (Zod schema or other format) */ - responseFormat?: TResponse; - /** Optional schema for context (not persisted between invocations) */ - contextSchema?: ContextSchema; - /** Optional checkpointer for persisting agent state between runs */ - checkpointer?: BaseCheckpointSaver | boolean; - /** Optional store for persisting longterm memories */ - store?: BaseStore; - /** - * Optional backend for filesystem operations. - * Can be either a backend instance or a factory function that creates one. - * The factory receives a config object with state and store. - */ - backend?: - | BackendProtocol - | ((config: { state: unknown; store?: BaseStore }) => BackendProtocol); - /** Optional interrupt configuration mapping tool names to interrupt configs */ - interruptOn?: Record; - /** The name of the agent */ - name?: string; - /** - * Optional list of memory file paths (AGENTS.md files) to load - * (e.g., ["~/.deepagents/AGENTS.md", "./.deepagents/AGENTS.md"]). - * Display names are automatically derived from paths. - * Memory is loaded at agent startup and added into the system prompt. - */ - memory?: string[]; - /** - * Optional list of skill source paths (e.g., `["/skills/user/", "/skills/project/"]`). - * - * Paths use POSIX conventions (forward slashes) and are relative to the backend's root. - * Later sources override earlier ones for skills with the same name (last one wins). - * - * @example - * ```typescript - * // With FilesystemBackend - skills loaded from disk - * const agent = await createDeepAgent({ - * backend: new FilesystemBackend({ rootDir: "/home/user/.deepagents" }), - * skills: ["/skills/"], - * }); - * - * // With StateBackend - skills provided in state - * const agent = await createDeepAgent({ - * skills: ["/skills/"], - * }); - * const result = await agent.invoke({ - * messages: [...], - * files: { - * "/skills/my-skill/SKILL.md": { - * content: ["---", "name: my-skill", "description: ...", "---", "# My Skill"], - * created_at: new Date().toISOString(), - * modified_at: new Date().toISOString(), - * }, - * }, - * }); - * ``` - */ - skills?: string[]; -} diff --git a/libs/deepagents/tsconfig.json b/libs/deepagents/tsconfig.json deleted file mode 100644 index 5d7a599f7..000000000 --- a/libs/deepagents/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "composite": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts", "src/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/libs/deepagents/tsdown.config.ts b/libs/deepagents/tsdown.config.ts deleted file mode 100644 index 08ad97cf9..000000000 --- a/libs/deepagents/tsdown.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig([ - { - entry: ["./src/index.ts"], - format: ["esm"], - dts: true, - clean: true, - sourcemap: true, - outDir: "dist", - outExtensions: () => ({ js: ".js" }), - }, - { - entry: ["./src/index.ts"], - format: ["cjs"], - dts: true, - clean: true, - sourcemap: true, - outDir: "dist", - outExtensions: () => ({ js: ".cjs" }), - }, -]); diff --git a/libs/deepagents/vitest.config.ts b/libs/deepagents/vitest.config.ts deleted file mode 100644 index f8f0c4b49..000000000 --- a/libs/deepagents/vitest.config.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - configDefaults, - defineConfig, - type ViteUserConfigExport, -} from "vitest/config"; - -export default defineConfig((env) => { - const common: ViteUserConfigExport = { - test: { - environment: "node", - hideSkippedTests: true, - globals: true, - testTimeout: 60_000, - hookTimeout: 60_000, - teardownTimeout: 60_000, - exclude: ["**/*.int.test.ts", ...configDefaults.exclude], - setupFiles: ["dotenv/config"], - }, - }; - - if (env.mode === "int") { - return { - test: { - ...common.test, - globals: false, - testTimeout: 100_000, - exclude: configDefaults.exclude, - include: ["**/*.int.test.ts"], - name: "int", - }, - } satisfies ViteUserConfigExport; - } - - return { - test: { - ...common.test, - include: ["src/**/*.test.ts"], - }, - } satisfies ViteUserConfigExport; -}); diff --git a/package.json b/package.json index 15ec5ce98..37a6e5999 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/langchain-ai/deepagentsjs.git" + "url": "git+https://github.com/DavinciDreams/deepagentsjs.git" }, "keywords": [ "ai", @@ -32,12 +32,12 @@ "typescript", "llm" ], - "author": "LangChain", + "author": "DavinciDreams", "license": "MIT", "bugs": { - "url": "https://github.com/langchain-ai/deepagentsjs/issues" + "url": "https://github.com/DavinciDreams/deepagentsjs/issues" }, - "homepage": "https://github.com/langchain-ai/deepagentsjs#readme", + "homepage": "https://github.com/DavinciDreams/deepagentsjs#readme", "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", diff --git a/plans/modular-architecture-plan.md b/plans/modular-architecture-plan.md new file mode 100644 index 000000000..3f90fbea5 --- /dev/null +++ b/plans/modular-architecture-plan.md @@ -0,0 +1,3261 @@ +# DeepAgentsJS Modular Architecture Plan + +## Executive Summary + +This document provides a comprehensive modular architecture plan for DeepAgentsJS monorepo to address critical rebranding issues, decouple the application from direct LangChain dependencies, and establish a clean separation between framework and application concerns. + +## Table of Contents + +1. [Repository Rebranding Strategy](#1-repository-rebranding-strategy) +2. [Decoupling Strategy for App](#2-decoupling-strategy-for-app) +3. [Adapter Pattern for LangChain](#3-adapter-pattern-for-langchain) +4. [Submodule Migration Plan](#4-submodule-migration-plan) +5. [Peer Dependencies Strategy](#5-peer-dependencies-strategy) +6. [Model Provider Abstraction](#6-model-provider-abstraction) +7. [Documentation Updates](#7-documentation-updates) +8. [Testing Strategy](#8-testing-strategy) +9. [Migration Timeline](#9-migration-timeline) +10. [Future-Proofing](#10-future-proofing) + +--- + +## 1. Repository Rebranding Strategy + +### 1.1 Overview + +All package.json files currently reference `langchain-ai/deepagentsjs` which causes PR submissions to LangChain's repository. This needs to be corrected to point to `DavinciDreams/deepagentsjs`. + +### 1.2 Files Requiring Updates + +| File | Current URL | Target URL | Priority | +|------|-------------|------------|----------| +| `package.json` (root) | `git+https://github.com/langchain-ai/deepagentsjs.git` | `git+https://github.com/DavinciDreams/deepagentsjs.git` | CRITICAL | +| `libs/deepagents/package.json` | `git+https://github.com/langchain-ai/deepagentsjs.git` | `git+https://github.com/DavinciDreams/deepagentsjs.git` | CRITICAL | +| `libs/cli/package.json` | `git+https://github.com/langchain-ai/deepagentsjs.git` | `git+https://github.com/DavinciDreams/deepagentsjs.git` | CRITICAL | +| `libs/deepagents/README.md` | `https://github.com/langchain-ai/deepagentsjs` | `https://github.com/DavinciDreams/deepagentsjs` | HIGH | +| `libs/cli/README.md` | `https://github.com/langchain-ai/deepagentsjs` | `https://github.com/DavinciDreams/deepagentsjs` | HIGH | + +### 1.3 Additional Metadata Updates + +The following fields also need updating across package.json files: + +#### Root `package.json` +```json +{ + "name": "deepagentsjs-monorepo", + "description": "Deep Agents - a library for building controllable AI agents with LangGraph", + "author": "DavinciDreams", + "bugs": { + "url": "https://github.com/DavinciDreams/deepagentsjs/issues" + }, + "homepage": "https://github.com/DavinciDreams/deepagentsjs#readme" +} +``` + +#### `libs/deepagents/package.json` +```json +{ + "name": "deepagents", + "description": "Deep Agents - a library for building controllable AI agents with LangGraph", + "author": "DavinciDreams", + "bugs": { + "url": "https://github.com/DavinciDreams/deepagentsjs/issues" + }, + "homepage": "https://github.com/DavinciDreams/deepagentsjs#readme" +} +``` + +#### `libs/cli/package.json` +```json +{ + "name": "deepagents-cli", + "description": "DeepAgents CLI - AI Coding Assistant for your terminal", + "author": "DavinciDreams", + "bugs": { + "url": "https://github.com/DavinciDreams/deepagentsjs/issues" + }, + "homepage": "https://github.com/DavinciDreams/deepagentsjs/tree/main/libs/cli#readme" +} +``` + +### 1.4 Verification Steps + +1. **Search for remaining references**: + ```bash + # Search for langchain-ai references in package.json files + find . -name "package.json" -type f -exec grep -l "langchain-ai" {} \; + + # Search for langchain-ai in README files + find . -name "README.md" -type f -exec grep -l "langchain-ai" {} \; + ``` + +2. **Validate repository URLs**: + ```bash + # Check git remote configuration + git remote -v + + # Should show: + # origin https://github.com/DavinciDreams/deepagentsjs.git (fetch) + # origin https://github.com/DavinciDreams/deepagentsjs.git (push) + ``` + +3. **Test npm publish preparation**: + ```bash + # Dry-run publish to verify package metadata + pnpm publish --dry-run --filter deepagents + pnpm publish --dry-run --filter deepagents-cli + ``` + +--- + +## 2. Decoupling Strategy for App + +### 2.1 Current Problem Analysis + +The `apps/agents-of-empire` application currently has direct LangChain dependencies: + +```json +{ + "dependencies": { + "@langchain/anthropic": "^1.3.11", + "@langchain/core": "^1.1.16", + "@langchain/langgraph": "^1.1.1", + "@langchain/openai": "^1.2.3", + "langchain": "^1.2.12", + "langsmith": "^0.4.8", + "openai": "^6.17.0" + } +} +``` + +This violates the principle that applications should only depend on the `deepagents` library abstraction. + +### 2.2 Required Framework Exports + +The `deepagents` library needs to expose the following functionality that the app currently imports directly: + +| Current Import | Source | Required Framework Export | +|----------------|--------|-------------------------| +| `tool` | `langchain` | Export from `deepagents` | +| `z` | `zod` | Already exported via re-export | +| `ChatAnthropic` | `@langchain/anthropic` | Export as `createAnthropicModel` | +| `ChatOpenAI` | `@langchain/openai` | Export as `createOpenAIModel` | +| `BaseLanguageModel` | `@langchain/core/language_models/base` | Export as type | + +### 2.3 New Framework API Design + +#### 2.3.1 Model Provider Exports + +Create a new module `libs/deepagents/src/models/index.ts`: + +```typescript +/** + * Model provider exports for deepagents + * + * These provide a consistent interface for creating model instances + * without requiring direct LangChain imports. + */ + +export type { BaseLanguageModel } from "@langchain/core/language_models/base"; + +/** + * Create an Anthropic model instance + * + * @param config - Model configuration + * @returns Configured Anthropic model + */ +export function createAnthropicModel( + config: AnthropicModelConfig +): BaseLanguageModel { + const { ChatAnthropic } = require("@langchain/anthropic"); + return new ChatAnthropic(config); +} + +/** + * Create an OpenAI model instance + * + * @param config - Model configuration + * @returns Configured OpenAI model + */ +export function createOpenAIModel( + config: OpenAIModelConfig +): BaseLanguageModel { + const { ChatOpenAI } = require("@langchain/openai"); + return new ChatOpenAI(config); +} + +/** + * Model configuration interfaces + */ +export interface AnthropicModelConfig { + model?: string; + temperature?: number; + maxTokens?: number; + apiKey?: string; + [key: string]: any; +} + +export interface OpenAIModelConfig { + model?: string; + temperature?: number; + maxTokens?: number; + apiKey?: string; + [key: string]: any; +} + +/** + * Model provider types + */ +export type ModelProvider = "anthropic" | "openai" | "google"; + +/** + * Create a model by provider name + * + * @param provider - The model provider + * @param config - Model configuration + * @returns Configured model instance + */ +export function createModel( + provider: ModelProvider, + config: AnthropicModelConfig | OpenAIModelConfig +): BaseLanguageModel { + switch (provider) { + case "anthropic": + return createAnthropicModel(config as AnthropicModelConfig); + case "openai": + return createOpenAIModel(config as OpenAIModelConfig); + default: + throw new Error(`Unsupported model provider: ${provider}`); + } +} +``` + +#### 2.3.2 Tool Creation Exports + +Create a new module `libs/deepagents/src/tools/index.ts`: + +```typescript +/** + * Tool creation utilities for deepagents + * + * These provide a consistent interface for creating tools + * without requiring direct LangChain imports. + */ + +import type { StructuredTool } from "@langchain/core/tools"; +import type { z } from "zod"; + +/** + * Create a tool from an async function + * + * @param func - The async function to execute + * @param config - Tool configuration + * @returns A structured tool + */ +export function createTool>( + func: (input: TInput) => Promise, + config: ToolConfig +): StructuredTool { + const { tool } = require("langchain"); + return tool(func, config); +} + +/** + * Tool configuration interface + */ +export interface ToolConfig> { + name: string; + description: string; + schema: z.ZodType; +} + +/** + * Re-export tool types for convenience + */ +export type { StructuredTool } from "@langchain/core/tools"; +``` + +#### 2.3.3 Update Main Index + +Update `libs/deepagents/src/index.ts` to include new exports: + +```typescript +// Existing exports... +export { createDeepAgent } from "./agent.js"; +export type { /* existing types */ } from "./types.js"; + +// New exports for model providers +export { + createAnthropicModel, + createOpenAIModel, + createModel, + type AnthropicModelConfig, + type OpenAIModelConfig, + type ModelProvider, + type BaseLanguageModel, +} from "./models/index.js"; + +// New exports for tools +export { + createTool, + type ToolConfig, + type StructuredTool, +} from "./tools/index.js"; +``` + +### 2.4 App Migration Path + +#### Phase 1: Add Framework Exports (No Breaking Changes) +1. Create new model and tool modules in `deepagents` +2. Export them from main index +3. Update framework version + +#### Phase 2: Update App Imports +1. Replace direct LangChain imports with deepagents exports: + +**Before:** +```typescript +import { tool } from "langchain"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { createDeepAgent } from "deepagents"; +``` + +**After:** +```typescript +import { createTool, createAnthropicModel, createDeepAgent } from "deepagents"; +``` + +2. Update tool creation calls: + +**Before:** +```typescript +const searchTool = tool( + async ({ query }: { query: string }) => { ... }, + { + name: "search", + description: "Search for information", + schema: z.object({ + query: z.string().describe("The search query"), + }), + } +); +``` + +**After:** +```typescript +const searchTool = createTool( + async ({ query }: { query: string }) => { ... }, + { + name: "search", + description: "Search for information", + schema: z.object({ + query: z.string().describe("The search query"), + }), + } +); +``` + +3. Update model creation: + +**Before:** +```typescript +const agent = createDeepAgent({ + model: new ChatAnthropic({ + model: "claude-sonnet-4-20250514", + temperature: 0, + }), +}); +``` + +**After:** +```typescript +const agent = createDeepAgent({ + model: createAnthropicModel({ + model: "claude-sonnet-4-20250514", + temperature: 0, + }), +}); +``` + +#### Phase 3: Remove Direct Dependencies +1. Remove LangChain packages from `apps/agents-of-empire/package.json`: + +```json +{ + "dependencies": { + "deepagents": "^2.0.0", + // Remove: @langchain/anthropic, @langchain/core, @langchain/langgraph, + // @langchain/openai, langchain, langsmith, openai + } +} +``` + +2. Run `pnpm install` to clean up dependencies + +3. Verify the app still works correctly + +### 2.5 Updated App Dependencies + +After migration, `apps/agents-of-empire/package.json` should only contain: + +```json +{ + "dependencies": { + "deepagents": "^2.0.0", + "@react-three/drei": "^9.121.4", + "@react-three/fiber": "^9.1.2", + "@react-three/postprocessing": "^3.0.4", + "dotenv": "^17.2.3", + "framer-motion": "^12.0.6", + "immer": "^10.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.173.0", + "uuid": "^11.0.0", + "zod": "^4.3.6", + "zustand": "^5.0.3" + } +} +``` + +--- + +## 3. Adapter Pattern for LangChain + +### 3.1 Architecture Overview + +The adapter pattern will create a clean abstraction layer between the deepagents framework and LangChain, enabling future framework swaps. + +### 3.2 Interface Design + +#### 3.2.1 Core Abstraction Interfaces + +Create `libs/deepagents/src/adapters/interfaces.ts`: + +```typescript +/** + * Core abstraction interfaces for agent frameworks + * + * These interfaces define the contract that any agent framework + * implementation must satisfy, allowing for future framework swaps. + */ + +import type { z } from "zod"; + +/** + * Base interface for language models + */ +export interface IModel { + /** The model name/identifier */ + readonly model: string; + + /** Temperature for generation */ + readonly temperature?: number; + + /** Maximum tokens to generate */ + readonly maxTokens?: number; + + /** Invoke model with messages */ + invoke(messages: any[]): Promise; + + /** Stream responses from model */ + stream(messages: any[]): AsyncIterable; +} + +/** + * Base interface for tools + */ +export interface ITool { + /** Tool name */ + readonly name: string; + + /** Tool description */ + readonly description: string; + + /** Input schema */ + readonly schema: z.ZodType; + + /** Execute tool */ + call(input: any): Promise; +} + +/** + * Base interface for agents + */ +export interface IAgent { + /** Agent name */ + readonly name?: string; + + /** Invoke agent with input */ + invoke(input: any): Promise; + + /** Stream agent execution */ + stream(input: any): AsyncIterable; + + /** Get agent state */ + getState(): any; +} + +/** + * Base interface for middleware + */ +export interface IMiddleware { + /** Middleware name */ + readonly name: string; + + /** Tools provided by this middleware */ + readonly tools?: ITool[]; + + /** Pre-processing hook */ + before?(input: any): Promise; + + /** Post-processing hook */ + after?(output: any): Promise; +} + +/** + * Configuration for creating an agent + */ +export interface IAgentConfig { + /** Model to use */ + model?: IModel | string; + + /** Tools available to agent */ + tools?: ITool[]; + + /** System prompt */ + systemPrompt?: string; + + /** Middleware to apply */ + middleware?: IMiddleware[]; + + /** Subagents */ + subagents?: ISubAgent[]; + + /** Response format */ + responseFormat?: any; + + /** Context schema */ + contextSchema?: any; + + /** Checkpointer for state persistence */ + checkpointer?: any; + + /** Store for long-term memory */ + store?: any; + + /** Backend for file operations */ + backend?: any; + + /** Interrupt configuration */ + interruptOn?: Record; +} + +/** + * Subagent configuration + */ +export interface ISubAgent { + /** Subagent name */ + name: string; + + /** Subagent description */ + description: string; + + /** System prompt */ + systemPrompt: string; + + /** Tools available to subagent */ + tools?: ITool[]; + + /** Model override */ + model?: IModel | string; + + /** Middleware */ + middleware?: IMiddleware[]; + + /** Interrupt configuration */ + interruptOn?: Record; +} + +/** + * Factory interface for creating agents + */ +export interface IAgentFactory { + /** + * Create an agent with the given configuration + */ + createAgent(config: IAgentConfig): IAgent; + + /** + * Create a model instance + */ + createModel(provider: string, config: any): IModel; + + /** + * Create a tool + */ + createTool( + func: (input: any) => Promise, + config: { name: string; description: string; schema: any } + ): ITool; +} +``` + +#### 3.2.2 LangChain Adapter Implementation + +Create `libs/deepagents/src/adapters/langchain/index.ts`: + +```typescript +/** + * LangChain adapter implementation + * + * This adapter wraps LangChain's implementation to conform + * to the deepagents abstraction interfaces. + */ + +import type { + IAgent, + IAgentConfig, + IAgentFactory, + IModel, + ITool, + IMiddleware, + ISubAgent, +} from "../interfaces.js"; +import type { BaseLanguageModel } from "@langchain/core/language_models/base"; +import type { StructuredTool } from "@langchain/core/tools"; +import type { ReactAgent } from "langchain"; + +/** + * LangChain-specific model wrapper + */ +export class LangChainModel implements IModel { + constructor(private readonly model: BaseLanguageModel) {} + + get model(): string { + return this.model.model as string; + } + + get temperature(): number | undefined { + return this.model.temperature; + } + + get maxTokens(): number | undefined { + return this.model.maxTokens; + } + + async invoke(messages: any[]): Promise { + return this.model.invoke(messages); + } + + async *stream(messages: any[]): AsyncIterable { + yield* this.model.stream(messages); + } +} + +/** + * LangChain-specific tool wrapper + */ +export class LangChainTool implements ITool { + constructor(private readonly tool: StructuredTool) {} + + get name(): string { + return this.tool.name; + } + + get description(): string { + return this.tool.description; + } + + get schema(): any { + return this.tool.schema; + } + + async call(input: any): Promise { + return this.tool.invoke(input); + } +} + +/** + * LangChain-specific agent wrapper + */ +export class LangChainAgent implements IAgent { + constructor(private readonly agent: ReactAgent) {} + + get name(): string | undefined { + return this.agent.name; + } + + async invoke(input: any): Promise { + return this.agent.invoke(input); + } + + async *stream(input: any): AsyncIterable { + yield* this.agent.stream(input); + } + + getState(): any { + return this.agent.getState(); + } +} + +/** + * LangChain agent factory + */ +export class LangChainAgentFactory implements IAgentFactory { + constructor(private readonly langchain: any) {} + + createAgent(config: IAgentConfig): IAgent { + // Convert config to LangChain format + const langchainConfig = this.convertConfig(config); + + // Create agent using LangChain's createAgent + const agent = this.langchain.createAgent(langchainConfig); + + return new LangChainAgent(agent); + } + + createModel(provider: string, config: any): IModel { + switch (provider) { + case "anthropic": { + const { ChatAnthropic } = require("@langchain/anthropic"); + return new LangChainModel(new ChatAnthropic(config)); + } + case "openai": { + const { ChatOpenAI } = require("@langchain/openai"); + return new LangChainModel(new ChatOpenAI(config)); + } + default: + throw new Error(`Unsupported provider: ${provider}`); + } + } + + createTool( + func: (input: any) => Promise, + config: { name: string; description: string; schema: any } + ): ITool { + const { tool } = this.langchain; + const langchainTool = tool(func, config); + return new LangChainTool(langchainTool); + } + + private convertConfig(config: IAgentConfig): any { + // Convert deepagents config to LangChain format + return { + model: config.model instanceof LangChainModel + ? config.model.model + : config.model, + tools: config.tools?.map(t => + t instanceof LangChainTool ? t.tool : t + ), + systemPrompt: config.systemPrompt, + middleware: config.middleware?.map(m => + m instanceof LangChainMiddleware ? m.middleware : m + ), + subagents: config.subagents?.map(s => this.convertSubAgent(s)), + responseFormat: config.responseFormat, + contextSchema: config.contextSchema, + checkpointer: config.checkpointer, + store: config.store, + backend: config.backend, + interruptOn: config.interruptOn, + }; + } + + private convertSubAgent(subagent: ISubAgent): any { + return { + name: subagent.name, + description: subagent.description, + systemPrompt: subagent.systemPrompt, + tools: subagent.tools?.map(t => + t instanceof LangChainTool ? t.tool : t + ), + model: subagent.model instanceof LangChainModel + ? subagent.model.model + : subagent.model, + middleware: subagent.middleware?.map(m => + m instanceof LangChainMiddleware ? m.middleware : m + ), + interruptOn: subagent.interruptOn, + }; + } +} +``` + +### 3.3 File Structure + +``` +libs/deepagents/src/ +├── adapters/ +│ ├── interfaces.ts # Core abstraction interfaces +│ ├── langchain/ +│ │ ├── index.ts # LangChain adapter implementation +│ │ ├── model.ts # LangChain model wrapper +│ │ ├── tool.ts # LangChain tool wrapper +│ │ ├── agent.ts # LangChain agent wrapper +│ │ └── middleware.ts # LangChain middleware wrapper +│ └── index.ts # Adapter factory and exports +├── models/ +│ └── index.ts # Model provider exports +├── tools/ +│ └── index.ts # Tool creation exports +└── agent.ts # Updated to use adapter pattern +``` + +### 3.4 Updated createDeepAgent Implementation + +Update `libs/deepagents/src/agent.ts` to use the adapter pattern: + +```typescript +import { LangChainAgentFactory } from "./adapters/langchain/index.js"; +import type { IAgentConfig } from "./adapters/interfaces.js"; + +// Create a singleton factory instance +const factory = new LangChainAgentFactory(require("langchain")); + +export function createDeepAgent( + params: TConfig = {} as TConfig +) { + // Use the factory to create the agent + const agent = factory.createAgent(params as IAgentConfig); + + // Return with type wrapper for backward compatibility + return agent as any; +} +``` + +### 3.5 Future Framework Swap Example + +To swap to a different framework in the future, create a new adapter: + +```typescript +// libs/deepagents/src/adapters/another-framework/index.ts +export class AnotherFrameworkFactory implements IAgentFactory { + createAgent(config: IAgentConfig): IAgent { + // Implementation using another framework + } + + createModel(provider: string, config: any): IModel { + // Implementation using another framework + } + + createTool(...): ITool { + // Implementation using another framework + } +} +``` + +Then update the factory selection: + +```typescript +// libs/deepagents/src/agent.ts +const factory = process.env.DEEPAGENTS_FRAMEWORK === 'another' + ? new AnotherFrameworkFactory() + : new LangChainAgentFactory(require("langchain")); +``` + +--- + +## 4. Submodule Migration Plan + +### 4.1 Overview + +Extract the framework code (`libs/deepagents` and `libs/cli`) into a separate repository and reference it as a git submodule. + +### 4.2 New Repository Structure + +#### Framework Repository: `DavinciDreams/deepagents-framework` + +``` +deepagents-framework/ +├── packages/ +│ ├── deepagents/ # Core framework +│ │ ├── src/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── tsdown.config.ts +│ │ └── vitest.config.ts +│ └── cli/ # CLI tool +│ ├── src/ +│ ├── package.json +│ ├── tsconfig.json +│ └── tsdown.config.ts +├── package.json # Root package (private) +├── pnpm-workspace.yaml # Workspace definition +├── tsconfig.json # Shared TypeScript config +├── .github/ +│ └── workflows/ # CI/CD workflows +├── LICENSE +└── README.md +``` + +#### App Repository: `DavinciDreams/deepagentsjs` (current repo) + +``` +deepagentsjs/ +├── libs/ +│ └── deepagents/ # ← git submodule (symlink or actual) +├── apps/ +│ └── agents-of-empire/ +├── examples/ +├── pnpm-workspace.yaml +├── package.json +└── .gitmodules +``` + +### 4.3 Migration Steps + +#### Phase 1: Prepare Framework Repository + +1. **Create new GitHub repository** `DavinciDreams/deepagents-framework` + +2. **Extract framework code using git subtree**: + ```bash + # From deepagentsjs root + git subtree split --prefix=libs/deepagents -b framework-deepagents + git subtree split --prefix=libs/cli -b framework-cli + + # Create new repo directory + mkdir ../deepagents-framework + cd ../deepagents-framework + git init + + # Pull both branches + git pull ../deepagentsjs framework-deepagents + git pull ../deepagentsjs framework-cli + + # Move to packages structure + mkdir -p packages + git mv * packages/deepagents/ + git mv packages/deepagents/.gitignore packages/ + + # Create CLI package + mkdir packages/cli + # Move CLI files (need to handle separately) + + # Add remote and push + git remote add origin https://github.com/DavinciDreams/deepagents-framework.git + git push -u origin main + ``` + +3. **Set up framework repository**: + ```bash + # Create pnpm-workspace.yaml + cat > pnpm-workspace.yaml << EOF + packages: + - packages/* + EOF + + # Create root package.json + cat > package.json << EOF + { + "name": "deepagents-framework", + "version": "1.0.0", + "private": true, + "description": "Deep Agents Framework Monorepo", + "scripts": { + "build": "pnpm --filter './packages/*' build", + "test": "pnpm --filter './packages/*' test", + "lint": "eslint packages", + "changeset:version": "changeset version", + "release": "pnpm build && changeset publish" + }, + "devDependencies": { + "@changesets/cli": "^2.29.8", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@10.27.0" + } + EOF + + # Create shared tsconfig.json + cat > tsconfig.json << EOF + { + "extends": "@tsconfig/recommended/tsconfig.json", + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } + } + EOF + ``` + +4. **Update package paths** in framework packages: + - Update `libs/deepagents/package.json` → `packages/deepagents/package.json` + - Update `libs/cli/package.json` → `packages/cli/package.json` + - Update import paths if needed + +5. **Verify framework builds independently**: + ```bash + pnpm install + pnpm build + pnpm test + ``` + +#### Phase 2: Convert App Repo to Use Submodule + +1. **Remove framework files from app repo**: + ```bash + # From deepagentsjs root + git rm -r libs/deepagents libs/cli + git commit -m "Remove framework code (moving to submodule)" + ``` + +2. **Add framework as submodule**: + ```bash + git submodule add https://github.com/DavinciDreams/deepagents-framework.git libs/deepagents-framework + git commit -m "Add deepagents framework as git submodule" + ``` + +3. **Update pnpm-workspace.yaml**: + ```yaml + packages: + - libs/deepagents-framework/packages/* + - examples + - apps/* + ``` + +4. **Create symlinks for backward compatibility** (optional): + ```bash + # Create symlinks from libs/deepagents to submodule + cd libs + ln -s deepagents-framework/packages/deepagents deepagents + ln -s deepagents-framework/packages/cli cli + cd .. + ``` + +5. **Update root package.json**: + ```json + { + "scripts": { + "build": "pnpm --filter './libs/deepagents-framework/packages/*' build", + "test": "pnpm --filter './libs/deepagents-framework/packages/*' test" + } + } + ``` + +6. **Verify workspace resolution**: + ```bash + pnpm install + pnpm build + pnpm test + ``` + +#### Phase 3: Configure Submodule Workflow + +1. **Pin submodule to specific version**: + ```bash + cd libs/deepagents-framework + git checkout v2.0.0 # or specific commit + cd ../.. + git add libs/deepagents-framework + git commit -m "Pin deepagents submodule to v2.0.0" + ``` + +2. **Create .gitmodules file**: + ```ini + [submodule "libs/deepagents-framework"] + path = libs/deepagents-framework + url = https://github.com/DavinciDreams/deepagents-framework.git + branch = main + ``` + +3. **Document submodule workflow** in README: + ```markdown + ## Getting Started + + ### Clone with Submodules + + ```bash + git clone --recurse-submodules https://github.com/DavinciDreams/deepagentsjs.git + cd deepagentsjs + ``` + + ### Update Submodules + + ```bash + # Update to latest framework version + git submodule update --remote + + # Update to specific version + cd libs/deepagents-framework + git checkout v2.0.0 + cd ../.. + git add libs/deepagents-framework + git commit -m "Update framework to v2.0.0" + ``` + ``` + +#### Phase 4: Update CI/CD + +1. **GitHub Actions** - Update workflows to checkout with submodules: + ```yaml + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + ``` + +2. **Vercel** - Enable submodule support in project settings: + - Go to Project Settings → Git + - Enable "Git Submodules" + - Set "Submodule Update Strategy" to "Recursive" + +3. **Changesets** - Move changeset config to framework repo: + ```bash + # In framework repo + pnpm changeset init + ``` + +### 4.4 Versioning and Release Process + +1. **Framework Versioning**: + - Use semantic versioning (major.minor.patch) + - Tag releases: `v2.0.0`, `v2.1.0`, etc. + - Publish to npm from framework repo + +2. **App Versioning**: + - Pin to specific framework version via submodule commit + - Document framework version in app's package.json: + ```json + { + "deepagents": { + "version": "2.0.0", + "commit": "abc123def456" + } + } + ``` + +3. **Release Process**: + ```bash + # In framework repo + pnpm changeset + pnpm changeset version + pnpm release + + # In app repo + cd libs/deepagents-framework + git pull origin main + git checkout v2.0.0 + cd ../.. + git add libs/deepagents-framework + git commit -m "Update framework to v2.0.0" + git tag app-v1.0.0 + ``` + +### 4.5 Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| pnpm workspace breaks with submodule | Build fails | Test workspace resolution before committing | +| Git history lost for framework files | Annoying but not critical | Use `git subtree split` to preserve history | +| CI/CD doesn't handle submodules | Deploys break | Add `--recurse-submodules` to all checkout steps | +| Vercel doesn't support submodules | agents-of-empire deploy breaks | Vercel supports submodules - verify in settings | +| Changeset publishing breaks | npm releases fail | Move changeset config to framework repo | +| Contributors forget `--recurse-submodules` | Missing framework code | Document in README + add git hooks | + +### 4.6 Success Criteria + +- [ ] Framework repo builds and tests independently +- [ ] App repo clones with `--recurse-submodules` and builds correctly +- [ ] `pnpm install && pnpm build && pnpm test` passes in app repo +- [ ] `agents-of-empire` dev server starts correctly +- [ ] npm publish still works for `deepagents` package +- [ ] Submodule can be updated to pull new framework changes + +--- + +## 5. Peer Dependencies Strategy + +### 5.1 Overview + +To reduce bundle size and allow consumers to control versions, certain dependencies should be marked as peer dependencies in the `deepagents` package. + +### 5.2 Dependencies Analysis + +| Dependency | Current Type | Should Be Peer? | Reason | +|------------|---------------|------------------|---------| +| `@langchain/anthropic` | direct | YES | Optional model provider | +| `@langchain/openai` | direct | YES | Optional model provider | +| `@langchain/core` | direct | NO | Required core types | +| `@langchain/langgraph` | direct | NO | Required for agent framework | +| `langchain` | direct | NO | Required for base functionality | +| `zod` | direct | NO | Required for schema validation | +| `yaml` | direct | NO | Required for skill loading | + +### 5.3 Updated package.json + +```json +{ + "name": "deepagents", + "version": "2.0.0", + "description": "Deep Agents - a library for building controllable AI agents", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "peerDependencies": { + "@langchain/anthropic": "^1.3.0", + "@langchain/openai": "^1.2.0" + }, + "peerDependenciesMeta": { + "@langchain/anthropic": { + "optional": true + }, + "@langchain/openai": { + "optional": true + } + }, + "dependencies": { + "@langchain/core": "^1.1.0", + "@langchain/langgraph": "^1.1.0", + "langchain": "^1.2.0", + "zod": "^4.3.0", + "yaml": "^2.8.0", + "fast-glob": "^3.3.0", + "micromatch": "^4.0.0" + }, + "devDependencies": { + "@langchain/anthropic": "^1.3.0", + "@langchain/openai": "^1.2.0", + "@langchain/langgraph-checkpoint": "^1.0.0", + "@tsconfig/recommended": "^1.0.0", + "@types/micromatch": "^4.0.0", + "@types/node": "^25.0.0", + "@types/uuid": "^11.0.0", + "@vitest/coverage-v8": "^4.0.0", + "@vitest/ui": "^4.0.0", + "dotenv": "^17.2.0", + "tsdown": "^0.19.0", + "tsx": "^4.21.0", + "typescript": "^5.9.0", + "uuid": "^13.0.0", + "vitest": "^4.0.0" + } +} +``` + +### 5.4 Updated Model Provider Code + +Update `libs/deepagents/src/models/index.ts` to handle optional peer dependencies: + +```typescript +/** + * Model provider exports for deepagents + * + * These provide a consistent interface for creating model instances. + * Model providers are peer dependencies and must be installed by the consumer. + */ + +export type { BaseLanguageModel } from "@langchain/core/language_models/base"; + +/** + * Create an Anthropic model instance + * + * @throws Error if @langchain/anthropic is not installed + */ +export function createAnthropicModel( + config: AnthropicModelConfig +): BaseLanguageModel { + try { + const { ChatAnthropic } = require("@langchain/anthropic"); + return new ChatAnthropic(config); + } catch (error) { + throw new Error( + "@langchain/anthropic is required but not installed. " + + "Please install it: npm install @langchain/anthropic" + ); + } +} + +/** + * Create an OpenAI model instance + * + * @throws Error if @langchain/openai is not installed + */ +export function createOpenAIModel( + config: OpenAIModelConfig +): BaseLanguageModel { + try { + const { ChatOpenAI } = require("@langchain/openai"); + return new ChatOpenAI(config); + } catch (error) { + throw new Error( + "@langchain/openai is required but not installed. " + + "Please install it: npm install @langchain/openai" + ); + } +} + +/** + * Create a model by provider name + * + * @param provider - The model provider + * @param config - Model configuration + * @returns Configured model instance + */ +export function createModel( + provider: ModelProvider, + config: AnthropicModelConfig | OpenAIModelConfig +): BaseLanguageModel { + switch (provider) { + case "anthropic": + return createAnthropicModel(config as AnthropicModelConfig); + case "openai": + return createOpenAIModel(config as OpenAIModelConfig); + default: + throw new Error(`Unsupported model provider: ${provider}`); + } +} + +/** + * Check if a model provider is available + */ +export function isModelProviderAvailable(provider: ModelProvider): boolean { + try { + switch (provider) { + case "anthropic": + require("@langchain/anthropic"); + return true; + case "openai": + require("@langchain/openai"); + return true; + default: + return false; + } + } catch { + return false; + } +} + +/** + * Get list of available model providers + */ +export function getAvailableModelProviders(): ModelProvider[] { + const providers: ModelProvider[] = []; + if (isModelProviderAvailable("anthropic")) providers.push("anthropic"); + if (isModelProviderAvailable("openai")) providers.push("openai"); + return providers; +} +``` + +### 5.5 Consumer Documentation + +Add to `libs/deepagents/README.md`: + +```markdown +## Installation + +### Basic Installation + +```bash +npm install deepagents +``` + +This installs the core framework with all required dependencies. + +### Model Providers + +Model providers are **peer dependencies** and must be installed separately based on which models you plan to use: + +```bash +# For Anthropic models (Claude) +npm install @langchain/anthropic + +# For OpenAI models (GPT) +npm install @langchain/openai + +# For both +npm install @langchain/anthropic @langchain/openai +``` + +### Checking Available Providers + +```typescript +import { + getAvailableModelProviders, + isModelProviderAvailable +} from "deepagents"; + +// Get all available providers +const providers = getAvailableModelProviders(); +console.log("Available providers:", providers); + +// Check specific provider +if (isModelProviderAvailable("anthropic")) { + console.log("Anthropic models are available"); +} +``` + +### Example Usage + +```typescript +import { createDeepAgent, createAnthropicModel } from "deepagents"; + +// This requires @langchain/anthropic to be installed +const agent = createDeepAgent({ + model: createAnthropicModel({ + model: "claude-sonnet-4-20250514", + temperature: 0, + }), +}); +``` + +### Troubleshooting + +**Error: "@langchain/anthropic is required but not installed"** + +This means you're trying to use an Anthropic model but haven't installed the peer dependency. Install it: + +```bash +npm install @langchain/anthropic +``` + +**Error: "@langchain/openai is required but not installed"** + +This means you're trying to use an OpenAI model but haven't installed the peer dependency. Install it: + +```bash +npm install @langchain/openai +``` +``` + +--- + +## 6. Model Provider Abstraction + +### 6.1 Overview + +Create a unified interface for model providers that allows easy addition of new providers and consistent configuration. + +### 6.2 Provider Interface + +Create `libs/deepagents/src/providers/interfaces.ts`: + +```typescript +/** + * Model provider interfaces + */ + +import type { IModel } from "../adapters/interfaces.js"; + +/** + * Supported model providers + */ +export type ModelProviderType = + | "anthropic" + | "openai" + | "google" + | "custom"; + +/** + * Base configuration for all model providers + */ +export interface BaseModelConfig { + /** Model name/identifier */ + model?: string; + /** Temperature for generation (0-2) */ + temperature?: number; + /** Maximum tokens to generate */ + maxTokens?: number; + /** Top-p sampling (nucleus sampling) */ + topP?: number; + /** Top-k sampling */ + topK?: number; + /** Frequency penalty */ + frequencyPenalty?: number; + /** Presence penalty */ + presencePenalty?: number; + /** Stop sequences */ + stop?: string[]; + /** API key (overrides environment variable) */ + apiKey?: string; + /** Base URL (for custom endpoints) */ + baseURL?: string; + /** Timeout in milliseconds */ + timeout?: number; + /** Additional provider-specific options */ + [key: string]: any; +} + +/** + * Anthropic-specific configuration + */ +export interface AnthropicConfig extends BaseModelConfig { + /** Anthropic API version */ + apiVersion?: string; + /** Enable prompt caching */ + enableCache?: boolean; +} + +/** + * OpenAI-specific configuration + */ +export interface OpenAIConfig extends BaseModelConfig { + /** Organization ID */ + organization?: string; + /** Enable Azure OpenAI */ + azureOpenAIApiKey?: string; + azureOpenAIApiInstanceName?: string; + azureOpenAIApiDeploymentName?: string; + azureOpenAIApiVersion?: string; +} + +/** + * Google-specific configuration + */ +export interface GoogleConfig extends BaseModelConfig { + /** Google API version */ + apiVersion?: string; +} + +/** + * Custom provider configuration + */ +export interface CustomProviderConfig extends BaseModelConfig { + /** Custom model class */ + modelClass: any; + /** Provider name for identification */ + providerName: string; +} + +/** + * Union type for all provider configurations + */ +export type ModelProviderConfig = + | AnthropicConfig + | OpenAIConfig + | GoogleConfig + | CustomProviderConfig; + +/** + * Model provider registry + */ +export interface IModelProviderRegistry { + /** + * Register a custom model provider + */ + registerProvider( + name: string, + factory: (config: BaseModelConfig) => IModel + ): void; + + /** + * Unregister a model provider + */ + unregisterProvider(name: string): void; + + /** + * Get a model provider factory + */ + getProvider(name: string): ((config: BaseModelConfig) => IModel) | undefined; + + /** + * List all registered providers + */ + listProviders(): string[]; +} +``` + +### 6.3 Provider Registry Implementation + +Create `libs/deepagents/src/providers/registry.ts`: + +```typescript +/** + * Model provider registry implementation + */ + +import type { + IModelProviderRegistry, + ModelProviderType, + BaseModelConfig, +} from "./interfaces.js"; +import type { IModel } from "../adapters/interfaces.js"; + +/** + * Default model provider registry + */ +class ModelProviderRegistry implements IModelProviderRegistry { + private providers = new Map IModel>(); + + constructor() { + this.registerBuiltInProviders(); + } + + registerProvider( + name: string, + factory: (config: BaseModelConfig) => IModel + ): void { + this.providers.set(name, factory); + } + + unregisterProvider(name: string): void { + this.providers.delete(name); + } + + getProvider(name: string): ((config: BaseModelConfig) => IModel) | undefined { + return this.providers.get(name); + } + + listProviders(): string[] { + return Array.from(this.providers.keys()); + } + + private registerBuiltInProviders(): void { + // Anthropic provider + this.registerProvider("anthropic", (config) => { + const { ChatAnthropic } = require("@langchain/anthropic"); + return new ChatAnthropic(config); + }); + + // OpenAI provider + this.registerProvider("openai", (config) => { + const { ChatOpenAI } = require("@langchain/openai"); + return new ChatOpenAI(config); + }); + + // Google provider (if available) + try { + this.registerProvider("google", (config) => { + const { ChatGoogleGenerativeAI } = require("@langchain/google-genai"); + return new ChatGoogleGenerativeAI(config); + }); + } catch { + // Google provider not installed, skip registration + } + } +} + +// Singleton instance +const registry = new ModelProviderRegistry(); + +export { registry }; +export { ModelProviderRegistry }; +``` + +### 6.4 Provider Factory + +Create `libs/deepagents/src/providers/factory.ts`: + +```typescript +/** + * Model provider factory + */ + +import { registry } from "./registry.js"; +import type { + ModelProviderType, + ModelProviderConfig, + BaseModelConfig, +} from "./interfaces.js"; +import type { IModel } from "../adapters/interfaces.js"; + +/** + * Create a model instance by provider type + * + * @param provider - The model provider type + * @param config - Provider-specific configuration + * @returns A configured model instance + * @throws Error if provider is not available + */ +export function createModel( + provider: ModelProviderType, + config: ModelProviderConfig +): IModel { + const factory = registry.getProvider(provider); + + if (!factory) { + throw new Error( + `Model provider '${provider}' is not available. ` + + `Available providers: ${registry.listProviders().join(", ")}` + ); + } + + return factory(config); +} + +/** + * Create a model from a string identifier + * + * Automatically detects provider from model name: + * - "claude-*" → Anthropic + * - "gpt-*" → OpenAI + * - "gemini-*" → Google + * + * @param model - Model name or identifier + * @param config - Additional configuration + * @returns A configured model instance + */ +export function createModelFromName( + model: string, + config: BaseModelConfig = {} +): IModel { + let provider: ModelProviderType; + + if (model.startsWith("claude-")) { + provider = "anthropic"; + } else if (model.startsWith("gpt-")) { + provider = "openai"; + } else if (model.startsWith("gemini-")) { + provider = "google"; + } else { + // Default to Anthropic for unknown models + provider = "anthropic"; + } + + return createModel(provider, { ...config, model }); +} + +/** + * Register a custom model provider + * + * @param name - Provider name + * @param factory - Factory function that creates model instances + */ +export function registerModelProvider( + name: string, + factory: (config: BaseModelConfig) => IModel +): void { + registry.registerProvider(name, factory); +} + +/** + * Get list of available model providers + */ +export function getAvailableProviders(): string[] { + return registry.listProviders(); +} + +/** + * Check if a model provider is available + */ +export function isProviderAvailable(provider: ModelProviderType): boolean { + return registry.getProvider(provider) !== undefined; +} +``` + +### 6.5 Configuration Helpers + +Create `libs/deepagents/src/providers/config.ts`: + +```typescript +/** + * Configuration helpers for model providers + */ + +import type { + AnthropicConfig, + OpenAIConfig, + GoogleConfig, +} from "./interfaces.js"; + +/** + * Default Anthropic model + */ +export const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929"; + +/** + * Default OpenAI model + */ +export const DEFAULT_OPENAI_MODEL = "gpt-4o"; + +/** + * Default Google model + */ +export const DEFAULT_GOOGLE_MODEL = "gemini-1.5-pro"; + +/** + * Create Anthropic configuration with defaults + */ +export function createAnthropicConfig( + config: Partial = {} +): AnthropicConfig { + return { + model: DEFAULT_ANTHROPIC_MODEL, + temperature: 0, + maxTokens: 4096, + ...config, + }; +} + +/** + * Create OpenAI configuration with defaults + */ +export function createOpenAIConfig( + config: Partial = {} +): OpenAIConfig { + return { + model: DEFAULT_OPENAI_MODEL, + temperature: 0, + maxTokens: 4096, + ...config, + }; +} + +/** + * Create Google configuration with defaults + */ +export function createGoogleConfig( + config: Partial = {} +): GoogleConfig { + return { + model: DEFAULT_GOOGLE_MODEL, + temperature: 0, + maxTokens: 4096, + ...config, + }; +} + +/** + * Get recommended configuration for a model + */ +export function getRecommendedConfig(model: string): Partial { + if (model.startsWith("claude-")) { + return createAnthropicConfig({ model }); + } else if (model.startsWith("gpt-")) { + return createOpenAIConfig({ model }); + } else if (model.startsWith("gemini-")) { + return createGoogleConfig({ model }); + } + return { model }; +} +``` + +### 6.6 Updated Main Index + +Update `libs/deepagents/src/index.ts`: + +```typescript +// Provider exports +export { + createModel, + createModelFromName, + registerModelProvider, + getAvailableProviders, + isProviderAvailable, + type ModelProviderType, + type ModelProviderConfig, + type BaseModelConfig, + type AnthropicConfig, + type OpenAIConfig, + type GoogleConfig, +} from "./providers/factory.js"; + +export { + createAnthropicConfig, + createOpenAIConfig, + createGoogleConfig, + getRecommendedConfig, + DEFAULT_ANTHROPIC_MODEL, + DEFAULT_OPENAI_MODEL, + DEFAULT_GOOGLE_MODEL, +} from "./providers/config.js"; +``` + +### 6.7 Usage Examples + +```typescript +import { + createDeepAgent, + createModel, + createModelFromName, + registerModelProvider, + createAnthropicConfig, +} from "deepagents"; + +// Method 1: Explicit provider selection +const agent1 = createDeepAgent({ + model: createModel("anthropic", createAnthropicConfig({ + model: "claude-sonnet-4-20250514", + temperature: 0.7, + })), +}); + +// Method 2: Auto-detect from model name +const agent2 = createDeepAgent({ + model: createModelFromName("gpt-4o", { + temperature: 0, + }), +}); + +// Method 3: Register custom provider +registerModelProvider("my-provider", (config) => { + return new MyCustomModel(config); +}); + +const agent3 = createDeepAgent({ + model: createModel("my-provider", { + model: "my-model-v1", + }), +}); +``` + +--- + +## 7. Documentation Updates + +### 7.1 Files Requiring Updates + +| File | Changes Needed | Priority | +|------|----------------|----------| +| `README.md` (root) | Update repository links, add architecture overview | HIGH | +| `libs/deepagents/README.md` | Update repository links, add peer dependency info | HIGH | +| `libs/cli/README.md` | Update repository links, update issue tracker | HIGH | +| `CONTRIBUTING.md` | Add contribution guidelines for framework | MEDIUM | +| `STATE.md` | Update architecture section | MEDIUM | +| `.github/ISSUE_TEMPLATE/` | Update issue templates | MEDIUM | + +### 7.2 New Documentation to Create + +#### 7.2.1 `docs/ARCHITECTURE.md` + +```markdown +# DeepAgentsJS Architecture + +## Overview + +DeepAgentsJS is a modular TypeScript framework for building controllable AI agents. The architecture is designed around several key principles: + +1. **Separation of Concerns** - Clear separation between framework, CLI, and applications +2. **Adapter Pattern** - Framework-agnostic interfaces allowing future provider swaps +3. **Peer Dependencies** - Optional model providers to reduce bundle size +4. **Submodule Structure** - Framework developed independently from applications + +## Repository Structure + +``` +deepagentsjs/ +├── libs/ +│ └── deepagents-framework/ # Git submodule (framework repo) +│ └── packages/ +│ ├── deepagents/ # Core framework library +│ └── cli/ # CLI tool +├── apps/ +│ └── agents-of-empire/ # Example application +├── examples/ # Framework usage examples +└── docs/ # Documentation +``` + +## Module Architecture + +### Core Components + +``` +deepagents/ +├── adapters/ # Framework abstraction layer +│ ├── interfaces.ts # Core abstraction interfaces +│ └── langchain/ # LangChain adapter implementation +├── providers/ # Model provider abstraction +│ ├── interfaces.ts # Provider interfaces +│ ├── registry.ts # Provider registry +│ ├── factory.ts # Provider factory +│ └── config.ts # Configuration helpers +├── models/ # Model creation utilities +├── tools/ # Tool creation utilities +├── backends/ # Storage backends +├── middleware/ # Agent middleware +├── skills/ # Skill management +└── agent.ts # Main agent factory +``` + +### Dependency Flow + +``` +Application (agents-of-empire) + ↓ depends on +deepagents (framework) + ↓ uses adapters for +LangChain (implementation) + ↓ uses +Model Providers (anthropic, openai, etc.) +``` + +## Design Patterns + +### Adapter Pattern + +The adapter pattern allows the framework to work with different agent frameworks (currently LangChain) through a common interface. + +```typescript +// Core interface +interface IAgent { + invoke(input: any): Promise; + stream(input: any): AsyncIterable; +} + +// LangChain adapter +class LangChainAgent implements IAgent { + constructor(private agent: ReactAgent) {} + // ... implementation +} +``` + +### Factory Pattern + +The factory pattern creates model instances and agents based on configuration. + +```typescript +// Model factory +const model = createModel("anthropic", { + model: "claude-sonnet-4-20250514", +}); + +// Agent factory +const agent = createDeepAgent({ + model, + tools: [...], +}); +``` + +### Registry Pattern + +The registry pattern allows dynamic registration of model providers. + +```typescript +registerModelProvider("custom", (config) => { + return new CustomModel(config); +}); +``` + +## Extension Points + +### Custom Model Providers + +To add a new model provider: + +1. Implement `IModel` interface +2. Register it with the provider registry +3. Use it via `createModel()` + +### Custom Backends + +To add a new storage backend: + +1. Implement `BackendProtocol` interface +2. Extend `BaseSandbox` for execution capabilities +3. Use it via `backend` parameter + +### Custom Middleware + +To add custom middleware: + +1. Implement `IMiddleware` interface +2. Add tools and hooks as needed +3. Pass to `createDeepAgent()` via `middleware` parameter +``` + +#### 7.2.2 `docs/MIGRATION.md` + +```markdown +# Migration Guide + +## Migrating from v1.x to v2.0 + +### Breaking Changes + +#### 1. Direct LangChain Imports No Longer Supported + +**Before:** +```typescript +import { tool } from "langchain"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { createDeepAgent } from "deepagents"; +``` + +**After:** +```typescript +import { createTool, createAnthropicModel, createDeepAgent } from "deepagents"; +``` + +#### 2. Model Provider Installation + +Model providers are now peer dependencies. You must install them separately: + +```bash +npm install @langchain/anthropic # For Claude models +npm install @langchain/openai # For GPT models +``` + +#### 3. Tool Creation + +Use `createTool()` instead of `tool()`: + +**Before:** +```typescript +const myTool = tool( + async ({ input }) => { ... }, + { name: "my-tool", schema: z.object(...) } +); +``` + +**After:** +```typescript +const myTool = createTool( + async ({ input }) => { ... }, + { name: "my-tool", schema: z.object(...) } +); +``` + +#### 4. Model Creation + +Use provider-specific functions: + +**Before:** +```typescript +const agent = createDeepAgent({ + model: new ChatAnthropic({ model: "claude-sonnet-4-20250514" }), +}); +``` + +**After:** +```typescript +const agent = createDeepAgent({ + model: createAnthropicModel({ model: "claude-sonnet-4-20250514" }), +}); +``` + +### New Features + +#### 1. Model Provider Abstraction + +You can now use any registered model provider: + +```typescript +import { createModel, createModelFromName } from "deepagents"; + +// Explicit provider +const model1 = createModel("anthropic", { + model: "claude-sonnet-4-20250514", +}); + +// Auto-detect from name +const model2 = createModelFromName("gpt-4o"); + +// Custom provider +registerModelProvider("custom", (config) => new MyModel(config)); +const model3 = createModel("custom", { model: "my-model" }); +``` + +#### 2. Configuration Helpers + +Pre-configured settings for common models: + +```typescript +import { + createAnthropicConfig, + createOpenAIConfig, + getRecommendedConfig, +} from "deepagents"; + +const agent = createDeepAgent({ + model: createModelFromName("claude-sonnet-4-20250514", + getRecommendedConfig("claude-sonnet-4-20250514") + ), +}); +``` + +### Step-by-Step Migration + +1. **Update dependencies**: + ```bash + npm install deepagents@latest + npm install @langchain/anthropic @langchain/openai + ``` + +2. **Update imports**: + - Replace `import { tool } from "langchain"` with `import { createTool } from "deepagents"` + - Replace `import { ChatAnthropic } from "@langchain/anthropic"` with `import { createAnthropicModel } from "deepagents"` + +3. **Update tool creation**: + - Replace `tool(...)` with `createTool(...)` + +4. **Update model creation**: + - Replace `new ChatAnthropic(...)` with `createAnthropicModel(...)` + - Replace `new ChatOpenAI(...)` with `createOpenAIModel(...)` + +5. **Test your application**: + ```bash + npm run build + npm test + ``` + +### Troubleshooting + +**Error: "@langchain/anthropic is required but not installed"** + +Install peer dependency: +```bash +npm install @langchain/anthropic +``` + +**Error: "tool is not a function"** + +Update your import: +```typescript +import { createTool } from "deepagents"; +``` + +**Error: "ChatAnthropic is not a constructor"** + +Update your model creation: +```typescript +import { createAnthropicModel } from "deepagents"; +const model = createAnthropicModel({ model: "..." }); +``` +``` + +#### 7.2.3 `CONTRIBUTING.md` + +```markdown +# Contributing to DeepAgentsJS + +## Getting Started + +### Prerequisites + +- Node.js >= 18 +- pnpm >= 10.27.0 +- Git + +### Clone with Submodules + +```bash +git clone --recurse-submodules https://github.com/DavinciDreams/deepagentsjs.git +cd deepagentsjs +``` + +If you already cloned without submodules: + +```bash +git submodule update --init --recursive +``` + +### Install Dependencies + +```bash +pnpm install +``` + +### Build + +```bash +# Build all packages +pnpm build + +# Build specific package +pnpm --filter deepagents build +pnpm --filter deepagents-cli build +``` + +### Test + +```bash +# Run all tests +pnpm test + +# Run specific test suite +pnpm --filter deepagents test +pnpm --filter deepagents test:watch +``` + +## Development Workflow + +### Framework Development + +The framework is a git submodule located at `libs/deepagents-framework`. + +```bash +# Navigate to framework +cd libs/deepagents-framework + +# Make changes +# ... + +# Build and test +pnpm build +pnpm test + +# Commit changes in framework repo +git add . +git commit -m "Your changes" +git push +``` + +### App Development + +The app can use the framework either from the submodule or from a local workspace link. + +```bash +# From app repo root +# The app automatically uses the submodule version + +# To use local workspace version for development +cd apps/agents-of-empire +pnpm link ../../../libs/deepagents-framework/packages/deepagents +``` + +### Updating Framework in App + +```bash +# From app repo root +cd libs/deepagents-framework +git pull origin main +git checkout v2.0.0 # or specific version +cd ../.. +git add libs/deepagents-framework +git commit -m "Update framework to v2.0.0" +``` + +## Code Style + +- Use TypeScript for all new code +- Follow ESLint rules (run `pnpm lint` to check) +- Use Prettier for formatting (run `pnpm format` to format) +- Write tests for new features + +## Submitting Changes + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Ensure all tests pass +6. Submit a pull request + +## Architecture Guidelines + +### Adding New Model Providers + +1. Create provider in `libs/deepagents-framework/packages/deepagents/src/providers/` +2. Implement `IModel` interface +3. Register in provider registry +4. Add tests +5. Update documentation + +### Adding New Backends + +1. Implement `BackendProtocol` interface +2. Add to `libs/deepagents-framework/packages/deepagents/src/backends/` +3. Add tests +4. Update documentation + +### Adding New Middleware + +1. Implement `IMiddleware` interface +2. Add to `libs/deepagents-framework/packages/deepagents/src/middleware/` +3. Add tests +4. Export from `index.ts` + +## Release Process + +Releases are managed using Changesets. + +```bash +# Add a changeset +pnpm changeset + +# Version packages +pnpm changeset version + +# Publish +pnpm release +``` + +## Questions? + +- Open an issue on GitHub +- Join our Discord community +- Check documentation +``` + +### 7.3 Update Existing README Files + +#### Root `README.md` + +```markdown +# DeepAgentsJS + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/) + +> A TypeScript framework for building controllable AI agents with LangGraph. + +## Overview + +DeepAgentsJS is a fork and rebranding of LangChain DeepAgents TypeScript implementation. It provides a modular, framework-agnostic approach to building sophisticated AI agents. + +## Repository Structure + +This is a monorepo containing: + +- **Framework** (`libs/deepagents-framework`) - Core agent framework (git submodule) +- **CLI** (`libs/deepagents-framework/packages/cli`) - Command-line interface +- **Apps** (`apps/`) - Example applications +- **Examples** (`examples/`) - Usage examples + +## Installation + +### Framework + +```bash +npm install deepagents +``` + +See [Framework Documentation](./libs/deepagents-framework/packages/deepagents/README.md) for details. + +### CLI + +```bash +npm install -g deepagents-cli +``` + +See [CLI Documentation](./libs/deepagents-framework/packages/cli/README.md) for details. + +## Quick Start + +```typescript +import { createDeepAgent, createAnthropicModel } from "deepagents"; + +const agent = createDeepAgent({ + model: createAnthropicModel({ + model: "claude-sonnet-4-20250514", + }), +}); + +const result = await agent.invoke({ + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +## Documentation + +- [Architecture](./docs/ARCHITECTURE.md) +- [Migration Guide](./docs/MIGRATION.md) +- [Contributing](./CONTRIBUTING.md) + +## License + +MIT - see [LICENSE](./LICENSE) + +## Related + +- [DeepAgents Framework](https://github.com/DavinciDreams/deepagents-framework) - Framework repository +- [LangGraph](https://github.com/langchain-ai/langgraph) - Stateful agent framework +``` + +--- + +## 8. Testing Strategy + +### 8.1 Unit Tests + +#### 8.1.1 Framework Tests + +```typescript +// libs/deepagents-framework/packages/deepagents/src/adapters/langchain/__tests__/agent.test.ts +import { describe, it, expect, vi } from "vitest"; +import { LangChainAgent } from "../index.js"; + +describe("LangChainAgent", () => { + it("should invoke underlying agent", async () => { + const mockAgent = { + invoke: vi.fn().mockResolvedValue({ result: "test" }), + }; + const agent = new LangChainAgent(mockAgent as any); + + const result = await agent.invoke({ test: "input" }); + + expect(mockAgent.invoke).toHaveBeenCalledWith({ test: "input" }); + expect(result).toEqual({ result: "test" }); + }); + + it("should stream from underlying agent", async () => { + const mockAgent = { + stream: vi.fn().mockImplementation(function* () { + yield { chunk: 1 }; + yield { chunk: 2 }; + }), + }; + const agent = new LangChainAgent(mockAgent as any); + + const chunks = []; + for await (const chunk of agent.stream({ test: "input" })) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ chunk: 1 }, { chunk: 2 }]); + }); +}); +``` + +#### 8.1.2 Provider Tests + +```typescript +// libs/deepagents-framework/packages/deepagents/src/providers/__tests__/factory.test.ts +import { describe, it, expect, vi } from "vitest"; +import { createModel, createModelFromName, registerModelProvider } from "../factory.js"; + +describe("Model Factory", () => { + it("should create Anthropic model", () => { + const model = createModel("anthropic", { + model: "claude-sonnet-4-20250514", + }); + + expect(model).toBeDefined(); + expect(model.model).toBe("claude-sonnet-4-20250514"); + }); + + it("should auto-detect provider from model name", () => { + const model = createModelFromName("gpt-4o"); + + expect(model).toBeDefined(); + }); + + it("should register custom provider", () => { + const mockModel = { model: "custom" }; + registerModelProvider("custom", () => mockModel); + + const model = createModel("custom", { model: "test" }); + + expect(model).toBe(mockModel); + }); +}); +``` + +### 8.2 Integration Tests + +```typescript +// libs/deepagents-framework/packages/deepagents/src/__tests__/integration/agent-integration.test.ts +import { describe, it, expect } from "vitest"; +import { createDeepAgent, createAnthropicModel } from "../../index.js"; + +describe("Agent Integration", () => { + it("should create and invoke an agent", async () => { + const agent = createDeepAgent({ + model: createAnthropicModel({ + model: "claude-3-haiku-20240307", // Use cheaper model for tests + }), + }); + + const result = await agent.invoke({ + messages: [{ role: "user", content: "Say 'test'" }], + }); + + expect(result).toBeDefined(); + expect(result.messages).toBeDefined(); + }, 30000); + + it("should stream agent responses", async () => { + const agent = createDeepAgent({ + model: createAnthropicModel({ + model: "claude-3-haiku-20240307", + }), + }); + + const chunks = []; + for await (const chunk of agent.stream({ + messages: [{ role: "user", content: "Count to 3" }], + })) { + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + }, 30000); +}); +``` + +### 8.3 Mock Strategies for LangChain Dependencies + +#### 8.3.1 Vitest Mocks + +```typescript +// libs/deepagents-framework/packages/deepagents/src/__mocks__/langchain.ts +export const createAgent = vi.fn(); +export const tool = vi.fn(); +export const todoListMiddleware = vi.fn(); +export const summarizationMiddleware = vi.fn(); +export const anthropicPromptCachingMiddleware = vi.fn(); + +export const ChatAnthropic = vi.fn().mockImplementation(() => ({ + model: "claude-3-haiku-20240307", + invoke: vi.fn(), + stream: vi.fn(), +})); + +export const ChatOpenAI = vi.fn().mockImplementation(() => ({ + model: "gpt-3.5-turbo", + invoke: vi.fn(), + stream: vi.fn(), +})); +``` + +#### 8.3.2 Mock Factory + +```typescript +// libs/deepagents-framework/packages/deepagents/src/testing/mock-factory.ts +/** + * Mock factory for testing without real LangChain dependencies + */ + +import type { IAgent, IModel, ITool } from "../adapters/interfaces.js"; + +export class MockModel implements IModel { + constructor( + public readonly model: string = "mock-model", + public readonly temperature: number = 0, + public readonly maxTokens: number = 4096 + ) {} + + async invoke(messages: any[]): Promise { + return { messages: [...messages, { role: "assistant", content: "Mock response" }] }; + } + + async *stream(messages: any[]): AsyncIterable { + yield { messages: [...messages, { role: "assistant", content: "Mock response" }] }; + } +} + +export class MockTool implements ITool { + constructor( + public readonly name: string, + public readonly description: string, + private readonly result: string = "Tool result" + ) {} + + readonly schema = { type: "object" } as any; + + async call(input: any): Promise { + return this.result; + } +} + +export class MockAgent implements IAgent { + constructor( + public readonly name: string = "mock-agent", + private readonly response: any = { result: "mock" } + ) {} + + async invoke(input: any): Promise { + return this.response; + } + + async *stream(input: any): AsyncIterable { + yield this.response; + } + + getState(): any { + return {}; + } +} + +/** + * Create a mock agent for testing + */ +export function createMockAgent(config: any = {}): IAgent { + return new MockAgent(config.name, config.response); +} + +/** + * Create a mock model for testing + */ +export function createMockModel(config: any = {}): IModel { + return new MockModel(config.model, config.temperature, config.maxTokens); +} + +/** + * Create a mock tool for testing + */ +export function createMockTool(name: string, description: string, result?: string): ITool { + return new MockTool(name, description, result); +} +``` + +#### 8.3.3 Test Helper + +```typescript +// libs/deepagents-framework/packages/deepagents/src/testing/test-helper.ts +/** + * Test helper utilities + */ + +import { createMockAgent, createMockModel, createMockTool } from "./mock-factory.js"; + +/** + * Create a test agent with mocked dependencies + */ +export function createTestAgent(config: any = {}) { + return createMockAgent(config); +} + +/** + * Skip tests if API keys are not available + */ +export function skipIfNoApiKey(keys: string[]): void { + const missing = keys.filter(key => !process.env[key]); + if (missing.length > 0) { + console.log(`Skipping test: Missing API keys: ${missing.join(", ")}`); + return; + } +} + +/** + * Get API key or throw if missing + */ +export function getApiKeyOrThrow(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} +``` + +### 8.4 Test Coverage Goals + +| Component | Target Coverage | Priority | +|-----------|----------------|----------| +| Core agent functionality | 80%+ | HIGH | +| Model providers | 70%+ | HIGH | +| Tool creation | 70%+ | HIGH | +| Middleware | 60%+ | MEDIUM | +| Backends | 60%+ | MEDIUM | +| Adapters | 80%+ | HIGH | + +### 8.5 CI/CD Test Configuration + +```yaml +# .github/workflows/test.yml +name: Tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm test:unit + - run: pnpm test:coverage + + integration-tests: + runs-on: ubuntu-latest + needs: unit-tests + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm test:integration + - run: pnpm test:e2e + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm lint +``` + +--- + +## 9. Migration Timeline + +### 9.1 Overview + +This migration will be executed in phases over approximately 40 days to minimize disruption and ensure each phase is stable before proceeding. + +### 9.2 Phase Timeline + +```mermaid +gantt + title DeepAgentsJS Migration Timeline + dateFormat YYYY-MM-DD + section Phase 1 + Rebranding Updates :p1, 2025-02-01, 3d + section Phase 2 + Framework Exports :p2, after p1, 5d + section Phase 3 + App Migration :p3, after p2, 7d + section Phase 4 + Adapter Pattern :p4, after p3, 10d + section Phase 5 + Submodule Migration :p5, after p4, 8d + section Phase 6 + Documentation & Testing :p6, after p5, 7d +``` + +### 9.3 Detailed Phase Breakdown + +#### Phase 1: Repository Rebranding (Days 1-3) + +**Goal**: Update all repository references from `langchain-ai` to `DavinciDreams` + +| Day | Tasks | Owner | Status | +|-----|-------|-------|--------| +| 1 | Update root package.json repository URL | Maintainer | Pending | +| 1 | Update libs/deepagents/package.json repository URL | Maintainer | Pending | +| 1 | Update libs/cli/package.json repository URL | Maintainer | Pending | +| 2 | Update README.md files with new repository links | Maintainer | Pending | +| 2 | Search for and update any remaining langchain-ai references | Maintainer | Pending | +| 3 | Verify all references updated with search commands | Maintainer | Pending | +| 3 | Test npm publish dry-run | Maintainer | Pending | + +**Risk Assessment**: LOW +- Simple find-and-replace operations +- No code changes required +- Easy to rollback if issues arise + +**Success Criteria**: +- [ ] No `langchain-ai` references in package.json files +- [ ] No `langchain-ai` references in README files +- [ ] `git remote -v` shows correct repository +- [ ] `pnpm publish --dry-run` succeeds + +--- + +#### Phase 2: Framework Exports (Days 4-8) + +**Goal**: Add new model and tool exports to deepagents library without breaking changes + +| Day | Tasks | Owner | Status | +|-----|-------|-------|--------| +| 4 | Create `libs/deepagents/src/models/index.ts` | Maintainer | Pending | +| 4 | Create `libs/deepagents/src/tools/index.ts` | Maintainer | Pending | +| 5 | Update `libs/deepagents/src/index.ts` with new exports | Maintainer | Pending | +| 5 | Add TypeScript type exports | Maintainer | Pending | +| 6 | Write unit tests for new exports | Maintainer | Pending | +| 7 | Run full test suite | Maintainer | Pending | +| 8 | Increment version to 2.0.0-alpha.1 | Maintainer | Pending | + +**Risk Assessment**: MEDIUM +- New code paths added +- Must ensure backward compatibility +- Type safety must be maintained + +**Success Criteria**: +- [ ] All existing tests pass +- [ ] New exports work correctly +- [ ] No breaking changes detected +- [ ] TypeScript compilation succeeds + +--- + +#### Phase 3: App Migration (Days 9-15) + +**Goal**: Update agents-of-empire app to use new framework exports + +| Day | Tasks | Owner | Status | +|-----|-------|-------|--------| +| 9 | Update app imports to use deepagents exports | Maintainer | Pending | +| 10 | Update tool creation calls | Maintainer | Pending | +| 11 | Update model creation calls | Maintainer | Pending | +| 12 | Test app functionality locally | Maintainer | Pending | +| 13 | Remove direct LangChain dependencies | Maintainer | Pending | +| 14 | Run full app test suite | Maintainer | Pending | +| 15 | Verify dev server starts correctly | Maintainer | Pending | + +**Risk Assessment**: HIGH +- App functionality must be preserved +- Multiple files need updates +- Integration testing required + +**Success Criteria**: +- [ ] App builds successfully +- [ ] All app tests pass +- [ ] Dev server starts without errors +- [ ] Agent functionality works as expected + +--- + +#### Phase 4: Adapter Pattern (Days 16-25) + +**Goal**: Implement adapter pattern for framework abstraction + +| Day | Tasks | Owner | Status | +|-----|-------|-------|--------| +| 16 | Create `libs/deepagents/src/adapters/interfaces.ts` | Maintainer | Pending | +| 17 | Create `libs/deepagents/src/adapters/langchain/index.ts` | Maintainer | Pending | +| 18 | Implement LangChainModel wrapper | Maintainer | Pending | +| 19 | Implement LangChainTool wrapper | Maintainer | Pending | +| 20 | Implement LangChainAgent wrapper | Maintainer | Pending | +| 21 | Implement LangChainAgentFactory | Maintainer | Pending | +| 22 | Update `libs/deepagents/src/agent.ts` to use factory | Maintainer | Pending | +| 23 | Write unit tests for adapters | Maintainer | Pending | +| 24 | Write integration tests for adapters | Maintainer | Pending | +| 25 | Increment version to 2.0.0-alpha.2 | Maintainer | Pending | + +**Risk Assessment**: HIGH +- Core architecture change +- Must maintain backward compatibility +- Complex type system updates + +**Success Criteria**: +- [ ] All adapter tests pass +- [ ] Existing functionality preserved +- [ ] Type system works correctly +- [ ] No breaking changes detected + +--- + +#### Phase 5: Submodule Migration (Days 26-33) + +**Goal**: Extract framework into separate repository as git submodule + +| Day | Tasks | Owner | Status | +|-----|-------|-------|--------| +| 26 | Create DavinciDreams/deepagents-framework repository | Maintainer | Pending | +| 27 | Extract framework code using git subtree | Maintainer | Pending | +| 28 | Set up framework repository structure | Maintainer | Pending | +| 29 | Update package paths in framework | Maintainer | Pending | +| 30 | Verify framework builds independently | Maintainer | Pending | +| 31 | Remove framework from app repo | Maintainer | Pending | +| 32 | Add framework as git submodule | Maintainer | Pending | +| 33 | Update pnpm-workspace.yaml | Maintainer | Pending | + +**Risk Assessment**: HIGH +- Git history manipulation +- Submodule workflow complexity +- CI/CD configuration changes + +**Success Criteria**: +- [ ] Framework repo builds independently +- [ ] App repo clones with submodules +- [ ] `pnpm install && pnpm build` succeeds +- [ ] CI/CD workflows pass + +--- + +#### Phase 6: Documentation & Testing (Days 34-40) + +**Goal**: Complete documentation and finalize testing + +| Day | Tasks | Owner | Status | +|-----|-------|-------|--------| +| 34 | Create docs/ARCHITECTURE.md | Maintainer | Pending | +| 35 | Create docs/MIGRATION.md | Maintainer | Pending | +| 36 | Update root README.md | Maintainer | Pending | +| 37 | Update libs/deepagents/README.md | Maintainer | Pending | +| 38 | Update libs/cli/README.md | Maintainer | Pending | +| 39 | Create CONTRIBUTING.md | Maintainer | Pending | +| 40 | Final review and release v2.0.0 | Maintainer | Pending | + +**Risk Assessment**: LOW +- Documentation updates +- No code changes +- Easy to iterate + +**Success Criteria**: +- [ ] All documentation files created/updated +- [ ] Documentation is accurate and complete +- [ ] v2.0.0 released successfully +- [ ] Migration guide published + +--- + +### 9.4 Risk Assessment Summary + +| Phase | Risk Level | Primary Risks | Mitigation | +|-------|------------|---------------|------------| +| Phase 1 | LOW | Missed references | Comprehensive search commands | +| Phase 2 | MEDIUM | Type errors | Strict TypeScript checks | +| Phase 3 | HIGH | App breaks | Incremental testing | +| Phase 4 | HIGH | Architecture issues | Thorough testing | +| Phase 5 | HIGH | Git issues | Backup before operations | +| Phase 6 | LOW | Incomplete docs | Peer review | + +### 9.5 Rollback Plan + +Each phase has a rollback strategy: + +**Phase 1-2**: Simple git revert +**Phase 3**: Restore old imports from git history +**Phase 4**: Revert to pre-adapter code +**Phase 5**: Restore framework files from backup +**Phase 6**: Documentation only, no rollback needed + +--- + +## 10. Future-Proofing + +### 10.1 Pre-Commit Hooks + +Implement pre-commit hooks to ensure code quality: + +```bash +# .husky/pre-commit +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +pnpm lint +pnpm test:unit +``` + +Install husky: +```bash +pnpm add -D husky +pnpm exec husky init +``` + +### 10.2 CI/CD Checks + +Add comprehensive CI/CD checks: + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm lint + + type-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm type-check + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: pnpm/action-setup@v2 + with: + version: 10.27.0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'pnpm' + - run: pnpm install + - run: pnpm build +``` + +### 10.3 Documentation Practices + +**Documentation Requirements**: + +1. **All public APIs must be documented** with JSDoc comments +2. **README files must include**: + - Installation instructions + - Quick start guide + - API reference link + - Examples +3. **Changesets** for all breaking changes +4. **Migration guides** for major version updates + +**Documentation Template**: + +```typescript +/** + * Creates a deep agent with the specified configuration. + * + * @example + * ```typescript + * const agent = createDeepAgent({ + * model: createAnthropicModel({ model: "claude-sonnet-4-20250514" }), + * tools: [searchTool], + * }); + * ``` + * + * @param config - Agent configuration + * @returns Configured agent instance + * @throws {Error} If model is not provided + */ +export function createDeepAgent( + config: TConfig +): IAgent { + // ... +} +``` + +### 10.4 Dependency Management + +**Version Policy**: + +1. **Use semantic versioning** for all releases +2. **Pin peer dependency ranges** to compatible versions +3. **Regular dependency updates** via Dependabot +4. **Security audits** via GitHub Dependabot + +**Dependabot Configuration**: + +```yaml +# .github/dependabot.yml +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "@langchain/*" + update-types: ["version-update:semver-major"] +``` + +### 10.5 Release Checklist + +**Pre-Release**: +- [ ] All tests passing +- [ ] Linting passes +- [ ] Type checking passes +- [ ] Documentation updated +- [ ] Changelog updated +- [ ] Version number incremented +- [ ] Changesets created + +**Release**: +- [ ] Create git tag +- [ ] Publish to npm +- [ ] Create GitHub release +- [ ] Update CHANGELOG.md + +**Post-Release**: +- [ ] Monitor for issues +- [ ] Respond to bug reports +- [ ] Plan next release + +### 10.6 Monitoring and Alerts + +**Error Tracking**: +- Integrate Sentry for error tracking +- Set up alerts for critical errors +- Monitor API usage and costs + +**Performance Monitoring**: +- Track agent execution times +- Monitor model API latency +- Track bundle sizes + +### 10.7 Community Guidelines + +**Contributor Guidelines**: +1. All contributions must pass CI/CD checks +2. All new features must include tests +3. All breaking changes must be documented +4. Code must follow project style guide + +**Issue Reporting**: +1. Use issue templates +2. Include reproduction steps +3. Provide environment details +4. Attach relevant logs + +### 10.8 Long-Term Maintenance + +**Monthly Tasks**: +- Review and merge PRs +- Update dependencies +- Review security advisories +- Update documentation + +**Quarterly Tasks**: +- Review architecture +- Plan major features +- Conduct security audit +- Review performance metrics + +**Yearly Tasks**: +- Major version planning +- Architecture review +- Technology stack evaluation +- Community feedback review + +--- + +## Conclusion + +This modular architecture plan provides a comprehensive roadmap for transforming DeepAgentsJS into a well-structured, maintainable, and future-proof framework. The key benefits include: + +1. **Clear Separation of Concerns** - Framework, CLI, and applications are properly separated +2. **Framework Agnosticism** - Adapter pattern enables future framework swaps +3. **Reduced Bundle Size** - Peer dependencies allow consumers to choose model providers +4. **Independent Development** - Submodule structure allows framework to evolve independently +5. **Better Documentation** - Comprehensive documentation improves developer experience +6. **Robust Testing** - Comprehensive test coverage ensures reliability + +Following this plan will result in a more maintainable and extensible codebase that can grow with the needs of the project and its users. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b68ab032b..524fa9cbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,18 +63,6 @@ importers: apps/agents-of-empire: dependencies: - '@langchain/anthropic': - specifier: ^1.3.11 - version: 1.3.11(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6))) - '@langchain/core': - specifier: ^1.1.16 - version: 1.1.16(openai@6.17.0(zod@4.3.6)) - '@langchain/langgraph': - specifier: ^1.1.1 - version: 1.1.1(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) - '@langchain/openai': - specifier: ^1.2.3 - version: 1.2.3(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6))) '@react-three/drei': specifier: ^9.121.4 version: 9.122.0(@react-three/fiber@9.5.0(@types/react@19.2.9)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.173.0))(@types/react@19.2.9)(@types/three@0.173.0)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.173.0)(use-sync-external-store@1.6.0(react@19.2.3)) @@ -85,8 +73,8 @@ importers: specifier: ^3.0.4 version: 3.0.4(@react-three/fiber@9.5.0(@types/react@19.2.9)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.173.0))(@types/three@0.173.0)(react@19.2.3)(three@0.173.0) deepagents: - specifier: ^1.6.0 - version: 1.6.0(openai@6.17.0(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: workspace:* + version: link:../../libs/deepagents dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -96,12 +84,6 @@ importers: immer: specifier: ^10.0.0 version: 10.2.0 - langchain: - specifier: ^1.2.12 - version: 1.2.12(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(openai@6.17.0(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - langsmith: - specifier: ^0.4.8 - version: 0.4.8(openai@6.17.0(zod@4.3.6)) openai: specifier: ^6.17.0 version: 6.17.0(zod@4.3.6) @@ -234,21 +216,9 @@ importers: libs/deepagents: dependencies: - '@langchain/anthropic': - specifier: ^1.3.11 - version: 1.3.11(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6))) - '@langchain/core': - specifier: ^1.1.16 - version: 1.1.16(openai@6.17.0(zod@4.3.6)) - '@langchain/langgraph': - specifier: ^1.1.1 - version: 1.1.1(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) fast-glob: specifier: ^3.3.3 version: 3.3.3 - langchain: - specifier: ^1.2.12 - version: 1.2.12(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(openai@6.17.0(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) micromatch: specifier: ^4.0.8 version: 4.0.8 @@ -259,6 +229,15 @@ importers: specifier: ^4.3.5 version: 4.3.6 devDependencies: + '@langchain/anthropic': + specifier: ^1.3.11 + version: 1.3.11(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6))) + '@langchain/core': + specifier: ^1.1.16 + version: 1.1.16(openai@6.17.0(zod@4.3.6)) + '@langchain/langgraph': + specifier: ^1.1.1 + version: 1.1.1(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) '@langchain/langgraph-checkpoint': specifier: ^1.0.0 version: 1.0.0(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6))) @@ -289,6 +268,9 @@ importers: dotenv: specifier: ^17.2.3 version: 17.2.3 + langchain: + specifier: ^1.2.12 + version: 1.2.12(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(openai@6.17.0(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tsdown: specifier: ^0.19.0 version: 0.19.0(synckit@0.11.12)(typescript@5.9.3) @@ -4978,25 +4960,6 @@ snapshots: - react-dom - zod-to-json-schema - deepagents@1.6.0(openai@6.17.0(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@langchain/anthropic': 1.3.11(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6))) - '@langchain/core': 1.1.16(openai@6.17.0(zod@4.3.6)) - '@langchain/langgraph': 1.1.1(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) - fast-glob: 3.3.3 - langchain: 1.2.12(@langchain/core@1.1.16(openai@6.17.0(zod@4.3.6)))(openai@6.17.0(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - micromatch: 4.0.8 - yaml: 2.8.2 - zod: 4.3.6 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - openai - - react - - react-dom - - zod-to-json-schema - define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 From cb8f3086cdc28c3253e61d5ec434dc8e8ce5c64d Mon Sep 17 00:00:00 2001 From: DavinciDreams Date: Wed, 4 Feb 2026 01:28:07 -0500 Subject: [PATCH 2/2] fix: update submodule URL to deepagentsjs and add branch analysis --- .gitmodules | 5 +- BRANCH_ANALYSIS.md | 382 +++++++++++++++++++++++++++ apps/agents-of-empire/src/ui/HUD.tsx | 2 +- 3 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 BRANCH_ANALYSIS.md diff --git a/.gitmodules b/.gitmodules index 8a1c62223..12b98a941 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "libs/deepagents"] - path = libs/deepagents - url = https://github.com/DavinciDreams/deepagentjs.git + path = libs/deepagents + url = https://github.com/DavinciDreams/deepagentsjs.git + branch = main diff --git a/BRANCH_ANALYSIS.md b/BRANCH_ANALYSIS.md new file mode 100644 index 000000000..6b018bd21 --- /dev/null +++ b/BRANCH_ANALYSIS.md @@ -0,0 +1,382 @@ +# Branch and PR Analysis + +**Date:** 2026-01-30 +**Repository:** deepagentsjs + +## Summary + +This document provides a comprehensive analysis of all remaining branches and open PRs in the repository, along with recommendations for merging into main. + +--- + +## All Remote Branches + +| Branch Name | Status | Notes | +|-------------|--------|-------| +| `origin/changeset-release/main` | Open PR (#118) | Version packages release branch | +| `origin/feature/COORD-001-connection-lines` | No PR | Connection lines between cooperating agents | +| `origin/feature/MAP-001-isometric-camera` | No PR | Isometric 3D camera view | +| `origin/feature/MAP-002-zoom-scroll-wheel` | No PR | Zoom with scroll wheel, agent goal assignment, dragon spawn | +| `origin/feature/PH2-agent-bridge` | No PR | File operation visuals, agent state transitions | +| `origin/feature/PH3-ui-layer` | No PR | Drag-and-drop tool equipping to agents | +| `origin/feature/UI-003-INV-001-panel-animations-equipment` | No PR | Panel animations, equipment items, goal structures | +| `origin/feature/agents-of-empire-workspace-update` | No PR | Workspace updates, LISA Loop skill, landing page | +| `origin/fix/agents-of-empire-startup-fixes` | Merged | Already merged into integration/main-merge | +| `origin/integration/main-merge` | Merged | Integration branch, pushed to main | +| `origin/lno-stars` | Merged | Already merged into integration/main-merge | +| `origin/main` | Base | Main branch | +| `origin/many-features` | Open PR (#116) | UI improvements, panel animations, inventory, goals | + +--- + +## Open Pull Requests + +### PR #118: chore: version packages +- **Branch:** `changeset-release/main` +- **Author:** app/github-actions (bot) +- **Created:** 2026-01-30 +- **Base:** main +- **Description:** Changesets release PR for versioning packages +- **Changes:** + - `deepagents-cli@0.0.17` - Fixed regex in platform key format test + +### PR #116: UI Improvements, Panel Animations, Inventory System, and Goal Assignment +- **Branch:** `many-features` +- **Author:** DavinciDreams +- **Created:** 2026-01-28 +- **Base:** main +- **Additions:** 8,319 lines +- **Deletions:** 325 lines +- **Description:** UI improvements, panel animations, inventory system, and goal assignment + +--- + +## Branch Analysis + +### 1. `many-features` (PR #116) +**Status:** Open PR +**Ancestry:** Based on main, merged with main recently + +**Commits:** +- Merge branch 'main' into many-features +- Delete .claude/skills/lisa-loop directory +- Phase 1: Foundation - MVP Pathfinding (PH1-001) (#114) +- fix: add missing usePartiesShallow import in HUD.tsx +- feat: UI improvements, panel animations, inventory system, and goal assignment +- feat: reorganize documentation and add UI tooltip components +- feat: update agents-of-empire game core functionality +- debug: add console logs for right-click context menu debugging +- fix: add missing useCallback import to Structure.tsx +- fix: resolve merge conflict markers from PR merge + +**Features:** +- UI improvements +- Panel animations +- Inventory system +- Goal assignment + +--- + +### 2. `changeset-release/main` (PR #118) +**Status:** Open PR +**Ancestry:** Based on main + +**Commits:** +- chore: version packages +- pnpm version +- feat: reimplement quest server with deepagents middleware and enhance game store +- Merge lno-stars branch with --allow-unrelated-histories +- feat: add quest system with AI-generated dungeon stories +- Initial commit: Add deepagentsjs project structure +- fix: resolve agents-of-empire startup and selection issues +- Delete .claude/skills/lisa-loop directory +- Phase 1: Foundation - MVP Pathfinding (PH1-001) (#114) +- feat: update agents-of-empire game core functionality + +**Features:** +- Version packages release +- Quest server with deepagents middleware +- Quest system with AI-generated dungeon stories + +--- + +### 3. `feature/COORD-001-connection-lines` +**Status:** No PR +**Ancestry:** Shares ancestry with main (merge base: 7fcd860) + +**Commits:** +- feat(agents-of-empire): implement connection lines between cooperating agents (COORD-001) +- feat(agents-of-empire): implement RTS-style HUD layout (UI-001) +- MAP-001: Isometric 3D camera view +- feat(agents-of-empire): implement isometric 3D camera view (MAP-001) +- Merge pull request #88 from DavinciDreams/feature/AG-002-drag-select-multiple-agents +- feat(agents-of-empire): implement drag-select multiple agents (AG-002) +- Merge pull request #87 from DavinciDreams/feature/AG-001-instanced-agent-rendering +- feat(agents-of-empire): implement instanced rendering for 100+ agents (AG-001) + +**Features:** +- Connection lines between cooperating agents +- RTS-style HUD layout +- Isometric 3D camera view +- Drag-select multiple agents +- Instanced rendering for 100+ agents + +--- + +### 4. `feature/MAP-001-isometric-camera` +**Status:** No PR +**Ancestry:** Shares ancestry with main (merge base: 9b0b60d) + +**Commits:** +- MAP-001: Isometric 3D camera view +- feat(agents-of-empire): implement isometric 3D camera view (MAP-001) +- Merge pull request #88 from DavinciDreams/feature/AG-002-drag-select-multiple-agents +- feat(agents-of-empire): implement drag-select multiple agents (AG-002) +- Merge pull request #87 from DavinciDreams/feature/AG-001-instanced-agent-rendering +- feat(agents-of-empire): implement instanced rendering for 100+ agents (AG-001) + +**Features:** +- Isometric 3D camera view +- Drag-select multiple agents +- Instanced rendering for 100+ agents + +--- + +### 5. `feature/MAP-002-zoom-scroll-wheel` +**Status:** No PR +**Ancestry:** Based on feature branches + +**Commits:** +- feat(goal): implement agent-to-goal assignment (GOAL-002) +- feat(map): implement zoom in/out with scroll wheel (MAP-002) +- feat(agent): implement enhanced agent state visualization (DA-003) +- feat(combat): implement dragon spawn on errors (COMB-001) +- docs(inventory): update plan.md with INV-001 implementation details +- feat(ui): panel animations and equipment items (UI-003, INV-001) +- Merge PR #94 +- Merge PR #91 +- Merge PR #92 +- feat(agents-of-empire): implement connection lines between cooperating agents (COORD-001) + +**Features:** +- Zoom in/out with scroll wheel +- Agent-to-goal assignment +- Enhanced agent state visualization +- Dragon spawn on errors +- Panel animations and equipment items + +--- + +### 6. `feature/PH2-agent-bridge` +**Status:** No PR +**Ancestry:** Based on feature branches + +**Commits:** +- feat(bridge): implement file operation visuals and agent state transitions +- feat(map): zoom scroll wheel and agent goal assignment (MAP-002, GOAL-002) +- feat(ui): panel animations and equipment items (UI-003, INV-001) +- Merge PR #94 +- Merge PR #91 +- Merge PR #92 +- feat(agents-of-empire): implement connection lines between cooperating agents (COORD-001) +- feat(agents-of-empire): implement RTS-style HUD layout (UI-001) +- chore: remove temporary HUD files and fix gameStore + +**Features:** +- File operation visuals +- Agent state transitions +- Zoom scroll wheel +- Agent goal assignment +- Panel animations and equipment items + +--- + +### 7. `feature/PH3-ui-layer` +**Status:** No PR +**Ancestry:** Based on feature branches + +**Commits:** +- feat(ui): implement drag-and-drop tool equipping to agents +- feat(map): zoom scroll wheel and agent goal assignment (MAP-002, GOAL-002) +- feat(ui): panel animations and equipment items (UI-003, INV-001) +- Merge PR #94 +- Merge PR #91 +- Merge PR #92 +- feat(agents-of-empire): implement connection lines between cooperating agents (COORD-001) +- feat(agents-of-empire): implement RTS-style HUD layout (UI-001) +- chore: remove temporary HUD files and fix gameStore + +**Features:** +- Drag-and-drop tool equipping to agents +- Zoom scroll wheel +- Agent goal assignment +- Panel animations and equipment items + +--- + +### 8. `feature/UI-003-INV-001-panel-animations-equipment` +**Status:** No PR +**Ancestry:** Based on feature branches + +**Commits:** +- fix: resolve merge conflict syntax errors in Structure.tsx +- Merge main into feature branch - resolved conflicts +- fix(agent): correct undefined ref variables in arm materials +- feat(map): zoom scroll wheel and agent goal assignment (MAP-002, GOAL-002) +- feat(ui): panel animations and equipment items (UI-003, INV-001) +- feat(combat): implement dragon spawn on errors (COMB-001) +- feat(goals): implement GOAL-001 - goals appear as physical structures +- docs(inventory): update plan.md with INV-001 implementation details +- feat(inventory): represent tools as equipment items with icons and rarity (INV-001) +- feat(ui): implement smooth panel animations with Framer Motion (UI-003) + +**Features:** +- Panel animations with Framer Motion +- Equipment items with icons and rarity +- Goals as physical structures +- Dragon spawn on errors +- Zoom scroll wheel and agent goal assignment + +--- + +### 9. `feature/agents-of-empire-workspace-update` +**Status:** No PR +**Ancestry:** Older branch + +**Commits:** +- feat(skills): add LISA Loop as standalone skill +- chore: ignore debug/test scripts and screenshots +- fix(ci): add vercel.json configuration for monorepo deployment +- feat(agents-of-empire): integrate real email service for waitlist +- feat(agents-of-empire): update game entities and landing page +- fix(agents-of-empire): resolve infinite loop in useCamera hook +- fix(agents-of-empire): add tiles shallow selector and fix GameHooks +- fix(agents-of-empire): resolve infinite loop and crash on game load +- ts fixes +- feat: add landing page and update game UI + +**Features:** +- LISA Loop as standalone skill +- Real email service for waitlist +- Landing page updates +- Various bug fixes + +--- + +## Current State of Main + +The main branch currently contains: +- Quest server with deepagents middleware +- Quest system with AI-generated dungeon stories +- Lno-stars merged with --allow-unrelated-histories +- Startup and selection fixes +- Phase 1: Foundation - MVP Pathfinding (PH1-001) + +--- + +## Recommended Merge Order + +Based on the analysis, here is the recommended merge order: + +### Priority 1: Merge Open PRs First + +1. **PR #116: `many-features` → main** + - This is the most comprehensive feature branch with 8,319 additions + - Contains UI improvements, panel animations, inventory system, and goal assignment + - Already has a PR open and is ready for review + - **Note:** This branch appears to supersede many of the individual feature branches + +2. **PR #118: `changeset-release/main` → main** (AFTER #116) + - This is a release branch for versioning packages + - Should be merged AFTER feature changes to include them in the release + - This is an automated changeset release PR + +### Priority 2: Evaluate Individual Feature Branches + +After merging the open PRs, evaluate if the following branches still need to be merged (they may be superseded by `many-features`): + +3. **`feature/UI-003-INV-001-panel-animations-equipment`** + - Panel animations and equipment items + - May be partially included in `many-features` + +4. **`feature/MAP-002-zoom-scroll-wheel`** + - Zoom with scroll wheel, agent goal assignment, dragon spawn + - May be partially included in `many-features` + +5. **`feature/PH3-ui-layer`** + - Drag-and-drop tool equipping + - May be partially included in `many-features` + +6. **`feature/PH2-agent-bridge`** + - File operation visuals and agent state transitions + - May be partially included in `many-features` + +7. **`feature/COORD-001-connection-lines`** + - Connection lines between cooperating agents + - RTS-style HUD layout + - Isometric camera view + - May be partially included in `many-features` + +8. **`feature/MAP-001-isometric-camera`** + - Isometric 3D camera view + - May be superseded by COORD-001 branch + +9. **`feature/agents-of-empire-workspace-update`** + - Older branch with workspace updates + - May already be merged into main or superseded + +--- + +## Key Observations + +1. **The `many-features` branch appears to be a consolidation branch** that includes features from multiple individual feature branches (UI-003, INV-001, MAP-002, PH2, PH3, etc.) + +2. **Branch overlap:** Several feature branches contain overlapping features: + - Panel animations appear in multiple branches + - Zoom scroll wheel appears in multiple branches + - Agent goal assignment appears in multiple branches + +3. **Ancestry:** Most feature branches share ancestry with main, indicating they were created from main at various points in time + +4. **PR Status:** Only 2 branches have open PRs, while 7 branches have no associated PRs + +--- + +## Recommendations + +### Immediate Actions + +1. **Merge PR #116 (`many-features`) first** - This appears to be the most comprehensive feature branch and may supersede many of the individual feature branches + +2. **Review and merge PR #118 (`changeset-release/main`) after #116** - This will create a proper release with all the new features + +### Post-Merge Actions + +3. **After merging #116, evaluate individual feature branches:** + - Check if features from individual branches are already included in `many-features` + - Delete branches that have been superseded + - Create PRs for any remaining unique features + +4. **Clean up merged branches:** + - `fix/agents-of-empire-startup-fixes` (already merged) + - `lno-stars` (already merged) + - `integration/main-merge` (already merged) + +### Questions for Review + +1. **Is `many-features` intended to replace the individual feature branches?** If so, the individual branches can be deleted. + +2. **Are there any features in the individual branches that are NOT in `many-features`?** These would need separate PRs. + +3. **Should `feature/agents-of-empire-workspace-update` be merged?** It appears to be an older branch that may have been superseded. + +--- + +## Next Steps + +1. Review PR #116 for any conflicts or issues +2. Merge PR #116 into main +3. Review PR #118 and merge after #116 +4. Evaluate remaining feature branches for unique features +5. Create PRs for any unique features not in `many-features` +6. Clean up obsolete branches diff --git a/apps/agents-of-empire/src/ui/HUD.tsx b/apps/agents-of-empire/src/ui/HUD.tsx index ca303ea15..c9d4ff89b 100644 --- a/apps/agents-of-empire/src/ui/HUD.tsx +++ b/apps/agents-of-empire/src/ui/HUD.tsx @@ -475,7 +475,7 @@ export function QuestTracker({ className = "" }: QuestTrackerProps) { initial={{ opacity: 0, x: -50, y: -20 }} animate={{ opacity: 1, x: 0, y: 0 }} transition={{ duration: 0.5, ease: "easeOut" }} - className={`absolute top-4 left-4 bg-gray-900/95 border-2 border-empire-gold rounded-lg p-4 text-white w-80 shadow-lg shadow-empire-gold/20 pointer-events-auto ${className}`} + className={`absolute top-4 left-4 z-40 bg-gray-900 border-2 border-empire-gold rounded-lg p-4 text-white w-80 shadow-lg shadow-empire-gold/20 pointer-events-auto max-h-[calc(100vh-2rem)] overflow-y-auto ${className}`} > {/* Classic RTS objectives header */}