@vellumai/assistant 0.11.1-dev.202608032034.aebd2da → 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.
Files changed (47) hide show
  1. package/openapi.yaml +1 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  4. package/src/__tests__/conversation-agent-loop.test.ts +16 -7
  5. package/src/__tests__/conversation-error.test.ts +106 -0
  6. package/src/__tests__/llm-resolver.test.ts +16 -0
  7. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  8. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +6 -6
  9. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  10. package/src/agent/loop.ts +36 -7
  11. package/src/cli/commands/notifications.help.ts +3 -2
  12. package/src/config/llm-resolver.ts +19 -3
  13. package/src/daemon/conversation-agent-loop-handlers.ts +15 -6
  14. package/src/daemon/conversation-agent-loop.ts +31 -18
  15. package/src/daemon/conversation-error.ts +40 -24
  16. package/src/home/__tests__/feed-types.test.ts +43 -8
  17. package/src/home/__tests__/feed-writer.test.ts +59 -0
  18. package/src/home/feed-types.ts +34 -7
  19. package/src/home/feed-writer.ts +15 -6
  20. package/src/live-voice/__tests__/activity-label.test.ts +68 -0
  21. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  22. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +14 -5
  23. package/src/live-voice/activity-label.ts +106 -0
  24. package/src/live-voice/live-activity-reporter.ts +35 -6
  25. package/src/live-voice/live-voice-session.ts +58 -0
  26. package/src/live-voice/protocol.ts +23 -0
  27. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  28. package/src/notifications/__tests__/decision-engine.test.ts +150 -20
  29. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  30. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  31. package/src/notifications/copy-composer.ts +23 -3
  32. package/src/notifications/decision-engine.ts +29 -7
  33. package/src/notifications/edit-notification.ts +8 -4
  34. package/src/notifications/home-feed-side-effect.ts +43 -6
  35. package/src/notifications/notification-utils.ts +8 -2
  36. package/src/persistence/conversation-title-service.ts +5 -170
  37. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  38. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  39. package/src/providers/call-site-routing.ts +77 -14
  40. package/src/providers/connection-resolution.ts +42 -2
  41. package/src/providers/types.ts +7 -1
  42. package/src/runtime/routes/notification-routes.ts +4 -1
  43. package/src/util/__tests__/short-title.test.ts +229 -0
  44. package/src/util/errors.ts +15 -0
  45. package/src/util/short-title.ts +189 -0
  46. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  47. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Tests for workspace migration `138-backfill-home-feed-titles`.
3
+ *
4
+ * The migration gives every item in `<workspace>/data/home-feed.json` a
5
+ * non-empty `title`, derived from the item's own `summary` when the
6
+ * field is missing or blank.
7
+ *
8
+ * Cases covered:
9
+ * 1. Missing file, malformed JSON, and a non-object root are no-ops.
10
+ * 2. Title-less items gain a first-sentence title from the summary.
11
+ * 3. Items that already have a title are untouched.
12
+ * 4. A long summary truncates to 60 characters with an ellipsis, the
13
+ * same cap the notification pipeline's derivation applies.
14
+ * 5. Markdown-rich and multi-line summaries yield a single line of
15
+ * plain text.
16
+ * 6. `version`, `updatedAt`, and unrelated item fields are preserved.
17
+ * 7. A second run writes nothing.
18
+ */
19
+
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
32
+
33
+ import { backfillHomeFeedTitlesMigration } from "../workspace/migrations/138-backfill-home-feed-titles.js";
34
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
35
+ import { getLastWorkspaceMigrationId } from "../workspace/migrations/runner.js";
36
+
37
+ let workspaceDir: string;
38
+ let feedPath: string;
39
+
40
+ beforeEach(() => {
41
+ workspaceDir = mkdtempSync(join(tmpdir(), "vellum-migration-138-test-"));
42
+ feedPath = join(workspaceDir, "data", "home-feed.json");
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (existsSync(workspaceDir)) {
47
+ rmSync(workspaceDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ function writeFeedFile(contents: unknown): void {
52
+ mkdirSync(join(workspaceDir, "data"), { recursive: true });
53
+ writeFileSync(feedPath, JSON.stringify(contents, null, 2), "utf-8");
54
+ }
55
+
56
+ function readFeedFile(): {
57
+ version: number;
58
+ items: Array<Record<string, unknown>>;
59
+ updatedAt: string;
60
+ } {
61
+ return JSON.parse(readFileSync(feedPath, "utf-8"));
62
+ }
63
+
64
+ function makeItem(
65
+ overrides: Record<string, unknown> & { id: string },
66
+ ): Record<string, unknown> {
67
+ return {
68
+ type: "notification",
69
+ priority: 50,
70
+ summary: "Something happened.",
71
+ timestamp: "2026-08-01T12:00:00.000Z",
72
+ status: "new",
73
+ createdAt: "2026-08-01T12:00:00.000Z",
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ function writeFeed(items: Array<Record<string, unknown>>): void {
79
+ writeFeedFile({
80
+ version: 2,
81
+ updatedAt: "2026-08-01T12:00:00.000Z",
82
+ items,
83
+ });
84
+ }
85
+
86
+ describe("workspace migration 138-backfill-home-feed-titles", () => {
87
+ test("has the expected id and description", () => {
88
+ expect(backfillHomeFeedTitlesMigration.id).toBe(
89
+ "138-backfill-home-feed-titles",
90
+ );
91
+ expect(backfillHomeFeedTitlesMigration.description).toContain("title");
92
+ });
93
+
94
+ // getLastWorkspaceMigrationId() reports the final array entry as the
95
+ // registry ceiling to the identity and rollback routes, so the
96
+ // highest-numbered migration must sit last in the registry.
97
+ test("the registry ceiling stays at the highest-numbered migration", () => {
98
+ const numericId = (id: string) => Number.parseInt(id, 10);
99
+ const highest = Math.max(
100
+ ...WORKSPACE_MIGRATIONS.map((m) => numericId(m.id)).filter(
101
+ Number.isFinite,
102
+ ),
103
+ );
104
+ const last = getLastWorkspaceMigrationId(WORKSPACE_MIGRATIONS);
105
+ expect(last).not.toBeNull();
106
+ expect(numericId(last!)).toBe(highest);
107
+ });
108
+
109
+ test("missing file is a no-op (no error, no file created)", () => {
110
+ expect(existsSync(feedPath)).toBe(false);
111
+
112
+ expect(() =>
113
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
114
+ ).not.toThrow();
115
+
116
+ expect(existsSync(feedPath)).toBe(false);
117
+ });
118
+
119
+ test("derives a title from the summary's first sentence", () => {
120
+ writeFeed([
121
+ makeItem({
122
+ id: "a-1",
123
+ summary: "Alice replied to your thread. She needs an answer.",
124
+ }),
125
+ makeItem({ id: "a-2", summary: "Deploy finished!" }),
126
+ makeItem({ id: "a-3", summary: "Ready to review? The draft is up." }),
127
+ ]);
128
+
129
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
130
+
131
+ const titles = readFeedFile().items.map((item) => item.title);
132
+ expect(titles).toEqual([
133
+ "Alice replied to your thread.",
134
+ "Deploy finished!",
135
+ "Ready to review?",
136
+ ]);
137
+ });
138
+
139
+ test("backfills items whose title is missing, empty, blank, or not a string", () => {
140
+ writeFeed([
141
+ makeItem({ id: "missing", summary: "First item summary." }),
142
+ makeItem({ id: "empty", title: "", summary: "Second item summary." }),
143
+ makeItem({ id: "blank", title: " ", summary: "Third item summary." }),
144
+ makeItem({ id: "wrong-type", title: null, summary: "Fourth summary." }),
145
+ ]);
146
+
147
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
148
+
149
+ const titles = readFeedFile().items.map((item) => item.title);
150
+ expect(titles).toEqual([
151
+ "First item summary.",
152
+ "Second item summary.",
153
+ "Third item summary.",
154
+ "Fourth summary.",
155
+ ]);
156
+ });
157
+
158
+ test("leaves items that already have a title untouched", () => {
159
+ writeFeed([
160
+ makeItem({
161
+ id: "titled",
162
+ title: "Existing headline",
163
+ summary: "A completely different summary sentence.",
164
+ }),
165
+ makeItem({ id: "untitled", summary: "Needs a headline." }),
166
+ ]);
167
+
168
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
169
+
170
+ const items = readFeedFile().items;
171
+ expect(items[0]!.title).toBe("Existing headline");
172
+ expect(items[1]!.title).toBe("Needs a headline.");
173
+ });
174
+
175
+ test("truncates a long summary to 60 characters with an ellipsis", () => {
176
+ const summary =
177
+ "Bob shared a very long document that keeps going well past the limit";
178
+ writeFeed([makeItem({ id: "long", summary })]);
179
+
180
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
181
+
182
+ const title = readFeedFile().items[0]!.title as string;
183
+ expect(title).toBe(
184
+ "Bob shared a very long document that keeps going well past t…",
185
+ );
186
+ // The ellipsis sits past the cap, so the retained text is exactly 60.
187
+ expect(title.slice(0, -1).length).toBe(60);
188
+ });
189
+
190
+ test("truncates a long first sentence rather than the whole summary", () => {
191
+ writeFeed([
192
+ makeItem({
193
+ id: "long-sentence",
194
+ summary:
195
+ "This opening sentence runs well beyond the sixty character cap on derived titles. Then another.",
196
+ }),
197
+ ]);
198
+
199
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
200
+
201
+ const title = readFeedFile().items[0]!.title as string;
202
+ expect(title).toBe(
203
+ "This opening sentence runs well beyond the sixty character c…",
204
+ );
205
+ });
206
+
207
+ test("flattens a markdown-rich multi-line summary into one plain line", () => {
208
+ writeFeed([
209
+ makeItem({
210
+ id: "markdown",
211
+ summary:
212
+ "## Nightly run\n\n- **Deploy** finished\n- Tests passed\n\nSee the `report` for details.",
213
+ }),
214
+ ]);
215
+
216
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
217
+
218
+ expect(readFeedFile().items[0]!.title).toBe(
219
+ "Nightly run Deploy finished Tests passed See the report for…",
220
+ );
221
+ });
222
+
223
+ test("unwraps inline markdown before taking the first sentence", () => {
224
+ writeFeed([
225
+ makeItem({
226
+ id: "inline",
227
+ summary:
228
+ "**Alice** shared the [design doc](https://example.com/doc). Review it soon.",
229
+ }),
230
+ ]);
231
+
232
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
233
+
234
+ expect(readFeedFile().items[0]!.title).toBe("Alice shared the design doc.");
235
+ });
236
+
237
+ test("collapses a newline-only summary run into single spaces", () => {
238
+ writeFeed([
239
+ makeItem({ id: "newlines", summary: "Backup complete\n\nNo errors" }),
240
+ ]);
241
+
242
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
243
+
244
+ expect(readFeedFile().items[0]!.title).toBe("Backup complete No errors");
245
+ });
246
+
247
+ test("skips an item whose summary is markdown structure only", () => {
248
+ writeFeed([makeItem({ id: "structure-only", summary: "##\n\n- \n\n> " })]);
249
+
250
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
251
+
252
+ expect(readFeedFile().items[0]!.title).toBeUndefined();
253
+ });
254
+
255
+ test("preserves version, updatedAt, and unrelated item fields", () => {
256
+ writeFeedFile({
257
+ version: 2,
258
+ updatedAt: "2026-08-01T12:00:00.000Z",
259
+ items: [
260
+ makeItem({
261
+ id: "keep-fields",
262
+ summary: "Payment failed.",
263
+ urgency: "high",
264
+ conversationId: "conv-abc",
265
+ actions: [{ label: "Retry", kind: "prompt" }],
266
+ detailPanel: { kind: "text", body: "Details" },
267
+ }),
268
+ ],
269
+ });
270
+
271
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
272
+
273
+ const out = readFeedFile();
274
+ expect(out.version).toBe(2);
275
+ expect(out.updatedAt).toBe("2026-08-01T12:00:00.000Z");
276
+
277
+ const item = out.items[0]!;
278
+ expect(item.title).toBe("Payment failed.");
279
+ expect(item.id).toBe("keep-fields");
280
+ expect(item.type).toBe("notification");
281
+ expect(item.priority).toBe(50);
282
+ expect(item.status).toBe("new");
283
+ expect(item.urgency).toBe("high");
284
+ expect(item.conversationId).toBe("conv-abc");
285
+ expect(item.actions).toEqual([{ label: "Retry", kind: "prompt" }]);
286
+ expect(item.detailPanel).toEqual({ kind: "text", body: "Details" });
287
+ });
288
+
289
+ test("idempotent: a second run writes nothing", () => {
290
+ writeFeed([
291
+ makeItem({ id: "a-1", summary: "Alice replied to your thread." }),
292
+ makeItem({ id: "a-2", title: "Already titled", summary: "Body text." }),
293
+ ]);
294
+
295
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
296
+ const afterFirst = readFileSync(feedPath, "utf-8");
297
+ const afterFirstStat = statSync(feedPath);
298
+
299
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
300
+
301
+ expect(readFileSync(feedPath, "utf-8")).toBe(afterFirst);
302
+ expect(statSync(feedPath).mtimeMs).toBe(afterFirstStat.mtimeMs);
303
+ });
304
+
305
+ test("a fully titled feed is left byte-identical", () => {
306
+ writeFeed([makeItem({ id: "a-1", title: "Titled", summary: "Body." })]);
307
+ const before = readFileSync(feedPath, "utf-8");
308
+ const beforeStat = statSync(feedPath);
309
+
310
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
311
+
312
+ expect(readFileSync(feedPath, "utf-8")).toBe(before);
313
+ expect(statSync(feedPath).mtimeMs).toBe(beforeStat.mtimeMs);
314
+ });
315
+
316
+ test("malformed JSON does not throw and leaves the file alone", () => {
317
+ mkdirSync(join(workspaceDir, "data"), { recursive: true });
318
+ writeFileSync(feedPath, "{not valid json", "utf-8");
319
+ const before = readFileSync(feedPath, "utf-8");
320
+
321
+ expect(() =>
322
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
323
+ ).not.toThrow();
324
+
325
+ expect(readFileSync(feedPath, "utf-8")).toBe(before);
326
+ });
327
+
328
+ test("non-object root is a no-op", () => {
329
+ writeFeedFile([1, 2, 3]);
330
+ const before = readFileSync(feedPath, "utf-8");
331
+
332
+ expect(() =>
333
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
334
+ ).not.toThrow();
335
+
336
+ expect(readFileSync(feedPath, "utf-8")).toBe(before);
337
+ });
338
+
339
+ test("items that cannot yield a title are skipped without throwing", () => {
340
+ writeFeedFile({
341
+ version: 2,
342
+ updatedAt: "2026-08-01T12:00:00.000Z",
343
+ items: [
344
+ "not an object",
345
+ makeItem({ id: "no-summary", summary: undefined }),
346
+ makeItem({ id: "blank-summary", summary: " " }),
347
+ makeItem({ id: "ok", summary: "Has a summary." }),
348
+ ],
349
+ });
350
+
351
+ expect(() =>
352
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
353
+ ).not.toThrow();
354
+
355
+ const items: unknown[] = readFeedFile().items;
356
+ expect(items[0]).toBe("not an object");
357
+ expect((items[1] as Record<string, unknown>).title).toBeUndefined();
358
+ expect((items[2] as Record<string, unknown>).title).toBeUndefined();
359
+ expect((items[3] as Record<string, unknown>).title).toBe("Has a summary.");
360
+ });
361
+
362
+ test("down() is a no-op (forward-only migration)", () => {
363
+ writeFeed([makeItem({ id: "a-1", summary: "Alice replied." })]);
364
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
365
+ const after = readFileSync(feedPath, "utf-8");
366
+
367
+ expect(() =>
368
+ backfillHomeFeedTitlesMigration.down(workspaceDir),
369
+ ).not.toThrow();
370
+
371
+ expect(readFileSync(feedPath, "utf-8")).toBe(after);
372
+ });
373
+ });
package/src/agent/loop.ts CHANGED
@@ -1028,6 +1028,10 @@ export class AgentLoop {
1028
1028
  // rejection means the repair could not recover, so the error surfaces
1029
1029
  // instead of looping. Turn-scoped, so each turn recovers afresh.
1030
1030
  let orderingRepairAttempted = false;
1031
+ // One resume per turn after a call dies mid-generation: a second
1032
+ // interruption means re-issuing is not getting through, so the error
1033
+ // surfaces instead of looping.
1034
+ let interruptedCallResumed = false;
1031
1035
  let lastLlmCallTime = 0;
1032
1036
  let exitReason: ExitReason | null = null;
1033
1037
  // Armed at the end of a tool-use iteration so the budget gate runs at the
@@ -1176,6 +1180,12 @@ export class AgentLoop {
1176
1180
  // elsewhere in the turn body (tool execution, the success-path stop
1177
1181
  // chain, post-model-call hooks) must not re-enter the stop chain.
1178
1182
  let providerCallError: unknown;
1183
+ // Set once the model streams its first token on this iteration's call.
1184
+ // Two readers: latency instrumentation stamps time-to-first-token off
1185
+ // its rising edge, and the outer catch reads it to tell a refused
1186
+ // request from a generation that died mid-flight. Declared here so that
1187
+ // catch can reach it.
1188
+ let streamedTokens = false;
1179
1189
 
1180
1190
  try {
1181
1191
  // ── Pre-call budget gate ─────────────────────────────────────
@@ -1527,11 +1537,6 @@ export class AgentLoop {
1527
1537
  // the client would otherwise see nothing.
1528
1538
  let streamedVisibleText = false;
1529
1539
 
1530
- // Latency instrumentation: stamp the first streamed token (thinking or
1531
- // text) of THIS call exactly once, so each per-call segment carries its
1532
- // own time-to-first-token. Reset per provider call.
1533
- let firstTokenMarked = false;
1534
-
1535
1540
  // The `onEvent` wrapping below applies sensitive-output placeholder
1536
1541
  // substitution to streamed text while forwarding every other event
1537
1542
  // type through unchanged.
@@ -1541,10 +1546,10 @@ export class AgentLoop {
1541
1546
  config: providerConfig,
1542
1547
  onEvent: (event) => {
1543
1548
  if (
1544
- !firstTokenMarked &&
1549
+ !streamedTokens &&
1545
1550
  (event.type === "thinking_delta" || event.type === "text_delta")
1546
1551
  ) {
1547
- firstTokenMarked = true;
1552
+ streamedTokens = true;
1548
1553
  latencyTracker?.markFirstToken(
1549
1554
  event.type === "thinking_delta" ? "thinking" : "text",
1550
1555
  );
@@ -2498,6 +2503,30 @@ export class AgentLoop {
2498
2503
  newMessagesStart = history.length;
2499
2504
  continue;
2500
2505
  }
2506
+
2507
+ // Last: the call died mid-generation. `streamedTokens` means the
2508
+ // provider accepted the request and started producing before it
2509
+ // failed, so the request is fine and re-issuing it as-is is the
2510
+ // recovery — nothing to repair, which is why no branch above claims
2511
+ // it. A rejection that streamed nothing is the opposite case (the
2512
+ // provider refused the request), and an identical resend would be
2513
+ // refused identically, so those surface. Main-agent turns only:
2514
+ // background call sites answer to callers that handle their own
2515
+ // failures.
2516
+ if (
2517
+ streamedTokens &&
2518
+ !interruptedCallResumed &&
2519
+ callSite === "mainAgent" &&
2520
+ postModelCallContinues < MAX_POST_MODEL_CALL_CONTINUES
2521
+ ) {
2522
+ interruptedCallResumed = true;
2523
+ postModelCallContinues++;
2524
+ rlog.warn(
2525
+ { turn: toolUseTurns, messageCount: history.length, err },
2526
+ "Model call died mid-generation, resuming the turn",
2527
+ );
2528
+ continue;
2529
+ }
2501
2530
  }
2502
2531
 
2503
2532
  rlog.error(
@@ -86,7 +86,7 @@ Examples:
86
86
  {
87
87
  flags: "--title <title>",
88
88
  description:
89
- "Short headline (≤ 8 words). Always provide one — the auto-derived fallback just truncates --message.",
89
+ "Short headline. Titles over 40 characters are trimmed to fit, and one over 40 characters that also runs past 7 words is cut to its first 5. A title that reads like prose (a reasoning opener, or a full sentence ending in terminal punctuation) is discarded in favor of a headline derived from --message.",
90
90
  },
91
91
  {
92
92
  flags: "--urgency <urgency>",
@@ -263,7 +263,8 @@ Examples:
263
263
  },
264
264
  {
265
265
  flags: "--title <title>",
266
- description: "New short headline (≤ 8 words).",
266
+ description:
267
+ "New short headline (≤ 8 words). An empty value is ignored, so the existing title stays put.",
267
268
  },
268
269
  {
269
270
  flags: "--urgency <urgency>",
@@ -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 resolveOverrideOrDefault(callSite, llm, opts);
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
- opts: ResolveCallSiteOpts,
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 = classifyConversationError(event.error, {
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 = classifyConversationError(event.error, {
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
- resolveEffectiveProfileKey,
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 = resolveCallSiteConfig(
437
- turnCallSite,
438
- config.llm,
439
- resolveOpts,
440
- );
441
- const profileName = resolveEffectiveProfileKey(
442
- turnCallSite,
443
- config.llm,
444
- resolveOpts,
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
- ...(resolved.provider_connection
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 (