@vellumai/assistant 0.10.3-dev.202606252237.df0fc92 → 0.10.3-dev.202606260109.8a3d17b
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__/config-loader-backfill.test.ts +148 -0
- package/src/__tests__/conversation-agent-loop.test.ts +96 -7
- package/src/__tests__/conversation-error.test.ts +18 -0
- package/src/config/seed-inference-profiles.ts +68 -13
- package/src/daemon/conversation-agent-loop-handlers.ts +17 -0
- package/src/daemon/conversation-agent-loop.ts +68 -61
- package/src/daemon/conversation-error.ts +7 -10
- package/src/daemon/message-types/surfaces.ts +2 -0
package/package.json
CHANGED
|
@@ -1426,9 +1426,156 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
|
|
|
1426
1426
|
|
|
1427
1427
|
expect(config.llm.profiles.balanced?.status).toBe("disabled");
|
|
1428
1428
|
expect(config.llm.profiles["quality-optimized"]?.status).toBe("disabled");
|
|
1429
|
+
expect(config.llm.profiles.frontier?.status).toBe("disabled");
|
|
1429
1430
|
expect(config.llm.profiles["cost-optimized"]?.status).toBe("disabled");
|
|
1430
1431
|
});
|
|
1431
1432
|
|
|
1433
|
+
test("off-platform BYOK hatch defaults advisor to the personal quality profile", () => {
|
|
1434
|
+
const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
|
|
1435
|
+
writeFileSync(
|
|
1436
|
+
overlayPath,
|
|
1437
|
+
JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
|
|
1438
|
+
"\n",
|
|
1439
|
+
);
|
|
1440
|
+
process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
|
|
1441
|
+
|
|
1442
|
+
mergeDefaultConfigAndSeedInferenceProfiles();
|
|
1443
|
+
const config = loadConfig();
|
|
1444
|
+
|
|
1445
|
+
expect(config.llm.activeProfile).toBe("custom-balanced");
|
|
1446
|
+
expect(config.llm.advisorProfile).toBe("custom-quality-optimized");
|
|
1447
|
+
expect(config.llm.profiles["custom-quality-optimized"]?.provider).toBe(
|
|
1448
|
+
"anthropic",
|
|
1449
|
+
);
|
|
1450
|
+
expect(
|
|
1451
|
+
config.llm.profiles["custom-quality-optimized"]?.provider_connection,
|
|
1452
|
+
).toBe("anthropic-personal");
|
|
1453
|
+
expect(config.llm.profiles.frontier?.status).toBe("disabled");
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("off-platform boot repairs a disabled managed advisor to a personal profile when no active managed replacement exists", () => {
|
|
1457
|
+
writeConfig({
|
|
1458
|
+
llm: {
|
|
1459
|
+
advisorProfile: "frontier",
|
|
1460
|
+
profiles: {
|
|
1461
|
+
frontier: {
|
|
1462
|
+
source: "managed",
|
|
1463
|
+
provider: "anthropic",
|
|
1464
|
+
provider_connection: "anthropic-managed",
|
|
1465
|
+
model: "claude-opus-4-8",
|
|
1466
|
+
status: "disabled",
|
|
1467
|
+
},
|
|
1468
|
+
balanced: {
|
|
1469
|
+
source: "managed",
|
|
1470
|
+
provider: "together",
|
|
1471
|
+
provider_connection: "together-managed",
|
|
1472
|
+
model: "open-model",
|
|
1473
|
+
status: "disabled",
|
|
1474
|
+
},
|
|
1475
|
+
"quality-optimized": {
|
|
1476
|
+
source: "managed",
|
|
1477
|
+
provider: "fireworks",
|
|
1478
|
+
provider_connection: "fireworks-managed",
|
|
1479
|
+
model: "accounts/fireworks/models/glm-5p2",
|
|
1480
|
+
status: "disabled",
|
|
1481
|
+
},
|
|
1482
|
+
"cost-optimized": {
|
|
1483
|
+
source: "managed",
|
|
1484
|
+
provider: "fireworks",
|
|
1485
|
+
provider_connection: "fireworks-managed",
|
|
1486
|
+
model: "accounts/fireworks/models/deepseek-v4-flash",
|
|
1487
|
+
status: "disabled",
|
|
1488
|
+
},
|
|
1489
|
+
"custom-quality-optimized": {
|
|
1490
|
+
source: "user",
|
|
1491
|
+
provider: "anthropic",
|
|
1492
|
+
provider_connection: "anthropic-personal",
|
|
1493
|
+
model: "claude-opus-4-8",
|
|
1494
|
+
label: "Quality",
|
|
1495
|
+
},
|
|
1496
|
+
},
|
|
1497
|
+
},
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
mergeDefaultConfigAndSeedInferenceProfiles();
|
|
1501
|
+
const config = loadConfig();
|
|
1502
|
+
|
|
1503
|
+
expect(config.llm.advisorProfile).toBe("custom-quality-optimized");
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
test("platform boot repairs a disabled managed advisor to an active managed profile", () => {
|
|
1507
|
+
process.env.IS_PLATFORM = "true";
|
|
1508
|
+
writeConfig({
|
|
1509
|
+
llm: {
|
|
1510
|
+
advisorProfile: "frontier",
|
|
1511
|
+
profiles: {
|
|
1512
|
+
frontier: {
|
|
1513
|
+
source: "managed",
|
|
1514
|
+
provider: "anthropic",
|
|
1515
|
+
provider_connection: "anthropic-managed",
|
|
1516
|
+
model: "claude-opus-4-8",
|
|
1517
|
+
status: "disabled",
|
|
1518
|
+
},
|
|
1519
|
+
"custom-quality-optimized": {
|
|
1520
|
+
source: "user",
|
|
1521
|
+
provider: "anthropic",
|
|
1522
|
+
provider_connection: "anthropic-personal",
|
|
1523
|
+
model: "claude-opus-4-8",
|
|
1524
|
+
label: "Quality",
|
|
1525
|
+
},
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
mergeDefaultConfigAndSeedInferenceProfiles();
|
|
1531
|
+
const config = loadConfig();
|
|
1532
|
+
|
|
1533
|
+
expect(config.llm.advisorProfile).toBe("quality-optimized");
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
test("off-platform boot clears a disabled managed advisor when no active replacement exists", () => {
|
|
1537
|
+
writeConfig({
|
|
1538
|
+
llm: {
|
|
1539
|
+
advisorProfile: "frontier",
|
|
1540
|
+
profiles: {
|
|
1541
|
+
frontier: {
|
|
1542
|
+
source: "managed",
|
|
1543
|
+
provider: "anthropic",
|
|
1544
|
+
provider_connection: "anthropic-managed",
|
|
1545
|
+
model: "claude-opus-4-8",
|
|
1546
|
+
status: "disabled",
|
|
1547
|
+
},
|
|
1548
|
+
balanced: {
|
|
1549
|
+
source: "managed",
|
|
1550
|
+
provider: "together",
|
|
1551
|
+
provider_connection: "together-managed",
|
|
1552
|
+
model: "open-model",
|
|
1553
|
+
status: "disabled",
|
|
1554
|
+
},
|
|
1555
|
+
"quality-optimized": {
|
|
1556
|
+
source: "managed",
|
|
1557
|
+
provider: "fireworks",
|
|
1558
|
+
provider_connection: "fireworks-managed",
|
|
1559
|
+
model: "accounts/fireworks/models/glm-5p2",
|
|
1560
|
+
status: "disabled",
|
|
1561
|
+
},
|
|
1562
|
+
"cost-optimized": {
|
|
1563
|
+
source: "managed",
|
|
1564
|
+
provider: "fireworks",
|
|
1565
|
+
provider_connection: "fireworks-managed",
|
|
1566
|
+
model: "accounts/fireworks/models/deepseek-v4-flash",
|
|
1567
|
+
status: "disabled",
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
},
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
mergeDefaultConfigAndSeedInferenceProfiles();
|
|
1574
|
+
const config = loadConfig();
|
|
1575
|
+
|
|
1576
|
+
expect(config.llm.advisorProfile).toBeUndefined();
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1432
1579
|
test("off-platform managed-inference hatch keeps selected managed connection active", () => {
|
|
1433
1580
|
const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
|
|
1434
1581
|
writeFileSync(
|
|
@@ -1451,6 +1598,7 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
|
|
|
1451
1598
|
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
1452
1599
|
|
|
1453
1600
|
expect(raw.llm.activeProfile).toBe("balanced");
|
|
1601
|
+
expect(raw.llm.advisorProfile).toBe("balanced");
|
|
1454
1602
|
expect(raw.llm.profiles.balanced.provider_connection).toBe(
|
|
1455
1603
|
"together-managed",
|
|
1456
1604
|
);
|
|
@@ -278,6 +278,7 @@ const deleteMessageByIdMock = mock(() => ({
|
|
|
278
278
|
}));
|
|
279
279
|
const reserveMessageMock = mock(async () => ({ id: "msg-reserve" }));
|
|
280
280
|
const updateMessageContentMock = mock(() => {});
|
|
281
|
+
const addMessageMock = mock(() => ({ id: "mock-msg-id" }));
|
|
281
282
|
mock.module("../memory/conversation-crud.js", () => ({
|
|
282
283
|
setConversationProcessingStartedAt: () => {},
|
|
283
284
|
isConversationProcessing: () => false,
|
|
@@ -292,7 +293,7 @@ mock.module("../memory/conversation-crud.js", () => ({
|
|
|
292
293
|
trustContext: undefined,
|
|
293
294
|
}),
|
|
294
295
|
getConversationOriginInterface: () => null,
|
|
295
|
-
addMessage:
|
|
296
|
+
addMessage: addMessageMock,
|
|
296
297
|
deleteMessageById: deleteMessageByIdMock,
|
|
297
298
|
updateConversationContextWindow: () => {},
|
|
298
299
|
updateConversationSlackContextWatermark:
|
|
@@ -560,13 +561,16 @@ mock.module("../workspace/git-service.js", () => ({
|
|
|
560
561
|
}),
|
|
561
562
|
}));
|
|
562
563
|
|
|
564
|
+
let mockConversationErrorClassification = {
|
|
565
|
+
code: "CONVERSATION_PROCESSING_FAILED",
|
|
566
|
+
userMessage: "Something went wrong processing your message.",
|
|
567
|
+
retryable: false,
|
|
568
|
+
errorCategory: "processing_failed",
|
|
569
|
+
};
|
|
570
|
+
|
|
563
571
|
mock.module("../daemon/conversation-error.js", () => ({
|
|
564
|
-
classifyConversationError: (_err: unknown, _ctx: unknown) =>
|
|
565
|
-
|
|
566
|
-
userMessage: "Something went wrong processing your message.",
|
|
567
|
-
retryable: false,
|
|
568
|
-
errorCategory: "processing_failed",
|
|
569
|
-
}),
|
|
572
|
+
classifyConversationError: (_err: unknown, _ctx: unknown) =>
|
|
573
|
+
mockConversationErrorClassification,
|
|
570
574
|
isUserCancellation: (err: unknown, ctx: { aborted?: boolean }) => {
|
|
571
575
|
if (!ctx.aborted) return false;
|
|
572
576
|
if (err instanceof DOMException && err.name === "AbortError") return true;
|
|
@@ -872,6 +876,13 @@ beforeEach(() => {
|
|
|
872
876
|
deleteMessageByIdMock.mockClear();
|
|
873
877
|
reserveMessageMock.mockClear();
|
|
874
878
|
updateMessageContentMock.mockClear();
|
|
879
|
+
addMessageMock.mockClear();
|
|
880
|
+
mockConversationErrorClassification = {
|
|
881
|
+
code: "CONVERSATION_PROCESSING_FAILED",
|
|
882
|
+
userMessage: "Something went wrong processing your message.",
|
|
883
|
+
retryable: false,
|
|
884
|
+
errorCategory: "processing_failed",
|
|
885
|
+
};
|
|
875
886
|
indexMessageNowMock.mockClear();
|
|
876
887
|
projectAssistantMessageMock.mockClear();
|
|
877
888
|
publishSyncInvalidationMock.mockClear();
|
|
@@ -2241,6 +2252,49 @@ describe("session-agent-loop", () => {
|
|
|
2241
2252
|
expect(backfillCall[0]).toBe("test-conv");
|
|
2242
2253
|
expect(backfillCall[1]).toBe("mock-msg-id");
|
|
2243
2254
|
});
|
|
2255
|
+
|
|
2256
|
+
test("does not persist managed credential refresh failures as assistant text", async () => {
|
|
2257
|
+
mockConversationErrorClassification = {
|
|
2258
|
+
code: "MANAGED_KEY_INVALID",
|
|
2259
|
+
userMessage: "Couldn't refresh assistant credentials.",
|
|
2260
|
+
retryable: false,
|
|
2261
|
+
errorCategory: "managed_key_invalid",
|
|
2262
|
+
};
|
|
2263
|
+
const events: ServerMessage[] = [];
|
|
2264
|
+
|
|
2265
|
+
const ctx = makeCtx({
|
|
2266
|
+
loopProvider: {
|
|
2267
|
+
name: "mock-provider",
|
|
2268
|
+
async sendMessage() {
|
|
2269
|
+
throw new Error("API key has expired.");
|
|
2270
|
+
},
|
|
2271
|
+
} as unknown as Provider,
|
|
2272
|
+
});
|
|
2273
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
|
|
2274
|
+
|
|
2275
|
+
expect(
|
|
2276
|
+
events.filter((event) => event.type === "assistant_text_delta"),
|
|
2277
|
+
).toHaveLength(0);
|
|
2278
|
+
|
|
2279
|
+
const conversationError = events.find(
|
|
2280
|
+
(event) => event.type === "conversation_error",
|
|
2281
|
+
);
|
|
2282
|
+
expect(conversationError).toBeDefined();
|
|
2283
|
+
expect(conversationError).toMatchObject({
|
|
2284
|
+
code: "MANAGED_KEY_INVALID",
|
|
2285
|
+
userMessage: "Couldn't refresh assistant credentials.",
|
|
2286
|
+
errorCategory: "managed_key_invalid",
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
expect(addMessageMock).not.toHaveBeenCalled();
|
|
2290
|
+
expect(recordRequestLogMock).not.toHaveBeenCalled();
|
|
2291
|
+
expect(backfillMessageIdOnLogsMock).not.toHaveBeenCalled();
|
|
2292
|
+
expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
|
|
2293
|
+
const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
|
|
2294
|
+
string,
|
|
2295
|
+
];
|
|
2296
|
+
expect(deleteCall[0]).toBe("msg-reserve");
|
|
2297
|
+
});
|
|
2244
2298
|
});
|
|
2245
2299
|
|
|
2246
2300
|
describe("B3 pre-allocation: indexing + cleanup", () => {
|
|
@@ -2452,6 +2506,41 @@ describe("session-agent-loop", () => {
|
|
|
2452
2506
|
expect(lastSync?.[1]).toBe("mock-msg-id");
|
|
2453
2507
|
expect(lastSync?.[1]).not.toBe("msg-orphaned-reservation");
|
|
2454
2508
|
});
|
|
2509
|
+
|
|
2510
|
+
test("managed-key provider-error cleanup publishes message invalidation after deleting the reservation", async () => {
|
|
2511
|
+
reserveMessageMock.mockImplementationOnce(async () => ({
|
|
2512
|
+
id: "msg-managed-key-reservation",
|
|
2513
|
+
}));
|
|
2514
|
+
mockConversationErrorClassification = {
|
|
2515
|
+
code: "MANAGED_KEY_INVALID",
|
|
2516
|
+
userMessage: "Couldn't refresh assistant credentials.",
|
|
2517
|
+
retryable: false,
|
|
2518
|
+
errorCategory: "managed_key_invalid",
|
|
2519
|
+
};
|
|
2520
|
+
|
|
2521
|
+
const ctx = makeCtx({
|
|
2522
|
+
loopProvider: {
|
|
2523
|
+
name: "mock-provider",
|
|
2524
|
+
async sendMessage() {
|
|
2525
|
+
throw new Error("API key has expired.");
|
|
2526
|
+
},
|
|
2527
|
+
} as unknown as Provider,
|
|
2528
|
+
});
|
|
2529
|
+
await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
|
|
2530
|
+
|
|
2531
|
+
expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
|
|
2532
|
+
const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
|
|
2533
|
+
string,
|
|
2534
|
+
];
|
|
2535
|
+
expect(deleteCall[0]).toBe("msg-managed-key-reservation");
|
|
2536
|
+
expect(addMessageMock).not.toHaveBeenCalled();
|
|
2537
|
+
expect(syncMessageToDiskMock).not.toHaveBeenCalled();
|
|
2538
|
+
|
|
2539
|
+
const messagePublishes = (
|
|
2540
|
+
publishSyncInvalidationMock.mock.calls as unknown as Array<[string[]]>
|
|
2541
|
+
).filter((args) => args[0]?.includes("conversation:test-conv:messages"));
|
|
2542
|
+
expect(messagePublishes).toHaveLength(1);
|
|
2543
|
+
});
|
|
2455
2544
|
});
|
|
2456
2545
|
|
|
2457
2546
|
describe("partial persistence", () => {
|
|
@@ -623,6 +623,24 @@ describe("classifyConversationError", () => {
|
|
|
623
623
|
expect(result.errorCategory).toBe("provider_invalid_key");
|
|
624
624
|
});
|
|
625
625
|
|
|
626
|
+
it("classifies managed-proxy auth failures as managed credential refresh failures", () => {
|
|
627
|
+
providerRoutingSources.anthropic = "managed-proxy";
|
|
628
|
+
const err = new ProviderError(
|
|
629
|
+
'Anthropic API error (403): {"detail":"API key has expired."}',
|
|
630
|
+
"anthropic",
|
|
631
|
+
403,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
const result = classifyConversationError(err, baseCtx);
|
|
635
|
+
|
|
636
|
+
expect(result.code).toBe("MANAGED_KEY_INVALID");
|
|
637
|
+
expect(result.userMessage).toBe(
|
|
638
|
+
"Couldn't refresh assistant credentials.",
|
|
639
|
+
);
|
|
640
|
+
expect(result.retryable).toBe(false);
|
|
641
|
+
expect(result.errorCategory).toBe("managed_key_invalid");
|
|
642
|
+
});
|
|
643
|
+
|
|
626
644
|
it("classifies ProviderError 401 with 'invalid x-api-key' message as PROVIDER_INVALID_KEY", () => {
|
|
627
645
|
// Regex-match branch — Anthropic's standard 401 wording.
|
|
628
646
|
const err = new ProviderError(
|
|
@@ -426,19 +426,32 @@ export function seedInferenceProfiles(
|
|
|
426
426
|
}
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
-
// Advisor profile: default to the strongest
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
429
|
+
// Advisor profile: BYOK hatches default to the strongest personal profile
|
|
430
|
+
// backed by the entered provider key. Managed-profile hatches and registered
|
|
431
|
+
// platform installs default to the strongest active managed profile.
|
|
432
|
+
const requestedAdvisorProfile = readString(llm.advisorProfile);
|
|
433
|
+
const requestedAdvisorEntry =
|
|
434
|
+
requestedAdvisorProfile !== undefined
|
|
435
|
+
? readObject(profiles[requestedAdvisorProfile])
|
|
436
|
+
: null;
|
|
437
|
+
const requestedAdvisorIsDisabledManaged =
|
|
438
|
+
requestedAdvisorEntry?.source === "managed" &&
|
|
439
|
+
requestedAdvisorEntry.status === "disabled";
|
|
440
|
+
const preferPersonalAdvisor =
|
|
441
|
+
userConnectionName !== undefined &&
|
|
442
|
+
hatchSelectedManagedConnection === undefined;
|
|
443
|
+
if (
|
|
444
|
+
requestedAdvisorProfile === undefined ||
|
|
445
|
+
requestedAdvisorIsDisabledManaged
|
|
446
|
+
) {
|
|
447
|
+
const defaultAdvisorProfile = selectDefaultAdvisorProfile(
|
|
448
|
+
profiles,
|
|
449
|
+
preferPersonalAdvisor,
|
|
450
|
+
);
|
|
451
|
+
if (defaultAdvisorProfile) {
|
|
452
|
+
llm.advisorProfile = defaultAdvisorProfile;
|
|
453
|
+
} else if (requestedAdvisorIsDisabledManaged) {
|
|
454
|
+
delete llm.advisorProfile;
|
|
442
455
|
}
|
|
443
456
|
}
|
|
444
457
|
|
|
@@ -578,6 +591,48 @@ function readString(value: unknown): string | undefined {
|
|
|
578
591
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
579
592
|
}
|
|
580
593
|
|
|
594
|
+
function selectDefaultAdvisorProfile(
|
|
595
|
+
profiles: Record<string, Record<string, unknown>>,
|
|
596
|
+
preferPersonalProfile: boolean,
|
|
597
|
+
): string | undefined {
|
|
598
|
+
const personal = firstActiveProfile(profiles, [
|
|
599
|
+
"custom-quality-optimized",
|
|
600
|
+
"custom-balanced",
|
|
601
|
+
"custom-cost-optimized",
|
|
602
|
+
]);
|
|
603
|
+
const managed = firstActiveManagedProfile(profiles, [
|
|
604
|
+
"frontier",
|
|
605
|
+
"quality-optimized",
|
|
606
|
+
"balanced",
|
|
607
|
+
"cost-optimized",
|
|
608
|
+
]);
|
|
609
|
+
return preferPersonalProfile ? (personal ?? managed) : (managed ?? personal);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function firstActiveProfile(
|
|
613
|
+
profiles: Record<string, Record<string, unknown>>,
|
|
614
|
+
names: string[],
|
|
615
|
+
): string | undefined {
|
|
616
|
+
for (const name of names) {
|
|
617
|
+
const profile = readObject(profiles[name]);
|
|
618
|
+
if (profile && profile.status !== "disabled") return name;
|
|
619
|
+
}
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function firstActiveManagedProfile(
|
|
624
|
+
profiles: Record<string, Record<string, unknown>>,
|
|
625
|
+
names: string[],
|
|
626
|
+
): string | undefined {
|
|
627
|
+
for (const name of names) {
|
|
628
|
+
const profile = readObject(profiles[name]);
|
|
629
|
+
if (profile?.source === "managed" && profile.status !== "disabled") {
|
|
630
|
+
return name;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
581
636
|
function getHatchSelectedManagedConnection(
|
|
582
637
|
llm: Record<string, unknown>,
|
|
583
638
|
profiles: Record<string, Record<string, unknown>>,
|
|
@@ -102,6 +102,12 @@ import type {
|
|
|
102
102
|
|
|
103
103
|
const log = getLogger("agent-loop-handlers");
|
|
104
104
|
|
|
105
|
+
function shouldPersistProviderErrorAsAssistantMessage(classified: {
|
|
106
|
+
code: string;
|
|
107
|
+
}): boolean {
|
|
108
|
+
return classified.code !== "MANAGED_KEY_INVALID";
|
|
109
|
+
}
|
|
110
|
+
|
|
105
111
|
/**
|
|
106
112
|
* Persist the history-stripped marker after the loop strips runtime injections
|
|
107
113
|
* for compaction / overflow recovery. The marker is a durability hint, not
|
|
@@ -161,6 +167,7 @@ export interface EventHandlerState {
|
|
|
161
167
|
readonly exchangeRawResponses: unknown[];
|
|
162
168
|
model: string;
|
|
163
169
|
providerErrorUserMessage: string | null;
|
|
170
|
+
persistProviderErrorAsAssistantMessage: boolean;
|
|
164
171
|
lastAssistantMessageId: string | undefined;
|
|
165
172
|
/**
|
|
166
173
|
* True when `handleLlmCallStarted` has reserved an empty assistant row
|
|
@@ -339,6 +346,7 @@ export function createEventHandlerState(): EventHandlerState {
|
|
|
339
346
|
exchangeRawResponses: [],
|
|
340
347
|
model: "",
|
|
341
348
|
providerErrorUserMessage: null,
|
|
349
|
+
persistProviderErrorAsAssistantMessage: false,
|
|
342
350
|
lastAssistantMessageId: undefined,
|
|
343
351
|
assistantRowAwaitingFinalization: false,
|
|
344
352
|
pendingToolResults: new Map(),
|
|
@@ -1616,6 +1624,8 @@ function handleError(
|
|
|
1616
1624
|
buildConversationErrorMessage(deps.ctx.conversationId, classified),
|
|
1617
1625
|
);
|
|
1618
1626
|
state.providerErrorUserMessage = classified.userMessage;
|
|
1627
|
+
state.persistProviderErrorAsAssistantMessage =
|
|
1628
|
+
shouldPersistProviderErrorAsAssistantMessage(classified);
|
|
1619
1629
|
}
|
|
1620
1630
|
|
|
1621
1631
|
export function handleMaxTokensReached(
|
|
@@ -2129,6 +2139,13 @@ function handleProviderError(
|
|
|
2129
2139
|
deps: EventHandlerDeps,
|
|
2130
2140
|
event: Extract<AgentEvent, { type: "provider_error" }>,
|
|
2131
2141
|
): void {
|
|
2142
|
+
const classified = classifyConversationError(event.error, {
|
|
2143
|
+
phase: "agent_loop",
|
|
2144
|
+
});
|
|
2145
|
+
if (!shouldPersistProviderErrorAsAssistantMessage(classified)) {
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2132
2149
|
try {
|
|
2133
2150
|
recordRequestLog(
|
|
2134
2151
|
deps.ctx.conversationId,
|
|
@@ -520,12 +520,14 @@ export async function runAgentLoopImpl(
|
|
|
520
520
|
let turnStarted = false;
|
|
521
521
|
const state = createEventHandlerState();
|
|
522
522
|
let persistedErrorAssistantMessage = false;
|
|
523
|
+
let deletedReservedAssistantMessage = false;
|
|
523
524
|
|
|
524
525
|
const publishLoopMessagesChanged = (): void => {
|
|
525
526
|
if (
|
|
526
527
|
state.lastAssistantMessageId ||
|
|
527
528
|
state.persistedToolUseIds.size > 0 ||
|
|
528
|
-
persistedErrorAssistantMessage
|
|
529
|
+
persistedErrorAssistantMessage ||
|
|
530
|
+
deletedReservedAssistantMessage
|
|
529
531
|
) {
|
|
530
532
|
publishConversationMessagesChanged(ctx.conversationId);
|
|
531
533
|
}
|
|
@@ -1151,23 +1153,22 @@ export async function runAgentLoopImpl(
|
|
|
1151
1153
|
!abortController.signal.aborted &&
|
|
1152
1154
|
!yieldedForHandoff
|
|
1153
1155
|
) {
|
|
1154
|
-
// Drop any reservation stranded by the failed LLM call
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
// (`syncLastAssistantMessageToDisk`) would mis-target the empty
|
|
1162
|
-
// row. After delete we set `lastAssistantMessageId` to the new
|
|
1163
|
-
// error row's id so the post-loop emission paths still point at
|
|
1164
|
-
// a real message.
|
|
1156
|
+
// Drop any reservation stranded by the failed LLM call. The B3
|
|
1157
|
+
// pre-allocation path reserves an empty assistant row at
|
|
1158
|
+
// `llm_call_started`; when the call exits through the provider-error
|
|
1159
|
+
// branch (no `message_complete`), `assistantRowAwaitingFinalization`
|
|
1160
|
+
// stays true. Without this delete the transcript would carry an empty
|
|
1161
|
+
// reserved row, and downstream sync (`syncLastAssistantMessageToDisk`)
|
|
1162
|
+
// would target it.
|
|
1165
1163
|
if (
|
|
1166
1164
|
state.assistantRowAwaitingFinalization &&
|
|
1167
1165
|
state.lastAssistantMessageId
|
|
1168
1166
|
) {
|
|
1169
1167
|
try {
|
|
1170
1168
|
deleteMessageById(state.lastAssistantMessageId);
|
|
1169
|
+
deletedReservedAssistantMessage = true;
|
|
1170
|
+
state.lastAssistantMessageId = undefined;
|
|
1171
|
+
state.assistantRowAwaitingFinalization = false;
|
|
1171
1172
|
} catch (err) {
|
|
1172
1173
|
rlog.warn(
|
|
1173
1174
|
{ err, messageId: state.lastAssistantMessageId },
|
|
@@ -1175,57 +1176,63 @@ export async function runAgentLoopImpl(
|
|
|
1175
1176
|
);
|
|
1176
1177
|
}
|
|
1177
1178
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
// message id. The previous reservation (if any) was already deleted
|
|
1201
|
-
// above. Mark finalization complete so the next LLM call in this run
|
|
1202
|
-
// (or a downstream handler) doesn't try to clean up an id that
|
|
1203
|
-
// already corresponds to a finalized row.
|
|
1204
|
-
state.lastAssistantMessageId = errorRow.id;
|
|
1205
|
-
state.assistantRowAwaitingFinalization = false;
|
|
1206
|
-
newMessages.push(errorAssistantMessage);
|
|
1207
|
-
// Pipe the just-assigned message id into any orphaned LLM request log
|
|
1208
|
-
// row(s) for this turn. The success path links rows via
|
|
1209
|
-
// `handleMessageComplete` -> `backfillMessageIdOnLogs`, but provider-
|
|
1210
|
-
// failure turns never fire `message_complete` (the synthetic assistant
|
|
1211
|
-
// message is persisted directly above), so without this call the rows
|
|
1212
|
-
// from `handleProviderError` stay with `message_id IS NULL` and a
|
|
1213
|
-
// later turn's backfill sweep would wrong-attach them to that turn's
|
|
1214
|
-
// assistant message. Scope is per-conversation, so concurrent runs on
|
|
1215
|
-
// other conversations cannot collide. Non-fatal — a DB hiccup must
|
|
1216
|
-
// not escalate a provider rejection into a turn-level throw.
|
|
1217
|
-
try {
|
|
1218
|
-
backfillMessageIdOnLogs(ctx.conversationId, errorRow.id);
|
|
1219
|
-
} catch (err) {
|
|
1220
|
-
rlog.warn(
|
|
1221
|
-
{ err },
|
|
1222
|
-
"Failed to backfill message_id on provider-error LLM request logs (non-fatal)",
|
|
1179
|
+
if (!state.persistProviderErrorAsAssistantMessage) {
|
|
1180
|
+
state.assistantRowAwaitingFinalization = false;
|
|
1181
|
+
state.lastAssistantMessageId = undefined;
|
|
1182
|
+
} else {
|
|
1183
|
+
const errChannelMeta = {
|
|
1184
|
+
...provenanceFromTrustContext(ctx.trustContext),
|
|
1185
|
+
userMessageChannel: capturedTurnChannelContext.userMessageChannel,
|
|
1186
|
+
assistantMessageChannel:
|
|
1187
|
+
capturedTurnChannelContext.assistantMessageChannel,
|
|
1188
|
+
userMessageInterface:
|
|
1189
|
+
capturedTurnInterfaceContext.userMessageInterface,
|
|
1190
|
+
assistantMessageInterface:
|
|
1191
|
+
capturedTurnInterfaceContext.assistantMessageInterface,
|
|
1192
|
+
};
|
|
1193
|
+
const errorAssistantMessage = createAssistantMessage(
|
|
1194
|
+
state.providerErrorUserMessage,
|
|
1195
|
+
);
|
|
1196
|
+
const errorRow = await addMessage(
|
|
1197
|
+
ctx.conversationId,
|
|
1198
|
+
"assistant",
|
|
1199
|
+
JSON.stringify(errorAssistantMessage.content),
|
|
1200
|
+
{ metadata: errChannelMeta },
|
|
1223
1201
|
);
|
|
1202
|
+
persistedErrorAssistantMessage = true;
|
|
1203
|
+
// Repoint `lastAssistantMessageId` at the synthetic error row so the
|
|
1204
|
+
// post-loop sync, attachment resolution, and `message_complete`/
|
|
1205
|
+
// `generation_handoff` emissions all reference a real, persisted
|
|
1206
|
+
// message id. The previous reservation (if any) was already deleted
|
|
1207
|
+
// above. Mark finalization complete so the next LLM call in this run
|
|
1208
|
+
// (or a downstream handler) doesn't try to clean up an id that
|
|
1209
|
+
// already corresponds to a finalized row.
|
|
1210
|
+
state.lastAssistantMessageId = errorRow.id;
|
|
1211
|
+
state.assistantRowAwaitingFinalization = false;
|
|
1212
|
+
newMessages.push(errorAssistantMessage);
|
|
1213
|
+
// Pipe the just-assigned message id into any orphaned LLM request log
|
|
1214
|
+
// row(s) for this turn. The success path links rows via
|
|
1215
|
+
// `handleMessageComplete` -> `backfillMessageIdOnLogs`, but provider-
|
|
1216
|
+
// failure turns never fire `message_complete` (the synthetic assistant
|
|
1217
|
+
// message is persisted directly above), so without this call the rows
|
|
1218
|
+
// from `handleProviderError` stay with `message_id IS NULL` and a
|
|
1219
|
+
// later turn's backfill sweep would wrong-attach them to that turn's
|
|
1220
|
+
// assistant message. Scope is per-conversation, so concurrent runs on
|
|
1221
|
+
// other conversations cannot collide. Non-fatal — a DB hiccup must
|
|
1222
|
+
// not escalate a provider rejection into a turn-level throw.
|
|
1223
|
+
try {
|
|
1224
|
+
backfillMessageIdOnLogs(ctx.conversationId, errorRow.id);
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
rlog.warn(
|
|
1227
|
+
{ err },
|
|
1228
|
+
"Failed to backfill message_id on provider-error LLM request logs (non-fatal)",
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
// Do NOT send assistant_text_delta here — handleProviderError already
|
|
1232
|
+
// emitted a conversation_error event for this same error text, and the
|
|
1233
|
+
// client renders it as an InlineChatErrorAlert. Sending a text delta
|
|
1234
|
+
// would create a duplicate plain-text bubble below the alert card.
|
|
1224
1235
|
}
|
|
1225
|
-
// Do NOT send assistant_text_delta here — handleProviderError already
|
|
1226
|
-
// emitted a conversation_error event for this same error text, and the
|
|
1227
|
-
// client renders it as an InlineChatErrorAlert. Sending a text delta
|
|
1228
|
-
// would create a duplicate plain-text bubble below the alert card.
|
|
1229
1236
|
}
|
|
1230
1237
|
|
|
1231
1238
|
// Base persisted into `ctx.messages` is the loop's own returned history
|
|
@@ -315,20 +315,17 @@ function classifyCore(
|
|
|
315
315
|
}
|
|
316
316
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
317
317
|
// Both managed-proxy and user-key 401/403s reach this branch.
|
|
318
|
-
// Managed-proxy routes through the assistant API key
|
|
319
|
-
//
|
|
320
|
-
// user-set credential that the upstream provider
|
|
321
|
-
// `PROVIDER_INVALID_KEY`
|
|
322
|
-
//
|
|
323
|
-
// which only fires when the key is genuinely missing — see
|
|
324
|
-
// `providerNotConfiguredClassification`).
|
|
318
|
+
// Managed-proxy routes through the assistant API key; if that
|
|
319
|
+
// credential is stale, the user cannot fix it from model settings.
|
|
320
|
+
// Everything else is a user-set credential that the upstream provider
|
|
321
|
+
// rejected, so emit `PROVIDER_INVALID_KEY` and let the chat banner point
|
|
322
|
+
// at Settings.
|
|
325
323
|
const providerName = error.provider;
|
|
326
324
|
if (getProviderRoutingSource(providerName) === "managed-proxy") {
|
|
327
325
|
return {
|
|
328
326
|
code: "MANAGED_KEY_INVALID",
|
|
329
|
-
userMessage:
|
|
330
|
-
|
|
331
|
-
retryable: true,
|
|
327
|
+
userMessage: "Couldn't refresh assistant credentials.",
|
|
328
|
+
retryable: false,
|
|
332
329
|
errorCategory: "managed_key_invalid",
|
|
333
330
|
};
|
|
334
331
|
}
|
|
@@ -116,6 +116,8 @@ export interface FormSurfaceData {
|
|
|
116
116
|
submitLabel?: string;
|
|
117
117
|
pages?: FormPage[];
|
|
118
118
|
pageLabels?: { next?: string; back?: string; submit?: string };
|
|
119
|
+
/** Progress indicator style for multi-page forms: segment bar or labeled tabs. */
|
|
120
|
+
progressStyle?: "bar" | "tabs";
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
export interface ListItem {
|