tools: timeout and default option on error to skip (#875)
Some checks failed
Publish Tools / publish (push) Has been cancelled

**`withSupermemory`** **(AI SDK)**

- **`skipMemoryOnError`** **defaults to** **`true`**. memory errors/timeouts log and the model runs on the **original** prompt unless you set `skipMemoryOnError: false`.
- **Pre-LLM** **`/v4/profile`** **is aborted after 5s** via `AbortSigna`

**Docs**

- `packages/tools/README.md`, **`apps/docs/integrations/ai-sdk.md`**
This commit is contained in:
MaheshtheDev 2026-04-23 00:11:20 +00:00
parent 5e3033d484
commit 23a60f90f6
8 changed files with 199 additions and 23 deletions

View file

@ -106,15 +106,15 @@ const model = withSupermemory(openai("gpt-4"), "user-123", {
// Console output shows memory retrieval details
```
### When Supermemory errors (optional: continue without memories)
### When Supermemory errors (default: continue without memories)
If the Supermemory API returns an error (or is unreachable), memory retrieval fails before the LLM runs. By default that error **propagates** (fails the call).
If the Supermemory API returns an error, is unreachable, or retrieval hits the internal time limit, memory injection is skipped. **`skipMemoryOnError` defaults to `true`**, so the LLM call still runs with the **original** prompt (no injected memories). Use `verbose: true` if you want console output when that happens.
To continue the LLM request **without** injected memories instead, opt in with `skipMemoryOnError: true`. Use `verbose: true` if you want console output when that happens.
To **fail the call** when memory retrieval fails instead, set `skipMemoryOnError: false`:
```typescript
const model = withSupermemory(openai("gpt-5"), "user-123", {
skipMemoryOnError: true
skipMemoryOnError: false
})
```

View file

@ -656,6 +656,9 @@ interface WithSupermemoryOptions {
addMemory?: "always" | "never"
/** Optional Supermemory API key. Use this in browser environments. */
apiKey?: string
baseUrl?: string
promptTemplate?: (data: MemoryPromptData) => string
skipMemoryOnError?: boolean
}
```
@ -663,6 +666,7 @@ interface WithSupermemoryOptions {
- **verbose**: Enable detailed logging of memory search and injection process (default: false)
- **mode**: Memory search mode - "profile" (default), "query", or "full"
- **addMemory**: Automatic memory storage mode - "always" or "never" (default: "never")
- **skipMemoryOnError**: If memory retrieval fails or hits the internal timeout, continue with the original prompt (default: true)
## Available Tools

View file

@ -1,7 +1,7 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "1.4.5",
"version": "1.4.6",
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
"scripts": {
"build": "tsdown",

View file

@ -18,6 +18,7 @@ import {
* @param queryText - Optional query text for semantic search
* @param baseUrl - The API base URL
* @param apiKey - The API key for authentication
* @param signal - Optional AbortSignal to cancel the request (e.g. retrieval timeout)
* @returns The profile structure with static, dynamic, and search results
*/
export const supermemoryProfileSearch = async (
@ -25,6 +26,7 @@ export const supermemoryProfileSearch = async (
queryText: string,
baseUrl: string,
apiKey: string,
signal?: AbortSignal,
): Promise<ProfileStructure> => {
const payload = queryText
? JSON.stringify({
@ -43,6 +45,7 @@ export const supermemoryProfileSearch = async (
Authorization: `Bearer ${apiKey}`,
},
body: payload,
...(signal ? { signal } : {}),
})
if (!response.ok) {
@ -72,6 +75,7 @@ export interface BuildMemoriesTextOptions {
apiKey: string
logger: Logger
promptTemplate?: PromptTemplate
signal?: AbortSignal
}
/**
@ -92,6 +96,7 @@ export const buildMemoriesText = async (
apiKey,
logger,
promptTemplate = defaultPromptTemplate,
signal,
} = options
const memoriesResponse = await supermemoryProfileSearch(
@ -99,6 +104,7 @@ export const buildMemoriesText = async (
queryText,
baseUrl,
apiKey,
signal,
)
const memoryCountStatic = memoriesResponse.profile.static?.length || 0

View file

@ -12,6 +12,8 @@ import {
} from "./middleware"
import type { PromptTemplate, MemoryPromptData } from "./memory-prompt"
const DEFAULT_MEMORY_RETRIEVAL_TIMEOUT_MS = 5000
interface WrapVercelLanguageModelOptions {
/** Optional conversation ID to group messages for contextual memory generation */
conversationId?: string
@ -51,9 +53,9 @@ interface WrapVercelLanguageModelOptions {
*/
promptTemplate?: PromptTemplate
/**
* When Supermemory memory retrieval / injection fails:
* - `false` (default): propagate the error.
* - `true`: log and call the base model with the original prompt (no memories).
* When Supermemory memory retrieval / injection fails or times out:
* - `true` (default): log and call the base model with the original prompt (no memories).
* - `false`: propagate the error (fail closed on memory).
*/
skipMemoryOnError?: boolean
}
@ -64,7 +66,8 @@ interface WrapVercelLanguageModelOptions {
*
* This wrapper searches the supermemory API for relevant memories using the container tag
* and user message, then either appends memories to an existing system prompt or creates
* a new system prompt with the memories.
* a new system prompt with the memories. Pre-LLM profile retrieval uses a fixed internal
* time budget and cannot be configured via options.
*
* Supports both Vercel AI SDK 5 (LanguageModelV2) and SDK 6 (LanguageModelV3) via runtime
* detection of `model.specificationVersion`.
@ -78,7 +81,7 @@ interface WrapVercelLanguageModelOptions {
* @param options.addMemory - Optional mode for memory search: "always", "never" (default: "never")
* @param options.apiKey - Optional Supermemory API key to use instead of the environment variable
* @param options.baseUrl - Optional base URL for the Supermemory API (default: "https://api.supermemory.ai")
* @param options.skipMemoryOnError - When memory retrieval fails: `false` (default) throws; `true` continues without injected memories
* @param options.skipMemoryOnError - When memory retrieval fails or times out: `true` (default) continues without injected memories; `false` throws
*
* @returns A wrapped language model that automatically includes relevant memories in prompts
*
@ -100,7 +103,7 @@ interface WrapVercelLanguageModelOptions {
* ```
*
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
* @throws {Error} When supermemory memory retrieval fails unless `skipMemoryOnError` is `true`
* @throws {Error} When supermemory memory retrieval fails and `skipMemoryOnError` is `false`
*/
const wrapVercelLanguageModel = <T extends LanguageModel>(
model: T,
@ -124,9 +127,10 @@ const wrapVercelLanguageModel = <T extends LanguageModel>(
addMemory: options?.addMemory ?? "never",
baseUrl: options?.baseUrl,
promptTemplate: options?.promptTemplate,
memoryRetrievalTimeoutMs: DEFAULT_MEMORY_RETRIEVAL_TIMEOUT_MS,
})
const skipMemoryOnError = options?.skipMemoryOnError ?? false
const skipMemoryOnError = options?.skipMemoryOnError ?? true
// Proxy keeps prototype/getter fields (e.g. provider, modelId) that `{ ...model }` drops.
return new Proxy(model, {

View file

@ -188,6 +188,8 @@ interface SupermemoryMiddlewareOptions {
baseUrl?: string
/** Custom function to format memory data into the system prompt */
promptTemplate?: PromptTemplate
/** Max wait (ms) for the pre-LLM `/v4/profile` retrieval. Omit for no limit (e.g. tests). `withSupermemory` sets this internally. */
memoryRetrievalTimeoutMs?: number
}
interface SupermemoryMiddlewareContext {
@ -200,6 +202,7 @@ interface SupermemoryMiddlewareContext {
normalizedBaseUrl: string
apiKey: string
promptTemplate?: PromptTemplate
memoryRetrievalTimeoutMs?: number
/**
* Per-turn memory cache. Stores the injected memories string for each
* user turn (keyed by turnKey) to avoid redundant API calls during tool-call
@ -219,6 +222,7 @@ export const createSupermemoryContext = (
addMemory = "never",
baseUrl,
promptTemplate,
memoryRetrievalTimeoutMs,
} = options
const logger = createLogger(verbose)
@ -241,6 +245,9 @@ export const createSupermemoryContext = (
normalizedBaseUrl,
apiKey,
promptTemplate,
...(memoryRetrievalTimeoutMs !== undefined
? { memoryRetrievalTimeoutMs }
: {}),
memoryCache: new MemoryCache<string>(),
}
}
@ -304,15 +311,32 @@ export const transformParamsWithMemory = async (
const queryText = extractQueryText(params, ctx.mode)
const memories = await buildMemoriesText({
containerTag: ctx.containerTag,
queryText,
mode: ctx.mode,
baseUrl: ctx.normalizedBaseUrl,
apiKey: ctx.apiKey,
logger: ctx.logger,
promptTemplate: ctx.promptTemplate,
})
let fetchSignal: AbortSignal | undefined
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutMs = ctx.memoryRetrievalTimeoutMs
if (timeoutMs !== undefined && timeoutMs > 0) {
const controller = new AbortController()
fetchSignal = controller.signal
timeoutId = setTimeout(() => controller.abort(), timeoutMs)
}
let memories: string
try {
memories = await buildMemoriesText({
containerTag: ctx.containerTag,
queryText,
mode: ctx.mode,
baseUrl: ctx.normalizedBaseUrl,
apiKey: ctx.apiKey,
logger: ctx.logger,
promptTemplate: ctx.promptTemplate,
...(fetchSignal ? { signal: fetchSignal } : {}),
})
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
ctx.memoryCache.set(turnKey, memories)
ctx.logger.debug("Cached memories for turn", { turnKey })

View file

@ -578,7 +578,8 @@ describe.skipIf(!shouldRunIntegration)(
})
it("should handle invalid API key gracefully", async () => {
const { model } = createIntegrationMockModel()
const { model, getCapturedGenerateParams } =
createIntegrationMockModel()
const wrapped = withSupermemory(
model,
@ -589,12 +590,38 @@ describe.skipIf(!shouldRunIntegration)(
},
)
await wrapped.doGenerate({
prompt: [
{
role: "user",
content: [{ type: "text", text: "Invalid key test" }],
},
],
})
const captured = getCapturedGenerateParams()
expect(captured?.prompt[0]?.role).toBe("user")
})
it("should reject on invalid API key when skipMemoryOnError is false", async () => {
const { model } = createIntegrationMockModel()
const wrapped = withSupermemory(
model,
INTEGRATION_CONFIG.containerTag,
{
apiKey: "invalid-api-key-12345",
mode: "profile",
skipMemoryOnError: false,
},
)
await expect(
wrapped.doGenerate({
prompt: [
{
role: "user",
content: [{ type: "text", text: "Invalid key test" }],
content: [{ type: "text", text: "Invalid key strict test" }],
},
],
}),

View file

@ -383,4 +383,115 @@ describe("Unit: withSupermemory", () => {
).toBe("Last")
})
})
describe("Wrapper retrieval resilience", () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
process.env.SUPERMEMORY_API_KEY = "test-key"
fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof fetch
vi.clearAllMocks()
})
it("continues without memories when profile fetch fails (default skip)", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: () => Promise.resolve("err"),
})
const inner = createMockLanguageModel()
vi.mocked(inner.doGenerate).mockResolvedValue({
content: [{ type: "text", text: "ok" }],
finishReason: "stop",
usage: {
inputTokens: 1,
outputTokens: 1,
},
rawCall: { rawPrompt: [], rawSettings: {} },
warnings: [],
})
const wrapped = withSupermemory(inner, TEST_CONFIG.containerTag, {
apiKey: "k",
})
const params: LanguageModelV2CallOptions = {
prompt: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
}
await wrapped.doGenerate(params)
expect(inner.doGenerate).toHaveBeenCalledWith(params)
})
it("throws when skipMemoryOnError is false and profile fetch fails", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: () => Promise.resolve("err"),
})
const inner = createMockLanguageModel()
const wrapped = withSupermemory(inner, TEST_CONFIG.containerTag, {
apiKey: "k",
skipMemoryOnError: false,
})
await expect(
wrapped.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
}),
).rejects.toThrow("Supermemory profile search failed")
})
it("aborts slow profile fetch after internal timeout and continues by default", async () => {
fetchMock.mockImplementation((_url: string, init?: RequestInit) => {
return new Promise((_resolve, reject) => {
const sig = init?.signal
if (!sig) return
if (sig.aborted) {
reject(new DOMException("Aborted", "AbortError"))
return
}
sig.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"))
})
})
})
const inner = createMockLanguageModel()
vi.mocked(inner.doGenerate).mockResolvedValue({
content: [{ type: "text", text: "ok" }],
finishReason: "stop",
usage: {
inputTokens: 1,
outputTokens: 1,
},
rawCall: { rawPrompt: [], rawSettings: {} },
warnings: [],
})
const wrapped = withSupermemory(inner, TEST_CONFIG.containerTag, {
apiKey: "k",
})
vi.useFakeTimers()
try {
const params: LanguageModelV2CallOptions = {
prompt: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
}
const genPromise = wrapped.doGenerate(params)
await vi.advanceTimersByTimeAsync(5000)
await genPromise
expect(inner.doGenerate).toHaveBeenCalledWith(params)
} finally {
vi.useRealTimers()
}
})
})
})