Skip to content

Commit 6965a2c

Browse files
authored
feat(memory): native Voyage AI support (#7078)
* feat(memory): add native Voyage AI embedding support with batching Cherry-picked from PR #2519, resolved conflict in memory-search.ts (hasRemote -> hasRemoteConfig rename + added voyage provider) * fix(memory): optimize voyage batch memory usage with streaming and deduplicate code Cherry-picked from PR #2519. Fixed lint error: changed this.runWithConcurrency to use imported runWithConcurrency function after extraction to internal.ts
1 parent e3d3893 commit 6965a2c

File tree

11 files changed

+879
-58
lines changed

11 files changed

+879
-58
lines changed

src/agents/memory-search.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export type ResolvedMemorySearchConfig = {
99
enabled: boolean;
1010
sources: Array<"memory" | "sessions">;
1111
extraPaths: string[];
12-
provider: "openai" | "local" | "gemini" | "auto";
12+
provider: "openai" | "local" | "gemini" | "voyage" | "auto";
1313
remote?: {
1414
baseUrl?: string;
1515
apiKey?: string;
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
2525
experimental: {
2626
sessionMemory: boolean;
2727
};
28-
fallback: "openai" | "gemini" | "local" | "none";
28+
fallback: "openai" | "gemini" | "local" | "voyage" | "none";
2929
model: string;
3030
local: {
3131
modelPath?: string;
@@ -72,6 +72,7 @@ export type ResolvedMemorySearchConfig = {
7272

7373
const DEFAULT_OPENAI_MODEL = "text-embedding-3-small";
7474
const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
75+
const DEFAULT_VOYAGE_MODEL = "voyage-4-large";
7576
const DEFAULT_CHUNK_TOKENS = 400;
7677
const DEFAULT_CHUNK_OVERLAP = 80;
7778
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
@@ -136,7 +137,11 @@ function mergeConfig(
136137
defaultRemote?.headers,
137138
);
138139
const includeRemote =
139-
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
140+
hasRemoteConfig ||
141+
provider === "openai" ||
142+
provider === "gemini" ||
143+
provider === "voyage" ||
144+
provider === "auto";
140145
const batch = {
141146
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
142147
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
@@ -163,7 +168,9 @@ function mergeConfig(
163168
? DEFAULT_GEMINI_MODEL
164169
: provider === "openai"
165170
? DEFAULT_OPENAI_MODEL
166-
: undefined;
171+
: provider === "voyage"
172+
? DEFAULT_VOYAGE_MODEL
173+
: undefined;
167174
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
168175
const local = {
169176
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,

src/config/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,8 @@ const FIELD_HELP: Record<string, string> = {
542542
"Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).",
543543
"agents.defaults.memorySearch.experimental.sessionMemory":
544544
"Enable experimental session transcript indexing for memory search (default: false).",
545-
"agents.defaults.memorySearch.provider": 'Embedding provider ("openai", "gemini", or "local").',
545+
"agents.defaults.memorySearch.provider":
546+
'Embedding provider ("openai", "gemini", "voyage", or "local").',
546547
"agents.defaults.memorySearch.remote.baseUrl":
547548
"Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).",
548549
"agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.",

src/config/types.tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export type MemorySearchConfig = {
234234
sessionMemory?: boolean;
235235
};
236236
/** Embedding provider mode. */
237-
provider?: "openai" | "gemini" | "local";
237+
provider?: "openai" | "gemini" | "local" | "voyage";
238238
remote?: {
239239
baseUrl?: string;
240240
apiKey?: string;
@@ -253,7 +253,7 @@ export type MemorySearchConfig = {
253253
};
254254
};
255255
/** Fallback behavior when embeddings fail. */
256-
fallback?: "openai" | "gemini" | "local" | "none";
256+
fallback?: "openai" | "gemini" | "local" | "voyage" | "none";
257257
/** Embedding model id (remote) or alias (local). */
258258
model?: string;
259259
/** Local embedding settings (node-llama-cpp). */

src/config/zod-schema.agent-runtime.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,9 @@ export const MemorySearchSchema = z
318318
})
319319
.strict()
320320
.optional(),
321-
provider: z.union([z.literal("openai"), z.literal("local"), z.literal("gemini")]).optional(),
321+
provider: z
322+
.union([z.literal("openai"), z.literal("local"), z.literal("gemini"), z.literal("voyage")])
323+
.optional(),
322324
remote: z
323325
.object({
324326
baseUrl: z.string().optional(),
@@ -338,7 +340,13 @@ export const MemorySearchSchema = z
338340
.strict()
339341
.optional(),
340342
fallback: z
341-
.union([z.literal("openai"), z.literal("gemini"), z.literal("local"), z.literal("none")])
343+
.union([
344+
z.literal("openai"),
345+
z.literal("gemini"),
346+
z.literal("local"),
347+
z.literal("voyage"),
348+
z.literal("none"),
349+
])
342350
.optional(),
343351
model: z.string().optional(),
344352
local: z

src/memory/batch-voyage.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { ReadableStream } from "node:stream/web";
3+
import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js";
4+
import type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
5+
6+
// Mock internal.js if needed, but runWithConcurrency is simple enough to keep real.
7+
// We DO need to mock retryAsync to avoid actual delays/retries logic complicating tests
8+
vi.mock("../infra/retry.js", () => ({
9+
retryAsync: async <T>(fn: () => Promise<T>) => fn(),
10+
}));
11+
12+
describe("runVoyageEmbeddingBatches", () => {
13+
afterEach(() => {
14+
vi.resetAllMocks();
15+
vi.unstubAllGlobals();
16+
});
17+
18+
const mockClient: VoyageEmbeddingClient = {
19+
baseUrl: "https://api.voyageai.com/v1",
20+
headers: { Authorization: "Bearer test-key" },
21+
model: "voyage-4-large",
22+
};
23+
24+
const mockRequests: VoyageBatchRequest[] = [
25+
{ custom_id: "req-1", body: { input: "text1" } },
26+
{ custom_id: "req-2", body: { input: "text2" } },
27+
];
28+
29+
it("successfully submits batch, waits, and streams results", async () => {
30+
const fetchMock = vi.fn();
31+
vi.stubGlobal("fetch", fetchMock);
32+
33+
// Sequence of fetch calls:
34+
// 1. Upload file
35+
fetchMock.mockResolvedValueOnce({
36+
ok: true,
37+
json: async () => ({ id: "file-123" }),
38+
});
39+
40+
// 2. Create batch
41+
fetchMock.mockResolvedValueOnce({
42+
ok: true,
43+
json: async () => ({ id: "batch-abc", status: "pending" }),
44+
});
45+
46+
// 3. Poll status (pending) - Optional depending on wait loop, let's say it finishes immediately for this test
47+
// Actually the code does: initial check (if completed) -> wait loop.
48+
// If create returns "pending", it enters waitForVoyageBatch.
49+
// waitForVoyageBatch fetches status.
50+
51+
// 3. Poll status (completed)
52+
fetchMock.mockResolvedValueOnce({
53+
ok: true,
54+
json: async () => ({
55+
id: "batch-abc",
56+
status: "completed",
57+
output_file_id: "file-out-999",
58+
}),
59+
});
60+
61+
// 4. Download content (Streaming)
62+
const outputLines: VoyageBatchOutputLine[] = [
63+
{
64+
custom_id: "req-1",
65+
response: { status_code: 200, body: { data: [{ embedding: [0.1, 0.1] }] } },
66+
},
67+
{
68+
custom_id: "req-2",
69+
response: { status_code: 200, body: { data: [{ embedding: [0.2, 0.2] }] } },
70+
},
71+
];
72+
73+
// Create a stream that emits the NDJSON lines
74+
const stream = new ReadableStream({
75+
start(controller) {
76+
const text = outputLines.map((l) => JSON.stringify(l)).join("\n");
77+
controller.enqueue(new TextEncoder().encode(text));
78+
controller.close();
79+
},
80+
});
81+
82+
fetchMock.mockResolvedValueOnce({
83+
ok: true,
84+
body: stream,
85+
});
86+
87+
const { runVoyageEmbeddingBatches } = await import("./batch-voyage.js");
88+
89+
const results = await runVoyageEmbeddingBatches({
90+
client: mockClient,
91+
agentId: "agent-1",
92+
requests: mockRequests,
93+
wait: true,
94+
pollIntervalMs: 1, // fast poll
95+
timeoutMs: 1000,
96+
concurrency: 1,
97+
});
98+
99+
expect(results.size).toBe(2);
100+
expect(results.get("req-1")).toEqual([0.1, 0.1]);
101+
expect(results.get("req-2")).toEqual([0.2, 0.2]);
102+
103+
// Verify calls
104+
expect(fetchMock).toHaveBeenCalledTimes(4);
105+
106+
// Verify File Upload
107+
expect(fetchMock.mock.calls[0][0]).toContain("/files");
108+
const uploadBody = fetchMock.mock.calls[0][1].body as FormData;
109+
expect(uploadBody).toBeInstanceOf(FormData);
110+
expect(uploadBody.get("purpose")).toBe("batch");
111+
112+
// Verify Batch Create
113+
expect(fetchMock.mock.calls[1][0]).toContain("/batches");
114+
const createBody = JSON.parse(fetchMock.mock.calls[1][1].body);
115+
expect(createBody.input_file_id).toBe("file-123");
116+
expect(createBody.completion_window).toBe("12h");
117+
118+
// Verify Content Fetch
119+
expect(fetchMock.mock.calls[3][0]).toContain("/files/file-out-999/content");
120+
});
121+
122+
it("handles empty lines and stream chunks correctly", async () => {
123+
const fetchMock = vi.fn();
124+
vi.stubGlobal("fetch", fetchMock);
125+
126+
// 1. Upload
127+
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "f1" }) });
128+
// 2. Create (completed immediately)
129+
fetchMock.mockResolvedValueOnce({
130+
ok: true,
131+
json: async () => ({ id: "b1", status: "completed", output_file_id: "out1" }),
132+
});
133+
// 3. Download Content (Streaming with chunks and newlines)
134+
const stream = new ReadableStream({
135+
start(controller) {
136+
const line1 = JSON.stringify({
137+
custom_id: "req-1",
138+
response: { body: { data: [{ embedding: [1] }] } },
139+
});
140+
const line2 = JSON.stringify({
141+
custom_id: "req-2",
142+
response: { body: { data: [{ embedding: [2] }] } },
143+
});
144+
145+
// Split across chunks
146+
controller.enqueue(new TextEncoder().encode(line1 + "\n"));
147+
controller.enqueue(new TextEncoder().encode("\n")); // empty line
148+
controller.enqueue(new TextEncoder().encode(line2)); // no newline at EOF
149+
controller.close();
150+
},
151+
});
152+
153+
fetchMock.mockResolvedValueOnce({ ok: true, body: stream });
154+
155+
const { runVoyageEmbeddingBatches } = await import("./batch-voyage.js");
156+
157+
const results = await runVoyageEmbeddingBatches({
158+
client: mockClient,
159+
agentId: "a1",
160+
requests: mockRequests,
161+
wait: true,
162+
pollIntervalMs: 1,
163+
timeoutMs: 1000,
164+
concurrency: 1,
165+
});
166+
167+
expect(results.get("req-1")).toEqual([1]);
168+
expect(results.get("req-2")).toEqual([2]);
169+
});
170+
});

0 commit comments

Comments
 (0)