@vellumai/assistant 0.10.1-staging.2 → 0.10.1-staging.4

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.
@@ -1,5 +1,9 @@
1
+ import * as Sentry from "@sentry/node";
1
2
  import { v4 as uuid } from "uuid";
3
+ import { z } from "zod";
2
4
 
5
+ import { SurfaceActionSchema } from "../api/events/ui-surface-show.js";
6
+ import { CardSurfaceDataSchema } from "../api/surfaces.js";
3
7
  import { isActivationSession } from "../memory/activation-session-store.js";
4
8
  import {
5
9
  addAppConversationId,
@@ -70,6 +74,16 @@ import type { TrustContext } from "./trust-context.js";
70
74
 
71
75
  const log = getLogger("conversation-surfaces");
72
76
 
77
+ // Tolerant variant of SurfaceActionSchema for parsing raw model output.
78
+ // The canonical schema rejects unknown style values; this one coerces them
79
+ // to "secondary" so a single mistyped style doesn't drop all actions.
80
+ const ModelActionSchema = SurfaceActionSchema.extend({
81
+ style: z
82
+ .enum(["primary", "secondary", "destructive"])
83
+ .catch("secondary")
84
+ .optional(),
85
+ });
86
+
73
87
  const MAX_UNDO_DEPTH = 10;
74
88
 
75
89
  /**
@@ -472,6 +486,27 @@ function normalizeDynamicPageShowData(
472
486
  return normalized as unknown as DynamicPageSurfaceData;
473
487
  }
474
488
 
489
+ /** First entry that is a non-empty (trimmed) string, else undefined. */
490
+ function firstNonEmptyString(values: unknown[]): string | undefined {
491
+ for (const value of values) {
492
+ if (typeof value === "string" && value.trim().length > 0) {
493
+ return value;
494
+ }
495
+ }
496
+ return undefined;
497
+ }
498
+
499
+ /** All non-empty (trimmed) strings from the values list. */
500
+ function allNonEmptyStrings(values: unknown[]): string[] {
501
+ const result: string[] = [];
502
+ for (const value of values) {
503
+ if (typeof value === "string" && value.trim().length > 0) {
504
+ result.push(value);
505
+ }
506
+ }
507
+ return result;
508
+ }
509
+
475
510
  function normalizeCardShowData(
476
511
  input: Record<string, unknown>,
477
512
  rawData: Record<string, unknown>,
@@ -507,6 +542,113 @@ function normalizeCardShowData(
507
542
  normalized.body = input.body;
508
543
  }
509
544
 
545
+ // The model sees every surface type's schema in the ui_show tool description,
546
+ // so it frequently borrows keys from sibling surfaces when emitting a card.
547
+ // Recover those into the canonical card fields, checking both data-level and
548
+ // top-level (input) placement. Multiple matches are concatenated (body) or
549
+ // first-wins (title/subtitle); all alias keys are deleted afterward so they
550
+ // don't appear as droppedKeys noise.
551
+ //
552
+ // body aliases: copy_block's `text`, confirmation's `message`, generic
553
+ // `content`, and cross-surface `description` (choice/form/oauth/work_result/
554
+ // dynamic_page — 5 types use it), work_result's `summary`, confirmation's
555
+ // `detail`.
556
+ const bodyAliasKeys = [
557
+ "text",
558
+ "message",
559
+ "content",
560
+ "description",
561
+ "summary",
562
+ "detail",
563
+ ] as const;
564
+ if (typeof normalized.body !== "string" || normalized.body.trim() === "") {
565
+ const candidates = allNonEmptyStrings(
566
+ bodyAliasKeys.map((k) => {
567
+ const dataVal = normalized[k];
568
+ if (typeof dataVal === "string" && dataVal.trim().length > 0)
569
+ return dataVal;
570
+ return input[k];
571
+ }),
572
+ );
573
+ if (candidates.length > 0) {
574
+ // Temporary: concatenate all matching aliases so no content is lost.
575
+ // A future pass should define per-alias semantic roles (e.g. summary
576
+ // as a subtitle, detail as supplementary) once production telemetry
577
+ // reveals which combinations actually occur.
578
+ normalized.body = candidates.join("\n\n");
579
+ const usedAliases = bodyAliasKeys.filter(
580
+ (k) =>
581
+ (typeof normalized[k] === "string" &&
582
+ (normalized[k] as string).trim().length > 0) ||
583
+ (typeof input[k] === "string" &&
584
+ (input[k] as string).trim().length > 0),
585
+ );
586
+ Sentry.addBreadcrumb({
587
+ category: "card-normalization",
588
+ message: `alias recovery: ${usedAliases.join(", ")} → body`,
589
+ level: "info",
590
+ data: { usedAliases, candidateCount: candidates.length },
591
+ });
592
+ }
593
+ }
594
+ for (const key of bodyAliasKeys) {
595
+ delete normalized[key];
596
+ }
597
+
598
+ // title aliases: natural synonyms the model reaches for when it doesn't
599
+ // use `title` verbatim.
600
+ const titleAliasKeys = ["heading", "header", "name"] as const;
601
+ if (typeof normalized.title !== "string" || normalized.title.trim() === "") {
602
+ const aliased = firstNonEmptyString([
603
+ ...titleAliasKeys.map((k) => normalized[k]),
604
+ ...titleAliasKeys.map((k) => input[k]),
605
+ ]);
606
+ if (aliased !== undefined) {
607
+ normalized.title = aliased;
608
+ Sentry.addBreadcrumb({
609
+ category: "card-normalization",
610
+ message: `alias recovery: title`,
611
+ level: "info",
612
+ });
613
+ }
614
+ }
615
+ for (const key of titleAliasKeys) {
616
+ delete normalized[key];
617
+ }
618
+
619
+ // subtitle aliases: table's `caption`, natural synonym `subheading`.
620
+ if (
621
+ typeof normalized.subtitle !== "string" &&
622
+ typeof input.subtitle === "string"
623
+ ) {
624
+ normalized.subtitle = input.subtitle;
625
+ }
626
+ const subtitleAliasKeys = ["subheading", "caption"] as const;
627
+ if (
628
+ typeof normalized.subtitle !== "string" ||
629
+ normalized.subtitle.trim() === ""
630
+ ) {
631
+ const aliased = firstNonEmptyString([
632
+ ...subtitleAliasKeys.map((k) => normalized[k]),
633
+ ...subtitleAliasKeys.map((k) => input[k]),
634
+ ]);
635
+ if (aliased !== undefined) {
636
+ normalized.subtitle = aliased;
637
+ Sentry.addBreadcrumb({
638
+ category: "card-normalization",
639
+ message: `alias recovery: subtitle`,
640
+ level: "info",
641
+ });
642
+ }
643
+ }
644
+ for (const key of subtitleAliasKeys) {
645
+ delete normalized[key];
646
+ }
647
+
648
+ if (!Array.isArray(normalized.metadata) && Array.isArray(input.metadata)) {
649
+ normalized.metadata = input.metadata;
650
+ }
651
+
510
652
  // task_progress cards: additional fallbacks for title from templateData.
511
653
  if (
512
654
  normalized.template === "task_progress" &&
@@ -533,7 +675,40 @@ function normalizeCardShowData(
533
675
  ensureTaskProgressTemplateData(normalized);
534
676
  }
535
677
 
536
- return normalized as unknown as CardSurfaceData;
678
+ // Parse, don't assert. The old `as unknown as CardSurfaceData` accepted any
679
+ // shape, so anything the model nested under an unmodelled key was carried
680
+ // through unread. Parsing draws the boundary; the dropped-key log surfaces
681
+ // the shapes we still don't recover, so the recovery list above can grow from
682
+ // real traffic rather than guesswork.
683
+ const droppedKeys = Object.keys(normalized).filter(
684
+ (key) => !(key in CardSurfaceDataSchema.shape),
685
+ );
686
+ if (droppedKeys.length > 0) {
687
+ log.warn(
688
+ { droppedKeys },
689
+ "ui_show card data carried keys the card contract does not model; their content will not render",
690
+ );
691
+ Sentry.addBreadcrumb({
692
+ category: "card-normalization",
693
+ message: `dropped keys: ${droppedKeys.join(", ")}`,
694
+ level: "warning",
695
+ data: { droppedKeys },
696
+ });
697
+ }
698
+ const parsed = CardSurfaceDataSchema.safeParse(normalized);
699
+ if (parsed.success) {
700
+ return parsed.data;
701
+ }
702
+ log.warn(
703
+ { issues: parsed.error.issues },
704
+ "ui_show card data failed CardSurfaceDataSchema; rendering only the fields that validated",
705
+ );
706
+ return CardSurfaceDataSchema.parse({
707
+ title: typeof normalized.title === "string" ? normalized.title : undefined,
708
+ subtitle:
709
+ typeof normalized.subtitle === "string" ? normalized.subtitle : undefined,
710
+ body: typeof normalized.body === "string" ? normalized.body : undefined,
711
+ });
537
712
  }
538
713
 
539
714
  function normalizeTaskProgressCardPatch(
@@ -2693,9 +2868,17 @@ export async function surfaceProxyResolver(
2693
2868
  const surfaceType = input.surface_type as SurfaceType;
2694
2869
  const title = typeof input.title === "string" ? input.title : undefined;
2695
2870
  const rawData = isPlainObject(input.data) ? input.data : {};
2696
- const data = (
2871
+ // Each surface type that has a canonical Zod schema gets parsed through it;
2872
+ // the rest pass through raw until migrated (LUM-2134 scope). The per-type
2873
+ // normalizers validate+recover; the union cast at the end is only for the
2874
+ // unmigrated branches that still return hand-written interfaces.
2875
+ const cardData =
2697
2876
  surfaceType === "card"
2698
2877
  ? normalizeCardShowData(input, rawData)
2878
+ : undefined;
2879
+ const data: SurfaceData =
2880
+ cardData !== undefined
2881
+ ? cardData
2699
2882
  : surfaceType === "choice"
2700
2883
  ? normalizeChoiceShowData(rawData)
2701
2884
  : surfaceType === "copy_block"
@@ -2704,21 +2887,43 @@ export async function surfaceProxyResolver(
2704
2887
  ? normalizeOAuthConnectShowData(rawData)
2705
2888
  : surfaceType === "dynamic_page"
2706
2889
  ? normalizeDynamicPageShowData(input, rawData)
2707
- : rawData
2708
- ) as SurfaceData;
2709
- const inputActions = input.actions as
2710
- | Array<{
2711
- id: string;
2712
- label: string;
2713
- style?: string;
2714
- data?: Record<string, unknown>;
2715
- }>
2716
- | undefined;
2890
+ : (rawData as SurfaceData);
2891
+ // Parse actions through the schema instead of typecasting raw model output.
2892
+ // The model may place actions inside `data` instead of the top-level
2893
+ // `actions` param — recover them so they aren't silently dropped.
2894
+ const rawActions = Array.isArray(input.actions)
2895
+ ? input.actions
2896
+ : Array.isArray(rawData.actions)
2897
+ ? rawData.actions
2898
+ : undefined;
2899
+ let inputActions: z.infer<typeof ModelActionSchema>[] | undefined;
2900
+ if (rawActions) {
2901
+ const valid: z.infer<typeof ModelActionSchema>[] = [];
2902
+ for (const raw of rawActions) {
2903
+ const result = ModelActionSchema.safeParse(raw);
2904
+ if (result.success) {
2905
+ valid.push(result.data);
2906
+ } else {
2907
+ Sentry.addBreadcrumb({
2908
+ category: "card-normalization",
2909
+ message: "action parse failure (individual)",
2910
+ level: "warning",
2911
+ data: {
2912
+ issuePaths: result.error.issues.map((i) => i.path.join(".")),
2913
+ keys:
2914
+ typeof raw === "object" && raw !== null
2915
+ ? Object.keys(raw)
2916
+ : [typeof raw],
2917
+ },
2918
+ });
2919
+ }
2920
+ }
2921
+ inputActions = valid.length > 0 ? valid : undefined;
2922
+ }
2717
2923
  const actions =
2718
2924
  surfaceType === "choice"
2719
2925
  ? buildChoiceActions(data as ChoiceSurfaceData)
2720
2926
  : inputActions;
2721
- // Interactive surfaces default to awaiting user action.
2722
2927
  const hasActions = Array.isArray(actions) && actions.length > 0;
2723
2928
  if (surfaceType === "choice" && !hasActions) {
2724
2929
  return {
@@ -2727,6 +2932,39 @@ export async function surfaceProxyResolver(
2727
2932
  isError: true,
2728
2933
  };
2729
2934
  }
2935
+ if (cardData !== undefined) {
2936
+ const hasTitle =
2937
+ (typeof title === "string" && title.trim().length > 0) ||
2938
+ (typeof cardData.title === "string" &&
2939
+ cardData.title.trim().length > 0);
2940
+ const hasBody =
2941
+ typeof cardData.body === "string" && cardData.body.trim().length > 0;
2942
+ const hasSubtitle =
2943
+ typeof cardData.subtitle === "string" &&
2944
+ cardData.subtitle.trim().length > 0;
2945
+ const hasMetadata =
2946
+ Array.isArray(cardData.metadata) && cardData.metadata.length > 0;
2947
+ const hasTemplate = typeof cardData.template === "string";
2948
+ if (
2949
+ !hasTitle &&
2950
+ !hasBody &&
2951
+ !hasSubtitle &&
2952
+ !hasMetadata &&
2953
+ !hasTemplate &&
2954
+ !hasActions
2955
+ ) {
2956
+ Sentry.addBreadcrumb({
2957
+ category: "card-normalization",
2958
+ message: "empty card rejected",
2959
+ level: "warning",
2960
+ });
2961
+ return {
2962
+ content:
2963
+ "Error: ui_show card requires content — provide `data.body`, a `template` (e.g. task_progress with steps), `data.metadata`, `data.subtitle`, a `title`, or `actions`. The surface was not displayed because it carried no renderable content. Resend ui_show with populated card content.",
2964
+ isError: true,
2965
+ };
2966
+ }
2967
+ }
2730
2968
  const oauthProviderKey =
2731
2969
  surfaceType === "oauth_connect"
2732
2970
  ? (data as unknown as Record<string, unknown>).providerKey
@@ -2741,6 +2979,7 @@ export async function surfaceProxyResolver(
2741
2979
  isError: true,
2742
2980
  };
2743
2981
  }
2982
+
2744
2983
  const isInteractive =
2745
2984
  surfaceType === "card"
2746
2985
  ? hasActions
@@ -2777,10 +3016,7 @@ export async function surfaceProxyResolver(
2777
3016
  const mappedActions = actions?.map((a) => ({
2778
3017
  id: a.id,
2779
3018
  label: a.label,
2780
- style: (a.style ?? "secondary") as
2781
- | "primary"
2782
- | "secondary"
2783
- | "destructive",
3019
+ style: a.style ?? "secondary",
2784
3020
  ...(a.data ? { data: a.data } : {}),
2785
3021
  }));
2786
3022
 
@@ -2896,7 +3132,26 @@ export async function surfaceProxyResolver(
2896
3132
  const currentHtml = (stored.data as DynamicPageSurfaceData).html;
2897
3133
  pushUndoState(ctx.surfaceUndoStacks, surfaceId, currentHtml);
2898
3134
  }
2899
- mergedData = { ...stored.data, ...patch } as SurfaceData;
3135
+ const rawMerged = { ...stored.data, ...patch };
3136
+ if (stored.surfaceType === "card") {
3137
+ // Validate the merged card data through the canonical schema so
3138
+ // malformed patches (e.g. metadata as a string) are caught here
3139
+ // instead of crashing the client's safeParse.
3140
+ const parsed = CardSurfaceDataSchema.safeParse(rawMerged);
3141
+ mergedData = parsed.success
3142
+ ? parsed.data
3143
+ : (CardSurfaceDataSchema.safeParse(stored.data).data ?? {});
3144
+ if (!parsed.success) {
3145
+ log.warn(
3146
+ { surfaceId, issues: parsed.error.issues },
3147
+ "ui_update card patch produced invalid merged data; reverting to stored data",
3148
+ );
3149
+ }
3150
+ } else {
3151
+ // Other surface types lack canonical Zod schemas (LUM-2134 scope).
3152
+ // The raw merge is the best we can do until they're migrated.
3153
+ mergedData = rawMerged as SurfaceData;
3154
+ }
2900
3155
  stored.data = mergedData;
2901
3156
  } else {
2902
3157
  mergedData = patch as unknown as SurfaceData;
@@ -1,6 +1,16 @@
1
1
  // Surface types, UI surface lifecycle messages.
2
2
 
3
- import { z } from "zod";
3
+ import {
4
+ type CardSurfaceData,
5
+ CardSurfaceDataSchema,
6
+ } from "../../api/surfaces.js";
7
+
8
+ // Surface `data` shapes are wire payloads owned by `@vellumai/assistant-api`.
9
+ // Card is migrated (canonical Zod schema); the remaining types below are still
10
+ // hand-written interfaces pending migration. Re-exported so the daemon's
11
+ // surface protocol barrel (`message-protocol.ts`) keeps surfacing them to
12
+ // daemon consumers under their canonical names.
13
+ export { type CardSurfaceData, CardSurfaceDataSchema };
4
14
 
5
15
  // === Surface type definitions ===
6
16
 
@@ -37,25 +47,6 @@ export interface SurfaceAction {
37
47
  data?: Record<string, unknown>;
38
48
  }
39
49
 
40
- /**
41
- * Card surface data. Defined as a Zod schema so the type is derived (not
42
- * hand-maintained) and the seed-content-block schema can compose it directly
43
- * instead of treating card `data` as an opaque record.
44
- */
45
- export const CardSurfaceDataSchema = z.object({
46
- title: z.string(),
47
- subtitle: z.string().optional(),
48
- body: z.string(),
49
- metadata: z
50
- .array(z.object({ label: z.string(), value: z.string() }))
51
- .optional(),
52
- /** Optional template name for specialized rendering (e.g. "weather_forecast"). */
53
- template: z.string().optional(),
54
- /** Arbitrary data consumed by the template renderer. Shape depends on template. */
55
- templateData: z.record(z.string(), z.unknown()).optional(),
56
- });
57
- export type CardSurfaceData = z.infer<typeof CardSurfaceDataSchema>;
58
-
59
50
  export interface ChoiceOption {
60
51
  id: string;
61
52
  title: string;
@@ -40,7 +40,7 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
40
40
  this.taskType = options?.taskType;
41
41
  this.dimensions = options?.dimensions;
42
42
  this.managedBaseUrl = options?.managedBaseUrl;
43
- this.interCallDelayMs = options?.interCallDelayMs ?? 5000;
43
+ this.interCallDelayMs = options?.interCallDelayMs ?? 100;
44
44
  }
45
45
 
46
46
  /** True when requests route through the managed platform proxy. */
@@ -835,6 +835,11 @@ export class AnthropicProvider implements Provider {
835
835
  disableCache: _disableCache,
836
836
  max_tokens: callerMaxTokens,
837
837
  usageAttributionHeaders,
838
+ // Pulled out of `restConfig` so they are forwarded conditionally below:
839
+ // newer models reject them outright (see `deprecatesSamplingParams`).
840
+ temperature: callerTemperature,
841
+ top_p: callerTopP,
842
+ top_k: callerTopK,
838
843
  ...restConfig
839
844
  } = (config ?? {}) as Record<string, unknown> & {
840
845
  // "xhigh" is an intermediate tier between "high" and "max" supported
@@ -847,6 +852,9 @@ export class AnthropicProvider implements Provider {
847
852
  speed?: "standard" | "fast";
848
853
  output_config?: Record<string, unknown>;
849
854
  usageAttributionHeaders?: Record<string, string>;
855
+ temperature?: number;
856
+ top_p?: number;
857
+ top_k?: number;
850
858
  };
851
859
  // Haiku does not support the effort / output_config parameter or
852
860
  // extended cache TTL betas.
@@ -856,6 +864,16 @@ export class AnthropicProvider implements Provider {
856
864
  (restConfig as Record<string, unknown>).model?.toString() ?? this.model;
857
865
  const isHaiku = effectiveModel.includes("haiku");
858
866
  const supportsEffort = !isHaiku;
867
+ // opus-4-7 / opus-4-8 reject `temperature` and `top_p` with a 400
868
+ // "`temperature`/`top_p` is deprecated for this model" — model-wide, not
869
+ // effort-conditional (verified 2026-06-23). opus-4-6 / sonnet-4-6 /
870
+ // haiku-4-5 still accept them. fable-5 is included conservatively (a
871
+ // frontier model that could not be verified directly but follows the same
872
+ // deprecation direction). Stripping the params here keeps callers that set
873
+ // them (e.g. the memory-v3 L2 selector's `temperature: 0`) from 400ing.
874
+ const deprecatesSamplingParams =
875
+ /claude-opus-4-[78]\b/.test(effectiveModel) ||
876
+ effectiveModel.startsWith("claude-fable-");
859
877
  const mergedOutputConfig = {
860
878
  ...(output_config ?? {}),
861
879
  ...(effort && effort !== "none" && supportsEffort
@@ -883,6 +901,19 @@ export class AnthropicProvider implements Provider {
883
901
  : 64000,
884
902
  messages: sentMessages,
885
903
  ...restConfig,
904
+ // Forward `temperature` / `top_p` / `top_k` only to models that still
905
+ // accept them; newer models 400 on any of the deprecated sampler params.
906
+ // `temperature: 0` is preserved for accepting models (a `typeof ===
907
+ // "number"` check, not truthiness).
908
+ ...(deprecatesSamplingParams
909
+ ? {}
910
+ : {
911
+ ...(typeof callerTemperature === "number"
912
+ ? { temperature: callerTemperature }
913
+ : {}),
914
+ ...(typeof callerTopP === "number" ? { top_p: callerTopP } : {}),
915
+ ...(typeof callerTopK === "number" ? { top_k: callerTopK } : {}),
916
+ }),
886
917
  ...(Object.keys(mergedOutputConfig).length > 0
887
918
  ? { output_config: mergedOutputConfig }
888
919
  : {}),
@@ -45,14 +45,6 @@ function proxyExecute(toolName: string) {
45
45
  };
46
46
  }
47
47
 
48
- if (toolName === "ui_show" && isEmptyCard(input)) {
49
- return {
50
- content:
51
- "Error: ui_show card requires content — provide `data.body`, a `template` (e.g. task_progress with steps), `data.metadata`, or `actions`. The surface was not displayed because it carried only a title, which renders as a blank box. Resend ui_show with populated card content.",
52
- isError: true,
53
- };
54
- }
55
-
56
48
  if (toolName === "ui_show" && isDynamicPageAppSubstitute(input)) {
57
49
  return {
58
50
  content:
@@ -168,41 +160,6 @@ function isEmptyDynamicPage(input: Record<string, unknown>): boolean {
168
160
  return typeof html !== "string" || html.trim().length === 0;
169
161
  }
170
162
 
171
- /**
172
- * A `card` ui_show carrying no renderable content — only a title (or nothing)
173
- * — renders as a blank bordered box. A declared `template` (task_progress,
174
- * weather_forecast, …) renders its own shell, and `body`/`subtitle`/`metadata`/
175
- * `actions` are real content; any of those passes. The model places these
176
- * either nested in `data` or at the top level, so both are checked. Title is
177
- * intentionally not content: a title-only card is the blank box.
178
- */
179
- function isEmptyCard(input: Record<string, unknown>): boolean {
180
- if (input.surface_type !== "card") {
181
- return false;
182
- }
183
- const data = asRecord(input.data) ?? {};
184
-
185
- const template =
186
- nonEmptyString(input.template) ?? nonEmptyString(data.template);
187
- if (template) {
188
- return false;
189
- }
190
-
191
- const hasBody = !!(nonEmptyString(input.body) ?? nonEmptyString(data.body));
192
- const hasSubtitle = !!nonEmptyString(data.subtitle);
193
- const hasMetadata = Array.isArray(data.metadata) && data.metadata.length > 0;
194
- const actions = input.actions ?? data.actions;
195
- const hasActions = Array.isArray(actions) && actions.length > 0;
196
-
197
- return !(hasBody || hasSubtitle || hasMetadata || hasActions);
198
- }
199
-
200
- function nonEmptyString(value: unknown): string | undefined {
201
- return typeof value === "string" && value.trim().length > 0
202
- ? value
203
- : undefined;
204
- }
205
-
206
163
  function isDynamicPageAppSubstitute(input: Record<string, unknown>): boolean {
207
164
  if (input.surface_type !== "dynamic_page") {
208
165
  return false;