Add terminal output and diagnostics to relevant details

This commit is contained in:
Saoud Rizwan
2024-09-08 18:06:52 -04:00
parent 076acf736e
commit a6c64bea8f
14 changed files with 320 additions and 455 deletions

View File

@@ -21,7 +21,7 @@ import { getApiMetrics } from "./shared/getApiMetrics"
import { HistoryItem } from "./shared/HistoryItem"
import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAskResponse } from "./shared/WebviewMessage"
import { findLast, findLastIndex } from "./utils"
import { findLast, findLastIndex, formatContentBlockToMarkdown } from "./utils"
import { truncateHalfConversation } from "./utils/context-management"
import { regexSearchFiles } from "./utils/ripgrep"
import { extractTextFromFile } from "./utils/extract-text"
@@ -281,7 +281,7 @@ export class ClaudeDev {
) {
this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager(provider.context)
this.terminalManager = new TerminalManager()
this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -445,31 +445,6 @@ export class ClaudeDev {
await this.providerRef.deref()?.postStateToWebview()
}
private formatImagesIntoBlocks(images?: string[]): Anthropic.ImageBlockParam[] {
return images
? images.map((dataUrl) => {
// data:image/png;base64,base64string
const [rest, base64] = dataUrl.split(",")
const mimeType = rest.split(":")[1].split(";")[0]
return {
type: "image",
source: { type: "base64", media_type: mimeType, data: base64 },
} as Anthropic.ImageBlockParam
})
: []
}
private formatIntoToolResponse(text: string, images?: string[]): ToolResponse {
if (images && images.length > 0) {
const textBlock: Anthropic.TextBlockParam = { type: "text", text }
const imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images)
// Placing images after text leads to better results
return [textBlock, ...imageBlocks]
} else {
return text
}
}
private async startTask(task?: string, images?: string[]): Promise<void> {
// conversationHistory (for API) and claudeMessages (for webview) need to be in sync
// if the extension process were killed, then on restart the claudeMessages might not be empty, so we need to set it to [] when we create a new ClaudeDev client (otherwise webview would show stale messages from previous session)
@@ -486,21 +461,19 @@ export class ClaudeDev {
await this.say(
"api_req_started",
JSON.stringify({
request: this.api.createUserReadableRequest([
{
type: "text",
text: `${taskText}\n\n<potentially_relevant_details>(see getPotentiallyRelevantDetails in src/ClaudeDev.ts)</potentially_relevant_details>`,
},
...imageBlocks,
]),
request: `${taskText}\n\n<potentially_relevant_details>\nLoading...\n</potentially_relevant_details>`,
})
)
this.shouldSkipNextApiReqStartedMessage = true
this.getPotentiallyRelevantDetails(true).then(async (verboseDetails) => {
this.getInitialDetails().then(async (initialDetails) => {
const lastApiReqIndex = findLastIndex(this.claudeMessages, (m) => m.say === "api_req_started")
this.claudeMessages[lastApiReqIndex].text = JSON.stringify({ request: `${taskText}\n\n${initialDetails}` })
await this.saveClaudeMessages()
await this.providerRef.deref()?.postStateToWebview()
await this.initiateTaskLoop([
{
type: "text",
text: `${taskText}\n\n${verboseDetails}`, // cannot be sent with system prompt since it's cached and these details can change
text: `${taskText}\n\n${initialDetails}`, // cannot be sent with system prompt since it's cached and these details can change
},
...imageBlocks,
])
@@ -687,8 +660,7 @@ export class ClaudeDev {
: "") +
(newUserContentText
? `\n\nNew instructions for task continuation:\n<user_message>\n${newUserContentText}\n</user_message>\n`
: "") +
`\n\n${await this.getPotentiallyRelevantDetails()}`
: "")
const newUserContentImages = newUserContent.filter((block) => block.type === "image")
const combinedModifiedOldUserContentWithNewUserContent: UserContent = (
@@ -781,21 +753,12 @@ export class ClaudeDev {
async writeToFile(relPath?: string, newContent?: string): Promise<ToolResponse> {
if (relPath === undefined) {
await this.say(
"error",
"Claude tried to use write_to_file without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("write_to_file", "path")
}
if (newContent === undefined) {
await this.say(
"error",
`Claude tried to use write_to_file for '${relPath}' without value for required parameter 'content'. This is likely due to output token limits. Retrying...`
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'content'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("write_to_file", "content", relPath)
}
this.consecutiveMistakeCount = 0
try {
@@ -991,9 +954,9 @@ export class ClaudeDev {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
const editedContent = updatedDocument.getText()
@@ -1070,9 +1033,11 @@ export class ClaudeDev {
diff: this.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent),
} as ClaudeSayTool)
)
return `The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath}. Note this does not mean you need to re-write the file with the user's changes, they have already been applied to the file.`
return this.formatToolResult(
`The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath}. Note this does not mean you need to re-write the file with the user's changes, they have already been applied to the file.`
)
} else {
return `The content was successfully saved to ${relPath}.`
return this.formatToolResult(`The content was successfully saved to ${relPath}.`)
}
} catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
@@ -1080,7 +1045,7 @@ export class ClaudeDev {
"error",
`Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString
return await this.formatToolError(errorString)
}
}
@@ -1152,12 +1117,8 @@ export class ClaudeDev {
async readFile(relPath?: string): Promise<ToolResponse> {
if (relPath === undefined) {
await this.say(
"error",
"Claude tried to use read_file without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("read_file", "path")
}
this.consecutiveMistakeCount = 0
try {
@@ -1176,9 +1137,9 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
@@ -1189,18 +1150,14 @@ export class ClaudeDev {
"error",
`Error reading file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString
return await this.formatToolError(errorString)
}
}
async listFiles(relDirPath?: string, recursiveRaw?: string): Promise<ToolResponse> {
if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use list_files without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("list_files", "path")
}
this.consecutiveMistakeCount = 0
try {
@@ -1221,13 +1178,13 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
return result
return this.formatToolResult(result)
} catch (error) {
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
await this.say(
@@ -1236,7 +1193,7 @@ export class ClaudeDev {
error.message ?? JSON.stringify(serializeError(error), null, 2)
}`
)
return errorString
return await this.formatToolError(errorString)
}
}
@@ -1301,12 +1258,8 @@ export class ClaudeDev {
async listCodeDefinitionNames(relDirPath?: string): Promise<ToolResponse> {
if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use list_code_definition_names without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("list_code_definition_names", "path")
}
this.consecutiveMistakeCount = 0
try {
@@ -1325,13 +1278,13 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
return result
return this.formatToolResult(result)
} catch (error) {
const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}`
await this.say(
@@ -1340,26 +1293,18 @@ export class ClaudeDev {
error.message ?? JSON.stringify(serializeError(error), null, 2)
}`
)
return errorString
return await this.formatToolError(errorString)
}
}
async searchFiles(relDirPath: string, regex: string, filePattern?: string): Promise<ToolResponse> {
if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use search_files without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("search_files", "path")
}
if (regex === undefined) {
await this.say(
"error",
`Claude tried to use search_files without value for required parameter 'regex'. Retrying...`
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'regex'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("search_files", "regex", relDirPath)
}
this.consecutiveMistakeCount = 0
try {
@@ -1381,40 +1326,36 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
return results
return this.formatToolResult(results)
} catch (error) {
const errorString = `Error searching files: ${JSON.stringify(serializeError(error))}`
await this.say(
"error",
`Error searching files:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString
return await this.formatToolError(errorString)
}
}
async executeCommand(command?: string, returnEmptyStringOnSuccess: boolean = false): Promise<ToolResponse> {
if (command === undefined) {
await this.say(
"error",
"Claude tried to use execute_command without value for required parameter 'command'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'command'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("execute_command", "command")
}
this.consecutiveMistakeCount = 0
const { response, text, images } = await this.ask("command", command)
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
try {
@@ -1439,7 +1380,7 @@ export class ClaudeDev {
let result = ""
process.on("line", (line) => {
console.log("sending line from here", line)
console.log("New line from process:", line)
result += line
sendCommandOutput(line)
})
@@ -1455,10 +1396,8 @@ export class ClaudeDev {
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images)
return this.formatIntoToolResponse(
`Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n<feedback>\n${
userFeedback.text
}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
return this.formatToolResponseWithImages(
`Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images
)
}
@@ -1467,39 +1406,31 @@ export class ClaudeDev {
if (returnEmptyStringOnSuccess) {
return ""
}
return `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`
return await this.formatToolResult(`Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`)
} catch (error) {
let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2)
const errorString = `Error executing command:\n${errorMessage}`
await this.say("error", `Error executing command:\n${errorMessage}`)
return errorString
return await this.formatToolError(errorString)
}
}
async askFollowupQuestion(question?: string): Promise<ToolResponse> {
if (question === undefined) {
await this.say(
"error",
"Claude tried to use ask_followup_question without value for required parameter 'question'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'question'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("ask_followup_question", "question")
}
this.consecutiveMistakeCount = 0
const { text, images } = await this.ask("followup", question)
await this.say("user_feedback", text ?? "", images)
return this.formatIntoToolResponse(`<answer>\n${text}\n</answer>`, images)
return this.formatToolResponseWithImages(`<answer>\n${text}\n</answer>`, images)
}
async attemptCompletion(result?: string, command?: string): Promise<ToolResponse> {
// result is required, command is optional
if (result === undefined) {
await this.say(
"error",
"Claude tried to use attempt_completion without value for required parameter 'result'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'result'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("attempt_completion", "result")
}
this.consecutiveMistakeCount = 0
let resultToSend = result
@@ -1518,8 +1449,8 @@ export class ClaudeDev {
return "" // signals to recursive loop to stop (for now this never happens since yesButtonTapped will trigger a new task)
}
await this.say("user_feedback", text ?? "", images)
return this.formatIntoToolResponse(
`The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n<feedback>\n${text}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
return this.formatToolResponseWithImages(
`The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n<feedback>\n${text}\n</feedback>`,
images
)
}
@@ -1600,7 +1531,7 @@ ${this.customInstructions.trim()}
...[
{
type: "text",
text: `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n<feedback>\n${text}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
text: `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n<feedback>\n${text}\n</feedback>`,
} as Anthropic.Messages.TextBlockParam,
...this.formatImagesIntoBlocks(images),
]
@@ -1609,15 +1540,15 @@ ${this.customInstructions.trim()}
this.consecutiveMistakeCount = 0
}
// add potentially relevant details as its own text block, separate from tool results
userContent.push({ type: "text", text: await this.getPotentiallyRelevantDetails() })
await this.addToApiConversationHistory({ role: "user", content: userContent })
if (!this.shouldSkipNextApiReqStartedMessage) {
await this.say(
"api_req_started",
// what the user sees in the webview
JSON.stringify({
request: this.api.createUserReadableRequest(userContent),
})
JSON.stringify({ request: userContent.map(formatContentBlockToMarkdown).join("\n\n") })
)
} else {
this.shouldSkipNextApiReqStartedMessage = false
@@ -1748,9 +1679,50 @@ ${this.customInstructions.trim()}
}
}
// Prompts
// Formatting responses to Claude
async getPotentiallyRelevantDetails(verbose: boolean = false) {
private formatImagesIntoBlocks(images?: string[]): Anthropic.ImageBlockParam[] {
return images
? images.map((dataUrl) => {
// data:image/png;base64,base64string
const [rest, base64] = dataUrl.split(",")
const mimeType = rest.split(":")[1].split(";")[0]
return {
type: "image",
source: { type: "base64", media_type: mimeType, data: base64 },
} as Anthropic.ImageBlockParam
})
: []
}
private formatToolResponseWithImages(text: string, images?: string[]): ToolResponse {
if (images && images.length > 0) {
const textBlock: Anthropic.TextBlockParam = { type: "text", text }
const imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images)
// Placing images after text leads to better results
return [textBlock, ...imageBlocks]
} else {
return text
}
}
async getInitialDetails() {
let details = "<potentially_relevant_details>"
const isDesktop = cwd === path.join(os.homedir(), "Desktop")
const files = await listFiles(cwd, !isDesktop)
const result = this.formatFilesList(cwd, files)
details += `\n# Current Working Directory ('${cwd}') File Structure:${
isDesktop
? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)"
: ""
}\n${result}\n`
details += "</potentially_relevant_details>"
return details
}
async getPotentiallyRelevantDetails() {
let details = `<potentially_relevant_details>
# VSCode Visible Files:
${
@@ -1769,25 +1741,76 @@ ${
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath))
.join("\n") || "(No tabs open)"
}
`
}`
if (verbose) {
const isDesktop = cwd === path.join(os.homedir(), "Desktop")
const files = await listFiles(cwd, !isDesktop)
const result = this.formatFilesList(cwd, files)
details += `\n# Current Working Directory ('${cwd}') File Structure:${
isDesktop
? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)"
: ""
}:\n${result}\n`
const busyTerminals = this.terminalManager.getBusyTerminals()
if (busyTerminals.length > 0) {
details += "\n\n# Active Terminals:"
for (const busyTerminal of busyTerminals) {
details += `\n## Original command:\n${busyTerminal.lastCommand}`
const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id)
if (newOutput) {
details += `\n## New output since last check:\n${newOutput}`
}
}
}
details += "</potentially_relevant_details>"
// Get diagnostics for all open files in the workspace
const diagnostics = vscode.languages.getDiagnostics()
const relevantDiagnostics = diagnostics.filter(([_, fileDiagnostics]) =>
fileDiagnostics.some(
(d) =>
d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning
)
)
if (relevantDiagnostics.length > 0) {
details += "\n\n# Workspace Diagnostics:"
for (const [uri, fileDiagnostics] of relevantDiagnostics) {
const relativePath = path.relative(cwd, uri.fsPath)
details += `\n## ${relativePath}:`
for (const diagnostic of fileDiagnostics) {
if (
diagnostic.severity === vscode.DiagnosticSeverity.Error ||
diagnostic.severity === vscode.DiagnosticSeverity.Warning
) {
let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning"
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
details += `\n- [${severity}] Line ${line}: ${diagnostic.message}`
}
}
}
}
details += "\n</potentially_relevant_details>"
return details
}
async formatGenericToolFeedback(feedback?: string) {
return `The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`
async formatToolDeniedFeedback(feedback?: string) {
return `The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>`
}
async formatToolDenied() {
return `The user denied this operation.`
}
async formatToolResult(result: string) {
return result // the successful result of the tool should never be manipulated, if we need to add details it should be as a separate user text block
}
async formatToolError(error?: string) {
return `The tool execution failed with the following error:\n<error>\n${error}\n</error>`
}
async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) {
await this.say(
"error",
`Claude tried to use ${toolName}${
relPath ? ` for '${relPath}'` : ""
} without value for required parameter '${paramName}'. Retrying...`
)
return await this.formatToolError(
`Missing value for required parameter '${paramName}'. Please retry with complete response.`
)
}
}

View File

@@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../shared/api"
export class AnthropicHandler implements ApiHandler {
@@ -99,24 +99,6 @@ export class AnthropicHandler implements ApiHandler {
}
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: AnthropicModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in anthropicModels) {

View File

@@ -1,6 +1,6 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../shared/api"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
@@ -39,24 +39,6 @@ export class AwsBedrockHandler implements ApiHandler {
return { message }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: BedrockModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in bedrockModels) {

View File

@@ -19,15 +19,6 @@ export interface ApiHandler {
tools: Anthropic.Messages.Tool[]
): Promise<ApiHandlerMessageResponse>
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any
getModel(): { id: string; info: ModelInfo }
}
@@ -50,31 +41,3 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new AnthropicHandler(options)
}
}
export function withoutImageData(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
> {
return userContent.map((part) => {
if (part.type === "image") {
return { ...part, source: { ...part.source, data: "..." } }
} else if (part.type === "tool_result" && typeof part.content !== "string") {
return {
...part,
content: part.content?.map((contentPart) => {
if (contentPart.type === "image") {
return { ...contentPart, source: { ...contentPart.source, data: "..." } }
}
return contentPart
}),
}
}
return part
})
}

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api"
import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format"
@@ -48,23 +48,6 @@ export class OllamaHandler implements ApiHandler {
return { message: anthropicMessage }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.options.ollamaModelId ?? "",
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: "auto",
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.ollamaModelId ?? "",

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api"
import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format"
@@ -48,23 +48,6 @@ export class OpenAiHandler implements ApiHandler {
return { message: anthropicMessage }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.options.openAiModelId ?? "",
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: "auto",
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.openAiModelId ?? "",

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import {
ApiHandlerOptions,
ModelInfo,
@@ -177,24 +177,6 @@ export class OpenRouterHandler implements ApiHandler {
return completion
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: "auto",
}
}
getModel(): { id: OpenRouterModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in openRouterModels) {

View File

@@ -1,6 +1,6 @@
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../shared/api"
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
@@ -33,24 +33,6 @@ export class VertexHandler implements ApiHandler {
return { message }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: VertexModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in vertexModels) {

View File

@@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { EventEmitter } from "events"
import delay from "delay"
import pWaitFor from "p-wait-for"
/*
TerminalManager:
@@ -21,6 +21,14 @@ Enables flexible command execution:
- Continue execution in background
- Retrieve missed output later
Notes:
- it turns out some shellIntegration APIs are available on cursor, although not on older versions of vscode
- "By default, the shell integration script should automatically activate on supported shells launched from VS Code."
Supported shells:
Linux/macOS: bash, fish, pwsh, zsh
Windows: pwsh
Example:
const terminalManager = new TerminalManager(context);
@@ -41,77 +49,75 @@ process.continue();
// Later, if you need to get the unretrieved output:
const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId);
console.log('Unretrieved output:', unretrievedOutput);
Resources:
- https://github.com/microsoft/vscode/issues/226655
- https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api
- https://code.visualstudio.com/docs/terminal/shell-integration
- https://code.visualstudio.com/api/references/vscode-api#Terminal
- https://github.com/microsoft/vscode-extension-samples/blob/main/terminal-sample/src/extension.ts
- https://github.com/microsoft/vscode-extension-samples/blob/main/shell-integration-sample/src/extension.ts
*/
export class TerminalManager {
private terminals: TerminalInfo[] = []
private processes: Map<number, TerminalProcess> = new Map()
private context: vscode.ExtensionContext
private nextTerminalId = 1
constructor(context: vscode.ExtensionContext) {
this.context = context
this.setupListeners()
}
private setupListeners() {
// todo: make sure we do this check everywhere we use the new terminal APIs
if (hasShellIntegrationApis()) {
this.context.subscriptions.push(
vscode.window.onDidOpenTerminal(this.handleOpenTerminal.bind(this)),
vscode.window.onDidCloseTerminal(this.handleClosedTerminal.bind(this)),
vscode.window.onDidChangeTerminalShellIntegration(this.handleShellIntegrationChange.bind(this)),
vscode.window.onDidStartTerminalShellExecution(this.handleShellExecutionStart.bind(this)),
vscode.window.onDidEndTerminalShellExecution(this.handleShellExecutionEnd.bind(this))
)
}
}
runCommand(terminalInfo: TerminalInfo, command: string, cwd: string): TerminalProcessResultPromise {
terminalInfo.busy = true
terminalInfo.lastCommand = command
const process = new TerminalProcess(terminalInfo, command)
const process = new TerminalProcess()
this.processes.set(terminalInfo.id, process)
process.once("completed", () => {
console.log(`completed received for terminal ${terminalInfo.id}`)
terminalInfo.busy = false
})
const promise = new Promise<void>((resolve, reject) => {
process.once(CONTINUE_EVENT, () => {
console.log("2")
process.once("continue", () => {
console.log(`continue received for terminal ${terminalInfo.id}`)
resolve()
})
process.once("error", reject)
process.once("error", (error) => {
console.error(`Error in terminal ${terminalInfo.id}:`, error)
reject(error)
})
})
// if shell integration is already active, run the command immediately
if (terminalInfo.terminal.shellIntegration) {
console.log(`Shell integration active for terminal ${terminalInfo.id}, running command immediately`)
process.waitForShellIntegration = false
process.run()
}
if (hasShellIntegrationApis()) {
// Fallback to sendText if there is no shell integration within 3 seconds of launching (could be because the user is not running one of the supported shells)
setTimeout(() => {
if (!terminalInfo.terminal.shellIntegration) {
process.waitForShellIntegration = false
process.run()
// Without shell integration, we can't know when the command has finished or what the
// exit code was.
}
}, 3000)
process.run(terminalInfo.terminal, command)
} else {
// User doesn't have shell integration API available, run command the old way
process.waitForShellIntegration = false
process.run()
console.log(`Waiting for shell integration for terminal ${terminalInfo.id}`)
// docs recommend waiting 3s for shell integration to activate
pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => {
console.log(
`Shell integration ${
terminalInfo.terminal.shellIntegration ? "activated" : "not activated"
} for terminal ${terminalInfo.id}`
)
const existingProcess = this.processes.get(terminalInfo.id)
if (existingProcess && existingProcess.waitForShellIntegration) {
existingProcess.waitForShellIntegration = false
existingProcess.run(terminalInfo.terminal, command)
}
})
}
// Merge the process and promise
return mergePromise(process, promise)
}
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
const availableTerminal = this.terminals.find((t) => {
if (t.busy) {
// it seems even if you close the terminal, it can still be reused
const isDisposed = !t.terminal || t.terminal.exitStatus // The exit status of the terminal will be undefined while the terminal is active.
console.log(`Terminal ${t.id} isDisposed:`, isDisposed)
if (t.busy || isDisposed) {
return false
}
const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal
@@ -121,7 +127,7 @@ export class TerminalManager {
return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath
})
if (availableTerminal) {
console.log("reusing terminal", availableTerminal.id)
console.log("Reusing terminal", availableTerminal.id)
return availableTerminal
}
@@ -140,63 +146,10 @@ export class TerminalManager {
return newTerminalInfo
}
private handleOpenTerminal(terminal: vscode.Terminal) {
console.log(`Terminal opened: ${terminal.name}`)
}
private handleClosedTerminal(terminal: vscode.Terminal) {
const index = this.terminals.findIndex((t) => t.terminal === terminal)
if (index !== -1) {
const terminalInfo = this.terminals[index]
this.terminals.splice(index, 1)
this.processes.delete(terminalInfo.id)
}
console.log(`Terminal closed: ${terminal.name}`)
}
private handleShellIntegrationChange(e: vscode.TerminalShellIntegrationChangeEvent) {
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
if (terminalInfo) {
const process = this.processes.get(terminalInfo.id)
if (process && process.waitForShellIntegration) {
process.waitForShellIntegration = false
process.run()
}
console.log(`Shell integration activated for terminal: ${e.terminal.name}`)
}
}
private handleShellExecutionStart(e: vscode.TerminalShellExecutionStartEvent) {
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
if (terminalInfo) {
terminalInfo.busy = true
terminalInfo.lastCommand = e.execution.commandLine.value
console.log(`Command started in terminal ${terminalInfo.id}: ${terminalInfo.lastCommand}`)
}
}
private handleShellExecutionEnd(e: vscode.TerminalShellExecutionEndEvent) {
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
if (terminalInfo) {
this.handleCommandCompletion(terminalInfo, e.exitCode)
}
}
private handleCommandCompletion(terminalInfo: TerminalInfo, exitCode?: number | undefined) {
terminalInfo.busy = false
console.log(
`Command "${terminalInfo.lastCommand}" in terminal ${terminalInfo.id} completed with exit code: ${exitCode}`
)
}
getBusyTerminals(): { id: number; lastCommand: string }[] {
return this.terminals.filter((t) => t.busy).map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
}
hasBusyTerminals(): boolean {
return this.terminals.some((t) => t.busy)
}
getUnretrievedOutput(terminalId: number): string {
const process = this.processes.get(terminalId)
if (!process) {
@@ -206,19 +159,14 @@ export class TerminalManager {
}
disposeAll() {
for (const info of this.terminals) {
info.terminal.dispose() // todo do we want to do this? test with tab view closing it
}
// for (const info of this.terminals) {
// //info.terminal.dispose() // dont want to dispose terminals when task is aborted
// }
this.terminals = []
this.processes.clear()
}
}
function hasShellIntegrationApis(): boolean {
const [major, minor] = vscode.version.split(".").map(Number)
return major > 1 || (major === 1 && minor >= 93)
}
interface TerminalInfo {
terminal: vscode.Terminal
busy: boolean
@@ -226,54 +174,59 @@ interface TerminalInfo {
id: number
}
const CONTINUE_EVENT = "CONTINUE_EVENT"
interface TerminalProcessEvents {
line: [line: string]
continue: []
completed: []
error: [error: Error]
}
export class TerminalProcess extends EventEmitter {
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
waitForShellIntegration: boolean = true
private isListening: boolean = true
private buffer: string = ""
private execution?: vscode.TerminalShellExecution
private stream?: AsyncIterable<string>
private fullOutput: string = ""
private lastRetrievedIndex: number = 0
constructor(public terminalInfo: TerminalInfo, private command: string) {
super()
}
// constructor() {
// super()
async run() {
if (this.terminalInfo.terminal.shellIntegration) {
this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command)
this.stream = this.execution.read()
async run(terminal: vscode.Terminal, command: string) {
if (terminal.shellIntegration) {
console.log(`Shell integration available for terminal`)
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors
let isFirstChunk = true // ignore first chunk since it's vscode shell integration marker
for await (const data of this.stream) {
console.log("data", data)
if (!isFirstChunk) {
this.fullOutput += data
if (this.isListening) {
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
} else {
isFirstChunk = false
for await (const data of stream) {
console.log(`Received data chunk for terminal:`, data)
this.fullOutput += data
if (this.isListening) {
console.log(`Emitting data for terminal`)
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
}
// Emit any remaining content in the buffer
if (this.buffer && this.isListening) {
console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
this.emit("line", this.buffer.trim())
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
this.emit(CONTINUE_EVENT)
console.log(`Command execution completed for terminal`)
this.emit("continue")
this.emit("completed")
} else {
this.terminalInfo.terminal.sendText(this.command, true)
console.log(`Shell integration not available for terminal, falling back to sendText`)
terminal.sendText(command, true)
// For terminals without shell integration, we can't know when the command completes
// So we'll just emit the continue event after a delay
setTimeout(() => {
this.emit(CONTINUE_EVENT)
console.log(`Emitting continue after delay for terminal`)
this.emit("continue")
// can't emit completed since we don't if the command actually completed, it could still be running server
}, 2000) // Adjust this delay as needed
}
}
@@ -294,13 +247,17 @@ export class TerminalProcess extends EventEmitter {
}
continue() {
// Emit any remaining content in the buffer
if (this.buffer && this.isListening) {
console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
this.emit("line", this.buffer.trim())
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
this.isListening = false
this.removeAllListeners("line")
this.emit(CONTINUE_EVENT)
}
isStillListening() {
return this.isListening
this.emit("continue")
}
getUnretrievedOutput(): string {

View File

@@ -4,7 +4,7 @@ import os from "os"
import * as path from "path"
import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser"
export const LIST_FILES_LIMIT = 500
export const LIST_FILES_LIMIT = 200
// TODO: implement caching behavior to avoid having to keep analyzing project for new tasks.
export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise<string> {

View File

@@ -42,7 +42,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
}
}
function formatContentBlockToMarkdown(
export function formatContentBlockToMarkdown(
block:
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam