mirror of
https://github.com/run-llama/LlamaIndexTS.git
synced 2026-07-16 07:14:29 -04:00
feat: new memory api (#2028)
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@llamaindex/core": patch
|
||||
---
|
||||
|
||||
Add new memory API
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Memory
|
||||
description: Manage conversation history and context with intelligent memory blocks
|
||||
---
|
||||
|
||||
Memory provides intelligent conversation history management with automatic context window optimization for LLMs and long-term memory storage through configurable memory blocks.
|
||||
|
||||
## Overview
|
||||
|
||||
The Memory class handles:
|
||||
|
||||
- **Message Storage**: Store and retrieve conversation messages with different adapters
|
||||
- **Context Management**: Automatically fit messages within LLM context windows. Balance short-term and long-term memory usage within token limits.
|
||||
- **Memory Blocks**: Include predefined contextual information or process messages into long-term memory blocks for summarization or retrieval
|
||||
- **Snapshots**: Save and restore memory state for persistence
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```ts twoslash
|
||||
import { openai } from "@llamaindex/openai";
|
||||
import { agent } from "@llamaindex/workflow";
|
||||
import { createMemory, staticBlock } from "llamaindex";
|
||||
|
||||
const llm = openai({ model: "gpt-4o-mini" });
|
||||
|
||||
// Create memory with predefined context
|
||||
const memory = createMemory({
|
||||
memoryBlocks: [
|
||||
staticBlock({
|
||||
content:
|
||||
"The user is a software engineer who loves TypeScript and LlamaIndex.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Create an agent with the memory
|
||||
const workflow = agent({
|
||||
name: "assistant",
|
||||
llm,
|
||||
memory,
|
||||
});
|
||||
|
||||
const result1 = await workflow.run("Hi, my name is John. Do you know me?");
|
||||
console.log("Response:", result1.data.result);
|
||||
|
||||
const result2 = await workflow.run("What is my name?");
|
||||
console.log("Response:", result2.data.result);
|
||||
|
||||
// You can also manually get messages with transient messages:
|
||||
const messages = await memory.getLLM(llm, [
|
||||
{
|
||||
role: "user",
|
||||
content: "What is my name?", // This message will be included in the result and won't be stored in the memory
|
||||
},
|
||||
]);
|
||||
|
||||
// You can also put messages in Vercel format directly to the memory
|
||||
await memory.add({
|
||||
id: "1",
|
||||
createdAt: new Date(),
|
||||
role: "user",
|
||||
content: "Hello!",
|
||||
options: {
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
data: "base64...",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// and get it back in Vercel format
|
||||
const messages = await memory.get({ type: "vercel" });
|
||||
console.log(messages);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Configure memory behavior with `MemoryOptions`:
|
||||
|
||||
- `tokenLimit`: Maximum tokens for memory retrieval.
|
||||
- `shortTermTokenLimitRatio`: Ratio of tokens for short-term vs long-term memory (default: 0.5)
|
||||
- `customAdapters`: Custom message adapters for different message formats. LlamaIndex (ChatMessageAdapter) and Vercel (VercelMessageAdapter) are built-in adapters.
|
||||
- `memoryBlocks`: Memory blocks for long-term storage
|
||||
|
||||
## Memory Blocks
|
||||
|
||||
Memory blocks hold contextual information that always included in the memory or long-term information that enriches the context of the conversation that can be included in priority order within token limits. The order of messages retrieved from getLLM() method are:
|
||||
|
||||
1. StaticMemoryBlock (always included)
|
||||
2. LongTermMemoryBlock
|
||||
3. ShortTermMemoryBlock
|
||||
4. Transient messages
|
||||
|
||||
We provided some built-in memory blocks for you:
|
||||
|
||||
- [Static Memory Block](/docs/api/classes/StaticMemoryBlock): Keeps track of static, non-changing information
|
||||
- [Fact Extraction Memory Block](/docs/api/classes/FactExtractionMemoryBlock) (long-term): Populates a list of facts extracted from the conversation when the messages are outside of Memory token limits. Check out [this example](https://github.com/run-llama/LlamaIndexTS/tree/main/examples/agents/memory/fact-extraction.ts) for the usage of the Fact Extraction Memory Block.
|
||||
|
||||
## Persistence with Snapshots
|
||||
|
||||
Save and restore memory state:
|
||||
|
||||
```ts twoslash
|
||||
import { createMemory, loadMemory } from "llamaindex";
|
||||
|
||||
const memory = createMemory();
|
||||
|
||||
// Add some messages
|
||||
await memory.add({ role: "user", content: "Hello!" });
|
||||
|
||||
// Create snapshot
|
||||
const snapshot = memory.snapshot();
|
||||
|
||||
// Later, restore from the snapshot
|
||||
const restoredMemory = loadMemory(snapshot);
|
||||
```
|
||||
|
||||
Want to learn more about the Memory class? Check out our example codes in [Github](https://github.com/run-llama/LlamaIndexTS/tree/main/examples/agents/memory).
|
||||
@@ -0,0 +1,36 @@
|
||||
import { openai } from "@llamaindex/openai";
|
||||
import { agent } from "@llamaindex/workflow";
|
||||
import { createMemory, staticBlock } from "llamaindex";
|
||||
|
||||
// Simple example: Agent with Predefined Memory
|
||||
async function simpleAgentMemoryExample() {
|
||||
console.log("=== Simple Agent Memory Example ===");
|
||||
|
||||
const memory = createMemory({
|
||||
memoryBlocks: [
|
||||
staticBlock({
|
||||
content:
|
||||
"The user is a software engineer who loves TypeScript and LlamaIndex.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Create agent workflow
|
||||
const workflow = agent({
|
||||
name: "assistant",
|
||||
llm: openai({ model: "gpt-4.1-nano" }),
|
||||
memory,
|
||||
});
|
||||
|
||||
// Test - agent should remember John and the shopping cart context
|
||||
console.log("\n--- Testing Memory Context ---");
|
||||
const result = await workflow.run("Hi, my name is John. Do you know me?");
|
||||
|
||||
console.log("Assistant Response:", result.data.result);
|
||||
|
||||
const result2 = await workflow.run("What is my name?");
|
||||
console.log("Assistant Response:", result2.data.result);
|
||||
}
|
||||
|
||||
// Run the example
|
||||
simpleAgentMemoryExample().catch(console.error);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { openai } from "@llamaindex/openai";
|
||||
import { createMemory } from "llamaindex";
|
||||
|
||||
// Example: Basic Memory Usage with Factory
|
||||
async function basicMemoryExample() {
|
||||
console.log("\n=== Example: Basic Memory Usage with Factory ===");
|
||||
|
||||
const memory = createMemory({ tokenLimit: 30 });
|
||||
|
||||
// Add messages to memory
|
||||
await memory.add({
|
||||
role: "user",
|
||||
content: "Hi, my name is John and I'm a software engineer.",
|
||||
});
|
||||
|
||||
await memory.add({
|
||||
role: "assistant",
|
||||
content: "Hello John! Nice to meet you. How can I help you today?",
|
||||
});
|
||||
|
||||
await memory.add({
|
||||
role: "user",
|
||||
content: "I love working with TypeScript and React.",
|
||||
});
|
||||
// Not all messages are included because of token limit is set to 30
|
||||
const llmMessages = await memory.getLLM();
|
||||
console.log(
|
||||
`\nLLM messages (${llmMessages.length} messages) limited by a small token limit:`,
|
||||
);
|
||||
llmMessages.forEach((msg, idx) => {
|
||||
console.log(`${idx + 1}. ${msg.role}: ${msg.content}`);
|
||||
});
|
||||
|
||||
// But the token limit above will be the window size of an LLM instance if you use getLLM with LLM
|
||||
const llm = openai({ model: "gpt-4.1-mini" });
|
||||
const llmMessagesWithLLM = await memory.getLLM(llm);
|
||||
// Now all the messages are included because of the LLM window size of the model is much larger
|
||||
console.log(
|
||||
`\nLLM messages with LLM (${llmMessagesWithLLM.length} messages) limited by LLM window size:`,
|
||||
);
|
||||
llmMessagesWithLLM.forEach((msg, idx) => {
|
||||
console.log(`${idx + 1}. ${msg.role}: ${msg.content}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
console.log("🧠 Basic Memory Factory Examples");
|
||||
console.log("===============================");
|
||||
|
||||
try {
|
||||
await basicMemoryExample();
|
||||
} catch (error) {
|
||||
console.error("Error running basic memory examples:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { openai } from "@llamaindex/openai";
|
||||
import { createMemory, factExtractionBlock } from "llamaindex";
|
||||
|
||||
// Configure OpenAI
|
||||
const llm = openai({ model: "gpt-4.1-mini" });
|
||||
|
||||
// Example: Memory with Fact Extraction
|
||||
async function factExtractionMemoryExample() {
|
||||
console.log("\n=== Memory with Fact Extraction ===");
|
||||
|
||||
// Create memory with a fact extraction
|
||||
const memory = createMemory([], {
|
||||
tokenLimit: 100,
|
||||
shortTermTokenLimitRatio: 0.7, // 70% for short-term, 30% for long-term
|
||||
memoryBlocks: [
|
||||
factExtractionBlock({
|
||||
id: "user-facts",
|
||||
priority: 5,
|
||||
llm: llm,
|
||||
maxFacts: 10,
|
||||
isLongTerm: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Simulate a conversation with facts
|
||||
const conversationTurns = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi, I'm Sarah and I work as a data scientist at Google.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Hello Sarah! It's great to meet you. Data science at Google must be exciting!",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Yes, I specialize in machine learning and natural language processing.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "That's impressive! ML and NLP are fascinating fields.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"I have a PhD in Computer Science from Stanford, and I love hiking on weekends.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Wow, Stanford PhD! And hiking is a great way to unwind from tech work.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "I also have two cats named Whiskers and Mittens.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Cats make wonderful companions! Whiskers and Mittens are cute names.",
|
||||
},
|
||||
];
|
||||
|
||||
// Add conversation turns to memory
|
||||
console.log("Adding conversation to memory...");
|
||||
for (const turn of conversationTurns) {
|
||||
await memory.add(turn);
|
||||
}
|
||||
|
||||
// Get messages - facts should be extracted and included
|
||||
const messages = await memory.getLLM(llm);
|
||||
console.log("\nMessages with extracted facts:");
|
||||
messages.forEach((msg, idx) => {
|
||||
console.log(`${idx + 1}. ${msg.role ?? "unknown"}: ${msg.content}`);
|
||||
});
|
||||
//Messages with extracted facts:
|
||||
// 1. assistant: Cats make wonderful companions! Whiskers and Mittens are cute names.
|
||||
// 2. user: I also have two cats named Whiskers and Mittens.
|
||||
// 3. assistant: Wow, Stanford PhD! And hiking is a great way to unwind from tech work.
|
||||
// 4. memory: Sarah works as a data scientist at Google
|
||||
// Sarah specializes in machine learning and natural language processing
|
||||
// Sarah has a PhD in Computer Science from Stanford
|
||||
// Sarah enjoys hiking on weekends
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
console.log("🧠 Fact Extraction Memory Example");
|
||||
console.log("=================================");
|
||||
|
||||
try {
|
||||
await factExtractionMemoryExample();
|
||||
} catch (error) {
|
||||
console.error("Error running fact extraction memory example:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { openai } from "@llamaindex/openai";
|
||||
import { createMemory, staticBlock } from "llamaindex";
|
||||
|
||||
// Configure OpenAI
|
||||
const llm = openai({ model: "gpt-4.1-mini" });
|
||||
|
||||
// Example: Memory with Static Blocks
|
||||
async function staticMemoryBlockExample() {
|
||||
console.log("\n=== Memory with Static Blocks ===");
|
||||
console.log("- Memory always include static block");
|
||||
console.log("- Memory cut off the messages within token limit\n");
|
||||
|
||||
// Create memory with a static block
|
||||
const memory = createMemory([], {
|
||||
tokenLimit: 30, // A small token limit which is not enough for the whole conversation below
|
||||
memoryBlocks: [
|
||||
staticBlock({
|
||||
content:
|
||||
"The user's name is John and he is a software engineer who loves TypeScript and LlamaIndex.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Add some messages to the memory
|
||||
await memory.add({
|
||||
role: "user",
|
||||
content: "What do you know about me?",
|
||||
});
|
||||
|
||||
await memory.add({
|
||||
role: "assistant",
|
||||
content:
|
||||
"Based on our conversation, I know you're John, a software engineer who enjoys working with TypeScript and LlamaIndex!",
|
||||
});
|
||||
|
||||
await memory.add({
|
||||
role: "user",
|
||||
content: "Which language does LlamaIndex support?",
|
||||
});
|
||||
|
||||
// Get messages
|
||||
// static block will always be included
|
||||
// only the last message will be included because of token limit set above
|
||||
const messages = await memory.getLLM(llm);
|
||||
messages.forEach((msg, idx) => {
|
||||
console.log(`${idx + 1}. ${msg.role}: ${msg.content}`);
|
||||
});
|
||||
// Messages with static block:
|
||||
// 1. user: The user's name is John and he is a software engineer who loves TypeScript and LlamaIndex.
|
||||
// 2. user: Which language does LlamaIndex support?
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
try {
|
||||
await staticMemoryBlockExample();
|
||||
} catch (error) {
|
||||
console.error("Error running static memory blocks example:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Anthropic } from "@llamaindex/anthropic";
|
||||
import { ChatMemoryBuffer, SimpleChatEngine } from "llamaindex";
|
||||
import { createMemory, SimpleChatEngine } from "llamaindex";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
@@ -9,14 +9,12 @@ import readline from "node:readline/promises";
|
||||
model: "claude-3-7-sonnet",
|
||||
});
|
||||
// chatHistory will store all the messages in the conversation
|
||||
const chatHistory = new ChatMemoryBuffer({
|
||||
chatHistory: [
|
||||
{
|
||||
content: "You want to talk in rhymes.",
|
||||
role: "system",
|
||||
},
|
||||
],
|
||||
});
|
||||
const chatHistory = createMemory([
|
||||
{
|
||||
content: "You want to talk in rhymes.",
|
||||
role: "system",
|
||||
},
|
||||
]);
|
||||
const chatEngine = new SimpleChatEngine({
|
||||
llm,
|
||||
memory: chatHistory,
|
||||
|
||||
@@ -2,11 +2,7 @@ import { stdin as input, stdout as output } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
import { OpenAI } from "@llamaindex/openai";
|
||||
import {
|
||||
ChatSummaryMemoryBuffer,
|
||||
Settings,
|
||||
SimpleChatEngine,
|
||||
} from "llamaindex";
|
||||
import { createMemory, Settings, SimpleChatEngine } from "llamaindex";
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
Settings.callbackManager.on("llm-end", (event) => {
|
||||
@@ -15,10 +11,13 @@ if (process.env.NODE_ENV === "development") {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Set maxTokens to 75% of the context window size of 4096
|
||||
// This will trigger the summarizer once the chat history reaches 25% of the context window size (1024 tokens)
|
||||
const llm = new OpenAI({ model: "gpt-3.5-turbo", maxTokens: 4096 * 0.75 });
|
||||
const chatHistory = new ChatSummaryMemoryBuffer({ llm });
|
||||
const llm = new OpenAI({ model: "gpt-3.5-turbo" });
|
||||
const chatHistory = createMemory([
|
||||
{
|
||||
content: "You are a helpful assistant.",
|
||||
role: "system",
|
||||
},
|
||||
]);
|
||||
const chatEngine = new SimpleChatEngine({ llm });
|
||||
const rl = readline.createInterface({ input, output });
|
||||
|
||||
@@ -29,10 +28,6 @@ async function main() {
|
||||
chatHistory,
|
||||
stream: true,
|
||||
});
|
||||
if (chatHistory.getLastSummary()) {
|
||||
// Print the summary of the conversation so far that is produced by the SummaryChatHistory
|
||||
console.log(`Summary: ${chatHistory.getLastSummary()?.content}`);
|
||||
}
|
||||
for await (const chunk of stream) {
|
||||
process.stdout.write(chunk.response);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export type AgentParamsBase<
|
||||
|
||||
/**
|
||||
* Worker will schedule tasks and handle the task execution
|
||||
* @deprecated Use agent instead.
|
||||
*/
|
||||
export abstract class AgentWorker<
|
||||
AI extends LLM,
|
||||
@@ -250,6 +251,7 @@ export abstract class AgentWorker<
|
||||
|
||||
/**
|
||||
* Runner will manage the task execution and provide a high-level API for the user
|
||||
* @deprecated Use agent instead.
|
||||
*/
|
||||
export abstract class AgentRunner<
|
||||
AI extends LLM,
|
||||
|
||||
@@ -62,6 +62,9 @@ export class LLMAgentWorker extends AgentWorker<LLM> {
|
||||
taskHandler = AgentRunner.defaultTaskHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use agent instead.
|
||||
*/
|
||||
export class LLMAgent extends AgentRunner<LLM> {
|
||||
constructor(params: LLMAgentParams<LLM>) {
|
||||
validateAgentParams(params);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage, MessageContent } from "../llms";
|
||||
import type { BaseMemory } from "../memory";
|
||||
import type { Memory } from "../memory";
|
||||
import { EngineResponse } from "../schema";
|
||||
|
||||
export interface BaseChatEngineParams<
|
||||
@@ -9,9 +9,7 @@ export interface BaseChatEngineParams<
|
||||
/**
|
||||
* Optional chat history if you want to customize the chat history.
|
||||
*/
|
||||
chatHistory?:
|
||||
| ChatMessage<AdditionalMessageOptions>[]
|
||||
| BaseMemory<AdditionalMessageOptions>;
|
||||
chatHistory?: ChatMessage<AdditionalMessageOptions>[] | Memory;
|
||||
}
|
||||
|
||||
export interface StreamingChatEngineParams<
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { wrapEventCaller } from "../decorator";
|
||||
import { Settings } from "../global";
|
||||
import type { ChatMessage, LLM, MessageContent, MessageType } from "../llms";
|
||||
import { BaseMemory, ChatMemoryBuffer } from "../memory";
|
||||
import { Memory, createMemory } from "../memory";
|
||||
import type { BaseNodePostprocessor } from "../postprocessor";
|
||||
import {
|
||||
type ContextSystemPrompt,
|
||||
@@ -23,7 +23,7 @@ import type { ContextGenerator } from "./type";
|
||||
export type ContextChatEngineOptions = {
|
||||
retriever: BaseRetriever;
|
||||
chatModel?: LLM | undefined;
|
||||
chatHistory?: ChatMessage[] | undefined;
|
||||
chatHistory?: ChatMessage[] | Memory | undefined;
|
||||
contextSystemPrompt?: ContextSystemPrompt | undefined;
|
||||
nodePostprocessors?: BaseNodePostprocessor[] | undefined;
|
||||
systemPrompt?: string | undefined;
|
||||
@@ -37,18 +37,21 @@ export type ContextChatEngineOptions = {
|
||||
*/
|
||||
export class ContextChatEngine extends PromptMixin implements BaseChatEngine {
|
||||
chatModel: LLM;
|
||||
memory: BaseMemory;
|
||||
memory: Memory;
|
||||
contextGenerator: ContextGenerator & PromptMixin;
|
||||
systemPrompt?: string | undefined;
|
||||
|
||||
get chatHistory() {
|
||||
return this.memory.getMessages();
|
||||
return this.memory.getLLM();
|
||||
}
|
||||
|
||||
constructor(init: ContextChatEngineOptions) {
|
||||
super();
|
||||
this.chatModel = init.chatModel ?? Settings.llm;
|
||||
this.memory = new ChatMemoryBuffer({ chatHistory: init?.chatHistory });
|
||||
this.memory =
|
||||
init?.chatHistory instanceof Memory
|
||||
? init.chatHistory
|
||||
: createMemory(init?.chatHistory ?? []);
|
||||
this.contextGenerator = new DefaultContextGenerator({
|
||||
retriever: init.retriever,
|
||||
contextSystemPrompt: init?.contextSystemPrompt,
|
||||
@@ -87,12 +90,9 @@ export class ContextChatEngine extends PromptMixin implements BaseChatEngine {
|
||||
): Promise<EngineResponse | AsyncIterable<EngineResponse>> {
|
||||
const { message, stream } = params;
|
||||
const chatHistory = params.chatHistory
|
||||
? new ChatMemoryBuffer({
|
||||
chatHistory:
|
||||
params.chatHistory instanceof BaseMemory
|
||||
? await params.chatHistory.getMessages()
|
||||
: params.chatHistory,
|
||||
})
|
||||
? params.chatHistory instanceof Memory
|
||||
? params.chatHistory
|
||||
: createMemory(params.chatHistory)
|
||||
: this.memory;
|
||||
const requestMessages = await this.prepareRequestMessages(
|
||||
message,
|
||||
@@ -110,7 +110,7 @@ export class ContextChatEngine extends PromptMixin implements BaseChatEngine {
|
||||
initialValue: "",
|
||||
reducer: (accumulator, part) => (accumulator += part.delta),
|
||||
finished: (accumulator) => {
|
||||
chatHistory.put({ content: accumulator, role: "assistant" });
|
||||
void chatHistory.add({ content: accumulator, role: "assistant" });
|
||||
},
|
||||
}),
|
||||
(r) => EngineResponse.fromChatResponseChunk(r, requestMessages.nodes),
|
||||
@@ -120,26 +120,26 @@ export class ContextChatEngine extends PromptMixin implements BaseChatEngine {
|
||||
messages: requestMessages.messages,
|
||||
additionalChatOptions: params.chatOptions as object,
|
||||
});
|
||||
chatHistory.put(response.message);
|
||||
await chatHistory.add(response.message);
|
||||
return EngineResponse.fromChatResponse(response, requestMessages.nodes);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.memory.reset();
|
||||
async reset() {
|
||||
await this.memory.clear();
|
||||
}
|
||||
|
||||
private async prepareRequestMessages(
|
||||
message: MessageContent,
|
||||
chatHistory: BaseMemory,
|
||||
chatHistory: Memory,
|
||||
) {
|
||||
chatHistory.put({
|
||||
await chatHistory.add({
|
||||
content: message,
|
||||
role: "user",
|
||||
});
|
||||
const textOnly = extractText(message);
|
||||
const context = await this.contextGenerator.generate(textOnly);
|
||||
const systemMessage = this.prependSystemPrompt(context.message);
|
||||
const messages = await chatHistory.getMessages([systemMessage]);
|
||||
const messages = await chatHistory.getLLM(this.chatModel, [systemMessage]);
|
||||
return { nodes: context.nodes, messages };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LLM } from "../llms";
|
||||
import { BaseMemory, ChatMemoryBuffer } from "../memory";
|
||||
import { createMemory, Memory } from "../memory";
|
||||
import { EngineResponse } from "../schema";
|
||||
import { streamConverter, streamReducer } from "../utils";
|
||||
import type {
|
||||
@@ -16,20 +16,16 @@ import { Settings } from "../global";
|
||||
*/
|
||||
|
||||
export class SimpleChatEngine implements BaseChatEngine {
|
||||
memory: BaseMemory;
|
||||
memory: Memory;
|
||||
llm: LLM;
|
||||
|
||||
get chatHistory() {
|
||||
return this.memory.getMessages();
|
||||
return this.memory.getLLM();
|
||||
}
|
||||
|
||||
constructor(init?: Partial<SimpleChatEngine>) {
|
||||
this.llm = init?.llm ?? Settings.llm;
|
||||
this.memory =
|
||||
init?.memory ??
|
||||
new ChatMemoryBuffer({
|
||||
llm: this.llm,
|
||||
});
|
||||
this.memory = init?.memory ?? createMemory();
|
||||
}
|
||||
|
||||
chat(params: NonStreamingChatEngineParams): Promise<EngineResponse>;
|
||||
@@ -43,19 +39,15 @@ export class SimpleChatEngine implements BaseChatEngine {
|
||||
const { message, stream } = params;
|
||||
|
||||
const chatHistory = params.chatHistory
|
||||
? new ChatMemoryBuffer({
|
||||
llm: this.llm,
|
||||
chatHistory:
|
||||
params.chatHistory instanceof BaseMemory
|
||||
? await params.chatHistory.getMessages()
|
||||
: params.chatHistory,
|
||||
})
|
||||
? params.chatHistory instanceof Memory
|
||||
? params.chatHistory
|
||||
: createMemory(params.chatHistory)
|
||||
: this.memory;
|
||||
chatHistory.put({ content: message, role: "user" });
|
||||
await chatHistory.add({ content: message, role: "user" });
|
||||
|
||||
if (stream) {
|
||||
const stream = await this.llm.chat({
|
||||
messages: await chatHistory.getMessages(),
|
||||
messages: await chatHistory.getLLM(this.llm),
|
||||
stream: true,
|
||||
});
|
||||
return streamConverter(
|
||||
@@ -64,7 +56,7 @@ export class SimpleChatEngine implements BaseChatEngine {
|
||||
initialValue: "",
|
||||
reducer: (accumulator, part) => accumulator + part.delta,
|
||||
finished: (accumulator) => {
|
||||
chatHistory.put({ content: accumulator, role: "assistant" });
|
||||
void chatHistory.add({ content: accumulator, role: "assistant" });
|
||||
},
|
||||
}),
|
||||
EngineResponse.fromChatResponseChunk,
|
||||
@@ -73,13 +65,13 @@ export class SimpleChatEngine implements BaseChatEngine {
|
||||
|
||||
const response = await this.llm.chat({
|
||||
stream: false,
|
||||
messages: await chatHistory.getMessages(),
|
||||
messages: await chatHistory.getLLM(this.llm),
|
||||
});
|
||||
chatHistory.put(response.message);
|
||||
await chatHistory.add(response.message);
|
||||
return EngineResponse.fromChatResponse(response);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.memory.reset();
|
||||
async reset() {
|
||||
await this.memory.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MemoryMessage } from "../types";
|
||||
|
||||
export interface MessageAdapter<T, TMessageOptions extends object = object> {
|
||||
fromMemory(message: MemoryMessage<TMessageOptions>): T;
|
||||
toMemory(message: T): MemoryMessage<TMessageOptions>;
|
||||
isCompatible(message: unknown): message is T;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { randomUUID } from "@llamaindex/env";
|
||||
import type { ChatMessage } from "../../llms";
|
||||
import type { MemoryMessage } from "../types";
|
||||
import { type MessageAdapter } from "./base";
|
||||
|
||||
export class ChatMessageAdapter<
|
||||
AdditionalMessageOptions extends object = object,
|
||||
> implements
|
||||
MessageAdapter<
|
||||
ChatMessage<AdditionalMessageOptions>,
|
||||
AdditionalMessageOptions
|
||||
>
|
||||
{
|
||||
fromMemory(
|
||||
message: MemoryMessage<AdditionalMessageOptions>,
|
||||
): ChatMessage<AdditionalMessageOptions> {
|
||||
return {
|
||||
content: message.content,
|
||||
role: message.role,
|
||||
options: message.options,
|
||||
};
|
||||
}
|
||||
toMemory(
|
||||
message: ChatMessage<AdditionalMessageOptions>,
|
||||
): MemoryMessage<AdditionalMessageOptions> {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(),
|
||||
...message,
|
||||
};
|
||||
}
|
||||
isCompatible(
|
||||
message: unknown,
|
||||
): message is ChatMessage<AdditionalMessageOptions> {
|
||||
return !!(
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
"role" in message &&
|
||||
message.role &&
|
||||
"content" in message
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./base";
|
||||
export * from "./chat";
|
||||
export * from "./vercel";
|
||||
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
MessageContent,
|
||||
MessageContentDetail,
|
||||
} from "../../llms";
|
||||
import { extractText } from "../../utils";
|
||||
import type { MemoryMessage } from "../types";
|
||||
import type { MessageAdapter } from "./base";
|
||||
|
||||
// UIMessage from the vercel/ai package (external)
|
||||
export type VercelMessage = {
|
||||
id: string;
|
||||
role: "system" | "user" | "assistant" | "data";
|
||||
content: string;
|
||||
createdAt?: Date | undefined;
|
||||
annotations?: Array<unknown> | undefined;
|
||||
parts: Array<{ type: string; [key: string]: unknown }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility class for converting between LlamaIndex ChatMessage and Vercel UI Message formats
|
||||
*/
|
||||
export class VercelMessageAdapter<
|
||||
AdditionalMessageOptions extends object = object,
|
||||
> implements MessageAdapter<VercelMessage, AdditionalMessageOptions>
|
||||
{
|
||||
/**
|
||||
* Convert LlamaIndex ChatMessage to Vercel UI Message format
|
||||
*/
|
||||
fromMemory(memoryMessage: MemoryMessage<object>): VercelMessage {
|
||||
const parts = this.convertMessageContentToVercelParts(
|
||||
memoryMessage.content,
|
||||
);
|
||||
|
||||
// Convert role to UI message role
|
||||
let role: VercelMessage["role"];
|
||||
switch (memoryMessage.role) {
|
||||
case "system":
|
||||
case "user":
|
||||
case "assistant":
|
||||
role = memoryMessage.role;
|
||||
break;
|
||||
case "memory":
|
||||
role = "system";
|
||||
break;
|
||||
case "developer":
|
||||
role = "user";
|
||||
break;
|
||||
default:
|
||||
role = "user"; // Default fallback, should not happen
|
||||
}
|
||||
|
||||
return {
|
||||
id: memoryMessage.id,
|
||||
role,
|
||||
content: extractText(memoryMessage.content),
|
||||
parts,
|
||||
createdAt: memoryMessage.createdAt,
|
||||
annotations: memoryMessage.annotations,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert Vercel UI Message to LlamaIndex ChatMessage format
|
||||
*/
|
||||
toMemory(uiMessage: VercelMessage): MemoryMessage<AdditionalMessageOptions> {
|
||||
// Convert UI message role to MessageType
|
||||
let role: ChatMessage["role"];
|
||||
switch (uiMessage.role) {
|
||||
case "system":
|
||||
case "user":
|
||||
case "assistant":
|
||||
role = uiMessage.role;
|
||||
break;
|
||||
case "data":
|
||||
role = "user"; // Map data role to user
|
||||
break;
|
||||
default:
|
||||
role = "user"; // Default fallback, should not happen
|
||||
}
|
||||
|
||||
// Convert parts to MessageContent
|
||||
const content = this.convertVercelPartsToMessageContent(uiMessage.parts);
|
||||
|
||||
return {
|
||||
id: uiMessage.id,
|
||||
content: content ?? uiMessage.content,
|
||||
role,
|
||||
createdAt: uiMessage.createdAt,
|
||||
annotations: uiMessage.annotations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if object matches VercelMessage structure
|
||||
*/
|
||||
isCompatible(message: unknown): message is VercelMessage {
|
||||
return !!(
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
"role" in message &&
|
||||
"content" in message &&
|
||||
"parts" in message
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UI parts to MessageContent
|
||||
*/
|
||||
private convertVercelPartsToMessageContent(
|
||||
parts: VercelMessage["parts"],
|
||||
): MessageContent | null {
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details: MessageContentDetail[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "file": {
|
||||
details.push({
|
||||
type: "file",
|
||||
data: part.data as string,
|
||||
mimeType: part.mimeType as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For other part types, convert to text
|
||||
details.push({
|
||||
type: "text",
|
||||
text: part.text as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If only one text detail, return as string
|
||||
if (details.length === 1 && details[0]?.type === "text") {
|
||||
return details[0].text;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MessageContent to UI parts
|
||||
*/
|
||||
private convertMessageContentToVercelParts(
|
||||
content: MessageContent,
|
||||
): VercelMessage["parts"] {
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const parts: VercelMessage["parts"] = [];
|
||||
|
||||
for (const detail of content) {
|
||||
switch (detail.type) {
|
||||
case "text":
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: detail.text,
|
||||
});
|
||||
break;
|
||||
case "image_url":
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: `[Image URL: ${detail.image_url.url}]`,
|
||||
});
|
||||
break;
|
||||
case "audio":
|
||||
case "video":
|
||||
case "image":
|
||||
case "file":
|
||||
parts.push({
|
||||
type: "file",
|
||||
data: detail.data,
|
||||
mimeType: detail.type,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// For unknown types, create a text representation
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: JSON.stringify(detail),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from "@llamaindex/env";
|
||||
import type { MemoryMessage } from "../types";
|
||||
|
||||
export type MemoryBlockOptions = {
|
||||
/**
|
||||
* The id of the memory block.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* The priority of the memory block.
|
||||
* Note: if priority is 0, the block content is always included in the memory context.
|
||||
*/
|
||||
priority: number;
|
||||
/**
|
||||
* Whether the memory block is long term.
|
||||
* Default is true.
|
||||
*/
|
||||
isLongTerm?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A base class for memory blocks.
|
||||
*/
|
||||
export abstract class BaseMemoryBlock<
|
||||
TAdditionalMessageOptions extends object = object,
|
||||
> {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly isLongTerm: boolean;
|
||||
|
||||
constructor(options: MemoryBlockOptions) {
|
||||
this.id = options.id ?? `memory-block-${randomUUID()}`;
|
||||
this.priority = options.priority;
|
||||
this.isLongTerm = options.isLongTerm ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the memory block content (async).
|
||||
*
|
||||
* @returns The memory block content as an array of ChatMessage.
|
||||
*/
|
||||
abstract get(): Promise<MemoryMessage<TAdditionalMessageOptions>[]>;
|
||||
|
||||
/**
|
||||
* Store the messages in the memory block.
|
||||
*/
|
||||
abstract put(
|
||||
messages: MemoryMessage<TAdditionalMessageOptions>[],
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { LLM, MessageType } from "../../llms";
|
||||
import type { MemoryMessage } from "../types";
|
||||
import { BaseMemoryBlock, type MemoryBlockOptions } from "./base";
|
||||
|
||||
const DEFAULT_EXTRACTION_PROMPT = `
|
||||
You are a precise fact extraction system designed to identify key information from conversations.
|
||||
|
||||
CONVERSATION SEGMENT:
|
||||
{{conversation}}
|
||||
|
||||
EXISTING FACTS:
|
||||
{{existing_facts}}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Review the conversation segment provided above.
|
||||
2. Extract specific, concrete facts the user has disclosed or important information discovered
|
||||
3. Focus on factual information like preferences, personal details, requirements, constraints, or context
|
||||
4. Do not include opinions, summaries, or interpretations - only extract explicit information
|
||||
5. Do not duplicate facts that are already in the existing facts list
|
||||
|
||||
Respond with the new facts from the conversation segment using the following JSON format:
|
||||
{
|
||||
"facts": ["fact1", "fact2", "fact3", ...]
|
||||
}
|
||||
`;
|
||||
|
||||
const DEFAULT_SUMMARY_PROMPT = `
|
||||
You are a precise fact condensing system designed to summarize facts in a concise manner.
|
||||
|
||||
EXISTING FACTS:
|
||||
{{existing_facts}}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Review the current list of existing facts
|
||||
2. Condense the facts into a more concise list, less than {{ max_facts }} facts
|
||||
3. Focus on factual information like preferences, personal details, requirements, constraints, or context
|
||||
4. Do not include opinions, summaries, or interpretations - only extract explicit information
|
||||
5. Do not duplicate facts that are already in the existing facts list
|
||||
|
||||
Respond with the condensed facts using the following JSON format:
|
||||
{
|
||||
"facts": ["fact1", "fact2", "fact3", ...]
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The options for the fact extraction memory block.
|
||||
*/
|
||||
export type FactExtractionMemoryBlockOptions = {
|
||||
/**
|
||||
* The fact extraction model to use.
|
||||
*/
|
||||
llm: LLM;
|
||||
/**
|
||||
* The maximum number of facts to extract.
|
||||
*/
|
||||
maxFacts: number;
|
||||
/**
|
||||
* The prompt to use for fact extraction.
|
||||
*/
|
||||
extractionPrompt?: string;
|
||||
/**
|
||||
* The prompt to use for fact summary.
|
||||
*/
|
||||
summaryPrompt?: string;
|
||||
} & MemoryBlockOptions & {
|
||||
isLongTerm?: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* A memory block that stores facts extracted from conversations.
|
||||
*/
|
||||
export class FactExtractionMemoryBlock<
|
||||
TAdditionalMessageOptions extends object = object,
|
||||
> extends BaseMemoryBlock<TAdditionalMessageOptions> {
|
||||
private readonly llm: LLM;
|
||||
private facts: string[] = [];
|
||||
private readonly maxFacts: number;
|
||||
private readonly extractionPrompt: string;
|
||||
private readonly summaryPrompt: string;
|
||||
|
||||
constructor(options: FactExtractionMemoryBlockOptions) {
|
||||
super(options);
|
||||
this.llm = options.llm;
|
||||
this.maxFacts = options.maxFacts;
|
||||
this.extractionPrompt =
|
||||
options.extractionPrompt ?? DEFAULT_EXTRACTION_PROMPT;
|
||||
this.summaryPrompt = options.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
|
||||
}
|
||||
|
||||
async get(): Promise<MemoryMessage<TAdditionalMessageOptions>[]> {
|
||||
const fact = {
|
||||
id: this.id,
|
||||
content: this.facts.join("\n"),
|
||||
role: "memory" as MessageType,
|
||||
};
|
||||
return [fact];
|
||||
}
|
||||
|
||||
async put(
|
||||
messages: MemoryMessage<TAdditionalMessageOptions>[],
|
||||
): Promise<void> {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Format existing facts
|
||||
const existingFactsStr = `{ facts: [${this.facts.join(", ")}] }`;
|
||||
// Format conversation
|
||||
const conversation = `\n\t${messages.map((m) => m.content).join("\n\t")}`;
|
||||
// Format prompt
|
||||
const prompt = this.extractionPrompt
|
||||
.replace("{{conversation}}", conversation)
|
||||
.replace("{{existing_facts}}", existingFactsStr);
|
||||
// Call the LLM
|
||||
const response = await this.llm.complete({
|
||||
prompt,
|
||||
});
|
||||
// Parse and validate the response
|
||||
const newFacts = JSON.parse(response.text);
|
||||
if (newFacts.facts === undefined || !Array.isArray(newFacts.facts)) {
|
||||
throw new Error(
|
||||
`[FactExtraction] Invalid response from LLM: ${response.text}`,
|
||||
);
|
||||
}
|
||||
// No new facts, so no need to update the facts
|
||||
if (newFacts.facts.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Update the facts
|
||||
this.facts.push(...newFacts.facts);
|
||||
|
||||
// Condense the facts
|
||||
if (this.facts.length > this.maxFacts) {
|
||||
const existingFactsStr = `{ facts: [${this.facts.join(", ")}] }`;
|
||||
const prompt = this.summaryPrompt
|
||||
.replace("{{existing_facts}}", existingFactsStr)
|
||||
.replace("{{max_facts}}", this.maxFacts.toString());
|
||||
const response = await this.llm.complete({
|
||||
prompt,
|
||||
});
|
||||
const condensedFacts = JSON.parse(response.text);
|
||||
if (
|
||||
condensedFacts.facts === undefined ||
|
||||
!Array.isArray(condensedFacts.facts) ||
|
||||
condensedFacts.facts.length === 0
|
||||
) {
|
||||
throw new Error("Invalid response from LLM");
|
||||
}
|
||||
// Only get the first maxFacts facts (in case the LLM returned more)
|
||||
this.facts = condensedFacts.facts.slice(0, this.maxFacts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { BaseMemoryBlock } from "./base";
|
||||
export { FactExtractionMemoryBlock } from "./fact";
|
||||
export { StaticMemoryBlock } from "./static";
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { MessageContent, MessageType } from "../../llms";
|
||||
import type { MemoryMessage } from "../types";
|
||||
import { BaseMemoryBlock, type MemoryBlockOptions } from "./base";
|
||||
|
||||
export type StaticMemoryBlockOptions = {
|
||||
/**
|
||||
* The static content to store.
|
||||
*/
|
||||
content: MessageContent;
|
||||
/**
|
||||
* The role of the message.
|
||||
*/
|
||||
messageRole?: MessageType;
|
||||
} & Omit<MemoryBlockOptions, "priority" | "isLongTerm">;
|
||||
|
||||
/**
|
||||
* A memory block that stores static content that doesn't change.
|
||||
* Static content is always included in the memory context.
|
||||
*/
|
||||
export class StaticMemoryBlock<
|
||||
TAdditionalMessageOptions extends object = object,
|
||||
> extends BaseMemoryBlock<TAdditionalMessageOptions> {
|
||||
private readonly content: MessageContent;
|
||||
private readonly messageRole: MessageType;
|
||||
|
||||
constructor(options: StaticMemoryBlockOptions) {
|
||||
super({ ...options, priority: 0, isLongTerm: false });
|
||||
this.content = options.content;
|
||||
this.messageRole = options.messageRole ?? "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the static content.
|
||||
* The messages parameter is ignored since this block contains static content.
|
||||
*/
|
||||
async get(): Promise<MemoryMessage<TAdditionalMessageOptions>[]> {
|
||||
return [
|
||||
{
|
||||
id: this.id,
|
||||
role: this.messageRole,
|
||||
content: this.content,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async put(
|
||||
_messages: MemoryMessage<TAdditionalMessageOptions>[],
|
||||
): Promise<void> {
|
||||
// No-op: static content doesn't change
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Settings } from "../global";
|
||||
import type { ChatMessage } from "../llms";
|
||||
import { type BaseChatStore, SimpleChatStore } from "../storage/chat-store";
|
||||
import { extractText } from "../utils";
|
||||
import { Settings } from "../../global";
|
||||
import type { ChatMessage } from "../../llms";
|
||||
import { type BaseChatStore, SimpleChatStore } from "../../storage/chat-store";
|
||||
import { extractText } from "../../utils";
|
||||
|
||||
export const DEFAULT_TOKEN_LIMIT_RATIO = 0.75;
|
||||
export const DEFAULT_CHAT_STORE_KEY = "chat_history";
|
||||
|
||||
/**
|
||||
* A ChatMemory is used to keep the state of back and forth chat messages
|
||||
* @deprecated Use Memory instead.
|
||||
*/
|
||||
export abstract class BaseMemory<
|
||||
AdditionalMessageOptions extends object = object,
|
||||
@@ -55,6 +56,9 @@ export abstract class BaseMemory<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Memory with snapshot feature with your own storage instead.
|
||||
*/
|
||||
export abstract class BaseChatStoreMemory<
|
||||
AdditionalMessageOptions extends object = object,
|
||||
> extends BaseMemory<AdditionalMessageOptions> {
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
import { Settings } from "../global";
|
||||
import type { ChatMessage, LLM } from "../llms";
|
||||
import { type BaseChatStore } from "../storage/chat-store";
|
||||
import { Settings } from "../../global";
|
||||
import type { ChatMessage, LLM } from "../../llms";
|
||||
import { type BaseChatStore } from "../../storage/chat-store";
|
||||
import { BaseChatStoreMemory, DEFAULT_TOKEN_LIMIT_RATIO } from "./base";
|
||||
|
||||
type ChatMemoryBufferOptions<AdditionalMessageOptions extends object = object> =
|
||||
@@ -12,6 +12,9 @@ type ChatMemoryBufferOptions<AdditionalMessageOptions extends object = object> =
|
||||
llm?: LLM<object, AdditionalMessageOptions> | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use Memory instead.
|
||||
*/
|
||||
export class ChatMemoryBuffer<
|
||||
AdditionalMessageOptions extends object = object,
|
||||
> extends BaseChatStoreMemory<AdditionalMessageOptions> {
|
||||
+7
-4
@@ -1,10 +1,13 @@
|
||||
import { type Tokenizer, tokenizers } from "@llamaindex/env/tokenizers";
|
||||
import { Settings } from "../global";
|
||||
import type { ChatMessage, LLM, MessageType } from "../llms";
|
||||
import { defaultSummaryPrompt, type SummaryPrompt } from "../prompts";
|
||||
import { extractText, messagesToHistory } from "../utils";
|
||||
import { Settings } from "../../global";
|
||||
import type { ChatMessage, LLM, MessageType } from "../../llms";
|
||||
import { defaultSummaryPrompt, type SummaryPrompt } from "../../prompts";
|
||||
import { extractText, messagesToHistory } from "../../utils";
|
||||
import { BaseMemory } from "./base";
|
||||
|
||||
/**
|
||||
* @deprecated Use Memory instead.
|
||||
*/
|
||||
export class ChatSummaryMemoryBuffer extends BaseMemory {
|
||||
/**
|
||||
* Tokenizer function that converts text to tokens,
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ChatMessage } from "../llms";
|
||||
import { ChatMessageAdapter } from "./adapter/chat";
|
||||
import {
|
||||
FactExtractionMemoryBlock,
|
||||
type FactExtractionMemoryBlockOptions,
|
||||
} from "./block/fact";
|
||||
import {
|
||||
StaticMemoryBlock,
|
||||
type StaticMemoryBlockOptions,
|
||||
} from "./block/static";
|
||||
import { DEFAULT_TOKEN_LIMIT, Memory, type MemoryOptions } from "./memory";
|
||||
import type { MemoryMessage } from "./types";
|
||||
|
||||
/**
|
||||
* Create a Memory instance with default options
|
||||
* @returns A new Memory instance with default configuration
|
||||
*/
|
||||
export function createMemory<TMessageOptions extends object = object>(): Memory<
|
||||
Record<string, never>,
|
||||
TMessageOptions
|
||||
>;
|
||||
|
||||
/**
|
||||
* Create a Memory instance with options only
|
||||
* @param options - Memory configuration options
|
||||
* @returns A new Memory instance
|
||||
*/
|
||||
export function createMemory<TMessageOptions extends object = object>(
|
||||
options: MemoryOptions<TMessageOptions>,
|
||||
): Memory<Record<string, never>, TMessageOptions>;
|
||||
|
||||
/**
|
||||
* Create a Memory instance with ChatMessage array (IDs will be generated)
|
||||
* @param messages - Initial ChatMessage array for the memory
|
||||
* @param options - Memory configuration options
|
||||
* @returns A new Memory instance
|
||||
*/
|
||||
export function createMemory<TMessageOptions extends object = object>(
|
||||
messages: ChatMessage<TMessageOptions>[],
|
||||
options?: MemoryOptions<TMessageOptions>,
|
||||
): Memory<Record<string, never>, TMessageOptions>;
|
||||
|
||||
/**
|
||||
* Create a Memory instance with MemoryMessage array and options
|
||||
* @param messages - Initial MemoryMessage array for the memory
|
||||
* @param options - Memory configuration options
|
||||
* @returns A new Memory instance
|
||||
*/
|
||||
export function createMemory<TMessageOptions extends object = object>(
|
||||
messages: MemoryMessage<TMessageOptions>[],
|
||||
options: MemoryOptions<TMessageOptions>,
|
||||
): Memory<Record<string, never>, TMessageOptions>;
|
||||
|
||||
/**
|
||||
* Create a Memory instance
|
||||
* @param messagesOrOptions - Either initial messages or options
|
||||
* @param options - Memory configuration options (when first param is messages)
|
||||
* @returns A new Memory instance
|
||||
*/
|
||||
export function createMemory<TMessageOptions extends object = object>(
|
||||
messagesOrOptions:
|
||||
| ChatMessage<TMessageOptions>[]
|
||||
| MemoryMessage<TMessageOptions>[]
|
||||
| MemoryOptions<TMessageOptions> = [],
|
||||
options: MemoryOptions<TMessageOptions> = {},
|
||||
): Memory<Record<string, never>, TMessageOptions> {
|
||||
let messages: MemoryMessage<TMessageOptions>[] = [];
|
||||
|
||||
if (Array.isArray(messagesOrOptions)) {
|
||||
const firstMessage = messagesOrOptions[0];
|
||||
if (firstMessage) {
|
||||
if ("id" in firstMessage) {
|
||||
messages = messagesOrOptions as MemoryMessage<TMessageOptions>[];
|
||||
} else {
|
||||
const adapter = new ChatMessageAdapter<TMessageOptions>();
|
||||
messages = messagesOrOptions.map((chatMessage) =>
|
||||
adapter.toMemory(chatMessage),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Memory<Record<string, never>, TMessageOptions>(messages, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* create a StaticMemoryBlock
|
||||
* @param options - Configuration options for the static memory block
|
||||
* @returns A new StaticMemoryBlock instance
|
||||
*/
|
||||
export function staticBlock<TMessageOptions extends object = object>(
|
||||
options: StaticMemoryBlockOptions,
|
||||
): StaticMemoryBlock<TMessageOptions> {
|
||||
return new StaticMemoryBlock<TMessageOptions>(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* create a FactExtractionMemoryBlock
|
||||
* @param options - Configuration options for the fact extraction memory block
|
||||
* @returns A new FactExtractionMemoryBlock instance
|
||||
*/
|
||||
export function factExtractionBlock<TMessageOptions extends object = object>(
|
||||
options: FactExtractionMemoryBlockOptions,
|
||||
): FactExtractionMemoryBlock<TMessageOptions> {
|
||||
return new FactExtractionMemoryBlock<TMessageOptions>(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Memory instance from a snapshot
|
||||
* @param snapshot The snapshot to load from
|
||||
* @param options Optional MemoryOptions to apply when loading (including memory blocks)
|
||||
* @returns A new Memory instance with the snapshot data and provided options
|
||||
*/
|
||||
export function loadMemory<TMessageOptions extends object = object>(
|
||||
snapshot: string,
|
||||
options?: MemoryOptions<TMessageOptions>,
|
||||
): Memory<Record<string, never>, TMessageOptions> {
|
||||
const { messages, tokenLimit, memoryCursor } = JSON.parse(snapshot);
|
||||
|
||||
// Merge snapshot data with provided options
|
||||
const mergedOptions: MemoryOptions<TMessageOptions> = {
|
||||
tokenLimit: options?.tokenLimit ?? tokenLimit ?? DEFAULT_TOKEN_LIMIT,
|
||||
...(options?.shortTermTokenLimitRatio && {
|
||||
shortTermTokenLimitRatio: options.shortTermTokenLimitRatio,
|
||||
}),
|
||||
...(options?.customAdapters && {
|
||||
customAdapters: options.customAdapters,
|
||||
}),
|
||||
memoryBlocks: options?.memoryBlocks ?? [],
|
||||
memoryCursor: memoryCursor ?? 0,
|
||||
};
|
||||
|
||||
return new Memory<Record<string, never>, TMessageOptions>(
|
||||
messages,
|
||||
mergedOptions,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
export { BaseMemory } from "./base";
|
||||
export { ChatMemoryBuffer } from "./chat-memory-buffer";
|
||||
export { ChatSummaryMemoryBuffer } from "./summary-memory";
|
||||
export { BaseMemory } from "./deprecated/base";
|
||||
export { ChatMemoryBuffer } from "./deprecated/chat-memory-buffer";
|
||||
export { ChatSummaryMemoryBuffer } from "./deprecated/summary-memory";
|
||||
|
||||
export * from "./adapter";
|
||||
export * from "./block";
|
||||
export * from "./factories";
|
||||
export { Memory } from "./memory";
|
||||
export * from "./types";
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Settings } from "../global";
|
||||
import type { ChatMessage, LLM } from "../llms";
|
||||
import { extractText } from "../utils";
|
||||
import { type MessageAdapter } from "./adapter/base";
|
||||
import { ChatMessageAdapter } from "./adapter/chat";
|
||||
import { VercelMessageAdapter } from "./adapter/vercel";
|
||||
import type { BaseMemoryBlock } from "./block/base.js";
|
||||
import { DEFAULT_TOKEN_LIMIT_RATIO } from "./deprecated/base";
|
||||
import type { MemoryMessage } from "./types";
|
||||
|
||||
export const DEFAULT_TOKEN_LIMIT = 4096;
|
||||
const DEFAULT_SHORT_TERM_TOKEN_LIMIT_RATIO = 0.5;
|
||||
|
||||
type BuiltinAdapters<TMessageOptions extends object = object> = {
|
||||
vercel: VercelMessageAdapter;
|
||||
llamaindex: ChatMessageAdapter<TMessageOptions>;
|
||||
};
|
||||
|
||||
export type MemoryOptions<TMessageOptions extends object = object> = {
|
||||
tokenLimit?: number;
|
||||
/**
|
||||
* How much of the token limit is used for short term memory.
|
||||
* The remaining token limit is used for long term memory.
|
||||
* Default is 0.5.
|
||||
*/
|
||||
shortTermTokenLimitRatio?: number;
|
||||
customAdapters?: Record<string, MessageAdapter<unknown, object>>;
|
||||
memoryBlocks?: BaseMemoryBlock<TMessageOptions>[];
|
||||
/**
|
||||
* The cursor position for tracking processed messages into long-term memory.
|
||||
* Used internally for memory restoration from snapshots.
|
||||
*/
|
||||
memoryCursor?: number;
|
||||
};
|
||||
|
||||
export class Memory<
|
||||
TAdapters extends Record<
|
||||
string,
|
||||
MessageAdapter<unknown, TMessageOptions>
|
||||
> = Record<string, never>,
|
||||
TMessageOptions extends object = object,
|
||||
> {
|
||||
/**
|
||||
* Hold all messages put into the memory.
|
||||
*/
|
||||
private messages: MemoryMessage<TMessageOptions>[] = [];
|
||||
/**
|
||||
* The token limit for memory retrieval results.
|
||||
*/
|
||||
private tokenLimit: number = DEFAULT_TOKEN_LIMIT;
|
||||
/**
|
||||
* The ratio of the token limit for short term memory.
|
||||
*/
|
||||
private shortTermTokenLimitRatio: number =
|
||||
DEFAULT_SHORT_TERM_TOKEN_LIMIT_RATIO;
|
||||
/**
|
||||
* The adapters for the memory.
|
||||
*/
|
||||
private adapters: TAdapters & BuiltinAdapters<TMessageOptions>;
|
||||
/**
|
||||
* The memory blocks for the memory.
|
||||
*/
|
||||
private memoryBlocks: BaseMemoryBlock<TMessageOptions>[] = [];
|
||||
/**
|
||||
* The cursor for the messages that have been processed into long-term memory.
|
||||
*/
|
||||
private memoryCursor: number = 0;
|
||||
|
||||
constructor(
|
||||
messages: MemoryMessage<TMessageOptions>[] = [],
|
||||
options: MemoryOptions<TMessageOptions> = {},
|
||||
) {
|
||||
this.messages = messages;
|
||||
this.tokenLimit = options.tokenLimit ?? DEFAULT_TOKEN_LIMIT;
|
||||
this.shortTermTokenLimitRatio =
|
||||
options.shortTermTokenLimitRatio ?? DEFAULT_SHORT_TERM_TOKEN_LIMIT_RATIO;
|
||||
this.memoryBlocks = options.memoryBlocks ?? [];
|
||||
this.memoryCursor = options.memoryCursor ?? 0;
|
||||
|
||||
this.adapters = {
|
||||
...options.customAdapters,
|
||||
vercel: new VercelMessageAdapter(),
|
||||
llamaindex: new ChatMessageAdapter(),
|
||||
} as TAdapters & BuiltinAdapters<TMessageOptions>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the memory
|
||||
* @param message - The message to add to the memory
|
||||
*/
|
||||
async add(message: unknown): Promise<void> {
|
||||
let memoryMessage: MemoryMessage<TMessageOptions> | null = null;
|
||||
|
||||
// Try to find a compatible adapter among the other adapters
|
||||
for (const key in this.adapters) {
|
||||
const adapter = this.adapters[key as keyof typeof this.adapters];
|
||||
if (adapter?.isCompatible(message)) {
|
||||
memoryMessage = adapter.toMemory(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (memoryMessage) {
|
||||
this.messages.push(memoryMessage);
|
||||
// Automatically manage memory blocks when new messages are added
|
||||
await this.manageMemoryBlocks();
|
||||
} else {
|
||||
throw new Error(
|
||||
`None of the adapters ${Object.keys(this.adapters).join(", ")} are compatible with the message. ${JSON.stringify(message)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the messages of specific type from the memory
|
||||
* @param options - The options for the get method
|
||||
* @returns The messages of specific type
|
||||
*/
|
||||
async get<
|
||||
K extends keyof (TAdapters &
|
||||
BuiltinAdapters<TMessageOptions>) = "llamaindex",
|
||||
>(
|
||||
options: {
|
||||
type?: K;
|
||||
transientMessages?: ChatMessage<TMessageOptions>[];
|
||||
} = {},
|
||||
): Promise<
|
||||
K extends keyof (TAdapters & BuiltinAdapters<TMessageOptions>)
|
||||
? ReturnType<
|
||||
(TAdapters & BuiltinAdapters<TMessageOptions>)[K]["fromMemory"]
|
||||
>[]
|
||||
: never
|
||||
> {
|
||||
const { type = "llamaindex", transientMessages } = options;
|
||||
const adapter = this.adapters[type as keyof typeof this.adapters];
|
||||
if (!adapter) {
|
||||
throw new Error(`No adapter registered for type "${String(type)}"`);
|
||||
}
|
||||
|
||||
let messages = this.messages;
|
||||
|
||||
if (transientMessages && transientMessages.length > 0) {
|
||||
messages = [
|
||||
...this.messages,
|
||||
...transientMessages.map((m) => this.adapters.llamaindex.toMemory(m)),
|
||||
];
|
||||
}
|
||||
|
||||
// Convert memory messages to chat messages for memory block processing
|
||||
const chatMessages = messages.map((m) => adapter.fromMemory(m));
|
||||
return chatMessages as unknown as Promise<
|
||||
K extends keyof (TAdapters & BuiltinAdapters<TMessageOptions>)
|
||||
? ReturnType<
|
||||
(TAdapters & BuiltinAdapters<TMessageOptions>)[K]["fromMemory"]
|
||||
>[]
|
||||
: never
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the messages from the memory, optionally including transient messages.
|
||||
* only return messages that are within context window of the LLM
|
||||
* @param llm - To fit the result messages to the context window of the LLM. If not provided, the default token limit will be used.
|
||||
* @param transientMessages - Optional transient messages to include.
|
||||
* @returns The messages from the memory, optionally including transient messages.
|
||||
*/
|
||||
async getLLM(
|
||||
llm?: LLM,
|
||||
transientMessages?: ChatMessage<TMessageOptions>[],
|
||||
): Promise<ChatMessage[]> {
|
||||
// Priority of result messages:
|
||||
// [Fixed blocks (priority=0), Long term blocks, Short term messages(oldest to newest), Transient messages]
|
||||
|
||||
const contextWindow = llm?.metadata.contextWindow;
|
||||
const tokenLimit = contextWindow
|
||||
? Math.ceil(contextWindow * DEFAULT_TOKEN_LIMIT_RATIO)
|
||||
: this.tokenLimit;
|
||||
|
||||
// Start with fixed block messages (priority=0)
|
||||
// as it must always be included in the retrieval result
|
||||
const messages = await this.getMemoryBlockMessages(
|
||||
this.memoryBlocks.filter((block) => block.priority === 0),
|
||||
tokenLimit,
|
||||
);
|
||||
// remaining token limit for short-term and memory blocks content
|
||||
const remainingTokenLimit =
|
||||
tokenLimit -
|
||||
this.countMessagesToken([...messages, ...(transientMessages || [])]);
|
||||
|
||||
// if transient messages are provided, we need to check if they fit within the token limit
|
||||
if (remainingTokenLimit < 0) {
|
||||
throw new Error(
|
||||
`Could not fit fixed blocks and transient messages within memory context`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get messages for short-term and memory blocks
|
||||
const shortTermTokenLimit = Math.ceil(
|
||||
remainingTokenLimit * this.shortTermTokenLimitRatio,
|
||||
);
|
||||
const memoryBlocksTokenLimit = remainingTokenLimit - shortTermTokenLimit;
|
||||
|
||||
// Add long-term memory blocks (priority > 0)
|
||||
const longTermBlocks = [...this.memoryBlocks]
|
||||
.filter((block) => block.priority !== 0)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
const longTermBlockMessages = await this.getMemoryBlockMessages(
|
||||
longTermBlocks,
|
||||
memoryBlocksTokenLimit,
|
||||
);
|
||||
messages.push(...longTermBlockMessages);
|
||||
|
||||
// Process short-term messages (newest first for token efficiency, but maintain chronological order in result)
|
||||
const shortTermMessagesResult: ChatMessage<TMessageOptions>[] = [];
|
||||
const unprocessedMessages = this.messages.slice(this.memoryCursor);
|
||||
|
||||
// Process from newest to oldest for token efficiency
|
||||
for (let i = unprocessedMessages.length - 1; i >= 0; i--) {
|
||||
const memoryMessage = unprocessedMessages[i];
|
||||
if (!memoryMessage) continue;
|
||||
const chatMessage = this.adapters.llamaindex.fromMemory(memoryMessage);
|
||||
|
||||
// Check if adding this message would exceed token limit
|
||||
const newTokenCount =
|
||||
this.countMessagesToken(shortTermMessagesResult) +
|
||||
this.countMessagesToken([chatMessage]) +
|
||||
this.countMessagesToken(transientMessages || []);
|
||||
|
||||
if (newTokenCount > shortTermTokenLimit) {
|
||||
// Token limit reached, stop processing older messages
|
||||
break;
|
||||
}
|
||||
shortTermMessagesResult.push(chatMessage);
|
||||
}
|
||||
// reverse the short-term messages to maintain chronological order (oldest to newest)
|
||||
messages.push(...shortTermMessagesResult.reverse());
|
||||
|
||||
// Add transient messages at the end
|
||||
if (transientMessages && transientMessages.length > 0) {
|
||||
messages.push(...transientMessages);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content from the memory blocks
|
||||
* also convert the content to chat messages
|
||||
* @param blocks - The blocks to get the content from
|
||||
* @param tokenLimit - The token limit for the memory blocks, if not provided, all the memory blocks will be included
|
||||
*/
|
||||
private async getMemoryBlockMessages(
|
||||
blocks: BaseMemoryBlock<TMessageOptions>[],
|
||||
tokenLimit?: number,
|
||||
): Promise<ChatMessage<TMessageOptions>[]> {
|
||||
if (blocks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort memory blocks by priority (highest first)
|
||||
const sortedBlocks = [...blocks].sort((a, b) => b.priority - a.priority);
|
||||
const memoryContent: ChatMessage<TMessageOptions>[] = [];
|
||||
|
||||
// Get up to the token limit of the memory blocks
|
||||
let addedTokenCount = 0;
|
||||
for (const block of sortedBlocks) {
|
||||
try {
|
||||
const content = await block.get();
|
||||
for (const message of content) {
|
||||
const chatMessage = this.adapters.llamaindex.fromMemory(message);
|
||||
const messageTokenCount = this.countMessagesToken([chatMessage]);
|
||||
if (tokenLimit && addedTokenCount + messageTokenCount > tokenLimit) {
|
||||
return memoryContent;
|
||||
}
|
||||
memoryContent.push(chatMessage);
|
||||
addedTokenCount += messageTokenCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to get content from memory block ${block.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return memoryContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage the memory blocks
|
||||
* This method processes new messages into memory blocks when short-term memory exceeds its token limit.
|
||||
* It uses a cursor system to track which messages have already been processed into long-term memory.
|
||||
*/
|
||||
async manageMemoryBlocks(): Promise<void> {
|
||||
// Early return if no memory blocks configured
|
||||
if (this.memoryBlocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Should always calculate the number
|
||||
const shortTermTokenLimit = Math.ceil(
|
||||
this.tokenLimit * this.shortTermTokenLimitRatio,
|
||||
);
|
||||
|
||||
// Check if unprocessed messages exceed the short term token limit
|
||||
const unprocessedMessages = this.getUnprocessedMessages();
|
||||
const unprocessedMessagesTokenCount =
|
||||
this.countMemoryMessagesToken(unprocessedMessages);
|
||||
|
||||
if (unprocessedMessagesTokenCount <= shortTermTokenLimit) {
|
||||
// No need to manage memory blocks yet
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processMessagesIntoMemoryBlocks(unprocessedMessages);
|
||||
this.updateMemoryCursor(unprocessedMessages.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages that haven't been processed into long-term memory yet
|
||||
*/
|
||||
private getUnprocessedMessages(): MemoryMessage<TMessageOptions>[] {
|
||||
if (this.memoryCursor >= this.messages.length) {
|
||||
return [];
|
||||
}
|
||||
return this.messages.slice(this.memoryCursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process new messages into all memory blocks
|
||||
*/
|
||||
private async processMessagesIntoMemoryBlocks(
|
||||
newMessages: MemoryMessage<TMessageOptions>[],
|
||||
): Promise<void> {
|
||||
const longTermMemoryBlocks = this.memoryBlocks.filter(
|
||||
(block) => block.isLongTerm,
|
||||
);
|
||||
const promises = longTermMemoryBlocks.map(async (block) => {
|
||||
try {
|
||||
await block.put(newMessages);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to process messages into memory block ${block.id}:`,
|
||||
error,
|
||||
);
|
||||
// Continue processing other blocks even if one fails
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all memory blocks to process the messages
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the memory cursor after successful processing
|
||||
*/
|
||||
private updateMemoryCursor(processedCount: number): void {
|
||||
this.memoryCursor += processedCount;
|
||||
// Ensure cursor doesn't exceed message count
|
||||
this.memoryCursor = Math.min(this.memoryCursor, this.messages.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all the messages in the memory
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
this.messages = [];
|
||||
this.memoryCursor = 0; // Reset cursor when clearing messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot of the current memory state
|
||||
* Note: Memory blocks are not included in snapshots as they may contain non-serializable content.
|
||||
* Memory blocks should be recreated when loading from snapshot.
|
||||
* @returns A JSON-serializable object containing the memory state
|
||||
*/
|
||||
snapshot(): string {
|
||||
return JSON.stringify({
|
||||
messages: this.messages,
|
||||
memoryCursor: this.memoryCursor,
|
||||
});
|
||||
}
|
||||
|
||||
private countMemoryMessagesToken(
|
||||
messages: MemoryMessage<TMessageOptions>[],
|
||||
): number {
|
||||
return this.countMessagesToken(
|
||||
messages.map((m) =>
|
||||
this.adapters.llamaindex.fromMemory(m),
|
||||
) as ChatMessage[],
|
||||
);
|
||||
}
|
||||
|
||||
private countMessagesToken(messages: ChatMessage[]): number {
|
||||
if (messages.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const tokenizer = Settings.tokenizer;
|
||||
const str = messages.map((m) => extractText(m.content)).join(" ");
|
||||
return tokenizer.encode(str).length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ChatMessage } from "../llms";
|
||||
|
||||
/**
|
||||
* Additional properties for storing additional data to memory messages
|
||||
* using the same properties as vercel/ai for simplicity
|
||||
*/
|
||||
export type MemoryMessageExtension = {
|
||||
id: string;
|
||||
createdAt?: Date | undefined;
|
||||
annotations?: Array<unknown> | undefined;
|
||||
};
|
||||
|
||||
export type MemoryMessage<AdditionalMessageOptions extends object = object> =
|
||||
ChatMessage<AdditionalMessageOptions> & MemoryMessageExtension;
|
||||
|
||||
export type MemorySnapshot = {
|
||||
messages: MemoryMessage[];
|
||||
tokenLimit: number;
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
import { Settings } from "@llamaindex/core/global";
|
||||
import type { ChatMessage, LLM } from "@llamaindex/core/llms";
|
||||
import { createMemory, Memory } from "@llamaindex/core/memory";
|
||||
import { MockLLM } from "@llamaindex/core/utils";
|
||||
import type { Tokenizer } from "@llamaindex/env/tokenizers";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
} from "vitest";
|
||||
|
||||
// Mock tokenizer that returns predictable token counts
|
||||
const createMockTokenizer = (): Tokenizer => ({
|
||||
encode: (text: string): Uint32Array => {
|
||||
// Simple mock: 1 token per 4 characters (rounded up)
|
||||
const tokenCount = Math.ceil(text.length / 4);
|
||||
return new Uint32Array(Array.from({ length: tokenCount }, (_, i) => i));
|
||||
},
|
||||
decode: (tokens: Uint32Array): string => {
|
||||
// Simple mock: just return a string based on token count
|
||||
return `decoded_${tokens.length}_tokens`;
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to create mock LLMs with different context windows
|
||||
const createMockLLM = (contextWindow: number): LLM =>
|
||||
new MockLLM({
|
||||
metadata: {
|
||||
contextWindow,
|
||||
model: "test-model",
|
||||
temperature: 0.7,
|
||||
topP: 1.0,
|
||||
tokenizer: undefined,
|
||||
structuredOutput: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe("Memory", () => {
|
||||
let memory: Memory;
|
||||
let originalTokenizer: Tokenizer;
|
||||
|
||||
beforeAll(() => {
|
||||
// Save original tokenizer and set mock
|
||||
originalTokenizer = Settings.tokenizer;
|
||||
Settings.tokenizer = createMockTokenizer();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original tokenizer
|
||||
Settings.tokenizer = originalTokenizer;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
describe("add", () => {
|
||||
test("should add LlamaIndex ChatMessage", async () => {
|
||||
const message: ChatMessage = {
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
};
|
||||
|
||||
await memory.add(message);
|
||||
const messages = await memory.get();
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(message);
|
||||
});
|
||||
|
||||
test("should add Vercel UI Message and convert to ChatMessage", async () => {
|
||||
const vercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello from Vercel!",
|
||||
parts: [{ type: "text", text: "Hello from Vercel!" }],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
await memory.add(vercelMessage);
|
||||
const messages = await memory.get();
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual({
|
||||
role: "user",
|
||||
content: "Hello from Vercel!",
|
||||
});
|
||||
});
|
||||
|
||||
test("should add multiple messages in sequence", async () => {
|
||||
const message1: ChatMessage = { role: "user", content: "First message" };
|
||||
const message2: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "Second message",
|
||||
};
|
||||
|
||||
await memory.add(message1);
|
||||
await memory.add(message2);
|
||||
|
||||
const messages = await memory.get();
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(message1);
|
||||
expect(messages[1]).toEqual(message2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
beforeEach(async () => {
|
||||
// Add some test messages
|
||||
await memory.add({ role: "user", content: "User message" });
|
||||
await memory.add({ role: "assistant", content: "Assistant response" });
|
||||
});
|
||||
|
||||
test("should return messages in LlamaIndex format by default", async () => {
|
||||
const messages = await memory.get();
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual({ role: "user", content: "User message" });
|
||||
expect(messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Assistant response",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return messages in LlamaIndex format when explicitly requested", async () => {
|
||||
const messages = await memory.get({ type: "llamaindex" });
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual({ role: "user", content: "User message" });
|
||||
expect(messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Assistant response",
|
||||
});
|
||||
});
|
||||
|
||||
test("should add and get messages in LlamaIndex format when explicitly requested with options", async () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
topP: 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
await memory.add(message);
|
||||
const messages = await memory.get({ type: "llamaindex" });
|
||||
|
||||
expect(messages[messages.length - 1]).toEqual({
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
topP: 1.0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return messages in Vercel format when requested", async () => {
|
||||
const messages = await memory.get({ type: "vercel" });
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "User message",
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Assistant response",
|
||||
parts: [{ type: "text", text: "Assistant response" }],
|
||||
});
|
||||
|
||||
// Check that IDs and timestamps are generated
|
||||
expect(typeof messages[0]).toBe("object");
|
||||
expect(messages[0]).toHaveProperty("id");
|
||||
expect(messages[0]).toHaveProperty("parts");
|
||||
expect(messages[0]?.parts).toHaveLength(1);
|
||||
expect(messages[1]).toHaveProperty("parts");
|
||||
expect(messages[1]?.parts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should include transient messages without storing them", async () => {
|
||||
const transientMessages: ChatMessage[] = [
|
||||
{ role: "system", content: "Transient system message" },
|
||||
{ role: "user", content: "Transient user message" },
|
||||
];
|
||||
|
||||
const messages = await memory.get({ transientMessages });
|
||||
|
||||
// Should return stored messages + transient messages
|
||||
expect(messages).toHaveLength(4);
|
||||
expect(messages[0]).toEqual({ role: "user", content: "User message" });
|
||||
expect(messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Assistant response",
|
||||
});
|
||||
expect(messages[2]).toEqual({
|
||||
role: "system",
|
||||
content: "Transient system message",
|
||||
});
|
||||
expect(messages[3]).toEqual({
|
||||
role: "user",
|
||||
content: "Transient user message",
|
||||
});
|
||||
|
||||
// Verify transient messages are not stored permanently
|
||||
const storedMessages = await memory.get();
|
||||
expect(storedMessages).toHaveLength(2);
|
||||
expect(storedMessages[0]).toEqual({
|
||||
role: "user",
|
||||
content: "User message",
|
||||
});
|
||||
expect(storedMessages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Assistant response",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLLM", () => {
|
||||
beforeEach(async () => {
|
||||
// Add test messages with varying lengths
|
||||
await memory.add({ role: "user", content: "Short message 1" });
|
||||
await memory.add({
|
||||
role: "assistant",
|
||||
content:
|
||||
"This is a longer assistant response with more content to test token limits",
|
||||
});
|
||||
await memory.add({ role: "user", content: "Another user message" });
|
||||
await memory.add({
|
||||
role: "assistant",
|
||||
content: "Final assistant response",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return all messages when no LLM is provided", async () => {
|
||||
const messages = await memory.getLLM();
|
||||
|
||||
expect(messages).toHaveLength(4);
|
||||
expect(messages[0]?.content).toBe("Short message 1");
|
||||
expect(messages[1]?.content).toBe(
|
||||
"This is a longer assistant response with more content to test token limits",
|
||||
);
|
||||
expect(messages[2]?.content).toBe("Another user message");
|
||||
expect(messages[3]?.content).toBe("Final assistant response");
|
||||
});
|
||||
|
||||
test("should include transient messages in token calculation", async () => {
|
||||
const transientMessages: ChatMessage[] = [
|
||||
{ role: "system", content: "System instruction" },
|
||||
{ role: "user", content: "Transient user question" },
|
||||
];
|
||||
|
||||
const messages = await memory.getLLM(
|
||||
createMockLLM(500),
|
||||
transientMessages,
|
||||
);
|
||||
|
||||
// Should include some combination of stored and transient messages
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Check if transient messages are included (they should be recent)
|
||||
const messageContents = messages.map((m) => m.content);
|
||||
const hasTransientMessage = messageContents.some(
|
||||
(content) =>
|
||||
content === "System instruction" ||
|
||||
content === "Transient user question",
|
||||
);
|
||||
expect(hasTransientMessage).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle empty memory with transient messages", async () => {
|
||||
const emptyMemory = createMemory();
|
||||
const transientMessages: ChatMessage[] = [
|
||||
{ role: "system", content: "System message" },
|
||||
{ role: "user", content: "User question" },
|
||||
];
|
||||
|
||||
const messages = await emptyMemory.getLLM(
|
||||
createMockLLM(1000),
|
||||
transientMessages,
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]?.content).toBe("System message");
|
||||
expect(messages[1]?.content).toBe("User question");
|
||||
});
|
||||
});
|
||||
|
||||
describe("token limit handling", () => {
|
||||
beforeEach(async () => {
|
||||
// Add messages with different lengths for testing
|
||||
await memory.add({
|
||||
role: "assistant",
|
||||
content:
|
||||
"This is a medium length response that should take up more tokens than the previous message",
|
||||
});
|
||||
await memory.add({ role: "user", content: "Short" }); // has 2 tokens
|
||||
await memory.add({ role: "assistant", content: "Last message" }); // has 4 tokens
|
||||
});
|
||||
|
||||
test("should return messages in token limit", async () => {
|
||||
const messages = await memory.getLLM(createMockLLM(1000));
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0]?.content).toBe(
|
||||
"This is a medium length response that should take up more tokens than the previous message",
|
||||
);
|
||||
expect(messages[1]?.content).toBe("Short");
|
||||
expect(messages[2]?.content).toBe("Last message");
|
||||
});
|
||||
|
||||
test("should only return messages that fit in the token limit", async () => {
|
||||
const messages = await memory.getLLM(createMockLLM(6));
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.content).toBe("Last message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
test("should clear all messages", async () => {
|
||||
await memory.add({ role: "user", content: "Test message" });
|
||||
await memory.add({ role: "assistant", content: "Test response" });
|
||||
|
||||
expect(await memory.get()).toHaveLength(2);
|
||||
|
||||
await memory.clear();
|
||||
|
||||
expect(await memory.get()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should allow adding messages after clearing", async () => {
|
||||
await memory.add({ role: "user", content: "First message" });
|
||||
await memory.clear();
|
||||
await memory.add({ role: "user", content: "After clear" });
|
||||
|
||||
const messages = await memory.get();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.content).toBe("After clear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should handle message with empty content", async () => {
|
||||
await memory.add({ role: "user", content: "" });
|
||||
const messages = await memory.get();
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.content).toBe("");
|
||||
});
|
||||
|
||||
test("should handle different role types", async () => {
|
||||
const roles: ChatMessage["role"][] = [
|
||||
"user",
|
||||
"assistant",
|
||||
"system",
|
||||
"memory",
|
||||
"developer",
|
||||
];
|
||||
|
||||
for (const role of roles) {
|
||||
await memory.add({ role, content: `Message from ${role}` });
|
||||
}
|
||||
|
||||
const messages = await memory.get();
|
||||
expect(messages).toHaveLength(roles.length);
|
||||
|
||||
roles.forEach((role, index) => {
|
||||
expect(messages[index]?.role).toBe(role);
|
||||
expect(messages[index]?.content).toBe(`Message from ${role}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle Vercel message with data role", async () => {
|
||||
const vercelMessage = {
|
||||
id: "test-id",
|
||||
role: "data",
|
||||
content: "Data message",
|
||||
parts: [{ type: "text", text: "Data message" }],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
await memory.add(vercelMessage);
|
||||
const messages = await memory.get();
|
||||
|
||||
expect(messages[0]?.role).toBe("user"); // data role should be mapped to user
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import type { ChatMessage, MessageContentDetail } from "@llamaindex/core/llms";
|
||||
import type { MemoryMessage, VercelMessage } from "@llamaindex/core/memory";
|
||||
import { VercelMessageAdapter } from "@llamaindex/core/memory";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("VercelMessageAdapter", () => {
|
||||
const adapter = new VercelMessageAdapter();
|
||||
|
||||
describe("toLlamaIndexMessage", () => {
|
||||
test("should convert basic Vercel message to LlamaIndex message", () => {
|
||||
const vercelMessage: VercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
parts: [{ type: "text", text: "Hello, world!" }],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.toMemory(vercelMessage);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
annotations: [],
|
||||
createdAt: vercelMessage.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle all supported Vercel message roles", () => {
|
||||
const roles: Array<VercelMessage["role"]> = [
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"data",
|
||||
];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const vercelMessage: VercelMessage = {
|
||||
id: "test-id",
|
||||
role,
|
||||
content: `Message from ${role}`,
|
||||
parts: [{ type: "text", text: `Message from ${role}` }],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.toMemory(vercelMessage);
|
||||
|
||||
// Data role should be mapped to user
|
||||
const expectedRole = role === "data" ? "user" : role;
|
||||
expect(result.role).toBe(expectedRole);
|
||||
expect(result.content).toBe(`Message from ${role}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should convert file parts to MessageContent", () => {
|
||||
const vercelMessage: VercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "File message",
|
||||
parts: [
|
||||
{ type: "file", data: "base64data", mimeType: "image/png" },
|
||||
{ type: "text", text: "Description" },
|
||||
],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.toMemory(vercelMessage);
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{ type: "file", data: "base64data", mimeType: "image/png" },
|
||||
{ type: "text", text: "Description" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle empty parts array", () => {
|
||||
const vercelMessage: VercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Fallback content",
|
||||
parts: [],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.toMemory(vercelMessage);
|
||||
|
||||
expect(result.content).toBe("Fallback content");
|
||||
});
|
||||
|
||||
test("should handle single text part", () => {
|
||||
const vercelMessage: VercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Original content",
|
||||
parts: [{ type: "text", text: "Single text part" }],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.toMemory(vercelMessage);
|
||||
|
||||
expect(result.content).toBe("Single text part");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toUIMessage", () => {
|
||||
test("should convert basic MemoryMessage to Vercel message", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello, LlamaIndex!",
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello, LlamaIndex!",
|
||||
parts: [{ type: "text", text: "Hello, LlamaIndex!" }],
|
||||
annotations: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("should convert MemoryMessage with options to Vercel message", () => {
|
||||
const createdAt = new Date();
|
||||
const annotations = ["test"];
|
||||
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello, LlamaIndex!",
|
||||
createdAt,
|
||||
annotations,
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
role: "user",
|
||||
content: "Hello, LlamaIndex!",
|
||||
parts: [{ type: "text", text: "Hello, LlamaIndex!" }],
|
||||
id: "test-id",
|
||||
createdAt,
|
||||
annotations,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle all MemoryMessage roles", () => {
|
||||
const roles: Array<MemoryMessage["role"]> = [
|
||||
"user",
|
||||
"assistant",
|
||||
"system",
|
||||
"memory",
|
||||
"developer",
|
||||
];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role,
|
||||
content: `Message from ${role}`,
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
|
||||
// Memory role should be mapped to system, developer to user
|
||||
let expectedRole: VercelMessage["role"];
|
||||
switch (role) {
|
||||
case "memory":
|
||||
expectedRole = "system";
|
||||
break;
|
||||
case "developer":
|
||||
expectedRole = "user";
|
||||
break;
|
||||
default:
|
||||
expectedRole = role as VercelMessage["role"];
|
||||
}
|
||||
|
||||
expect(result.role).toBe(expectedRole);
|
||||
expect(result.content).toBe(`Message from ${role}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should convert multi-modal content to parts", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Text content" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "https://example.com/image.jpg" },
|
||||
},
|
||||
{ type: "file", data: "base64data", mimeType: "application/pdf" },
|
||||
] as MessageContentDetail[],
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
|
||||
expect(result.parts).toEqual([
|
||||
{ type: "text", text: "Text content" },
|
||||
{ type: "text", text: "[Image URL: https://example.com/image.jpg]" },
|
||||
{ type: "file", data: "base64data", mimeType: "file" },
|
||||
]);
|
||||
expect(result.content).toBe("Text content");
|
||||
});
|
||||
|
||||
test("should handle different media types", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "audio", data: "audio-data", mimeType: "audio/mp3" },
|
||||
{ type: "video", data: "video-data", mimeType: "video/mp4" },
|
||||
{ type: "image", data: "image-data", mimeType: "image/png" },
|
||||
] as MessageContentDetail[],
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
|
||||
expect(result.parts).toEqual([
|
||||
{ type: "file", data: "audio-data", mimeType: "audio" },
|
||||
{ type: "file", data: "video-data", mimeType: "video" },
|
||||
{ type: "file", data: "image-data", mimeType: "image" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle unknown content types", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "unknown",
|
||||
data: "unknown-data",
|
||||
} as unknown as MessageContentDetail,
|
||||
],
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
|
||||
expect(result.parts).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ type: "unknown", data: "unknown-data" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVercelMessage", () => {
|
||||
test("should return true for valid Vercel message", () => {
|
||||
const validMessage: VercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Test content",
|
||||
parts: [],
|
||||
createdAt: new Date(),
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
expect(adapter.isCompatible(validMessage)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for all valid roles", () => {
|
||||
const roles: Array<VercelMessage["role"]> = [
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"data",
|
||||
];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const message = {
|
||||
id: "test-id",
|
||||
role,
|
||||
content: "Test content",
|
||||
parts: [],
|
||||
};
|
||||
|
||||
expect(adapter.isCompatible(message)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLlamaIndexMessage", () => {
|
||||
test("should return true for valid LlamaIndex message", () => {
|
||||
const validMessage: ChatMessage = {
|
||||
role: "user",
|
||||
content: "Test content",
|
||||
};
|
||||
|
||||
expect(adapter.isCompatible(validMessage)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for all valid roles", () => {
|
||||
const roles: Array<ChatMessage["role"]> = [
|
||||
"user",
|
||||
"assistant",
|
||||
"system",
|
||||
"memory",
|
||||
"developer",
|
||||
];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const message = {
|
||||
role,
|
||||
content: "Test content",
|
||||
};
|
||||
|
||||
expect(adapter.isCompatible(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for invalid message structures", () => {
|
||||
const invalidMessages = [
|
||||
null,
|
||||
undefined,
|
||||
"string",
|
||||
123,
|
||||
{},
|
||||
{ role: "user" }, // missing content
|
||||
{ content: "test" }, // missing role
|
||||
{ role: "invalid", content: "test" }, // invalid role
|
||||
{ role: "user", content: 123 }, // invalid content type (not string or array)
|
||||
];
|
||||
|
||||
invalidMessages.forEach((message) => {
|
||||
expect(adapter.isCompatible(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases and error handling", () => {
|
||||
test("should handle conversion with undefined optional fields", () => {
|
||||
const vercelMessage = {
|
||||
id: "test-id",
|
||||
role: "user" as const,
|
||||
content: "Test content",
|
||||
parts: [{ type: "text" as const, text: "Test content" }],
|
||||
// missing optional fields
|
||||
};
|
||||
|
||||
const result = adapter.toMemory(vercelMessage);
|
||||
expect(result.role).toBe("user");
|
||||
expect(result.content).toBe("Test content");
|
||||
});
|
||||
|
||||
test("should handle empty string content", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "",
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
expect(result.content).toBe("");
|
||||
expect(result.parts).toEqual([{ type: "text", text: "" }]);
|
||||
});
|
||||
|
||||
test("should handle empty array content", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = adapter.fromMemory(memoryMessage);
|
||||
expect(result.content).toBe("");
|
||||
expect(result.parts).toEqual([]);
|
||||
});
|
||||
|
||||
test("should generate unique IDs", () => {
|
||||
const memoryMessage: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Test",
|
||||
};
|
||||
|
||||
const result1 = adapter.fromMemory(memoryMessage);
|
||||
const result2 = adapter.toMemory(result1);
|
||||
|
||||
// Both should have valid UUIDs (they will be different)
|
||||
expect(typeof result1.id).toBe("string");
|
||||
expect(result1.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
createMemory,
|
||||
loadMemory,
|
||||
type MemoryMessage,
|
||||
} from "@llamaindex/core/memory";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Memory Snapshot", () => {
|
||||
it("should create a snapshot of empty memory", () => {
|
||||
const memory = createMemory();
|
||||
const snapshot = memory.snapshot();
|
||||
const parsedSnapshot = JSON.parse(snapshot);
|
||||
|
||||
expect(typeof snapshot).toBe("string");
|
||||
expect(parsedSnapshot).toEqual({
|
||||
messages: [],
|
||||
memoryCursor: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a snapshot with messages", async () => {
|
||||
const memory = createMemory();
|
||||
const message1: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
};
|
||||
const message2: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "assistant",
|
||||
content: "Hi there!",
|
||||
};
|
||||
|
||||
await memory.add(message1);
|
||||
await memory.add(message2);
|
||||
|
||||
const snapshot = memory.snapshot();
|
||||
const parsedSnapshot = JSON.parse(snapshot);
|
||||
|
||||
expect(typeof snapshot).toBe("string");
|
||||
expect(parsedSnapshot.messages).toHaveLength(2);
|
||||
expect(parsedSnapshot.messages[0].id).toBe(message1.id);
|
||||
expect(parsedSnapshot.messages[1].id).toBe(message2.id);
|
||||
});
|
||||
|
||||
it("should load memory from snapshot", async () => {
|
||||
const originalMemory = createMemory();
|
||||
const message: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Test message",
|
||||
};
|
||||
|
||||
await originalMemory.add(message);
|
||||
const snapshot = originalMemory.snapshot();
|
||||
|
||||
const loadedMemory = loadMemory(snapshot);
|
||||
const loadedSnapshot = JSON.parse(loadedMemory.snapshot());
|
||||
|
||||
expect(loadedSnapshot).toEqual(JSON.parse(snapshot));
|
||||
});
|
||||
|
||||
it("should load memory with correct messages", async () => {
|
||||
const message1: MemoryMessage = {
|
||||
id: "test-id-1",
|
||||
role: "user",
|
||||
content: "First message",
|
||||
};
|
||||
const message2: MemoryMessage = {
|
||||
id: "test-id-2",
|
||||
role: "assistant",
|
||||
content: "Second message",
|
||||
};
|
||||
|
||||
const snapshot = JSON.stringify({
|
||||
messages: [message1, message2],
|
||||
});
|
||||
|
||||
const memory = loadMemory(snapshot);
|
||||
const messages = await memory.get();
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]?.content).toBe(message1.content);
|
||||
expect(messages[1]?.content).toBe(message2.content);
|
||||
|
||||
const vercelMessages = await memory.get({ type: "vercel" });
|
||||
expect(vercelMessages).toHaveLength(2);
|
||||
expect(vercelMessages[0]?.id).toBe(message1.id);
|
||||
expect(vercelMessages[1]?.id).toBe(message2.id);
|
||||
});
|
||||
|
||||
it("should create independent memory instances", async () => {
|
||||
const originalMemory = createMemory();
|
||||
const message: MemoryMessage = {
|
||||
id: "test-id",
|
||||
role: "user",
|
||||
content: "Original message",
|
||||
};
|
||||
|
||||
await originalMemory.add(message);
|
||||
const snapshot = originalMemory.snapshot();
|
||||
|
||||
const loadedMemory = loadMemory(snapshot);
|
||||
const newMessage: MemoryMessage = {
|
||||
id: "test-id-2",
|
||||
role: "user",
|
||||
content: "New message",
|
||||
};
|
||||
|
||||
await loadedMemory.add(newMessage);
|
||||
|
||||
const originalMessages = await originalMemory.get();
|
||||
const loadedMessages = await loadedMemory.get();
|
||||
|
||||
expect(originalMessages).toHaveLength(1);
|
||||
expect(loadedMessages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SimpleChatEngine } from "@llamaindex/core/chat-engine";
|
||||
import { ChatMemoryBuffer } from "@llamaindex/core/memory";
|
||||
import { Memory } from "@llamaindex/core/memory";
|
||||
import { MockLLM } from "@llamaindex/core/utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -8,7 +8,6 @@ describe("SimpleChatEngine", () => {
|
||||
const llm = new MockLLM();
|
||||
const engine = new SimpleChatEngine({ llm });
|
||||
expect(engine.llm).toBe(llm);
|
||||
expect(engine.memory).toBeInstanceOf(ChatMemoryBuffer);
|
||||
expect((engine.memory as ChatMemoryBuffer).tokenLimit).toBe(768);
|
||||
expect(engine.memory).toBeInstanceOf(Memory);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@llamaindex/core/chat-engine";
|
||||
import { wrapEventCaller } from "@llamaindex/core/decorator";
|
||||
import type { ChatMessage, LLM } from "@llamaindex/core/llms";
|
||||
import { BaseMemory, ChatMemoryBuffer } from "@llamaindex/core/memory";
|
||||
import { createMemory, Memory } from "@llamaindex/core/memory";
|
||||
import {
|
||||
type CondenseQuestionPrompt,
|
||||
defaultCondenseQuestionPrompt,
|
||||
@@ -32,12 +32,12 @@ import { Settings } from "../../Settings.js";
|
||||
*/
|
||||
export class CondenseQuestionChatEngine extends BaseChatEngine {
|
||||
queryEngine: BaseQueryEngine;
|
||||
memory: BaseMemory;
|
||||
memory: Memory;
|
||||
llm: LLM;
|
||||
condenseMessagePrompt: CondenseQuestionPrompt;
|
||||
|
||||
get chatHistory() {
|
||||
return this.memory.getMessages();
|
||||
return this.memory.getLLM();
|
||||
}
|
||||
|
||||
constructor(init: {
|
||||
@@ -48,9 +48,7 @@ export class CondenseQuestionChatEngine extends BaseChatEngine {
|
||||
super();
|
||||
|
||||
this.queryEngine = init.queryEngine;
|
||||
this.memory = new ChatMemoryBuffer({
|
||||
chatHistory: init?.chatHistory,
|
||||
});
|
||||
this.memory = createMemory(init.chatHistory);
|
||||
this.llm = Settings.llm;
|
||||
this.condenseMessagePrompt =
|
||||
init?.condenseMessagePrompt ?? defaultCondenseQuestionPrompt;
|
||||
@@ -74,8 +72,8 @@ export class CondenseQuestionChatEngine extends BaseChatEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private async condenseQuestion(chatHistory: BaseMemory, question: string) {
|
||||
const chatHistoryStr = messagesToHistory(await chatHistory.getMessages());
|
||||
private async condenseQuestion(chatHistory: Memory, question: string) {
|
||||
const chatHistoryStr = messagesToHistory(await chatHistory.getLLM());
|
||||
|
||||
return this.llm.complete({
|
||||
prompt: this.condenseMessagePrompt.format({
|
||||
@@ -95,18 +93,15 @@ export class CondenseQuestionChatEngine extends BaseChatEngine {
|
||||
): Promise<EngineResponse | AsyncIterable<EngineResponse>> {
|
||||
const { message, stream } = params;
|
||||
const chatHistory = params.chatHistory
|
||||
? new ChatMemoryBuffer({
|
||||
chatHistory:
|
||||
params.chatHistory instanceof BaseMemory
|
||||
? await params.chatHistory.getMessages()
|
||||
: params.chatHistory,
|
||||
})
|
||||
? params.chatHistory instanceof Memory
|
||||
? params.chatHistory
|
||||
: createMemory(params.chatHistory)
|
||||
: this.memory;
|
||||
|
||||
const condensedQuestion = (
|
||||
await this.condenseQuestion(chatHistory, extractText(message))
|
||||
).text;
|
||||
chatHistory.put({ content: message, role: "user" });
|
||||
await chatHistory.add({ content: message, role: "user" });
|
||||
|
||||
if (stream) {
|
||||
const stream = await this.queryEngine.query({
|
||||
@@ -119,14 +114,14 @@ export class CondenseQuestionChatEngine extends BaseChatEngine {
|
||||
reducer: (accumulator, part) =>
|
||||
(accumulator += extractText(part.message.content)),
|
||||
finished: (accumulator) => {
|
||||
chatHistory.put({ content: accumulator, role: "assistant" });
|
||||
void chatHistory.add({ content: accumulator, role: "assistant" });
|
||||
},
|
||||
});
|
||||
}
|
||||
const response = await this.queryEngine.query({
|
||||
query: condensedQuestion,
|
||||
});
|
||||
chatHistory.put({
|
||||
await chatHistory.add({
|
||||
content: response.message.content,
|
||||
role: "assistant",
|
||||
});
|
||||
@@ -135,6 +130,6 @@ export class CondenseQuestionChatEngine extends BaseChatEngine {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.memory.reset();
|
||||
void this.memory.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Settings } from "@llamaindex/core/global";
|
||||
import type { ChatMessage, MessageContent } from "@llamaindex/core/llms";
|
||||
import { ChatMemoryBuffer } from "@llamaindex/core/memory";
|
||||
import { createMemory, Memory } from "@llamaindex/core/memory";
|
||||
import { PromptTemplate } from "@llamaindex/core/prompts";
|
||||
import { tool } from "@llamaindex/core/tools";
|
||||
import { stringifyJSONToMessageContent } from "@llamaindex/core/utils";
|
||||
@@ -85,6 +84,11 @@ export type AgentStep = {
|
||||
export const agentStepEvent = workflowEvent<AgentStep>();
|
||||
|
||||
export type SingleAgentParams = FunctionAgentParams & {
|
||||
/**
|
||||
* Optional predefined memory to use for the workflow.
|
||||
* If not provided, a new empty memory will be created.
|
||||
*/
|
||||
memory?: Memory;
|
||||
/**
|
||||
* Whether to log verbose output
|
||||
*/
|
||||
@@ -108,6 +112,11 @@ export type AgentWorkflowParams = {
|
||||
* Can also be an AgentWorkflow object, in which case the workflow must have exactly one agent.
|
||||
*/
|
||||
rootAgent: BaseWorkflowAgent | AgentWorkflow;
|
||||
/**
|
||||
* Optional predefined memory to use for the workflow.
|
||||
* If not provided, a new empty memory will be created.
|
||||
*/
|
||||
memory?: Memory | undefined;
|
||||
verbose?: boolean;
|
||||
/**
|
||||
* Timeout for the workflow in seconds.
|
||||
@@ -148,9 +157,13 @@ export class AgentWorkflow implements Workflow {
|
||||
private agents: Map<string, BaseWorkflowAgent> = new Map();
|
||||
private verbose: boolean;
|
||||
private rootAgentName: string;
|
||||
private initialMemory?: Memory;
|
||||
|
||||
constructor({ agents, rootAgent, verbose }: AgentWorkflowParams) {
|
||||
constructor({ agents, rootAgent, memory, verbose }: AgentWorkflowParams) {
|
||||
this.verbose = verbose ?? false;
|
||||
if (memory) {
|
||||
this.initialMemory = memory;
|
||||
}
|
||||
|
||||
// Handle AgentWorkflow cases for agents
|
||||
const processedAgents: BaseWorkflowAgent[] = [];
|
||||
@@ -286,12 +299,15 @@ export class AgentWorkflow implements Workflow {
|
||||
canHandoffTo: params.canHandoffTo,
|
||||
});
|
||||
|
||||
const workflow = new AgentWorkflow({
|
||||
const workflowParams: AgentWorkflowParams = {
|
||||
agents: [agent],
|
||||
rootAgent: agent,
|
||||
verbose: params.verbose ?? false,
|
||||
timeout: params.timeout ?? 60,
|
||||
});
|
||||
memory: params.memory,
|
||||
};
|
||||
|
||||
const workflow = new AgentWorkflow(workflowParams);
|
||||
|
||||
return workflow;
|
||||
}
|
||||
@@ -304,7 +320,7 @@ export class AgentWorkflow implements Workflow {
|
||||
const memory = state.memory;
|
||||
if (chatHistory) {
|
||||
chatHistory.forEach((message: ChatMessage) => {
|
||||
memory.put(message);
|
||||
memory.add(message);
|
||||
});
|
||||
}
|
||||
if (userInput) {
|
||||
@@ -312,7 +328,7 @@ export class AgentWorkflow implements Workflow {
|
||||
role: "user",
|
||||
content: userInput,
|
||||
};
|
||||
memory.put(userMessage);
|
||||
memory.add(userMessage);
|
||||
} else if (chatHistory) {
|
||||
// If no user message, use the last message from chat history as user_msg_str
|
||||
const lastMessage = chatHistory[chatHistory.length - 1];
|
||||
@@ -328,7 +344,7 @@ export class AgentWorkflow implements Workflow {
|
||||
console.log(`[Agent ${this.rootAgentName}]: Starting agent`);
|
||||
}
|
||||
return agentInputEvent.with({
|
||||
input: await memory.getMessages(),
|
||||
input: await memory.getLLM(this.agents.get(this.rootAgentName)?.llm),
|
||||
currentAgentName: this.rootAgentName,
|
||||
});
|
||||
};
|
||||
@@ -514,7 +530,7 @@ export class AgentWorkflow implements Workflow {
|
||||
|
||||
const messages = await this.stateful
|
||||
.getContext()
|
||||
.state.memory.getMessages();
|
||||
.state.memory.getLLM(this.agents.get(nextAgentName)?.llm);
|
||||
if (this.verbose) {
|
||||
console.log(`[Agent ${nextAgentName}]: Starting agent`);
|
||||
}
|
||||
@@ -534,7 +550,7 @@ export class AgentWorkflow implements Workflow {
|
||||
// Continue with another agent step
|
||||
const messages = await this.stateful
|
||||
.getContext()
|
||||
.state.memory.getMessages();
|
||||
.state.memory.getLLM(this.agents.get(agent.name)?.llm);
|
||||
return agentInputEvent.with({
|
||||
input: messages,
|
||||
currentAgentName: agent.name,
|
||||
@@ -562,9 +578,7 @@ export class AgentWorkflow implements Workflow {
|
||||
|
||||
private createInitialState(): AgentWorkflowState {
|
||||
return {
|
||||
memory: new ChatMemoryBuffer({
|
||||
llm: this.agents.get(this.rootAgentName)?.llm ?? Settings.llm,
|
||||
}),
|
||||
memory: this.initialMemory ?? createMemory(),
|
||||
scratchpad: [],
|
||||
currentAgentName: this.rootAgentName,
|
||||
agents: Array.from(this.agents.keys()),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseToolWithCall, ChatMessage, LLM } from "@llamaindex/core/llms";
|
||||
import { BaseMemory } from "@llamaindex/core/memory";
|
||||
import { Memory } from "@llamaindex/core/memory";
|
||||
import type { WorkflowContext } from "@llamaindex/workflow-core";
|
||||
import type { AgentOutput, AgentToolCallResult } from "./events";
|
||||
|
||||
export type AgentWorkflowState = {
|
||||
memory: BaseMemory;
|
||||
memory: Memory;
|
||||
scratchpad: ChatMessage[];
|
||||
agents: string[];
|
||||
currentAgentName: string;
|
||||
|
||||
@@ -262,7 +262,7 @@ export class FunctionAgent implements BaseWorkflowAgent {
|
||||
const scratchpad: ChatMessage[] = state.scratchpad;
|
||||
|
||||
for (const msg of scratchpad) {
|
||||
state.memory.put(msg);
|
||||
state.memory.add(msg);
|
||||
}
|
||||
|
||||
// Clear scratchpad after finalization
|
||||
|
||||
@@ -233,7 +233,7 @@ describe("agent", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = response.data.state!.memory.getAllMessages();
|
||||
const messages = await response.data.state!.memory.get();
|
||||
const fileMessage = messages[0].content[0];
|
||||
|
||||
expect(fileMessage.type).toEqual("file");
|
||||
|
||||
Reference in New Issue
Block a user