Browse Source

Save latest assistant response before tool use; fix task resumption conversation reconstruction

Saoud Rizwan 1 year ago
parent
commit
5544d81bc5
1 changed files with 40 additions and 41 deletions
  1. 40 41
      src/ClaudeDev.ts

+ 40 - 41
src/ClaudeDev.ts

@@ -507,16 +507,12 @@ export class ClaudeDev {
 		}
 		}
 
 
 		const { response, text, images } = await this.ask(askType) // calls poststatetowebview
 		const { response, text, images } = await this.ask(askType) // calls poststatetowebview
-
-		let newUserContent: UserContent = []
+		let responseText: string | undefined
+		let responseImages: string[] | undefined
 		if (response === "messageResponse") {
 		if (response === "messageResponse") {
 			await this.say("user_feedback", text, images)
 			await this.say("user_feedback", text, images)
-			if (images && images.length > 0) {
-				newUserContent.push(...this.formatImagesIntoBlocks(images))
-			}
-			if (text) {
-				newUserContent.push({ type: "text", text })
-			}
+			responseText = text
+			responseImages = images
 		}
 		}
 
 
 		// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with claude messages
 		// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with claude messages
@@ -529,8 +525,8 @@ export class ClaudeDev {
 		const existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
 		const existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
 			await this.getSavedApiConversationHistory()
 			await this.getSavedApiConversationHistory()
 
 
-		let modifiedOldUserContent: UserContent
-		let modifiedApiConversationHistory: Anthropic.Messages.MessageParam[]
+		let modifiedOldUserContent: UserContent // either the last message if its user message, or the user message before the last (assistant) message
+		let modifiedApiConversationHistory: Anthropic.Messages.MessageParam[] // need to remove the last user message to replace with new modified user message
 		if (existingApiConversationHistory.length > 0) {
 		if (existingApiConversationHistory.length > 0) {
 			const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
 			const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
 
 
@@ -556,7 +552,7 @@ export class ClaudeDev {
 					modifiedOldUserContent = []
 					modifiedOldUserContent = []
 				}
 				}
 			} else if (lastMessage.role === "user") {
 			} else if (lastMessage.role === "user") {
-				const previousAssistantMessage =
+				const previousAssistantMessage: Anthropic.Messages.MessageParam | undefined =
 					existingApiConversationHistory[existingApiConversationHistory.length - 2]
 					existingApiConversationHistory[existingApiConversationHistory.length - 2]
 
 
 				const existingUserContent: UserContent = Array.isArray(lastMessage.content)
 				const existingUserContent: UserContent = Array.isArray(lastMessage.content)
@@ -586,7 +582,7 @@ export class ClaudeDev {
 								content: "Task was interrupted before this tool call could be completed.",
 								content: "Task was interrupted before this tool call could be completed.",
 							}))
 							}))
 
 
-						modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
+						modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message
 						modifiedOldUserContent = [...existingUserContent, ...missingToolResponses]
 						modifiedOldUserContent = [...existingUserContent, ...missingToolResponses]
 					} else {
 					} else {
 						modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
 						modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
@@ -603,10 +599,8 @@ export class ClaudeDev {
 			throw new Error("Unexpected: No existing API conversation history")
 			throw new Error("Unexpected: No existing API conversation history")
 		}
 		}
 
 
-		// now we have newUserContent which is user's current message, and the modifiedOldUserContent which is the old message with tool responses filled in
-		// we need to combine them while ensuring there is only one text block
-		const modifiedOldUserContentText = modifiedOldUserContent.find((block) => block.type === "text")?.text
-		const newUserContentText = newUserContent.find((block) => block.type === "text")?.text
+		let newUserContent: UserContent = [...modifiedOldUserContent]
+
 		const agoText = (() => {
 		const agoText = (() => {
 			const timestamp = lastClaudeMessage?.ts ?? Date.now()
 			const timestamp = lastClaudeMessage?.ts ?? Date.now()
 			const now = Date.now()
 			const now = Date.now()
@@ -627,22 +621,21 @@ export class ClaudeDev {
 			return "just now"
 			return "just now"
 		})()
 		})()
 
 
-		const combinedText =
-			`Task resumption: This autonomous coding task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now ${cwd}. If the task has not been completed, retry the last step before interruption and proceed with completing the task.` +
-			(modifiedOldUserContentText
-				? `\n\nLast recorded user input before interruption:\n<previous_message>\n${modifiedOldUserContentText}\n</previous_message>`
-				: "") +
-			(newUserContentText
-				? `\n\nNew instructions for task continuation:\n<user_message>\n${newUserContentText}\n</user_message>`
-				: "")
+		newUserContent.push({
+			type: "text",
+			text:
+				`Task resumption: This autonomous coding task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.` +
+				(responseText
+					? `\n\nNew instructions for task continuation:\n<user_message>\n${responseText}\n</user_message>`
+					: ""),
+		})
 
 
-		const newUserContentImages = newUserContent.filter((block) => block.type === "image")
-		const combinedModifiedOldUserContentWithNewUserContent: UserContent = (
-			modifiedOldUserContent.filter((block) => block.type !== "text") as UserContent
-		).concat([{ type: "text", text: combinedText }, ...newUserContentImages])
+		if (responseImages && responseImages.length > 0) {
+			newUserContent.push(...this.formatImagesIntoBlocks(responseImages))
+		}
 
 
 		await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
 		await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
-		await this.initiateTaskLoop(combinedModifiedOldUserContentWithNewUserContent)
+		await this.initiateTaskLoop(newUserContent)
 	}
 	}
 
 
 	private async initiateTaskLoop(userContent: UserContent): Promise<void> {
 	private async initiateTaskLoop(userContent: UserContent): Promise<void> {
@@ -1649,17 +1642,34 @@ ${this.customInstructions.trim()}
 
 
 			// A response always returns text content blocks (it's just that before we were iterating over the completion_attempt response before we could append text response, resulting in bug)
 			// A response always returns text content blocks (it's just that before we were iterating over the completion_attempt response before we could append text response, resulting in bug)
 			for (const contentBlock of response.content) {
 			for (const contentBlock of response.content) {
+				// type can only be text or tool_use
 				if (contentBlock.type === "text") {
 				if (contentBlock.type === "text") {
 					assistantResponses.push(contentBlock)
 					assistantResponses.push(contentBlock)
 					await this.say("text", contentBlock.text)
 					await this.say("text", contentBlock.text)
+				} else if (contentBlock.type === "tool_use") {
+					assistantResponses.push(contentBlock)
 				}
 				}
 			}
 			}
 
 
+			// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
+			if (assistantResponses.length > 0) {
+				await this.addToApiConversationHistory({ role: "assistant", content: assistantResponses })
+			} else {
+				// this should never happen! it there's no assistant_responses, that means we got no text or tool_use content blocks from API which we should assume is an error
+				await this.say(
+					"error",
+					"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output."
+				)
+				await this.addToApiConversationHistory({
+					role: "assistant",
+					content: [{ type: "text", text: "Failure: I did not provide a response." }],
+				})
+			}
+
 			let toolResults: Anthropic.ToolResultBlockParam[] = []
 			let toolResults: Anthropic.ToolResultBlockParam[] = []
 			let attemptCompletionBlock: Anthropic.Messages.ToolUseBlock | undefined
 			let attemptCompletionBlock: Anthropic.Messages.ToolUseBlock | undefined
 			for (const contentBlock of response.content) {
 			for (const contentBlock of response.content) {
 				if (contentBlock.type === "tool_use") {
 				if (contentBlock.type === "tool_use") {
-					assistantResponses.push(contentBlock)
 					const toolName = contentBlock.name as ToolName
 					const toolName = contentBlock.name as ToolName
 					const toolInput = contentBlock.input
 					const toolInput = contentBlock.input
 					const toolUseId = contentBlock.id
 					const toolUseId = contentBlock.id
@@ -1677,17 +1687,6 @@ ${this.customInstructions.trim()}
 				}
 				}
 			}
 			}
 
 
-			if (assistantResponses.length > 0) {
-				await this.addToApiConversationHistory({ role: "assistant", content: assistantResponses })
-			} else {
-				// this should never happen! it there's no assistant_responses, that means we got no text or tool_use content blocks from API which we should assume is an error
-				await this.say("error", "Unexpected Error: No assistant messages were found in the API response")
-				await this.addToApiConversationHistory({
-					role: "assistant",
-					content: [{ type: "text", text: "Failure: I did not have a response to provide." }],
-				})
-			}
-
 			let didEndLoop = false
 			let didEndLoop = false
 
 
 			// attempt_completion is always done last, since there might have been other tools that needed to be called first before the job is finished
 			// attempt_completion is always done last, since there might have been other tools that needed to be called first before the job is finished