@convex-dev/agent 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +10 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +74 -14
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +726 -622
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/streams.d.ts +6 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +14 -13
- package/dist/component/streams.js.map +1 -1
- package/dist/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +47 -17
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +304 -301
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +22 -9
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +10 -0
- package/src/component/messages.test.ts +404 -0
- package/src/component/messages.ts +94 -12
- package/src/component/streams.ts +20 -15
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +113 -17
- package/src/vercel/client/streamText.ts +52 -28
- package/src/vercel/client/streaming.test.ts +71 -7
- package/src/vercel/client/streaming.ts +27 -7
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- package/src/vercel/index.ts +2 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { APICallError } from "@ai-sdk/provider";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { errorToString } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
describe("errorToString", () => {
|
|
6
|
+
test("preserves provider error classifications", () => {
|
|
7
|
+
const details = {
|
|
8
|
+
error: {
|
|
9
|
+
code: "invalid_prompt",
|
|
10
|
+
message: "Invalid prompt: flagged by policy",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const apiError = new APICallError({
|
|
14
|
+
message: "Invalid prompt: flagged by policy",
|
|
15
|
+
url: "https://api.example.test",
|
|
16
|
+
requestBodyValues: {},
|
|
17
|
+
statusCode: 400,
|
|
18
|
+
data: details,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(errorToString(details)).toBe(
|
|
22
|
+
"invalid_prompt: Invalid prompt: flagged by policy",
|
|
23
|
+
);
|
|
24
|
+
expect(errorToString(apiError)).toBe(
|
|
25
|
+
"invalid_prompt: Invalid prompt: flagged by policy",
|
|
26
|
+
);
|
|
27
|
+
expect(errorToString(new Error())).toBe("Error");
|
|
28
|
+
expect(errorToString(new TypeError())).toBe("TypeError");
|
|
29
|
+
const systemError = Object.assign(new Error("socket hang up"), {
|
|
30
|
+
code: "ECONNRESET",
|
|
31
|
+
});
|
|
32
|
+
expect(errorToString(systemError)).toBe("socket hang up");
|
|
33
|
+
const codeOnly = Object.assign(new Error("Request failed"), {
|
|
34
|
+
data: { code: "rate_limit" },
|
|
35
|
+
});
|
|
36
|
+
expect(errorToString(codeOnly)).toBe("rate_limit: Request failed");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("serializes objects without mistaking shared values for cycles", () => {
|
|
40
|
+
const shared = { detail: "provider disconnected" };
|
|
41
|
+
const circular: Record<string, unknown> = { shared };
|
|
42
|
+
circular.self = circular;
|
|
43
|
+
|
|
44
|
+
expect(errorToString({ x: shared, y: shared })).toBe(
|
|
45
|
+
'{"x":{"detail":"provider disconnected"},"y":{"detail":"provider disconnected"}}',
|
|
46
|
+
);
|
|
47
|
+
expect(errorToString(circular)).toBe(
|
|
48
|
+
'{"shared":{"detail":"provider disconnected"},"self":"[Circular]"}',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("bounds stored error text without splitting surrogate pairs", () => {
|
|
53
|
+
const serialized = errorToString(`${"x".repeat(1022)}😀tail`);
|
|
54
|
+
|
|
55
|
+
expect(serialized.length).toBeLessThanOrEqual(1024);
|
|
56
|
+
expect(serialized.endsWith("x…")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does not throw when Error properties are hostile accessors", () => {
|
|
60
|
+
const error = new Error();
|
|
61
|
+
Object.defineProperties(error, {
|
|
62
|
+
message: {
|
|
63
|
+
get() {
|
|
64
|
+
throw new Error("message getter failed");
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
name: {
|
|
68
|
+
get() {
|
|
69
|
+
throw new Error("name getter failed");
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(errorToString(error)).toBe("Unknown error");
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const MAX_ERROR_LENGTH = 1024;
|
|
2
|
+
|
|
3
|
+
export function errorToString(error: unknown): string {
|
|
4
|
+
return truncateError(describeError(error));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function describeError(error: unknown): string {
|
|
8
|
+
if (typeof error === "string") return error;
|
|
9
|
+
if (error instanceof Error) {
|
|
10
|
+
const message = property(error, "message");
|
|
11
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
12
|
+
const name = property(error, "name");
|
|
13
|
+
return typeof name === "string" && name.length > 0
|
|
14
|
+
? name
|
|
15
|
+
: safeString(error);
|
|
16
|
+
}
|
|
17
|
+
const nested = errorDetails(
|
|
18
|
+
property(error, "error") ?? property(error, "data"),
|
|
19
|
+
);
|
|
20
|
+
return (
|
|
21
|
+
formatDetails({
|
|
22
|
+
message: nested.message ?? message,
|
|
23
|
+
code: nested.code,
|
|
24
|
+
}) ?? message
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const details = formatDetails(errorDetails(error));
|
|
29
|
+
if (details) return details;
|
|
30
|
+
|
|
31
|
+
if (error && typeof error === "object") {
|
|
32
|
+
try {
|
|
33
|
+
const ancestors: object[] = [];
|
|
34
|
+
const serialized = JSON.stringify(error, function (_key, value: unknown) {
|
|
35
|
+
if (typeof value === "bigint") return value.toString();
|
|
36
|
+
if (!value || typeof value !== "object") return value;
|
|
37
|
+
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
|
|
38
|
+
ancestors.pop();
|
|
39
|
+
}
|
|
40
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
41
|
+
ancestors.push(value);
|
|
42
|
+
return value;
|
|
43
|
+
});
|
|
44
|
+
if (serialized) return serialized;
|
|
45
|
+
} catch {
|
|
46
|
+
return safeString(error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return safeString(error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function safeString(error: unknown): string {
|
|
54
|
+
try {
|
|
55
|
+
return String(error);
|
|
56
|
+
} catch {
|
|
57
|
+
return "Unknown error";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function errorDetails(error: unknown): { message?: string; code?: string } {
|
|
62
|
+
let current = error;
|
|
63
|
+
let message: string | undefined;
|
|
64
|
+
let code: string | undefined;
|
|
65
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
66
|
+
if (typeof current === "string") {
|
|
67
|
+
message ??= current;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (!current || typeof current !== "object") break;
|
|
71
|
+
|
|
72
|
+
const currentMessage = property(current, "message");
|
|
73
|
+
if (typeof currentMessage === "string" && currentMessage.length > 0) {
|
|
74
|
+
message ??= currentMessage;
|
|
75
|
+
}
|
|
76
|
+
const currentCode = property(current, "code");
|
|
77
|
+
if (typeof currentCode === "string" || typeof currentCode === "number") {
|
|
78
|
+
code ??= String(currentCode);
|
|
79
|
+
}
|
|
80
|
+
if (message && code) break;
|
|
81
|
+
current = property(current, "error") ?? property(current, "data");
|
|
82
|
+
}
|
|
83
|
+
return { message, code };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function property(value: object, key: string): unknown {
|
|
87
|
+
try {
|
|
88
|
+
return (value as Record<string, unknown>)[key];
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatDetails({
|
|
95
|
+
message,
|
|
96
|
+
code,
|
|
97
|
+
}: {
|
|
98
|
+
message?: string;
|
|
99
|
+
code?: string;
|
|
100
|
+
}): string | undefined {
|
|
101
|
+
if (message && code) {
|
|
102
|
+
return message.startsWith(`${code}:`) ? message : `${code}: ${message}`;
|
|
103
|
+
}
|
|
104
|
+
return message ?? code;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function truncateError(error: string): string {
|
|
108
|
+
if (error.length <= MAX_ERROR_LENGTH) return error;
|
|
109
|
+
let truncated = error.slice(0, MAX_ERROR_LENGTH - 1);
|
|
110
|
+
const last = truncated.charCodeAt(truncated.length - 1);
|
|
111
|
+
if (last >= 0xd800 && last <= 0xdbff) truncated = truncated.slice(0, -1);
|
|
112
|
+
return `${truncated}…`;
|
|
113
|
+
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
createThread,
|
|
5
5
|
createTool,
|
|
6
6
|
filterOutOrphanedToolMessages,
|
|
7
|
+
saveMessage,
|
|
8
|
+
toUIMessages,
|
|
7
9
|
type MessageDoc,
|
|
8
10
|
} from "../index.js";
|
|
9
11
|
import type { DataModelFromSchemaDefinition } from "convex/server";
|
|
@@ -458,6 +460,51 @@ describe("Agent message operations", () => {
|
|
|
458
460
|
expect(messages.length).toBe(2);
|
|
459
461
|
expect(messages[1]._id).toBeDefined();
|
|
460
462
|
});
|
|
463
|
+
|
|
464
|
+
test("saveMessage can place a standalone assistant message on a new order", async () => {
|
|
465
|
+
const t = initConvexTest(schema);
|
|
466
|
+
const threadId = await t.run(async (ctx) =>
|
|
467
|
+
createThread(ctx, components.agent, { userId: "operator-test" }),
|
|
468
|
+
);
|
|
469
|
+
const { message: agentReply } = await t.run(async (ctx) =>
|
|
470
|
+
agent.saveMessage(ctx, {
|
|
471
|
+
threadId,
|
|
472
|
+
message: {
|
|
473
|
+
role: "assistant",
|
|
474
|
+
content: [
|
|
475
|
+
{
|
|
476
|
+
type: "tool-call",
|
|
477
|
+
toolCallId: "handoff-1",
|
|
478
|
+
toolName: "handoff",
|
|
479
|
+
input: {},
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
const { message: operatorReply } = await t.run(async (ctx) =>
|
|
486
|
+
saveMessage(ctx, components.agent, {
|
|
487
|
+
threadId,
|
|
488
|
+
order: "next",
|
|
489
|
+
agentName: "human:Alex",
|
|
490
|
+
message: { role: "assistant", content: "Operator reply" },
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
const uiMessages = toUIMessages([agentReply, operatorReply]);
|
|
494
|
+
|
|
495
|
+
expect(agentReply).toMatchObject({ order: 0, stepOrder: 0 });
|
|
496
|
+
expect(operatorReply).toMatchObject({
|
|
497
|
+
order: 1,
|
|
498
|
+
stepOrder: 0,
|
|
499
|
+
agentName: "human:Alex",
|
|
500
|
+
});
|
|
501
|
+
expect(uiMessages).toHaveLength(2);
|
|
502
|
+
expect(uiMessages[1]).toMatchObject({
|
|
503
|
+
order: 1,
|
|
504
|
+
agentName: "human:Alex",
|
|
505
|
+
text: "Operator reply",
|
|
506
|
+
});
|
|
507
|
+
});
|
|
461
508
|
});
|
|
462
509
|
|
|
463
510
|
describe("Agent text/object generation", () => {
|
|
@@ -11,6 +11,7 @@ import { toUIMessages, type UIMessage } from "../UIMessages.js";
|
|
|
11
11
|
import {
|
|
12
12
|
listMessages,
|
|
13
13
|
saveMessages as saveCanonicalMessages,
|
|
14
|
+
type MessageOrder,
|
|
14
15
|
} from "../../client/messages.js";
|
|
15
16
|
import type {
|
|
16
17
|
AgentComponent,
|
|
@@ -36,6 +37,15 @@ export async function listUIMessages(
|
|
|
36
37
|
export type SaveMessagesArgs = {
|
|
37
38
|
threadId: string;
|
|
38
39
|
userId?: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Save the first message at this order. Pass `"next"` to allocate a new
|
|
42
|
+
* order after the current latest message. If the numeric order already
|
|
43
|
+
* contains messages, the message is appended at the next stepOrder.
|
|
44
|
+
* Numeric orders must be non-negative safe integers less than
|
|
45
|
+
* Number.MAX_SAFE_INTEGER.
|
|
46
|
+
* Cannot be combined with promptMessageId or pendingMessageId.
|
|
47
|
+
*/
|
|
48
|
+
order?: MessageOrder;
|
|
39
49
|
/**
|
|
40
50
|
* The message that these messages are in response to. They will be
|
|
41
51
|
* the same "order" as this message, at increasing stepOrder(s).
|
|
@@ -86,6 +96,7 @@ export async function saveMessages(
|
|
|
86
96
|
userId: args.userId ?? undefined,
|
|
87
97
|
agentName: args.agentName,
|
|
88
98
|
promptMessageId: args.promptMessageId,
|
|
99
|
+
order: args.order,
|
|
89
100
|
pendingMessageId: args.pendingMessageId,
|
|
90
101
|
embeddings: args.embeddings,
|
|
91
102
|
messages: serialized.map(({ message }) => message),
|
|
@@ -105,6 +116,14 @@ export async function saveMessages(
|
|
|
105
116
|
export type SaveMessageArgs = {
|
|
106
117
|
threadId: string;
|
|
107
118
|
userId?: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Save the message at this order. Pass `"next"` to allocate a new order
|
|
121
|
+
* after the current latest message. If the numeric order already contains
|
|
122
|
+
* messages, the message is appended at the next stepOrder. Numeric orders
|
|
123
|
+
* must be non-negative safe integers less than Number.MAX_SAFE_INTEGER.
|
|
124
|
+
* Cannot be combined with promptMessageId or pendingMessageId.
|
|
125
|
+
*/
|
|
126
|
+
order?: MessageOrder;
|
|
108
127
|
/**
|
|
109
128
|
* The message that these messages are in response to. They will be
|
|
110
129
|
* the same "order" as this message, at increasing stepOrder(s).
|
|
@@ -169,6 +188,7 @@ export async function saveMessage(
|
|
|
169
188
|
userId: args.userId ?? undefined,
|
|
170
189
|
agentName: args.agentName,
|
|
171
190
|
promptMessageId: args.promptMessageId,
|
|
191
|
+
order: args.order,
|
|
172
192
|
pendingMessageId: args.pendingMessageId,
|
|
173
193
|
messages:
|
|
174
194
|
args.prompt !== undefined
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
fetchContextMessages,
|
|
21
21
|
filterOutOrphanedToolMessages,
|
|
22
22
|
getPromptArray,
|
|
23
|
+
trimIncompleteOldestOrder,
|
|
23
24
|
} from "./search.js";
|
|
24
25
|
import { components, initConvexTest } from "./setup.test.js";
|
|
25
26
|
import { createThread } from "../../client/threads.js";
|
|
@@ -31,13 +32,14 @@ const createMockMessageDoc = (
|
|
|
31
32
|
role: "user" | "assistant" | "tool" | "system",
|
|
32
33
|
content: any,
|
|
33
34
|
order: number = 1,
|
|
35
|
+
stepOrder: number = 0,
|
|
34
36
|
): MessageDoc => ({
|
|
35
37
|
_id: id,
|
|
36
38
|
_creationTime: Date.now(),
|
|
37
39
|
userId: "test-user",
|
|
38
40
|
threadId: "test-thread",
|
|
39
41
|
order,
|
|
40
|
-
stepOrder
|
|
42
|
+
stepOrder,
|
|
41
43
|
status: "success",
|
|
42
44
|
tool: false,
|
|
43
45
|
message: { role, content },
|
|
@@ -127,6 +129,27 @@ describe("search.ts", () => {
|
|
|
127
129
|
});
|
|
128
130
|
});
|
|
129
131
|
|
|
132
|
+
describe("trimIncompleteOldestOrder", () => {
|
|
133
|
+
it("keeps a window that begins at the start of an order", () => {
|
|
134
|
+
const messages = [
|
|
135
|
+
createMockMessageDoc("user", "user", "Question", 1, 0),
|
|
136
|
+
createMockMessageDoc("assistant", "assistant", "Answer", 1, 1),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
expect(trimIncompleteOldestOrder(messages)).toEqual(messages);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("omits only the incomplete oldest order", () => {
|
|
143
|
+
const messages = [
|
|
144
|
+
createMockMessageDoc("partial", "assistant", "Partial", 1, 2),
|
|
145
|
+
createMockMessageDoc("next-user", "user", "Next question", 2, 0),
|
|
146
|
+
createMockMessageDoc("next-answer", "assistant", "Next answer", 2, 1),
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
expect(trimIncompleteOldestOrder(messages)).toEqual(messages.slice(1));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
130
153
|
describe("filterOutOrphanedToolMessages", () => {
|
|
131
154
|
it("should keep non-tool messages", () => {
|
|
132
155
|
const messages: MessageDoc[] = [
|
|
@@ -442,6 +465,7 @@ describe("search.ts", () => {
|
|
|
442
465
|
expect(result.length).toBe(2);
|
|
443
466
|
expect(result[0]._id).toBe("1"); // Should be reversed back to asc order
|
|
444
467
|
expect(result[1]._id).toBe("2");
|
|
468
|
+
expect(mockCtx.runQuery).toHaveBeenCalledTimes(1);
|
|
445
469
|
});
|
|
446
470
|
|
|
447
471
|
it("should skip recent messages when recentMessages is 0", async () => {
|
|
@@ -560,11 +584,13 @@ describe("search.ts", () => {
|
|
|
560
584
|
_id: "ctx1",
|
|
561
585
|
message: { role: "user", content: "Context message 1" },
|
|
562
586
|
order: 1,
|
|
587
|
+
stepOrder: 0,
|
|
563
588
|
} as MessageDoc,
|
|
564
589
|
{
|
|
565
590
|
_id: "ctx2",
|
|
566
591
|
message: { role: "assistant", content: "Context response 1" },
|
|
567
592
|
order: 2,
|
|
593
|
+
stepOrder: 0,
|
|
568
594
|
} as MessageDoc,
|
|
569
595
|
];
|
|
570
596
|
|
|
@@ -590,6 +616,143 @@ describe("search.ts", () => {
|
|
|
590
616
|
});
|
|
591
617
|
});
|
|
592
618
|
|
|
619
|
+
it("should complete an order truncated by the recent messages limit", async () => {
|
|
620
|
+
const toolCallId = "call_issue_309";
|
|
621
|
+
vi.mocked(mockCtx.runQuery)
|
|
622
|
+
.mockResolvedValueOnce({
|
|
623
|
+
page: [
|
|
624
|
+
createMockMessageDoc(
|
|
625
|
+
"assistant-final",
|
|
626
|
+
"assistant",
|
|
627
|
+
[{ type: "text", text: "The result is 72." }],
|
|
628
|
+
1,
|
|
629
|
+
3,
|
|
630
|
+
),
|
|
631
|
+
createMockMessageDoc(
|
|
632
|
+
"tool-result",
|
|
633
|
+
"tool",
|
|
634
|
+
[
|
|
635
|
+
{
|
|
636
|
+
type: "tool-result",
|
|
637
|
+
toolCallId,
|
|
638
|
+
toolName: "getWeather",
|
|
639
|
+
output: {
|
|
640
|
+
type: "json",
|
|
641
|
+
value: { temperature: 72 },
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
1,
|
|
646
|
+
2,
|
|
647
|
+
),
|
|
648
|
+
createMockMessageDoc(
|
|
649
|
+
"assistant-tool-call",
|
|
650
|
+
"assistant",
|
|
651
|
+
[
|
|
652
|
+
{
|
|
653
|
+
type: "tool-call",
|
|
654
|
+
toolCallId,
|
|
655
|
+
toolName: "getWeather",
|
|
656
|
+
input: { city: "New York" },
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
1,
|
|
660
|
+
1,
|
|
661
|
+
),
|
|
662
|
+
],
|
|
663
|
+
continueCursor: "complete-order",
|
|
664
|
+
isDone: false,
|
|
665
|
+
})
|
|
666
|
+
.mockResolvedValueOnce({
|
|
667
|
+
page: [
|
|
668
|
+
createMockMessageDoc(
|
|
669
|
+
"user",
|
|
670
|
+
"user",
|
|
671
|
+
"What is the weather?",
|
|
672
|
+
1,
|
|
673
|
+
0,
|
|
674
|
+
),
|
|
675
|
+
],
|
|
676
|
+
continueCursor: "done",
|
|
677
|
+
isDone: true,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const result = await fetchContextWithPrompt(
|
|
681
|
+
mockCtx,
|
|
682
|
+
components.agent,
|
|
683
|
+
{
|
|
684
|
+
...baseArgs,
|
|
685
|
+
prompt: "What should I do next?",
|
|
686
|
+
messages: undefined,
|
|
687
|
+
promptMessageId: undefined,
|
|
688
|
+
contextOptions: { recentMessages: 3 },
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
expect(result.messages.map((message) => message.role)).toEqual([
|
|
693
|
+
"user",
|
|
694
|
+
"assistant",
|
|
695
|
+
"tool",
|
|
696
|
+
"assistant",
|
|
697
|
+
"user",
|
|
698
|
+
]);
|
|
699
|
+
expect(mockCtx.runQuery).toHaveBeenNthCalledWith(2, expect.anything(), {
|
|
700
|
+
threadId: "thread123",
|
|
701
|
+
excludeToolMessages: undefined,
|
|
702
|
+
paginationOpts: { numItems: 1, cursor: "complete-order" },
|
|
703
|
+
upToAndIncludingMessageId: undefined,
|
|
704
|
+
order: "desc",
|
|
705
|
+
statuses: ["success"],
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("should omit an order when its boundary cannot be fetched", async () => {
|
|
710
|
+
vi.mocked(mockCtx.runQuery)
|
|
711
|
+
.mockResolvedValueOnce({
|
|
712
|
+
page: [
|
|
713
|
+
createMockMessageDoc(
|
|
714
|
+
"assistant-final",
|
|
715
|
+
"assistant",
|
|
716
|
+
"Partial answer",
|
|
717
|
+
1,
|
|
718
|
+
2,
|
|
719
|
+
),
|
|
720
|
+
],
|
|
721
|
+
continueCursor: "complete-order",
|
|
722
|
+
isDone: false,
|
|
723
|
+
})
|
|
724
|
+
.mockResolvedValueOnce({
|
|
725
|
+
page: [
|
|
726
|
+
createMockMessageDoc(
|
|
727
|
+
"assistant-step",
|
|
728
|
+
"assistant",
|
|
729
|
+
"Still partial",
|
|
730
|
+
1,
|
|
731
|
+
1,
|
|
732
|
+
),
|
|
733
|
+
],
|
|
734
|
+
continueCursor: "done",
|
|
735
|
+
isDone: true,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const result = await fetchContextWithPrompt(
|
|
739
|
+
mockCtx,
|
|
740
|
+
components.agent,
|
|
741
|
+
{
|
|
742
|
+
...baseArgs,
|
|
743
|
+
prompt: "New prompt",
|
|
744
|
+
messages: undefined,
|
|
745
|
+
promptMessageId: undefined,
|
|
746
|
+
contextOptions: { recentMessages: 1 },
|
|
747
|
+
},
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
expect(result.messages).toEqual([
|
|
751
|
+
{ role: "user", content: "New prompt" },
|
|
752
|
+
]);
|
|
753
|
+
expect(mockCtx.runQuery).toHaveBeenCalledTimes(2);
|
|
754
|
+
});
|
|
755
|
+
|
|
593
756
|
it("should handle input messages correctly", async () => {
|
|
594
757
|
const inputMessages: ModelMessage[] = [
|
|
595
758
|
{ role: "user", content: "Input message 1" },
|
|
@@ -618,16 +781,19 @@ describe("search.ts", () => {
|
|
|
618
781
|
_id: "msg1",
|
|
619
782
|
message: { role: "user", content: "Before prompt" },
|
|
620
783
|
order: 1,
|
|
784
|
+
stepOrder: 0,
|
|
621
785
|
} as MessageDoc,
|
|
622
786
|
{
|
|
623
787
|
_id: "prompt-msg",
|
|
624
788
|
message: { role: "user", content: "Original prompt" },
|
|
625
789
|
order: 2,
|
|
790
|
+
stepOrder: 0,
|
|
626
791
|
} as MessageDoc,
|
|
627
792
|
{
|
|
628
793
|
_id: "msg3",
|
|
629
794
|
message: { role: "assistant", content: "After prompt" },
|
|
630
795
|
order: 3,
|
|
796
|
+
stepOrder: 0,
|
|
631
797
|
} as MessageDoc,
|
|
632
798
|
];
|
|
633
799
|
|
|
@@ -659,16 +825,19 @@ describe("search.ts", () => {
|
|
|
659
825
|
_id: "msg1",
|
|
660
826
|
message: { role: "user", content: "Before prompt" },
|
|
661
827
|
order: 1,
|
|
828
|
+
stepOrder: 0,
|
|
662
829
|
} as MessageDoc,
|
|
663
830
|
{
|
|
664
831
|
_id: "prompt-msg",
|
|
665
832
|
message: { role: "user", content: "Original prompt" },
|
|
666
833
|
order: 2,
|
|
834
|
+
stepOrder: 0,
|
|
667
835
|
} as MessageDoc,
|
|
668
836
|
{
|
|
669
837
|
_id: "msg3",
|
|
670
838
|
message: { role: "assistant", content: "After prompt" },
|
|
671
839
|
order: 3,
|
|
840
|
+
stepOrder: 0,
|
|
672
841
|
} as MessageDoc,
|
|
673
842
|
];
|
|
674
843
|
|
|
@@ -696,16 +865,19 @@ describe("search.ts", () => {
|
|
|
696
865
|
_id: "ctx1",
|
|
697
866
|
message: { role: "user", content: "Context 1" },
|
|
698
867
|
order: 1,
|
|
868
|
+
stepOrder: 0,
|
|
699
869
|
} as MessageDoc,
|
|
700
870
|
{
|
|
701
871
|
_id: "prompt-msg",
|
|
702
872
|
message: { role: "user", content: "Prompt" },
|
|
703
873
|
order: 3,
|
|
874
|
+
stepOrder: 0,
|
|
704
875
|
} as MessageDoc,
|
|
705
876
|
{
|
|
706
877
|
_id: "ctx2",
|
|
707
878
|
message: { role: "assistant", content: "Context 2" },
|
|
708
879
|
order: 5,
|
|
880
|
+
stepOrder: 0,
|
|
709
881
|
} as MessageDoc,
|
|
710
882
|
];
|
|
711
883
|
|
|
@@ -864,10 +1036,10 @@ describe("search.ts", () => {
|
|
|
864
1036
|
});
|
|
865
1037
|
});
|
|
866
1038
|
|
|
867
|
-
it("should
|
|
1039
|
+
it("should extend recentMessages to complete an order", async () => {
|
|
868
1040
|
const threadId = await createTestThread("user999");
|
|
869
1041
|
|
|
870
|
-
//
|
|
1042
|
+
// Start with a two-message window that splits the previous order.
|
|
871
1043
|
await createTestMessages(threadId, "user999", [
|
|
872
1044
|
{ role: "user", content: "Message 1", order: 1 },
|
|
873
1045
|
{ role: "assistant", content: "Response 1", order: 2 },
|
|
@@ -883,13 +1055,16 @@ describe("search.ts", () => {
|
|
|
883
1055
|
prompt: "New prompt",
|
|
884
1056
|
messages: undefined,
|
|
885
1057
|
promptMessageId: undefined,
|
|
886
|
-
contextOptions: { recentMessages: 2 },
|
|
1058
|
+
contextOptions: { recentMessages: 2 },
|
|
887
1059
|
});
|
|
888
1060
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
expect(result.messages
|
|
892
|
-
expect(result.messages[
|
|
1061
|
+
// The limit splits the previous order after its user message, so the
|
|
1062
|
+
// context extends backward to include the whole order.
|
|
1063
|
+
expect(result.messages).toHaveLength(4);
|
|
1064
|
+
expect(result.messages[0].content).toBe("Message 2");
|
|
1065
|
+
expect(result.messages[1].content).toBe("Response 2");
|
|
1066
|
+
expect(result.messages[2].content).toBe("Message 3");
|
|
1067
|
+
expect(result.messages[3]).toEqual({
|
|
893
1068
|
role: "user",
|
|
894
1069
|
content: "New prompt",
|
|
895
1070
|
});
|
|
@@ -36,10 +36,24 @@ import {
|
|
|
36
36
|
} from "../mapping.js";
|
|
37
37
|
|
|
38
38
|
const DEFAULT_VECTOR_SCORE_THRESHOLD = 0.0;
|
|
39
|
+
// Bound the rare boundary-extension query; incomplete orders are trimmed below.
|
|
40
|
+
const MAX_ORDER_COMPLETION_MESSAGES = 1_000;
|
|
39
41
|
// 10k characters should be more than enough for most cases, and stays under
|
|
40
42
|
// the 8k token limit for some models.
|
|
41
43
|
const MAX_EMBEDDING_TEXT_LENGTH = 10_000;
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Omit the oldest order when a descending pagination window split it.
|
|
47
|
+
* Expects the messages to be sorted in ascending order.
|
|
48
|
+
*/
|
|
49
|
+
export function trimIncompleteOldestOrder(docs: MessageDoc[]) {
|
|
50
|
+
const oldest = docs[0];
|
|
51
|
+
if (!oldest || oldest.stepOrder === 0) {
|
|
52
|
+
return docs;
|
|
53
|
+
}
|
|
54
|
+
return docs.filter((doc) => doc.order !== oldest.order);
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
export type GetEmbedding = (text: string) => Promise<
|
|
44
58
|
| {
|
|
45
59
|
embedding: number[];
|
|
@@ -133,25 +147,45 @@ export async function fetchRecentAndSearchMessages(
|
|
|
133
147
|
let included: Set<string> | undefined;
|
|
134
148
|
let recentMessages: MessageDoc[] = [];
|
|
135
149
|
let searchMessages: MessageDoc[] = [];
|
|
150
|
+
const threadId = args.threadId;
|
|
136
151
|
const targetMessageId =
|
|
137
152
|
args.targetMessageId ?? args.upToAndIncludingMessageId;
|
|
138
|
-
if (
|
|
139
|
-
const
|
|
140
|
-
component.messages.listMessagesByThreadId,
|
|
141
|
-
|
|
142
|
-
threadId: args.threadId,
|
|
153
|
+
if (threadId && opts.recentMessages !== 0) {
|
|
154
|
+
const fetchRecentPage = (numItems: number, cursor: string | null) =>
|
|
155
|
+
ctx.runQuery(component.messages.listMessagesByThreadId, {
|
|
156
|
+
threadId,
|
|
143
157
|
excludeToolMessages: opts.excludeToolMessages,
|
|
144
|
-
paginationOpts: {
|
|
145
|
-
numItems: opts.recentMessages ?? DEFAULT_RECENT_MESSAGES,
|
|
146
|
-
cursor: null,
|
|
147
|
-
},
|
|
158
|
+
paginationOpts: { numItems, cursor },
|
|
148
159
|
upToAndIncludingMessageId: targetMessageId,
|
|
149
160
|
order: "desc",
|
|
150
161
|
statuses: ["success"],
|
|
151
|
-
}
|
|
162
|
+
});
|
|
163
|
+
const firstPage = await fetchRecentPage(
|
|
164
|
+
opts.recentMessages ?? DEFAULT_RECENT_MESSAGES,
|
|
165
|
+
null,
|
|
152
166
|
);
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
let page = firstPage.page;
|
|
168
|
+
const oldest = page.at(-1);
|
|
169
|
+
if (
|
|
170
|
+
oldest &&
|
|
171
|
+
oldest.stepOrder > 0 &&
|
|
172
|
+
!firstPage.isDone &&
|
|
173
|
+
firstPage.continueCursor
|
|
174
|
+
) {
|
|
175
|
+
const completionPage = await fetchRecentPage(
|
|
176
|
+
Math.min(oldest.stepOrder, MAX_ORDER_COMPLETION_MESSAGES),
|
|
177
|
+
firstPage.continueCursor,
|
|
178
|
+
);
|
|
179
|
+
page = [
|
|
180
|
+
...page,
|
|
181
|
+
...completionPage.page.filter(
|
|
182
|
+
(message) => message.order === oldest.order,
|
|
183
|
+
),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
const retained = trimIncompleteOldestOrder(sorted(page));
|
|
187
|
+
included = new Set(retained.map((m) => m._id));
|
|
188
|
+
recentMessages = filterOutOrphanedToolMessages(retained);
|
|
155
189
|
}
|
|
156
190
|
if (
|
|
157
191
|
(opts.searchOptions?.textSearch || opts.searchOptions?.vectorSearch) &&
|