@forwardimpact/libmemory
Advanced tools
+165
| import { common, tool } from "@forwardimpact/libtype"; | ||
| import { getModelBudget } from "./models.js"; | ||
| // Re-export model utilities | ||
| export { getModelBudget } from "./models.js"; | ||
| /** | ||
| * Memory window builder for managing conversation history with budget constraints | ||
| * Builds complete message arrays including assistant, tools, and conversation history | ||
| */ | ||
| export class MemoryWindow { | ||
| #resourceId; | ||
| #resourceIndex; | ||
| #memoryIndex; | ||
| /** | ||
| * Creates a new MemoryWindow instance for a specific resource | ||
| * @param {string} resourceId - The resource ID (conversation ID) | ||
| * @param {import("@forwardimpact/libresource").ResourceIndex} resourceIndex - Resource index for loading resources | ||
| * @param {import("./index/memory.js").MemoryIndex} memoryIndex - Memory index instance for this specific resource | ||
| */ | ||
| constructor(resourceId, resourceIndex, memoryIndex) { | ||
| if (!resourceId) throw new Error("resourceId is required"); | ||
| if (!resourceIndex) throw new Error("resourceIndex is required"); | ||
| if (!memoryIndex) throw new Error("memoryIndex is required"); | ||
| this.#resourceId = resourceId; | ||
| this.#resourceIndex = resourceIndex; | ||
| this.#memoryIndex = memoryIndex; | ||
| } | ||
| /** | ||
| * Builds a complete memory window with messages and tools | ||
| * @param {string} model - Model name to determine budget | ||
| * @param {number} maxTokens - Tokens reserved for LLM output | ||
| * @returns {Promise<{messages: object[], tools: object[]}>} Complete window with messages and tools | ||
| */ | ||
| async build(model, maxTokens) { | ||
| if (!model) { | ||
| throw new Error("model is required"); | ||
| } | ||
| if (!maxTokens || maxTokens <= 0) { | ||
| throw new Error("maxTokens is required and must be positive"); | ||
| } | ||
| const total = getModelBudget(model); | ||
| const actor = "common.System.root"; | ||
| // Load conversation to get agent_id | ||
| const [conversation] = await this.#resourceIndex.get( | ||
| [this.#resourceId], | ||
| actor, | ||
| ); | ||
| if (!conversation) { | ||
| throw new Error(`Conversation not found: ${this.#resourceId}`); | ||
| } | ||
| // Load agent | ||
| const [agent] = await this.#resourceIndex.get( | ||
| [conversation.agent_id], | ||
| actor, | ||
| ); | ||
| if (!agent) { | ||
| throw new Error(`Agent not found: ${conversation.agent_id}`); | ||
| } | ||
| // Load tools from agent configuration | ||
| const toolNames = agent.tools || []; | ||
| const functionIds = toolNames.map((name) => `tool.ToolFunction.${name}`); | ||
| const functions = await this.#resourceIndex.get(functionIds, actor); | ||
| // Calculate overhead (agent + tools tokens) | ||
| let overhead = agent.id?.tokens || 0; | ||
| for (const f of functions) { | ||
| overhead += f.id?.tokens || 0; | ||
| } | ||
| // History budget = total - overhead - reserved output tokens | ||
| const historyBudget = Math.max(0, total - overhead - maxTokens); | ||
| // Wrap the function in a call object, ensuring OpenAI-compatible parameters | ||
| const tools = functions.map((f) => { | ||
| // OpenAI requires parameters.properties and parameters.required even if empty | ||
| // Protobuf3 omits empty fields, so we must ensure they exist | ||
| const params = f.parameters || {}; | ||
| const normalizedFunction = { | ||
| ...f, | ||
| parameters: { | ||
| type: params.type || "object", | ||
| properties: params.properties || {}, | ||
| required: params.required || [], | ||
| }, | ||
| }; | ||
| return tool.ToolCall.fromObject({ | ||
| type: "function", | ||
| function: normalizedFunction, | ||
| }); | ||
| }); | ||
| // Get conversation history within budget | ||
| const identifiers = await this.#filterByBudget(historyBudget); | ||
| // Load full message objects from identifiers | ||
| const history = await this.#resourceIndex.get(identifiers, actor); | ||
| // Build complete messages array: agent + conversation history | ||
| const messages = [ | ||
| // Create a system message from agent | ||
| common.Message.fromObject({ | ||
| role: "system", | ||
| ...agent, | ||
| }), | ||
| ...history, | ||
| ]; | ||
| return { messages, tools }; | ||
| } | ||
| /** | ||
| * Filters memory identifiers by token budget | ||
| * @param {number} budget - Token budget | ||
| * @returns {Promise<import("@forwardimpact/libtype").resource.Identifier[]>} Filtered identifiers | ||
| * @private | ||
| */ | ||
| async #filterByBudget(budget) { | ||
| const allIdentifiers = await this.#memoryIndex.queryItems(); | ||
| let totalTokens = 0; | ||
| const filtered = []; | ||
| // Process newest first, then break when budget is reached | ||
| for (let i = allIdentifiers.length - 1; i >= 0; i--) { | ||
| const identifier = allIdentifiers[i]; | ||
| if (identifier.tokens === undefined || identifier.tokens === null) { | ||
| throw new Error( | ||
| `Identifier missing tokens field: ${JSON.stringify(identifier)}`, | ||
| ); | ||
| } | ||
| if (totalTokens + identifier.tokens <= budget) { | ||
| filtered.unshift(identifier); | ||
| totalTokens += identifier.tokens; | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
| // Ensure tool call integrity: tool messages must be preceded by assistant message | ||
| while (filtered.length > 0 && filtered[0].type === "tool.ToolCallMessage") { | ||
| filtered.shift(); | ||
| } | ||
| return filtered; | ||
| } | ||
| /** | ||
| * Appends identifiers to memory in a single operation | ||
| * @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Array of identifiers to append | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async append(identifiers) { | ||
| if (!identifiers || identifiers.length === 0) return; | ||
| await this.#memoryIndex.add(identifiers); | ||
| } | ||
| } |
| import { IndexBase } from "@forwardimpact/libindex"; | ||
| /** | ||
| * Memory index for managing conversation memory using JSONL storage | ||
| * Extends IndexBase to provide memory-specific operations with deduplication | ||
| * Each instance manages memory for a single resource/conversation | ||
| * @implements {import("@forwardimpact/libindex").IndexInterface} | ||
| */ | ||
| export class MemoryIndex extends IndexBase { | ||
| /** | ||
| * Adds identifiers to memory in a single storage operation | ||
| * @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Identifiers to add | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async add(identifiers) { | ||
| if (!identifiers || identifiers.length === 0) return; | ||
| if (!this.loaded) await this.loadData(); | ||
| // Build items for index and storage | ||
| const items = identifiers.map((identifier) => ({ | ||
| id: String(identifier), | ||
| identifier, | ||
| })); | ||
| // Update in-memory index | ||
| for (const item of items) { | ||
| this.index.set(item.id, item); | ||
| } | ||
| // Single append to storage with all items | ||
| const lines = items.map((item) => JSON.stringify(item)).join("\n"); | ||
| await this.storage().append(this.indexKey, lines); | ||
| } | ||
| } |
| /** | ||
| * Static map of model names to their context window token budgets | ||
| * Seeded from GitHub Models API via `./scripts/env.sh node scripts/models.js` | ||
| * @type {Map<string, number>} | ||
| */ | ||
| export const MODEL_BUDGETS = new Map([ | ||
| ["ai21-labs/ai21-jamba-1.5-large", 262144], | ||
| ["cohere/cohere-command-a", 131072], | ||
| ["cohere/cohere-command-r-08-2024", 131072], | ||
| ["cohere/cohere-command-r-plus-08-2024", 131072], | ||
| ["deepseek/deepseek-r1", 128000], | ||
| ["deepseek/deepseek-r1-0528", 128000], | ||
| ["deepseek/deepseek-v3-0324", 128000], | ||
| ["meta/llama-3.2-11b-vision-instruct", 128000], | ||
| ["meta/llama-3.2-90b-vision-instruct", 128000], | ||
| ["meta/llama-3.3-70b-instruct", 128000], | ||
| ["meta/llama-4-maverick-17b-128e-instruct-fp8", 1000000], | ||
| ["meta/llama-4-scout-17b-16e-instruct", 10000000], | ||
| ["meta/meta-llama-3.1-405b-instruct", 131072], | ||
| ["meta/meta-llama-3.1-8b-instruct", 131072], | ||
| ["microsoft/mai-ds-r1", 128000], | ||
| ["microsoft/phi-4", 16384], | ||
| ["microsoft/phi-4-mini-instruct", 128000], | ||
| ["microsoft/phi-4-mini-reasoning", 128000], | ||
| ["microsoft/phi-4-multimodal-instruct", 128000], | ||
| ["microsoft/phi-4-reasoning", 32768], | ||
| ["mistral-ai/codestral-2501", 256000], | ||
| ["mistral-ai/ministral-3b", 131072], | ||
| ["mistral-ai/mistral-medium-2505", 128000], | ||
| ["mistral-ai/mistral-small-2503", 128000], | ||
| ["openai/gpt-4.1", 1048576], | ||
| ["openai/gpt-4.1-mini", 1048576], | ||
| ["openai/gpt-4.1-nano", 1048576], | ||
| ["openai/gpt-4o", 131072], | ||
| ["openai/gpt-4o-mini", 131072], | ||
| ["openai/gpt-5", 200000], | ||
| ["openai/gpt-5-chat", 200000], | ||
| ["openai/gpt-5-mini", 200000], | ||
| ["openai/gpt-5-nano", 200000], | ||
| ["openai/o1", 200000], | ||
| ["openai/o1-mini", 128000], | ||
| ["openai/o1-preview", 128000], | ||
| ["openai/o3", 200000], | ||
| ["openai/o3-mini", 200000], | ||
| ["openai/o4-mini", 200000], | ||
| ["openai/text-embedding-3-large", 8191], | ||
| ["openai/text-embedding-3-small", 8191], | ||
| ["xai/grok-3", 131072], | ||
| ["xai/grok-3-mini", 131072], | ||
| // Test models with specific budgets for unit tests | ||
| ["test-model-1000", 1000], | ||
| ["test-model-125", 125], | ||
| ["test-model-230", 230], | ||
| ["test-model-300", 300], | ||
| ]); | ||
| /** | ||
| * Returns the token budget for a given model | ||
| * @param {string} model - Model name with provider prefix (e.g., 'openai/gpt-5') | ||
| * @returns {number} Token budget for the model | ||
| * @throws {Error} If model is not found in MODEL_BUDGETS | ||
| */ | ||
| export function getModelBudget(model) { | ||
| const budget = MODEL_BUDGETS.get(model); | ||
| if (!budget) { | ||
| throw new Error( | ||
| `Unknown model: ${model}. Known models: ${[...MODEL_BUDGETS.keys()].join(", ")}`, | ||
| ); | ||
| } | ||
| return budget; | ||
| } |
+9
-4
| { | ||
| "name": "@forwardimpact/libmemory", | ||
| "version": "0.1.43", | ||
| "version": "0.1.44", | ||
| "description": "Memory management library for Guide", | ||
@@ -8,6 +8,6 @@ "license": "Apache-2.0", | ||
| "type": "module", | ||
| "main": "index.js", | ||
| "main": "./src/index.js", | ||
| "exports": { | ||
| ".": "./index.js", | ||
| "./index/memory.js": "./index/memory.js" | ||
| ".": "./src/index.js", | ||
| "./index/memory.js": "./src/index/memory.js" | ||
| }, | ||
@@ -17,2 +17,7 @@ "bin": { | ||
| }, | ||
| "files": [ | ||
| "src/**/*.js", | ||
| "bin/**/*.js", | ||
| "README.md" | ||
| ], | ||
| "engines": { | ||
@@ -19,0 +24,0 @@ "bun": ">=1.2.0", |
-165
| import { common, tool } from "@forwardimpact/libtype"; | ||
| import { getModelBudget } from "./models.js"; | ||
| // Re-export model utilities | ||
| export { getModelBudget } from "./models.js"; | ||
| /** | ||
| * Memory window builder for managing conversation history with budget constraints | ||
| * Builds complete message arrays including assistant, tools, and conversation history | ||
| */ | ||
| export class MemoryWindow { | ||
| #resourceId; | ||
| #resourceIndex; | ||
| #memoryIndex; | ||
| /** | ||
| * Creates a new MemoryWindow instance for a specific resource | ||
| * @param {string} resourceId - The resource ID (conversation ID) | ||
| * @param {import("@forwardimpact/libresource").ResourceIndex} resourceIndex - Resource index for loading resources | ||
| * @param {import("./index/memory.js").MemoryIndex} memoryIndex - Memory index instance for this specific resource | ||
| */ | ||
| constructor(resourceId, resourceIndex, memoryIndex) { | ||
| if (!resourceId) throw new Error("resourceId is required"); | ||
| if (!resourceIndex) throw new Error("resourceIndex is required"); | ||
| if (!memoryIndex) throw new Error("memoryIndex is required"); | ||
| this.#resourceId = resourceId; | ||
| this.#resourceIndex = resourceIndex; | ||
| this.#memoryIndex = memoryIndex; | ||
| } | ||
| /** | ||
| * Builds a complete memory window with messages and tools | ||
| * @param {string} model - Model name to determine budget | ||
| * @param {number} maxTokens - Tokens reserved for LLM output | ||
| * @returns {Promise<{messages: object[], tools: object[]}>} Complete window with messages and tools | ||
| */ | ||
| async build(model, maxTokens) { | ||
| if (!model) { | ||
| throw new Error("model is required"); | ||
| } | ||
| if (!maxTokens || maxTokens <= 0) { | ||
| throw new Error("maxTokens is required and must be positive"); | ||
| } | ||
| const total = getModelBudget(model); | ||
| const actor = "common.System.root"; | ||
| // Load conversation to get agent_id | ||
| const [conversation] = await this.#resourceIndex.get( | ||
| [this.#resourceId], | ||
| actor, | ||
| ); | ||
| if (!conversation) { | ||
| throw new Error(`Conversation not found: ${this.#resourceId}`); | ||
| } | ||
| // Load agent | ||
| const [agent] = await this.#resourceIndex.get( | ||
| [conversation.agent_id], | ||
| actor, | ||
| ); | ||
| if (!agent) { | ||
| throw new Error(`Agent not found: ${conversation.agent_id}`); | ||
| } | ||
| // Load tools from agent configuration | ||
| const toolNames = agent.tools || []; | ||
| const functionIds = toolNames.map((name) => `tool.ToolFunction.${name}`); | ||
| const functions = await this.#resourceIndex.get(functionIds, actor); | ||
| // Calculate overhead (agent + tools tokens) | ||
| let overhead = agent.id?.tokens || 0; | ||
| for (const f of functions) { | ||
| overhead += f.id?.tokens || 0; | ||
| } | ||
| // History budget = total - overhead - reserved output tokens | ||
| const historyBudget = Math.max(0, total - overhead - maxTokens); | ||
| // Wrap the function in a call object, ensuring OpenAI-compatible parameters | ||
| const tools = functions.map((f) => { | ||
| // OpenAI requires parameters.properties and parameters.required even if empty | ||
| // Protobuf3 omits empty fields, so we must ensure they exist | ||
| const params = f.parameters || {}; | ||
| const normalizedFunction = { | ||
| ...f, | ||
| parameters: { | ||
| type: params.type || "object", | ||
| properties: params.properties || {}, | ||
| required: params.required || [], | ||
| }, | ||
| }; | ||
| return tool.ToolCall.fromObject({ | ||
| type: "function", | ||
| function: normalizedFunction, | ||
| }); | ||
| }); | ||
| // Get conversation history within budget | ||
| const identifiers = await this.#filterByBudget(historyBudget); | ||
| // Load full message objects from identifiers | ||
| const history = await this.#resourceIndex.get(identifiers, actor); | ||
| // Build complete messages array: agent + conversation history | ||
| const messages = [ | ||
| // Create a system message from agent | ||
| common.Message.fromObject({ | ||
| role: "system", | ||
| ...agent, | ||
| }), | ||
| ...history, | ||
| ]; | ||
| return { messages, tools }; | ||
| } | ||
| /** | ||
| * Filters memory identifiers by token budget | ||
| * @param {number} budget - Token budget | ||
| * @returns {Promise<import("@forwardimpact/libtype").resource.Identifier[]>} Filtered identifiers | ||
| * @private | ||
| */ | ||
| async #filterByBudget(budget) { | ||
| const allIdentifiers = await this.#memoryIndex.queryItems(); | ||
| let totalTokens = 0; | ||
| const filtered = []; | ||
| // Process newest first, then break when budget is reached | ||
| for (let i = allIdentifiers.length - 1; i >= 0; i--) { | ||
| const identifier = allIdentifiers[i]; | ||
| if (identifier.tokens === undefined || identifier.tokens === null) { | ||
| throw new Error( | ||
| `Identifier missing tokens field: ${JSON.stringify(identifier)}`, | ||
| ); | ||
| } | ||
| if (totalTokens + identifier.tokens <= budget) { | ||
| filtered.unshift(identifier); | ||
| totalTokens += identifier.tokens; | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
| // Ensure tool call integrity: tool messages must be preceded by assistant message | ||
| while (filtered.length > 0 && filtered[0].type === "tool.ToolCallMessage") { | ||
| filtered.shift(); | ||
| } | ||
| return filtered; | ||
| } | ||
| /** | ||
| * Appends identifiers to memory in a single operation | ||
| * @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Array of identifiers to append | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async append(identifiers) { | ||
| if (!identifiers || identifiers.length === 0) return; | ||
| await this.#memoryIndex.add(identifiers); | ||
| } | ||
| } |
| import { IndexBase } from "@forwardimpact/libindex"; | ||
| /** | ||
| * Memory index for managing conversation memory using JSONL storage | ||
| * Extends IndexBase to provide memory-specific operations with deduplication | ||
| * Each instance manages memory for a single resource/conversation | ||
| * @implements {import("@forwardimpact/libindex").IndexInterface} | ||
| */ | ||
| export class MemoryIndex extends IndexBase { | ||
| /** | ||
| * Adds identifiers to memory in a single storage operation | ||
| * @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Identifiers to add | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async add(identifiers) { | ||
| if (!identifiers || identifiers.length === 0) return; | ||
| if (!this.loaded) await this.loadData(); | ||
| // Build items for index and storage | ||
| const items = identifiers.map((identifier) => ({ | ||
| id: String(identifier), | ||
| identifier, | ||
| })); | ||
| // Update in-memory index | ||
| for (const item of items) { | ||
| this.index.set(item.id, item); | ||
| } | ||
| // Single append to storage with all items | ||
| const lines = items.map((item) => JSON.stringify(item)).join("\n"); | ||
| await this.storage().append(this.indexKey, lines); | ||
| } | ||
| } |
-71
| /** | ||
| * Static map of model names to their context window token budgets | ||
| * Seeded from GitHub Models API via `./scripts/env.sh node scripts/models.js` | ||
| * @type {Map<string, number>} | ||
| */ | ||
| export const MODEL_BUDGETS = new Map([ | ||
| ["ai21-labs/ai21-jamba-1.5-large", 262144], | ||
| ["cohere/cohere-command-a", 131072], | ||
| ["cohere/cohere-command-r-08-2024", 131072], | ||
| ["cohere/cohere-command-r-plus-08-2024", 131072], | ||
| ["deepseek/deepseek-r1", 128000], | ||
| ["deepseek/deepseek-r1-0528", 128000], | ||
| ["deepseek/deepseek-v3-0324", 128000], | ||
| ["meta/llama-3.2-11b-vision-instruct", 128000], | ||
| ["meta/llama-3.2-90b-vision-instruct", 128000], | ||
| ["meta/llama-3.3-70b-instruct", 128000], | ||
| ["meta/llama-4-maverick-17b-128e-instruct-fp8", 1000000], | ||
| ["meta/llama-4-scout-17b-16e-instruct", 10000000], | ||
| ["meta/meta-llama-3.1-405b-instruct", 131072], | ||
| ["meta/meta-llama-3.1-8b-instruct", 131072], | ||
| ["microsoft/mai-ds-r1", 128000], | ||
| ["microsoft/phi-4", 16384], | ||
| ["microsoft/phi-4-mini-instruct", 128000], | ||
| ["microsoft/phi-4-mini-reasoning", 128000], | ||
| ["microsoft/phi-4-multimodal-instruct", 128000], | ||
| ["microsoft/phi-4-reasoning", 32768], | ||
| ["mistral-ai/codestral-2501", 256000], | ||
| ["mistral-ai/ministral-3b", 131072], | ||
| ["mistral-ai/mistral-medium-2505", 128000], | ||
| ["mistral-ai/mistral-small-2503", 128000], | ||
| ["openai/gpt-4.1", 1048576], | ||
| ["openai/gpt-4.1-mini", 1048576], | ||
| ["openai/gpt-4.1-nano", 1048576], | ||
| ["openai/gpt-4o", 131072], | ||
| ["openai/gpt-4o-mini", 131072], | ||
| ["openai/gpt-5", 200000], | ||
| ["openai/gpt-5-chat", 200000], | ||
| ["openai/gpt-5-mini", 200000], | ||
| ["openai/gpt-5-nano", 200000], | ||
| ["openai/o1", 200000], | ||
| ["openai/o1-mini", 128000], | ||
| ["openai/o1-preview", 128000], | ||
| ["openai/o3", 200000], | ||
| ["openai/o3-mini", 200000], | ||
| ["openai/o4-mini", 200000], | ||
| ["openai/text-embedding-3-large", 8191], | ||
| ["openai/text-embedding-3-small", 8191], | ||
| ["xai/grok-3", 131072], | ||
| ["xai/grok-3-mini", 131072], | ||
| // Test models with specific budgets for unit tests | ||
| ["test-model-1000", 1000], | ||
| ["test-model-125", 125], | ||
| ["test-model-230", 230], | ||
| ["test-model-300", 300], | ||
| ]); | ||
| /** | ||
| * Returns the token budget for a given model | ||
| * @param {string} model - Model name with provider prefix (e.g., 'openai/gpt-5') | ||
| * @returns {number} Token budget for the model | ||
| * @throws {Error} If model is not found in MODEL_BUDGETS | ||
| */ | ||
| export function getModelBudget(model) { | ||
| const budget = MODEL_BUDGETS.get(model); | ||
| if (!budget) { | ||
| throw new Error( | ||
| `Unknown model: ${model}. Known models: ${[...MODEL_BUDGETS.keys()].join(", ")}`, | ||
| ); | ||
| } | ||
| return budget; | ||
| } |
| import { describe, it, beforeEach, mock } from "node:test"; | ||
| import assert from "node:assert"; | ||
| import { MemoryIndex } from "../index/memory.js"; | ||
| import { resource } from "@forwardimpact/libtype"; | ||
| describe("MemoryIndex - IndexBase Functionality", () => { | ||
| let storage; | ||
| let memoryIndex; | ||
| beforeEach(() => { | ||
| storage = { | ||
| exists: mock.fn(() => Promise.resolve(false)), | ||
| get: mock.fn(() => Promise.resolve([])), | ||
| append: mock.fn(() => Promise.resolve()), | ||
| }; | ||
| memoryIndex = new MemoryIndex(storage, "test-conversation.jsonl"); | ||
| }); | ||
| describe("Constructor and Properties", () => { | ||
| it("should throw error when storage is missing", () => { | ||
| assert.throws(() => new MemoryIndex(), /storage is required/); | ||
| }); | ||
| it("should set properties correctly", () => { | ||
| const index = new MemoryIndex(storage, "custom.jsonl"); | ||
| assert.strictEqual(index.storage(), storage, "Should set storage"); | ||
| assert.strictEqual(index.indexKey, "custom.jsonl", "Should set indexKey"); | ||
| assert.strictEqual( | ||
| index.loaded, | ||
| false, | ||
| "Should initialize loaded as false", | ||
| ); | ||
| }); | ||
| it("should use default indexKey when not provided", () => { | ||
| const index = new MemoryIndex(storage); | ||
| assert.strictEqual( | ||
| index.indexKey, | ||
| "index.jsonl", | ||
| "Should use default indexKey", | ||
| ); | ||
| }); | ||
| }); | ||
| describe("Data Loading", () => { | ||
| it("should initialize empty index when file doesn't exist", async () => { | ||
| storage.exists = mock.fn(() => Promise.resolve(false)); | ||
| await memoryIndex.loadData(); | ||
| assert.strictEqual(memoryIndex.loaded, true, "Should mark as loaded"); | ||
| assert.strictEqual( | ||
| storage.exists.mock.callCount(), | ||
| 1, | ||
| "Should check file existence", | ||
| ); | ||
| assert.strictEqual( | ||
| storage.get.mock.callCount(), | ||
| 0, | ||
| "Should not try to read non-existent file", | ||
| ); | ||
| }); | ||
| it("should load existing data from storage", async () => { | ||
| const testData = [ | ||
| { | ||
| id: "common.Message.msg1", | ||
| identifier: { type: "common.Message", name: "msg1", tokens: 10 }, | ||
| }, | ||
| { | ||
| id: "common.Message.msg2", | ||
| identifier: { type: "common.Message", name: "msg2", tokens: 20 }, | ||
| }, | ||
| ]; | ||
| storage.exists = mock.fn(() => Promise.resolve(true)); | ||
| storage.get = mock.fn(() => Promise.resolve(testData)); | ||
| await memoryIndex.loadData(); | ||
| assert.strictEqual(memoryIndex.loaded, true, "Should mark as loaded"); | ||
| assert.strictEqual( | ||
| storage.exists.mock.callCount(), | ||
| 1, | ||
| "Should check file existence", | ||
| ); | ||
| assert.strictEqual( | ||
| storage.get.mock.callCount(), | ||
| 1, | ||
| "Should read existing file", | ||
| ); | ||
| // Verify data was loaded into index | ||
| assert.strictEqual( | ||
| await memoryIndex.has("common.Message.msg1"), | ||
| true, | ||
| "Should load first item", | ||
| ); | ||
| assert.strictEqual( | ||
| await memoryIndex.has("common.Message.msg2"), | ||
| true, | ||
| "Should load second item", | ||
| ); | ||
| }); | ||
| it("should be idempotent", async () => { | ||
| storage.exists = mock.fn(() => Promise.resolve(false)); | ||
| await memoryIndex.loadData(); | ||
| storage.exists.mock.resetCalls(); | ||
| await memoryIndex.loadData(); | ||
| assert.strictEqual( | ||
| storage.exists.mock.callCount(), | ||
| 0, | ||
| "Should not check existence again when already loaded", | ||
| ); | ||
| assert.strictEqual(memoryIndex.loaded, true, "Should remain loaded"); | ||
| }); | ||
| }); | ||
| describe("Item Management", () => { | ||
| it("should return false for non-existent items", async () => { | ||
| const exists = await memoryIndex.has("common.Message.nonexistent"); | ||
| assert.strictEqual( | ||
| exists, | ||
| false, | ||
| "Should return false for non-existent item", | ||
| ); | ||
| }); | ||
| it("should return true for existing items", async () => { | ||
| const identifier = resource.Identifier.fromObject({ | ||
| type: "common.Message", | ||
| name: "test1", | ||
| tokens: 10, | ||
| }); | ||
| await memoryIndex.add([identifier]); | ||
| const exists = await memoryIndex.has(String(identifier)); | ||
| assert.strictEqual(exists, true, "Should return true for existing item"); | ||
| }); | ||
| it("should add items with correct structure", async () => { | ||
| const identifier = resource.Identifier.fromObject({ | ||
| type: "common.Message", | ||
| name: "test1", | ||
| tokens: 10, | ||
| }); | ||
| await memoryIndex.add([identifier]); | ||
| assert.strictEqual( | ||
| storage.append.mock.callCount(), | ||
| 1, | ||
| "Should call storage append", | ||
| ); | ||
| const appendedData = JSON.parse( | ||
| storage.append.mock.calls[0].arguments[1], | ||
| ); | ||
| assert.strictEqual(appendedData.id, String(identifier)); | ||
| assert.strictEqual(appendedData.identifier.name, "test1"); | ||
| assert.strictEqual(appendedData.identifier.type, "common.Message"); | ||
| assert.strictEqual(appendedData.identifier.tokens, 10); | ||
| }); | ||
| it("should get items by IDs", async () => { | ||
| const identifier = resource.Identifier.fromObject({ | ||
| type: "common.Message", | ||
| name: "test1", | ||
| tokens: 10, | ||
| }); | ||
| await memoryIndex.add([identifier]); | ||
| const result = await memoryIndex.get([String(identifier)]); | ||
| assert.strictEqual(result.length, 1, "Should return one item"); | ||
| assert.strictEqual( | ||
| result[0].name, | ||
| "test1", | ||
| "Should return correct identifier", | ||
| ); | ||
| assert.strictEqual( | ||
| result[0].type, | ||
| "common.Message", | ||
| "Should return correct type", | ||
| ); | ||
| assert.strictEqual(result[0].tokens, 10, "Should return correct tokens"); | ||
| }); | ||
| it("should return empty array for non-existent IDs", async () => { | ||
| const result = await memoryIndex.get(["common.Message.nonexistent"]); | ||
| assert.strictEqual( | ||
| result.length, | ||
| 0, | ||
| "Should return empty array for non-existent item", | ||
| ); | ||
| }); | ||
| it("should handle null IDs parameter", async () => { | ||
| const result = await memoryIndex.get(null); | ||
| assert.deepStrictEqual(result, [], "Should return empty array for null"); | ||
| }); | ||
| it("should handle empty IDs array", async () => { | ||
| const result = await memoryIndex.get([]); | ||
| assert.deepStrictEqual( | ||
| result, | ||
| [], | ||
| "Should return empty array for empty array", | ||
| ); | ||
| }); | ||
| }); | ||
| describe("Query and Filtering", () => { | ||
| beforeEach(async () => { | ||
| // Add test items with different types and token counts | ||
| const items = [ | ||
| { type: "common.Message", name: "msg1", tokens: 10 }, | ||
| { type: "common.Message", name: "msg2", tokens: 20 }, | ||
| { type: "tool.ToolFunction", name: "func1", tokens: 15 }, | ||
| { type: "tool.ToolFunction", name: "func2", tokens: 25 }, | ||
| { type: "resource.Document", name: "doc1", tokens: 30 }, | ||
| ]; | ||
| const identifiers = items.map((item) => | ||
| resource.Identifier.fromObject(item), | ||
| ); | ||
| await memoryIndex.add(identifiers); | ||
| }); | ||
| it("should return all items without filters", async () => { | ||
| const results = await memoryIndex.queryItems({}); | ||
| assert.strictEqual( | ||
| results.length, | ||
| 5, | ||
| "Should return all items without filters", | ||
| ); | ||
| }); | ||
| it("should filter by prefix", async () => { | ||
| const messageResults = await memoryIndex.queryItems({ | ||
| prefix: "common.Message", | ||
| }); | ||
| assert.strictEqual( | ||
| messageResults.length, | ||
| 2, | ||
| "Should return only Message items", | ||
| ); | ||
| const toolResults = await memoryIndex.queryItems({ | ||
| prefix: "tool.ToolFunction", | ||
| }); | ||
| assert.strictEqual( | ||
| toolResults.length, | ||
| 2, | ||
| "Should return only ToolFunction items", | ||
| ); | ||
| const noMatchResults = await memoryIndex.queryItems({ | ||
| prefix: "nonexistent", | ||
| }); | ||
| assert.strictEqual( | ||
| noMatchResults.length, | ||
| 0, | ||
| "Should return no items for non-matching prefix", | ||
| ); | ||
| }); | ||
| it("should apply limit filter", async () => { | ||
| const limitedResults = await memoryIndex.queryItems({ limit: 3 }); | ||
| assert.strictEqual( | ||
| limitedResults.length, | ||
| 3, | ||
| "Should return limited items", | ||
| ); | ||
| const zeroLimitResults = await memoryIndex.queryItems({ limit: 0 }); | ||
| assert.strictEqual( | ||
| zeroLimitResults.length, | ||
| 5, | ||
| "Should return all items when limit is 0", | ||
| ); | ||
| }); | ||
| it("should apply max_tokens filter", async () => { | ||
| const tokenLimitedResults = await memoryIndex.queryItems({ | ||
| max_tokens: 35, | ||
| }); | ||
| assert( | ||
| tokenLimitedResults.length >= 1, | ||
| "Should return at least one item within token limit", | ||
| ); | ||
| assert( | ||
| tokenLimitedResults.length <= 5, | ||
| "Should not return more than available items", | ||
| ); | ||
| const strictTokenResults = await memoryIndex.queryItems({ | ||
| max_tokens: 20, | ||
| }); | ||
| assert.strictEqual( | ||
| strictTokenResults.length, | ||
| 1, | ||
| "Should return only first item for strict limit", | ||
| ); | ||
| const veryStrictResults = await memoryIndex.queryItems({ | ||
| max_tokens: 5, | ||
| }); | ||
| assert.strictEqual( | ||
| veryStrictResults.length, | ||
| 0, | ||
| "Should return no items when first exceeds limit", | ||
| ); | ||
| }); | ||
| it("should apply combined filters", async () => { | ||
| const combinedResults = await memoryIndex.queryItems({ | ||
| prefix: "common.Message", | ||
| limit: 1, | ||
| max_tokens: 50, | ||
| }); | ||
| assert.strictEqual( | ||
| combinedResults.length, | ||
| 1, | ||
| "Should apply all filters together", | ||
| ); | ||
| assert( | ||
| String(combinedResults[0]).startsWith("common.Message"), | ||
| "Should match prefix filter", | ||
| ); | ||
| }); | ||
| it("should throw error when identifier is missing tokens field", async () => { | ||
| // Add item without tokens by directly manipulating index | ||
| const badIdentifier = { | ||
| type: "common.Message", | ||
| name: "bad", | ||
| }; | ||
| const badItem = { | ||
| id: "common.Message.bad", | ||
| identifier: badIdentifier, | ||
| }; | ||
| memoryIndex.index.set(badItem.id, badItem); | ||
| await assert.rejects( | ||
| async () => await memoryIndex.queryItems({ max_tokens: 100 }), | ||
| /Identifier missing tokens field/, | ||
| "Should throw when tokens field is missing", | ||
| ); | ||
| }); | ||
| }); | ||
| describe("Deduplication Behavior", () => { | ||
| it("should deduplicate items with same ID", async () => { | ||
| const identifier1 = resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 10, | ||
| }); | ||
| const identifier2 = resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 12, | ||
| }); | ||
| await memoryIndex.add([identifier1]); | ||
| await memoryIndex.add([identifier2]); | ||
| const memory = await memoryIndex.queryItems(); | ||
| assert.strictEqual(memory.length, 1, "Should have only one item"); | ||
| assert.strictEqual(memory[0].tokens, 12, "Should keep latest occurrence"); | ||
| }); | ||
| }); | ||
| }); |
| import { test, describe } from "node:test"; | ||
| import { MemoryWindow } from "../index.js"; | ||
| import { MemoryIndex } from "../index/memory.js"; | ||
| import { createPerformanceTest } from "@forwardimpact/libperf"; | ||
| describe("LibMemory Performance Tests", () => { | ||
| /** | ||
| * Generate mock identifiers with scores and tokens | ||
| * @param {number} count - Number of identifiers to generate | ||
| * @param {string} typePrefix - Type prefix for identifiers | ||
| * @returns {object[]} Mock identifiers | ||
| */ | ||
| function generateMockIdentifiers(count, typePrefix = "common.Message") { | ||
| const identifiers = []; | ||
| for (let i = 0; i < count; i++) { | ||
| identifiers.push({ | ||
| name: `item-${i.toString().padStart(6, "0")}`, | ||
| type: typePrefix, | ||
| score: Math.random(), | ||
| tokens: Math.floor(Math.random() * 500) + 100, | ||
| }); | ||
| } | ||
| return identifiers; | ||
| } | ||
| /** | ||
| * Create mock storage and memory index | ||
| * @param {number} itemCount - Number of items in memory | ||
| * @returns {object} Mock dependencies | ||
| */ | ||
| function createDependencies(itemCount) { | ||
| const items = generateMockIdentifiers(itemCount); | ||
| const jsonlData = items | ||
| .map((item) => JSON.stringify({ id: item.name, identifier: item })) | ||
| .join("\n"); | ||
| const mockStorage = { | ||
| get: (key) => { | ||
| if (key === "index.jsonl") { | ||
| return Promise.resolve(Buffer.from(jsonlData)); | ||
| } | ||
| return Promise.resolve(Buffer.from("")); | ||
| }, | ||
| exists: (key) => { | ||
| if (key === "index.jsonl") return Promise.resolve(true); | ||
| return Promise.resolve(false); | ||
| }, | ||
| put: () => Promise.resolve(), | ||
| }; | ||
| return { mockStorage, items }; | ||
| } | ||
| test( | ||
| "MemoryWindow.build by window size", | ||
| createPerformanceTest({ | ||
| count: [50, 100, 200, 500], | ||
| setupFn: async (windowSize) => { | ||
| const { mockStorage } = createDependencies(windowSize); | ||
| const memoryIndex = new MemoryIndex(mockStorage, "index.jsonl"); | ||
| await memoryIndex.loadData(); | ||
| // Mock resourceIndex for testing | ||
| const mockResourceIndex = { | ||
| get: async (ids) => { | ||
| // Handle conversation lookup | ||
| if (ids[0] === "test-conversation") { | ||
| return [ | ||
| { | ||
| identifier: { name: "test-conversation" }, | ||
| descriptor: { name: "Test Conversation" }, | ||
| type: "common.Conversation", | ||
| agent_id: "test-agent", | ||
| }, | ||
| ]; | ||
| } | ||
| // Handle agent lookup | ||
| if (ids[0] === "test-agent") { | ||
| return [ | ||
| { | ||
| identifier: { name: "test-agent" }, | ||
| descriptor: { name: "Test Agent" }, | ||
| type: "common.Agent", | ||
| instructions: "Test instructions", | ||
| tools: [], // No tools to keep it simple | ||
| }, | ||
| ]; | ||
| } | ||
| // Handle tool function lookups (return empty array if tools requested) | ||
| if (ids[0] && ids[0].startsWith("tool.ToolFunction.")) { | ||
| return []; | ||
| } | ||
| // Handle message lookups (return mock messages based on identifiers) | ||
| return ids.map((id, idx) => ({ | ||
| identifier: { name: id }, | ||
| role: idx % 2 === 0 ? "user" : "assistant", | ||
| content: `Message ${idx}`, | ||
| })); | ||
| }, | ||
| }; | ||
| const memoryWindow = new MemoryWindow( | ||
| "test-conversation", | ||
| mockResourceIndex, | ||
| memoryIndex, | ||
| ); | ||
| const model = "openai/gpt-4.1"; | ||
| const maxTokens = 4096; | ||
| return { memoryWindow, model, maxTokens }; | ||
| }, | ||
| testFn: ({ memoryWindow, model, maxTokens }) => | ||
| memoryWindow.build(model, maxTokens), | ||
| constraints: { | ||
| maxDuration: 120, | ||
| maxMemory: 5000, | ||
| scaling: "linear", | ||
| tolerance: 50, // High tolerance due to small memory footprint causing GC noise | ||
| }, | ||
| }), | ||
| ); | ||
| }); |
| import { describe, it, beforeEach } from "node:test"; | ||
| import assert from "node:assert"; | ||
| import { MemoryWindow } from "../index.js"; | ||
| import { MemoryIndex } from "../index/memory.js"; | ||
| import { resource, common, tool } from "@forwardimpact/libtype"; | ||
| import { | ||
| MockStorage, | ||
| createMockResourceIndex, | ||
| } from "@forwardimpact/libharness"; | ||
| describe("MemoryWindow", () => { | ||
| let storage; | ||
| let memoryIndex; | ||
| let resourceIndex; | ||
| let memoryWindow; | ||
| beforeEach(() => { | ||
| storage = new MockStorage(); | ||
| memoryIndex = new MemoryIndex(storage, "test-conversation.jsonl"); | ||
| resourceIndex = createMockResourceIndex({ | ||
| tools: ["search", "analyze"], | ||
| temperature: 0.5, | ||
| }); | ||
| memoryWindow = new MemoryWindow( | ||
| "test-conversation", | ||
| resourceIndex, | ||
| memoryIndex, | ||
| ); | ||
| }); | ||
| it("should create instance with required parameters", () => { | ||
| assert.throws(() => new MemoryWindow(), /resourceId is required/); | ||
| assert.throws(() => new MemoryWindow("test"), /resourceIndex is required/); | ||
| assert.throws( | ||
| () => new MemoryWindow("test", resourceIndex), | ||
| /memoryIndex is required/, | ||
| ); | ||
| }); | ||
| it("should require model parameter", async () => { | ||
| const msg = resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 15, | ||
| }); | ||
| await memoryIndex.add([msg]); | ||
| await assert.rejects(() => memoryWindow.build(), /model is required/); | ||
| await assert.rejects(() => memoryWindow.build(""), /model is required/); | ||
| }); | ||
| it("should require maxTokens parameter", async () => { | ||
| await assert.rejects( | ||
| () => memoryWindow.build("test-model-1000"), | ||
| /maxTokens is required/, | ||
| ); | ||
| await assert.rejects( | ||
| () => memoryWindow.build("test-model-1000", 0), | ||
| /maxTokens is required/, | ||
| ); | ||
| await assert.rejects( | ||
| () => memoryWindow.build("test-model-1000", -1), | ||
| /maxTokens is required/, | ||
| ); | ||
| }); | ||
| it("should return messages and tools structure", async () => { | ||
| const msg1 = resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 15, | ||
| }); | ||
| // Add message to resource index so it can be loaded | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "msg1", tokens: 15 }, | ||
| role: "user", | ||
| content: "Hello", | ||
| }), | ||
| ); | ||
| await memoryIndex.add([msg1]); | ||
| const result = await memoryWindow.build("test-model-1000", 100); | ||
| assert.ok(result.messages, "Should have messages array"); | ||
| assert.ok(result.tools, "Should have tools array"); | ||
| assert.ok(Array.isArray(result.messages), "messages should be an array"); | ||
| assert.ok(Array.isArray(result.tools), "tools should be an array"); | ||
| }); | ||
| it("should include assistant as first message", async () => { | ||
| const result = await memoryWindow.build("test-model-1000", 100); | ||
| // First message should be the assistant | ||
| assert.ok(result.messages.length >= 1); | ||
| assert.strictEqual(result.messages[0].id?.name, "test-agent"); | ||
| }); | ||
| it("should include tools from assistant configuration", async () => { | ||
| const result = await memoryWindow.build("test-model-1000", 100); | ||
| // Should have 2 tools (search and analyze) | ||
| assert.strictEqual(result.tools.length, 2); | ||
| assert.strictEqual(result.tools[0].function?.name, "search"); | ||
| assert.strictEqual(result.tools[1].function?.name, "analyze"); | ||
| }); | ||
| it("should return all conversation items within budget", async () => { | ||
| const msg1 = resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 15, | ||
| }); | ||
| const msg2 = resource.Identifier.fromObject({ | ||
| name: "msg2", | ||
| type: "common.Message", | ||
| tokens: 20, | ||
| }); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "msg1", tokens: 15 }, | ||
| role: "user", | ||
| content: "Hello", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "msg2", tokens: 20 }, | ||
| role: "assistant", | ||
| content: "Hi there!", | ||
| }), | ||
| ); | ||
| await memoryIndex.add([msg1]); | ||
| await memoryIndex.add([msg2]); | ||
| const result = await memoryWindow.build("test-model-1000", 100); | ||
| // Should have assistant + 2 conversation messages | ||
| assert.strictEqual(result.messages.length, 3); | ||
| assert.strictEqual(result.messages[0].id?.name, "test-agent"); | ||
| assert.strictEqual(result.messages[1].id?.name, "msg1"); | ||
| assert.strictEqual(result.messages[2].id?.name, "msg2"); | ||
| }); | ||
| it("should build window with budget constraint reserving output tokens", async () => { | ||
| const msg1 = resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 15, | ||
| }); | ||
| const msg2 = resource.Identifier.fromObject({ | ||
| name: "msg2", | ||
| type: "common.Message", | ||
| tokens: 25, | ||
| }); | ||
| const msg3 = resource.Identifier.fromObject({ | ||
| name: "msg3", | ||
| type: "common.Message", | ||
| tokens: 10, | ||
| }); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "msg1", tokens: 15 }, | ||
| role: "user", | ||
| content: "First", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "msg2", tokens: 25 }, | ||
| role: "assistant", | ||
| content: "Second", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "msg3", tokens: 10 }, | ||
| role: "user", | ||
| content: "Third", | ||
| }), | ||
| ); | ||
| await memoryIndex.add([msg1]); | ||
| await memoryIndex.add([msg2]); | ||
| await memoryIndex.add([msg3]); | ||
| // Budget of 125: assistant(50) + 2 tools(40) = 90 fixed overhead | ||
| // Reserve 50 tokens for output (maxTokens parameter) | ||
| // historyBudget = 125 - 90 - 50 = -15 (nothing fits) | ||
| // But with maxTokens=15: historyBudget = 125 - 90 - 15 = 20, fits msg3(10) | ||
| const result = await memoryWindow.build("test-model-125", 15); | ||
| // Should be assistant + msg3 only (msg1 and msg2 don't fit) | ||
| assert.strictEqual(result.messages.length, 2); | ||
| assert.strictEqual(result.messages[0].id?.name, "test-agent"); | ||
| assert.strictEqual(result.messages[1].id?.name, "msg3"); | ||
| }); | ||
| it("should drop orphaned tool messages at start of window", async () => { | ||
| // Simulate a conversation where budget cut leaves tool messages without | ||
| // their preceding assistant message | ||
| const msg1 = resource.Identifier.fromObject({ | ||
| name: "assistant-with-tool-calls", | ||
| type: "common.Message", | ||
| tokens: 100, | ||
| }); | ||
| const toolResult1 = resource.Identifier.fromObject({ | ||
| name: "tool-result-1", | ||
| type: "tool.ToolCallMessage", | ||
| tokens: 50, | ||
| }); | ||
| const toolResult2 = resource.Identifier.fromObject({ | ||
| name: "tool-result-2", | ||
| type: "tool.ToolCallMessage", | ||
| tokens: 50, | ||
| }); | ||
| const msg2 = resource.Identifier.fromObject({ | ||
| name: "final-assistant", | ||
| type: "common.Message", | ||
| tokens: 30, | ||
| }); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "assistant-with-tool-calls", tokens: 100 }, | ||
| role: "assistant", | ||
| content: "I'll call tools", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| tool.ToolCallMessage.fromObject({ | ||
| id: { name: "tool-result-1", tokens: 50 }, | ||
| role: "tool", | ||
| content: "Result 1", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| tool.ToolCallMessage.fromObject({ | ||
| id: { name: "tool-result-2", tokens: 50 }, | ||
| role: "tool", | ||
| content: "Result 2", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "final-assistant", tokens: 30 }, | ||
| role: "assistant", | ||
| content: "Final response", | ||
| }), | ||
| ); | ||
| await memoryIndex.add([msg1]); | ||
| await memoryIndex.add([toolResult1]); | ||
| await memoryIndex.add([toolResult2]); | ||
| await memoryIndex.add([msg2]); | ||
| // Budget of 230: assistant(50) + 2 tools(40) = 90 fixed overhead | ||
| // Reserve 50 for output: historyBudget = 230 - 90 - 50 = 90 | ||
| // From newest: msg2(30) + toolResult2(50) = 80, fits | ||
| // But toolResult2 is orphaned (no preceding assistant), so drop it | ||
| // Continue: msg2(30) fits, but now check toolResult1(50) - orphaned, drop | ||
| // Final: just msg2(30) | ||
| const result = await memoryWindow.build("test-model-230", 50); | ||
| // Should only have assistant + final assistant message (tool messages dropped) | ||
| assert.strictEqual(result.messages.length, 2); | ||
| assert.strictEqual(result.messages[0].id?.name, "test-agent"); | ||
| assert.strictEqual(result.messages[1].id?.name, "final-assistant"); | ||
| }); | ||
| it("should keep tool messages when preceded by assistant message", async () => { | ||
| const msg1 = resource.Identifier.fromObject({ | ||
| name: "assistant-with-tool-calls", | ||
| type: "common.Message", | ||
| tokens: 50, | ||
| }); | ||
| const toolResult1 = resource.Identifier.fromObject({ | ||
| name: "tool-result-1", | ||
| type: "tool.ToolCallMessage", | ||
| tokens: 30, | ||
| }); | ||
| const msg2 = resource.Identifier.fromObject({ | ||
| name: "final-assistant", | ||
| type: "common.Message", | ||
| tokens: 20, | ||
| }); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "assistant-with-tool-calls", tokens: 50 }, | ||
| role: "assistant", | ||
| content: "Calling tool", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| tool.ToolCallMessage.fromObject({ | ||
| id: { name: "tool-result-1", tokens: 30 }, | ||
| role: "tool", | ||
| content: "Tool result", | ||
| }), | ||
| ); | ||
| resourceIndex.addMessage( | ||
| common.Message.fromObject({ | ||
| id: { name: "final-assistant", tokens: 20 }, | ||
| role: "assistant", | ||
| content: "Done", | ||
| }), | ||
| ); | ||
| await memoryIndex.add([msg1]); | ||
| await memoryIndex.add([toolResult1]); | ||
| await memoryIndex.add([msg2]); | ||
| // Budget of 300: assistant(50) + tools(40) = 90 fixed overhead | ||
| // Reserve 50 for output: historyBudget = 300 - 90 - 50 = 160 | ||
| // All 3 messages fit (50+30+20=100) | ||
| const result = await memoryWindow.build("test-model-300", 50); | ||
| assert.strictEqual(result.messages.length, 4); | ||
| assert.strictEqual(result.messages[0].id?.name, "test-agent"); | ||
| assert.strictEqual( | ||
| result.messages[1].id?.name, | ||
| "assistant-with-tool-calls", | ||
| ); | ||
| assert.strictEqual(result.messages[2].id?.name, "tool-result-1"); | ||
| assert.strictEqual(result.messages[3].id?.name, "final-assistant"); | ||
| }); | ||
| it("should append identifiers through window", async () => { | ||
| const identifiers = [ | ||
| resource.Identifier.fromObject({ | ||
| name: "msg1", | ||
| type: "common.Message", | ||
| tokens: 10, | ||
| }), | ||
| ]; | ||
| await memoryWindow.append(identifiers); | ||
| const fetchedIdentifiers = await memoryIndex.queryItems(); | ||
| assert.strictEqual(fetchedIdentifiers.length, 1); | ||
| assert.strictEqual(fetchedIdentifiers[0].name, "msg1"); | ||
| }); | ||
| }); |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
23226
-52.88%6
-33.33%294
-72%1
Infinity%