mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-27 06:15:15 +00:00
fix(tools): keep model-supplied containerTag inside the configured scope (2.3.1) (#1824)
Co-authored-by: abhinav7x94 <raj.abhinav7x94@gmail.com> Co-authored-by: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Co-authored-by: Atharvsinh-codez <at41rv@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
d0f53b0d64
commit
e5c6c27344
7 changed files with 308 additions and 19 deletions
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch --ignore-watch .turbo",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest --testTimeout 100000",
|
||||
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts",
|
||||
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/document-delete-scope.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts",
|
||||
"test:watch": "vitest --watch --testTimeout 100000"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
clampSearchLimit,
|
||||
deleteDocumentByIdentifier,
|
||||
getContainerTags,
|
||||
resolveConfiguredContainerTag,
|
||||
} from "./tools-shared"
|
||||
import { forgetMemoryRequest } from "./shared/forget-memory"
|
||||
import type { SupermemoryToolsConfig } from "./types"
|
||||
|
|
@ -146,7 +147,7 @@ export const getProfileTool = (
|
|||
}),
|
||||
execute: async ({ containerTag, query }) => {
|
||||
try {
|
||||
const tag = containerTag || containerTags[0]
|
||||
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||
|
||||
const response = await client.profile({
|
||||
containerTag: tag,
|
||||
|
|
@ -199,7 +200,7 @@ export const documentListTool = (
|
|||
execute: async ({ containerTag, limit, page }) => {
|
||||
try {
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
? [resolveConfiguredContainerTag(containerTags, containerTag)]
|
||||
: containerTags
|
||||
|
||||
const response = await client.documents.list({
|
||||
|
|
@ -248,7 +249,7 @@ export const documentDeleteTool = (
|
|||
execute: async ({ documentId, containerTag }) => {
|
||||
try {
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
? [resolveConfiguredContainerTag(containerTags, containerTag)]
|
||||
: containerTags
|
||||
await deleteDocumentByIdentifier(client, documentId, scopeTags)
|
||||
|
||||
|
|
@ -339,12 +340,12 @@ export const memoryForgetTool = (
|
|||
}
|
||||
}
|
||||
|
||||
const tag = containerTag || containerTags[0]
|
||||
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||
|
||||
await forgetMemoryRequest(
|
||||
apiKey,
|
||||
{
|
||||
containerTag: tag as string,
|
||||
containerTag: tag,
|
||||
...(memoryId && { id: memoryId }),
|
||||
...(memoryContent && { content: memoryContent }),
|
||||
...(reason && { reason }),
|
||||
|
|
|
|||
139
packages/tools/src/document-delete-scope.test.ts
Normal file
139
packages/tools/src/document-delete-scope.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const documentsDeleteBulk = vi.fn()
|
||||
const documentsGet = vi.fn()
|
||||
const documentsList = vi.fn()
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
documents = {
|
||||
deleteBulk: documentsDeleteBulk,
|
||||
get: documentsGet,
|
||||
list: documentsList,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import * as aiSdk from "./ai-sdk"
|
||||
import * as openAi from "./openai/tools"
|
||||
|
||||
const API_KEY = "sm_test_key"
|
||||
|
||||
type ToolExecutionResult = { success: boolean; error?: string }
|
||||
type ToolWithExecute = {
|
||||
execute: (args: Record<string, unknown>) => Promise<ToolExecutionResult>
|
||||
}
|
||||
|
||||
function executeTool(
|
||||
tool: unknown,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolExecutionResult> {
|
||||
return (tool as ToolWithExecute).execute(args)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
documentsDeleteBulk.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
deletedCount: 1,
|
||||
errors: [],
|
||||
})
|
||||
documentsGet.mockReset()
|
||||
documentsList.mockReset().mockResolvedValue({
|
||||
memories: [],
|
||||
pagination: { currentPage: 1, totalItems: 0, totalPages: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
describe("documentDelete configured container scope", () => {
|
||||
it("ai-sdk refuses a model-supplied tag outside the configured scope", async () => {
|
||||
documentsGet.mockResolvedValue({
|
||||
id: "doc_in_tenant_b",
|
||||
customId: "doc_in_tenant_b",
|
||||
containerTags: ["tenant-b"],
|
||||
})
|
||||
|
||||
const result = await executeTool(
|
||||
aiSdk.documentDeleteTool(API_KEY, { containerTags: ["tenant-a"] }),
|
||||
{ documentId: "doc_in_tenant_b", containerTag: "tenant-b" },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("outside the configured scope")
|
||||
expect(documentsGet).not.toHaveBeenCalled()
|
||||
expect(documentsList).not.toHaveBeenCalled()
|
||||
expect(documentsDeleteBulk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("openai refuses a model-supplied tag outside the configured scope", async () => {
|
||||
documentsGet.mockResolvedValue({
|
||||
id: "doc_in_tenant_b",
|
||||
customId: "doc_in_tenant_b",
|
||||
containerTags: ["tenant-b"],
|
||||
})
|
||||
|
||||
const documentDelete = openAi.createDocumentDeleteFunction(API_KEY, {
|
||||
containerTags: ["tenant-a"],
|
||||
})
|
||||
const result = await documentDelete({
|
||||
documentId: "doc_in_tenant_b",
|
||||
containerTag: "tenant-b",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("outside the configured scope")
|
||||
expect(documentsGet).not.toHaveBeenCalled()
|
||||
expect(documentsList).not.toHaveBeenCalled()
|
||||
expect(documentsDeleteBulk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects tags outside an implicit project scope", async () => {
|
||||
const result = await executeTool(
|
||||
aiSdk.documentDeleteTool(API_KEY, { projectId: "alpha" }),
|
||||
{ documentId: "doc_1", containerTag: "tenant-b" },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("outside the configured scope")
|
||||
expect(documentsDeleteBulk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("allows selecting another explicitly configured tag", async () => {
|
||||
documentsGet.mockResolvedValue({
|
||||
id: "doc_in_tenant_b",
|
||||
customId: "doc_in_tenant_b",
|
||||
containerTags: ["tenant-b"],
|
||||
})
|
||||
|
||||
const result = await executeTool(
|
||||
aiSdk.documentDeleteTool(API_KEY, {
|
||||
containerTags: ["tenant-a", "tenant-b"],
|
||||
}),
|
||||
{ documentId: "doc_in_tenant_b", containerTag: "tenant-b" },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({
|
||||
ids: ["doc_in_tenant_b"],
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps the configured union when no tag is supplied", async () => {
|
||||
documentsGet.mockResolvedValue({
|
||||
id: "doc_in_tenant_a",
|
||||
customId: "doc_in_tenant_a",
|
||||
containerTags: ["tenant-a"],
|
||||
})
|
||||
|
||||
const result = await executeTool(
|
||||
aiSdk.documentDeleteTool(API_KEY, { containerTags: ["tenant-a"] }),
|
||||
{ documentId: "doc_in_tenant_a" },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({
|
||||
ids: ["doc_in_tenant_a"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
clampSearchLimit,
|
||||
deleteDocumentByIdentifier,
|
||||
getContainerTags,
|
||||
resolveConfiguredContainerTag,
|
||||
} from "../tools-shared"
|
||||
import { forgetMemoryRequest } from "../shared/forget-memory"
|
||||
import type { SupermemoryToolsConfig } from "../types"
|
||||
|
|
@ -333,7 +334,7 @@ export function createGetProfileFunction(
|
|||
query?: string
|
||||
}): Promise<ProfileResult> {
|
||||
try {
|
||||
const tag = containerTag || containerTags[0]
|
||||
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||
|
||||
const response = await client.profile({
|
||||
containerTag: tag,
|
||||
|
|
@ -374,7 +375,7 @@ export function createDocumentListFunction(
|
|||
}): Promise<DocumentListResult> {
|
||||
try {
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
? [resolveConfiguredContainerTag(containerTags, containerTag)]
|
||||
: containerTags
|
||||
|
||||
const response = await client.documents.list({
|
||||
|
|
@ -415,7 +416,7 @@ export function createDocumentDeleteFunction(
|
|||
}): Promise<DocumentDeleteResult> {
|
||||
try {
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
? [resolveConfiguredContainerTag(containerTags, containerTag)]
|
||||
: containerTags
|
||||
await deleteDocumentByIdentifier(client, documentId, scopeTags)
|
||||
|
||||
|
|
@ -502,12 +503,12 @@ export function createMemoryForgetFunction(
|
|||
}
|
||||
}
|
||||
|
||||
const tag = containerTag || containerTags[0]
|
||||
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||
|
||||
await forgetMemoryRequest(
|
||||
apiKey,
|
||||
{
|
||||
containerTag: tag as string,
|
||||
containerTag: tag,
|
||||
...(memoryId && { id: memoryId }),
|
||||
...(memoryContent && { content: memoryContent }),
|
||||
...(reason && { reason }),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||
const documentsDeleteBulk = vi.fn()
|
||||
const documentsGet = vi.fn()
|
||||
const documentsList = vi.fn()
|
||||
const profileRequest = vi.fn()
|
||||
const clientAdd = vi.fn()
|
||||
const clientSearch = vi.fn()
|
||||
const clientOptions: unknown[] = []
|
||||
|
|
@ -15,6 +16,7 @@ vi.mock("supermemory", () => {
|
|||
constructor(options: unknown) {
|
||||
clientOptions.push(options)
|
||||
}
|
||||
profile = profileRequest
|
||||
add = clientAdd
|
||||
search = clientSearch
|
||||
documents = {
|
||||
|
|
@ -34,9 +36,15 @@ import * as openAi from "./openai/tools"
|
|||
|
||||
const API_KEY = "sm_test_key"
|
||||
|
||||
type ToolWithExecute = { execute: (args: Record<string, unknown>) => unknown }
|
||||
type ToolExecutionResult = { success: boolean; error?: string }
|
||||
type ToolWithExecute = {
|
||||
execute: (args: Record<string, unknown>) => Promise<ToolExecutionResult>
|
||||
}
|
||||
|
||||
function executeTool(tool: unknown, args: Record<string, unknown>) {
|
||||
function executeTool(
|
||||
tool: unknown,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolExecutionResult> {
|
||||
return (tool as ToolWithExecute).execute(args)
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +63,10 @@ beforeEach(() => {
|
|||
memories: [{ id: "doc_1", title: "Doc one" }],
|
||||
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
|
||||
})
|
||||
profileRequest.mockReset().mockResolvedValue({
|
||||
profile: { static: [], dynamic: [] },
|
||||
searchResults: { results: [] },
|
||||
})
|
||||
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
|
||||
clientSearch.mockReset().mockResolvedValue({ results: [] })
|
||||
clientOptions.length = 0
|
||||
|
|
@ -112,12 +124,96 @@ describe("searchMemories", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("configured container scope", () => {
|
||||
it("rejects out-of-scope tags across both tool surfaces before I/O", async () => {
|
||||
const config = { containerTags: ["tenant-a"] }
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
|
||||
const results: ToolExecutionResult[] = await Promise.all([
|
||||
executeTool(aiSdk.getProfileTool(API_KEY, config), {
|
||||
containerTag: "tenant-b",
|
||||
}),
|
||||
openAi.createGetProfileFunction(
|
||||
API_KEY,
|
||||
config,
|
||||
)({
|
||||
containerTag: "tenant-b",
|
||||
}),
|
||||
executeTool(aiSdk.documentListTool(API_KEY, config), {
|
||||
containerTag: "tenant-b",
|
||||
}),
|
||||
openAi.createDocumentListFunction(
|
||||
API_KEY,
|
||||
config,
|
||||
)({
|
||||
containerTag: "tenant-b",
|
||||
}),
|
||||
executeTool(aiSdk.memoryForgetTool(API_KEY, config), {
|
||||
containerTag: "tenant-b",
|
||||
memoryId: "mem_1",
|
||||
}),
|
||||
openAi.createMemoryForgetFunction(
|
||||
API_KEY,
|
||||
config,
|
||||
)({
|
||||
containerTag: "tenant-b",
|
||||
memoryId: "mem_1",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(results).toHaveLength(6)
|
||||
for (const result of results) {
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("outside the configured scope")
|
||||
}
|
||||
expect(profileRequest).not.toHaveBeenCalled()
|
||||
expect(documentsList).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("allows selecting another explicitly configured tag", async () => {
|
||||
const getProfile = openAi.createGetProfileFunction(API_KEY, {
|
||||
containerTags: ["tenant-a", "tenant-b"],
|
||||
})
|
||||
|
||||
const result = await getProfile({ containerTag: "tenant-b" })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(profileRequest).toHaveBeenCalledWith({
|
||||
containerTag: "tenant-b",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not let model input override implicit or project scopes", async () => {
|
||||
const implicitResult = await openAi.createDocumentListFunction(API_KEY)({
|
||||
containerTag: "tenant-b",
|
||||
})
|
||||
const projectResult = await executeTool(
|
||||
aiSdk.getProfileTool(API_KEY, { projectId: "alpha" }),
|
||||
{ containerTag: "tenant-b" },
|
||||
)
|
||||
|
||||
expect(implicitResult.success).toBe(false)
|
||||
expect(implicitResult.error).toContain("outside the configured scope")
|
||||
expect(projectResult.success).toBe(false)
|
||||
expect(projectResult.error).toContain("outside the configured scope")
|
||||
expect(documentsList).not.toHaveBeenCalled()
|
||||
expect(profileRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("fails closed when the configured scope is empty", () => {
|
||||
expect(() =>
|
||||
openAi.createGetProfileFunction(API_KEY, { containerTags: [] }),
|
||||
).toThrow("at least one non-empty containerTag")
|
||||
expect(profileRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("documentDelete", () => {
|
||||
it("ai-sdk variant passes the document id string to the SDK", async () => {
|
||||
const tool = aiSdk.documentDeleteTool(API_KEY)
|
||||
const result = (await executeTool(tool, { documentId: "doc_123" })) as {
|
||||
success: boolean
|
||||
}
|
||||
const result = await executeTool(tool, { documentId: "doc_123" })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(documentsGet).toHaveBeenCalledWith("doc_123")
|
||||
|
|
@ -209,9 +305,9 @@ describe("memoryForget", () => {
|
|||
containerTags: ["user_2"],
|
||||
})
|
||||
|
||||
const result = (await executeTool(tool, {
|
||||
const result = await executeTool(tool, {
|
||||
memoryContent: "stale fact",
|
||||
})) as { success: boolean }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
clampSearchLimit,
|
||||
deduplicateMemoriesForMode,
|
||||
getContainerTags,
|
||||
resolveConfiguredContainerTag,
|
||||
} from "./tools-shared"
|
||||
|
||||
describe("clampSearchLimit", () => {
|
||||
|
|
@ -58,6 +59,32 @@ describe("getContainerTags", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("resolveConfiguredContainerTag", () => {
|
||||
it("defaults to the first configured tag", () => {
|
||||
expect(resolveConfiguredContainerTag(["tenant-a", "tenant-b"])).toBe(
|
||||
"tenant-a",
|
||||
)
|
||||
})
|
||||
|
||||
it("allows selection within a multi-tag scope", () => {
|
||||
expect(
|
||||
resolveConfiguredContainerTag(["tenant-a", "tenant-b"], "tenant-b"),
|
||||
).toBe("tenant-b")
|
||||
})
|
||||
|
||||
it("rejects tags outside the configured scope", () => {
|
||||
expect(() =>
|
||||
resolveConfiguredContainerTag(["tenant-a"], "tenant-b"),
|
||||
).toThrow('Container tag "tenant-b" is outside the configured scope')
|
||||
})
|
||||
|
||||
it("rejects an empty configured scope", () => {
|
||||
expect(() => resolveConfiguredContainerTag([])).toThrow(
|
||||
"require at least one configured container tag",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deduplicateMemoriesForMode", () => {
|
||||
// The profile is not injected in "query" mode, so a memory that is both a
|
||||
// profile fact and a search hit must survive in the search results —
|
||||
|
|
|
|||
|
|
@ -125,6 +125,31 @@ export function getContainerTags(config?: {
|
|||
return [...CONTAINER_TAG_CONSTANTS.defaultTags]
|
||||
}
|
||||
|
||||
// A model-supplied tag may only pick one of the developer-configured tags, never widen the scope.
|
||||
export function resolveConfiguredContainerTag(
|
||||
configuredTags: readonly string[],
|
||||
requestedTag?: string,
|
||||
): string {
|
||||
const defaultTag = configuredTags[0]
|
||||
if (defaultTag === undefined) {
|
||||
throw new Error(
|
||||
"Supermemory tools require at least one configured container tag.",
|
||||
)
|
||||
}
|
||||
|
||||
if (!requestedTag) {
|
||||
return defaultTag
|
||||
}
|
||||
|
||||
if (!configuredTags.includes(requestedTag)) {
|
||||
throw new Error(
|
||||
`Container tag "${requestedTag}" is outside the configured scope.`,
|
||||
)
|
||||
}
|
||||
|
||||
return requestedTag
|
||||
}
|
||||
|
||||
/** Delete exactly one document by its internal ID. */
|
||||
export async function deleteDocumentById(
|
||||
client: Supermemory,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue