diff --git a/.changeset/small-actors-explode.md b/.changeset/small-actors-explode.md new file mode 100644 index 0000000000000000000000000000000000000000..85a1025b7cd6c7818e6d4ed115a1b27f502c9300 --- /dev/null +++ b/.changeset/small-actors-explode.md @@ -0,0 +1,7 @@ +--- +"llamaindex": patch +"@llamaindex/core-test": patch +"@llamaindex/core-e2e": patch +--- + +fix: anthropic agent on multiple chat diff --git a/packages/core/e2e/node/claude.e2e.ts b/packages/core/e2e/node/claude.e2e.ts index c22a717f2d1feefe7750c615988f8a0ba8b79708..faa96cd735f9f45eda4635eb4ac85100ee1a46b4 100644 --- a/packages/core/e2e/node/claude.e2e.ts +++ b/packages/core/e2e/node/claude.e2e.ts @@ -4,7 +4,7 @@ import { AnthropicAgent } from "llamaindex/agent/anthropic"; import { extractText } from "llamaindex/llm/utils"; import { ok, strictEqual } from "node:assert"; import { beforeEach, test } from "node:test"; -import { sumNumbersTool } from "./fixtures/tools.js"; +import { getWeatherTool, sumNumbersTool } from "./fixtures/tools.js"; import { mockLLMEvent } from "./utils.js"; let llm: LLM; @@ -118,14 +118,58 @@ await test("anthropic agent", async (t) => { }); await t.test("sum numbers", async () => { - const openaiAgent = new AnthropicAgent({ + const anthropicAgent = new AnthropicAgent({ tools: [sumNumbersTool], }); - const { response } = await openaiAgent.chat({ + const { response } = await anthropicAgent.chat({ message: "how much is 1 + 1?", }); ok(extractText(response.message.content).includes("2")); }); }); + +await test("anthropic agent with multiple chat", async (t) => { + await mockLLMEvent(t, "anthropic-agent-multiple-chat"); + await t.test("chat", async () => { + const agent = new AnthropicAgent({ + tools: [getWeatherTool], + }); + { + const { response } = await agent.chat({ + message: 'Hello? Response to me "Yes"', + }); + consola.debug("response:", response.message.content); + ok(extractText(response.message.content).includes("Yes")); + } + { + const { response } = await agent.chat({ + message: 'Hello? Response to me "No"', + }); + consola.debug("response:", response.message.content); + ok(extractText(response.message.content).includes("No")); + } + { + const { response } = await agent.chat({ + message: 'Hello? Response to me "Maybe"', + }); + consola.debug("response:", response.message.content); + ok(extractText(response.message.content).includes("Maybe")); + } + { + const { response } = await agent.chat({ + message: "What is the weather in San Francisco?", + }); + consola.debug("response:", response.message.content); + ok(extractText(response.message.content).includes("72")); + } + { + const { response } = await agent.chat({ + message: "What is the weather in Shanghai?", + }); + consola.debug("response:", response.message.content); + ok(extractText(response.message.content).includes("72")); + } + }); +}); diff --git a/packages/core/e2e/node/snapshot/anthropic-agent-multiple-chat.snap b/packages/core/e2e/node/snapshot/anthropic-agent-multiple-chat.snap new file mode 100644 index 0000000000000000000000000000000000000000..b77cbcbe3ceb44367d9992d6a809a226b874bfaa --- /dev/null +++ b/packages/core/e2e/node/snapshot/anthropic-agent-multiple-chat.snap @@ -0,0 +1,552 @@ +{ + "llmEventStart": [ + { + "id": "PRESERVE_0", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + } + ] + }, + { + "id": "PRESERVE_1", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"No\"" + } + ] + }, + { + "id": "PRESERVE_2", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"No\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"No\".\n</thinking>\n\nNo" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"Maybe\"" + } + ] + }, + { + "id": "PRESERVE_3", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"No\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"No\".\n</thinking>\n\nNo" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"Maybe\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Maybe\".\n</thinking>\n\nMaybe" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "What is the weather in San Francisco?" + } + ] + }, + { + "id": "PRESERVE_4", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"No\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"No\".\n</thinking>\n\nNo" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"Maybe\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Maybe\".\n</thinking>\n\nMaybe" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "What is the weather in San Francisco?" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has asked for the weather in a specific city, San Francisco. The relevant tool to answer this is the getWeather function.\n\nLooking at the required parameters for getWeather:\ncity (string): The user directly provided the city \"San Francisco\"\n\nSince the required \"city\" parameter has been provided, we can proceed with the getWeather function call.\n</thinking>" + } + ], + "role": "assistant", + "options": { + "toolCall": { + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL", + "name": "getWeather", + "input": { + "city": "San Francisco" + } + } + } + }, + { + "content": "The weather in San Francisco is 72 degrees", + "role": "user", + "options": { + "toolResult": { + "result": "The weather in San Francisco is 72 degrees", + "isError": false, + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL" + } + } + } + ] + }, + { + "id": "PRESERVE_5", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"No\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"No\".\n</thinking>\n\nNo" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"Maybe\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Maybe\".\n</thinking>\n\nMaybe" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "What is the weather in San Francisco?" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has asked for the weather in a specific city, San Francisco. The relevant tool to answer this is the getWeather function.\n\nLooking at the required parameters for getWeather:\ncity (string): The user directly provided the city \"San Francisco\"\n\nSince the required \"city\" parameter has been provided, we can proceed with the getWeather function call.\n</thinking>" + } + ], + "role": "assistant", + "options": { + "toolCall": { + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL", + "name": "getWeather", + "input": { + "city": "San Francisco" + } + } + } + }, + { + "content": "The weather in San Francisco is 72 degrees", + "role": "user", + "options": { + "toolResult": { + "result": "The weather in San Francisco is 72 degrees", + "isError": false, + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL" + } + } + }, + { + "content": [ + { + "type": "text", + "text": "The current weather in San Francisco is 72 degrees." + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "What is the weather in Shanghai?" + } + ] + }, + { + "id": "PRESERVE_6", + "messages": [ + { + "role": "user", + "content": "Hello? Response to me \"Yes\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"No\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"No\".\n</thinking>\n\nNo" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "Hello? Response to me \"Maybe\"" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Maybe\".\n</thinking>\n\nMaybe" + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "What is the weather in San Francisco?" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has asked for the weather in a specific city, San Francisco. The relevant tool to answer this is the getWeather function.\n\nLooking at the required parameters for getWeather:\ncity (string): The user directly provided the city \"San Francisco\"\n\nSince the required \"city\" parameter has been provided, we can proceed with the getWeather function call.\n</thinking>" + } + ], + "role": "assistant", + "options": { + "toolCall": { + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL", + "name": "getWeather", + "input": { + "city": "San Francisco" + } + } + } + }, + { + "content": "The weather in San Francisco is 72 degrees", + "role": "user", + "options": { + "toolResult": { + "result": "The weather in San Francisco is 72 degrees", + "isError": false, + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL" + } + } + }, + { + "content": [ + { + "type": "text", + "text": "The current weather in San Francisco is 72 degrees." + } + ], + "role": "assistant", + "options": {} + }, + { + "role": "user", + "content": "What is the weather in Shanghai?" + }, + { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has asked for the weather in a specific city, Shanghai. The relevant tool to answer this is the getWeather function.\n\nLooking at the required parameters for getWeather:\ncity (string): The user directly provided the city \"Shanghai\"\n\nSince the required \"city\" parameter has been provided, we can proceed with the getWeather function call.\n</thinking>" + } + ], + "role": "assistant", + "options": { + "toolCall": { + "id": "toolu_01NHyahSUqrPjxQk9mvCvvGe", + "name": "getWeather", + "input": { + "city": "Shanghai" + } + } + } + }, + { + "content": "The weather in Shanghai is 72 degrees", + "role": "user", + "options": { + "toolResult": { + "result": "The weather in Shanghai is 72 degrees", + "isError": false, + "id": "toolu_01NHyahSUqrPjxQk9mvCvvGe" + } + } + } + ] + } + ], + "llmEventEnd": [ + { + "id": "PRESERVE_0", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Yes\".\n</thinking>\n\nYes" + } + ], + "role": "assistant", + "options": {} + } + } + }, + { + "id": "PRESERVE_1", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"No\".\n</thinking>\n\nNo" + } + ], + "role": "assistant", + "options": {} + } + } + }, + { + "id": "PRESERVE_2", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has not asked a question that requires using any of the available tools. They have simply requested that I respond with the word \"Maybe\".\n</thinking>\n\nMaybe" + } + ], + "role": "assistant", + "options": {} + } + } + }, + { + "id": "PRESERVE_3", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has asked for the weather in a specific city, San Francisco. The relevant tool to answer this is the getWeather function.\n\nLooking at the required parameters for getWeather:\ncity (string): The user directly provided the city \"San Francisco\"\n\nSince the required \"city\" parameter has been provided, we can proceed with the getWeather function call.\n</thinking>" + } + ], + "role": "assistant", + "options": { + "toolCall": { + "id": "toolu_01Gy7Gxbx7uGmjVncGH6pubL", + "name": "getWeather", + "input": { + "city": "San Francisco" + } + } + } + } + } + }, + { + "id": "PRESERVE_4", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "The current weather in San Francisco is 72 degrees." + } + ], + "role": "assistant", + "options": {} + } + } + }, + { + "id": "PRESERVE_5", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "<thinking>\nThe user has asked for the weather in a specific city, Shanghai. The relevant tool to answer this is the getWeather function.\n\nLooking at the required parameters for getWeather:\ncity (string): The user directly provided the city \"Shanghai\"\n\nSince the required \"city\" parameter has been provided, we can proceed with the getWeather function call.\n</thinking>" + } + ], + "role": "assistant", + "options": { + "toolCall": { + "id": "toolu_01NHyahSUqrPjxQk9mvCvvGe", + "name": "getWeather", + "input": { + "city": "Shanghai" + } + } + } + } + } + }, + { + "id": "PRESERVE_6", + "response": { + "raw": null, + "message": { + "content": [ + { + "type": "text", + "text": "The current weather in Shanghai is 72 degrees." + } + ], + "role": "assistant", + "options": {} + } + } + } + ], + "llmEventStream": [] +} \ No newline at end of file diff --git a/packages/core/src/agent/base.ts b/packages/core/src/agent/base.ts index aa0a192cb35f94d0416c5725b10701ecda1cacad..cb2ce97b118bc0c36c539ae1efe874f85b157774 100644 --- a/packages/core/src/agent/base.ts +++ b/packages/core/src/agent/base.ts @@ -348,6 +348,8 @@ export abstract class AgentRunner< >, ) => { for await (const stepOutput of iter) { + // update chat history for each round + this.#chatHistory = [...stepOutput.taskStep.context.store.messages]; if (stepOutput.isLast) { return stepOutput; } @@ -356,7 +358,6 @@ export abstract class AgentRunner< }, ); const { output, taskStep } = stepOutput; - this.#chatHistory = [...taskStep.context.store.messages]; if (isAsyncIterable(output)) { return output.pipeThrough< AgentStreamChatResponse<AdditionalMessageOptions> diff --git a/packages/core/src/llm/anthropic.ts b/packages/core/src/llm/anthropic.ts index e6d4bfa4e85bb0d1b59a0023cfca812cd89797e4..78f5b7461245f6446d87f16749a1bffce949fa09 100644 --- a/packages/core/src/llm/anthropic.ts +++ b/packages/core/src/llm/anthropic.ts @@ -156,58 +156,104 @@ export class Anthropic extends ToolCallLLM<AnthropicAdditionalChatOptions> { formatMessages<Beta = false>( messages: ChatMessage<ToolCallLLMMessageOptions>[], ): Beta extends true ? ToolsBetaMessageParam[] : MessageParam[] { - return messages.map<any>((message) => { - if (message.role !== "user" && message.role !== "assistant") { - throw new Error("Unsupported Anthropic role"); - } - const options = message.options ?? {}; - if ("toolResult" in options) { - const { id, isError } = options.toolResult; - return { - role: "user", - content: [ - { - type: "tool_result", - is_error: isError, - content: [ - { - type: "text", - text: extractText(message.content), - }, - ], - tool_use_id: id, - }, - ] satisfies ToolResultBlockParam[], - } satisfies ToolsBetaMessageParam; - } else if ("toolCall" in options) { - const aiThinkingText = extractText(message.content); - return { - role: "assistant", - content: [ - // this could be empty when you call two tools in one query - ...(aiThinkingText.trim() - ? [ + const result: ToolsBetaMessageParam[] = messages + .filter( + (message) => message.role === "user" || message.role === "assistant", + ) + .map((message) => { + const options = message.options ?? {}; + if ("toolResult" in options) { + const { id, isError } = options.toolResult; + return { + role: "user", + content: [ + { + type: "tool_result", + is_error: isError, + content: [ { type: "text", - text: aiThinkingText, - } satisfies TextBlockParam, - ] - : []), - { - type: "tool_use", - id: options.toolCall.id, - name: options.toolCall.name, - input: options.toolCall.input, - } satisfies ToolUseBlockParam, - ] satisfies ToolsBetaContentBlock[], - } satisfies ToolsBetaMessageParam; + text: extractText(message.content), + }, + ], + tool_use_id: id, + }, + ] satisfies ToolResultBlockParam[], + } satisfies ToolsBetaMessageParam; + } else if ("toolCall" in options) { + const aiThinkingText = extractText(message.content); + return { + role: "assistant", + content: [ + // this could be empty when you call two tools in one query + ...(aiThinkingText.trim() + ? [ + { + type: "text", + text: aiThinkingText, + } satisfies TextBlockParam, + ] + : []), + { + type: "tool_use", + id: options.toolCall.id, + name: options.toolCall.name, + input: options.toolCall.input, + } satisfies ToolUseBlockParam, + ] satisfies ToolsBetaContentBlock[], + } satisfies ToolsBetaMessageParam; + } + + return { + content: extractText(message.content), + role: message.role as "user" | "assistant", + } satisfies MessageParam; + }); + // merge messages with the same role + // in case of 'messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row' + const realResult: ToolsBetaMessageParam[] = []; + for (let i = 0; i < result.length; i++) { + if (i === 0) { + realResult.push(result[i]); + continue; + } + const current = result[i]; + const previous = result[i - 1]; + if (current.role === previous.role) { + // merge two messages with the same role + if (Array.isArray(previous.content)) { + if (Array.isArray(current.content)) { + previous.content.push(...current.content); + } else { + previous.content.push({ + type: "text", + text: current.content, + }); + } + } else { + if (Array.isArray(current.content)) { + previous.content = [ + { + type: "text", + text: previous.content, + }, + ...current.content, + ]; + } else { + previous.content += `\n${current.content}`; + } + } + // no need to push the message + } + // if the roles are different, just push the message + else { + realResult.push(current); } + } - return { - content: extractText(message.content), - role: message.role, - } satisfies MessageParam; - }); + return realResult as Beta extends true + ? ToolsBetaMessageParam[] + : MessageParam[]; } chat( diff --git a/packages/core/tests/llm/index.test.ts b/packages/core/tests/llm/index.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbde9f13caef427d947284dc8e2c262f2e09d94e --- /dev/null +++ b/packages/core/tests/llm/index.test.ts @@ -0,0 +1,66 @@ +import { setEnvs } from "@llamaindex/env"; +import { Anthropic } from "llamaindex"; +import { beforeAll, describe, expect, test } from "vitest"; + +beforeAll(() => { + setEnvs({ + ANTHROPIC_API_KEY: "valid", + }); +}); + +describe("Anthropic llm", () => { + test("format messages", () => { + const anthropic = new Anthropic(); + expect( + anthropic.formatMessages([ + { + content: "You are a helpful assistant.", + role: "assistant", + }, + { + content: "Hello?", + role: "user", + }, + ]), + ).toEqual([ + { + content: "You are a helpful assistant.", + role: "assistant", + }, + { + content: "Hello?", + role: "user", + }, + ]); + + expect( + anthropic.formatMessages([ + { + content: "You are a helpful assistant.", + role: "assistant", + }, + { + content: "Hello?", + role: "user", + }, + { + content: "I am a system message.", + role: "system", + }, + { + content: "What is your name?", + role: "user", + }, + ]), + ).toEqual([ + { + content: "You are a helpful assistant.", + role: "assistant", + }, + { + content: "Hello?\nWhat is your name?", + role: "user", + }, + ]); + }); +});