@vellumai/assistant 0.12.2-dev.202609181315.9aaf787 → 0.12.2-dev.202609181515.7e76c92

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 (60) hide show
  1. package/docs/voice-input-diagnostics.md +8 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  8. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  9. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  10. package/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  11. package/openapi.yaml +41 -0
  12. package/package.json +1 -1
  13. package/src/__tests__/agent-loop-provider-error-recording.test.ts +87 -19
  14. package/src/__tests__/anthropic-gateway-shared.test.ts +22 -0
  15. package/src/__tests__/conversation-surface-snapshots.test.ts +543 -0
  16. package/src/__tests__/oauth-provider-serializer.test.ts +12 -0
  17. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  18. package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -0
  19. package/src/agent/loop.ts +22 -29
  20. package/src/api/events/tool-result.ts +1 -0
  21. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +5 -2
  22. package/src/cli/lib/bundled-marketplace.json +70 -33
  23. package/src/cli/lib/plugin-marketplace.ts +8 -0
  24. package/src/config/feature-flag-registry.json +8 -0
  25. package/src/config/schemas/services.ts +3 -2
  26. package/src/daemon/conversation-agent-loop-handlers.ts +1 -1
  27. package/src/daemon/conversation-surface-snapshots.ts +135 -0
  28. package/src/daemon/message-types/web-activity.ts +2 -1
  29. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +105 -4
  30. package/src/live-voice/__tests__/microphone-idle-gate.test.ts +87 -0
  31. package/src/live-voice/live-voice-session.ts +23 -4
  32. package/src/live-voice/microphone-idle-gate.ts +56 -0
  33. package/src/oauth/provider-serializer.ts +7 -0
  34. package/src/oauth/seed-providers.ts +30 -0
  35. package/src/plugins/defaults/index.ts +24 -0
  36. package/src/plugins/defaults/task-progress/__tests__/registration.test.ts +70 -0
  37. package/src/plugins/defaults/task-progress/__tests__/task-progress-context.test.ts +380 -0
  38. package/src/plugins/defaults/task-progress/hooks/post-compact.ts +19 -0
  39. package/src/plugins/defaults/task-progress/hooks/user-prompt-submit.ts +24 -0
  40. package/src/plugins/defaults/task-progress/package.json +14 -0
  41. package/src/plugins/defaults/task-progress/src/apply-task-progress-context.ts +93 -0
  42. package/src/plugins/defaults/task-progress/src/format-task-progress-context.ts +67 -0
  43. package/src/providers/__tests__/context-overflow-error.test.ts +10 -0
  44. package/src/providers/__tests__/search-provider-catalog.test.ts +9 -0
  45. package/src/providers/anthropic/client.ts +10 -1
  46. package/src/providers/anthropic-gateway-shared.ts +6 -0
  47. package/src/providers/gemini/client.ts +25 -8
  48. package/src/providers/jev/client.ts +4 -3
  49. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +94 -1
  50. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +45 -0
  51. package/src/providers/openai/chat-completions-provider.ts +49 -15
  52. package/src/providers/openai/responses-provider.ts +23 -1
  53. package/src/providers/search-provider-catalog.ts +12 -0
  54. package/src/providers/types.ts +5 -0
  55. package/src/runtime/agent-wake.ts +1 -1
  56. package/src/runtime/routes/oauth-providers.ts +7 -0
  57. package/src/runtime/routes/plugins-routes.ts +6 -0
  58. package/src/tools/network/__tests__/web-search.test.ts +111 -0
  59. package/src/tools/network/web-search.ts +242 -4
  60. package/src/util/errors.ts +13 -3
@@ -30,6 +30,7 @@ Search the assistant log for `Live voice input diagnostics` or `voice_input_`. A
30
30
  - `voice_input_window` summarizes each approximately five-second arrival window: received audio duration, zero-signal duration, duration-weighted mean amplitude, minimum/maximum chunk mean amplitude, classified speech/silence/echo duration, and maximum arrival gap. Window emission requires incoming audio; `voice_input_closed` flushes a partial window.
31
31
  - The same window includes `sttSubmittedChunks`, `sttSubmittedAudioMs`, and `sttMaxSubmissionGapMs`. These measure handoff to the transcription adapter, not provider receipt. A probe can buffer audio before submission, and the final chunk of an arrival window can be submitted in the next window. Compare sustained windows to identify input that arrives normally but stops reaching transcription.
32
32
  - `voice_input_provider_turn_start` and `voice_input_provider_turn_end` record provider boundaries alongside local VAD state. End records include confidence, the provider's `trigger` (`model`, `timeout`, or `manual`), decoded audio position in seconds from stream start, and elapsed time since locally detected speech. Missing provider fields are null. These records describe received decisions, including ones ignored as stale or after fallback; correlate with the turn metrics to identify the boundary that committed the request.
33
+ - `voice_input_microphone_gate_changed` records when the transcription audio gate closes or reopens. `microphoneGateClosed` is included with the other input diagnostics. Closing replaces room audio with silence; it does not commit a transcript or stop local interruption detection.
33
34
  - `voice_input_barge_in_armed`, `voice_input_barge_in_fired`, and `voice_input_barge_in_expired` distinguish a candidate from an accepted or expired interruption. Fired events include accumulated speech duration, guard resets, and a bounded trace of the preceding two seconds (at most 40 input chunks).
34
35
  - Each trace entry captures the amplitude and gate/echo state before classification, and classified durations, remaining probe buffer, and correlation result after classification. The classifier can hold a probe and release multiple older chunks together, so a row's classified duration can differ from its received duration. A null correlation means no comparison ran, not a measured zero correlation.
35
36
  - `voice_input_speech_routed` binds the speech generation to the input turn that actually receives its audio. At onset, the prior spent cycle may still be installed, so use this routing record for correlation rather than assuming the onset's `inputTurnId` is the new utterance.
@@ -41,6 +42,12 @@ These are local diagnostic logs, not new analytics events or voice protocol fiel
41
42
 
42
43
  ## Flux pause handling
43
44
 
44
- When Flux owns turn detection, it receives microphone audio through idle periods and pauses, including quiet speech below the local VAD gate. Confirmed playback echo is replaced by silence of the same duration so it advances the provider's audio clock without transcribing the assistant's voice. Echo probes can briefly buffer that audio. Local VAD still owns barge-in; its thresholds and guard are independent of this continuous transcription feed. Idle audio is not accumulated into request recordings.
45
+ When Flux owns turn detection, microphone audio passes through for one second of received audio after the last locally detected speech. After that, the input gate replaces room audio with digital silence while the microphone and local interruption detector keep listening. The cutoff is measured in PCM samples, so network batching does not change it. Flux continues receiving elapsed silence and owns the turn boundary; closing this gate does not release the question.
46
+
47
+ While closed, the gate holds the most recent 200 ms before deciding whether to send those samples as silence or as the lead-in to resumed speech. On speech onset, it forwards that lead-in and the new speech exactly once. The provider feed can lag input by up to 200 ms while idle, then catches up on speech onset. Confirmed playback echo is replaced with silence before entering this buffer. Local VAD still owns barge-in, and idle audio is not accumulated into request recordings. Manual capture and locally endpointed providers retain their existing routing.
48
+
49
+ The gate shares the existing adaptive energy classifier. Quiet speech within the one-second tail passes through; quiet onset in the 200 ms buffer is preserved if followed by detected speech. Sustained speech entirely below the local threshold can be suppressed, and noise classified as speech can reopen the gate. This limits background exposure rather than guaranteeing that every false transcript is eliminated.
45
50
 
46
51
  For a focused reproduction, say an unfinished question such as "Did they help invent", pause briefly, then complete it with "ChatGPT". Repeat with a shorter and a longer pause, plus a complete short question. Note when the assistant starts answering and export the logs. Check that STT submissions continue during the pause, then use the provider end event's time, confidence, and trigger to distinguish model endpointing from a timeout. Tests verify audio delivery and event handling; they do not establish how Flux will segment real speech.
52
+
53
+ For the idle gate, stay silent and type for 20 seconds, then ask a question with a pause longer than one second. Repeat with a quiet voice and with a deliberate interruption during an answer. Check for phantom requests, a clipped first syllable, split questions, and missed interruptions. Correlate those observations with gate transitions and provider turn events.
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
package/openapi.yaml CHANGED
@@ -21120,6 +21120,7 @@ paths:
21120
21120
  - keenable
21121
21121
  - firecrawl
21122
21122
  - fastcrw
21123
+ - searxng
21123
21124
  resultCount:
21124
21125
  type: number
21125
21126
  durationMs:
@@ -21637,6 +21638,7 @@ paths:
21637
21638
  - keenable
21638
21639
  - firecrawl
21639
21640
  - fastcrw
21641
+ - searxng
21640
21642
  resultCount:
21641
21643
  type: number
21642
21644
  durationMs:
@@ -24508,6 +24510,22 @@ paths:
24508
24510
  anyOf:
24509
24511
  - type: string
24510
24512
  - type: "null"
24513
+ category:
24514
+ anyOf:
24515
+ - type: string
24516
+ enum:
24517
+ - productivity
24518
+ - communication
24519
+ - meetings
24520
+ - sales
24521
+ - marketing
24522
+ - finance
24523
+ - commerce
24524
+ - engineering
24525
+ - knowledge
24526
+ - recruiting
24527
+ - type: "null"
24528
+ description: Where the integrations catalog files the provider; null for a provider without a seed entry.
24511
24529
  tenant_host:
24512
24530
  anyOf:
24513
24531
  - type: object
@@ -24540,6 +24558,7 @@ paths:
24540
24558
  - supports_managed_mode
24541
24559
  - managed_service_is_paid
24542
24560
  - feature_flag
24561
+ - category
24543
24562
  - tenant_host
24544
24563
  - acts_as
24545
24564
  additionalProperties: false
@@ -25059,6 +25078,22 @@ paths:
25059
25078
  anyOf:
25060
25079
  - type: string
25061
25080
  - type: "null"
25081
+ category:
25082
+ anyOf:
25083
+ - type: string
25084
+ enum:
25085
+ - productivity
25086
+ - communication
25087
+ - meetings
25088
+ - sales
25089
+ - marketing
25090
+ - finance
25091
+ - commerce
25092
+ - engineering
25093
+ - knowledge
25094
+ - recruiting
25095
+ - type: "null"
25096
+ description: Where the integrations catalog files the provider; null for a provider without a seed entry.
25062
25097
  tenant_host:
25063
25098
  anyOf:
25064
25099
  - type: object
@@ -25091,6 +25126,7 @@ paths:
25091
25126
  - supports_managed_mode
25092
25127
  - managed_service_is_paid
25093
25128
  - feature_flag
25129
+ - category
25094
25130
  - tenant_host
25095
25131
  - acts_as
25096
25132
  additionalProperties: false
@@ -27656,6 +27692,11 @@ paths:
27656
27692
  type: string
27657
27693
  oauthProvider:
27658
27694
  type: string
27695
+ category:
27696
+ description:
27697
+ "Where the integrations catalog files the entry: a slug from the shared integration-category vocabulary,
27698
+ passed through as the catalog declares it."
27699
+ type: string
27659
27700
  required:
27660
27701
  - kind
27661
27702
  - displayName
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609181315.9aaf787",
3
+ "version": "0.12.2-dev.202609181515.7e76c92",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,17 +3,19 @@
3
3
  *
4
4
  * When the provider call throws (provider rejected the request before
5
5
  * returning a usable response), the loop must emit a `provider_error` event
6
- * carrying the loop-level raw request and the thrown error so downstream
7
- * consumers can persist an `llm_request_logs` row. Without this, rejected
8
- * calls leave nothing in the LLM inspector — only a pino log line.
6
+ * carrying the inspectable wire request (when the throw attached one) and
7
+ * the thrown error so downstream consumers can persist an `llm_request_logs`
8
+ * row. Without this, rejected calls leave nothing in the LLM inspector —
9
+ * only a pino log line.
9
10
  *
10
11
  * Coverage:
11
- * - Emits `provider_error` with `rawRequest`, `error`, and `actualProvider`
12
- * when the provider throws a `ProviderError`.
13
- * - `rawRequest` carries the message history, tools, and system prompt the
14
- * loop attempted to send — so the row replays/debugs cleanly.
15
- * - `actualProvider` echoes `ProviderError.provider` when available, falling
16
- * back to `provider.name` for non-ProviderError throws.
12
+ * - Emits `provider_error` with `error` and `actualProvider` when the
13
+ * provider throws a `ProviderError`.
14
+ * - `rawRequest` is `ProviderError.rawRequest` when present, and stays
15
+ * unset when the throw did not attach a wire payload. The loop does
16
+ * not invent a messages/tools/systemPrompt snapshot.
17
+ * - `actualProvider` echoes `ProviderError.provider` when available, even
18
+ * if the wrapping `provider.name` is a different default transport.
17
19
  * - The error is still re-thrown internally (the existing `error` event
18
20
  * still fires after the new `provider_error` event), preserving the
19
21
  * outer-catch behavior (abort/Sentry/break).
@@ -73,7 +75,7 @@ function makeThrowingProvider(
73
75
  }
74
76
 
75
77
  describe("AgentLoop provider_error event emission", () => {
76
- test("emits provider_error with loop-level rawRequest when provider throws ProviderError", async () => {
78
+ test("emits provider_error without inventing a rawRequest snapshot", async () => {
77
79
  const thrown = new ProviderError(
78
80
  "Anthropic API error (429): rate limited",
79
81
  "anthropic",
@@ -107,16 +109,81 @@ describe("AgentLoop provider_error event emission", () => {
107
109
  }
108
110
  expect(providerErrorEvent.error).toBe(thrown);
109
111
  expect(providerErrorEvent.actualProvider).toBe("anthropic");
112
+ expect(providerErrorEvent.rawRequest).toBeUndefined();
113
+ });
114
+
115
+ test("forwards ProviderError.rawRequest on the provider_error event", async () => {
116
+ const wirePayload = {
117
+ model: "qwen/qwen3-8b",
118
+ directions: { serious: 0.8 },
119
+ };
120
+ const thrown = new ProviderError(
121
+ "Vellum API error (400): directions are not loaded",
122
+ "vellum",
123
+ 400,
124
+ {
125
+ rawRequest: wirePayload,
126
+ rawBody: '{"detail":"directions are not loaded"}',
127
+ },
128
+ );
129
+ const { provider } = makeThrowingProvider("fireworks", () => thrown);
130
+
131
+ const events: AgentEvent[] = [];
132
+ const loop = new AgentLoop({
133
+ provider: provider,
134
+ systemPrompt: "system",
135
+ conversationId: "test-conversation",
136
+ });
137
+
138
+ await loop.run({
139
+ requestId: "test-request",
140
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
141
+ onEvent: (e) => {
142
+ events.push(e);
143
+ },
144
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
145
+ });
146
+
147
+ const providerErrorEvent = events.find((e) => e.type === "provider_error");
148
+ expect(providerErrorEvent).toBeDefined();
149
+ if (providerErrorEvent?.type !== "provider_error") {
150
+ throw new Error("type narrowing");
151
+ }
152
+ expect(providerErrorEvent.actualProvider).toBe("vellum");
153
+ expect(providerErrorEvent.rawRequest).toEqual(wirePayload);
154
+ });
155
+
156
+ test("actualProvider uses ProviderError.provider, not the wrapper name", async () => {
157
+ const thrown = new ProviderError(
158
+ "Vellum API error (400): directions are not loaded",
159
+ "vellum",
160
+ 400,
161
+ );
162
+ const { provider } = makeThrowingProvider("fireworks", () => thrown);
163
+
164
+ const events: AgentEvent[] = [];
165
+ const loop = new AgentLoop({
166
+ provider: provider,
167
+ systemPrompt: "system",
168
+ conversationId: "test-conversation",
169
+ });
170
+
171
+ await loop.run({
172
+ requestId: "test-request",
173
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
174
+ onEvent: (e) => {
175
+ events.push(e);
176
+ },
177
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
178
+ });
110
179
 
111
- // rawRequest should carry the loop-level abstract shape: messages,
112
- // tools, systemPrompt, and the provider name we tried to dispatch
113
- // through. The provider-specific shape (e.g. Gemini's `contents`) is
114
- // never built because the provider threw before returning it.
115
- const raw = providerErrorEvent.rawRequest as Record<string, unknown>;
116
- expect(raw.provider).toBe("anthropic");
117
- expect(raw.systemPrompt).toBe("you are a helpful assistant");
118
- expect(Array.isArray(raw.messages)).toBe(true);
119
- expect((raw.messages as Message[])[0].role).toBe("user");
180
+ const providerErrorEvent = events.find((e) => e.type === "provider_error");
181
+ expect(providerErrorEvent).toBeDefined();
182
+ if (providerErrorEvent?.type !== "provider_error") {
183
+ throw new Error("type narrowing");
184
+ }
185
+ expect(providerErrorEvent.actualProvider).toBe("vellum");
186
+ expect(providerErrorEvent.rawRequest).toBeUndefined();
120
187
  });
121
188
 
122
189
  test("error event still fires after provider_error (outer catch behavior unchanged)", async () => {
@@ -185,6 +252,7 @@ describe("AgentLoop provider_error event emission", () => {
185
252
  // `provider` column populated even for surprise errors.
186
253
  expect(providerErrorEvent.actualProvider).toBe("openai");
187
254
  expect(providerErrorEvent.error).toBe(thrown);
255
+ expect(providerErrorEvent.rawRequest).toBeUndefined();
188
256
  });
189
257
 
190
258
  test("does NOT emit provider_error on user-aborted runs", async () => {
@@ -103,6 +103,26 @@ describe("retagDelegateError", () => {
103
103
  expect(err.apiErrorCode).toBe("restricted");
104
104
  expect(err.apiErrorParam).toBe("model");
105
105
  expect(err.rawBody).toBe('{"type":"error"}');
106
+ expect(err.rawRequest).toBeUndefined();
107
+ });
108
+
109
+ test("carries rawRequest across the re-tag", () => {
110
+ const rawRequest = { model: "anthropic/claude-opus-4.6", messages: [] };
111
+ const inner = new ProviderError("rate limited", "anthropic", 429, {
112
+ rawRequest,
113
+ });
114
+
115
+ let caught: unknown;
116
+ try {
117
+ retagDelegateError(inner, providerName);
118
+ } catch (error) {
119
+ caught = error;
120
+ }
121
+
122
+ expect(caught).toBeInstanceOf(ProviderError);
123
+ const err = caught as ProviderError;
124
+ expect(err.provider).toBe(providerName);
125
+ expect(err.rawRequest).toEqual(rawRequest);
106
126
  });
107
127
 
108
128
  test("re-tags a delegate ContextOverflowError, preserving token counts", () => {
@@ -110,6 +130,7 @@ describe("retagDelegateError", () => {
110
130
  actualTokens: 250_000,
111
131
  maxTokens: 200_000,
112
132
  statusCode: 400,
133
+ rawRequest: { model: "anthropic/claude-opus-4.6" },
113
134
  });
114
135
 
115
136
  let caught: unknown;
@@ -126,6 +147,7 @@ describe("retagDelegateError", () => {
126
147
  expect(err.maxTokens).toBe(200_000);
127
148
  expect(err.statusCode).toBe(400);
128
149
  expect(err.cause).toBe(inner);
150
+ expect(err.rawRequest).toEqual({ model: "anthropic/claude-opus-4.6" });
129
151
  });
130
152
 
131
153
  test("rethrows an already-tagged error unchanged", () => {