@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.
- package/openapi.yaml +1 -0
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/conversation-agent-loop.test.ts +16 -7
- 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/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +6 -6
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/agent/loop.ts +36 -7
- package/src/cli/commands/notifications.help.ts +3 -2
- 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/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- 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/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +150 -20
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/copy-composer.ts +23 -3
- package/src/notifications/decision-engine.ts +29 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +8 -2
- package/src/persistence/conversation-title-service.ts +5 -170
- 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/runtime/routes/notification-routes.ts +4 -1
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/errors.ts +15 -0
- package/src/util/short-title.ts +189 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- 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
|
-
!
|
|
1549
|
+
!streamedTokens &&
|
|
1545
1550
|
(event.type === "thinking_delta" || event.type === "text_delta")
|
|
1546
1551
|
) {
|
|
1547
|
-
|
|
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
|
|
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:
|
|
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
|
|
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 (
|