@vellumai/assistant 0.11.1-dev.202608032128.6f59e1c → 0.11.1-dev.202608032226.351a4e2
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-error.test.ts +106 -0
- package/src/__tests__/llm-resolver.test.ts +16 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/config/llm-resolver.ts +19 -3
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -6
- package/src/daemon/conversation-agent-loop.ts +31 -18
- package/src/daemon/conversation-error.ts +40 -24
- package/src/live-voice/__tests__/activity-label.test.ts +68 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +14 -5
- package/src/live-voice/activity-label.ts +106 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +58 -0
- package/src/live-voice/protocol.ts +23 -0
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/types.ts +7 -1
- package/src/util/errors.ts +15 -0
package/package.json
CHANGED
|
@@ -170,6 +170,112 @@ describe("classifyConversationError", () => {
|
|
|
170
170
|
expect(result.userMessage).toContain("AI provider");
|
|
171
171
|
expect(result.errorCategory).toBe("rate_limit");
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
it("uses the ChatGPT OAuth route instead of the OpenAI registry default", () => {
|
|
175
|
+
providerRoutingSources.openai = "managed-proxy";
|
|
176
|
+
const err = new ProviderError(
|
|
177
|
+
"OpenAI API error (429): Too many requests",
|
|
178
|
+
"openai",
|
|
179
|
+
429,
|
|
180
|
+
{ reason: "rate_limited" },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const result = classifyConversationError(err, {
|
|
184
|
+
...baseCtx,
|
|
185
|
+
connectionName: "chatgpt-subscription",
|
|
186
|
+
isManagedRoute: false,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(result.code).toBe("PROVIDER_RATE_LIMIT");
|
|
190
|
+
expect(result.userMessage).toContain("AI provider");
|
|
191
|
+
expect(result.errorCategory).toBe("rate_limit");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("prefers the failed call's direct route over stale managed turn attribution", () => {
|
|
195
|
+
providerRoutingSources.openai = "managed-proxy";
|
|
196
|
+
const err = new ProviderError(
|
|
197
|
+
"OpenAI API error (429): Too many requests",
|
|
198
|
+
"openai",
|
|
199
|
+
429,
|
|
200
|
+
{ reason: "rate_limited" },
|
|
201
|
+
);
|
|
202
|
+
err.attachRouteAttribution({
|
|
203
|
+
connectionName: "chatgpt-subscription",
|
|
204
|
+
profileName: "chatgpt",
|
|
205
|
+
isManagedRoute: false,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const result = classifyConversationError(err, {
|
|
209
|
+
...baseCtx,
|
|
210
|
+
connectionName: "vellum",
|
|
211
|
+
profileName: "managed",
|
|
212
|
+
isManagedRoute: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.code).toBe("PROVIDER_RATE_LIMIT");
|
|
216
|
+
expect(result.userMessage).toContain("AI provider");
|
|
217
|
+
expect(result.errorCategory).toBe("rate_limit");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("prefers the failed call's managed fallback over stale direct turn attribution", () => {
|
|
221
|
+
const err = new ProviderError(
|
|
222
|
+
"Anthropic API error (429): Too many requests",
|
|
223
|
+
"anthropic",
|
|
224
|
+
429,
|
|
225
|
+
{ reason: "rate_limited" },
|
|
226
|
+
);
|
|
227
|
+
err.attachRouteAttribution({
|
|
228
|
+
profileName: "direct",
|
|
229
|
+
isManagedRoute: true,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = classifyConversationError(err, {
|
|
233
|
+
...baseCtx,
|
|
234
|
+
connectionName: "anthropic-key",
|
|
235
|
+
profileName: "direct",
|
|
236
|
+
isManagedRoute: false,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.code).toBe("MANAGED_USAGE_LIMIT");
|
|
240
|
+
expect(result.userMessage).toContain("Vellum managed inference");
|
|
241
|
+
expect(result.errorCategory).toBe("managed_usage_limit");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not apply a managed LLM route to a plain rate-limit error", () => {
|
|
245
|
+
const result = classifyConversationError(
|
|
246
|
+
new Error("429 Too Many Requests from a tool API"),
|
|
247
|
+
{
|
|
248
|
+
...baseCtx,
|
|
249
|
+
isManagedRoute: true,
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(result.code).toBe("PROVIDER_RATE_LIMIT");
|
|
254
|
+
expect(result.errorCategory).toBe("rate_limit");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("falls back to context for fields the failed call's route omits", () => {
|
|
258
|
+
const err = new ProviderError("Unauthorized", "anthropic", 401);
|
|
259
|
+
err.attachRouteAttribution({ profileName: "direct" });
|
|
260
|
+
|
|
261
|
+
const result = classifyConversationError(err, {
|
|
262
|
+
...baseCtx,
|
|
263
|
+
connectionName: "anthropic-key",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.connectionName).toBe("anthropic-key");
|
|
267
|
+
expect(result.profileName).toBe("direct");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("still recognizes a rewrapped Vellum quota body", () => {
|
|
271
|
+
const result = classifyConversationError(
|
|
272
|
+
new Error('429 {"code":"daily_quota_exceeded"}'),
|
|
273
|
+
baseCtx,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(result.code).toBe("MANAGED_USAGE_LIMIT");
|
|
277
|
+
expect(result.errorCategory).toBe("managed_usage_limit");
|
|
278
|
+
});
|
|
173
279
|
});
|
|
174
280
|
|
|
175
281
|
describe("provider overloaded errors", () => {
|
|
@@ -6,6 +6,7 @@ import { CODE_DEFAULT_PROFILE_ENTRIES } from "../config/default-profile-catalog.
|
|
|
6
6
|
import {
|
|
7
7
|
type ResolutionFallbackReason,
|
|
8
8
|
resolveCallSiteConfig,
|
|
9
|
+
resolveCallSiteConfigWithProfile,
|
|
9
10
|
resolveDefaultProfileKey,
|
|
10
11
|
resolveEffectiveProfileKey,
|
|
11
12
|
} from "../config/llm-resolver.js";
|
|
@@ -859,6 +860,21 @@ describe("mix profiles", () => {
|
|
|
859
860
|
}
|
|
860
861
|
});
|
|
861
862
|
|
|
863
|
+
test("config and profile attribution share one mix selection", () => {
|
|
864
|
+
const selectedArms: string[] = [];
|
|
865
|
+
const resolved = resolveCallSiteConfigWithProfile("mainAgent", mixLlm, {
|
|
866
|
+
onMixSelected: ({ chosenProfile }) => {
|
|
867
|
+
selectedArms.push(chosenProfile);
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
expect(resolved.profileName).toBe("ab");
|
|
872
|
+
expect(selectedArms).toHaveLength(1);
|
|
873
|
+
expect(resolved.config.model).toBe(
|
|
874
|
+
selectedArms[0] === "a" ? "model-a" : "model-b",
|
|
875
|
+
);
|
|
876
|
+
});
|
|
877
|
+
|
|
862
878
|
test("all dereference spots in a turn agree for the same seed", () => {
|
|
863
879
|
// mainAgent (mix as activeProfile) and a non-main call site resolving the
|
|
864
880
|
// same mix as its call-site profile must pick the same arm when given the
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ProviderResponse,
|
|
28
28
|
SendMessageOptions,
|
|
29
29
|
} from "../providers/types.js";
|
|
30
|
+
import { ProviderError } from "../util/errors.js";
|
|
30
31
|
import { setConfig } from "./helpers/set-config.js";
|
|
31
32
|
|
|
32
33
|
const DUMMY_MESSAGES: Message[] = [
|
|
@@ -239,6 +240,91 @@ describe("SendMessageOptions.config.overrideProfile", () => {
|
|
|
239
240
|
expect(response.model).toBe("openai");
|
|
240
241
|
});
|
|
241
242
|
|
|
243
|
+
test("CallSiteRoutingProvider attributes provider errors to the actual resolved connection", async () => {
|
|
244
|
+
setLlmConfig({
|
|
245
|
+
profiles: {
|
|
246
|
+
fast: {
|
|
247
|
+
provider: "openai",
|
|
248
|
+
provider_connection: "openai-conn",
|
|
249
|
+
model: "gpt-5.4",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const providerError = new ProviderError(
|
|
255
|
+
"OpenAI API error (429): Too many requests",
|
|
256
|
+
"openai",
|
|
257
|
+
429,
|
|
258
|
+
{ reason: "rate_limited" },
|
|
259
|
+
);
|
|
260
|
+
const defaultProvider = makeThrowingProvider(
|
|
261
|
+
"anthropic",
|
|
262
|
+
new Error("default provider should not be called"),
|
|
263
|
+
);
|
|
264
|
+
const altProvider = makeThrowingProvider("openai", providerError);
|
|
265
|
+
altProvider.routeAttribution = {
|
|
266
|
+
connectionName: "openai-recovered",
|
|
267
|
+
isManagedRoute: false,
|
|
268
|
+
};
|
|
269
|
+
const wrapped = new CallSiteRoutingProvider(
|
|
270
|
+
defaultProvider,
|
|
271
|
+
async (connectionName) =>
|
|
272
|
+
connectionName === "openai-conn" ? altProvider : null,
|
|
273
|
+
{ connectionName: "vellum", isManagedRoute: true },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await expect(
|
|
277
|
+
wrapped.sendMessage(DUMMY_MESSAGES, {
|
|
278
|
+
config: { callSite: "mainAgent", overrideProfile: "fast" },
|
|
279
|
+
}),
|
|
280
|
+
).rejects.toBe(providerError);
|
|
281
|
+
expect(providerError.routeAttribution).toEqual({
|
|
282
|
+
connectionName: "openai-recovered",
|
|
283
|
+
profileName: "fast",
|
|
284
|
+
isManagedRoute: false,
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("CallSiteRoutingProvider attributes soft fallback errors to the actual default connection", async () => {
|
|
289
|
+
setLlmConfig({
|
|
290
|
+
profiles: {
|
|
291
|
+
fast: {
|
|
292
|
+
provider: "openai",
|
|
293
|
+
provider_connection: "openai-conn",
|
|
294
|
+
model: "gpt-5.4",
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const providerError = new ProviderError(
|
|
300
|
+
"Anthropic API error (429): Too many requests",
|
|
301
|
+
"anthropic",
|
|
302
|
+
429,
|
|
303
|
+
{ reason: "rate_limited" },
|
|
304
|
+
);
|
|
305
|
+
const defaultProvider = makeThrowingProvider("anthropic", providerError);
|
|
306
|
+
defaultProvider.routeAttribution = {
|
|
307
|
+
connectionName: "anthropic-default",
|
|
308
|
+
isManagedRoute: false,
|
|
309
|
+
};
|
|
310
|
+
const wrapped = new CallSiteRoutingProvider(
|
|
311
|
+
defaultProvider,
|
|
312
|
+
async () => null,
|
|
313
|
+
defaultProvider.routeAttribution,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await expect(
|
|
317
|
+
wrapped.sendMessage(DUMMY_MESSAGES, {
|
|
318
|
+
config: { callSite: "mainAgent", overrideProfile: "fast" },
|
|
319
|
+
}),
|
|
320
|
+
).rejects.toBe(providerError);
|
|
321
|
+
expect(providerError.routeAttribution).toEqual({
|
|
322
|
+
connectionName: "anthropic-default",
|
|
323
|
+
profileName: "fast",
|
|
324
|
+
isManagedRoute: false,
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
242
328
|
test("missing overrideProfile name silently falls through to base resolution", async () => {
|
|
243
329
|
setLlmConfig({
|
|
244
330
|
// The call-site tweak applies last in resolution, so it pins the model
|
|
@@ -328,6 +414,15 @@ describe("SendMessageOptions.config.overrideProfile", () => {
|
|
|
328
414
|
});
|
|
329
415
|
});
|
|
330
416
|
|
|
417
|
+
function makeThrowingProvider(name: string, error: Error): Provider {
|
|
418
|
+
return {
|
|
419
|
+
name,
|
|
420
|
+
async sendMessage(): Promise<ProviderResponse> {
|
|
421
|
+
throw error;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
331
426
|
describe("SendMessageOptions.config.forceOverrideProfile", () => {
|
|
332
427
|
test("CallSiteConfiguredProvider forwards forceOverrideProfile into the send config", async () => {
|
|
333
428
|
let captured: SendMessageOptions | undefined;
|
|
@@ -114,12 +114,29 @@ export interface ResolveCallSiteOpts {
|
|
|
114
114
|
|
|
115
115
|
export type ResolutionFallbackReason = "missing" | "disabled" | "incomplete";
|
|
116
116
|
|
|
117
|
+
export interface ResolvedCallSiteConfig {
|
|
118
|
+
config: z.infer<typeof LLMConfigBase>;
|
|
119
|
+
profileName?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveCallSiteConfigWithProfile(
|
|
123
|
+
callSite: LLMCallSite,
|
|
124
|
+
llm: z.infer<typeof LLMSchema>,
|
|
125
|
+
opts: ResolveCallSiteOpts = {},
|
|
126
|
+
): ResolvedCallSiteConfig {
|
|
127
|
+
const selection = selectWinningProfile(callSite, llm, opts);
|
|
128
|
+
return {
|
|
129
|
+
config: resolveOverrideOrDefault(callSite, llm, selection),
|
|
130
|
+
...(selection.profileName ? { profileName: selection.profileName } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
export function resolveCallSiteConfig(
|
|
118
135
|
callSite: LLMCallSite,
|
|
119
136
|
llm: z.infer<typeof LLMSchema>,
|
|
120
137
|
opts: ResolveCallSiteOpts = {},
|
|
121
138
|
): z.infer<typeof LLMConfigBase> {
|
|
122
|
-
return
|
|
139
|
+
return resolveCallSiteConfigWithProfile(callSite, llm, opts).config;
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
// ─── Single-winner profile resolution ───────────────────────────────────────
|
|
@@ -358,9 +375,8 @@ const CODE_DEFAULT_BASE: z.infer<typeof LLMConfigBase> = LLMConfigBase.parse(
|
|
|
358
375
|
function resolveOverrideOrDefault(
|
|
359
376
|
callSite: LLMCallSite,
|
|
360
377
|
llm: z.infer<typeof LLMSchema>,
|
|
361
|
-
|
|
378
|
+
selection: ProfileWinnerSelection,
|
|
362
379
|
): z.infer<typeof LLMConfigBase> {
|
|
363
|
-
const selection = selectWinningProfile(callSite, llm, opts);
|
|
364
380
|
const winnerFragment: Mergeable =
|
|
365
381
|
selection.entry == null ? {} : winnerConfigFragment(selection.entry);
|
|
366
382
|
|
|
@@ -120,6 +120,7 @@ import type { AssistantSurface } from "./conversation-agent-loop.js";
|
|
|
120
120
|
import {
|
|
121
121
|
buildConversationErrorMessage,
|
|
122
122
|
classifyConversationError,
|
|
123
|
+
type ConversationErrorAttribution,
|
|
123
124
|
maxTokensReachedClassification,
|
|
124
125
|
} from "./conversation-error.js";
|
|
125
126
|
import { buildDeferredFinalizeEffect } from "./conversation-turn-finalize.js";
|
|
@@ -555,6 +556,8 @@ export interface EventHandlerDeps {
|
|
|
555
556
|
* degrades gracefully when it's absent.
|
|
556
557
|
*/
|
|
557
558
|
readonly latencyTracker?: TurnLatencyTracker;
|
|
559
|
+
/** Best-effort resolved route metadata for provider error classification. */
|
|
560
|
+
readonly errorAttribution?: () => ConversationErrorAttribution;
|
|
558
561
|
}
|
|
559
562
|
|
|
560
563
|
// ── Factory ──────────────────────────────────────────────────────────
|
|
@@ -2628,9 +2631,7 @@ function handleError(
|
|
|
2628
2631
|
deps: EventHandlerDeps,
|
|
2629
2632
|
event: Extract<AgentEvent, { type: "error" }>,
|
|
2630
2633
|
): void {
|
|
2631
|
-
const classified =
|
|
2632
|
-
phase: "agent_loop",
|
|
2633
|
-
});
|
|
2634
|
+
const classified = classifyAgentLoopError(event.error, deps);
|
|
2634
2635
|
if (classified.errorCategory === "provider_api_error") {
|
|
2635
2636
|
log.error(
|
|
2636
2637
|
{
|
|
@@ -3140,9 +3141,7 @@ function handleProviderError(
|
|
|
3140
3141
|
deps: EventHandlerDeps,
|
|
3141
3142
|
event: Extract<AgentEvent, { type: "provider_error" }>,
|
|
3142
3143
|
): void {
|
|
3143
|
-
const classified =
|
|
3144
|
-
phase: "agent_loop",
|
|
3145
|
-
});
|
|
3144
|
+
const classified = classifyAgentLoopError(event.error, deps);
|
|
3146
3145
|
if (!shouldPersistProviderErrorAsAssistantMessage(classified)) {
|
|
3147
3146
|
return;
|
|
3148
3147
|
}
|
|
@@ -3164,6 +3163,16 @@ function handleProviderError(
|
|
|
3164
3163
|
}
|
|
3165
3164
|
}
|
|
3166
3165
|
|
|
3166
|
+
function classifyAgentLoopError(
|
|
3167
|
+
error: Error,
|
|
3168
|
+
deps: EventHandlerDeps,
|
|
3169
|
+
): ReturnType<typeof classifyConversationError> {
|
|
3170
|
+
return classifyConversationError(error, {
|
|
3171
|
+
phase: "agent_loop",
|
|
3172
|
+
...deps.errorAttribution?.(),
|
|
3173
|
+
});
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3167
3176
|
// ── Dispatcher ───────────────────────────────────────────────────────
|
|
3168
3177
|
|
|
3169
3178
|
/** Routes an AgentEvent to the appropriate handler. */
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "../config/llm-context-resolution.js";
|
|
31
31
|
import {
|
|
32
32
|
resolveCallSiteConfig,
|
|
33
|
-
|
|
33
|
+
resolveCallSiteConfigWithProfile,
|
|
34
34
|
resolveProfilelessModelKey,
|
|
35
35
|
selectWinningProfile,
|
|
36
36
|
} from "../config/llm-resolver.js";
|
|
@@ -61,6 +61,11 @@ import { HOOKS } from "../plugin-api/constants.js";
|
|
|
61
61
|
import type { ConversationGraphMemory } from "../plugins/defaults/memory/graph/conversation-graph-memory.js";
|
|
62
62
|
import { enqueueMemoryRetrospectiveOnCompaction } from "../plugins/defaults/memory/memory-retrospective-enqueue.js";
|
|
63
63
|
import { runHook } from "../plugins/pipeline.js";
|
|
64
|
+
import { isManagedConnectionRoute } from "../providers/connection-resolution.js";
|
|
65
|
+
import {
|
|
66
|
+
ConnectionResolutionError,
|
|
67
|
+
resolveRoutingIdentity,
|
|
68
|
+
} from "../providers/routing-identity.js";
|
|
64
69
|
import type { ContentBlock, Message } from "../providers/types.js";
|
|
65
70
|
import type { Provider } from "../providers/types.js";
|
|
66
71
|
import { resolveCapabilities } from "../runtime/capabilities.js";
|
|
@@ -95,6 +100,7 @@ import {
|
|
|
95
100
|
budgetYieldUnrecoveredClassification,
|
|
96
101
|
buildConversationErrorMessage,
|
|
97
102
|
classifyConversationError,
|
|
103
|
+
type ConversationErrorAttribution,
|
|
98
104
|
isUserCancellation,
|
|
99
105
|
} from "./conversation-error.js";
|
|
100
106
|
import { raceWithTimeout } from "./conversation-media-retry.js";
|
|
@@ -422,10 +428,7 @@ export async function runAgentLoopImpl(
|
|
|
422
428
|
// connection and profile so credential/connection errors point at the
|
|
423
429
|
// exact slot to fix instead of a generic banner. Resolution can itself
|
|
424
430
|
// throw on a broken config — attribution must never mask the real error.
|
|
425
|
-
const turnErrorAttribution = (): {
|
|
426
|
-
connectionName?: string;
|
|
427
|
-
profileName?: string;
|
|
428
|
-
} => {
|
|
431
|
+
const turnErrorAttribution = (): ConversationErrorAttribution => {
|
|
429
432
|
try {
|
|
430
433
|
const overrideProfile = readCurrentOverrideProfile();
|
|
431
434
|
const resolveOpts = {
|
|
@@ -433,21 +436,30 @@ export async function runAgentLoopImpl(
|
|
|
433
436
|
forceOverrideProfile,
|
|
434
437
|
selectionSeed: ctx.conversationId,
|
|
435
438
|
};
|
|
436
|
-
const resolved =
|
|
437
|
-
turnCallSite,
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
439
|
+
const { config: resolved, profileName } =
|
|
440
|
+
resolveCallSiteConfigWithProfile(turnCallSite, config.llm, resolveOpts);
|
|
441
|
+
let connectionName = resolved.provider_connection;
|
|
442
|
+
try {
|
|
443
|
+
connectionName =
|
|
444
|
+
resolveRoutingIdentity(resolved.provider, resolved.model)
|
|
445
|
+
?.connectionName ?? connectionName;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (!(error instanceof ConnectionResolutionError)) {
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
connectionName = error.connectionName;
|
|
451
|
+
}
|
|
452
|
+
// Managed-ness comes from the connection row, matching what dispatch
|
|
453
|
+
// decides. The profile's own provider can't stand in: a concrete
|
|
454
|
+
// provider tweak over a managed winner keeps the managed connection
|
|
455
|
+
// while replacing the provider (`llm-resolver.ts`).
|
|
456
|
+
const isManagedRoute = connectionName
|
|
457
|
+
? isManagedConnectionRoute(connectionName)
|
|
458
|
+
: undefined;
|
|
446
459
|
return {
|
|
447
|
-
...(
|
|
448
|
-
? { connectionName: resolved.provider_connection }
|
|
449
|
-
: {}),
|
|
460
|
+
...(connectionName ? { connectionName } : {}),
|
|
450
461
|
...(profileName ? { profileName } : {}),
|
|
462
|
+
...(isManagedRoute !== undefined ? { isManagedRoute } : {}),
|
|
451
463
|
};
|
|
452
464
|
} catch {
|
|
453
465
|
return {};
|
|
@@ -1049,6 +1061,7 @@ export async function runAgentLoopImpl(
|
|
|
1049
1061
|
turnInterfaceContext: capturedTurnInterfaceContext,
|
|
1050
1062
|
applyCompaction: applySuccessfulCompaction,
|
|
1051
1063
|
latencyTracker,
|
|
1064
|
+
errorAttribution: turnErrorAttribution,
|
|
1052
1065
|
};
|
|
1053
1066
|
const eventHandler = (event: AgentEvent): Promise<void> => {
|
|
1054
1067
|
if (
|
|
@@ -63,6 +63,8 @@ export interface ClassifiedConversationError {
|
|
|
63
63
|
export interface ConversationErrorAttribution {
|
|
64
64
|
connectionName?: string;
|
|
65
65
|
profileName?: string;
|
|
66
|
+
/** Whether the resolved turn route uses Vellum-managed inference. */
|
|
67
|
+
isManagedRoute?: boolean;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
// Network-level error patterns (connection refused, timeout, DNS, reset)
|
|
@@ -181,6 +183,11 @@ export interface ErrorContext {
|
|
|
181
183
|
* underlying connection name is generic.
|
|
182
184
|
*/
|
|
183
185
|
profileName?: string;
|
|
186
|
+
/**
|
|
187
|
+
* Whether the resolved turn route uses Vellum-managed inference. Turn routing
|
|
188
|
+
* takes precedence over the provider registry's boot-time default.
|
|
189
|
+
*/
|
|
190
|
+
isManagedRoute?: boolean;
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
/**
|
|
@@ -253,9 +260,19 @@ export function classifyConversationError(
|
|
|
253
260
|
// credential-related classifications can name the exact slot to fix.
|
|
254
261
|
// A `ProviderNotConfiguredError` instance carries its own attribution
|
|
255
262
|
// (from the throw site) which takes priority over context when present.
|
|
263
|
+
// The failed call's own route wins field by field — it is resolved at
|
|
264
|
+
// dispatch, while context describes the turn and can be stale for this
|
|
265
|
+
// call. Per-field, so a route carrying only some fields doesn't blank the
|
|
266
|
+
// rest.
|
|
267
|
+
const providerRoute =
|
|
268
|
+
error instanceof ProviderError ? error.routeAttribution : undefined;
|
|
269
|
+
const connectionName = providerRoute?.connectionName ?? ctx.connectionName;
|
|
270
|
+
const profileName = providerRoute?.profileName ?? ctx.profileName;
|
|
271
|
+
const isManagedRoute = providerRoute?.isManagedRoute ?? ctx.isManagedRoute;
|
|
256
272
|
const attribution: ConversationErrorAttribution = {
|
|
257
|
-
...(
|
|
258
|
-
...(
|
|
273
|
+
...(connectionName ? { connectionName } : {}),
|
|
274
|
+
...(profileName ? { profileName } : {}),
|
|
275
|
+
...(isManagedRoute !== undefined ? { isManagedRoute } : {}),
|
|
259
276
|
};
|
|
260
277
|
|
|
261
278
|
// Dedicated classification for missing provider API key
|
|
@@ -354,13 +371,18 @@ function classifyCore(
|
|
|
354
371
|
message: string,
|
|
355
372
|
attribution: ConversationErrorAttribution = {},
|
|
356
373
|
): Omit<ClassifiedConversationError, "debugDetails"> {
|
|
374
|
+
const isManagedRoute =
|
|
375
|
+
error instanceof ProviderError &&
|
|
376
|
+
(attribution.isManagedRoute ??
|
|
377
|
+
getProviderRoutingSource(error.provider) === "managed-proxy");
|
|
378
|
+
|
|
357
379
|
// Prefer the semantic reason stamped by the provider layer, regardless of
|
|
358
380
|
// HTTP status — statusless errors (e.g. SDK streaming failures) still carry a
|
|
359
381
|
// reason. Reasons that map cleanly win here; `bad_request`/`unknown` (and a
|
|
360
382
|
// reason-less error) fall through to the status switch + regex battery below.
|
|
361
383
|
if (error instanceof ProviderError && error.reason) {
|
|
362
384
|
const c = reasonToClassification(error.reason, {
|
|
363
|
-
|
|
385
|
+
isManagedRoute,
|
|
364
386
|
attribution,
|
|
365
387
|
message,
|
|
366
388
|
});
|
|
@@ -382,20 +404,19 @@ function classifyCore(
|
|
|
382
404
|
// Everything else is a user-set credential that the upstream provider
|
|
383
405
|
// rejected, so emit `PROVIDER_INVALID_KEY` and let the chat banner point
|
|
384
406
|
// at Settings.
|
|
385
|
-
|
|
386
|
-
if (getProviderRoutingSource(providerName) === "managed-proxy") {
|
|
407
|
+
if (isManagedRoute) {
|
|
387
408
|
return managedKeyInvalidClassification();
|
|
388
409
|
}
|
|
389
410
|
return invalidApiKeyClassification(attribution);
|
|
390
411
|
}
|
|
391
412
|
if (error.statusCode === 402) {
|
|
392
|
-
if (
|
|
413
|
+
if (isManagedRoute) {
|
|
393
414
|
return managedBalanceClassification();
|
|
394
415
|
}
|
|
395
416
|
return providerBillingClassification();
|
|
396
417
|
}
|
|
397
418
|
if (error.statusCode === 429) {
|
|
398
|
-
if (isManagedUsageLimitError(
|
|
419
|
+
if (isManagedUsageLimitError(message, isManagedRoute)) {
|
|
399
420
|
return managedUsageLimitClassification();
|
|
400
421
|
}
|
|
401
422
|
return rateLimitClassification();
|
|
@@ -508,7 +529,7 @@ function classifyCore(
|
|
|
508
529
|
}
|
|
509
530
|
|
|
510
531
|
// Regex fallback for non-ProviderError or ProviderError without statusCode
|
|
511
|
-
return classifyByMessage(
|
|
532
|
+
return classifyByMessage(message, isManagedRoute);
|
|
512
533
|
}
|
|
513
534
|
|
|
514
535
|
/**
|
|
@@ -540,12 +561,12 @@ function extractProviderDetail(message: string): string | undefined {
|
|
|
540
561
|
function reasonToClassification(
|
|
541
562
|
reason: ProviderErrorReason,
|
|
542
563
|
args: {
|
|
543
|
-
|
|
564
|
+
isManagedRoute: boolean;
|
|
544
565
|
attribution?: ConversationErrorAttribution;
|
|
545
566
|
message: string;
|
|
546
567
|
},
|
|
547
568
|
): Omit<ClassifiedConversationError, "debugDetails"> | null {
|
|
548
|
-
const managed = args.
|
|
569
|
+
const managed = args.isManagedRoute;
|
|
549
570
|
switch (reason) {
|
|
550
571
|
case "invalid_credentials":
|
|
551
572
|
return managed
|
|
@@ -670,18 +691,13 @@ function isStreamingError(message: string): boolean {
|
|
|
670
691
|
return STREAMING_ERROR_PATTERNS.some((p) => p.test(message));
|
|
671
692
|
}
|
|
672
693
|
|
|
673
|
-
function isManagedUsageLimitError(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return MANAGED_USAGE_LIMIT_PATTERNS.some((p) => p.test(message));
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function isManagedBalanceError(error: ProviderError): boolean {
|
|
684
|
-
return getProviderRoutingSource(error.provider) === "managed-proxy";
|
|
694
|
+
function isManagedUsageLimitError(
|
|
695
|
+
message: string,
|
|
696
|
+
isManagedRoute: boolean,
|
|
697
|
+
): boolean {
|
|
698
|
+
return (
|
|
699
|
+
isManagedRoute || MANAGED_USAGE_LIMIT_PATTERNS.some((p) => p.test(message))
|
|
700
|
+
);
|
|
685
701
|
}
|
|
686
702
|
|
|
687
703
|
function isProviderBillingError(message: string): boolean {
|
|
@@ -912,8 +928,8 @@ function providerNotConfiguredClassification(
|
|
|
912
928
|
* short-circuit and the status switch both decline to classify.
|
|
913
929
|
*/
|
|
914
930
|
function classifyByMessage(
|
|
915
|
-
error: unknown,
|
|
916
931
|
message: string,
|
|
932
|
+
isManagedRoute: boolean,
|
|
917
933
|
): Omit<ClassifiedConversationError, "debugDetails"> {
|
|
918
934
|
// Empty-request-messages is always an internal construction failure — check
|
|
919
935
|
// it before the provider/network patterns so a ProviderError that carries the
|
|
@@ -930,7 +946,7 @@ function classifyByMessage(
|
|
|
930
946
|
// Check rate limit first (before network, since 429 could match both)
|
|
931
947
|
for (const pattern of RATE_LIMIT_PATTERNS) {
|
|
932
948
|
if (pattern.test(message)) {
|
|
933
|
-
if (isManagedUsageLimitError(
|
|
949
|
+
if (isManagedUsageLimitError(message, isManagedRoute)) {
|
|
934
950
|
return managedUsageLimitClassification();
|
|
935
951
|
}
|
|
936
952
|
return rateLimitClassification();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the voice activity line.
|
|
3
|
+
*
|
|
4
|
+
* The invariant worth guarding is totality: the tool vocabulary is open
|
|
5
|
+
* (plugins, MCP servers, and skills all contribute names this module has never
|
|
6
|
+
* seen), and the surface this feeds is a Lock Screen the user cannot correct.
|
|
7
|
+
* A label that came back empty would blank the island exactly when the
|
|
8
|
+
* assistant is busiest.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
activityLabelForTool,
|
|
15
|
+
GENERIC_ACTIVITY_LABEL,
|
|
16
|
+
} from "../activity-label.js";
|
|
17
|
+
|
|
18
|
+
describe("activityLabelForTool", () => {
|
|
19
|
+
test("names what the user would say is happening, not the tool", () => {
|
|
20
|
+
expect(activityLabelForTool("bash")).toBe("Running a command");
|
|
21
|
+
expect(activityLabelForTool("web_search")).toBe("Searching the web");
|
|
22
|
+
expect(activityLabelForTool("file_read")).toBe("Reading a file");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Where a tool runs is not a distinction anyone glancing at a Lock Screen is
|
|
26
|
+
// making.
|
|
27
|
+
test("host and sandbox variants read the same", () => {
|
|
28
|
+
expect(activityLabelForTool("host_bash")).toBe(
|
|
29
|
+
activityLabelForTool("bash"),
|
|
30
|
+
);
|
|
31
|
+
expect(activityLabelForTool("host_file_read")).toBe(
|
|
32
|
+
activityLabelForTool("file_read"),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("falls back by prefix for structured tool families", () => {
|
|
37
|
+
expect(activityLabelForTool("browser_click")).toBe("Using the browser");
|
|
38
|
+
expect(activityLabelForTool("mcp_notion_search")).toBe(
|
|
39
|
+
"Using a connected app",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("every tool gets a label, including ones this module has never seen", () => {
|
|
44
|
+
for (const name of [
|
|
45
|
+
"slack_conversations_history",
|
|
46
|
+
"",
|
|
47
|
+
"a",
|
|
48
|
+
"some_vendor::weird.tool/name",
|
|
49
|
+
]) {
|
|
50
|
+
expect(activityLabelForTool(name)).toBe(GENERIC_ACTIVITY_LABEL);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("labels stay short enough for the slot they land in", () => {
|
|
55
|
+
for (const name of [
|
|
56
|
+
"bash",
|
|
57
|
+
"web_search",
|
|
58
|
+
"host_file_transfer",
|
|
59
|
+
"computer_use",
|
|
60
|
+
"ui_show",
|
|
61
|
+
"unknown_tool",
|
|
62
|
+
]) {
|
|
63
|
+
const label = activityLabelForTool(name);
|
|
64
|
+
expect(label.length).toBeLessThanOrEqual(32);
|
|
65
|
+
expect(label).not.toMatch(/[.!?]$/);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|