mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 20:42:10 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ae2c05ddb | ||
|
|
8f0fc59a48 | ||
|
|
c25150523a | ||
|
|
ea796f3cbd | ||
|
|
7f5ebca3fb | ||
|
|
75b44e945e | ||
|
|
ad8611c399 | ||
|
|
c5abff0e43 | ||
|
|
5c98bd0404 | ||
|
|
71b660ae3b | ||
|
|
4ee25af3f9 | ||
|
|
1a5a9c16c6 | ||
|
|
5ca27369ef | ||
|
|
8068d894c1 | ||
|
|
71e745d5fb |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0
|
||||
|
||||
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
|
||||
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
|
||||
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
|
||||
- Update to @agentclientprotocol/sdk@0.4.8
|
||||
|
||||
## 0.5.5
|
||||
|
||||
- Migrate to @agentclientprotocol/sdk@0.4.5
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
|
||||
|
||||
## 0.5.4
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
|
||||
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.5
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
|
||||
- Make improvements to ACP tools provided to the model
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
|
||||
|
||||
699
package-lock.json
generated
699
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -6,7 +6,7 @@
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -51,9 +51,10 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.0",
|
||||
"@modelcontextprotocol/sdk": "1.18.2",
|
||||
"@zed-industries/agent-client-protocol": "0.4.3",
|
||||
"@agentclientprotocol/sdk": "0.4.8",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.15",
|
||||
"@anthropic-ai/claude-code": "2.0.15",
|
||||
"@modelcontextprotocol/sdk": "1.20.0",
|
||||
"diff": "8.0.2",
|
||||
"express": "5.1.0",
|
||||
"minimist": "1.2.8",
|
||||
@@ -63,17 +64,17 @@
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/node": "24.7.2",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"eslint": "9.36.0",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
SessionModelState,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
McpServerConfig,
|
||||
Options,
|
||||
@@ -37,7 +40,7 @@ import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
createMcpServer,
|
||||
createPermissionMcpServer,
|
||||
@@ -74,6 +77,8 @@ type ToolUseCache = {
|
||||
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL_ID = "default";
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
@@ -169,16 +174,33 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
};
|
||||
|
||||
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
|
||||
if (params._meta?.systemPrompt) {
|
||||
const customPrompt = params._meta.systemPrompt;
|
||||
if (typeof customPrompt === "string") {
|
||||
systemPrompt = customPrompt;
|
||||
} else if (
|
||||
typeof customPrompt === "object" &&
|
||||
"append" in customPrompt &&
|
||||
typeof customPrompt.append === "string"
|
||||
) {
|
||||
systemPrompt.append = customPrompt.append;
|
||||
}
|
||||
}
|
||||
|
||||
const options: Options = {
|
||||
cwd: params.cwd,
|
||||
mcpServers,
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
permissionPromptToolName: PERMISSION_TOOL_NAME,
|
||||
stderr: (err) => console.error(err),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
executable: process.execPath as any,
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
};
|
||||
|
||||
const allowedTools = [];
|
||||
@@ -188,12 +210,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
disallowedTools.push("Read");
|
||||
}
|
||||
if (this.clientCapabilities?.fs?.writeTextFile) {
|
||||
allowedTools.push(toolNames.write);
|
||||
disallowedTools.push("Write", "Edit", "MultiEdit");
|
||||
disallowedTools.push("Write", "Edit");
|
||||
}
|
||||
if (this.clientCapabilities?.terminal) {
|
||||
allowedTools.push(toolNames.bashOutput, toolNames.killBash);
|
||||
disallowedTools.push("Bash", "BashOutput", "KillBash");
|
||||
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
|
||||
disallowedTools.push("Bash", "BashOutput", "KillShell");
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
@@ -224,8 +245,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
});
|
||||
|
||||
const models = await getAvailableModels(q);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
@@ -308,7 +332,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return { stopReason: "refusal" };
|
||||
}
|
||||
}
|
||||
case "user": {
|
||||
case "user":
|
||||
case "assistant": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
||||
if (
|
||||
@@ -326,15 +355,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
console.error(message.message.content);
|
||||
break;
|
||||
}
|
||||
// Skip user messages for now, since they seem to just be messages we don't want in the feed
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
|
||||
if (message.type === "user" && typeof message.message.content === "string") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
message.message.model === "<synthetic>" &&
|
||||
Array.isArray(message.message.content) &&
|
||||
message.message.content.length === 1 &&
|
||||
@@ -369,6 +396,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await this.sessions[params.sessionId].query.interrupt();
|
||||
}
|
||||
|
||||
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.sessions[params.sessionId].query.setModel(params.modelId);
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
@@ -377,18 +411,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
switch (params.modeId) {
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
case "plan":
|
||||
this.sessions[params.sessionId].permissionMode = params.modeId;
|
||||
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
|
||||
return {};
|
||||
case "bypassPermissions":
|
||||
// For some reason, the SDK doesn't support setting the mode to `bypassPermissions`
|
||||
// so we do it ourselves
|
||||
this.sessions[params.sessionId].permissionMode = "bypassPermissions";
|
||||
await this.sessions[params.sessionId].query.setPermissionMode("acceptEdits");
|
||||
try {
|
||||
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message ? error.message : "Invalid Mode";
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return {};
|
||||
default:
|
||||
throw new Error("Invalid mode");
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +443,26 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailableModels(query: Query): Promise<SessionModelState> {
|
||||
const models = await query.supportedModels();
|
||||
|
||||
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
|
||||
// otherwise we just choose the first model in the list.
|
||||
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
|
||||
await query.setModel(currentModel.value);
|
||||
|
||||
const availableModels = models.map((model) => ({
|
||||
modelId: model.value,
|
||||
name: model.displayName,
|
||||
description: model.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"add-dir",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
||||
import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
@@ -17,15 +17,14 @@ export const SYSTEM_REMINDER = `
|
||||
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
|
||||
</system-reminder>`;
|
||||
|
||||
const defaults = { maxFileSize: 50000, linesToRead: 1000 };
|
||||
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
|
||||
|
||||
const unqualifiedToolNames = {
|
||||
read: "read",
|
||||
edit: "edit",
|
||||
write: "write",
|
||||
multiEdit: "multi-edit",
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
write: "Write",
|
||||
bash: "Bash",
|
||||
killBash: "KillBash",
|
||||
killShell: "KillShell",
|
||||
bashOutput: "BashOutput",
|
||||
};
|
||||
|
||||
@@ -34,13 +33,12 @@ export const toolNames = {
|
||||
read: SERVER_PREFIX + unqualifiedToolNames.read,
|
||||
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
|
||||
write: SERVER_PREFIX + unqualifiedToolNames.write,
|
||||
multiEdit: SERVER_PREFIX + unqualifiedToolNames.multiEdit,
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killBash: SERVER_PREFIX + unqualifiedToolNames.killBash,
|
||||
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
};
|
||||
|
||||
const editToolNames = [toolNames.edit, toolNames.multiEdit, toolNames.write];
|
||||
const editToolNames = [toolNames.edit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
@@ -54,27 +52,36 @@ export function createMcpServer(
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: "Read",
|
||||
title: unqualifiedToolNames.read,
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
Never attempt to read a path that hasn't been previously mentioned.
|
||||
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
|
||||
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
|
||||
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
file_path: z.string().describe("The absolute path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(0)
|
||||
.default(1)
|
||||
.describe(
|
||||
"Which line to start reading from (0-based). Omit to start from the beginning.",
|
||||
"The line number to start reading from. Only provide if the file is too large to read at once",
|
||||
),
|
||||
linesToRead: z
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(defaults.linesToRead)
|
||||
.describe(
|
||||
`How many lines to read. Omit for ${defaults.linesToRead}. (Reading files that are too large can fill up the context window unnecessarily, so be thoughtful about how much you read at a time.)`,
|
||||
`The number of lines to read. Only provide if the file is too large to read at once.`,
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
@@ -101,9 +108,9 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
|
||||
|
||||
const content = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
line: input.offset + 1,
|
||||
limit: input.linesToRead,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
@@ -111,13 +118,13 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
if (input.offset > 0 || result.wasLimited) {
|
||||
if (input.offset > 1 || result.wasLimited) {
|
||||
readInfo = "\n\n<file-read-info>";
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
|
||||
} else {
|
||||
readInfo += `Read lines ${(input.offset ?? 0) + 1}-${result.linesRead}. `;
|
||||
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
|
||||
}
|
||||
|
||||
if (result.wasLimited) {
|
||||
@@ -153,14 +160,23 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.write,
|
||||
{
|
||||
title: "Write",
|
||||
description: `Writes content to the specified file in the project.
|
||||
title: unqualifiedToolNames.write,
|
||||
description: `Writes a file to the local filesystem..
|
||||
|
||||
In sessions with ${toolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.`,
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to write"),
|
||||
content: z.string().describe("The full content to write"),
|
||||
file_path: z
|
||||
.string()
|
||||
.describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Write file",
|
||||
@@ -185,7 +201,7 @@ allow the user to conveniently review changes.`,
|
||||
}
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
|
||||
@@ -208,26 +224,30 @@ allow the user to conveniently review changes.`,
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: "Edit",
|
||||
description: `Edit a file.
|
||||
title: unqualifiedToolNames.edit,
|
||||
description: `Performs exact string replacements in files.
|
||||
|
||||
In sessions with ${toolNames.edit} always use it instead of Edit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
- The \`old_string\` param must match existing file content, including indentation.
|
||||
- The \`old_string\` param must come from the actual file, not an outline.
|
||||
- The \`old_string\` section must not be empty.
|
||||
- Be minimal with replacements:
|
||||
- For unique lines, include only those lines.
|
||||
- For non-unique lines, include enough context to identify them.
|
||||
- Do not escape quotes, newlines, or other characters.
|
||||
- Only edit the specified file.
|
||||
- If the \`old_string\` value isn't found in the file, the edit won't be applied. The tool will fail and must be retried.`,
|
||||
Usage:
|
||||
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
|
||||
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
old_string: z.string().describe("The old text to replace (must be unique in the file)"),
|
||||
new_string: z.string().describe("The new text."),
|
||||
file_path: z.string().describe("The absolute path to the file to modify"),
|
||||
old_string: z.string().describe("The text to replace"),
|
||||
new_string: z
|
||||
.string()
|
||||
.describe("The text to replace it with (must be different from old_string)"),
|
||||
replace_all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
@@ -252,105 +272,17 @@ File editing instructions:
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: false,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.abs_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.multiEdit,
|
||||
{
|
||||
title: "Multi Edit",
|
||||
description: `Edit a file with multiple sequential edits.
|
||||
|
||||
In sessions with ${toolNames.multiEdit} always use it instead of MultiEdit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
- The \`old_string\` param must match existing file content, including indentation.
|
||||
- The \`old_string\` param must come from the actual file, not an outline.
|
||||
- The \`old_string\` section must not be empty.
|
||||
- Be minimal with replacements:
|
||||
- For unique lines, include only those lines.
|
||||
- For non-unique lines, include enough context to identify them, unless you're using \`replace_all\`
|
||||
- Do not escape quotes, newlines, or other characters.
|
||||
- If any of the provided \`old_string\` values aren't found in the file, no edits will be applied. The tool will fail and must be retried.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
old_string: z.string().describe("The text to replace"),
|
||||
new_string: z.string().describe("The text to replace it with"),
|
||||
replace_all: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Replace all occurrences of old_string (default false)"),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Multi Edit file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(
|
||||
content,
|
||||
input.edits.map((edit) => ({
|
||||
oldText: edit.old_string,
|
||||
newText: edit.new_string,
|
||||
replaceAll: edit.replace_all ?? false,
|
||||
})),
|
||||
);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
@@ -375,19 +307,34 @@ File editing instructions:
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: "Bash",
|
||||
description: "Executes a bash command",
|
||||
title: unqualifiedToolNames.bash,
|
||||
description: `Executes a bash command
|
||||
|
||||
In sessions with ${toolNames.bash} always use it instead of Bash`,
|
||||
inputSchema: {
|
||||
command: z.string().describe("The bash command to execute as a one-liner"),
|
||||
timeout_ms: z
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.default(2 * 60 * 1000)
|
||||
.describe("Optional timeout in milliseconds"),
|
||||
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
description: z.string().optional()
|
||||
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
|
||||
Input: ls
|
||||
Output: List files in current directory
|
||||
|
||||
Input: git status
|
||||
Output: Show working tree status
|
||||
|
||||
Input: npm install
|
||||
Output: Install package dependencies
|
||||
|
||||
Input: mkdir foo
|
||||
Output: Create directory 'foo'`),
|
||||
run_in_background: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
`When set to true, the command is started in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killBash}\` tool to stop it early.`,
|
||||
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -426,6 +373,7 @@ File editing instructions:
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
title: input.description,
|
||||
content: [{ type: "terminal", terminalId: handle.id }],
|
||||
},
|
||||
});
|
||||
@@ -443,7 +391,7 @@ File editing instructions:
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
|
||||
sleep(input.timeout_ms).then(async () => {
|
||||
sleep(input.timeout).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
@@ -510,20 +458,25 @@ File editing instructions:
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bashOutput,
|
||||
{
|
||||
title: "BashOutput",
|
||||
description:
|
||||
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
|
||||
title: unqualifiedToolNames.bashOutput,
|
||||
description: `- Retrieves output from a running or completed background bash shell
|
||||
- Takes a shell_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Returns stdout and stderr output along with shell status
|
||||
- Use this tool when you need to monitor or check the output of a long-running shell
|
||||
|
||||
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
|
||||
inputSchema: {
|
||||
id: z
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.id];
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.id}`);
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
if (bgTerm.status === "started") {
|
||||
@@ -559,21 +512,26 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.killBash,
|
||||
unqualifiedToolNames.killShell,
|
||||
{
|
||||
title: "KillBash",
|
||||
description: "Stops a background command by its id",
|
||||
title: unqualifiedToolNames.killShell,
|
||||
description: `- Kills a running background bash shell by its ID
|
||||
- Takes a shell_id parameter identifying the shell to kill
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running shell
|
||||
|
||||
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
|
||||
inputSchema: {
|
||||
id: z
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.id];
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.id}`);
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
switch (bgTerm.status) {
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||
import { toAcpNotifications } from "../acp-agent.js";
|
||||
import { SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
@@ -371,13 +371,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__write tool calls", () => {
|
||||
it("should handle mcp__acp__Write tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01GHI789JKL456",
|
||||
name: "mcp__acp__write",
|
||||
name: "mcp__acp__Write",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/config.json",
|
||||
file_path: "/Users/test/project/config.json",
|
||||
content: '{"version": "1.0.0"}',
|
||||
},
|
||||
};
|
||||
@@ -415,13 +415,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read tool calls", () => {
|
||||
it("should handle mcp__acp__Read tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01YZA789BCD123",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/data.json",
|
||||
file_path: "/Users/test/project/data.json",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -433,13 +433,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with limit", () => {
|
||||
it("should handle mcp__acp__Read with limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EFG456HIJ789",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
limit: 100,
|
||||
},
|
||||
};
|
||||
@@ -452,13 +452,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with offset and limit", () => {
|
||||
it("should handle mcp__acp__Read with offset and limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01KLM789NOP456",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 50,
|
||||
limit: 100,
|
||||
},
|
||||
@@ -472,13 +472,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with only offset", () => {
|
||||
it("should handle mcp__acp__Read with only offset", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01QRS123TUV789",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 200,
|
||||
},
|
||||
};
|
||||
@@ -539,7 +539,7 @@ describe("tool conversions", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
|
||||
name: "KillBash",
|
||||
name: "KillShell",
|
||||
input: {
|
||||
shell_id: "bash_1",
|
||||
},
|
||||
@@ -701,78 +701,13 @@ describe("tool conversions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should show full diff for multi edit tool", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01DEF456",
|
||||
name: "mcp__acp__multi-edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/config.json",
|
||||
edits: [
|
||||
{
|
||||
old_string: 'version": 1',
|
||||
new_string: 'version": 2',
|
||||
replace_all: false,
|
||||
},
|
||||
{
|
||||
old_string: 'enabled": false',
|
||||
new_string: 'enabled": true',
|
||||
replace_all: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const fileCache: { [key: string]: string } = {
|
||||
"/Users/test/project/config.json": JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
filler: "filler",
|
||||
enabled: false,
|
||||
},
|
||||
null,
|
||||
4,
|
||||
),
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
|
||||
content: [
|
||||
{
|
||||
newText: `{
|
||||
"version": 2,
|
||||
"filler": "filler",
|
||||
"enabled": true
|
||||
}`,
|
||||
oldText: `{
|
||||
"version": 1,
|
||||
"filler": "filler",
|
||||
"enabled": false
|
||||
}`,
|
||||
path: "/Users/test/project/config.json",
|
||||
type: "diff",
|
||||
},
|
||||
],
|
||||
kind: "edit",
|
||||
locations: [
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 1,
|
||||
},
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 3,
|
||||
},
|
||||
],
|
||||
title: "Edit /Users/test/project/config.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty update for successful edit result", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__edit",
|
||||
name: "mcp__acp__Edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
@@ -800,9 +735,9 @@ describe("tool conversions", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__edit",
|
||||
name: "mcp__acp__Edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
@@ -845,3 +780,12 @@ describe("escape markdown", () => {
|
||||
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDK behavior", () => {
|
||||
it("query has a 'default' model", async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
const defaultModel = models.find((m) => m.value === "default");
|
||||
expect(defaultModel).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
81
src/tools.ts
81
src/tools.ts
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
|
||||
@@ -91,8 +86,8 @@ export function toolInfoFromToolUse(
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "KillBash":
|
||||
case toolNames.killBash:
|
||||
case "KillShell":
|
||||
case toolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
@@ -108,11 +103,11 @@ export function toolInfoFromToolUse(
|
||||
limit = " (from line " + (input.offset + 1) + ")";
|
||||
}
|
||||
return {
|
||||
title: "Read " + (input.abs_path ?? "File") + limit,
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
kind: "read",
|
||||
locations: [
|
||||
{
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
],
|
||||
@@ -138,7 +133,7 @@ export function toolInfoFromToolUse(
|
||||
|
||||
case toolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.abs_path ?? input?.file_path;
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let affectedLines: number[] = [];
|
||||
@@ -182,66 +177,13 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case toolNames.multiEdit:
|
||||
case "MultiEdit": {
|
||||
const multiInput = input as {
|
||||
file_path: string;
|
||||
edits: {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}[];
|
||||
};
|
||||
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
|
||||
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
|
||||
let affectedLines: number[] = [];
|
||||
try {
|
||||
if (multiInput.edits && multiInput.file_path) {
|
||||
const oldContent =
|
||||
cachedFileContent[multiInput.file_path] ||
|
||||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
|
||||
const newContent = replaceAndCalculateLocation(
|
||||
oldContent,
|
||||
multiInput.edits.map((edit) => ({
|
||||
oldText: edit.old_string,
|
||||
newText: edit.new_string,
|
||||
replaceAll: edit.replace_all,
|
||||
})),
|
||||
);
|
||||
oldTextMulti = oldContent;
|
||||
newTextMulti = newContent.newContent;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// Display it as a normal edit, because end users don't care about
|
||||
// the distinction between edits and multi-edits.
|
||||
return {
|
||||
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||
kind: "edit",
|
||||
content: [
|
||||
{
|
||||
type: "diff" as const,
|
||||
path: input.file_path,
|
||||
oldText: oldTextMulti,
|
||||
newText: newTextMulti,
|
||||
},
|
||||
],
|
||||
locations: input?.file_path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path: input.file_path }))
|
||||
: [{ path: input.file_path }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
case toolNames.write: {
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.abs_path) {
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
oldText: null,
|
||||
newText: input.content,
|
||||
},
|
||||
@@ -255,10 +197,10 @@ export function toolInfoFromToolUse(
|
||||
];
|
||||
}
|
||||
return {
|
||||
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -481,9 +423,6 @@ export function toolUpdateFromToolResult(
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case toolNames.edit:
|
||||
case toolNames.multiEdit:
|
||||
case "multi-edit":
|
||||
case "MultiEdit":
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user