From 72c2b5f1bef91eaa5d5f289544aea225450adc13 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 4 Feb 2026 16:02:27 -0800 Subject: [PATCH 1/3] feat(server): add ACP support --- examples/acp-server/README.md | 118 +++ examples/acp-server/server.ts | 94 +++ examples/package.json | 1 + libs/deepagents-server/README.md | 393 ++++++++++ libs/deepagents-server/package.json | 82 ++ libs/deepagents-server/src/adapter.test.ts | 477 ++++++++++++ libs/deepagents-server/src/adapter.ts | 218 ++++++ libs/deepagents-server/src/cli.ts | 274 +++++++ libs/deepagents-server/src/index.ts | 67 ++ libs/deepagents-server/src/server.ts | 841 +++++++++++++++++++++ libs/deepagents-server/src/types.ts | 290 +++++++ libs/deepagents-server/tsconfig.json | 8 + libs/deepagents-server/tsdown.config.ts | 43 ++ libs/deepagents-server/vitest.config.ts | 13 + pnpm-lock.yaml | 55 ++ 15 files changed, 2974 insertions(+) create mode 100644 examples/acp-server/README.md create mode 100644 examples/acp-server/server.ts create mode 100644 libs/deepagents-server/README.md create mode 100644 libs/deepagents-server/package.json create mode 100644 libs/deepagents-server/src/adapter.test.ts create mode 100644 libs/deepagents-server/src/adapter.ts create mode 100644 libs/deepagents-server/src/cli.ts create mode 100644 libs/deepagents-server/src/index.ts create mode 100644 libs/deepagents-server/src/server.ts create mode 100644 libs/deepagents-server/src/types.ts create mode 100644 libs/deepagents-server/tsconfig.json create mode 100644 libs/deepagents-server/tsdown.config.ts create mode 100644 libs/deepagents-server/vitest.config.ts diff --git a/examples/acp-server/README.md b/examples/acp-server/README.md new file mode 100644 index 00000000..f9d90a24 --- /dev/null +++ b/examples/acp-server/README.md @@ -0,0 +1,118 @@ +# DeepAgents ACP Server Example + +This example demonstrates how to run DeepAgents as an ACP (Agent Client Protocol) server for integration with IDEs like Zed, JetBrains, and other ACP-compatible clients. + +## Prerequisites + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Build the packages: + ```bash + pnpm build + ``` + +## Running the Server + +### Direct Execution + +```bash +npx tsx examples/acp-server/server.ts +``` + +### With Debug Logging + +```bash +DEBUG=true npx tsx examples/acp-server/server.ts +``` + +### With Custom Workspace + +```bash +WORKSPACE_ROOT=/path/to/your/project npx tsx examples/acp-server/server.ts +``` + +## IDE Configuration + +### Zed + +Add to your Zed settings (`~/.config/zed/settings.json` on Linux, `~/Library/Application Support/Zed/settings.json` on macOS): + +```json +{ + "agent": { + "profiles": { + "deepagents": { + "name": "DeepAgents", + "command": "npx", + "args": ["tsx", "examples/acp-server/server.ts"], + "cwd": "/path/to/deepagentsjs", + "env": { + "WORKSPACE_ROOT": "${workspaceFolder}" + } + } + } + } +} +``` + +### JetBrains IDEs + +JetBrains ACP support is coming soon. Check the [ACP documentation](https://agentclientprotocol.com/get-started/clients) for updates. + +## Features + +The DeepAgents ACP server provides: + +- **Full Filesystem Access**: Read, write, edit files in the workspace +- **Code Search**: Grep and glob patterns for finding code +- **Task Management**: Todo list tracking for complex tasks +- **Subagent Delegation**: Spawn specialized subagents for specific tasks +- **Session Persistence**: Maintain conversation context across interactions +- **Multiple Modes**: Switch between Agent, Plan, and Ask modes + +## Customization + +Edit `server.ts` to customize: + +- Model selection +- System prompt +- Skills and memory paths +- Custom tools +- Middleware configuration + +## Protocol Details + +The server implements the [Agent Client Protocol](https://agentclientprotocol.com): + +- Communication: JSON-RPC 2.0 over stdio +- Session management with persistent state +- Streaming responses via session updates +- Tool call tracking and status updates +- Plan/todo list synchronization + +## Troubleshooting + +### Server not starting + +- Check that all dependencies are installed: `pnpm install` +- Ensure packages are built: `pnpm build` +- Check for TypeScript errors: `pnpm typecheck` + +### Debug logging + +Enable debug mode to see detailed logs: + +```bash +DEBUG=true npx tsx examples/acp-server/server.ts +``` + +Logs are written to stderr to avoid interfering with the ACP protocol on stdout. + +### Connection issues + +- Verify the command path in your IDE configuration +- Check that the workspace path exists +- Ensure the LLM API key is set (e.g., `ANTHROPIC_API_KEY`) diff --git a/examples/acp-server/server.ts b/examples/acp-server/server.ts new file mode 100644 index 00000000..2f30073c --- /dev/null +++ b/examples/acp-server/server.ts @@ -0,0 +1,94 @@ +/** + * DeepAgents ACP Server Example + * + * This example demonstrates how to start a DeepAgents ACP server + * that can be used with IDEs like Zed, JetBrains, and other ACP clients. + * + * Usage: + * npx tsx examples/acp-server/server.ts + * + * Then configure your IDE to use this agent. For Zed, add to settings.json: + * + * { + * "agent": { + * "profiles": { + * "deepagents": { + * "name": "DeepAgents", + * "command": "npx", + * "args": ["tsx", "examples/acp-server/server.ts"], + * "cwd": "/path/to/deepagentsjs" + * } + * } + * } + * } + */ + +import { DeepAgentsServer } from "deepagents-server"; +import { FilesystemBackend } from "deepagents"; +import path from "node:path"; + +// Get workspace root from environment or use current directory +const workspaceRoot = process.env.WORKSPACE_ROOT ?? process.cwd(); + +// Create the ACP server with a coding assistant agent +const server = new DeepAgentsServer({ + // Agent configuration + agents: [ + { + name: "coding-assistant", + description: + "AI coding assistant powered by DeepAgents with full filesystem access, " + + "code search, task management, and subagent delegation capabilities.", + + // Use Claude Sonnet as the default model + model: "claude-sonnet-4-5-20250929", + + // Use filesystem backend rooted at the workspace + backend: new FilesystemBackend({ + rootDir: workspaceRoot, + }), + + // Load skills from the workspace if available + skills: [ + path.join(workspaceRoot, ".deepagents", "skills"), + path.join(workspaceRoot, "skills"), + ], + + // Load memory/context from AGENTS.md files + memory: [ + path.join(workspaceRoot, ".deepagents", "AGENTS.md"), + path.join(workspaceRoot, "AGENTS.md"), + ], + + // Custom system prompt (optional) + systemPrompt: `You are an AI coding assistant integrated with an IDE through the Agent Client Protocol (ACP). + +You have access to the workspace at: ${workspaceRoot} + +When working on tasks: +1. First understand the codebase structure +2. Make a plan before making changes +3. Test your changes when possible +4. Explain your reasoning + +Always be helpful, concise, and focused on the user's coding tasks.`, + }, + ], + + // Server configuration + serverName: "deepagents-acp-server", + serverVersion: "0.0.1", + workspaceRoot, + + // Enable debug logging (set to true to see debug output on stderr) + debug: process.env.DEBUG === "true", +}); + +// Start the server +console.error("[deepagents] Starting ACP server..."); +console.error(`[deepagents] Workspace: ${workspaceRoot}`); + +server.start().catch((error) => { + console.error("[deepagents] Server error:", error); + process.exit(1); +}); diff --git a/examples/package.json b/examples/package.json index 970e1af5..e2c2d2bc 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "deepagents": "workspace:*", + "deepagents-server": "workspace:*", "@langchain/anthropic": "^1.3.7", "@langchain/core": "^1.1.12", "@langchain/langgraph-checkpoint": "^1.0.0", diff --git a/libs/deepagents-server/README.md b/libs/deepagents-server/README.md new file mode 100644 index 00000000..a0bd9092 --- /dev/null +++ b/libs/deepagents-server/README.md @@ -0,0 +1,393 @@ +# deepagents-server + +ACP (Agent Client Protocol) server for DeepAgents - enables integration with IDEs like Zed, JetBrains, and other ACP-compatible clients. + +## Overview + +This package wraps DeepAgents with the [Agent Client Protocol (ACP)](https://agentclientprotocol.com), allowing your AI agents to communicate with code editors and development tools through a standardized protocol. + +### What is ACP? + +The Agent Client Protocol is a standardized communication protocol between code editors and AI-powered coding agents. It enables: + +- **IDE Integration**: Connect your agents to Zed, JetBrains IDEs, and other compatible tools +- **Standardized Communication**: JSON-RPC 2.0 based protocol over stdio +- **Rich Interactions**: Support for text, images, file operations, and tool calls +- **Session Management**: Persistent conversations with state management + +## Installation + +```bash +npm install deepagents-server +# or +pnpm add deepagents-server +``` + +## Quick Start + +### Using the CLI (Recommended) + +The easiest way to start is with the CLI: + +```bash +# Run with defaults +npx deepagents-server + +# With custom options +npx deepagents-server --name my-agent --debug + +# Full options +npx deepagents-server \ + --name coding-assistant \ + --model claude-sonnet-4-5-20250929 \ + --workspace /path/to/project \ + --skills ./skills,~/.deepagents/skills \ + --debug +``` + +### CLI Options + +| Option | Short | Description | +| ---------------------- | ----- | ------------------------------------------------- | +| `--name ` | `-n` | Agent name (default: "deepagents") | +| `--description ` | `-d` | Agent description | +| `--model ` | `-m` | LLM model (default: "claude-sonnet-4-5-20250929") | +| `--workspace ` | `-w` | Workspace root directory (default: cwd) | +| `--skills ` | `-s` | Comma-separated skill paths | +| `--memory ` | | Comma-separated AGENTS.md paths | +| `--debug` | | Enable debug logging to stderr | +| `--help` | `-h` | Show help message | +| `--version` | `-v` | Show version | + +### Environment Variables + +| Variable | Description | +| ------------------- | ---------------------------------------------- | +| `ANTHROPIC_API_KEY` | API key for Anthropic/Claude models (required) | +| `OPENAI_API_KEY` | API key for OpenAI models | +| `DEBUG` | Set to "true" to enable debug logging | +| `WORKSPACE_ROOT` | Alternative to --workspace flag | + +### Programmatic Usage + +```typescript +import { startServer } from "deepagents-server"; + +await startServer({ + agents: { + name: "coding-assistant", + description: "AI coding assistant with filesystem access", + }, + workspaceRoot: process.cwd(), +}); +``` + +### Advanced Configuration + +```typescript +import { DeepAgentsServer } from "deepagents-server"; +import { FilesystemBackend } from "deepagents"; + +const server = new DeepAgentsServer({ + // Define multiple agents + agents: [ + { + name: "code-agent", + description: "Full-featured coding assistant", + model: "claude-sonnet-4-5-20250929", + skills: ["./skills/"], + memory: ["./.deepagents/AGENTS.md"], + }, + { + name: "reviewer", + description: "Code review specialist", + model: "claude-sonnet-4-5-20250929", + systemPrompt: "You are a code review expert...", + }, + ], + + // Server options + serverName: "my-deepagents-server", + serverVersion: "1.0.0", + workspaceRoot: process.cwd(), + debug: true, +}); + +await server.start(); +``` + +## Usage with Zed + +To use with [Zed](https://zed.dev), add the agent to your settings (`~/.config/zed/settings.json` on Linux, `~/Library/Application Support/Zed/settings.json` on macOS): + +### Simple Setup + +```json +{ + "agent": { + "profiles": { + "deepagents": { + "name": "DeepAgents", + "command": "npx", + "args": ["deepagents-server"] + } + } + } +} +``` + +### With Options + +```json +{ + "agent": { + "profiles": { + "deepagents": { + "name": "DeepAgents", + "command": "npx", + "args": [ + "deepagents-server", + "--name", "my-assistant", + "--skills", "./skills", + "--debug" + ], + "env": { + "ANTHROPIC_API_KEY": "sk-ant-..." + } + } + } + } +} +``` + +### Custom Script (Advanced) + +For more control, create a custom script: + +```typescript +// server.ts +import { startServer } from "deepagents-server"; + +await startServer({ + agents: { + name: "my-agent", + description: "My custom coding agent", + skills: ["./skills/"], + }, +}); +``` + +Then configure Zed: + +```json +{ + "agent": { + "profiles": { + "my-agent": { + "name": "My Agent", + "command": "npx", + "args": ["tsx", "./server.ts"] + } + } + } +} +``` + +## API Reference + +### DeepAgentsServer + +The main server class that handles ACP communication. + +```typescript +import { DeepAgentsServer } from "deepagents-server"; + +const server = new DeepAgentsServer(options); +``` + +#### Options + +| Option | Type | Description | +| --------------- | -------------------------------------- | -------------------------------------------------- | +| `agents` | `DeepAgentConfig \| DeepAgentConfig[]` | Agent configuration(s) | +| `serverName` | `string` | Server name for ACP (default: "deepagents-server") | +| `serverVersion` | `string` | Server version (default: "0.0.1") | +| `workspaceRoot` | `string` | Workspace root directory (default: cwd) | +| `debug` | `boolean` | Enable debug logging (default: false) | + +#### DeepAgentConfig + +| Option | Type | Description | +| -------------- | ----------------------------------- | ------------------------------------------------- | +| `name` | `string` | Unique agent name (required) | +| `description` | `string` | Agent description | +| `model` | `string` | LLM model (default: "claude-sonnet-4-5-20250929") | +| `tools` | `StructuredTool[]` | Custom tools | +| `systemPrompt` | `string` | Custom system prompt | +| `middleware` | `AgentMiddleware[]` | Custom middleware | +| `backend` | `BackendProtocol \| BackendFactory` | Filesystem backend | +| `skills` | `string[]` | Skill source paths | +| `memory` | `string[]` | Memory source paths (AGENTS.md) | + +### Methods + +#### start() + +Start the ACP server. Listens on stdio by default. + +```typescript +await server.start(); +``` + +#### stop() + +Stop the server and cleanup. + +```typescript +server.stop(); +``` + +### startServer() + +Convenience function to create and start a server. + +```typescript +import { startServer } from "deepagents-server"; + +const server = await startServer(options); +``` + +## ACP Protocol Support + +This package implements the following ACP methods: + +### Agent Methods (what we implement) + +| Method | Description | +| ------------------ | ----------------------------------- | +| `initialize` | Negotiate versions and capabilities | +| `authenticate` | Handle authentication (passthrough) | +| `session/new` | Create a new conversation session | +| `session/load` | Resume an existing session | +| `session/prompt` | Process user prompts | +| `session/cancel` | Cancel ongoing operations | +| `session/set_mode` | Switch agent modes | + +### Session Updates (what we send) + +| Update | Description | +| ----------------------- | ----------------------------- | +| `agent_message_chunk` | Stream agent text responses | +| `thought_message_chunk` | Stream agent thinking | +| `tool_call` | Notify about tool invocations | +| `tool_call_update` | Update tool call status | +| `plan` | Send task plan entries | + +### Capabilities + +The server advertises these capabilities: + +- `fsReadTextFile`: File reading support +- `fsWriteTextFile`: File writing support +- `loadSession`: Session persistence +- `modes`: Agent mode switching +- `commands`: Slash command support + +## Modes + +The server supports three operating modes: + +1. **Agent Mode** (`agent`): Full autonomous agent with file access +2. **Plan Mode** (`plan`): Planning and discussion without changes +3. **Ask Mode** (`ask`): Q&A without file modifications + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ IDE (Zed, JetBrains) │ +│ ACP Client │ +└─────────────────────┬───────────────────────────────────────┘ + │ stdio (JSON-RPC 2.0) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ deepagents-server │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ AgentSideConnection │ │ +│ │ (from @agentclientprotocol/sdk) │ │ +│ └─────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────▼───────────────────────────────┐ │ +│ │ Message Adapter │ │ +│ │ ACP ContentBlock ←→ LangChain Messages │ │ +│ └─────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────▼───────────────────────────────┐ │ +│ │ DeepAgent │ │ +│ │ (from deepagents package) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Examples + +### Custom Backend + +```typescript +import { DeepAgentsServer } from "deepagents-server"; +import { CompositeBackend, FilesystemBackend, StateBackend } from "deepagents"; + +const server = new DeepAgentsServer({ + agents: { + name: "custom-agent", + backend: new CompositeBackend({ + routes: [ + { prefix: "/workspace", backend: new FilesystemBackend({ rootDir: "./workspace" }) }, + { prefix: "/", backend: (config) => new StateBackend(config) }, + ], + }), + }, +}); +``` + +### With Custom Tools + +```typescript +import { DeepAgentsServer } from "deepagents-server"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +const searchTool = tool( + async ({ query }) => { + // Search implementation + return `Results for: ${query}`; + }, + { + name: "search", + description: "Search the codebase", + schema: z.object({ query: z.string() }), + } +); + +const server = new DeepAgentsServer({ + agents: { + name: "search-agent", + tools: [searchTool], + }, +}); +``` + +## Contributing + +See the main [deepagentsjs repository](https://github.com/langchain-ai/deepagentsjs) for contribution guidelines. + +## License + +MIT + +## Resources + +- [Agent Client Protocol Documentation](https://agentclientprotocol.com) +- [ACP TypeScript SDK](https://github.com/agentclientprotocol/typescript-sdk) +- [DeepAgents Documentation](https://github.com/langchain-ai/deepagentsjs) +- [Zed Editor](https://zed.dev) diff --git a/libs/deepagents-server/package.json b/libs/deepagents-server/package.json new file mode 100644 index 00000000..3e300570 --- /dev/null +++ b/libs/deepagents-server/package.json @@ -0,0 +1,82 @@ +{ + "name": "deepagents-server", + "version": "0.0.1", + "description": "ACP (Agent Client Protocol) server for DeepAgents - enables IDE integration with Zed, JetBrains, and other ACP clients", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "bin": { + "deepagents-server": "./dist/cli.js" + }, + "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: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", + "acp", + "agent-client-protocol", + "zed", + "ide", + "langgraph", + "langchain", + "typescript" + ], + "author": "LangChain", + "license": "MIT", + "bugs": { + "url": "https://github.com/langchain-ai/deepagentsjs/issues" + }, + "homepage": "https://github.com/langchain-ai/deepagentsjs#readme", + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.0", + "deepagents": "workspace:*" + }, + "devDependencies": { + "@langchain/core": "^1.1.19", + "@langchain/langgraph": "^1.1.3", + "@langchain/langgraph-checkpoint": "^1.0.0", + "@tsconfig/recommended": "^1.0.13", + "@types/node": "^25.1.0", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "tsdown": "^0.20.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0", + "@langchain/langgraph": "^1.0.0" + }, + "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-server/src/adapter.test.ts b/libs/deepagents-server/src/adapter.test.ts new file mode 100644 index 00000000..6c5aa168 --- /dev/null +++ b/libs/deepagents-server/src/adapter.test.ts @@ -0,0 +1,477 @@ +/** + * Unit tests for the ACP <-> LangChain adapter functions + */ + +import { describe, it, expect } from "vitest"; +import { HumanMessage, AIMessage } from "@langchain/core/messages"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +import { + acpContentToLangChain, + acpPromptToHumanMessage, + langChainContentToACP, + langChainMessageToACP, + extractToolCalls, + todosToPlanEntries, + generateSessionId, + generateToolCallId, + fileUriToPath, + pathToFileUri, + getToolCallKind, + formatToolCallTitle, +} from "./adapter.js"; + +describe("acpContentToLangChain", () => { + it("should convert single text block to string", () => { + const content: ContentBlock[] = [{ type: "text", text: "Hello world" }]; + const result = acpContentToLangChain(content); + expect(result).toBe("Hello world"); + }); + + it("should convert multiple text blocks to array", () => { + const content: ContentBlock[] = [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ]; + const result = acpContentToLangChain(content); + expect(result).toEqual([ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ]); + }); + + it("should convert image block with base64 data", () => { + const content = [ + { + type: "image", + data: "base64encodeddata", + mediaType: "image/jpeg", + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toEqual([ + { + type: "image_url", + image_url: "data:image/jpeg;base64,base64encodeddata", + }, + ]); + }); + + it("should convert image block with URL", () => { + const content = [ + { + type: "image", + url: "https://example.com/image.png", + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toEqual([ + { + type: "image_url", + image_url: "https://example.com/image.png", + }, + ]); + }); + + it("should default to image/png for image without mediaType", () => { + const content = [ + { + type: "image", + data: "somedata", + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toEqual([ + { + type: "image_url", + image_url: "data:image/png;base64,somedata", + }, + ]); + }); + + it("should convert resource block to text", () => { + const content = [ + { + type: "resource", + resource: { + uri: "file:///path/to/file.txt", + text: "File contents here", + }, + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toEqual([ + { + type: "text", + text: "[Resource: file:///path/to/file.txt]\nFile contents here", + }, + ]); + }); + + it("should handle resource without uri or text", () => { + const content = [ + { + type: "resource", + resource: {}, + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toEqual([ + { + type: "text", + text: "[Resource: unknown]\n", + }, + ]); + }); + + it("should handle unknown block types", () => { + const content = [ + { + type: "custom", + data: "something", + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toHaveLength(1); + expect((result as Array<{ type: string }>)[0].type).toBe("text"); + }); + + it("should handle mixed content types", () => { + const content = [ + { type: "text", text: "Check this image:" }, + { + type: "image", + url: "https://example.com/img.png", + }, + ] as unknown as ContentBlock[]; + + const result = acpContentToLangChain(content); + expect(result).toHaveLength(2); + expect((result as Array<{ type: string }>)[0]).toEqual({ + type: "text", + text: "Check this image:", + }); + expect((result as Array<{ type: string; image_url: string }>)[1]).toEqual({ + type: "image_url", + image_url: "https://example.com/img.png", + }); + }); +}); + +describe("acpPromptToHumanMessage", () => { + it("should create HumanMessage from text content", () => { + const content: ContentBlock[] = [{ type: "text", text: "Hello assistant" }]; + const result = acpPromptToHumanMessage(content); + + expect(result).toBeInstanceOf(HumanMessage); + expect(result.content).toBe("Hello assistant"); + }); + + it("should create HumanMessage with array content for multiple blocks", () => { + const content: ContentBlock[] = [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ]; + const result = acpPromptToHumanMessage(content); + + expect(result).toBeInstanceOf(HumanMessage); + expect(Array.isArray(result.content)).toBe(true); + }); +}); + +describe("langChainContentToACP", () => { + it("should convert string content to text block", () => { + const result = langChainContentToACP("Hello world"); + expect(result).toEqual([{ type: "text", text: "Hello world" }]); + }); + + it("should convert array of text blocks", () => { + const content = [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ]; + const result = langChainContentToACP(content); + expect(result).toEqual([ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ]); + }); + + it("should convert non-text blocks to JSON string", () => { + const content = [{ type: "image_url", image_url: "https://example.com" }]; + const result = langChainContentToACP(content); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe("text"); + expect((result[0] as { text: string }).text).toContain("image_url"); + }); +}); + +describe("langChainMessageToACP", () => { + it("should convert AIMessage with string content", () => { + const message = new AIMessage("I can help with that"); + const result = langChainMessageToACP(message); + + expect(result).toEqual([{ type: "text", text: "I can help with that" }]); + }); + + it("should convert HumanMessage with string content", () => { + const message = new HumanMessage("Help me please"); + const result = langChainMessageToACP(message); + + expect(result).toEqual([{ type: "text", text: "Help me please" }]); + }); +}); + +describe("extractToolCalls", () => { + it("should extract tool calls from AIMessage", () => { + const message = new AIMessage({ + content: "Let me help", + tool_calls: [ + { + id: "call_123", + name: "read_file", + args: { path: "/test.txt" }, + }, + ], + }); + + const result = extractToolCalls(message); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: "call_123", + name: "read_file", + args: { path: "/test.txt" }, + status: "pending", + }); + }); + + it("should handle multiple tool calls", () => { + const message = new AIMessage({ + content: "", + tool_calls: [ + { id: "call_1", name: "ls", args: { path: "/" } }, + { id: "call_2", name: "grep", args: { pattern: "TODO" } }, + ], + }); + + const result = extractToolCalls(message); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe("ls"); + expect(result[1].name).toBe("grep"); + }); + + it("should return empty array when no tool calls", () => { + const message = new AIMessage("Just text"); + const result = extractToolCalls(message); + + expect(result).toEqual([]); + }); + + it("should generate ID for tool calls without ID", () => { + const message = new AIMessage({ + content: "", + tool_calls: [{ name: "test", args: {} }], + }); + + const result = extractToolCalls(message); + + expect(result).toHaveLength(1); + expect(result[0].id).toBeDefined(); + expect(result[0].id.length).toBeGreaterThan(0); + }); +}); + +describe("todosToPlanEntries", () => { + it("should convert todos to plan entries", () => { + const todos = [ + { id: "1", content: "Task 1", status: "pending" as const }, + { id: "2", content: "Task 2", status: "in_progress" as const }, + { id: "3", content: "Task 3", status: "completed" as const }, + ]; + + const result = todosToPlanEntries(todos); + + expect(result).toEqual([ + { content: "Task 1", priority: "medium", status: "pending" }, + { content: "Task 2", priority: "medium", status: "in_progress" }, + { content: "Task 3", priority: "medium", status: "completed" }, + ]); + }); + + it("should convert cancelled to skipped", () => { + const todos = [{ id: "1", content: "Cancelled task", status: "cancelled" as const }]; + + const result = todosToPlanEntries(todos); + + expect(result[0].status).toBe("skipped"); + }); + + it("should preserve priority when provided", () => { + const todos = [ + { id: "1", content: "High priority", status: "pending" as const, priority: "high" }, + { id: "2", content: "Low priority", status: "pending" as const, priority: "low" }, + ]; + + const result = todosToPlanEntries(todos); + + expect(result[0].priority).toBe("high"); + expect(result[1].priority).toBe("low"); + }); + + it("should handle empty todos array", () => { + const result = todosToPlanEntries([]); + expect(result).toEqual([]); + }); +}); + +describe("generateSessionId", () => { + it("should generate unique session IDs", () => { + const id1 = generateSessionId(); + const id2 = generateSessionId(); + + expect(id1).not.toBe(id2); + }); + + it("should start with sess_ prefix", () => { + const id = generateSessionId(); + expect(id).toMatch(/^sess_/); + }); + + it("should have correct length", () => { + const id = generateSessionId(); + // "sess_" (5) + 16 hex chars = 21 + expect(id).toHaveLength(21); + }); +}); + +describe("generateToolCallId", () => { + it("should generate unique tool call IDs", () => { + const id1 = generateToolCallId(); + const id2 = generateToolCallId(); + + expect(id1).not.toBe(id2); + }); + + it("should start with call_ prefix", () => { + const id = generateToolCallId(); + expect(id).toMatch(/^call_/); + }); + + it("should have correct length", () => { + const id = generateToolCallId(); + // "call_" (5) + 12 hex chars = 17 + expect(id).toHaveLength(17); + }); +}); + +describe("fileUriToPath", () => { + it("should remove file:// prefix", () => { + const result = fileUriToPath("file:///path/to/file.txt"); + expect(result).toBe("/path/to/file.txt"); + }); + + it("should return path as-is if no file:// prefix", () => { + const result = fileUriToPath("/path/to/file.txt"); + expect(result).toBe("/path/to/file.txt"); + }); + + it("should handle relative paths", () => { + const result = fileUriToPath("./relative/path.txt"); + expect(result).toBe("./relative/path.txt"); + }); +}); + +describe("pathToFileUri", () => { + it("should add file:// prefix", () => { + const result = pathToFileUri("/path/to/file.txt"); + expect(result).toBe("file:///path/to/file.txt"); + }); + + it("should not double-prefix", () => { + const result = pathToFileUri("file:///path/to/file.txt"); + expect(result).toBe("file:///path/to/file.txt"); + }); +}); + +describe("getToolCallKind", () => { + it("should identify file read tools", () => { + expect(getToolCallKind("read_file")).toBe("file_read"); + expect(getToolCallKind("ls")).toBe("file_read"); + expect(getToolCallKind("grep")).toBe("file_read"); + expect(getToolCallKind("glob")).toBe("file_read"); + }); + + it("should identify file write tools", () => { + expect(getToolCallKind("write_file")).toBe("file_write"); + expect(getToolCallKind("edit_file")).toBe("file_write"); + }); + + it("should identify shell tools", () => { + expect(getToolCallKind("execute")).toBe("shell"); + expect(getToolCallKind("shell")).toBe("shell"); + expect(getToolCallKind("terminal")).toBe("shell"); + }); + + it("should return other for unknown tools", () => { + expect(getToolCallKind("custom_tool")).toBe("other"); + expect(getToolCallKind("unknown")).toBe("other"); + }); +}); + +describe("formatToolCallTitle", () => { + it("should format read_file title", () => { + const result = formatToolCallTitle("read_file", { path: "/src/index.ts" }); + expect(result).toBe("Reading /src/index.ts"); + }); + + it("should format write_file title", () => { + const result = formatToolCallTitle("write_file", { path: "/output.txt" }); + expect(result).toBe("Writing /output.txt"); + }); + + it("should format edit_file title", () => { + const result = formatToolCallTitle("edit_file", { path: "/config.json" }); + expect(result).toBe("Editing /config.json"); + }); + + it("should format ls title", () => { + const result = formatToolCallTitle("ls", { path: "/src" }); + expect(result).toBe("Listing /src"); + }); + + it("should format grep title", () => { + const result = formatToolCallTitle("grep", { pattern: "TODO" }); + expect(result).toBe('Searching for "TODO"'); + }); + + it("should format glob title", () => { + const result = formatToolCallTitle("glob", { pattern: "*.ts" }); + expect(result).toBe("Finding files matching *.ts"); + }); + + it("should format task title", () => { + const result = formatToolCallTitle("task", { description: "Run tests" }); + expect(result).toBe("Delegating: Run tests"); + }); + + it("should format unknown tool title", () => { + const result = formatToolCallTitle("custom_tool", { foo: "bar" }); + expect(result).toBe("Executing custom_tool"); + }); + + it("should handle missing args gracefully", () => { + expect(formatToolCallTitle("read_file", {})).toBe("Reading file"); + expect(formatToolCallTitle("ls", {})).toBe("Listing directory"); + expect(formatToolCallTitle("grep", {})).toBe('Searching for "pattern"'); + expect(formatToolCallTitle("task", {})).toBe("Delegating: subtask"); + }); +}); diff --git a/libs/deepagents-server/src/adapter.ts b/libs/deepagents-server/src/adapter.ts new file mode 100644 index 00000000..935adbbc --- /dev/null +++ b/libs/deepagents-server/src/adapter.ts @@ -0,0 +1,218 @@ +/** + * Message and content adapter for ACP <-> LangChain translation + * + * This module handles the conversion between ACP message formats + * and LangChain/LangGraph message formats. + */ + +import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +import type { ToolCallInfo, PlanEntry } from "./types.js"; + +/** + * Convert ACP content blocks to LangChain message content + */ +export function acpContentToLangChain( + content: ContentBlock[], +): string | Array<{ type: string; text?: string; image_url?: string }> { + if (content.length === 1 && content[0].type === "text") { + return content[0].text; + } + + return content.map((block) => { + // Cast to any for flexible type handling across ACP SDK versions + const b = block as Record; + switch (block.type) { + case "text": + return { type: "text", text: (block as { text: string }).text }; + case "image": { + // Handle different image source formats across SDK versions + const data = b.data as string | undefined; + const url = b.url as string | undefined; + const mediaType = b.mediaType as string | undefined; + if (data) { + return { + type: "image_url", + image_url: `data:${mediaType ?? "image/png"};base64,${data}`, + }; + } + return { + type: "image_url", + image_url: url ?? "", + }; + } + case "resource": { + // Resources are treated as text content with context + const resource = b.resource as + | { uri?: string; text?: string } + | undefined; + const uri = resource?.uri ?? "unknown"; + const text = resource?.text ?? ""; + return { + type: "text", + text: `[Resource: ${uri}]\n${text}`, + }; + } + default: + return { type: "text", text: String(block) }; + } + }); +} + +/** + * Convert ACP prompt content blocks to a LangChain HumanMessage + */ +export function acpPromptToHumanMessage(content: ContentBlock[]): HumanMessage { + return new HumanMessage({ + content: acpContentToLangChain(content), + }); +} + +/** + * Convert LangChain message content to ACP content blocks + */ +export function langChainContentToACP( + content: + | string + | Array<{ type: string; text?: string; [key: string]: unknown }>, +): ContentBlock[] { + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + + return content.map((block) => { + if (block.type === "text" && block.text) { + return { type: "text", text: block.text }; + } + // For other types, convert to text representation + return { type: "text", text: JSON.stringify(block) }; + }) as ContentBlock[]; +} + +/** + * Convert LangChain BaseMessage to ACP content blocks + */ +export function langChainMessageToACP(message: BaseMessage): ContentBlock[] { + return langChainContentToACP( + message.content as string | Array<{ type: string; text?: string }>, + ); +} + +/** + * Extract tool calls from LangChain AIMessage + */ +export function extractToolCalls(message: AIMessage): ToolCallInfo[] { + const toolCalls = message.tool_calls ?? []; + + return toolCalls.map((tc) => ({ + id: tc.id ?? crypto.randomUUID(), + name: tc.name, + args: tc.args as Record, + status: "pending" as const, + })); +} + +/** + * Convert todo list state to ACP plan entries + */ +export function todosToPlanEntries( + todos: Array<{ + id: string; + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + priority?: string; + }>, +): PlanEntry[] { + return todos.map((todo) => ({ + content: todo.content, + priority: (todo.priority as "high" | "medium" | "low") ?? "medium", + status: + todo.status === "cancelled" + ? "skipped" + : (todo.status as "pending" | "in_progress" | "completed"), + })); +} + +/** + * Generate a unique session ID + */ +export function generateSessionId(): string { + return `sess_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; +} + +/** + * Generate a unique tool call ID + */ +export function generateToolCallId(): string { + return `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +/** + * Parse file URI to absolute path + */ +export function fileUriToPath(uri: string): string { + if (uri.startsWith("file://")) { + return uri.slice(7); + } + return uri; +} + +/** + * Convert absolute path to file URI + */ +export function pathToFileUri(path: string): string { + if (path.startsWith("file://")) { + return path; + } + return `file://${path}`; +} + +/** + * Determine the kind of tool call for ACP display + */ +export function getToolCallKind( + toolName: string, +): "file_read" | "file_write" | "shell" | "other" { + const readTools = ["read_file", "ls", "grep", "glob"]; + const writeTools = ["write_file", "edit_file"]; + const shellTools = ["execute", "shell", "terminal"]; + + if (readTools.includes(toolName)) { + return "file_read"; + } + if (writeTools.includes(toolName)) { + return "file_write"; + } + if (shellTools.includes(toolName)) { + return "shell"; + } + return "other"; +} + +/** + * Format tool call title for ACP display + */ +export function formatToolCallTitle( + toolName: string, + args: Record, +): string { + switch (toolName) { + case "read_file": + return `Reading ${args.path ?? "file"}`; + case "write_file": + return `Writing ${args.path ?? "file"}`; + case "edit_file": + return `Editing ${args.path ?? "file"}`; + case "ls": + return `Listing ${args.path ?? "directory"}`; + case "grep": + return `Searching for "${args.pattern ?? "pattern"}"`; + case "glob": + return `Finding files matching ${args.pattern ?? "pattern"}`; + case "task": + return `Delegating: ${args.description ?? "subtask"}`; + default: + return `Executing ${toolName}`; + } +} diff --git a/libs/deepagents-server/src/cli.ts b/libs/deepagents-server/src/cli.ts new file mode 100644 index 00000000..f2a3189b --- /dev/null +++ b/libs/deepagents-server/src/cli.ts @@ -0,0 +1,274 @@ +/** + * DeepAgents ACP Server CLI + * + * Run a DeepAgents ACP server for integration with IDEs like Zed. + * + * Usage: + * npx deepagents-server [options] + * + * Options: + * --name Agent name (default: "deepagents") + * --description Agent description + * --model LLM model (default: "claude-sonnet-4-5-20250929") + * --workspace Workspace root directory (default: cwd) + * --skills Comma-separated skill paths + * --memory Comma-separated memory/AGENTS.md paths + * --debug Enable debug logging + * --help Show this help message + * --version Show version + */ + +import { DeepAgentsServer } from "./server.js"; +import { FilesystemBackend } from "deepagents"; +import * as path from "node:path"; +import * as fs from "node:fs"; + +interface CLIOptions { + name: string; + description: string; + model: string; + workspace: string; + skills: string[]; + memory: string[]; + debug: boolean; + help: boolean; + version: boolean; +} + +function parseArgs(args: string[]): CLIOptions { + const options: CLIOptions = { + name: "deepagents", + description: "AI coding assistant powered by DeepAgents", + model: "claude-sonnet-4-5-20250929", + workspace: process.cwd(), + skills: [], + memory: [], + debug: process.env.DEBUG === "true", + help: false, + version: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const nextArg = args[i + 1]; + + switch (arg) { + case "--name": + case "-n": + if (nextArg) { + options.name = nextArg; + i++; + } + break; + + case "--description": + case "-d": + if (nextArg) { + options.description = nextArg; + i++; + } + break; + + case "--model": + case "-m": + if (nextArg) { + options.model = nextArg; + i++; + } + break; + + case "--workspace": + case "-w": + if (nextArg) { + options.workspace = path.resolve(nextArg); + i++; + } + break; + + case "--skills": + case "-s": + if (nextArg) { + options.skills = nextArg.split(",").map((p) => p.trim()); + i++; + } + break; + + case "--memory": + if (nextArg) { + options.memory = nextArg.split(",").map((p) => p.trim()); + i++; + } + break; + + case "--debug": + options.debug = true; + break; + + case "--help": + case "-h": + options.help = true; + break; + + case "--version": + case "-v": + options.version = true; + break; + } + } + + return options; +} + +function showHelp(): void { + console.log(` +DeepAgents ACP Server + +Run a DeepAgents-powered AI coding assistant that integrates with IDEs +like Zed, JetBrains, and other ACP-compatible clients. + +USAGE: + npx deepagents-server [options] + +OPTIONS: + -n, --name Agent name (default: "deepagents") + -d, --description Agent description + -m, --model LLM model (default: "claude-sonnet-4-5-20250929") + -w, --workspace Workspace root directory (default: current directory) + -s, --skills Comma-separated skill paths (SKILL.md locations) + --memory Comma-separated memory paths (AGENTS.md locations) + --debug Enable debug logging to stderr + -h, --help Show this help message + -v, --version Show version + +ENVIRONMENT VARIABLES: + ANTHROPIC_API_KEY API key for Anthropic models (required for Claude) + OPENAI_API_KEY API key for OpenAI models + DEBUG Set to "true" to enable debug logging + WORKSPACE_ROOT Alternative to --workspace flag + +EXAMPLES: + # Start with defaults + npx deepagents-server + + # Custom agent with skills + npx deepagents-server --name my-agent --skills ./skills,~/.deepagents/skills + + # Debug mode with custom workspace + npx deepagents-server --debug --workspace /path/to/project + +ZED INTEGRATION: + Add to your Zed settings.json: + + { + "agent": { + "profiles": { + "deepagents": { + "name": "DeepAgents", + "command": "npx", + "args": ["deepagents-server"], + "env": {} + } + } + } + } + +For more information, visit: + https://github.com/langchain-ai/deepagentsjs +`); +} + +function showVersion(): void { + // Read version from package.json + try { + const packageJsonPath = path.resolve( + import.meta.dirname ?? __dirname, + "..", + "package.json", + ); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + console.log(`deepagents-server v${packageJson.version}`); + } catch { + console.log("deepagents-server v0.0.1"); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const options = parseArgs(args); + + if (options.help) { + showHelp(); + process.exit(0); + } + + if (options.version) { + showVersion(); + process.exit(0); + } + + // Use environment variable as fallback for workspace + const workspaceRoot = + options.workspace || process.env.WORKSPACE_ROOT || process.cwd(); + + // Build default skill/memory paths if not provided + const defaultSkillPaths = [ + path.join(workspaceRoot, ".deepagents", "skills"), + path.join(workspaceRoot, "skills"), + ]; + + const defaultMemoryPaths = [ + path.join(workspaceRoot, ".deepagents", "AGENTS.md"), + path.join(workspaceRoot, "AGENTS.md"), + ]; + + const skills = + options.skills.length > 0 + ? options.skills.map((p) => path.resolve(workspaceRoot, p)) + : defaultSkillPaths; + + const memory = + options.memory.length > 0 + ? options.memory.map((p) => path.resolve(workspaceRoot, p)) + : defaultMemoryPaths; + + // Log startup info to stderr (stdout is reserved for ACP protocol) + const log = (...msgArgs: unknown[]) => { + if (options.debug) { + console.error("[deepagents-server]", ...msgArgs); + } + }; + + log("Starting..."); + log("Agent:", options.name); + log("Model:", options.model); + log("Workspace:", workspaceRoot); + log("Skills:", skills.join(", ")); + log("Memory:", memory.join(", ")); + + try { + const server = new DeepAgentsServer({ + agents: { + name: options.name, + description: options.description, + model: options.model, + backend: new FilesystemBackend({ rootDir: workspaceRoot }), + skills, + memory, + }, + serverName: "deepagents-server", + workspaceRoot, + debug: options.debug, + }); + + await server.start(); + } catch (error) { + console.error("[deepagents-server] Fatal error:", error); + process.exit(1); + } +} + +// Handle top-level errors +main().catch((err) => { + console.error("[deepagents-server] Unhandled error:", err); + process.exit(1); +}); diff --git a/libs/deepagents-server/src/index.ts b/libs/deepagents-server/src/index.ts new file mode 100644 index 00000000..3f98cec7 --- /dev/null +++ b/libs/deepagents-server/src/index.ts @@ -0,0 +1,67 @@ +/** + * DeepAgents Server - ACP Integration + * + * This package provides an Agent Client Protocol (ACP) server that wraps + * DeepAgents, enabling seamless integration with IDEs like Zed, JetBrains, + * and other ACP-compatible clients. + * + * @packageDocumentation + * @module deepagents-server + * + * @example + * ```typescript + * import { DeepAgentsServer, startServer } from "deepagents-server"; + * + * // Quick start + * await startServer({ + * agents: { + * name: "coding-assistant", + * description: "AI coding assistant with filesystem access", + * }, + * workspaceRoot: process.cwd(), + * }); + * + * // Or create a server instance manually + * const server = new DeepAgentsServer({ + * agents: [{ + * name: "coding-assistant", + * description: "AI coding assistant", + * skills: ["./skills/"], + * memory: ["./.deepagents/AGENTS.md"], + * }], + * debug: true, + * }); + * + * await server.start(); + * ``` + */ + +// Main server export +export { DeepAgentsServer, startServer } from "./server.js"; + +// Type exports +export type { + DeepAgentConfig, + DeepAgentsServerOptions, + SessionState, + ToolCallInfo, + PlanEntry, + StopReason, + ACPCapabilities, + ServerEvents, +} from "./types.js"; + +// Adapter utilities (for advanced use cases) +export { + acpPromptToHumanMessage, + langChainMessageToACP, + langChainContentToACP, + extractToolCalls, + todosToPlanEntries, + generateSessionId, + generateToolCallId, + getToolCallKind, + formatToolCallTitle, + fileUriToPath, + pathToFileUri, +} from "./adapter.js"; diff --git a/libs/deepagents-server/src/server.ts b/libs/deepagents-server/src/server.ts new file mode 100644 index 00000000..fd8dbcd5 --- /dev/null +++ b/libs/deepagents-server/src/server.ts @@ -0,0 +1,841 @@ +/** + * DeepAgents ACP Server Implementation + * + * This module provides an ACP (Agent Client Protocol) server that wraps + * DeepAgents, enabling integration with IDEs like Zed, JetBrains, and others. + * + * @see https://agentclientprotocol.com + * @see https://github.com/agentclientprotocol/typescript-sdk + */ + +import { + AgentSideConnection, + ndJsonStream, + type Agent, + type ContentBlock, +} from "@agentclientprotocol/sdk"; + +import { + createDeepAgent, + FilesystemBackend, + type BackendProtocol, + type BackendFactory, +} from "deepagents"; + +import { HumanMessage, AIMessage, isAIMessage } from "@langchain/core/messages"; +import { MemorySaver } from "@langchain/langgraph-checkpoint"; + +import type { + DeepAgentConfig, + DeepAgentsServerOptions, + SessionState, + ToolCallInfo, + StopReason, + ACPCapabilities, +} from "./types.js"; + +import { + acpPromptToHumanMessage, + langChainMessageToACP, + extractToolCalls, + todosToPlanEntries, + generateSessionId, + getToolCallKind, + formatToolCallTitle, +} from "./adapter.js"; + +// Type definitions for ACP requests/responses (SDK uses generic types) +type InitializeRequest = Record; +type InitializeResponse = Record; +type NewSessionRequest = Record; +type NewSessionResponse = Record; +type LoadSessionRequest = Record; +type LoadSessionResponse = Record; +type PromptRequest = Record; +type PromptResponse = Record; +type CancelNotification = Record; +type SetSessionModeRequest = Record; +type SetSessionModeResponse = Record; +type AuthenticateRequest = Record; +type AuthenticateResponse = Record; +type SessionNotification = Record; + +/** + * DeepAgents ACP Server + * + * Wraps DeepAgents with the Agent Client Protocol, enabling communication + * with ACP clients like Zed, JetBrains IDEs, and other compatible tools. + * + * @example + * ```typescript + * import { DeepAgentsServer } from "deepagents-server"; + * + * const server = new DeepAgentsServer({ + * agents: { + * name: "coding-assistant", + * description: "AI coding assistant with filesystem access", + * }, + * workspaceRoot: process.cwd(), + * }); + * + * await server.start(); + * ``` + */ +export class DeepAgentsServer { + private connection: AgentSideConnection | null = null; + private agents: Map> = new Map(); + private agentConfigs: Map = new Map(); + private sessions: Map = new Map(); + private checkpointer: MemorySaver; + private clientCapabilities: ACPCapabilities = {}; + private isRunning = false; + private currentPromptAbortController: AbortController | null = null; + + private readonly serverName: string; + private readonly serverVersion: string; + private readonly debug: boolean; + private readonly workspaceRoot: string; + + constructor(options: DeepAgentsServerOptions) { + this.serverName = options.serverName ?? "deepagents-server"; + this.serverVersion = options.serverVersion ?? "0.0.1"; + this.debug = options.debug ?? false; + this.workspaceRoot = options.workspaceRoot ?? process.cwd(); + + // Shared checkpointer for session persistence + this.checkpointer = new MemorySaver(); + + // Initialize agent configurations + const agentConfigs = Array.isArray(options.agents) + ? options.agents + : [options.agents]; + + for (const config of agentConfigs) { + this.agentConfigs.set(config.name, config); + } + + this.log("Initialized with agents:", [...this.agentConfigs.keys()]); + } + + /** + * Start the ACP server and listen for connections + * + * Uses stdio transport by default (stdin/stdout) + */ + async start(): Promise { + if (this.isRunning) { + throw new Error("Server is already running"); + } + + // Set up process signal handlers for graceful shutdown + const handleSignal = (signal: string) => { + this.log(`Received ${signal}, shutting down...`); + this.stop(); + process.exit(0); + }; + + process.on("SIGINT", () => handleSignal("SIGINT")); + process.on("SIGTERM", () => handleSignal("SIGTERM")); + + // Handle uncaught errors + process.on("uncaughtException", (err) => { + this.log("Uncaught exception:", err); + process.exit(1); + }); + + process.on("unhandledRejection", (reason) => { + this.log("Unhandled rejection:", reason); + // Don't exit - try to keep running + }); + + try { + // Create the stdio stream for ACP communication + // ndJsonStream signature: (output: WritableStream, input: ReadableStream) + // output = where we write responses (stdout) + // input = where we read requests from (stdin) + const input = new ReadableStream({ + start: (controller) => { + // Keep stdin open in raw mode for continuous reading + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + process.stdin.on("data", (chunk: Buffer) => { + try { + controller.enqueue(new Uint8Array(chunk)); + } catch (err) { + this.log("Error enqueueing data:", err); + } + }); + + process.stdin.on("end", () => { + this.log("stdin ended"); + try { + controller.close(); + } catch { + // Controller may already be closed + } + }); + + process.stdin.on("error", (err) => { + this.log("stdin error:", err); + try { + controller.error(err); + } catch { + // Controller may already be errored + } + }); + + process.stdin.on("close", () => { + this.log("stdin closed"); + }); + }, + }); + + const output = new WritableStream({ + write: (chunk) => { + return new Promise((resolve, reject) => { + if (!process.stdout.writable) { + this.log("stdout not writable, dropping message"); + resolve(); + return; + } + process.stdout.write(chunk, (err) => { + if (err) { + this.log("stdout write error:", err); + reject(err); + } else { + resolve(); + } + }); + }); + }, + close: () => { + this.log("output stream closed"); + }, + abort: (reason) => { + this.log("output stream aborted:", reason); + }, + }); + + // ndJsonStream(output, input) - output first, then input + const stream = ndJsonStream(output, input); + + // Create the agent-side connection with our Agent implementation + this.connection = new AgentSideConnection( + (conn) => this.createAgentHandler(conn), + stream, + ); + + this.isRunning = true; + this.log("Server started, waiting for connections..."); + + // Wait for the connection to close + await this.connection.closed; + + this.isRunning = false; + this.log("Server stopped"); + } catch (err) { + this.log("Server error:", err); + this.isRunning = false; + throw err; + } + } + + /** + * Stop the ACP server + */ + stop(): void { + if (!this.isRunning) { + return; + } + + this.isRunning = false; + this.connection = null; + this.sessions.clear(); + this.log("Server stopped"); + } + + /** + * Create the Agent handler for ACP + */ + private createAgentHandler(conn: AgentSideConnection): Agent { + return { + initialize: (params) => + this.handleInitialize(params as InitializeRequest), + authenticate: (params) => + this.handleAuthenticate(params as AuthenticateRequest), + newSession: (params) => + this.handleNewSession(params as NewSessionRequest, conn), + loadSession: (params) => + this.handleLoadSession(params as LoadSessionRequest, conn), + prompt: (params) => this.handlePrompt(params as PromptRequest, conn), + cancel: (params) => this.handleCancel(params as CancelNotification), + setSessionMode: (params) => + this.handleSetSessionMode(params as SetSessionModeRequest), + }; + } + + /** + * Handle ACP initialize request + */ + private async handleInitialize( + params: InitializeRequest, + ): Promise { + this.log( + "Client connected:", + params.clientName ?? "unknown", + params.clientVersion ?? "unknown", + ); + + // Store client capabilities + const capabilities = params.capabilities as + | Record + | undefined; + if (capabilities) { + const fs = capabilities.fs as Record | undefined; + this.clientCapabilities = { + fsReadTextFile: fs?.readTextFile ?? false, + fsWriteTextFile: fs?.writeTextFile ?? false, + terminal: capabilities.terminal !== undefined, + }; + } + + return { + serverName: this.serverName, + serverVersion: this.serverVersion, + protocolVersion: params.protocolVersion ?? "1.0", + capabilities: { + // We support session loading + loadSession: true, + // We support modes + modes: true, + // We support commands + commands: true, + }, + // Prompt capabilities - what content types we accept + promptCapabilities: { + text: true, + images: true, + resources: true, + }, + }; + } + + /** + * Handle ACP authenticate request (no-op for now) + */ + private async handleAuthenticate( + _params: AuthenticateRequest, + ): Promise { + // No authentication required + return; + } + + /** + * Handle ACP session/new request + */ + private async handleNewSession( + params: NewSessionRequest, + _conn: AgentSideConnection, + ): Promise { + const sessionId = generateSessionId(); + const threadId = crypto.randomUUID(); + + // Default to first agent if not specified + const configOptions = params.configOptions as + | Record + | undefined; + const agentName = + (configOptions?.agent as string) ?? [...this.agentConfigs.keys()][0]; + + if (!agentName || !this.agentConfigs.has(agentName)) { + throw new Error(`Unknown agent: ${agentName}`); + } + + // Create session state + const session: SessionState = { + id: sessionId, + agentName, + threadId, + messages: [], + createdAt: new Date(), + lastActivityAt: new Date(), + mode: params.mode as string | undefined, + }; + + this.sessions.set(sessionId, session); + + // Lazily create the agent if not already created + if (!this.agents.has(agentName)) { + this.createAgent(agentName); + } + + this.log("Created session:", sessionId, "for agent:", agentName); + + return { + sessionId, + // Available modes for this agent + availableModes: [ + { + id: "agent", + name: "Agent Mode", + description: "Full autonomous agent", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning and discussion", + }, + { + id: "ask", + name: "Ask Mode", + description: "Q&A without file changes", + }, + ], + currentMode: (params.mode as string) ?? "agent", + // Available slash commands + availableCommands: [ + { name: "help", description: "Show available commands" }, + { name: "clear", description: "Clear conversation history" }, + { name: "status", description: "Show current task status" }, + ], + }; + } + + /** + * Handle ACP session/load request + */ + private async handleLoadSession( + params: LoadSessionRequest, + _conn: AgentSideConnection, + ): Promise { + const sessionId = params.sessionId as string; + const session = this.sessions.get(sessionId); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + session.lastActivityAt = new Date(); + + return { + sessionId: session.id, + availableModes: [ + { + id: "agent", + name: "Agent Mode", + description: "Full autonomous agent", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning and discussion", + }, + { + id: "ask", + name: "Ask Mode", + description: "Q&A without file changes", + }, + ], + currentMode: session.mode ?? "agent", + availableCommands: [ + { name: "help", description: "Show available commands" }, + { name: "clear", description: "Clear conversation history" }, + { name: "status", description: "Show current task status" }, + ], + }; + } + + /** + * Handle ACP session/prompt request + * + * This is the main entry point for agent interactions + */ + private async handlePrompt( + params: PromptRequest, + conn: AgentSideConnection, + ): Promise { + const sessionId = params.sessionId as string; + const session = this.sessions.get(sessionId); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + const agent = this.agents.get(session.agentName); + + if (!agent) { + throw new Error(`Agent not found: ${session.agentName}`); + } + + session.lastActivityAt = new Date(); + + // Create abort controller for cancellation + this.currentPromptAbortController = new AbortController(); + + try { + // Convert ACP prompt to LangChain message + const prompt = params.prompt as ContentBlock[]; + const humanMessage = acpPromptToHumanMessage(prompt); + + // Stream the agent response + const stopReason = await this.streamAgentResponse( + session, + agent, + humanMessage, + conn, + ); + + return { stopReason }; + } catch (error) { + if ((error as Error).name === "AbortError") { + return { stopReason: "cancelled" }; + } + throw error; + } finally { + this.currentPromptAbortController = null; + } + } + + /** + * Handle ACP session/cancel notification + */ + private async handleCancel(params: CancelNotification): Promise { + this.log("Cancelling session:", params.sessionId); + + if (this.currentPromptAbortController) { + this.currentPromptAbortController.abort(); + } + } + + /** + * Handle ACP session/set_mode request + */ + private async handleSetSessionMode( + params: SetSessionModeRequest, + ): Promise { + const sessionId = params.sessionId as string; + const session = this.sessions.get(sessionId); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + session.mode = params.mode as string; + this.log("Set mode for session:", sessionId, "to:", params.mode); + + return; + } + + /** + * Stream agent response and send ACP updates + */ + private async streamAgentResponse( + session: SessionState, + agent: ReturnType, + humanMessage: HumanMessage, + conn: AgentSideConnection, + ): Promise { + const config = { + configurable: { thread_id: session.threadId }, + signal: this.currentPromptAbortController?.signal, + }; + + // Track active tool calls + const activeToolCalls = new Map(); + + // Stream the agent + const stream = await agent.stream({ messages: [humanMessage] }, config); + + for await (const event of stream) { + // Check for cancellation + if (this.currentPromptAbortController?.signal.aborted) { + // Cancel all active tool calls + for (const toolCall of activeToolCalls.values()) { + await this.sendToolCallUpdate(session.id, conn, { + ...toolCall, + status: "cancelled", + }); + } + return "cancelled"; + } + + // Handle different event types + if (event.messages && Array.isArray(event.messages)) { + for (const message of event.messages) { + if (isAIMessage(message)) { + await this.handleAIMessage( + session, + message as AIMessage, + activeToolCalls, + conn, + ); + } + } + } + + // Handle todo list updates (plan entries) + if (event.todos && Array.isArray(event.todos)) { + const planEntries = todosToPlanEntries(event.todos); + await this.sendPlanUpdate(session.id, conn, planEntries); + } + } + + return "end_turn"; + } + + /** + * Handle an AI message from the agent + */ + private async handleAIMessage( + session: SessionState, + message: AIMessage, + activeToolCalls: Map, + conn: AgentSideConnection, + ): Promise { + // Handle text content + if (message.content && typeof message.content === "string") { + const contentBlocks = langChainMessageToACP(message); + await this.sendMessageChunk(session.id, conn, "agent", contentBlocks); + } + + // Handle tool calls + const toolCalls = extractToolCalls(message); + + for (const toolCall of toolCalls) { + // Send tool call notification + await this.sendToolCall(session.id, conn, toolCall); + activeToolCalls.set(toolCall.id, toolCall); + + // Update to in_progress + toolCall.status = "in_progress"; + await this.sendToolCallUpdate(session.id, conn, toolCall); + } + } + + /** + * Send a message chunk update to the client + */ + private async sendMessageChunk( + sessionId: string, + conn: AgentSideConnection, + messageType: "agent" | "user" | "thought", + content: ContentBlock[], + ): Promise { + const sessionUpdate = + messageType === "thought" + ? "thought_message_chunk" + : messageType === "user" + ? "user_message_chunk" + : "agent_message_chunk"; + + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate, + content: content[0], // ACP expects single content block per chunk + }, + } as SessionNotification); + } + + /** + * Send a tool call notification to the client + */ + private async sendToolCall( + sessionId: string, + conn: AgentSideConnection, + toolCall: ToolCallInfo, + ): Promise { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: toolCall.id, + title: formatToolCallTitle(toolCall.name, toolCall.args), + kind: getToolCallKind(toolCall.name), + status: toolCall.status, + }, + } as SessionNotification); + } + + /** + * Send a tool call update to the client + */ + private async sendToolCallUpdate( + sessionId: string, + conn: AgentSideConnection, + toolCall: ToolCallInfo, + ): Promise { + const update: Record = { + sessionUpdate: "tool_call_update", + toolCallId: toolCall.id, + status: toolCall.status, + }; + + // Add content if completed + if (toolCall.status === "completed" && toolCall.result) { + update.content = [ + { + type: "content", + content: { + type: "text", + text: + typeof toolCall.result === "string" + ? toolCall.result + : JSON.stringify(toolCall.result, null, 2), + }, + }, + ]; + } + + await conn.sessionUpdate({ + sessionId, + update, + } as SessionNotification); + } + + /** + * Send a plan update to the client + */ + private async sendPlanUpdate( + sessionId: string, + conn: AgentSideConnection, + entries: Array<{ + content: string; + priority?: "high" | "medium" | "low"; + status: "pending" | "in_progress" | "completed" | "skipped"; + }>, + ): Promise { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries, + }, + } as SessionNotification); + } + + /** + * Create a DeepAgent instance for the given configuration + */ + private createAgent(agentName: string): void { + const config = this.agentConfigs.get(agentName); + + if (!config) { + throw new Error(`Agent configuration not found: ${agentName}`); + } + + // Create backend - prefer ACP filesystem if client supports it + const backend = this.createBackend(config); + + const agent = createDeepAgent({ + model: config.model, + tools: config.tools, + systemPrompt: config.systemPrompt, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + middleware: config.middleware as any, + backend, + skills: config.skills, + memory: config.memory, + checkpointer: this.checkpointer, + name: config.name, + }); + + this.agents.set(agentName, agent); + this.log("Created agent:", agentName); + } + + /** + * Create the appropriate backend for the agent + */ + private createBackend( + config: DeepAgentConfig, + ): BackendProtocol | BackendFactory { + // If a custom backend is provided, use it + if (config.backend) { + return config.backend; + } + + // If client supports file operations, we could create an ACP-backed filesystem + // For now, default to FilesystemBackend with workspace root + if ( + this.clientCapabilities.fsReadTextFile && + this.clientCapabilities.fsWriteTextFile + ) { + // TODO: Implement ACPFilesystemBackend that proxies to client + this.log("Client supports filesystem, using local backend"); + } + + return new FilesystemBackend({ + rootDir: this.workspaceRoot, + }); + } + + /** + * Read a file through the ACP client (if supported) + */ + async readFileViaClient(path: string): Promise { + if (!this.connection || !this.clientCapabilities.fsReadTextFile) { + return null; + } + + try { + const result = await this.connection.readTextFile({ path }); + return result.text; + } catch { + return null; + } + } + + /** + * Write a file through the ACP client (if supported) + */ + async writeFileViaClient(path: string, content: string): Promise { + if (!this.connection || !this.clientCapabilities.fsWriteTextFile) { + return false; + } + + try { + await this.connection.writeTextFile({ path, text: content }); + return true; + } catch { + return false; + } + } + + /** + * Log a debug message + */ + private log(...args: unknown[]): void { + if (this.debug) { + console.error("[deepagents-server]", ...args); + } + } +} + +/** + * Create and start a DeepAgents ACP server + * + * Convenience function for quick server setup + * + * @example + * ```typescript + * import { startServer } from "deepagents-server"; + * + * await startServer({ + * agents: { + * name: "my-agent", + * description: "My coding assistant", + * }, + * }); + * ``` + */ +export async function startServer( + options: DeepAgentsServerOptions, +): Promise { + const server = new DeepAgentsServer(options); + await server.start(); + return server; +} diff --git a/libs/deepagents-server/src/types.ts b/libs/deepagents-server/src/types.ts new file mode 100644 index 00000000..e7afdfbb --- /dev/null +++ b/libs/deepagents-server/src/types.ts @@ -0,0 +1,290 @@ +/** + * Type definitions for the DeepAgents ACP Server + * + * This module provides TypeScript type definitions for integrating + * DeepAgents with the Agent Client Protocol (ACP). + */ + +import type { BackendProtocol, BackendFactory } from "deepagents"; +import type { StructuredTool } from "@langchain/core/tools"; +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; + +// Re-export middleware type for convenience +type AgentMiddleware = unknown; + +// ResponseFormat placeholder (actual type comes from langchain) +type ResponseFormat = unknown; + +// Checkpointer type alias +type Checkpointer = BaseCheckpointSaver; + +/** + * Configuration for a DeepAgent exposed via ACP + */ +export interface DeepAgentConfig { + /** + * Unique name for this agent (used in session routing) + */ + name: string; + + /** + * Human-readable description of the agent's capabilities + */ + description?: string; + + /** + * LLM model to use (default: "claude-sonnet-4-5-20250929") + */ + model?: string; + + /** + * Custom tools available to the agent + */ + tools?: StructuredTool[]; + + /** + * Custom system prompt (combined with base prompt) + */ + systemPrompt?: string; + + /** + * Custom middleware array + */ + middleware?: AgentMiddleware[]; + + /** + * Backend for filesystem operations + * Can be an instance or a factory function + */ + backend?: BackendProtocol | BackendFactory; + + /** + * Array of skill source paths (SKILL.md files) + */ + skills?: string[]; + + /** + * Array of memory source paths (AGENTS.md files) + */ + memory?: string[]; + + /** + * Structured output format + */ + responseFormat?: ResponseFormat; + + /** + * State persistence checkpointer + */ + checkpointer?: Checkpointer; +} + +/** + * Server configuration options + */ +export interface DeepAgentsServerOptions { + /** + * Agent configuration(s) - can be a single agent or multiple + */ + agents: DeepAgentConfig | DeepAgentConfig[]; + + /** + * Server name for ACP initialization + */ + serverName?: string; + + /** + * Server version + */ + serverVersion?: string; + + /** + * Enable debug logging + */ + debug?: boolean; + + /** + * Workspace root directory (defaults to cwd) + */ + workspaceRoot?: string; +} + +/** + * ACP Session state + */ +export interface SessionState { + /** + * Session ID + */ + id: string; + + /** + * Agent name for this session + */ + agentName: string; + + /** + * LangGraph thread ID for state persistence + */ + threadId: string; + + /** + * Conversation messages history + */ + messages: unknown[]; + + /** + * Created timestamp + */ + createdAt: Date; + + /** + * Last activity timestamp + */ + lastActivityAt: Date; + + /** + * Current mode (if applicable) + */ + mode?: string; +} + +/** + * Tool call tracking for ACP updates + */ +export interface ToolCallInfo { + /** + * Unique tool call ID + */ + id: string; + + /** + * Tool name + */ + name: string; + + /** + * Tool arguments + */ + args: Record; + + /** + * Current status + */ + status: "pending" | "in_progress" | "completed" | "failed" | "cancelled"; + + /** + * Result content (if completed) + */ + result?: unknown; + + /** + * Error message (if failed) + */ + error?: string; +} + +/** + * Plan entry for ACP agent plan updates + */ +export interface PlanEntry { + /** + * Plan entry content/description + */ + content: string; + + /** + * Priority level + */ + priority?: "high" | "medium" | "low"; + + /** + * Current status + */ + status: "pending" | "in_progress" | "completed" | "skipped"; +} + +/** + * Stop reasons for ACP prompt responses + */ +export type StopReason = + | "end_turn" + | "max_tokens" + | "max_turn_requests" + | "refusal" + | "cancelled"; + +/** + * ACP capability flags + */ +export interface ACPCapabilities { + /** + * File system read capability + */ + fsReadTextFile?: boolean; + + /** + * File system write capability + */ + fsWriteTextFile?: boolean; + + /** + * Terminal capability + */ + terminal?: boolean; + + /** + * Session loading capability + */ + loadSession?: boolean; + + /** + * Modes capability + */ + modes?: boolean; + + /** + * Commands capability + */ + commands?: boolean; +} + +/** + * Events emitted by the server + */ +export interface ServerEvents { + /** + * Session created + */ + sessionCreated: (session: SessionState) => void; + + /** + * Session ended + */ + sessionEnded: (sessionId: string) => void; + + /** + * Prompt started + */ + promptStarted: (sessionId: string, prompt: string) => void; + + /** + * Prompt completed + */ + promptCompleted: (sessionId: string, stopReason: StopReason) => void; + + /** + * Tool call started + */ + toolCallStarted: (sessionId: string, toolCall: ToolCallInfo) => void; + + /** + * Tool call completed + */ + toolCallCompleted: (sessionId: string, toolCall: ToolCallInfo) => void; + + /** + * Error occurred + */ + error: (error: Error) => void; +} diff --git a/libs/deepagents-server/tsconfig.json b/libs/deepagents-server/tsconfig.json new file mode 100644 index 00000000..de299e72 --- /dev/null +++ b/libs/deepagents-server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/deepagents-server/tsdown.config.ts b/libs/deepagents-server/tsdown.config.ts new file mode 100644 index 00000000..35b7e017 --- /dev/null +++ b/libs/deepagents-server/tsdown.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from "tsdown"; + +// Mark all node_modules as external since this is a library +const external = [/^[^./]/]; + +export default defineConfig([ + // Library builds (ESM + CJS) + { + entry: ["./src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + outDir: "dist", + outExtensions: () => ({ js: ".js" }), + external, + }, + { + entry: ["./src/index.ts"], + format: ["cjs"], + dts: true, + clean: true, + sourcemap: true, + outDir: "dist", + outExtensions: () => ({ js: ".cjs" }), + external, + }, + // CLI build (ESM only, executable) + { + entry: ["./src/cli.ts"], + format: ["esm"], + dts: false, + clean: false, // Don't clean to preserve other builds + sourcemap: true, + outDir: "dist", + outExtensions: () => ({ js: ".js" }), + external, + // Add shebang for executable + banner: { + js: "#!/usr/bin/env node", + }, + }, +]); diff --git a/libs/deepagents-server/vitest.config.ts b/libs/deepagents-server/vitest.config.ts new file mode 100644 index 00000000..fbb00a63 --- /dev/null +++ b/libs/deepagents-server/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["src/**/*.int.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 245ff75a..3cf347cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: deepagents: specifier: workspace:* version: link:../libs/deepagents + deepagents-server: + specifier: workspace:* + version: link:../libs/deepagents-server langchain: specifier: ^1.0.4 version: 1.2.16(@langchain/core@1.1.18(openai@6.17.0(zod@4.3.6)))(openai@6.17.0(zod@4.3.6)) @@ -201,8 +204,56 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + libs/deepagents-server: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.14.0 + version: 0.14.0(zod@4.3.6) + deepagents: + specifier: workspace:* + version: link:../deepagents + devDependencies: + '@langchain/core': + specifier: ^1.1.19 + version: 1.1.19(openai@6.17.0(zod@4.3.6)) + '@langchain/langgraph': + specifier: ^1.1.3 + version: 1.1.3(@langchain/core@1.1.19(openai@6.17.0(zod@4.3.6)))(zod@4.3.6) + '@langchain/langgraph-checkpoint': + specifier: ^1.0.0 + version: 1.0.0(@langchain/core@1.1.19(openai@6.17.0(zod@4.3.6))) + '@tsconfig/recommended': + specifier: ^1.0.13 + version: 1.0.13 + '@types/node': + specifier: ^25.1.0 + version: 25.2.0 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) + '@vitest/ui': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) + tsdown: + specifier: ^0.20.1 + version: 0.20.1(synckit@0.11.12)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + packages: + '@agentclientprotocol/sdk@0.14.0': + resolution: {integrity: sha512-PNaDAiFIRzthaBjPljioHoadzYD2mRovA00ksCeCaerAU9qyqUQJdRBiJwlOxJ3SucY/nyJg8+0sh1sZrPhgmA==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@anthropic-ai/sdk@0.71.2': resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} hasBin: true @@ -2585,6 +2636,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@0.14.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 From bf15fae079b52ffafff99bf9288212c4eaacb060 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 4 Feb 2026 18:33:23 -0800 Subject: [PATCH 2/3] improvements --- libs/deepagents-server/package.json | 2 + libs/deepagents-server/src/cli.int.test.ts | 595 ++++++++++++++++++ libs/deepagents-server/src/cli.ts | 74 ++- libs/deepagents-server/src/index.ts | 4 + libs/deepagents-server/src/logger.test.ts | 251 ++++++++ libs/deepagents-server/src/logger.ts | 301 +++++++++ libs/deepagents-server/src/server.int.test.ts | 373 +++++++++++ libs/deepagents-server/src/server.test.ts | 575 +++++++++++++++++ libs/deepagents-server/src/server.ts | 466 +++++++++++--- libs/deepagents-server/src/types.ts | 85 +-- libs/deepagents-server/vitest.config.ts | 63 +- 11 files changed, 2604 insertions(+), 185 deletions(-) create mode 100644 libs/deepagents-server/src/cli.int.test.ts create mode 100644 libs/deepagents-server/src/logger.test.ts create mode 100644 libs/deepagents-server/src/logger.ts create mode 100644 libs/deepagents-server/src/server.int.test.ts create mode 100644 libs/deepagents-server/src/server.test.ts diff --git a/libs/deepagents-server/package.json b/libs/deepagents-server/package.json index 3e300570..3ea3619d 100644 --- a/libs/deepagents-server/package.json +++ b/libs/deepagents-server/package.json @@ -17,6 +17,8 @@ "prepublishOnly": "pnpm build", "test": "vitest run", "test:unit": "vitest run", + "test:int": "vitest run --mode int", + "test:all": "vitest run --mode all", "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage" diff --git a/libs/deepagents-server/src/cli.int.test.ts b/libs/deepagents-server/src/cli.int.test.ts new file mode 100644 index 00000000..85a6f45d --- /dev/null +++ b/libs/deepagents-server/src/cli.int.test.ts @@ -0,0 +1,595 @@ +/** + * Integration tests for the DeepAgents ACP Server CLI + * + * These tests spawn the actual CLI process and communicate with it + * via the ACP protocol over stdio (stdin/stdout with ndjson). + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as readline from "node:readline"; + +// ACP message types +interface ACPRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: Record; +} + +interface ACPResponse { + jsonrpc: "2.0"; + id: number; + result?: Record; + error?: { code: number; message: string }; +} + +interface ACPNotification { + jsonrpc: "2.0"; + method: string; + params?: Record; +} + +/** + * Helper class to manage the CLI process and ACP communication + */ +class CLITestHelper { + private process: ChildProcess | null = null; + private responseQueue: Map void; + reject: (error: Error) => void; + }> = new Map(); + private notifications: ACPNotification[] = []; + private nextId = 1; + private rl: readline.Interface | null = null; + private stderrOutput: string[] = []; + + constructor( + private cliPath: string, + private args: string[] = [], + ) {} + + /** + * Start the CLI process + */ + async start(): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("CLI startup timeout")); + }, 10000); + + this.process = spawn("node", [this.cliPath, ...this.args], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + // Ensure we don't need an API key for tests + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? "test-key", + }, + }); + + // Handle stderr (debug output) + this.process.stderr?.on("data", (data: Buffer) => { + const lines = data.toString().split("\n").filter(Boolean); + this.stderrOutput.push(...lines); + + // Check for startup message + if (lines.some((l) => l.includes("Server started") || l.includes("waiting for connections"))) { + clearTimeout(timeout); + resolve(); + } + }); + + // Handle stdout (ACP protocol messages) + if (this.process.stdout) { + this.rl = readline.createInterface({ + input: this.process.stdout, + crlfDelay: Infinity, + }); + + this.rl.on("line", (line) => { + if (!line.trim()) return; + + try { + const message = JSON.parse(line); + + if ("id" in message && this.responseQueue.has(message.id)) { + // This is a response to a request + const handler = this.responseQueue.get(message.id)!; + this.responseQueue.delete(message.id); + handler.resolve(message as ACPResponse); + } else if ("method" in message && !("id" in message)) { + // This is a notification + this.notifications.push(message as ACPNotification); + } + } catch { + // Ignore non-JSON lines + } + }); + } + + this.process.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + this.process.on("exit", (code) => { + if (code !== 0 && code !== null) { + clearTimeout(timeout); + reject(new Error(`CLI exited with code ${code}`)); + } + }); + + // If no debug output, resolve after a short delay + setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 2000); + }); + } + + /** + * Send an ACP request and wait for response + */ + async sendRequest(method: string, params?: Record): Promise { + if (!this.process?.stdin) { + throw new Error("CLI not started"); + } + + const id = this.nextId++; + const request: ACPRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.responseQueue.delete(id); + reject(new Error(`Request timeout: ${method}`)); + }, 30000); + + this.responseQueue.set(id, { + resolve: (response) => { + clearTimeout(timeout); + resolve(response); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + + const line = JSON.stringify(request) + "\n"; + this.process!.stdin!.write(line); + }); + } + + /** + * Send an ACP notification (no response expected) + */ + sendNotification(method: string, params?: Record): void { + if (!this.process?.stdin) { + throw new Error("CLI not started"); + } + + const notification: ACPNotification = { + jsonrpc: "2.0", + method, + params, + }; + + const line = JSON.stringify(notification) + "\n"; + this.process.stdin.write(line); + } + + /** + * Get collected notifications + */ + getNotifications(): ACPNotification[] { + return [...this.notifications]; + } + + /** + * Clear collected notifications + */ + clearNotifications(): void { + this.notifications = []; + } + + /** + * Get stderr output + */ + getStderr(): string[] { + return [...this.stderrOutput]; + } + + /** + * Stop the CLI process + */ + async stop(): Promise { + if (this.rl) { + this.rl.close(); + this.rl = null; + } + + if (this.process) { + // Close stdin to signal EOF + this.process.stdin?.end(); + + // Wait for process to exit + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.process?.kill("SIGKILL"); + resolve(); + }, 5000); + + this.process!.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + + this.process?.kill("SIGTERM"); + }); + + this.process = null; + } + } + + /** + * Check if process is running + */ + isRunning(): boolean { + return this.process !== null && !this.process.killed; + } +} + +describe("CLI Integration Tests", () => { + let helper: CLITestHelper; + let tempDir: string; + let logFile: string; + const cliPath = path.resolve(__dirname, "..", "dist", "cli.js"); + + beforeEach(() => { + // Create temp directory for log files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepagents-cli-test-")); + logFile = path.join(tempDir, "test.log"); + }); + + afterEach(async () => { + // Stop the CLI process + if (helper) { + await helper.stop(); + } + + // Cleanup temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("CLI Startup", () => { + it("should start the CLI process successfully", async () => { + helper = new CLITestHelper(cliPath, ["--debug"]); + await helper.start(); + + expect(helper.isRunning()).toBe(true); + }); + + it("should start with custom agent name", async () => { + helper = new CLITestHelper(cliPath, [ + "--name", "test-agent", + "--debug", + ]); + await helper.start(); + + expect(helper.isRunning()).toBe(true); + + const stderr = helper.getStderr(); + expect(stderr.some((line) => line.includes("test-agent"))).toBe(true); + }); + + it("should write logs to file when --log-file is specified", async () => { + helper = new CLITestHelper(cliPath, [ + "--log-file", logFile, + ]); + await helper.start(); + + // Give time for log to be written + await new Promise((r) => setTimeout(r, 500)); + + expect(fs.existsSync(logFile)).toBe(true); + const logContent = fs.readFileSync(logFile, "utf8"); + expect(logContent).toContain("Started at"); + }); + }); + + describe("ACP Protocol - Initialize", () => { + beforeEach(async () => { + helper = new CLITestHelper(cliPath, ["--debug"]); + await helper.start(); + }); + + it("should respond to initialize request", async () => { + const response = await helper.sendRequest("initialize", { + // ACP spec requires protocolVersion as number + protocolVersion: 1, + clientInfo: { + name: "test-client", + version: "1.0.0", + }, + }); + + expect(response.result).toBeDefined(); + // Check for ACP spec format (agentInfo) + const agentInfo = response.result?.agentInfo as { name?: string; version?: string } | undefined; + expect(agentInfo?.name).toBe("deepagents-server"); + expect(response.result?.protocolVersion).toBe(1); + }); + + it("should return server capabilities", async () => { + const response = await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + expect(response.result?.agentCapabilities).toBeDefined(); + const capabilities = response.result?.agentCapabilities as Record; + // ACP spec: loadSession is a boolean + expect(capabilities.loadSession).toBe(true); + // ACP spec: sessionCapabilities contains modes and commands + const sessionCaps = capabilities.sessionCapabilities as Record; + expect(sessionCaps.modes).toBe(true); + expect(sessionCaps.commands).toBe(true); + }); + + it("should return prompt capabilities", async () => { + const response = await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + expect(response.result?.agentCapabilities).toBeDefined(); + const agentCaps = response.result?.agentCapabilities as Record; + // ACP spec: promptCapabilities has image, audio, embeddedContext + const promptCaps = agentCaps.promptCapabilities as Record; + expect(promptCaps.image).toBe(true); + expect(promptCaps.embeddedContext).toBe(true); + }); + }); + + describe("ACP Protocol - Session Management", () => { + beforeEach(async () => { + helper = new CLITestHelper(cliPath, ["--debug", "--name", "test-agent"]); + await helper.start(); + + // Initialize first with ACP spec format + await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + }); + + it("should create a new session", async () => { + // ACP spec requires cwd and mcpServers for session/new + const response = await helper.sendRequest("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + + expect(response.result).toBeDefined(); + expect(response.result?.sessionId).toBeDefined(); + expect(typeof response.result?.sessionId).toBe("string"); + expect((response.result?.sessionId as string).startsWith("sess_")).toBe(true); + }); + + it("should return available modes in new session", async () => { + const response = await helper.sendRequest("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + + // ACP spec uses 'modes' object with 'availableModes' array and 'currentModeId' + expect(response.result?.modes).toBeDefined(); + const modesState = response.result?.modes as { availableModes?: Array<{ id: string; name: string }>; currentModeId?: string }; + expect(modesState.availableModes).toBeDefined(); + expect(modesState.availableModes!.length).toBeGreaterThan(0); + + const modeIds = modesState.availableModes!.map((m) => m.id); + expect(modeIds).toContain("agent"); + expect(modeIds).toContain("plan"); + expect(modeIds).toContain("ask"); + }); + + it("should return currentModeId in new session", async () => { + const response = await helper.sendRequest("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + + // ACP spec: modes object contains currentModeId + expect(response.result?.modes).toBeDefined(); + const modesState = response.result?.modes as { currentModeId?: string }; + expect(modesState.currentModeId).toBe("agent"); + }); + + it("should load an existing session", async () => { + // Create a session first + const createResponse = await helper.sendRequest("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + const sessionId = createResponse.result?.sessionId as string; + + // Load the session with ACP spec required params + const loadResponse = await helper.sendRequest("session/load", { + sessionId, + cwd: process.cwd(), + mcpServers: [], + }); + + // ACP spec: LoadSessionResponse returns modes, not sessionId + expect(loadResponse.result).toBeDefined(); + expect(loadResponse.result?.modes).toBeDefined(); + }); + + it("should fail to load non-existent session", async () => { + const response = await helper.sendRequest("session/load", { + sessionId: "sess_nonexistent12345", + cwd: process.cwd(), + mcpServers: [], + }); + + // ACP SDK wraps internal errors + expect(response.error).toBeDefined(); + }); + + it("should set session mode", async () => { + // Create a session first + const createResponse = await helper.sendRequest("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + const sessionId = createResponse.result?.sessionId as string; + + // Set mode to plan using ACP spec param name 'modeId' + const modeResponse = await helper.sendRequest("session/set_mode", { + sessionId, + modeId: "plan", + }); + + // Should not return an error + expect(modeResponse.error).toBeUndefined(); + }); + }); + + describe("ACP Protocol - Cancel", () => { + beforeEach(async () => { + helper = new CLITestHelper(cliPath, ["--debug"]); + await helper.start(); + + await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + }); + + it("should handle cancel notification", async () => { + // Create a session + const createResponse = await helper.sendRequest("session/new", {}); + const sessionId = createResponse.result?.sessionId as string; + + // Send cancel notification (no response expected) + helper.sendNotification("session/cancel", { sessionId }); + + // Should not crash - wait a bit + await new Promise((r) => setTimeout(r, 100)); + expect(helper.isRunning()).toBe(true); + }); + }); + + describe("Debug Logging", () => { + it("should output debug logs to stderr when --debug is set", async () => { + helper = new CLITestHelper(cliPath, ["--debug"]); + await helper.start(); + + await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "debug-test", version: "1.0.0" }, + }); + + const stderr = helper.getStderr(); + expect(stderr.some((line) => line.includes("[deepagents-server]"))).toBe(true); + }); + + it("should log client connection info in debug mode", async () => { + helper = new CLITestHelper(cliPath, ["--debug"]); + await helper.start(); + + await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "my-test-client", version: "2.0.0" }, + }); + + const stderr = helper.getStderr(); + expect(stderr.some((line) => + line.includes("Client connected") || line.includes("my-test-client") + )).toBe(true); + }); + }); + + describe("Error Handling", () => { + beforeEach(async () => { + helper = new CLITestHelper(cliPath, ["--debug"]); + await helper.start(); + }); + + it("should return error for unknown method", async () => { + const response = await helper.sendRequest("unknown/method", {}); + + // The ACP SDK may handle this differently, but we should get some response + expect(response).toBeDefined(); + }); + + it("should handle invalid session ID gracefully", async () => { + await helper.sendRequest("initialize", { + protocolVersion: 1, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + const response = await helper.sendRequest("session/load", { + sessionId: "invalid-session-id", + cwd: process.cwd(), + mcpServers: [], + }); + + expect(response.error).toBeDefined(); + }); + }); +}); + +describe("CLI Help and Version", () => { + it("should show help with --help flag", async () => { + const cliPath = path.resolve(__dirname, "..", "dist", "cli.js"); + + const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve) => { + const proc = spawn("node", [cliPath, "--help"]); + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data) => { stdout += data.toString(); }); + proc.stderr?.on("data", (data) => { stderr += data.toString(); }); + proc.on("exit", (code) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("DeepAgents ACP Server"); + expect(result.stdout).toContain("--name"); + expect(result.stdout).toContain("--debug"); + expect(result.stdout).toContain("--log-file"); + }); + + it("should show version with --version flag", async () => { + const cliPath = path.resolve(__dirname, "..", "dist", "cli.js"); + + const result = await new Promise<{ stdout: string; code: number }>((resolve) => { + const proc = spawn("node", [cliPath, "--version"]); + let stdout = ""; + + proc.stdout?.on("data", (data) => { stdout += data.toString(); }); + proc.on("exit", (code) => { + resolve({ stdout, code: code ?? 0 }); + }); + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("deepagents-server"); + }); +}); diff --git a/libs/deepagents-server/src/cli.ts b/libs/deepagents-server/src/cli.ts index f2a3189b..efdb90b3 100644 --- a/libs/deepagents-server/src/cli.ts +++ b/libs/deepagents-server/src/cli.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * DeepAgents ACP Server CLI * @@ -13,15 +14,16 @@ * --workspace Workspace root directory (default: cwd) * --skills Comma-separated skill paths * --memory Comma-separated memory/AGENTS.md paths - * --debug Enable debug logging + * --debug Enable debug logging to stderr + * --log-file Write logs to file (for production debugging) * --help Show this help message * --version Show version */ import { DeepAgentsServer } from "./server.js"; import { FilesystemBackend } from "deepagents"; -import * as path from "node:path"; -import * as fs from "node:fs"; +import path from "node:path"; +import fs from "node:fs"; interface CLIOptions { name: string; @@ -31,10 +33,42 @@ interface CLIOptions { skills: string[]; memory: string[]; debug: boolean; + logFile: string | null; help: boolean; version: boolean; } +/** + * Normalize arguments to handle various formats: + * - "--name value" (space-separated in single string) + * - "--name=value" (equals-separated) + * - "--name", "value" (separate array elements - standard) + */ +function normalizeArgs(args: string[]): string[] { + const normalized: string[] = []; + + for (const arg of args) { + // Handle space-separated args in a single string (e.g., "--name deepagents") + if (arg.includes(" ") && arg.startsWith("-")) { + const parts = arg.split(/\s+/); + normalized.push(...parts); + } + // Handle equals-separated args (e.g., "--name=deepagents") + else if (arg.includes("=") && arg.startsWith("-")) { + const eqIndex = arg.indexOf("="); + const key = arg.slice(0, eqIndex); + const value = arg.slice(eqIndex + 1); + normalized.push(key, value); + } + // Standard format + else { + normalized.push(arg); + } + } + + return normalized; +} + function parseArgs(args: string[]): CLIOptions { const options: CLIOptions = { name: "deepagents", @@ -44,13 +78,17 @@ function parseArgs(args: string[]): CLIOptions { skills: [], memory: [], debug: process.env.DEBUG === "true", + logFile: process.env.DEEPAGENTS_LOG_FILE ?? null, help: false, version: false, }; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - const nextArg = args[i + 1]; + // Normalize args to handle various input formats + const normalizedArgs = normalizeArgs(args); + + for (let i = 0; i < normalizedArgs.length; i++) { + const arg = normalizedArgs[i]; + const nextArg = normalizedArgs[i + 1]; switch (arg) { case "--name": @@ -104,6 +142,14 @@ function parseArgs(args: string[]): CLIOptions { options.debug = true; break; + case "--log-file": + case "-l": + if (nextArg) { + options.logFile = path.resolve(nextArg); + i++; + } + break; + case "--help": case "-h": options.help = true; @@ -137,6 +183,7 @@ OPTIONS: -s, --skills Comma-separated skill paths (SKILL.md locations) --memory Comma-separated memory paths (AGENTS.md locations) --debug Enable debug logging to stderr + -l, --log-file Write logs to file (for production debugging) -h, --help Show this help message -v, --version Show version @@ -144,6 +191,7 @@ ENVIRONMENT VARIABLES: ANTHROPIC_API_KEY API key for Anthropic models (required for Claude) OPENAI_API_KEY API key for OpenAI models DEBUG Set to "true" to enable debug logging + DEEPAGENTS_LOG_FILE Path to log file (alternative to --log-file) WORKSPACE_ROOT Alternative to --workspace flag EXAMPLES: @@ -156,6 +204,12 @@ EXAMPLES: # Debug mode with custom workspace npx deepagents-server --debug --workspace /path/to/project + # Production debugging with log file + npx deepagents-server --log-file /var/log/deepagents.log + + # Combined debug and file logging + npx deepagents-server --debug --log-file ./debug.log + ZED INTEGRATION: Add to your Zed settings.json: @@ -165,7 +219,7 @@ ZED INTEGRATION: "deepagents": { "name": "DeepAgents", "command": "npx", - "args": ["deepagents-server"], + "args": ["deepagents-server", "--log-file", "/tmp/deepagents.log"], "env": {} } } @@ -233,7 +287,7 @@ async function main(): Promise { // Log startup info to stderr (stdout is reserved for ACP protocol) const log = (...msgArgs: unknown[]) => { - if (options.debug) { + if (options.debug || options.logFile) { console.error("[deepagents-server]", ...msgArgs); } }; @@ -244,6 +298,9 @@ async function main(): Promise { log("Workspace:", workspaceRoot); log("Skills:", skills.join(", ")); log("Memory:", memory.join(", ")); + if (options.logFile) { + log("Log file:", options.logFile); + } try { const server = new DeepAgentsServer({ @@ -258,6 +315,7 @@ async function main(): Promise { serverName: "deepagents-server", workspaceRoot, debug: options.debug, + logFile: options.logFile ?? undefined, }); await server.start(); diff --git a/libs/deepagents-server/src/index.ts b/libs/deepagents-server/src/index.ts index 3f98cec7..457b04a3 100644 --- a/libs/deepagents-server/src/index.ts +++ b/libs/deepagents-server/src/index.ts @@ -65,3 +65,7 @@ export { fileUriToPath, pathToFileUri, } from "./adapter.js"; + +// Logger utilities +export { Logger, createLogger, nullLogger } from "./logger.js"; +export type { LogLevel, LoggerOptions } from "./logger.js"; diff --git a/libs/deepagents-server/src/logger.test.ts b/libs/deepagents-server/src/logger.test.ts new file mode 100644 index 00000000..7a646d33 --- /dev/null +++ b/libs/deepagents-server/src/logger.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests for the Logger utility + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { Logger, createLogger, nullLogger } from "./logger.js"; + +describe("Logger", () => { + let tempDir: string; + let logFilePath: string; + + beforeEach(() => { + // Create temp directory for log files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepagents-test-")); + logFilePath = path.join(tempDir, "test.log"); + vi.clearAllMocks(); + }); + + afterEach(() => { + // Cleanup temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("constructor", () => { + it("should create logger with default options", () => { + const logger = new Logger(); + expect(logger.isEnabled()).toBe(false); + expect(logger.hasFileLogging()).toBe(false); + }); + + it("should enable debug logging when debug=true", () => { + const logger = new Logger({ debug: true }); + expect(logger.isEnabled()).toBe(true); + }); + + it("should enable file logging when logFile is provided", () => { + const logger = new Logger({ logFile: logFilePath }); + expect(logger.isEnabled()).toBe(true); + expect(logger.hasFileLogging()).toBe(true); + expect(logger.getLogFilePath()).toBe(logFilePath); + }); + + it("should create log file directory if it doesn't exist", () => { + const nestedPath = path.join(tempDir, "nested", "dir", "test.log"); + const logger = new Logger({ logFile: nestedPath }); + expect(logger.hasFileLogging()).toBe(true); + expect(fs.existsSync(path.dirname(nestedPath))).toBe(true); + }); + }); + + describe("logging methods", () => { + it("should log to stderr when debug=true", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ debug: true }); + + logger.log("test message"); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy.mock.calls[0][0]).toContain("test message"); + consoleSpy.mockRestore(); + }); + + it("should not log to stderr when debug=false", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ debug: false }); + + logger.log("test message"); + + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("should log errors to stderr even when debug=false", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ debug: false }); + + logger.error("error message"); + + // Errors should still be logged + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("should write to file when logFile is provided", async () => { + const logger = new Logger({ logFile: logFilePath }); + + logger.log("file message"); + await logger.close(); + + const content = fs.readFileSync(logFilePath, "utf8"); + expect(content).toContain("file message"); + }); + + it("should include timestamps in file logs", async () => { + const logger = new Logger({ logFile: logFilePath }); + + logger.log("timestamp test"); + await logger.close(); + + const content = fs.readFileSync(logFilePath, "utf8"); + // ISO timestamp format + expect(content).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("should use custom prefix", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ + debug: true, + prefix: "[custom-prefix]", + }); + + logger.log("test"); + + expect(consoleSpy.mock.calls[0][0]).toContain("[custom-prefix]"); + consoleSpy.mockRestore(); + }); + }); + + describe("log levels", () => { + it("should support info level", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ debug: true }); + + logger.info("info message"); + + expect(consoleSpy.mock.calls[0][0]).toContain("[INFO]"); + consoleSpy.mockRestore(); + }); + + it("should support warn level", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ debug: true }); + + logger.warn("warning message"); + + expect(consoleSpy.mock.calls[0][0]).toContain("[WARN]"); + consoleSpy.mockRestore(); + }); + + it("should support error level", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logger = new Logger({ debug: true }); + + logger.error("error message"); + + expect(consoleSpy.mock.calls[0][0]).toContain("[ERROR]"); + consoleSpy.mockRestore(); + }); + + it("should format Error objects with stack trace", async () => { + const logger = new Logger({ logFile: logFilePath }); + const error = new Error("test error"); + + logger.error(error); + await logger.close(); + + const content = fs.readFileSync(logFilePath, "utf8"); + expect(content).toContain("test error"); + expect(content).toContain("Error:"); + }); + + it("should stringify objects", async () => { + const logger = new Logger({ logFile: logFilePath }); + + logger.log({ key: "value", nested: { a: 1 } }); + await logger.close(); + + const content = fs.readFileSync(logFilePath, "utf8"); + expect(content).toContain('"key"'); + expect(content).toContain('"value"'); + }); + }); + + describe("close", () => { + it("should write shutdown message on close", async () => { + const logger = new Logger({ logFile: logFilePath }); + + logger.log("before close"); + await logger.close(); + + const content = fs.readFileSync(logFilePath, "utf8"); + expect(content).toContain("Shutting down"); + }); + + it("should be safe to call close multiple times", async () => { + const logger = new Logger({ logFile: logFilePath }); + + await logger.close(); + await expect(logger.close()).resolves.not.toThrow(); + }); + }); + + describe("flush", () => { + it("should flush pending writes", async () => { + const logger = new Logger({ logFile: logFilePath }); + + logger.log("flush test"); + await logger.flush(); + await logger.close(); + + const content = fs.readFileSync(logFilePath, "utf8"); + expect(content).toContain("flush test"); + }); + }); +}); + +describe("createLogger", () => { + it("should create a logger instance", () => { + const logger = createLogger({ debug: true }); + expect(logger).toBeInstanceOf(Logger); + }); + + it("should pass options to Logger", () => { + const logger = createLogger({ debug: true, prefix: "[test]" }); + expect(logger.isEnabled()).toBe(true); + }); +}); + +describe("nullLogger", () => { + it("should have all logger methods", () => { + expect(typeof nullLogger.log).toBe("function"); + expect(typeof nullLogger.info).toBe("function"); + expect(typeof nullLogger.warn).toBe("function"); + expect(typeof nullLogger.error).toBe("function"); + expect(typeof nullLogger.close).toBe("function"); + }); + + it("should return false for isEnabled", () => { + expect(nullLogger.isEnabled()).toBe(false); + }); + + it("should return false for hasFileLogging", () => { + expect(nullLogger.hasFileLogging()).toBe(false); + }); + + it("should return null for getLogFilePath", () => { + expect(nullLogger.getLogFilePath()).toBeNull(); + }); + + it("should not throw when called", () => { + expect(() => nullLogger.log("test")).not.toThrow(); + expect(() => nullLogger.error("test")).not.toThrow(); + }); +}); diff --git a/libs/deepagents-server/src/logger.ts b/libs/deepagents-server/src/logger.ts new file mode 100644 index 00000000..c10da188 --- /dev/null +++ b/libs/deepagents-server/src/logger.ts @@ -0,0 +1,301 @@ +/* eslint-disable no-console */ +/** + * Logger utility for DeepAgents ACP Server + * + * Supports logging to stderr (for debug mode) and/or a file (for production debugging). + * All output goes to stderr or file to keep stdout clean for ACP protocol communication. + */ + +import fs from "node:fs"; +import path from "node:path"; + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface LoggerOptions { + /** + * Enable debug logging to stderr + */ + debug?: boolean; + + /** + * Path to log file for persistent logging + * If provided, logs will be written to this file regardless of debug flag + */ + logFile?: string; + + /** + * Minimum log level to output (default: "debug" if debug=true, "info" otherwise) + */ + minLevel?: LogLevel; + + /** + * Prefix for log messages + */ + prefix?: string; + + /** + * Include timestamps in log messages (default: true for file, false for stderr) + */ + timestamps?: boolean; +} + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +/** + * Logger class for DeepAgents ACP Server + */ +export class Logger { + private debug: boolean; + private logFile: string | null; + private fileStream: fs.WriteStream | null = null; + private minLevel: number; + private prefix: string; + private timestampsForStderr: boolean; + private timestampsForFile: boolean; + + constructor(options: LoggerOptions = {}) { + this.debug = options.debug ?? false; + this.logFile = options.logFile ?? null; + this.prefix = options.prefix ?? "[deepagents-server]"; + this.timestampsForStderr = options.timestamps ?? false; + this.timestampsForFile = true; // Always include timestamps in file logs + + // Determine minimum log level + if (options.minLevel) { + this.minLevel = LOG_LEVELS[options.minLevel]; + } else { + this.minLevel = this.debug ? LOG_LEVELS.debug : LOG_LEVELS.info; + } + + // Initialize file stream if logFile is provided + if (this.logFile) { + this.initFileStream(this.logFile); + } + } + + /** + * Initialize the file write stream + */ + private initFileStream(logFilePath: string): void { + try { + // Resolve the path + const resolvedPath = path.resolve(logFilePath); + + // Ensure the directory exists + const dir = path.dirname(resolvedPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Open file stream in append mode + this.fileStream = fs.createWriteStream(resolvedPath, { + flags: "a", + encoding: "utf8", + }); + + // Write startup marker + const startupMessage = `\n${"=".repeat(60)}\n${this.prefix} Started at ${new Date().toISOString()}\n${"=".repeat(60)}\n`; + this.fileStream.write(startupMessage); + + // Handle stream errors + this.fileStream.on("error", (err) => { + console.error(`${this.prefix} Log file error:`, err); + this.fileStream = null; + }); + } catch (err) { + console.error(`${this.prefix} Failed to initialize log file:`, err); + this.fileStream = null; + } + } + + /** + * Format a log message + */ + private formatMessage( + level: LogLevel, + args: unknown[], + includeTimestamp: boolean, + ): string { + const timestamp = includeTimestamp ? `[${new Date().toISOString()}] ` : ""; + const levelTag = `[${level.toUpperCase()}]`; + const message = args + .map((arg) => { + // eslint-disable-next-line no-instanceof/no-instanceof + if (arg instanceof Error) { + return `${arg.message}\n${arg.stack}`; + } + if (typeof arg === "object") { + try { + return JSON.stringify(arg, null, 2); + } catch { + return String(arg); + } + } + return String(arg); + }) + .join(" "); + + return `${timestamp}${this.prefix} ${levelTag} ${message}`; + } + + /** + * Write a log message + */ + private write(level: LogLevel, args: unknown[]): void { + const levelNum = LOG_LEVELS[level]; + + // Check if we should log at this level + if (levelNum < this.minLevel && !this.logFile) { + return; + } + + // Write to stderr if debug mode or level >= minLevel + if (this.debug || levelNum >= LOG_LEVELS.warn) { + const stderrMessage = this.formatMessage( + level, + args, + this.timestampsForStderr, + ); + console.error(stderrMessage); + } + + // Write to file if configured + if (this.fileStream) { + const fileMessage = this.formatMessage( + level, + args, + this.timestampsForFile, + ); + this.fileStream.write(fileMessage + "\n"); + } + } + + /** + * Log a debug message + */ + log(...args: unknown[]): void { + this.write("debug", args); + } + + /** + * Log a debug message (alias for log) + */ + debug_log(...args: unknown[]): void { + this.write("debug", args); + } + + /** + * Log an info message + */ + info(...args: unknown[]): void { + this.write("info", args); + } + + /** + * Log a warning message + */ + warn(...args: unknown[]): void { + this.write("warn", args); + } + + /** + * Log an error message + */ + error(...args: unknown[]): void { + this.write("error", args); + } + + /** + * Log with a specific level + */ + logLevel(level: LogLevel, ...args: unknown[]): void { + this.write(level, args); + } + + /** + * Check if logging is enabled (either debug or file) + */ + isEnabled(): boolean { + return this.debug || this.fileStream !== null; + } + + /** + * Check if file logging is enabled + */ + hasFileLogging(): boolean { + return this.fileStream !== null; + } + + /** + * Get the log file path + */ + getLogFilePath(): string | null { + return this.logFile; + } + + /** + * Close the logger and flush any pending writes + */ + close(): Promise { + return new Promise((resolve) => { + if (this.fileStream) { + const shutdownMessage = `${this.prefix} Shutting down at ${new Date().toISOString()}\n${"=".repeat(60)}\n`; + this.fileStream.write(shutdownMessage, () => { + this.fileStream?.end(() => { + this.fileStream = null; + resolve(); + }); + }); + } else { + resolve(); + } + }); + } + + /** + * Flush pending writes to file + */ + flush(): Promise { + return new Promise((resolve) => { + if (this.fileStream) { + // Use drain event if write buffer is full + if (!this.fileStream.write("")) { + this.fileStream.once("drain", resolve); + } else { + resolve(); + } + } else { + resolve(); + } + }); + } +} + +/** + * Create a logger instance + */ +export function createLogger(options: LoggerOptions = {}): Logger { + return new Logger(options); +} + +/** + * Default no-op logger for when logging is disabled + */ +export const nullLogger: Logger = { + log: () => {}, + debug_log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + logLevel: () => {}, + isEnabled: () => false, + hasFileLogging: () => false, + getLogFilePath: () => null, + close: () => Promise.resolve(), + flush: () => Promise.resolve(), +} as unknown as Logger; diff --git a/libs/deepagents-server/src/server.int.test.ts b/libs/deepagents-server/src/server.int.test.ts new file mode 100644 index 00000000..b6386596 --- /dev/null +++ b/libs/deepagents-server/src/server.int.test.ts @@ -0,0 +1,373 @@ +/** + * Integration tests for the DeepAgents ACP Server + * + * These tests verify the full ACP protocol flow with a real DeepAgent. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { DeepAgentsServer } from "./server.js"; +import { generateSessionId } from "./adapter.js"; +import type { DeepAgentConfig, SessionState } from "./types.js"; + +// These tests use the actual deepagents library but mock the LLM +vi.mock("@langchain/anthropic", () => ({ + ChatAnthropic: vi.fn().mockImplementation(() => ({ + invoke: vi.fn().mockResolvedValue({ + content: "I can help with that!", + tool_calls: [], + }), + bindTools: vi.fn().mockReturnThis(), + })), +})); + +describe("DeepAgentsServer Integration", () => { + let server: DeepAgentsServer; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + if (server) { + server.stop(); + } + }); + + describe("Session Management", () => { + it("should create and track sessions", async () => { + server = new DeepAgentsServer({ + agents: { + name: "test-agent", + description: "Test agent for integration tests", + }, + debug: false, + }); + + // Access internal state + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + + // Create first session + const result1 = await serverAny.handleNewSession({}, mockConn); + expect(result1.sessionId).toBeDefined(); + + // Create second session + const result2 = await serverAny.handleNewSession({}, mockConn); + expect(result2.sessionId).toBeDefined(); + expect(result2.sessionId).not.toBe(result1.sessionId); + + // Both sessions should exist + expect(serverAny.sessions.size).toBe(2); + }); + + it("should load existing session", async () => { + server = new DeepAgentsServer({ + agents: { + name: "test-agent", + }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + handleLoadSession: ( + params: Record, + conn: unknown + ) => Promise<{ modes: { availableModes: unknown[] } }>; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + + // Create a session + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + // Try to load it + const loadResult = await serverAny.handleLoadSession( + { sessionId }, + mockConn + ); + + // ACP spec: LoadSessionResponse returns modes, not sessionId + expect(loadResult.modes).toBeDefined(); + expect(loadResult.modes.availableModes).toBeDefined(); + }); + + it("should throw when loading unknown session", async () => { + server = new DeepAgentsServer({ + agents: { + name: "test-agent", + }, + }); + + const serverAny = server as unknown as { + handleLoadSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + + // Try to load a non-existent session - should throw + await expect( + serverAny.handleLoadSession({ sessionId: "non-existent" }, mockConn) + ).rejects.toThrow("Session not found"); + }); + }); + + describe("Mode Handling", () => { + it("should support agent mode by default", async () => { + server = new DeepAgentsServer({ + agents: { + name: "test-agent", + }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ modes: { availableModes: Array<{ id: string; name: string }> } }>; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const result = await serverAny.handleNewSession({}, mockConn); + + const agentMode = result.modes.availableModes.find((m) => m.id === "agent"); + expect(agentMode).toBeDefined(); + expect(agentMode?.name).toBe("Agent Mode"); + }); + + it("should support plan mode", async () => { + server = new DeepAgentsServer({ + agents: { + name: "test-agent", + }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ modes: { availableModes: Array<{ id: string }> } }>; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const result = await serverAny.handleNewSession({}, mockConn); + + const planMode = result.modes.availableModes.find((m) => m.id === "plan"); + expect(planMode).toBeDefined(); + }); + + it("should update session mode when set", async () => { + server = new DeepAgentsServer({ + agents: { + name: "test-agent", + }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + handleSetSessionMode: (params: Record) => Promise; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + // Set to plan mode + await serverAny.handleSetSessionMode({ + sessionId, + mode: "plan", + }); + + const session = serverAny.sessions.get(sessionId); + expect(session?.mode).toBe("plan"); + + // Set back to agent mode + await serverAny.handleSetSessionMode({ + sessionId, + mode: "agent", + }); + + const updatedSession = serverAny.sessions.get(sessionId); + expect(updatedSession?.mode).toBe("agent"); + }); + }); + + describe("Multi-Agent Support", () => { + it("should route sessions to correct agent", async () => { + server = new DeepAgentsServer({ + agents: [ + { name: "coding-agent", description: "For coding tasks" }, + { name: "writing-agent", description: "For writing tasks" }, + ], + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + + // Create session with specific agent + const { sessionId } = await serverAny.handleNewSession( + { configOptions: { agent: "writing-agent" } }, + mockConn + ); + + const session = serverAny.sessions.get(sessionId); + expect(session?.agentName).toBe("writing-agent"); + }); + + it("should use first agent as default", async () => { + server = new DeepAgentsServer({ + agents: [ + { name: "first-agent" }, + { name: "second-agent" }, + ], + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + const session = serverAny.sessions.get(sessionId); + expect(session?.agentName).toBe("first-agent"); + }); + }); + + describe("Cancel Handling", () => { + it("should abort active prompt when cancelled", async () => { + server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + handleCancel: (params: Record) => Promise; + currentPromptAbortController: AbortController | null; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + // Simulate an active prompt + const controller = new AbortController(); + serverAny.currentPromptAbortController = controller; + + expect(controller.signal.aborted).toBe(false); + + // Cancel the prompt + await serverAny.handleCancel({ sessionId }); + + expect(controller.signal.aborted).toBe(true); + }); + + it("should handle multiple cancels gracefully", async () => { + server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown + ) => Promise<{ sessionId: string }>; + handleCancel: (params: Record) => Promise; + currentPromptAbortController: AbortController | null; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + const controller = new AbortController(); + serverAny.currentPromptAbortController = controller; + + // First cancel + await serverAny.handleCancel({ sessionId }); + expect(controller.signal.aborted).toBe(true); + + // Second cancel should not throw + await expect(serverAny.handleCancel({ sessionId })).resolves.not.toThrow(); + }); + }); + + describe("Initialize Response", () => { + it("should return correct capabilities", async () => { + server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + serverName: "test-server", + serverVersion: "1.2.3", + }); + + const serverAny = server as unknown as { + handleInitialize: (params: Record) => Promise<{ + agentInfo: { name: string; version: string }; + agentCapabilities: { + loadSession: boolean; + promptCapabilities: { image: boolean }; + }; + }>; + }; + + const result = await serverAny.handleInitialize({ + protocolVersion: 1, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + // ACP spec: agentInfo contains name and version + expect(result.agentInfo).toBeDefined(); + expect(result.agentInfo.name).toBe("test-server"); + expect(result.agentInfo.version).toBe("1.2.3"); + // ACP spec: agentCapabilities + expect(result.agentCapabilities).toBeDefined(); + expect(result.agentCapabilities.loadSession).toBe(true); + expect(result.agentCapabilities.promptCapabilities).toBeDefined(); + expect(result.agentCapabilities.promptCapabilities.image).toBe(true); + }); + }); +}); + +describe("generateSessionId", () => { + it("should generate unique IDs", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateSessionId()); + } + expect(ids.size).toBe(100); + }); + + it("should generate IDs with correct format", () => { + const id = generateSessionId(); + expect(id).toMatch(/^sess_[a-f0-9]{16}$/); + }); +}); diff --git a/libs/deepagents-server/src/server.test.ts b/libs/deepagents-server/src/server.test.ts new file mode 100644 index 00000000..c1a38c95 --- /dev/null +++ b/libs/deepagents-server/src/server.test.ts @@ -0,0 +1,575 @@ +/** + * Unit tests for the DeepAgents ACP Server + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DeepAgentsServer } from "./server.js"; +import type { DeepAgentConfig, DeepAgentsServerOptions } from "./types.js"; + +// Mock the deepagents module +vi.mock("deepagents", () => { + // Define MockFilesystemBackend inside the factory to avoid hoisting issues + class MockFilesystemBackend { + rootDir: string; + constructor(options: { rootDir: string }) { + this.rootDir = options.rootDir; + } + lsInfo = vi.fn(); + read = vi.fn(); + write = vi.fn(); + edit = vi.fn(); + grepRaw = vi.fn(); + globInfo = vi.fn(); + downloadFiles = vi.fn().mockResolvedValue([]); + uploadFiles = vi.fn().mockResolvedValue([]); + } + + return { + createDeepAgent: vi.fn().mockReturnValue({ + stream: vi.fn().mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + yield { event: "on_chain_start", data: {} }; + }, + }), + }), + FilesystemBackend: MockFilesystemBackend, + }; +}); + +// Mock the ACP SDK +vi.mock("@agentclientprotocol/sdk", () => ({ + AgentSideConnection: vi.fn().mockImplementation(() => ({ + closed: Promise.resolve(), + sessionUpdate: vi.fn(), + })), + ndJsonStream: vi.fn().mockReturnValue({}), +})); + +describe("DeepAgentsServer", () => { + let defaultConfig: DeepAgentConfig; + let defaultOptions: DeepAgentsServerOptions; + + beforeEach(() => { + defaultConfig = { + name: "test-agent", + description: "A test agent", + model: "gpt-4", + }; + + defaultOptions = { + agents: defaultConfig, + serverName: "test-server", + serverVersion: "1.0.0", + debug: false, + }; + + vi.clearAllMocks(); + }); + + describe("constructor", () => { + it("should create server with single agent config", () => { + const server = new DeepAgentsServer(defaultOptions); + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + + it("should create server with multiple agent configs", () => { + const options: DeepAgentsServerOptions = { + agents: [ + { name: "agent1", description: "First agent" }, + { name: "agent2", description: "Second agent" }, + ], + serverName: "multi-agent-server", + }; + + const server = new DeepAgentsServer(options); + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + + it("should use default server name if not provided", () => { + const options: DeepAgentsServerOptions = { + agents: defaultConfig, + }; + + const server = new DeepAgentsServer(options); + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + + it("should use default server version if not provided", () => { + const options: DeepAgentsServerOptions = { + agents: defaultConfig, + }; + + const server = new DeepAgentsServer(options); + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + + it("should use current working directory as default workspace", () => { + const options: DeepAgentsServerOptions = { + agents: defaultConfig, + }; + + const server = new DeepAgentsServer(options); + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + + it("should respect custom workspace root", () => { + const options: DeepAgentsServerOptions = { + agents: defaultConfig, + workspaceRoot: "/custom/workspace", + }; + + const server = new DeepAgentsServer(options); + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + }); + + describe("stop", () => { + it("should do nothing if server is not running", () => { + const server = new DeepAgentsServer(defaultOptions); + // Should not throw + server.stop(); + }); + + it("should clear sessions when stopped", async () => { + const server = new DeepAgentsServer(defaultOptions); + // Access internal state for testing + const serverAny = server as unknown as { + sessions: Map; + isRunning: boolean; + }; + serverAny.isRunning = true; + serverAny.sessions.set("test-session", { id: "test-session" }); + + server.stop(); + + expect(serverAny.sessions.size).toBe(0); + }); + }); + + describe("debug logging", () => { + it("should log when debug is enabled", () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const options: DeepAgentsServerOptions = { + agents: defaultConfig, + debug: true, + }; + + new DeepAgentsServer(options); + + // Should have logged initialization + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("should not log when debug is disabled", () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const options: DeepAgentsServerOptions = { + agents: defaultConfig, + debug: false, + }; + + new DeepAgentsServer(options); + + // Should not have logged + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); + +describe("DeepAgentsServer handlers", () => { + // Test the internal handlers by accessing them through reflection + // In a real scenario, these would be tested via integration tests + + describe("handleInitialize", () => { + it("should return server capabilities", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test", description: "Test agent" }, + serverName: "my-server", + serverVersion: "2.0.0", + }); + + // Access private method for testing + const serverAny = server as unknown as { + handleInitialize: ( + params: Record, + ) => Promise>; + }; + + const result: any = await serverAny.handleInitialize({ + clientInfo: { name: "test-client", version: "1.0.0" }, + protocolVersion: 1, + }); + + // ACP spec: agentInfo contains name and version + expect(result.agentInfo).toBeDefined(); + expect(result.agentInfo.name).toBe("my-server"); + expect(result.agentInfo.version).toBe("2.0.0"); + // Protocol version is now a number per ACP spec + expect(result.protocolVersion).toBe(1); + // ACP spec: agentCapabilities with promptCapabilities nested + expect(result.agentCapabilities).toBeDefined(); + expect(result.agentCapabilities.promptCapabilities).toBeDefined(); + }); + + it("should store client capabilities", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test" }, + }); + + const serverAny = server as unknown as { + handleInitialize: ( + params: Record, + ) => Promise>; + clientCapabilities: { + fsReadTextFile: boolean; + fsWriteTextFile: boolean; + terminal: boolean; + }; + }; + + await serverAny.handleInitialize({ + clientInfo: { name: "test-client", version: "1.0.0" }, + protocolVersion: 1, + // ACP spec uses clientCapabilities instead of capabilities + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: {}, + }, + }); + + expect(serverAny.clientCapabilities.fsReadTextFile).toBe(true); + expect(serverAny.clientCapabilities.fsWriteTextFile).toBe(true); + expect(serverAny.clientCapabilities.terminal).toBe(true); + }); + }); + + describe("handleAuthenticate", () => { + it("should return void (no auth required)", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test" }, + }); + + const serverAny = server as unknown as { + handleAuthenticate: (params: Record) => Promise; + }; + + const result = await serverAny.handleAuthenticate({}); + expect(result).toBeUndefined(); + }); + }); + + describe("handleNewSession", () => { + it("should create a new session", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent", description: "Test" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown, + ) => Promise<{ sessionId: string; modes?: unknown[] }>; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const result = await serverAny.handleNewSession({}, mockConn); + + expect(result.sessionId).toBeDefined(); + expect(result.sessionId).toMatch(/^sess_/); + expect(serverAny.sessions.has(result.sessionId)).toBe(true); + }); + + it("should throw for unknown agent", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown, + ) => Promise; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + + await expect( + serverAny.handleNewSession( + { configOptions: { agent: "unknown-agent" } }, + mockConn, + ), + ).rejects.toThrow("Unknown agent"); + }); + + it("should return available modes", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown, + ) => Promise<{ + modes: { availableModes: Array<{ id: string; name: string }> }; + }>; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const result = await serverAny.handleNewSession({}, mockConn); + + // ACP spec: modes object contains availableModes + expect(result.modes).toBeDefined(); + expect(result.modes.availableModes).toBeDefined(); + expect(Array.isArray(result.modes.availableModes)).toBe(true); + expect(result.modes.availableModes.length).toBeGreaterThan(0); + }); + }); + + describe("handleSetSessionMode", () => { + it("should update session mode", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown, + ) => Promise<{ sessionId: string }>; + handleSetSessionMode: ( + params: Record, + ) => Promise; + sessions: Map; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + await serverAny.handleSetSessionMode({ + sessionId, + mode: "plan", + }); + + const session = serverAny.sessions.get(sessionId); + expect(session?.mode).toBe("plan"); + }); + + it("should throw for unknown session", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleSetSessionMode: ( + params: Record, + ) => Promise; + }; + + await expect( + serverAny.handleSetSessionMode({ + sessionId: "unknown-session", + mode: "plan", + }), + ).rejects.toThrow("Session not found"); + }); + }); + + describe("handleCancel", () => { + it("should handle cancel notification", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleNewSession: ( + params: Record, + conn: unknown, + ) => Promise<{ sessionId: string }>; + handleCancel: (params: Record) => Promise; + currentPromptAbortController: AbortController | null; + }; + + const mockConn = { sessionUpdate: vi.fn() }; + const { sessionId } = await serverAny.handleNewSession({}, mockConn); + + // Set up an active prompt abort controller + const controller = new AbortController(); + serverAny.currentPromptAbortController = controller; + + // Cancel should abort + await serverAny.handleCancel({ sessionId }); + + expect(controller.signal.aborted).toBe(true); + }); + + it("should do nothing for session without active prompt", async () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleCancel: (params: Record) => Promise; + }; + + // Should not throw + await expect( + serverAny.handleCancel({ sessionId: "no-active-prompt" }), + ).resolves.not.toThrow(); + }); + }); +}); + +describe("DeepAgentsServer configuration", () => { + it("should handle agent with all options", () => { + const fullConfig: DeepAgentConfig = { + name: "full-agent", + description: "Fully configured agent", + model: "claude-sonnet-4-5-20250929", + systemPrompt: "You are a helpful assistant", + skills: ["/path/to/skills"], + memory: ["/path/to/memory"], + }; + + const server = new DeepAgentsServer({ + agents: fullConfig, + debug: true, + }); + + expect(server).toBeInstanceOf(DeepAgentsServer); + }); + + it("should handle multiple agents with different configurations", () => { + const agents: DeepAgentConfig[] = [ + { + name: "coding-agent", + description: "Agent for coding tasks", + model: "claude-sonnet-4-5-20250929", + }, + { + name: "writing-agent", + description: "Agent for writing tasks", + model: "gpt-4", + }, + ]; + + const server = new DeepAgentsServer({ + agents, + serverName: "multi-agent-server", + }); + + expect(server).toBeInstanceOf(DeepAgentsServer); + }); +}); + +describe("DeepAgentsServer streaming", () => { + it("should have sendMessageChunk method", () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + sendMessageChunk: ( + sessionId: string, + conn: unknown, + messageType: string, + content: unknown[], + ) => Promise; + }; + + expect(typeof serverAny.sendMessageChunk).toBe("function"); + }); + + it("should have sendToolCall method", () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + sendToolCall: ( + sessionId: string, + conn: unknown, + toolCall: unknown, + ) => Promise; + }; + + expect(typeof serverAny.sendToolCall).toBe("function"); + }); + + it("should have sendToolCallUpdate method", () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + sendToolCallUpdate: ( + sessionId: string, + conn: unknown, + toolCall: unknown, + ) => Promise; + }; + + expect(typeof serverAny.sendToolCallUpdate).toBe("function"); + }); + + it("should have sendPlanUpdate method", () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + sendPlanUpdate: ( + sessionId: string, + conn: unknown, + entries: unknown[], + ) => Promise; + }; + + expect(typeof serverAny.sendPlanUpdate).toBe("function"); + }); + + it("should have handleToolMessage method for tool completions", () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + handleToolMessage: ( + session: unknown, + message: unknown, + activeToolCalls: Map, + conn: unknown, + ) => Promise; + }; + + expect(typeof serverAny.handleToolMessage).toBe("function"); + }); + + it("should have streamAgentResponse method", () => { + const server = new DeepAgentsServer({ + agents: { name: "test-agent" }, + }); + + const serverAny = server as unknown as { + streamAgentResponse: ( + session: unknown, + agent: unknown, + humanMessage: unknown, + conn: unknown, + ) => Promise; + }; + + expect(typeof serverAny.streamAgentResponse).toBe("function"); + }); +}); diff --git a/libs/deepagents-server/src/server.ts b/libs/deepagents-server/src/server.ts index fd8dbcd5..b398b1f2 100644 --- a/libs/deepagents-server/src/server.ts +++ b/libs/deepagents-server/src/server.ts @@ -22,7 +22,12 @@ import { type BackendFactory, } from "deepagents"; -import { HumanMessage, AIMessage, isAIMessage } from "@langchain/core/messages"; +import { + type BaseMessage, + HumanMessage, + AIMessage, + ToolMessage, +} from "@langchain/core/messages"; import { MemorySaver } from "@langchain/langgraph-checkpoint"; import type { @@ -34,6 +39,8 @@ import type { ACPCapabilities, } from "./types.js"; +import { Logger, createLogger } from "./logger.js"; + import { acpPromptToHumanMessage, langChainMessageToACP, @@ -95,6 +102,7 @@ export class DeepAgentsServer { private readonly serverVersion: string; private readonly debug: boolean; private readonly workspaceRoot: string; + private readonly logger: Logger; constructor(options: DeepAgentsServerOptions) { this.serverName = options.serverName ?? "deepagents-server"; @@ -102,6 +110,13 @@ export class DeepAgentsServer { this.debug = options.debug ?? false; this.workspaceRoot = options.workspaceRoot ?? process.cwd(); + // Initialize logger with debug and/or file logging + this.logger = createLogger({ + debug: this.debug, + logFile: options.logFile, + prefix: "[deepagents-server]", + }); + // Shared checkpointer for session persistence this.checkpointer = new MemorySaver(); @@ -115,6 +130,10 @@ export class DeepAgentsServer { } this.log("Initialized with agents:", [...this.agentConfigs.keys()]); + + if (options.logFile) { + this.log("Logging to file:", options.logFile); + } } /** @@ -246,7 +265,7 @@ export class DeepAgentsServer { /** * Stop the ACP server */ - stop(): void { + async stop(): Promise { if (!this.isRunning) { return; } @@ -255,6 +274,9 @@ export class DeepAgentsServer { this.connection = null; this.sessions.clear(); this.log("Server stopped"); + + // Close the logger to flush any pending writes + await this.logger.close(); } /** @@ -283,44 +305,69 @@ export class DeepAgentsServer { private async handleInitialize( params: InitializeRequest, ): Promise { - this.log( - "Client connected:", - params.clientName ?? "unknown", - params.clientVersion ?? "unknown", - ); + // Extract client info from either new format (clientInfo) or legacy format + const clientInfo = params.clientInfo as + | { name?: string; version?: string } + | undefined; + const clientName = + clientInfo?.name ?? (params.clientName as string) ?? "unknown"; + const clientVersion = + clientInfo?.version ?? (params.clientVersion as string) ?? "unknown"; + + this.log("Client connected:", clientName, clientVersion); // Store client capabilities - const capabilities = params.capabilities as + const clientCaps = params.clientCapabilities as | Record | undefined; - if (capabilities) { - const fs = capabilities.fs as Record | undefined; + if (clientCaps) { + const fs = clientCaps.fs as Record | undefined; this.clientCapabilities = { fsReadTextFile: fs?.readTextFile ?? false, fsWriteTextFile: fs?.writeTextFile ?? false, - terminal: capabilities.terminal !== undefined, + terminal: clientCaps.terminal !== undefined, }; } - return { - serverName: this.serverName, - serverVersion: this.serverVersion, - protocolVersion: params.protocolVersion ?? "1.0", - capabilities: { - // We support session loading - loadSession: true, - // We support modes - modes: true, - // We support commands - commands: true, + // Protocol version - ensure it's a number (ACP spec requires number) + const requestedVersion = + typeof params.protocolVersion === "number" + ? params.protocolVersion + : parseInt(String(params.protocolVersion), 10) || 1; + + const response = { + // Required: protocol version as number per ACP spec + protocolVersion: requestedVersion, + // ACP spec: agentInfo contains name and version + agentInfo: { + name: this.serverName, + version: this.serverVersion, }, - // Prompt capabilities - what content types we accept - promptCapabilities: { - text: true, - images: true, - resources: true, + // ACP spec: agentCapabilities with correct structure + agentCapabilities: { + // Whether we support session/load - must be boolean + loadSession: true, + // Prompt capabilities - what content types we accept + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + // MCP capabilities + mcpCapabilities: { + http: false, + sse: false, + }, + // Session capabilities (modes, commands, etc.) + sessionCapabilities: { + modes: true, + commands: true, + }, }, }; + + this.log("Initialize response:", JSON.stringify(response)); + return response; } /** @@ -374,34 +421,34 @@ export class DeepAgentsServer { this.log("Created session:", sessionId, "for agent:", agentName); - return { + // ACP spec NewSessionResponse only allows: sessionId, modes, models, configOptions + const response = { sessionId, - // Available modes for this agent - availableModes: [ - { - id: "agent", - name: "Agent Mode", - description: "Full autonomous agent", - }, - { - id: "plan", - name: "Plan Mode", - description: "Planning and discussion", - }, - { - id: "ask", - name: "Ask Mode", - description: "Q&A without file changes", - }, - ], - currentMode: (params.mode as string) ?? "agent", - // Available slash commands - availableCommands: [ - { name: "help", description: "Show available commands" }, - { name: "clear", description: "Clear conversation history" }, - { name: "status", description: "Show current task status" }, - ], + // ACP spec: modes object with availableModes and currentModeId + modes: { + availableModes: [ + { + id: "agent", + name: "Agent Mode", + description: "Full autonomous agent", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning and discussion", + }, + { + id: "ask", + name: "Ask Mode", + description: "Q&A without file changes", + }, + ], + currentModeId: (params.mode as string) ?? "agent", + }, }; + + this.log("New session response:", JSON.stringify(response)); + return response; } /** @@ -415,37 +462,44 @@ export class DeepAgentsServer { const session = this.sessions.get(sessionId); if (!session) { + this.log("Load session failed: session not found:", sessionId); throw new Error(`Session not found: ${sessionId}`); } + this.log("Loading session:", { + sessionId, + agent: session.agentName, + mode: session.mode, + }); session.lastActivityAt = new Date(); - return { - sessionId: session.id, - availableModes: [ - { - id: "agent", - name: "Agent Mode", - description: "Full autonomous agent", - }, - { - id: "plan", - name: "Plan Mode", - description: "Planning and discussion", - }, - { - id: "ask", - name: "Ask Mode", - description: "Q&A without file changes", - }, - ], - currentMode: session.mode ?? "agent", - availableCommands: [ - { name: "help", description: "Show available commands" }, - { name: "clear", description: "Clear conversation history" }, - { name: "status", description: "Show current task status" }, - ], + // ACP spec LoadSessionResponse only allows: modes, models, configOptions + const response = { + // ACP spec: modes object with availableModes and currentModeId + modes: { + availableModes: [ + { + id: "agent", + name: "Agent Mode", + description: "Full autonomous agent", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning and discussion", + }, + { + id: "ask", + name: "Ask Mode", + description: "Q&A without file changes", + }, + ], + currentModeId: session.mode ?? "agent", + }, }; + + this.log("Load session response:", JSON.stringify(response)); + return response; } /** @@ -461,12 +515,14 @@ export class DeepAgentsServer { const session = this.sessions.get(sessionId); if (!session) { + this.log("Prompt failed: session not found:", sessionId); throw new Error(`Session not found: ${sessionId}`); } const agent = this.agents.get(session.agentName); if (!agent) { + this.log("Prompt failed: agent not found:", session.agentName); throw new Error(`Agent not found: ${session.agentName}`); } @@ -475,9 +531,17 @@ export class DeepAgentsServer { // Create abort controller for cancellation this.currentPromptAbortController = new AbortController(); + // Extract prompt text for logging + const prompt = params.prompt as ContentBlock[]; + const promptPreview = this.getPromptPreview(prompt); + this.log("Prompt received:", { + sessionId, + agent: session.agentName, + preview: promptPreview, + }); + try { // Convert ACP prompt to LangChain message - const prompt = params.prompt as ContentBlock[]; const humanMessage = acpPromptToHumanMessage(prompt); // Stream the agent response @@ -488,17 +552,32 @@ export class DeepAgentsServer { conn, ); + this.log("Prompt completed:", { sessionId, stopReason }); return { stopReason }; } catch (error) { if ((error as Error).name === "AbortError") { + this.log("Prompt cancelled:", sessionId); return { stopReason: "cancelled" }; } + this.log("Prompt error:", { sessionId, error: (error as Error).message }); throw error; } finally { this.currentPromptAbortController = null; } } + /** + * Get a preview of the prompt for logging (truncated) + */ + private getPromptPreview(prompt: ContentBlock[]): string { + const textBlocks = prompt.filter((b) => b.type === "text"); + if (textBlocks.length === 0) { + return `[${prompt.length} non-text blocks]`; + } + const text = (textBlocks[0] as { text: string }).text; + return text.length > 100 ? text.slice(0, 100) + "..." : text; + } + /** * Handle ACP session/cancel notification */ @@ -523,8 +602,10 @@ export class DeepAgentsServer { throw new Error(`Session not found: ${sessionId}`); } - session.mode = params.mode as string; - this.log("Set mode for session:", sessionId, "to:", params.mode); + // Accept both ACP spec 'modeId' and legacy 'mode' parameter + const mode = (params.modeId as string) ?? (params.mode as string); + session.mode = mode; + this.log("Set mode for session:", sessionId, "to:", mode); return; } @@ -545,13 +626,28 @@ export class DeepAgentsServer { // Track active tool calls const activeToolCalls = new Map(); + let eventCount = 0; + + this.log("Starting agent stream:", { + sessionId: session.id, + threadId: session.threadId, + }); // Stream the agent const stream = await agent.stream({ messages: [humanMessage] }, config); for await (const event of stream) { + eventCount++; + + // Log event structure for debugging + const eventKeys = Object.keys(event); + // Check for cancellation if (this.currentPromptAbortController?.signal.aborted) { + this.log( + "Stream cancelled, cleaning up tool calls:", + activeToolCalls.size, + ); // Cancel all active tool calls for (const toolCall of activeToolCalls.values()) { await this.sendToolCallUpdate(session.id, conn, { @@ -562,27 +658,86 @@ export class DeepAgentsServer { return "cancelled"; } - // Handle different event types + // Extract messages from the event structure + // LangGraph stream events have node names as keys (e.g., "model_request", "tools") + // Messages are nested inside these node updates + let messages: BaseMessage[] = []; + + // Check for direct messages property if (event.messages && Array.isArray(event.messages)) { - for (const message of event.messages) { - if (isAIMessage(message)) { - await this.handleAIMessage( - session, - message as AIMessage, - activeToolCalls, - conn, - ); - } + messages = event.messages; + } + // Check for model_request node which contains messages + else if (event.model_request && typeof event.model_request === "object") { + const modelReq = event.model_request as { messages?: BaseMessage[] }; + if (modelReq.messages && Array.isArray(modelReq.messages)) { + messages = modelReq.messages; + } + } + // Check for tools node which may contain tool messages + else if (event.tools && typeof event.tools === "object") { + const toolsUpdate = event.tools as { messages?: BaseMessage[] }; + if (toolsUpdate.messages && Array.isArray(toolsUpdate.messages)) { + messages = toolsUpdate.messages; + } + } + + this.log("Stream event:", { + sessionId: session.id, + eventNum: eventCount, + keys: eventKeys, + messagesFound: messages.length, + }); + + // Process any messages found + for (const message of messages) { + const messageType = message.constructor?.name ?? typeof message; + this.log("Processing message:", { + sessionId: session.id, + type: messageType, + isAI: AIMessage.isInstance(message), + isTool: ToolMessage.isInstance(message), + contentType: typeof message.content, + contentPreview: + typeof message.content === "string" + ? message.content.slice(0, 100) + : "[complex]", + }); + + if (AIMessage.isInstance(message)) { + await this.handleAIMessage( + session, + message as AIMessage, + activeToolCalls, + conn, + ); + } else if (ToolMessage.isInstance(message)) { + // Handle tool completion + await this.handleToolMessage( + session, + message as ToolMessage, + activeToolCalls, + conn, + ); } } // Handle todo list updates (plan entries) if (event.todos && Array.isArray(event.todos)) { + this.log("Plan updated:", { + sessionId: session.id, + entries: event.todos.length, + }); const planEntries = todosToPlanEntries(event.todos); await this.sendPlanUpdate(session.id, conn, planEntries); } } + this.log("Agent stream completed:", { + sessionId: session.id, + eventCount, + toolCalls: activeToolCalls.size, + }); return "end_turn"; } @@ -598,6 +753,10 @@ export class DeepAgentsServer { // Handle text content if (message.content && typeof message.content === "string") { const contentBlocks = langChainMessageToACP(message); + const preview = + message.content.slice(0, 50) + + (message.content.length > 50 ? "..." : ""); + this.log("Agent message:", { sessionId: session.id, preview }); await this.sendMessageChunk(session.id, conn, "agent", contentBlocks); } @@ -605,6 +764,13 @@ export class DeepAgentsServer { const toolCalls = extractToolCalls(message); for (const toolCall of toolCalls) { + this.log("Tool call started:", { + sessionId: session.id, + toolId: toolCall.id, + tool: toolCall.name, + args: Object.keys(toolCall.args), + }); + // Send tool call notification await this.sendToolCall(session.id, conn, toolCall); activeToolCalls.set(toolCall.id, toolCall); @@ -615,6 +781,60 @@ export class DeepAgentsServer { } } + /** + * Handle a tool message (tool result) from the agent + */ + private async handleToolMessage( + session: SessionState, + message: ToolMessage, + activeToolCalls: Map, + conn: AgentSideConnection, + ): Promise { + // Get the tool call ID from the message + const toolCallId = message.tool_call_id; + const toolCall = activeToolCalls.get(toolCallId); + + if (!toolCall) { + this.log("Tool message for unknown tool call:", { + sessionId: session.id, + toolCallId, + }); + return; + } + + // Extract the result content + const resultContent = + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content); + + // Determine status based on result + const isError = + message.status === "error" || + (typeof message.content === "string" && + message.content.toLowerCase().includes("error")); + + const resultPreview = + resultContent.slice(0, 100) + (resultContent.length > 100 ? "..." : ""); + this.log("Tool completed:", { + sessionId: session.id, + toolId: toolCallId, + tool: toolCall.name, + status: isError ? "error" : "completed", + resultPreview, + }); + + // Update the tool call with the result + toolCall.status = isError ? "error" : "completed"; + toolCall.result = resultContent; + + // Send the update + await this.sendToolCallUpdate(session.id, conn, toolCall); + + // Remove from active tracking + activeToolCalls.delete(toolCallId); + } + /** * Send a message chunk update to the client */ @@ -626,18 +846,30 @@ export class DeepAgentsServer { ): Promise { const sessionUpdate = messageType === "thought" - ? "thought_message_chunk" + ? "agent_thought_chunk" : messageType === "user" ? "user_message_chunk" : "agent_message_chunk"; - await conn.sessionUpdate({ + const notification = { sessionId, update: { sessionUpdate, content: content[0], // ACP expects single content block per chunk }, - } as SessionNotification); + } as SessionNotification; + + this.log("Sending message chunk:", { + sessionId, + type: sessionUpdate, + contentType: content[0]?.type, + preview: + content[0]?.type === "text" + ? (content[0] as { text: string }).text?.slice(0, 50) + : "[non-text]", + }); + + await conn.sessionUpdate(notification); } /** @@ -724,9 +956,18 @@ export class DeepAgentsServer { const config = this.agentConfigs.get(agentName); if (!config) { + this.log("Agent configuration not found:", agentName); throw new Error(`Agent configuration not found: ${agentName}`); } + this.log("Creating agent:", { + name: agentName, + model: config.model ?? "default", + skills: config.skills?.length ?? 0, + memory: config.memory?.length ?? 0, + tools: config.tools?.length ?? 0, + }); + // Create backend - prefer ACP filesystem if client supports it const backend = this.createBackend(config); @@ -734,8 +975,7 @@ export class DeepAgentsServer { model: config.model, tools: config.tools, systemPrompt: config.systemPrompt, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - middleware: config.middleware as any, + middleware: config.middleware, backend, skills: config.skills, memory: config.memory, @@ -744,7 +984,7 @@ export class DeepAgentsServer { }); this.agents.set(agentName, agent); - this.log("Created agent:", agentName); + this.log("Agent created successfully:", agentName); } /** @@ -755,6 +995,7 @@ export class DeepAgentsServer { ): BackendProtocol | BackendFactory { // If a custom backend is provided, use it if (config.backend) { + this.log("Using custom backend for agent:", config.name); return config.backend; } @@ -765,9 +1006,12 @@ export class DeepAgentsServer { this.clientCapabilities.fsWriteTextFile ) { // TODO: Implement ACPFilesystemBackend that proxies to client - this.log("Client supports filesystem, using local backend"); + this.log( + "Client supports filesystem operations, but using local backend", + ); } + this.log("Creating FilesystemBackend:", { rootDir: this.workspaceRoot }); return new FilesystemBackend({ rootDir: this.workspaceRoot, }); @@ -778,13 +1022,20 @@ export class DeepAgentsServer { */ async readFileViaClient(path: string): Promise { if (!this.connection || !this.clientCapabilities.fsReadTextFile) { + this.log("readFileViaClient: client does not support file read"); return null; } + this.log("Reading file via client:", path); try { const result = await this.connection.readTextFile({ path }); + this.log("File read successful:", { + path, + length: result.text?.length ?? 0, + }); return result.text; - } catch { + } catch (err) { + this.log("File read failed:", { path, error: (err as Error).message }); return null; } } @@ -794,13 +1045,17 @@ export class DeepAgentsServer { */ async writeFileViaClient(path: string, content: string): Promise { if (!this.connection || !this.clientCapabilities.fsWriteTextFile) { + this.log("writeFileViaClient: client does not support file write"); return false; } + this.log("Writing file via client:", { path, length: content.length }); try { await this.connection.writeTextFile({ path, text: content }); + this.log("File write successful:", path); return true; - } catch { + } catch (err) { + this.log("File write failed:", { path, error: (err as Error).message }); return false; } } @@ -809,9 +1064,14 @@ export class DeepAgentsServer { * Log a debug message */ private log(...args: unknown[]): void { - if (this.debug) { - console.error("[deepagents-server]", ...args); - } + this.logger.log(...args); + } + + /** + * Get the logger instance (for external access if needed) + */ + getLogger(): Logger { + return this.logger; } } diff --git a/libs/deepagents-server/src/types.ts b/libs/deepagents-server/src/types.ts index e7afdfbb..c492eaa4 100644 --- a/libs/deepagents-server/src/types.ts +++ b/libs/deepagents-server/src/types.ts @@ -5,78 +5,25 @@ * DeepAgents with the Agent Client Protocol (ACP). */ -import type { BackendProtocol, BackendFactory } from "deepagents"; -import type { StructuredTool } from "@langchain/core/tools"; -import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; - -// Re-export middleware type for convenience -type AgentMiddleware = unknown; - -// ResponseFormat placeholder (actual type comes from langchain) -type ResponseFormat = unknown; - -// Checkpointer type alias -type Checkpointer = BaseCheckpointSaver; +import type { CreateDeepAgentParams } from "deepagents"; /** * Configuration for a DeepAgent exposed via ACP + * + * Extends CreateDeepAgentParams from deepagents with ACP-specific fields. + * The `name` field is required for ACP session routing. */ -export interface DeepAgentConfig { +export interface DeepAgentConfig extends CreateDeepAgentParams { /** - * Unique name for this agent (used in session routing) + * Unique name for this agent (required for ACP session routing) */ name: string; /** * Human-readable description of the agent's capabilities + * Shown to ACP clients when listing available agents */ description?: string; - - /** - * LLM model to use (default: "claude-sonnet-4-5-20250929") - */ - model?: string; - - /** - * Custom tools available to the agent - */ - tools?: StructuredTool[]; - - /** - * Custom system prompt (combined with base prompt) - */ - systemPrompt?: string; - - /** - * Custom middleware array - */ - middleware?: AgentMiddleware[]; - - /** - * Backend for filesystem operations - * Can be an instance or a factory function - */ - backend?: BackendProtocol | BackendFactory; - - /** - * Array of skill source paths (SKILL.md files) - */ - skills?: string[]; - - /** - * Array of memory source paths (AGENTS.md files) - */ - memory?: string[]; - - /** - * Structured output format - */ - responseFormat?: ResponseFormat; - - /** - * State persistence checkpointer - */ - checkpointer?: Checkpointer; } /** @@ -99,10 +46,16 @@ export interface DeepAgentsServerOptions { serverVersion?: string; /** - * Enable debug logging + * Enable debug logging to stderr */ debug?: boolean; + /** + * Path to log file for persistent logging + * Logs are written to this file regardless of debug flag, useful for production debugging + */ + logFile?: string; + /** * Workspace root directory (defaults to cwd) */ @@ -171,10 +124,16 @@ export interface ToolCallInfo { /** * Current status */ - status: "pending" | "in_progress" | "completed" | "failed" | "cancelled"; + status: + | "pending" + | "in_progress" + | "completed" + | "failed" + | "cancelled" + | "error"; /** - * Result content (if completed) + * Result content (if completed or error) */ result?: unknown; diff --git a/libs/deepagents-server/vitest.config.ts b/libs/deepagents-server/vitest.config.ts index fbb00a63..e61b5052 100644 --- a/libs/deepagents-server/vitest.config.ts +++ b/libs/deepagents-server/vitest.config.ts @@ -1,13 +1,54 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts"], - exclude: ["src/**/*.int.test.ts"], - coverage: { - provider: "v8", - reporter: ["text", "json", "html"], +import { + configDefaults, + defineConfig, + type ViteUserConfigExport, +} from "vitest/config"; + +export default defineConfig((env) => { + const common: ViteUserConfigExport = { + test: { + environment: "node", + testTimeout: 30_000, + hookTimeout: 30_000, + exclude: ["**/*.int.test.ts", ...configDefaults.exclude], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + }, + }; + + // Integration tests mode: vitest --mode int + if (env.mode === "int") { + return { + test: { + ...common.test, + testTimeout: 60_000, + exclude: configDefaults.exclude, + include: ["src/**/*.int.test.ts"], + name: "int", + }, + } satisfies ViteUserConfigExport; + } + + // All tests mode: vitest --mode all + if (env.mode === "all") { + return { + test: { + ...common.test, + testTimeout: 60_000, + exclude: configDefaults.exclude, + include: ["src/**/*.test.ts", "src/**/*.int.test.ts"], + name: "all", + }, + } satisfies ViteUserConfigExport; + } + + // Default: unit tests only + return { + test: { + ...common.test, + include: ["src/**/*.test.ts"], }, - }, + } satisfies ViteUserConfigExport; }); From 334f1ec4dc896e5a7fea7b3d24d69af279cfdb19 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 4 Feb 2026 18:36:29 -0800 Subject: [PATCH 3/3] rename to deepagents-acp --- examples/acp-server/server.ts | 2 +- examples/package.json | 4 +- libs/{deepagents-server => acp}/README.md | 48 +++---- libs/{deepagents-server => acp}/package.json | 6 +- .../src/adapter.test.ts | 0 .../{deepagents-server => acp}/src/adapter.ts | 0 .../src/cli.int.test.ts | 134 ++++++++++++------ libs/{deepagents-server => acp}/src/cli.ts | 28 ++-- libs/{deepagents-server => acp}/src/index.ts | 4 +- .../src/logger.test.ts | 0 libs/{deepagents-server => acp}/src/logger.ts | 2 +- .../src/server.int.test.ts | 0 .../src/server.test.ts | 0 libs/{deepagents-server => acp}/src/server.ts | 8 +- libs/{deepagents-server => acp}/src/types.ts | 0 libs/{deepagents-server => acp}/tsconfig.json | 0 .../tsdown.config.ts | 0 .../vitest.config.ts | 0 pnpm-lock.yaml | 90 ++++++------ 19 files changed, 185 insertions(+), 141 deletions(-) rename libs/{deepagents-server => acp}/README.md (92%) rename libs/{deepagents-server => acp}/package.json (96%) rename libs/{deepagents-server => acp}/src/adapter.test.ts (100%) rename libs/{deepagents-server => acp}/src/adapter.ts (100%) rename libs/{deepagents-server => acp}/src/cli.int.test.ts (88%) rename libs/{deepagents-server => acp}/src/cli.ts (91%) rename libs/{deepagents-server => acp}/src/index.ts (94%) rename libs/{deepagents-server => acp}/src/logger.test.ts (100%) rename libs/{deepagents-server => acp}/src/logger.ts (99%) rename libs/{deepagents-server => acp}/src/server.int.test.ts (100%) rename libs/{deepagents-server => acp}/src/server.test.ts (100%) rename libs/{deepagents-server => acp}/src/server.ts (99%) rename libs/{deepagents-server => acp}/src/types.ts (100%) rename libs/{deepagents-server => acp}/tsconfig.json (100%) rename libs/{deepagents-server => acp}/tsdown.config.ts (100%) rename libs/{deepagents-server => acp}/vitest.config.ts (100%) diff --git a/examples/acp-server/server.ts b/examples/acp-server/server.ts index 2f30073c..3a369698 100644 --- a/examples/acp-server/server.ts +++ b/examples/acp-server/server.ts @@ -23,7 +23,7 @@ * } */ -import { DeepAgentsServer } from "deepagents-server"; +import { DeepAgentsServer } from "deepagents-acp"; import { FilesystemBackend } from "deepagents"; import path from "node:path"; diff --git a/examples/package.json b/examples/package.json index e2c2d2bc..18f57c6a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,7 +7,7 @@ }, "dependencies": { "deepagents": "workspace:*", - "deepagents-server": "workspace:*", + "deepagents-acp": "workspace:*", "@langchain/anthropic": "^1.3.7", "@langchain/core": "^1.1.12", "@langchain/langgraph-checkpoint": "^1.0.0", @@ -25,4 +25,4 @@ "dotenv": "^17.2.3", "typescript": "^5.9.2" } -} +} \ No newline at end of file diff --git a/libs/deepagents-server/README.md b/libs/acp/README.md similarity index 92% rename from libs/deepagents-server/README.md rename to libs/acp/README.md index a0bd9092..fc265788 100644 --- a/libs/deepagents-server/README.md +++ b/libs/acp/README.md @@ -1,4 +1,4 @@ -# deepagents-server +# deepagents-acp ACP (Agent Client Protocol) server for DeepAgents - enables integration with IDEs like Zed, JetBrains, and other ACP-compatible clients. @@ -18,9 +18,9 @@ The Agent Client Protocol is a standardized communication protocol between code ## Installation ```bash -npm install deepagents-server +npm install deepagents-acp # or -pnpm add deepagents-server +pnpm add deepagents-acp ``` ## Quick Start @@ -31,13 +31,13 @@ The easiest way to start is with the CLI: ```bash # Run with defaults -npx deepagents-server +npx deepagents-acp # With custom options -npx deepagents-server --name my-agent --debug +npx deepagents-acp --name my-agent --debug # Full options -npx deepagents-server \ +npx deepagents-acp \ --name coding-assistant \ --model claude-sonnet-4-5-20250929 \ --workspace /path/to/project \ @@ -71,7 +71,7 @@ npx deepagents-server \ ### Programmatic Usage ```typescript -import { startServer } from "deepagents-server"; +import { startServer } from "deepagents-acp"; await startServer({ agents: { @@ -85,7 +85,7 @@ await startServer({ ### Advanced Configuration ```typescript -import { DeepAgentsServer } from "deepagents-server"; +import { DeepAgentsServer } from "deepagents-acp"; import { FilesystemBackend } from "deepagents"; const server = new DeepAgentsServer({ @@ -107,7 +107,7 @@ const server = new DeepAgentsServer({ ], // Server options - serverName: "my-deepagents-server", + serverName: "my-deepagents-acp", serverVersion: "1.0.0", workspaceRoot: process.cwd(), debug: true, @@ -129,7 +129,7 @@ To use with [Zed](https://zed.dev), add the agent to your settings (`~/.config/z "deepagents": { "name": "DeepAgents", "command": "npx", - "args": ["deepagents-server"] + "args": ["deepagents-acp"] } } } @@ -146,7 +146,7 @@ To use with [Zed](https://zed.dev), add the agent to your settings (`~/.config/z "name": "DeepAgents", "command": "npx", "args": [ - "deepagents-server", + "deepagents-acp", "--name", "my-assistant", "--skills", "./skills", "--debug" @@ -166,7 +166,7 @@ For more control, create a custom script: ```typescript // server.ts -import { startServer } from "deepagents-server"; +import { startServer } from "deepagents-acp"; await startServer({ agents: { @@ -200,20 +200,20 @@ Then configure Zed: The main server class that handles ACP communication. ```typescript -import { DeepAgentsServer } from "deepagents-server"; +import { DeepAgentsServer } from "deepagents-acp"; const server = new DeepAgentsServer(options); ``` #### Options -| Option | Type | Description | -| --------------- | -------------------------------------- | -------------------------------------------------- | -| `agents` | `DeepAgentConfig \| DeepAgentConfig[]` | Agent configuration(s) | -| `serverName` | `string` | Server name for ACP (default: "deepagents-server") | -| `serverVersion` | `string` | Server version (default: "0.0.1") | -| `workspaceRoot` | `string` | Workspace root directory (default: cwd) | -| `debug` | `boolean` | Enable debug logging (default: false) | +| Option | Type | Description | +| --------------- | -------------------------------------- | ----------------------------------------------- | +| `agents` | `DeepAgentConfig \| DeepAgentConfig[]` | Agent configuration(s) | +| `serverName` | `string` | Server name for ACP (default: "deepagents-acp") | +| `serverVersion` | `string` | Server version (default: "0.0.1") | +| `workspaceRoot` | `string` | Workspace root directory (default: cwd) | +| `debug` | `boolean` | Enable debug logging (default: false) | #### DeepAgentConfig @@ -252,7 +252,7 @@ server.stop(); Convenience function to create and start a server. ```typescript -import { startServer } from "deepagents-server"; +import { startServer } from "deepagents-acp"; const server = await startServer(options); ``` @@ -311,7 +311,7 @@ The server supports three operating modes: │ stdio (JSON-RPC 2.0) ▼ ┌─────────────────────────────────────────────────────────────┐ -│ deepagents-server │ +│ deepagents-acp │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ AgentSideConnection │ │ │ │ (from @agentclientprotocol/sdk) │ │ @@ -334,7 +334,7 @@ The server supports three operating modes: ### Custom Backend ```typescript -import { DeepAgentsServer } from "deepagents-server"; +import { DeepAgentsServer } from "deepagents-acp"; import { CompositeBackend, FilesystemBackend, StateBackend } from "deepagents"; const server = new DeepAgentsServer({ @@ -353,7 +353,7 @@ const server = new DeepAgentsServer({ ### With Custom Tools ```typescript -import { DeepAgentsServer } from "deepagents-server"; +import { DeepAgentsServer } from "deepagents-acp"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; diff --git a/libs/deepagents-server/package.json b/libs/acp/package.json similarity index 96% rename from libs/deepagents-server/package.json rename to libs/acp/package.json index 3ea3619d..4eb8b64a 100644 --- a/libs/deepagents-server/package.json +++ b/libs/acp/package.json @@ -1,5 +1,5 @@ { - "name": "deepagents-server", + "name": "deepagents-acp", "version": "0.0.1", "description": "ACP (Agent Client Protocol) server for DeepAgents - enables IDE integration with Zed, JetBrains, and other ACP clients", "main": "./dist/index.cjs", @@ -7,7 +7,7 @@ "types": "./dist/index.d.ts", "type": "module", "bin": { - "deepagents-server": "./dist/cli.js" + "deepagents-acp": "./dist/cli.js" }, "scripts": { "build": "tsdown", @@ -81,4 +81,4 @@ "files": [ "dist/**/*" ] -} +} \ No newline at end of file diff --git a/libs/deepagents-server/src/adapter.test.ts b/libs/acp/src/adapter.test.ts similarity index 100% rename from libs/deepagents-server/src/adapter.test.ts rename to libs/acp/src/adapter.test.ts diff --git a/libs/deepagents-server/src/adapter.ts b/libs/acp/src/adapter.ts similarity index 100% rename from libs/deepagents-server/src/adapter.ts rename to libs/acp/src/adapter.ts diff --git a/libs/deepagents-server/src/cli.int.test.ts b/libs/acp/src/cli.int.test.ts similarity index 88% rename from libs/deepagents-server/src/cli.int.test.ts rename to libs/acp/src/cli.int.test.ts index 85a6f45d..754aba56 100644 --- a/libs/deepagents-server/src/cli.int.test.ts +++ b/libs/acp/src/cli.int.test.ts @@ -38,10 +38,13 @@ interface ACPNotification { */ class CLITestHelper { private process: ChildProcess | null = null; - private responseQueue: Map void; - reject: (error: Error) => void; - }> = new Map(); + private responseQueue: Map< + number, + { + resolve: (value: ACPResponse) => void; + reject: (error: Error) => void; + } + > = new Map(); private notifications: ACPNotification[] = []; private nextId = 1; private rl: readline.Interface | null = null; @@ -74,9 +77,15 @@ class CLITestHelper { this.process.stderr?.on("data", (data: Buffer) => { const lines = data.toString().split("\n").filter(Boolean); this.stderrOutput.push(...lines); - + // Check for startup message - if (lines.some((l) => l.includes("Server started") || l.includes("waiting for connections"))) { + if ( + lines.some( + (l) => + l.includes("Server started") || + l.includes("waiting for connections"), + ) + ) { clearTimeout(timeout); resolve(); } @@ -91,10 +100,10 @@ class CLITestHelper { this.rl.on("line", (line) => { if (!line.trim()) return; - + try { const message = JSON.parse(line); - + if ("id" in message && this.responseQueue.has(message.id)) { // This is a response to a request const handler = this.responseQueue.get(message.id)!; @@ -133,7 +142,10 @@ class CLITestHelper { /** * Send an ACP request and wait for response */ - async sendRequest(method: string, params?: Record): Promise { + async sendRequest( + method: string, + params?: Record, + ): Promise { if (!this.process?.stdin) { throw new Error("CLI not started"); } @@ -219,7 +231,7 @@ class CLITestHelper { if (this.process) { // Close stdin to signal EOF this.process.stdin?.end(); - + // Wait for process to exit await new Promise((resolve) => { const timeout = setTimeout(() => { @@ -282,22 +294,17 @@ describe("CLI Integration Tests", () => { }); it("should start with custom agent name", async () => { - helper = new CLITestHelper(cliPath, [ - "--name", "test-agent", - "--debug", - ]); + helper = new CLITestHelper(cliPath, ["--name", "test-agent", "--debug"]); await helper.start(); expect(helper.isRunning()).toBe(true); - + const stderr = helper.getStderr(); expect(stderr.some((line) => line.includes("test-agent"))).toBe(true); }); it("should write logs to file when --log-file is specified", async () => { - helper = new CLITestHelper(cliPath, [ - "--log-file", logFile, - ]); + helper = new CLITestHelper(cliPath, ["--log-file", logFile]); await helper.start(); // Give time for log to be written @@ -327,8 +334,10 @@ describe("CLI Integration Tests", () => { expect(response.result).toBeDefined(); // Check for ACP spec format (agentInfo) - const agentInfo = response.result?.agentInfo as { name?: string; version?: string } | undefined; - expect(agentInfo?.name).toBe("deepagents-server"); + const agentInfo = response.result?.agentInfo as + | { name?: string; version?: string } + | undefined; + expect(agentInfo?.name).toBe("deepagents-acp"); expect(response.result?.protocolVersion).toBe(1); }); @@ -339,11 +348,17 @@ describe("CLI Integration Tests", () => { }); expect(response.result?.agentCapabilities).toBeDefined(); - const capabilities = response.result?.agentCapabilities as Record; + const capabilities = response.result?.agentCapabilities as Record< + string, + unknown + >; // ACP spec: loadSession is a boolean expect(capabilities.loadSession).toBe(true); // ACP spec: sessionCapabilities contains modes and commands - const sessionCaps = capabilities.sessionCapabilities as Record; + const sessionCaps = capabilities.sessionCapabilities as Record< + string, + boolean + >; expect(sessionCaps.modes).toBe(true); expect(sessionCaps.commands).toBe(true); }); @@ -355,9 +370,15 @@ describe("CLI Integration Tests", () => { }); expect(response.result?.agentCapabilities).toBeDefined(); - const agentCaps = response.result?.agentCapabilities as Record; + const agentCaps = response.result?.agentCapabilities as Record< + string, + unknown + >; // ACP spec: promptCapabilities has image, audio, embeddedContext - const promptCaps = agentCaps.promptCapabilities as Record; + const promptCaps = agentCaps.promptCapabilities as Record< + string, + boolean + >; expect(promptCaps.image).toBe(true); expect(promptCaps.embeddedContext).toBe(true); }); @@ -385,7 +406,9 @@ describe("CLI Integration Tests", () => { expect(response.result).toBeDefined(); expect(response.result?.sessionId).toBeDefined(); expect(typeof response.result?.sessionId).toBe("string"); - expect((response.result?.sessionId as string).startsWith("sess_")).toBe(true); + expect((response.result?.sessionId as string).startsWith("sess_")).toBe( + true, + ); }); it("should return available modes in new session", async () => { @@ -396,10 +419,13 @@ describe("CLI Integration Tests", () => { // ACP spec uses 'modes' object with 'availableModes' array and 'currentModeId' expect(response.result?.modes).toBeDefined(); - const modesState = response.result?.modes as { availableModes?: Array<{ id: string; name: string }>; currentModeId?: string }; + const modesState = response.result?.modes as { + availableModes?: Array<{ id: string; name: string }>; + currentModeId?: string; + }; expect(modesState.availableModes).toBeDefined(); expect(modesState.availableModes!.length).toBeGreaterThan(0); - + const modeIds = modesState.availableModes!.map((m) => m.id); expect(modeIds).toContain("agent"); expect(modeIds).toContain("plan"); @@ -504,7 +530,9 @@ describe("CLI Integration Tests", () => { }); const stderr = helper.getStderr(); - expect(stderr.some((line) => line.includes("[deepagents-server]"))).toBe(true); + expect(stderr.some((line) => line.includes("[deepagents-acp]"))).toBe( + true, + ); }); it("should log client connection info in debug mode", async () => { @@ -517,9 +545,13 @@ describe("CLI Integration Tests", () => { }); const stderr = helper.getStderr(); - expect(stderr.some((line) => - line.includes("Client connected") || line.includes("my-test-client") - )).toBe(true); + expect( + stderr.some( + (line) => + line.includes("Client connected") || + line.includes("my-test-client"), + ), + ).toBe(true); }); }); @@ -556,14 +588,22 @@ describe("CLI Integration Tests", () => { describe("CLI Help and Version", () => { it("should show help with --help flag", async () => { const cliPath = path.resolve(__dirname, "..", "dist", "cli.js"); - - const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve) => { + + const result = await new Promise<{ + stdout: string; + stderr: string; + code: number; + }>((resolve) => { const proc = spawn("node", [cliPath, "--help"]); let stdout = ""; let stderr = ""; - proc.stdout?.on("data", (data) => { stdout += data.toString(); }); - proc.stderr?.on("data", (data) => { stderr += data.toString(); }); + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); proc.on("exit", (code) => { resolve({ stdout, stderr, code: code ?? 0 }); }); @@ -578,18 +618,22 @@ describe("CLI Help and Version", () => { it("should show version with --version flag", async () => { const cliPath = path.resolve(__dirname, "..", "dist", "cli.js"); - - const result = await new Promise<{ stdout: string; code: number }>((resolve) => { - const proc = spawn("node", [cliPath, "--version"]); - let stdout = ""; - proc.stdout?.on("data", (data) => { stdout += data.toString(); }); - proc.on("exit", (code) => { - resolve({ stdout, code: code ?? 0 }); - }); - }); + const result = await new Promise<{ stdout: string; code: number }>( + (resolve) => { + const proc = spawn("node", [cliPath, "--version"]); + let stdout = ""; + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.on("exit", (code) => { + resolve({ stdout, code: code ?? 0 }); + }); + }, + ); expect(result.code).toBe(0); - expect(result.stdout).toContain("deepagents-server"); + expect(result.stdout).toContain("deepagents-acp"); }); }); diff --git a/libs/deepagents-server/src/cli.ts b/libs/acp/src/cli.ts similarity index 91% rename from libs/deepagents-server/src/cli.ts rename to libs/acp/src/cli.ts index efdb90b3..2c41e2b9 100644 --- a/libs/deepagents-server/src/cli.ts +++ b/libs/acp/src/cli.ts @@ -5,7 +5,7 @@ * Run a DeepAgents ACP server for integration with IDEs like Zed. * * Usage: - * npx deepagents-server [options] + * npx deepagents-acp [options] * * Options: * --name Agent name (default: "deepagents") @@ -173,7 +173,7 @@ Run a DeepAgents-powered AI coding assistant that integrates with IDEs like Zed, JetBrains, and other ACP-compatible clients. USAGE: - npx deepagents-server [options] + npx deepagents-acp [options] OPTIONS: -n, --name Agent name (default: "deepagents") @@ -196,19 +196,19 @@ ENVIRONMENT VARIABLES: EXAMPLES: # Start with defaults - npx deepagents-server + npx deepagents-acp # Custom agent with skills - npx deepagents-server --name my-agent --skills ./skills,~/.deepagents/skills + npx deepagents-acp --name my-agent --skills ./skills,~/.deepagents/skills # Debug mode with custom workspace - npx deepagents-server --debug --workspace /path/to/project + npx deepagents-acp --debug --workspace /path/to/project # Production debugging with log file - npx deepagents-server --log-file /var/log/deepagents.log + npx deepagents-acp --log-file /var/log/deepagents.log # Combined debug and file logging - npx deepagents-server --debug --log-file ./debug.log + npx deepagents-acp --debug --log-file ./debug.log ZED INTEGRATION: Add to your Zed settings.json: @@ -219,7 +219,7 @@ ZED INTEGRATION: "deepagents": { "name": "DeepAgents", "command": "npx", - "args": ["deepagents-server", "--log-file", "/tmp/deepagents.log"], + "args": ["deepagents-acp", "--log-file", "/tmp/deepagents.log"], "env": {} } } @@ -240,9 +240,9 @@ function showVersion(): void { "package.json", ); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); - console.log(`deepagents-server v${packageJson.version}`); + console.log(`deepagents-acp v${packageJson.version}`); } catch { - console.log("deepagents-server v0.0.1"); + console.log("deepagents-acp v0.0.1"); } } @@ -288,7 +288,7 @@ async function main(): Promise { // Log startup info to stderr (stdout is reserved for ACP protocol) const log = (...msgArgs: unknown[]) => { if (options.debug || options.logFile) { - console.error("[deepagents-server]", ...msgArgs); + console.error("[deepagents-acp]", ...msgArgs); } }; @@ -312,7 +312,7 @@ async function main(): Promise { skills, memory, }, - serverName: "deepagents-server", + serverName: "deepagents-acp", workspaceRoot, debug: options.debug, logFile: options.logFile ?? undefined, @@ -320,13 +320,13 @@ async function main(): Promise { await server.start(); } catch (error) { - console.error("[deepagents-server] Fatal error:", error); + console.error("[deepagents-acp] Fatal error:", error); process.exit(1); } } // Handle top-level errors main().catch((err) => { - console.error("[deepagents-server] Unhandled error:", err); + console.error("[deepagents-acp] Unhandled error:", err); process.exit(1); }); diff --git a/libs/deepagents-server/src/index.ts b/libs/acp/src/index.ts similarity index 94% rename from libs/deepagents-server/src/index.ts rename to libs/acp/src/index.ts index 457b04a3..f64c63a3 100644 --- a/libs/deepagents-server/src/index.ts +++ b/libs/acp/src/index.ts @@ -6,11 +6,11 @@ * and other ACP-compatible clients. * * @packageDocumentation - * @module deepagents-server + * @module deepagents-acp * * @example * ```typescript - * import { DeepAgentsServer, startServer } from "deepagents-server"; + * import { DeepAgentsServer, startServer } from "deepagents-acp"; * * // Quick start * await startServer({ diff --git a/libs/deepagents-server/src/logger.test.ts b/libs/acp/src/logger.test.ts similarity index 100% rename from libs/deepagents-server/src/logger.test.ts rename to libs/acp/src/logger.test.ts diff --git a/libs/deepagents-server/src/logger.ts b/libs/acp/src/logger.ts similarity index 99% rename from libs/deepagents-server/src/logger.ts rename to libs/acp/src/logger.ts index c10da188..a56480f6 100644 --- a/libs/deepagents-server/src/logger.ts +++ b/libs/acp/src/logger.ts @@ -61,7 +61,7 @@ export class Logger { constructor(options: LoggerOptions = {}) { this.debug = options.debug ?? false; this.logFile = options.logFile ?? null; - this.prefix = options.prefix ?? "[deepagents-server]"; + this.prefix = options.prefix ?? "[deepagents-acp]"; this.timestampsForStderr = options.timestamps ?? false; this.timestampsForFile = true; // Always include timestamps in file logs diff --git a/libs/deepagents-server/src/server.int.test.ts b/libs/acp/src/server.int.test.ts similarity index 100% rename from libs/deepagents-server/src/server.int.test.ts rename to libs/acp/src/server.int.test.ts diff --git a/libs/deepagents-server/src/server.test.ts b/libs/acp/src/server.test.ts similarity index 100% rename from libs/deepagents-server/src/server.test.ts rename to libs/acp/src/server.test.ts diff --git a/libs/deepagents-server/src/server.ts b/libs/acp/src/server.ts similarity index 99% rename from libs/deepagents-server/src/server.ts rename to libs/acp/src/server.ts index b398b1f2..62138af9 100644 --- a/libs/deepagents-server/src/server.ts +++ b/libs/acp/src/server.ts @@ -75,7 +75,7 @@ type SessionNotification = Record; * * @example * ```typescript - * import { DeepAgentsServer } from "deepagents-server"; + * import { DeepAgentsServer } from "deepagents-acp"; * * const server = new DeepAgentsServer({ * agents: { @@ -105,7 +105,7 @@ export class DeepAgentsServer { private readonly logger: Logger; constructor(options: DeepAgentsServerOptions) { - this.serverName = options.serverName ?? "deepagents-server"; + this.serverName = options.serverName ?? "deepagents-acp"; this.serverVersion = options.serverVersion ?? "0.0.1"; this.debug = options.debug ?? false; this.workspaceRoot = options.workspaceRoot ?? process.cwd(); @@ -114,7 +114,7 @@ export class DeepAgentsServer { this.logger = createLogger({ debug: this.debug, logFile: options.logFile, - prefix: "[deepagents-server]", + prefix: "[deepagents-acp]", }); // Shared checkpointer for session persistence @@ -1082,7 +1082,7 @@ export class DeepAgentsServer { * * @example * ```typescript - * import { startServer } from "deepagents-server"; + * import { startServer } from "deepagents-acp"; * * await startServer({ * agents: { diff --git a/libs/deepagents-server/src/types.ts b/libs/acp/src/types.ts similarity index 100% rename from libs/deepagents-server/src/types.ts rename to libs/acp/src/types.ts diff --git a/libs/deepagents-server/tsconfig.json b/libs/acp/tsconfig.json similarity index 100% rename from libs/deepagents-server/tsconfig.json rename to libs/acp/tsconfig.json diff --git a/libs/deepagents-server/tsdown.config.ts b/libs/acp/tsdown.config.ts similarity index 100% rename from libs/deepagents-server/tsdown.config.ts rename to libs/acp/tsdown.config.ts diff --git a/libs/deepagents-server/vitest.config.ts b/libs/acp/vitest.config.ts similarity index 100% rename from libs/deepagents-server/vitest.config.ts rename to libs/acp/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf347cd..4f680563 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,9 @@ importers: deepagents: specifier: workspace:* version: link:../libs/deepagents - deepagents-server: + deepagents-acp: specifier: workspace:* - version: link:../libs/deepagents-server + version: link:../libs/acp langchain: specifier: ^1.0.4 version: 1.2.16(@langchain/core@1.1.18(openai@6.17.0(zod@4.3.6)))(openai@6.17.0(zod@4.3.6)) @@ -106,6 +106,49 @@ importers: specifier: ^5.9.2 version: 5.9.3 + libs/acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.14.0 + version: 0.14.0(zod@4.3.6) + deepagents: + specifier: workspace:* + version: link:../deepagents + devDependencies: + '@langchain/core': + specifier: ^1.1.19 + version: 1.1.19(openai@6.17.0(zod@4.3.6)) + '@langchain/langgraph': + specifier: ^1.1.3 + version: 1.1.3(@langchain/core@1.1.19(openai@6.17.0(zod@4.3.6)))(zod@4.3.6) + '@langchain/langgraph-checkpoint': + specifier: ^1.0.0 + version: 1.0.0(@langchain/core@1.1.19(openai@6.17.0(zod@4.3.6))) + '@tsconfig/recommended': + specifier: ^1.0.13 + version: 1.0.13 + '@types/node': + specifier: ^25.1.0 + version: 25.2.0 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) + '@vitest/ui': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) + tsdown: + specifier: ^0.20.1 + version: 0.20.1(synckit@0.11.12)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + libs/cli: devDependencies: '@types/fs-extra': @@ -204,49 +247,6 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) - libs/deepagents-server: - dependencies: - '@agentclientprotocol/sdk': - specifier: ^0.14.0 - version: 0.14.0(zod@4.3.6) - deepagents: - specifier: workspace:* - version: link:../deepagents - devDependencies: - '@langchain/core': - specifier: ^1.1.19 - version: 1.1.19(openai@6.17.0(zod@4.3.6)) - '@langchain/langgraph': - specifier: ^1.1.3 - version: 1.1.3(@langchain/core@1.1.19(openai@6.17.0(zod@4.3.6)))(zod@4.3.6) - '@langchain/langgraph-checkpoint': - specifier: ^1.0.0 - version: 1.0.0(@langchain/core@1.1.19(openai@6.17.0(zod@4.3.6))) - '@tsconfig/recommended': - specifier: ^1.0.13 - version: 1.0.13 - '@types/node': - specifier: ^25.1.0 - version: 25.2.0 - '@vitest/coverage-v8': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18) - '@vitest/ui': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18) - tsdown: - specifier: ^0.20.1 - version: 0.20.1(synckit@0.11.12)(typescript@5.9.3) - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) - packages: '@agentclientprotocol/sdk@0.14.0':