@vellumai/assistant 0.10.2-dev.202606242332.3fa9b2b → 0.10.2-dev.202606250106.466483e
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/package.json +1 -1
- package/src/__tests__/conversation-agent-loop.test.ts +129 -2
- package/src/__tests__/plugin-pipeline.test.ts +96 -0
- package/src/api/events/conversation-notice.ts +26 -0
- package/src/api/index.ts +7 -0
- package/src/config/feature-flag-registry.json +4 -4
- package/src/daemon/conversation-agent-loop.ts +19 -0
- package/src/daemon/conversation-notices.ts +60 -0
- package/src/daemon/message-types/conversations.ts +2 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +33 -3
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +48 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +4 -8
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +43 -15
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +48 -12
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +4 -10
- package/src/plugins/pipeline.ts +111 -13
package/package.json
CHANGED
|
@@ -10,8 +10,14 @@ import {
|
|
|
10
10
|
} from "bun:test";
|
|
11
11
|
|
|
12
12
|
import type { LoopToolExecutor } from "../agent/loop.js";
|
|
13
|
+
import {
|
|
14
|
+
queueConversationNotice,
|
|
15
|
+
resetConversationNoticesForTests,
|
|
16
|
+
} from "../daemon/conversation-notices.js";
|
|
13
17
|
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
18
|
+
import type { UserPromptSubmitContext } from "../plugin-api/types.js";
|
|
14
19
|
import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
|
|
20
|
+
import { registerPlugin } from "../plugins/registry.js";
|
|
15
21
|
import type { Message, Provider, ToolDefinition } from "../providers/types.js";
|
|
16
22
|
import { ContextOverflowError } from "../providers/types.js";
|
|
17
23
|
|
|
@@ -273,8 +279,8 @@ const deleteMessageByIdMock = mock(() => ({
|
|
|
273
279
|
const reserveMessageMock = mock(async () => ({ id: "msg-reserve" }));
|
|
274
280
|
const updateMessageContentMock = mock(() => {});
|
|
275
281
|
mock.module("../memory/conversation-crud.js", () => ({
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
setConversationProcessingStartedAt: () => {},
|
|
283
|
+
isConversationProcessing: () => false,
|
|
278
284
|
setConversationOriginChannelIfUnset: () => {},
|
|
279
285
|
updateConversationUsage: () => {},
|
|
280
286
|
updateMessageMetadata: updateMessageMetadataMock,
|
|
@@ -867,7 +873,14 @@ beforeEach(() => {
|
|
|
867
873
|
indexMessageNowMock.mockClear();
|
|
868
874
|
projectAssistantMessageMock.mockClear();
|
|
869
875
|
publishSyncInvalidationMock.mockClear();
|
|
876
|
+
resolveAssistantAttachmentsMock.mockClear();
|
|
877
|
+
resolveAssistantAttachmentsMock.mockImplementation(async () => ({
|
|
878
|
+
assistantAttachments: [],
|
|
879
|
+
emittedAttachments: [],
|
|
880
|
+
directiveWarnings: [],
|
|
881
|
+
}));
|
|
870
882
|
mockMessageById = null;
|
|
883
|
+
resetConversationNoticesForTests();
|
|
871
884
|
// The compaction pipeline runs through the plugin registry; reset and
|
|
872
885
|
// re-register every default so it dispatches to middleware backed by the
|
|
873
886
|
// mocked collaborators these tests install (`syncMessageToDisk`, etc.)
|
|
@@ -876,6 +889,120 @@ beforeEach(() => {
|
|
|
876
889
|
});
|
|
877
890
|
|
|
878
891
|
describe("session-agent-loop", () => {
|
|
892
|
+
describe("user-prompt-submit hook failures", () => {
|
|
893
|
+
test("logs and continues with prior hook mutations", async () => {
|
|
894
|
+
registerPlugin({
|
|
895
|
+
manifest: {
|
|
896
|
+
name: "test-user-prompt-rewrite",
|
|
897
|
+
version: "1.0.0",
|
|
898
|
+
},
|
|
899
|
+
hooks: {
|
|
900
|
+
"user-prompt-submit": async (_ctx: UserPromptSubmitContext) => ({
|
|
901
|
+
latestMessages: [
|
|
902
|
+
{
|
|
903
|
+
role: "user" as const,
|
|
904
|
+
content: [{ type: "text" as const, text: "rewritten prompt" }],
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
}),
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
registerPlugin({
|
|
911
|
+
manifest: {
|
|
912
|
+
name: "test-user-prompt-throw",
|
|
913
|
+
version: "1.0.0",
|
|
914
|
+
},
|
|
915
|
+
hooks: {
|
|
916
|
+
"user-prompt-submit": async () => {
|
|
917
|
+
throw new Error("simulated hook failure");
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const events: ServerMessage[] = [];
|
|
923
|
+
const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
|
|
924
|
+
const runSpy = spyOn(ctx.agentLoop, "run");
|
|
925
|
+
|
|
926
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
|
|
927
|
+
|
|
928
|
+
expect(runSpy).toHaveBeenCalledTimes(1);
|
|
929
|
+
const call = runSpy.mock.calls[0]?.[0] as
|
|
930
|
+
| { messages: Message[] }
|
|
931
|
+
| undefined;
|
|
932
|
+
expect(call?.messages[0]?.content).toEqual([
|
|
933
|
+
{ type: "text", text: "rewritten prompt" },
|
|
934
|
+
]);
|
|
935
|
+
expect(
|
|
936
|
+
events.find((event) => event.type === "conversation_error"),
|
|
937
|
+
).toBeUndefined();
|
|
938
|
+
expect(
|
|
939
|
+
events.find((event) => event.type === "message_complete"),
|
|
940
|
+
).toBeDefined();
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
describe("conversation notices", () => {
|
|
945
|
+
test("emits queued billing notices after a successful turn", async () => {
|
|
946
|
+
const events: ServerMessage[] = [];
|
|
947
|
+
const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
|
|
948
|
+
queueConversationNotice(ctx.conversationId, "memory-v3-test", {
|
|
949
|
+
source: "memory_v3",
|
|
950
|
+
code: "PROVIDER_BILLING",
|
|
951
|
+
userMessage: "You've run out of credits.",
|
|
952
|
+
errorCategory: "credits_exhausted",
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
|
|
956
|
+
|
|
957
|
+
expect(
|
|
958
|
+
events.find((event) => event.type === "conversation_error"),
|
|
959
|
+
).toBeUndefined();
|
|
960
|
+
const messageCompleteIndex = events.findIndex(
|
|
961
|
+
(event) => event.type === "message_complete",
|
|
962
|
+
);
|
|
963
|
+
const conversationNoticeIndex = events.findIndex(
|
|
964
|
+
(event) => event.type === "conversation_notice",
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
expect(messageCompleteIndex).toBeGreaterThanOrEqual(0);
|
|
968
|
+
expect(conversationNoticeIndex).toBeGreaterThan(messageCompleteIndex);
|
|
969
|
+
expect(events[conversationNoticeIndex]).toEqual({
|
|
970
|
+
type: "conversation_notice",
|
|
971
|
+
conversationId: "test-conv",
|
|
972
|
+
source: "memory_v3",
|
|
973
|
+
code: "PROVIDER_BILLING",
|
|
974
|
+
userMessage: "You've run out of credits.",
|
|
975
|
+
errorCategory: "credits_exhausted",
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("clears queued notices when post-loop success work fails", async () => {
|
|
980
|
+
resolveAssistantAttachmentsMock.mockImplementation(async () => {
|
|
981
|
+
throw new Error("attachment resolution failed");
|
|
982
|
+
});
|
|
983
|
+
const events: ServerMessage[] = [];
|
|
984
|
+
const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
|
|
985
|
+
queueConversationNotice(ctx.conversationId, "memory-v3-test", {
|
|
986
|
+
source: "memory_v3",
|
|
987
|
+
code: "PROVIDER_BILLING",
|
|
988
|
+
userMessage: "You've run out of credits.",
|
|
989
|
+
errorCategory: "credits_exhausted",
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
|
|
993
|
+
|
|
994
|
+
expect(
|
|
995
|
+
events.find((event) => event.type === "conversation_notice"),
|
|
996
|
+
).toBeUndefined();
|
|
997
|
+
expect(
|
|
998
|
+
events.find((event) => event.type === "message_complete"),
|
|
999
|
+
).toBeUndefined();
|
|
1000
|
+
expect(
|
|
1001
|
+
events.find((event) => event.type === "conversation_error"),
|
|
1002
|
+
).toBeDefined();
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
|
|
879
1006
|
describe("timezone turn context", () => {
|
|
880
1007
|
test("passes ctx.clientTimezone and ui.detectedTimezone into timezone resolution", async () => {
|
|
881
1008
|
mockUiConfig = {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { runHook } from "../plugins/pipeline.js";
|
|
4
|
+
import {
|
|
5
|
+
registerPlugin,
|
|
6
|
+
resetPluginRegistryForTests,
|
|
7
|
+
} from "../plugins/registry.js";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
resetPluginRegistryForTests();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("plugin pipeline", () => {
|
|
14
|
+
test("logs and skips failed hooks while preserving threaded mutations", async () => {
|
|
15
|
+
registerPlugin({
|
|
16
|
+
manifest: {
|
|
17
|
+
name: "test-first-hook",
|
|
18
|
+
version: "1.0.0",
|
|
19
|
+
},
|
|
20
|
+
hooks: {
|
|
21
|
+
"user-prompt-submit": async () => ({
|
|
22
|
+
value: 1,
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
registerPlugin({
|
|
27
|
+
manifest: {
|
|
28
|
+
name: "test-throwing-hook",
|
|
29
|
+
version: "1.0.0",
|
|
30
|
+
},
|
|
31
|
+
hooks: {
|
|
32
|
+
"user-prompt-submit": async () => {
|
|
33
|
+
throw new Error("hook failed");
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
registerPlugin({
|
|
38
|
+
manifest: {
|
|
39
|
+
name: "test-final-hook",
|
|
40
|
+
version: "1.0.0",
|
|
41
|
+
},
|
|
42
|
+
hooks: {
|
|
43
|
+
"user-prompt-submit": async (ctx: { value: number }) => ({
|
|
44
|
+
value: ctx.value + 1,
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = await runHook("user-prompt-submit", { value: 0 });
|
|
50
|
+
|
|
51
|
+
expect(result).toEqual({ value: 2 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("discards in-place mutations from a failed hook", async () => {
|
|
55
|
+
registerPlugin({
|
|
56
|
+
manifest: {
|
|
57
|
+
name: "test-first-hook",
|
|
58
|
+
version: "1.0.0",
|
|
59
|
+
},
|
|
60
|
+
hooks: {
|
|
61
|
+
"user-prompt-submit": async (ctx: { items: string[] }) => {
|
|
62
|
+
ctx.items.push("first");
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
registerPlugin({
|
|
67
|
+
manifest: {
|
|
68
|
+
name: "test-throwing-hook",
|
|
69
|
+
version: "1.0.0",
|
|
70
|
+
},
|
|
71
|
+
hooks: {
|
|
72
|
+
"user-prompt-submit": async (ctx: { items: string[] }) => {
|
|
73
|
+
ctx.items.push("failed");
|
|
74
|
+
throw new Error("hook failed");
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
registerPlugin({
|
|
79
|
+
manifest: {
|
|
80
|
+
name: "test-final-hook",
|
|
81
|
+
version: "1.0.0",
|
|
82
|
+
},
|
|
83
|
+
hooks: {
|
|
84
|
+
"user-prompt-submit": async (ctx: { items: string[] }) => {
|
|
85
|
+
ctx.items.push("final");
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = await runHook<{ items: string[] }>("user-prompt-submit", {
|
|
91
|
+
items: [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.items).toEqual(["first", "final"]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `conversation_notice` SSE event.
|
|
3
|
+
*
|
|
4
|
+
* Non-terminal, conversation-scoped notice for actionable runtime conditions
|
|
5
|
+
* that should not mark the turn as failed. The client may render CTA UI from
|
|
6
|
+
* this event while preserving the current assistant response.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { ConversationErrorCodeSchema } from "./conversation-error.js";
|
|
12
|
+
|
|
13
|
+
export const ConversationNoticeSourceSchema = z.enum(["memory_v3"]);
|
|
14
|
+
|
|
15
|
+
export const ConversationNoticeEventSchema = z.object({
|
|
16
|
+
type: z.literal("conversation_notice"),
|
|
17
|
+
conversationId: z.string(),
|
|
18
|
+
source: ConversationNoticeSourceSchema,
|
|
19
|
+
code: ConversationErrorCodeSchema,
|
|
20
|
+
userMessage: z.string(),
|
|
21
|
+
errorCategory: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type ConversationNoticeEvent = z.infer<
|
|
25
|
+
typeof ConversationNoticeEventSchema
|
|
26
|
+
>;
|
package/src/api/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ConfirmationRequestEventSchema } from "./events/confirmation-request.js
|
|
|
11
11
|
import { ContactRequestEventSchema } from "./events/contact-request.js";
|
|
12
12
|
import { ConversationErrorEventSchema } from "./events/conversation-error.js";
|
|
13
13
|
import { ConversationListInvalidatedEventSchema } from "./events/conversation-list-invalidated.js";
|
|
14
|
+
import { ConversationNoticeEventSchema } from "./events/conversation-notice.js";
|
|
14
15
|
import { ConversationTitleUpdatedEventSchema } from "./events/conversation-title-updated.js";
|
|
15
16
|
import { DiskPressureStatusChangedEventSchema } from "./events/disk-pressure-status-changed.js";
|
|
16
17
|
import { DocumentCommentCreatedEventSchema } from "./events/document-comment-created.js";
|
|
@@ -138,6 +139,11 @@ export {
|
|
|
138
139
|
type ConversationListInvalidatedReason,
|
|
139
140
|
ConversationListInvalidatedReasonSchema,
|
|
140
141
|
} from "./events/conversation-list-invalidated.js";
|
|
142
|
+
export {
|
|
143
|
+
type ConversationNoticeEvent,
|
|
144
|
+
ConversationNoticeEventSchema,
|
|
145
|
+
ConversationNoticeSourceSchema,
|
|
146
|
+
} from "./events/conversation-notice.js";
|
|
141
147
|
export {
|
|
142
148
|
type ConversationTitleUpdatedEvent,
|
|
143
149
|
ConversationTitleUpdatedEventSchema,
|
|
@@ -509,6 +515,7 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
|
|
|
509
515
|
ContactRequestEventSchema,
|
|
510
516
|
ConversationErrorEventSchema,
|
|
511
517
|
ConversationListInvalidatedEventSchema,
|
|
518
|
+
ConversationNoticeEventSchema,
|
|
512
519
|
ConversationTitleUpdatedEventSchema,
|
|
513
520
|
DiskPressureStatusChangedEventSchema,
|
|
514
521
|
DocumentCommentCreatedEventSchema,
|
|
@@ -403,11 +403,11 @@
|
|
|
403
403
|
"defaultEnabled": false
|
|
404
404
|
},
|
|
405
405
|
{
|
|
406
|
-
"id": "mcp-
|
|
406
|
+
"id": "mcp-add-server",
|
|
407
407
|
"scope": "assistant",
|
|
408
|
-
"key": "mcp-
|
|
409
|
-
"label": "MCP
|
|
410
|
-
"description": "Show the
|
|
408
|
+
"key": "mcp-add-server",
|
|
409
|
+
"label": "MCP Add Server",
|
|
410
|
+
"description": "Show the Add Server action on the MCP settings page. The MCP page itself remains visible; this flag gates only creating new Model Context Protocol server connections.",
|
|
411
411
|
"defaultEnabled": false
|
|
412
412
|
},
|
|
413
413
|
{
|
|
@@ -100,6 +100,10 @@ import {
|
|
|
100
100
|
isUserCancellation,
|
|
101
101
|
} from "./conversation-error.js";
|
|
102
102
|
import { raceWithTimeout } from "./conversation-media-retry.js";
|
|
103
|
+
import {
|
|
104
|
+
clearConversationNotices,
|
|
105
|
+
drainConversationNotices,
|
|
106
|
+
} from "./conversation-notices.js";
|
|
103
107
|
import {
|
|
104
108
|
getSlackCompactionWatermarkForPrefix,
|
|
105
109
|
loadSlackChronologicalContext,
|
|
@@ -1017,6 +1021,15 @@ export async function runAgentLoopImpl(
|
|
|
1017
1021
|
);
|
|
1018
1022
|
}
|
|
1019
1023
|
|
|
1024
|
+
const shouldEmitQueuedConversationNotices =
|
|
1025
|
+
!overflowTerminalReason &&
|
|
1026
|
+
!yieldedForHandoff &&
|
|
1027
|
+
!state.providerErrorUserMessage &&
|
|
1028
|
+
!abortController.signal.aborted;
|
|
1029
|
+
if (!shouldEmitQueuedConversationNotices) {
|
|
1030
|
+
clearConversationNotices(ctx.conversationId);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1020
1033
|
// Flush remaining tool results. On a normal turn these drain at the next
|
|
1021
1034
|
// `message_complete`; an aborted or yielded loop exits with them still
|
|
1022
1035
|
// buffered, so finalize the (possibly already on-arrival-reserved) grouped
|
|
@@ -1409,10 +1422,16 @@ export async function runAgentLoopImpl(
|
|
|
1409
1422
|
? { messageId: state.lastAssistantMessageId }
|
|
1410
1423
|
: {}),
|
|
1411
1424
|
});
|
|
1425
|
+
if (shouldEmitQueuedConversationNotices) {
|
|
1426
|
+
for (const notice of drainConversationNotices(ctx.conversationId)) {
|
|
1427
|
+
onEvent(notice);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1412
1430
|
publishLoopMessagesChanged();
|
|
1413
1431
|
}
|
|
1414
1432
|
}
|
|
1415
1433
|
} catch (err) {
|
|
1434
|
+
clearConversationNotices(ctx.conversationId);
|
|
1416
1435
|
const errorCtx = {
|
|
1417
1436
|
phase: "agent_loop" as const,
|
|
1418
1437
|
aborted: abortController.signal.aborted,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ConversationNoticeEvent } from "../api/events/conversation-notice.js";
|
|
2
|
+
|
|
3
|
+
export type PendingConversationNotice = Omit<
|
|
4
|
+
ConversationNoticeEvent,
|
|
5
|
+
"type" | "conversationId"
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
const MAX_TRACKED_CONVERSATIONS = 256;
|
|
9
|
+
|
|
10
|
+
const pendingNotices = new Map<
|
|
11
|
+
string,
|
|
12
|
+
Map<string, PendingConversationNotice>
|
|
13
|
+
>();
|
|
14
|
+
|
|
15
|
+
function touchConversation(
|
|
16
|
+
conversationId: string,
|
|
17
|
+
): Map<string, PendingConversationNotice> {
|
|
18
|
+
const existing = pendingNotices.get(conversationId);
|
|
19
|
+
if (existing) {
|
|
20
|
+
pendingNotices.delete(conversationId);
|
|
21
|
+
pendingNotices.set(conversationId, existing);
|
|
22
|
+
return existing;
|
|
23
|
+
}
|
|
24
|
+
if (pendingNotices.size >= MAX_TRACKED_CONVERSATIONS) {
|
|
25
|
+
const oldest = pendingNotices.keys().next().value;
|
|
26
|
+
if (oldest !== undefined) pendingNotices.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
const next = new Map<string, PendingConversationNotice>();
|
|
29
|
+
pendingNotices.set(conversationId, next);
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function queueConversationNotice(
|
|
34
|
+
conversationId: string,
|
|
35
|
+
key: string,
|
|
36
|
+
notice: PendingConversationNotice,
|
|
37
|
+
): void {
|
|
38
|
+
touchConversation(conversationId).set(key, notice);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function drainConversationNotices(
|
|
42
|
+
conversationId: string,
|
|
43
|
+
): ConversationNoticeEvent[] {
|
|
44
|
+
const notices = pendingNotices.get(conversationId);
|
|
45
|
+
if (!notices) return [];
|
|
46
|
+
pendingNotices.delete(conversationId);
|
|
47
|
+
return Array.from(notices.values(), (notice) => ({
|
|
48
|
+
type: "conversation_notice" as const,
|
|
49
|
+
conversationId,
|
|
50
|
+
...notice,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clearConversationNotices(conversationId: string): void {
|
|
55
|
+
pendingNotices.delete(conversationId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resetConversationNoticesForTests(): void {
|
|
59
|
+
pendingNotices.clear();
|
|
60
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { CompactionCircuitClosedEvent } from "../../api/events/compaction-c
|
|
|
4
4
|
import type { CompactionCircuitOpenEvent } from "../../api/events/compaction-circuit-open.js";
|
|
5
5
|
import type { ConversationErrorEvent } from "../../api/events/conversation-error.js";
|
|
6
6
|
import type { ConversationListInvalidatedEvent } from "../../api/events/conversation-list-invalidated.js";
|
|
7
|
+
import type { ConversationNoticeEvent } from "../../api/events/conversation-notice.js";
|
|
7
8
|
import type { ConversationTitleUpdatedEvent } from "../../api/events/conversation-title-updated.js";
|
|
8
9
|
import type { GenerationCancelledEvent } from "../../api/events/generation-cancelled.js";
|
|
9
10
|
import type { GenerationHandoffEvent } from "../../api/events/generation-handoff.js";
|
|
@@ -543,6 +544,7 @@ export type _ConversationsServerMessages =
|
|
|
543
544
|
| CompactionCircuitOpenEvent
|
|
544
545
|
| CompactionCircuitClosedEvent
|
|
545
546
|
| ConversationErrorEvent
|
|
547
|
+
| ConversationNoticeEvent
|
|
546
548
|
| ConversationInfo
|
|
547
549
|
| ConversationTitleUpdatedEvent
|
|
548
550
|
| ConversationListResponse
|
|
@@ -67,13 +67,17 @@ let pruneConfig: {
|
|
|
67
67
|
maxResidentBytes: number;
|
|
68
68
|
targetResidentBytes: number;
|
|
69
69
|
} | null = null;
|
|
70
|
-
/** Canned orchestrate result per turnIndex; `null` simulates
|
|
71
|
-
let turnResults = new Map<number, OrchestrateResult | null>();
|
|
70
|
+
/** Canned orchestrate result per turnIndex; `null` simulates an ordinary miss. */
|
|
71
|
+
let turnResults = new Map<number, OrchestrateResult | null | Error>();
|
|
72
72
|
const observeTurnSpy = mock(
|
|
73
73
|
async (
|
|
74
74
|
_conversationId: string,
|
|
75
75
|
turnIndex: number,
|
|
76
|
-
): Promise<OrchestrateResult | null> =>
|
|
76
|
+
): Promise<OrchestrateResult | null> => {
|
|
77
|
+
const value = turnResults.get(turnIndex) ?? null;
|
|
78
|
+
if (value instanceof Error) throw value;
|
|
79
|
+
return value;
|
|
80
|
+
},
|
|
77
81
|
);
|
|
78
82
|
|
|
79
83
|
const logCalls: Array<{ data: unknown; msg: string }> = [];
|
|
@@ -199,6 +203,9 @@ const {
|
|
|
199
203
|
} = await import("../ever-injected-store.js");
|
|
200
204
|
const { V3_CARDS_INJECTION_HEADER } = await import("../render-injection.js");
|
|
201
205
|
const { flushPruneValveForTests } = await import("../prune.js");
|
|
206
|
+
const { drainConversationNotices, resetConversationNoticesForTests } =
|
|
207
|
+
await import("../../../../daemon/conversation-notices.js");
|
|
208
|
+
const { MemoryV3RetrievalUnavailableError } = await import("../pool-select.js");
|
|
202
209
|
|
|
203
210
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
204
211
|
|
|
@@ -313,6 +320,7 @@ beforeEach(async () => {
|
|
|
313
320
|
logCalls.length = 0;
|
|
314
321
|
testDb = makeDb();
|
|
315
322
|
resetMemoryV3InjectorStateForTests();
|
|
323
|
+
resetConversationNoticesForTests();
|
|
316
324
|
});
|
|
317
325
|
|
|
318
326
|
afterAll(async () => {
|
|
@@ -335,6 +343,28 @@ describe("memoryV3Injector — frozen net-new cards", () => {
|
|
|
335
343
|
expect(getActiveSlugs("conv-1")).toEqual(new Set());
|
|
336
344
|
});
|
|
337
345
|
|
|
346
|
+
test("live retrieval failure queues a degraded-memory notice", async () => {
|
|
347
|
+
liveEnabled = true;
|
|
348
|
+
turnResults.set(
|
|
349
|
+
0,
|
|
350
|
+
new MemoryV3RetrievalUnavailableError("selector unavailable"),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await expect(produceCardsWithoutCommit("conv-1", 0)).resolves.toBeNull();
|
|
354
|
+
|
|
355
|
+
expect(drainConversationNotices("conv-1")).toEqual([
|
|
356
|
+
{
|
|
357
|
+
type: "conversation_notice",
|
|
358
|
+
conversationId: "conv-1",
|
|
359
|
+
source: "memory_v3",
|
|
360
|
+
code: "UNKNOWN",
|
|
361
|
+
userMessage:
|
|
362
|
+
"Memory is temporarily unavailable, so this response may not use your saved memories. You can retry in a moment.",
|
|
363
|
+
errorCategory: "memory_v3_degraded",
|
|
364
|
+
},
|
|
365
|
+
]);
|
|
366
|
+
});
|
|
367
|
+
|
|
338
368
|
test("turn 1 renders cards; turn 2 re-selecting the same pages renders ZERO new cards", async () => {
|
|
339
369
|
liveEnabled = true;
|
|
340
370
|
turnResults.set(0, result(["page-a", "page-b"]));
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* The provider is stubbed so no network calls fire; mirrors selector.test.ts.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { createRequire } from "node:module";
|
|
29
30
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
30
31
|
|
|
31
32
|
import type {
|
|
@@ -35,6 +36,7 @@ import type {
|
|
|
35
36
|
SendMessageOptions,
|
|
36
37
|
ToolUseContent,
|
|
37
38
|
} from "../../../../providers/types.js";
|
|
39
|
+
import { ProviderError } from "../../../../util/errors.js";
|
|
38
40
|
import type { MemoryRoutingTurn } from "../types.js";
|
|
39
41
|
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +45,11 @@ import type { MemoryRoutingTurn } from "../types.js";
|
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
|
|
45
47
|
let providerStub: Provider | null = null;
|
|
48
|
+
const registryReal = {
|
|
49
|
+
...(createRequire(import.meta.url)(
|
|
50
|
+
"../../../../providers/registry.js",
|
|
51
|
+
) as Record<string, unknown>),
|
|
52
|
+
};
|
|
46
53
|
|
|
47
54
|
interface ProviderCall {
|
|
48
55
|
messages: Message[];
|
|
@@ -57,6 +64,12 @@ mock.module("../../../../providers/provider-send-message.js", () => ({
|
|
|
57
64
|
response.content.find((b): b is ToolUseContent => b.type === "tool_use"),
|
|
58
65
|
}));
|
|
59
66
|
|
|
67
|
+
mock.module("../../../../providers/registry.js", () => ({
|
|
68
|
+
...registryReal,
|
|
69
|
+
getProviderRoutingSource: (providerName: string) =>
|
|
70
|
+
providerName === "managed" ? "managed-proxy" : "user-key",
|
|
71
|
+
}));
|
|
72
|
+
|
|
60
73
|
mock.module("../../../../util/logger.js", () => ({
|
|
61
74
|
getLogger: () => ({
|
|
62
75
|
warn: (...args: unknown[]) => warnCalls.push({ args }),
|
|
@@ -253,10 +266,9 @@ describe("selectPool — id mapping", () => {
|
|
|
253
266
|
});
|
|
254
267
|
|
|
255
268
|
// ---------------------------------------------------------------------------
|
|
256
|
-
// selectPool — infrastructure failures THROW
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// hard-fail the turn instead of shipping it with no memory.
|
|
269
|
+
// selectPool — infrastructure failures THROW. A deliberate empty selection and
|
|
270
|
+
// an empty pool (covered above) still return normally; only a genuine infra
|
|
271
|
+
// failure throws so callers can log it distinctly from an empty selection.
|
|
260
272
|
// ---------------------------------------------------------------------------
|
|
261
273
|
|
|
262
274
|
describe("selectPool — infrastructure failures throw", () => {
|
|
@@ -371,6 +383,38 @@ describe("selectPool — infrastructure failures throw", () => {
|
|
|
371
383
|
]);
|
|
372
384
|
});
|
|
373
385
|
|
|
386
|
+
test("managed provider 402 attaches a non-terminal credits notice", async () => {
|
|
387
|
+
providerStub = {
|
|
388
|
+
name: "managed",
|
|
389
|
+
sendMessage: async (messages, options) => {
|
|
390
|
+
providerCalls.push({ messages, options });
|
|
391
|
+
throw new ProviderError(
|
|
392
|
+
"Together AI API error (402): 402 status code (no body)",
|
|
393
|
+
"managed",
|
|
394
|
+
402,
|
|
395
|
+
);
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
let caught: unknown;
|
|
399
|
+
try {
|
|
400
|
+
await selectPool(makePool(), makeTurn("x"));
|
|
401
|
+
} catch (err) {
|
|
402
|
+
caught = err;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
expect(caught).toBeInstanceOf(MemoryV3RetrievalUnavailableError);
|
|
406
|
+
const notice = (
|
|
407
|
+
caught as InstanceType<typeof MemoryV3RetrievalUnavailableError>
|
|
408
|
+
).conversationNotice;
|
|
409
|
+
expect(notice).toEqual({
|
|
410
|
+
source: "memory_v3",
|
|
411
|
+
code: "PROVIDER_BILLING",
|
|
412
|
+
userMessage:
|
|
413
|
+
"You've run out of credits. Add funds to continue using the assistant.",
|
|
414
|
+
errorCategory: "credits_exhausted",
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
374
418
|
test("provider throw redacts sensitive message details in diagnostics", async () => {
|
|
375
419
|
const providerSecret = ["sk-proj-", "a".repeat(40)].join("");
|
|
376
420
|
const message = `provider rejected Authorization: Bearer ${providerSecret}`;
|
|
@@ -808,7 +808,7 @@ describe("memory-v3 shadow plugin", () => {
|
|
|
808
808
|
});
|
|
809
809
|
});
|
|
810
810
|
|
|
811
|
-
describe("memory-v3 infrastructure-failure handling
|
|
811
|
+
describe("memory-v3 infrastructure-failure handling", () => {
|
|
812
812
|
const throwInfra = () =>
|
|
813
813
|
orchestrateSpy.mockImplementationOnce(async () => {
|
|
814
814
|
throw new MemoryV3RetrievalUnavailableError(
|
|
@@ -816,14 +816,12 @@ describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", ()
|
|
|
816
816
|
);
|
|
817
817
|
});
|
|
818
818
|
|
|
819
|
-
test("LIVE injector
|
|
819
|
+
test("LIVE injector logs and degrades to no v3 block on an infra failure", async () => {
|
|
820
820
|
liveEnabled = true;
|
|
821
821
|
shadowEnabled = false;
|
|
822
822
|
throwInfra();
|
|
823
823
|
|
|
824
|
-
await
|
|
825
|
-
MemoryV3RetrievalUnavailableError,
|
|
826
|
-
);
|
|
824
|
+
expect(await produce("conv-infra-live", 0)).toBeNull();
|
|
827
825
|
});
|
|
828
826
|
|
|
829
827
|
test("SHADOW injector swallows an infra failure (v2 fallback) — no throw, no block", async () => {
|
|
@@ -832,7 +830,7 @@ describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", ()
|
|
|
832
830
|
throwInfra();
|
|
833
831
|
|
|
834
832
|
// Shadow mode: v2 retrieval still ran this turn, so the v3 injector returns
|
|
835
|
-
// null
|
|
833
|
+
// null.
|
|
836
834
|
expect(await produce("conv-infra-shadow", 0)).toBeNull();
|
|
837
835
|
});
|
|
838
836
|
|
|
@@ -853,8 +851,6 @@ describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", ()
|
|
|
853
851
|
throw new Error("some unexpected non-infra bug");
|
|
854
852
|
});
|
|
855
853
|
|
|
856
|
-
// Only INFRA failures hard-fail; any other error stays non-fatal so a bug
|
|
857
|
-
// in one lane can't take every turn down.
|
|
858
854
|
expect(await produce("conv-nonfatal-live", 0)).toBeNull();
|
|
859
855
|
});
|
|
860
856
|
});
|
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
|
|
64
64
|
import { getConfig } from "../../../config/loader.js";
|
|
65
65
|
import { isMemoryV3Live } from "../../../config/memory-v3-gate.js";
|
|
66
|
+
import {
|
|
67
|
+
type PendingConversationNotice,
|
|
68
|
+
queueConversationNotice,
|
|
69
|
+
} from "../../../daemon/conversation-notices.js";
|
|
66
70
|
import { isPersonalMemoryAllowed } from "../../../daemon/trust-context.js";
|
|
67
71
|
import {
|
|
68
72
|
wrapMemoryBlock,
|
|
@@ -120,6 +124,26 @@ function lruSet<V>(map: Map<string, V>, key: string, value: V): void {
|
|
|
120
124
|
map.set(key, value);
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
function queueMemoryV3ConversationNotice(
|
|
128
|
+
err: MemoryV3RetrievalUnavailableError,
|
|
129
|
+
ctx: TurnContext,
|
|
130
|
+
live: boolean,
|
|
131
|
+
): void {
|
|
132
|
+
if (!live) return;
|
|
133
|
+
const notice: PendingConversationNotice = err.conversationNotice ?? {
|
|
134
|
+
source: "memory_v3",
|
|
135
|
+
code: "UNKNOWN",
|
|
136
|
+
userMessage:
|
|
137
|
+
"Memory is temporarily unavailable, so this response may not use your saved memories. You can retry in a moment.",
|
|
138
|
+
errorCategory: "memory_v3_degraded",
|
|
139
|
+
};
|
|
140
|
+
queueConversationNotice(
|
|
141
|
+
ctx.conversationId,
|
|
142
|
+
`memory_v3:${ctx.turnIndex}:${notice.errorCategory ?? notice.code}`,
|
|
143
|
+
notice,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
123
147
|
// ─── shared per-turn orchestration memo ─────────────────────────────────────
|
|
124
148
|
|
|
125
149
|
interface ObservedTurn {
|
|
@@ -245,16 +269,16 @@ export const memoryV3Injector: Injector = {
|
|
|
245
269
|
try {
|
|
246
270
|
observed = await observeTurnOnce(ctx.conversationId, ctx.turnIndex);
|
|
247
271
|
} catch (err) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
if (err instanceof MemoryV3RetrievalUnavailableError) {
|
|
273
|
+
queueMemoryV3ConversationNotice(err, ctx, live);
|
|
274
|
+
log.error(
|
|
275
|
+
{
|
|
276
|
+
err: err.message,
|
|
277
|
+
conversationId: ctx.conversationId,
|
|
278
|
+
mode: live ? "live" : "shadow",
|
|
279
|
+
},
|
|
280
|
+
"memory-v3 selection failed; skipping v3 memory for this turn",
|
|
281
|
+
);
|
|
258
282
|
}
|
|
259
283
|
return null;
|
|
260
284
|
}
|
|
@@ -405,12 +429,16 @@ export const memoryV3SpotlightInjector: Injector = {
|
|
|
405
429
|
placement: "after-memory-prefix",
|
|
406
430
|
};
|
|
407
431
|
} catch (err) {
|
|
408
|
-
// Live-only injector: an infra failure must hard-fail the turn. The cards
|
|
409
|
-
// injector (ordered ahead of this one) normally throws first, so this
|
|
410
|
-
// path is defensive — it keeps the behavior correct if the cards injector
|
|
411
|
-
// is ever disabled or reordered.
|
|
412
432
|
if (err instanceof MemoryV3RetrievalUnavailableError) {
|
|
413
|
-
|
|
433
|
+
queueMemoryV3ConversationNotice(err, ctx, true);
|
|
434
|
+
log.error(
|
|
435
|
+
{
|
|
436
|
+
err: err.message,
|
|
437
|
+
conversationId: ctx.conversationId,
|
|
438
|
+
},
|
|
439
|
+
"memory-v3 spotlight selection failed; skipping spotlight",
|
|
440
|
+
);
|
|
441
|
+
return null;
|
|
414
442
|
}
|
|
415
443
|
log.warn(
|
|
416
444
|
{
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
* - infrastructure failure (selector provider unavailable — e.g. a transient
|
|
42
42
|
* CES credential blip drops the API key — or no usable `tool_use` / schema
|
|
43
43
|
* mismatch surviving the short re-prompt retry) → throw
|
|
44
|
-
* {@link MemoryV3RetrievalUnavailableError}.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* while the shadow/observation path swallows it and lets v2 retrieval serve
|
|
48
|
-
* the turn.
|
|
44
|
+
* {@link MemoryV3RetrievalUnavailableError}. The live injector treats this
|
|
45
|
+
* as a logged memory miss for the turn; shadow/observation callers swallow
|
|
46
|
+
* it so v2 retrieval can serve the turn.
|
|
49
47
|
*/
|
|
50
48
|
|
|
51
49
|
import { z } from "zod";
|
|
52
50
|
|
|
51
|
+
import { classifyConversationError } from "../../../daemon/conversation-error.js";
|
|
52
|
+
import type { PendingConversationNotice } from "../../../daemon/conversation-notices.js";
|
|
53
53
|
import { loadPromptOverride } from "../../../memory/prompt-override.js";
|
|
54
54
|
import { cachedTextBlock } from "../../../providers/cache-control.js";
|
|
55
55
|
import {
|
|
@@ -77,17 +77,43 @@ const log = getLogger("memory-v3-pool-select");
|
|
|
77
77
|
* re-prompt retry. Deliberately DISTINCT from a deliberate empty selection
|
|
78
78
|
* (`ids: []`) and an empty candidate pool, both of which return normally.
|
|
79
79
|
*
|
|
80
|
-
* The
|
|
81
|
-
* retryable failure) rather than silently shipping with no memory; the
|
|
80
|
+
* The live memory-v3 injector logs this as a memory miss for the turn; the
|
|
82
81
|
* shadow/observation path catches and swallows it.
|
|
83
82
|
*/
|
|
84
83
|
export class MemoryV3RetrievalUnavailableError extends Error {
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
readonly conversationNotice?: PendingConversationNotice;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
message: string,
|
|
88
|
+
options?: {
|
|
89
|
+
cause?: unknown;
|
|
90
|
+
conversationNotice?: PendingConversationNotice;
|
|
91
|
+
},
|
|
92
|
+
) {
|
|
93
|
+
super(
|
|
94
|
+
message,
|
|
95
|
+
options?.cause === undefined ? undefined : { cause: options.cause },
|
|
96
|
+
);
|
|
87
97
|
this.name = "MemoryV3RetrievalUnavailableError";
|
|
98
|
+
this.conversationNotice = options?.conversationNotice;
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
function providerBillingNoticeFromError(
|
|
103
|
+
error: unknown,
|
|
104
|
+
): PendingConversationNotice | undefined {
|
|
105
|
+
const classified = classifyConversationError(error, {
|
|
106
|
+
phase: "agent_loop",
|
|
107
|
+
});
|
|
108
|
+
if (classified.code !== "PROVIDER_BILLING") return undefined;
|
|
109
|
+
return {
|
|
110
|
+
source: "memory_v3",
|
|
111
|
+
code: classified.code,
|
|
112
|
+
userMessage: classified.userMessage,
|
|
113
|
+
errorCategory: classified.errorCategory,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
/** A dynamic-tail (finder) candidate: the slug plus the descriptor that
|
|
92
118
|
* justifies it — a matched section for a needle/dense hit, or a curated link
|
|
93
119
|
* description for an edge page. Rendered as a one-line snippet, prefixed
|
|
@@ -368,7 +394,7 @@ export async function selectPool(
|
|
|
368
394
|
stableCount: pool.stable.length,
|
|
369
395
|
finderCount: pool.finder.length,
|
|
370
396
|
},
|
|
371
|
-
"pool selector provider unavailable
|
|
397
|
+
"pool selector provider unavailable",
|
|
372
398
|
);
|
|
373
399
|
throw new MemoryV3RetrievalUnavailableError(
|
|
374
400
|
"memory-v3 pool selector provider unavailable",
|
|
@@ -422,6 +448,12 @@ export async function selectPool(
|
|
|
422
448
|
// (no usable tool_use, or tool input that fails the schema) re-prompts before
|
|
423
449
|
// we give up. `null` from an attempt means "unusable, retry"; the provider
|
|
424
450
|
// layer already backs off transient throws, so this loop adds no delay.
|
|
451
|
+
//
|
|
452
|
+
// `lastError` captures the most recent attempt's thrown provider error —
|
|
453
|
+
// `retryForResult` swallows attempt throws, so without this an infrastructure
|
|
454
|
+
// failure (e.g. an upstream HTTP 4xx/5xx) is indistinguishable from a 200 that
|
|
455
|
+
// carried no usable tool_use. It is cleared on every attempt that reaches a
|
|
456
|
+
// response, so it reflects the LAST attempt's failure mode.
|
|
425
457
|
let lastError: unknown = null;
|
|
426
458
|
const parsed = await retryForResult(async () => {
|
|
427
459
|
attempt += 1;
|
|
@@ -504,10 +536,14 @@ export async function selectPool(
|
|
|
504
536
|
providerName: provider.name,
|
|
505
537
|
failures,
|
|
506
538
|
},
|
|
507
|
-
"pool selector provider call failed after retries
|
|
539
|
+
"pool selector provider call failed after retries",
|
|
508
540
|
);
|
|
509
541
|
throw new MemoryV3RetrievalUnavailableError(
|
|
510
542
|
`memory-v3 pool selector provider call failed after retries: ${redactedDetail}`,
|
|
543
|
+
{
|
|
544
|
+
cause: lastError,
|
|
545
|
+
conversationNotice: providerBillingNoticeFromError(lastError),
|
|
546
|
+
},
|
|
511
547
|
);
|
|
512
548
|
}
|
|
513
549
|
log.warn(
|
|
@@ -519,7 +555,7 @@ export async function selectPool(
|
|
|
519
555
|
providerName: provider.name,
|
|
520
556
|
failures,
|
|
521
557
|
},
|
|
522
|
-
"pool selector returned no usable tool_use after retries
|
|
558
|
+
"pool selector returned no usable tool_use after retries",
|
|
523
559
|
);
|
|
524
560
|
throw new MemoryV3RetrievalUnavailableError(
|
|
525
561
|
"memory-v3 pool selector returned no usable selection after retries",
|
|
@@ -605,13 +605,9 @@ export async function observeTurn(
|
|
|
605
605
|
writeSelections(conversationId, turnIndex, rows);
|
|
606
606
|
return result;
|
|
607
607
|
} catch (err) {
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// so
|
|
611
|
-
// rather than shipping it with no `<memory>` block. The shadow/observation
|
|
612
|
-
// callers (the injector in shadow mode, runShadowObservation) catch this
|
|
613
|
-
// and swallow it, so observation never fails a turn. Other (non-infra)
|
|
614
|
-
// errors stay non-fatal and degrade to no v3 block, as before.
|
|
608
|
+
// Infrastructure failures are surfaced to callers that want distinct
|
|
609
|
+
// logging from ordinary orchestration misses. Observation callers swallow
|
|
610
|
+
// them so memory-v3 never fails the turn.
|
|
615
611
|
if (err instanceof MemoryV3RetrievalUnavailableError) {
|
|
616
612
|
throw err;
|
|
617
613
|
}
|
|
@@ -641,8 +637,6 @@ export async function runShadowObservation(
|
|
|
641
637
|
try {
|
|
642
638
|
await observeTurn(conversationId, turnIndex);
|
|
643
639
|
} catch {
|
|
644
|
-
// Shadow observation is fire-and-forget and must
|
|
645
|
-
// `observeTurn` now re-throws infra failures so the LIVE injector can
|
|
646
|
-
// hard-fail on them; here (observation only) we swallow them.
|
|
640
|
+
// Shadow observation is fire-and-forget and must never fail a turn.
|
|
647
641
|
}
|
|
648
642
|
}
|
package/src/plugins/pipeline.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* A "hook" is a named lifecycle event (`user-prompt-submit`, `post-tool-use`,
|
|
5
5
|
* ...) that every registered plugin may handle. The runner walks each plugin's
|
|
6
6
|
* hook for a given event in registration order, threading a context value
|
|
7
|
-
* through the chain so hooks can observe and transform it.
|
|
8
|
-
*
|
|
9
|
-
* context whose fields are merged
|
|
7
|
+
* through the chain so hooks can observe and transform it. Each hook receives
|
|
8
|
+
* an isolated draft of the current context. A hook either mutates the draft in
|
|
9
|
+
* place (returning `void`) or returns a partial context whose fields are merged
|
|
10
|
+
* onto the draft. Failed hook drafts are discarded.
|
|
10
11
|
*
|
|
11
12
|
* `getHooksFor` is now async — it pulls user-land hooks from the mtime
|
|
12
13
|
* cache (filesystem-as-truth) and default plugin hooks from the registry
|
|
@@ -16,34 +17,131 @@
|
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import type { HookName } from "../plugin-api/constants.js";
|
|
20
|
+
import { getLogger } from "../util/logger.js";
|
|
19
21
|
import { getHooksFor } from "./registry.js";
|
|
22
|
+
import type { PluginHookFn } from "./types.js";
|
|
20
23
|
|
|
21
24
|
// ─── Hook runner ────────────────────────────────────────────────────────────
|
|
22
25
|
|
|
26
|
+
const log = getLogger("plugin-pipeline");
|
|
27
|
+
|
|
28
|
+
function isPluginLogger(value: unknown): value is {
|
|
29
|
+
info: unknown;
|
|
30
|
+
warn: unknown;
|
|
31
|
+
error: unknown;
|
|
32
|
+
debug: unknown;
|
|
33
|
+
} {
|
|
34
|
+
return (
|
|
35
|
+
value !== null &&
|
|
36
|
+
typeof value === "object" &&
|
|
37
|
+
typeof (value as { info?: unknown }).info === "function" &&
|
|
38
|
+
typeof (value as { warn?: unknown }).warn === "function" &&
|
|
39
|
+
typeof (value as { error?: unknown }).error === "function" &&
|
|
40
|
+
typeof (value as { debug?: unknown }).debug === "function"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPlainObject(value: object): boolean {
|
|
45
|
+
const prototype = Object.getPrototypeOf(value);
|
|
46
|
+
return prototype === Object.prototype || prototype === null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cloneHookValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
|
50
|
+
if (value === null || typeof value !== "object") return value;
|
|
51
|
+
if (value instanceof Error || isPluginLogger(value)) return value;
|
|
52
|
+
|
|
53
|
+
const existing = seen.get(value);
|
|
54
|
+
if (existing !== undefined) return existing as T;
|
|
55
|
+
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
const copy: unknown[] = [];
|
|
58
|
+
seen.set(value, copy);
|
|
59
|
+
for (const item of value) {
|
|
60
|
+
copy.push(cloneHookValue(item, seen));
|
|
61
|
+
}
|
|
62
|
+
return copy as T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (value instanceof Date) {
|
|
66
|
+
return new Date(value.getTime()) as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (value instanceof Map) {
|
|
70
|
+
const copy = new Map();
|
|
71
|
+
seen.set(value, copy);
|
|
72
|
+
for (const [key, mapValue] of value) {
|
|
73
|
+
copy.set(cloneHookValue(key, seen), cloneHookValue(mapValue, seen));
|
|
74
|
+
}
|
|
75
|
+
return copy as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (value instanceof Set) {
|
|
79
|
+
const copy = new Set();
|
|
80
|
+
seen.set(value, copy);
|
|
81
|
+
for (const item of value) {
|
|
82
|
+
copy.add(cloneHookValue(item, seen));
|
|
83
|
+
}
|
|
84
|
+
return copy as T;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!isPlainObject(value)) return value;
|
|
88
|
+
|
|
89
|
+
const copy: Record<PropertyKey, unknown> = {};
|
|
90
|
+
seen.set(value, copy);
|
|
91
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
92
|
+
copy[key] = cloneHookValue(
|
|
93
|
+
(value as Record<PropertyKey, unknown>)[key],
|
|
94
|
+
seen,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return copy as T;
|
|
98
|
+
}
|
|
99
|
+
|
|
23
100
|
/**
|
|
24
101
|
* Execute a hook chain: walk every registered plugin's hook for `name` in
|
|
25
102
|
* registration order, threading `initialCtx` through each. Hooks may either
|
|
26
|
-
* mutate
|
|
27
|
-
* whose fields are merged onto the
|
|
28
|
-
* overwrite the running context, every other field is preserved.
|
|
29
|
-
*
|
|
103
|
+
* mutate their draft context in place (returning `void`) or return a partial
|
|
104
|
+
* context whose fields are merged onto the draft — keys the hook returns
|
|
105
|
+
* overwrite the running context, every other field is preserved. If a hook
|
|
106
|
+
* throws, its draft is discarded and the next hook receives the last
|
|
107
|
+
* successfully committed context. The final context after the chain settles is
|
|
108
|
+
* returned.
|
|
30
109
|
*
|
|
31
110
|
* @param name The hook identifier — pick one from {@link HOOKS}.
|
|
32
111
|
* @param initialCtx Context the first hook receives.
|
|
33
112
|
* @returns The final context after the chain settles. Same reference as
|
|
34
|
-
* `initialCtx` when no plugin registers `name
|
|
35
|
-
* chained hook returns `void` (mutation-in-place style).
|
|
113
|
+
* `initialCtx` when no plugin registers `name`.
|
|
36
114
|
*/
|
|
37
115
|
export async function runHook<TCtx>(
|
|
38
116
|
name: HookName,
|
|
39
117
|
initialCtx: TCtx,
|
|
40
118
|
): Promise<TCtx> {
|
|
41
|
-
|
|
119
|
+
let hooks: PluginHookFn<TCtx>[];
|
|
120
|
+
try {
|
|
121
|
+
hooks = await getHooksFor<TCtx>(name);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log.error(
|
|
124
|
+
{ err, hookName: name },
|
|
125
|
+
"plugin hook discovery failed — proceeding without hooks",
|
|
126
|
+
);
|
|
127
|
+
return initialCtx;
|
|
128
|
+
}
|
|
129
|
+
|
|
42
130
|
let active = initialCtx;
|
|
43
131
|
for (const hook of hooks) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
132
|
+
const draft = cloneHookValue(active);
|
|
133
|
+
try {
|
|
134
|
+
const result = await hook(draft);
|
|
135
|
+
if (result !== undefined) {
|
|
136
|
+
active = { ...draft, ...result };
|
|
137
|
+
} else {
|
|
138
|
+
active = draft;
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log.error(
|
|
142
|
+
{ err, hookName: name },
|
|
143
|
+
"plugin hook failed — proceeding with current context",
|
|
144
|
+
);
|
|
47
145
|
}
|
|
48
146
|
}
|
|
49
147
|
return active;
|