@agent-native/core 0.161.0 → 0.161.2

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 (44) hide show
  1. package/corpus/templates/analytics/actions/compose-dashboard.ts +5 -2
  2. package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +11 -5
  3. package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +89 -2
  4. package/corpus/templates/analytics/actions/update-dashboard.ts +14 -2
  5. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +6 -0
  6. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +89 -41
  7. package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +8 -3
  8. package/corpus/templates/analytics/server/lib/error-capture.ts +6 -0
  9. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +187 -44
  10. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +44 -16
  11. package/dist/agent/engine/builder-engine.js +13 -5
  12. package/dist/agent/engine/error-detail.d.ts +17 -0
  13. package/dist/agent/engine/error-detail.js +27 -0
  14. package/dist/agent/engine/types.d.ts +10 -0
  15. package/dist/agent/engine/types.js +3 -0
  16. package/dist/agent/production-agent.js +7 -1
  17. package/dist/agent/run-manager.js +8 -0
  18. package/dist/agent/thread-data-builder.js +5 -0
  19. package/dist/client/error-format.js +15 -0
  20. package/dist/client/sse-event-processor.js +4 -0
  21. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  22. package/dist/localization/core-messages/ar-SA.js +1 -0
  23. package/dist/localization/core-messages/de-DE.js +1 -0
  24. package/dist/localization/core-messages/en-US.d.ts +1 -0
  25. package/dist/localization/core-messages/en-US.js +1 -0
  26. package/dist/localization/core-messages/es-ES.js +1 -0
  27. package/dist/localization/core-messages/fr-FR.js +1 -0
  28. package/dist/localization/core-messages/hi-IN.js +1 -0
  29. package/dist/localization/core-messages/ja-JP.js +1 -0
  30. package/dist/localization/core-messages/ko-KR.js +1 -0
  31. package/dist/localization/core-messages/pt-BR.js +1 -0
  32. package/dist/localization/core-messages/zh-CN.js +1 -0
  33. package/dist/localization/core-messages/zh-TW.js +1 -0
  34. package/dist/localization/core-messages.d.ts +1 -0
  35. package/dist/observability/routes.d.ts +3 -3
  36. package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
  37. package/dist/resources/handlers.d.ts +1 -1
  38. package/dist/secrets/routes.d.ts +9 -9
  39. package/dist/server/realtime-token.d.ts +1 -1
  40. package/dist/server/release-migrations.js +4 -0
  41. package/dist/server/transcribe-voice.d.ts +1 -1
  42. package/dist/workspace-connections/migrations.d.ts +18 -0
  43. package/dist/workspace-connections/migrations.js +153 -0
  44. package/package.json +3 -3
@@ -34,6 +34,23 @@ export interface FirstPartyAnalyticsScope {
34
34
  orgId: string | null;
35
35
  }
36
36
 
37
+ /**
38
+ * Stored panel SQL is valid PostgreSQL but has no BigQuery equivalent. This is
39
+ * not a query failure: the panel cannot run for this scope until its SQL or the
40
+ * scope's sink changes, so retrying is pointless and callers render an
41
+ * explanatory state instead. `construct` names the exact syntax that has no
42
+ * mapping, and is the only part safe to show a user.
43
+ */
44
+ export class FirstPartyAnalyticsUnsupportedSqlError extends Error {
45
+ readonly construct: string;
46
+
47
+ constructor(construct: string, message: string) {
48
+ super(message);
49
+ this.name = "FirstPartyAnalyticsUnsupportedSqlError";
50
+ this.construct = construct;
51
+ }
52
+ }
53
+
37
54
  interface FirstPartyAnalyticsBackendSetting {
38
55
  sink?: unknown;
39
56
  table?: unknown;
@@ -572,6 +589,59 @@ function rewriteOutsideSqlLiterals(
572
589
  return result;
573
590
  }
574
591
 
592
+ const SQL_LITERAL_PLACEHOLDER_PREFIX = "_fpa_lit_";
593
+
594
+ /**
595
+ * Same intent as `rewriteOutsideSqlLiterals`, but `rewrite` sees one string
596
+ * with each literal stood in for by an identifier-shaped placeholder rather
597
+ * than a sequence of fragments split at every quote. A cast operand routinely
598
+ * sits on the far side of a literal — `'2026-08-01'::date`,
599
+ * `(COALESCE(properties, '{}'))::text` — and the fragment view cuts that
600
+ * operand in half, which is why both read as "invalid PostgreSQL cast".
601
+ */
602
+ function rewriteWithMaskedSqlLiterals(
603
+ sql: string,
604
+ rewrite: (code: string) => string,
605
+ ): string {
606
+ if (sql.includes(SQL_LITERAL_PLACEHOLDER_PREFIX)) {
607
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
608
+ `an identifier reserved for query translation (${SQL_LITERAL_PLACEHOLDER_PREFIX}*)`,
609
+ `First-party BigQuery query cannot use identifiers starting with ${SQL_LITERAL_PLACEHOLDER_PREFIX}`,
610
+ );
611
+ }
612
+ const literals: string[] = [];
613
+ let masked = "";
614
+ let cursor = 0;
615
+ while (cursor < sql.length) {
616
+ const literalStart = sql.indexOf("'", cursor);
617
+ if (literalStart === -1) {
618
+ masked += sql.slice(cursor);
619
+ break;
620
+ }
621
+ masked += sql.slice(cursor, literalStart);
622
+ let literalEnd = literalStart + 1;
623
+ while (literalEnd < sql.length) {
624
+ if (sql[literalEnd] !== "'") {
625
+ literalEnd++;
626
+ continue;
627
+ }
628
+ if (sql[literalEnd + 1] === "'") {
629
+ literalEnd += 2;
630
+ continue;
631
+ }
632
+ literalEnd++;
633
+ break;
634
+ }
635
+ masked += `${SQL_LITERAL_PLACEHOLDER_PREFIX}${literals.length}_`;
636
+ literals.push(sql.slice(literalStart, literalEnd));
637
+ cursor = literalEnd;
638
+ }
639
+ return rewrite(masked).replace(
640
+ new RegExp(`${SQL_LITERAL_PLACEHOLDER_PREFIX}(\\d+)_`, "g"),
641
+ (whole, index: string) => literals[Number(index)] ?? whole,
642
+ );
643
+ }
644
+
575
645
  function findMatchingSqlParen(sql: string, openIndex: number): number {
576
646
  let depth = 0;
577
647
  let inLiteral = false;
@@ -697,6 +767,46 @@ function coerceDateComparisonOperands(sql: string): string {
697
767
  );
698
768
  }
699
769
 
770
+ /**
771
+ * Extent of the operand a `::` cast at `castIndex` applies to.
772
+ *
773
+ * The function-call case is the trap: stopping at the matching `(` leaves the
774
+ * function name outside the rewritten CAST, so `sum(x)::numeric` became
775
+ * `sumCAST((x) AS NUMERIC)` and BigQuery answered `Function not found: SUMCAST`.
776
+ */
777
+ function postgresCastOperandBounds(
778
+ code: string,
779
+ castIndex: number,
780
+ ): { start: number; end: number } {
781
+ let end = castIndex;
782
+ while (end > 0 && /\s/.test(code[end - 1] ?? "")) {
783
+ end--;
784
+ }
785
+ let start = end;
786
+ if (code[end - 1] === ")") {
787
+ let depth = 0;
788
+ for (let index = end - 1; index >= 0; index--) {
789
+ if (code[index] === ")") depth++;
790
+ if (code[index] !== "(") continue;
791
+ depth--;
792
+ if (depth === 0) {
793
+ start = index;
794
+ break;
795
+ }
796
+ }
797
+ }
798
+ while (start > 0 && /[A-Za-z0-9_.$]/.test(code[start - 1] ?? "")) {
799
+ start--;
800
+ }
801
+ if (start === end) {
802
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
803
+ "a PostgreSQL cast with no readable operand",
804
+ "First-party BigQuery query has an invalid PostgreSQL cast",
805
+ );
806
+ }
807
+ return { start, end };
808
+ }
809
+
700
810
  function replacePostgresCastsInCode(code: string): string {
701
811
  const castType = new RegExp(
702
812
  "::\\s*(date|timestamp|timestamptz|int|int2|int4|int8|integer|float|float4|float8|double\\s+precision|numeric|text|varchar|boolean|bool|json|jsonb)\\b",
@@ -728,40 +838,16 @@ function replacePostgresCastsInCode(code: string): string {
728
838
  };
729
839
  const targetType = mappedType[normalizedType];
730
840
  if (!targetType) {
731
- throw new Error(
841
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
842
+ `a PostgreSQL ${normalizedType} cast`,
732
843
  `First-party BigQuery query does not support PostgreSQL ${normalizedType} casts`,
733
844
  );
734
845
  }
735
846
 
736
- let operandEnd = match.index;
737
- while (operandEnd > 0 && /\s/.test(result[operandEnd - 1] ?? "")) {
738
- operandEnd--;
739
- }
740
- let operandStart = operandEnd;
741
- if (result[operandEnd - 1] === ")") {
742
- let depth = 0;
743
- for (let index = operandEnd - 1; index >= 0; index--) {
744
- if (result[index] === ")") depth++;
745
- if (result[index] !== "(") continue;
746
- depth--;
747
- if (depth === 0) {
748
- operandStart = index;
749
- break;
750
- }
751
- }
752
- } else {
753
- while (
754
- operandStart > 0 &&
755
- /[A-Za-z0-9_.$]/.test(result[operandStart - 1] ?? "")
756
- ) {
757
- operandStart--;
758
- }
759
- }
760
- if (operandStart === operandEnd) {
761
- throw new Error(
762
- "First-party BigQuery query has an invalid PostgreSQL cast",
763
- );
764
- }
847
+ const { start: operandStart, end: operandEnd } = postgresCastOperandBounds(
848
+ result,
849
+ match.index,
850
+ );
765
851
  const operand = result.slice(operandStart, operandEnd).trim();
766
852
  const castEnd = match.index + match[0].length;
767
853
  result = `${result.slice(0, operandStart)}CAST(${operand} AS ${targetType})${result.slice(castEnd)}`;
@@ -810,12 +896,45 @@ function replaceBigQueryDateArithmetic(code: string): string {
810
896
  return translated;
811
897
  }
812
898
 
899
+ /**
900
+ * PostgreSQL truncates to the start of the ISO week (Monday); BigQuery's bare
901
+ * `WEEK` starts on Sunday, so the week mapping must name the weekday or the
902
+ * same query silently buckets differently on each backend.
903
+ */
904
+ const BIGQUERY_DATE_TRUNC_PARTS: Record<string, string> = {
905
+ day: "DAY",
906
+ week: "WEEK(MONDAY)",
907
+ month: "MONTH",
908
+ quarter: "QUARTER",
909
+ year: "YEAR",
910
+ };
911
+
912
+ /**
913
+ * BigQuery JSONPath field names are always quoted here rather than only when
914
+ * they look unusual: an unquoted `$.$ai_model` is rejected outright, and an
915
+ * unquoted `$.page.title` silently reads a nested field the caller never asked
916
+ * for.
917
+ */
918
+ function bigQueryJsonPath(key: string): string {
919
+ return `'$."${key.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"'`;
920
+ }
921
+
813
922
  function translatePostgresJsonOperators(sql: string): string {
814
- return sql.replace(
815
- /\b([A-Za-z_][A-Za-z0-9_.]*)\s*::\s*jsonb\s*->>\s*'([^']+)'/gi,
816
- (_match, expression: string, key: string) =>
817
- `JSON_VALUE(${expression}, '$.${key}')`,
818
- );
923
+ const jsonExtract = /\s*::\s*jsonb?\s*->>\s*'([^']*)'/gi;
924
+ let result = sql;
925
+ let match = jsonExtract.exec(result);
926
+ while (match) {
927
+ const { start, end } = postgresCastOperandBounds(result, match.index);
928
+ const operand = result.slice(start, end).trim();
929
+ const replacement = `JSON_VALUE(${operand}, ${bigQueryJsonPath(match[1] ?? "")})`;
930
+ result =
931
+ result.slice(0, start) +
932
+ replacement +
933
+ result.slice(match.index + match[0].length);
934
+ jsonExtract.lastIndex = start + replacement.length;
935
+ match = jsonExtract.exec(result);
936
+ }
937
+ return result;
819
938
  }
820
939
 
821
940
  function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
@@ -837,16 +956,20 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
837
956
  `INTERVAL ${amount} ${unit.replace(/s$/i, "").toUpperCase()}`,
838
957
  );
839
958
  translated = rewriteSqlFunctionCalls(translated, "date_trunc", (args) => {
840
- if (args.length !== 2 || !/^'week'$/i.test(args[0] ?? "")) {
841
- throw new Error(
842
- "First-party BigQuery query only supports PostgreSQL date_trunc('week', ...) expressions",
959
+ const unit = /^'([a-z]+)'$/i.exec(args[0] ?? "")?.[1]?.toLowerCase();
960
+ const datePart = unit ? BIGQUERY_DATE_TRUNC_PARTS[unit] : undefined;
961
+ if (args.length !== 2 || !datePart) {
962
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
963
+ `date_trunc(${args[0] ?? "?"}, ...)`,
964
+ `First-party BigQuery query supports PostgreSQL date_trunc with ${Object.keys(BIGQUERY_DATE_TRUNC_PARTS).join(", ")}`,
843
965
  );
844
966
  }
845
- return `DATE_TRUNC(CAST(${translatePostgresDateExpression(args[1] ?? "")} AS DATE), WEEK(MONDAY))`;
967
+ return `DATE_TRUNC(CAST(${translatePostgresDateExpression(args[1] ?? "")} AS DATE), ${datePart})`;
846
968
  });
847
969
  translated = rewriteSqlFunctionCalls(translated, "to_char", (args) => {
848
970
  if (args.length !== 2 || !/^'YYYY-MM-DD'$/i.test(args[1] ?? "")) {
849
- throw new Error(
971
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
972
+ `to_char(..., ${args[1] ?? "?"})`,
850
973
  "First-party BigQuery query only supports PostgreSQL to_char(..., 'YYYY-MM-DD') expressions",
851
974
  );
852
975
  }
@@ -854,7 +977,10 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
854
977
  });
855
978
  translated = rewriteSqlFunctionCalls(translated, "chr", (args) => {
856
979
  if (args.length !== 1) {
857
- throw new Error("First-party BigQuery query has an invalid chr call");
980
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
981
+ "a multi-argument chr(...) call",
982
+ "First-party BigQuery query has an invalid chr call",
983
+ );
858
984
  }
859
985
  return `CHR(${args[0]})`;
860
986
  });
@@ -864,14 +990,15 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
864
990
  translated = rewriteSqlFunctionCalls(translated, "split_part", (args) => {
865
991
  const index = Number(args[2]);
866
992
  if (args.length !== 3 || !Number.isInteger(index) || index < 1) {
867
- throw new Error(
993
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
994
+ "split_part(...) with a non-literal or non-positive index",
868
995
  "First-party BigQuery query only supports split_part with a positive integer index",
869
996
  );
870
997
  }
871
998
  return `SPLIT(${args[0]}, ${args[1]})[SAFE_OFFSET(${index - 1})]`;
872
999
  });
873
1000
  }
874
- translated = rewriteOutsideSqlLiterals(translated, (code) => {
1001
+ translated = rewriteWithMaskedSqlLiterals(translated, (code) => {
875
1002
  let rewritten = replacePostgresCastsInCode(code);
876
1003
  rewritten = rewritten
877
1004
  .replace(/\bnow\s*\(\s*\)/gi, "CURRENT_TIMESTAMP()")
@@ -893,10 +1020,16 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
893
1020
  [/\bINTERVAL\s*'/i, "PostgreSQL interval literals"],
894
1021
  [/\bAT\s+TIME\s+ZONE\b/i, "AT TIME ZONE"],
895
1022
  [/\bFILTER\s*\(\s*WHERE\b/i, "FILTER (WHERE ...)"],
1023
+ [/\bDISTINCT\s+ON\b/i, "SELECT DISTINCT ON"],
1024
+ // Anything the JSON translation above could not consume. BigQuery has no
1025
+ // such operators, so leaving it through buys a provider 400 instead of a
1026
+ // rendered explanation.
1027
+ [/->>|->|#>>|@>/, "PostgreSQL JSON operators"],
896
1028
  ];
897
1029
  const incompatible = unsupported.find(([pattern]) => pattern.test(code));
898
1030
  if (incompatible) {
899
- throw new Error(
1031
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
1032
+ incompatible[1],
900
1033
  `First-party analytics query uses unsupported PostgreSQL syntax (${incompatible[1]}) after BigQuery translation`,
901
1034
  );
902
1035
  }
@@ -977,6 +1110,16 @@ function addPartitionPrunedEventDeduplication(
977
1110
  return result;
978
1111
  }
979
1112
 
1113
+ /**
1114
+ * Throws `FirstPartyAnalyticsUnsupportedSqlError` when this SQL has no BigQuery
1115
+ * translation. Save-time validation runs on the panel's own (unscoped) SQL,
1116
+ * which is a subset of what the read path translates, so a pass here cannot
1117
+ * pass a construct through that the read path would then reject.
1118
+ */
1119
+ export function assertFirstPartyAnalyticsBigQuerySql(sql: string): void {
1120
+ translateFirstPartyAnalyticsBigQuerySql(sql);
1121
+ }
1122
+
980
1123
  export function renderFirstPartyAnalyticsBigQuerySql(
981
1124
  scopedSql: string,
982
1125
  args: Array<string | null>,
@@ -10,6 +10,8 @@ import {
10
10
  type DerivedExceptionFields,
11
11
  } from "./error-capture.js";
12
12
  import {
13
+ assertFirstPartyAnalyticsBigQuerySql,
14
+ type FirstPartyAnalyticsSink,
13
15
  getFirstPartyAnalyticsBackend,
14
16
  getFirstPartyAnalyticsTable,
15
17
  insertFirstPartyAnalyticsRows,
@@ -1088,6 +1090,44 @@ function inferSchema(rows: Record<string, unknown>[]): {
1088
1090
  }));
1089
1091
  }
1090
1092
 
1093
+ /**
1094
+ * Which store this query actually runs against under a given sink. Save-time
1095
+ * validation and the read path both route through here: a panel validated for
1096
+ * one store and executed against the other is the whole bug class.
1097
+ */
1098
+ function firstPartyAnalyticsQueryTarget(
1099
+ sql: string,
1100
+ sink: FirstPartyAnalyticsSink,
1101
+ ): "sql-store" | "bigquery" {
1102
+ if (sink !== "bigquery") return "sql-store";
1103
+ const usesSessionRecordings = /\bsession_recordings\b/i.test(sql);
1104
+ const usesEventTables =
1105
+ /\banalytics_events\b|\banalytics_event_daily_rollups\b|\banalytics_user_days\b/i.test(
1106
+ sql,
1107
+ );
1108
+ if (usesSessionRecordings && usesEventTables) {
1109
+ throw new Error(
1110
+ "Cross-backend joins are not supported; query first-party event tables in BigQuery and session_recordings in the Analytics SQL store separately.",
1111
+ );
1112
+ }
1113
+ return usesSessionRecordings ? "sql-store" : "bigquery";
1114
+ }
1115
+
1116
+ /**
1117
+ * Save-time counterpart to `queryFirstPartyAnalytics`. `sink` is a mutable
1118
+ * per-scope setting, so generic PostgreSQL validation alone accepts panels the
1119
+ * live backend cannot execute.
1120
+ */
1121
+ export async function validateFirstPartyAnalyticsSqlForScope(
1122
+ sql: string,
1123
+ scope: AnalyticsScope,
1124
+ ): Promise<void> {
1125
+ validateFirstPartyAnalyticsSql(sql);
1126
+ const backend = await getFirstPartyAnalyticsBackend(scope);
1127
+ if (firstPartyAnalyticsQueryTarget(sql, backend.sink) !== "bigquery") return;
1128
+ assertFirstPartyAnalyticsBigQuerySql(sql);
1129
+ }
1130
+
1091
1131
  export async function queryFirstPartyAnalytics(
1092
1132
  sql: string,
1093
1133
  scope: AnalyticsScope,
@@ -1095,22 +1135,10 @@ export async function queryFirstPartyAnalytics(
1095
1135
  ): Promise<AnalyticsQueryResult> {
1096
1136
  validateFirstPartyAnalyticsSql(sql);
1097
1137
  const backend = await getFirstPartyAnalyticsBackend(scope);
1098
- if (backend.sink === "bigquery") {
1099
- const usesSessionRecordings = /\bsession_recordings\b/i.test(sql);
1100
- const usesEventTables =
1101
- /\banalytics_events\b|\banalytics_event_daily_rollups\b|\banalytics_user_days\b/i.test(
1102
- sql,
1103
- );
1104
- if (usesSessionRecordings && usesEventTables) {
1105
- throw new Error(
1106
- "Cross-backend joins are not supported; query first-party event tables in BigQuery and session_recordings in the Analytics SQL store separately.",
1107
- );
1108
- }
1109
- if (!usesSessionRecordings) {
1110
- const table = await getFirstPartyAnalyticsTable(backend.table);
1111
- const scoped = scopedAnalyticsSql(sql, scope);
1112
- return queryFirstPartyAnalyticsInBigQuery(scoped.sql, scoped.args, table);
1113
- }
1138
+ if (firstPartyAnalyticsQueryTarget(sql, backend.sink) === "bigquery") {
1139
+ const table = await getFirstPartyAnalyticsTable(backend.table);
1140
+ const scoped = scopedAnalyticsSql(sql, scope);
1141
+ return queryFirstPartyAnalyticsInBigQuery(scoped.sql, scoped.args, table);
1114
1142
  }
1115
1143
  const scoped = scopedAnalyticsSql(sql, scope);
1116
1144
  const wrappedSql = `SELECT * FROM (${scoped.sql}) AS first_party_analytics_query LIMIT ${MAX_QUERY_ROWS}`;
@@ -21,7 +21,7 @@ import { isInBackgroundFunctionRuntime } from "../durable-background.js";
21
21
  import { BUILDER_MODEL_CONFIG } from "../model-config.js";
22
22
  import { getBuilderGatewayRequestHeaders } from "./builder-gateway-headers.js";
23
23
  import { gatewayVisitorFacingError, LLM_MISSING_CREDENTIALS_ERROR_CODE, LLM_MISSING_CREDENTIALS_MESSAGE, } from "./credential-errors.js";
24
- import { classifyTerminalErrorCode, describeErrorWithCauses, isContextOverflowCode, isContextOverflowMessage, isProviderConnectionErrorMessage, } from "./error-detail.js";
24
+ import { classifyTerminalErrorCode, describeErrorWithCauses, isBuilderGatewayInternalErrorMessage, isContextOverflowCode, isContextOverflowMessage, isProviderConnectionErrorMessage, } from "./error-detail.js";
25
25
  import { FIRST_STREAM_EVENT_TIMEOUT_MS } from "./first-event-timeout.js";
26
26
  import { resolveMaxOutputTokensForEngine } from "./output-tokens.js";
27
27
  import { splitSystemPromptForCache, stablePrefixCacheControl, } from "./prompt-cache.js";
@@ -340,6 +340,11 @@ function isTransientGatewayFailure(rawMessage, status) {
340
340
  if (status !== undefined && RETRYABLE_GATEWAY_STATUSES.has(status)) {
341
341
  return true;
342
342
  }
343
+ // The gateway's unhandled-500 envelope, which reaches the in-stream error
344
+ // frame with no status at all. Without this it read as terminal there while
345
+ // the identical body read as retryable when it arrived as an HTTP 500.
346
+ if (isBuilderGatewayInternalErrorMessage(rawMessage))
347
+ return true;
343
348
  return TRANSIENT_UPSTREAM_PATTERN.test(rawMessage);
344
349
  }
345
350
  /**
@@ -650,6 +655,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
650
655
  const explicitErrMsg = event.error || event.message || event.detail;
651
656
  const errMsg = explicitErrMsg ??
652
657
  `Gateway error (no detail; raw event: ${JSON.stringify(event)})`;
658
+ const gatewayRequestId = typeof event.requestId === "string" ? event.requestId : undefined;
653
659
  const gatewayErrCode = event.errorCode ?? event.code;
654
660
  // The gateway already authenticated this request before streaming,
655
661
  // so a bare "Unauthorized" here means the account cannot use this
@@ -679,7 +685,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
679
685
  // would otherwise do it downstream on the visitor line and
680
686
  // record `unknown` on the credits lane alone.
681
687
  classifyTerminalErrorCode(String(errMsg))));
682
- console.error(`[builder-engine] stop reason=error model=${model} code=${errCode ?? "(none)"} error=${errMsg}`);
688
+ console.error(`[builder-engine] stop reason=error model=${model} code=${errCode ?? "(none)"} requestId=${gatewayRequestId ?? "(none)"} error=${errMsg}`);
683
689
  if (isCredentialAuthError) {
684
690
  await recordBuilderGatewayAuthFailure({
685
691
  code: typeof gatewayErrCode === "string" ? gatewayErrCode : errCode,
@@ -694,9 +700,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
694
700
  // it's thrown, but without these tags.
695
701
  if (!explicitErrMsg) {
696
702
  captureBuilderGatewayNoDetailError({
697
- requestId: typeof event.requestId === "string"
698
- ? event.requestId
699
- : undefined,
703
+ requestId: gatewayRequestId,
700
704
  model,
701
705
  gatewayUrl: captureContext.gatewayUrl,
702
706
  rawEvent: event,
@@ -711,6 +715,10 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
711
715
  ...(isTransientGatewayFailure(String(errMsg))
712
716
  ? { providerRetryable: true }
713
717
  : {}),
718
+ // requestId rides the stop event whether or not the gateway sent a
719
+ // message: a message like "...ERROR ID: <hex>" is as opaque as no
720
+ // message at all, and this is the only key that reaches upstream.
721
+ ...(gatewayRequestId ? { requestId: gatewayRequestId } : {}),
714
722
  });
715
723
  }
716
724
  else if (reason === "end_turn" ||
@@ -35,6 +35,23 @@ export declare function isProviderConnectionError(err: unknown): boolean;
35
35
  * for every other engine, which still delivers its own message intact.
36
36
  */
37
37
  export declare function isContextOverflowMessage(message: string): boolean;
38
+ /**
39
+ * The Builder gateway's own 500 envelope, which is the whole message: an
40
+ * apology sentence plus a correlation id, e.g. "Sorry, we ran into an issue
41
+ * processing your request. ERROR ID: 0f3c...". The apology prose varies; the
42
+ * correlation id does not, so that is what the predicate below anchors on.
43
+ */
44
+ export declare const BUILDER_GATEWAY_INTERNAL_ERROR_CODE = "builder_gateway_internal_error";
45
+ /**
46
+ * The gateway attaches that envelope to an unhandled 500, and it arrives two
47
+ * ways: as an HTTP body, which is already retried because 500 is in the
48
+ * engine's retryable status set, and as an in-stream error frame after the
49
+ * gateway has already answered 200, where there is no status to read and the
50
+ * prose carries no keyword any other predicate here matches. Naming it is what
51
+ * makes the second path behave like the first instead of dying uncoded on the
52
+ * first attempt.
53
+ */
54
+ export declare function isBuilderGatewayInternalErrorMessage(message: string): boolean;
38
55
  /** The overflow codes a provider or gateway may report instead of prose. */
39
56
  export declare function isContextOverflowCode(code: string | undefined): boolean;
40
57
  /** Classification fields an AI SDK provider failure carries. */
@@ -83,6 +83,28 @@ export function isContextOverflowMessage(message) {
83
83
  msg.includes("input token count exceeds") ||
84
84
  msg.includes("request too large"));
85
85
  }
86
+ /**
87
+ * The Builder gateway's own 500 envelope, which is the whole message: an
88
+ * apology sentence plus a correlation id, e.g. "Sorry, we ran into an issue
89
+ * processing your request. ERROR ID: 0f3c...". The apology prose varies; the
90
+ * correlation id does not, so that is what the predicate below anchors on.
91
+ */
92
+ export const BUILDER_GATEWAY_INTERNAL_ERROR_CODE = "builder_gateway_internal_error";
93
+ const BUILDER_GATEWAY_ERROR_ID_PATTERN = /\berror id:\s*([0-9a-f]+)\b/i;
94
+ const BUILDER_GATEWAY_ERROR_ID_MIN_CHARS = 8;
95
+ /**
96
+ * The gateway attaches that envelope to an unhandled 500, and it arrives two
97
+ * ways: as an HTTP body, which is already retried because 500 is in the
98
+ * engine's retryable status set, and as an in-stream error frame after the
99
+ * gateway has already answered 200, where there is no status to read and the
100
+ * prose carries no keyword any other predicate here matches. Naming it is what
101
+ * makes the second path behave like the first instead of dying uncoded on the
102
+ * first attempt.
103
+ */
104
+ export function isBuilderGatewayInternalErrorMessage(message) {
105
+ const match = BUILDER_GATEWAY_ERROR_ID_PATTERN.exec(message);
106
+ return (match !== null && match[1].length >= BUILDER_GATEWAY_ERROR_ID_MIN_CHARS);
107
+ }
86
108
  /** The overflow codes a provider or gateway may report instead of prose. */
87
109
  export function isContextOverflowCode(code) {
88
110
  const normalized = (code ?? "").toLowerCase();
@@ -203,5 +225,10 @@ export function classifyTerminalErrorCode(message) {
203
225
  if (/(?:err_)?ssl|tlsv?\d|tls handshake|ssl routines|econnreset|econnrefused|und_err_socket|socket hang up/i.test(message)) {
204
226
  return "provider_network_error";
205
227
  }
228
+ // Last, so a gateway 500 whose body happens to quote a more specific upstream
229
+ // failure keeps that classification instead of collapsing to this one.
230
+ if (isBuilderGatewayInternalErrorMessage(message)) {
231
+ return BUILDER_GATEWAY_INTERNAL_ERROR_CODE;
232
+ }
206
233
  return undefined;
207
234
  }
@@ -26,6 +26,8 @@ export declare class EngineError extends Error {
26
26
  readonly statusCode?: number;
27
27
  /** Whether the provider explicitly marked this error as retryable. */
28
28
  readonly providerRetryable?: boolean;
29
+ /** Upstream request id, when the provider/gateway supplied one. */
30
+ readonly requestId?: string;
29
31
  /**
30
32
  * Whether the request exceeded the model's context window. Set by engines that
31
33
  * classified the provider's own reply, because the delivered message may not
@@ -39,6 +41,7 @@ export declare class EngineError extends Error {
39
41
  upgradeUrl?: string;
40
42
  statusCode?: number;
41
43
  providerRetryable?: boolean;
44
+ requestId?: string;
42
45
  contextOverflow?: boolean;
43
46
  });
44
47
  }
@@ -196,6 +199,13 @@ export type EngineEvent = {
196
199
  * should retry even if status code / message patterns don't match.
197
200
  */
198
201
  providerRetryable?: boolean;
202
+ /**
203
+ * Upstream request id, when the provider/gateway supplies one. This is
204
+ * the only key that ties a user-facing error back to the upstream log,
205
+ * so it must survive to the capture even when the error also carries a
206
+ * message — an opaque message is not a diagnostic.
207
+ */
208
+ requestId?: string;
199
209
  /**
200
210
  * The request exceeded the model's context window. Carried structurally
201
211
  * for the same reason as `providerRetryable`: `error` is visitor copy on a
@@ -25,6 +25,8 @@ export class EngineError extends Error {
25
25
  statusCode;
26
26
  /** Whether the provider explicitly marked this error as retryable. */
27
27
  providerRetryable;
28
+ /** Upstream request id, when the provider/gateway supplied one. */
29
+ requestId;
28
30
  /**
29
31
  * Whether the request exceeded the model's context window. Set by engines that
30
32
  * classified the provider's own reply, because the delivered message may not
@@ -40,6 +42,7 @@ export class EngineError extends Error {
40
42
  this.upgradeUrl = opts?.upgradeUrl;
41
43
  this.statusCode = opts?.statusCode;
42
44
  this.providerRetryable = opts?.providerRetryable;
45
+ this.requestId = opts?.requestId;
43
46
  this.contextOverflow = opts?.contextOverflow;
44
47
  }
45
48
  }
@@ -28,7 +28,7 @@ import { AGENT_CHAT_BACKGROUND_RUN_FIELD, AGENT_CHAT_PROCESS_RUN_PATH, backgroun
28
28
  import { applyContextXrayTransformForIteration } from "./engine/context-directives-transform.js";
29
29
  import { attemptContinuationDispatch } from "./engine/continuation-dispatch-retry.js";
30
30
  import { formatLlmCredentialErrorMessage, LLM_MISSING_CREDENTIALS_ERROR_CODE, LLM_MISSING_CREDENTIALS_MESSAGE, userFacingLlmCredentialError, } from "./engine/credential-errors.js";
31
- import { isContextOverflowCode, isContextOverflowMessage, isProviderConnectionErrorMessage, } from "./engine/error-detail.js";
31
+ import { BUILDER_GATEWAY_INTERNAL_ERROR_CODE, isContextOverflowCode, isContextOverflowMessage, isProviderConnectionErrorMessage, } from "./engine/error-detail.js";
32
32
  import { resolveEngine, explicitEngineName, registerBuiltinEngines, getStoredModelForEngine, normalizeModelForEngine, isResolvedEngineUsableForRequest, } from "./engine/index.js";
33
33
  import { resolveEmptyResponseRetryMaxOutputTokens, resolveMainChatMaxOutputTokens, resolveMaxOutputTokensForEngine, } from "./engine/output-tokens.js";
34
34
  import { PROVIDER_TO_ENV } from "./engine/provider-env-vars.js";
@@ -880,6 +880,9 @@ export function isRetryableError(err) {
880
880
  return true;
881
881
  }
882
882
  return (code === "builder_gateway_error" ||
883
+ // The gateway's unhandled-500 envelope arriving in-stream, where there is no
884
+ // status to read. Same failure as `http_500` below, so same verdict.
885
+ code === BUILDER_GATEWAY_INTERNAL_ERROR_CODE ||
883
886
  code === "builder_gateway_network_error" ||
884
887
  code === "provider_network_error" ||
885
888
  code === "http_429" ||
@@ -3749,6 +3752,7 @@ export async function runAgentLoop(opts) {
3749
3752
  statusCode: event.statusCode,
3750
3753
  providerRetryable: event.providerRetryable,
3751
3754
  contextOverflow: event.contextOverflow,
3755
+ requestId: event.requestId,
3752
3756
  });
3753
3757
  }
3754
3758
  }
@@ -5177,6 +5181,8 @@ export function isRecoverableContinuationError(event) {
5177
5181
  // the server read the sentence instead, which a Builder-credits deployment
5178
5182
  // replaces with one visitor line.
5179
5183
  code === "http_500" ||
5184
+ // The same 500, delivered in-stream with no status attached.
5185
+ code === BUILDER_GATEWAY_INTERNAL_ERROR_CODE ||
5180
5186
  code === "http_502" ||
5181
5187
  code === "http_503" ||
5182
5188
  code === "http_504" ||
@@ -1060,6 +1060,10 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
1060
1060
  let pendingTerminalEvent = null;
1061
1061
  const captureRunError = (error, phase) => {
1062
1062
  const errorCode = getRunErrorCode(error);
1063
+ // A gateway error often arrives as one opaque user-facing sentence, so the
1064
+ // structured fields EngineError already carries are the whole diagnostic.
1065
+ // Dropping them here left operators with an error id and nothing to join on.
1066
+ const engineError = error instanceof EngineError ? error : null;
1063
1067
  captureError(error, {
1064
1068
  route: "/_agent-native/agent-chat",
1065
1069
  aiTraceId: runId,
@@ -1070,6 +1074,10 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
1070
1074
  softTimedOut: softTimedOut ? "true" : "false",
1071
1075
  abortReason: run.abortReason,
1072
1076
  errorCode,
1077
+ gatewayRequestId: engineError?.requestId,
1078
+ statusCode: engineError?.statusCode != null
1079
+ ? String(engineError.statusCode)
1080
+ : undefined,
1073
1081
  },
1074
1082
  extra: {
1075
1083
  runId,
@@ -1,5 +1,6 @@
1
1
  import { formatChatErrorText, normalizeChatError, } from "../client/error-format.js";
2
2
  import { isCredentialGapCodeAgentEvent, normalizeCodeAgentTranscript, } from "../code-agents/transcript-normalizer.js";
3
+ import { BUILDER_GATEWAY_INTERNAL_ERROR_CODE } from "./engine/error-detail.js";
3
4
  const INTERRUPTED_TOOL_RESULT = "Interrupted before this tool returned a result.";
4
5
  export const ASSISTANT_RUN_DURATION_METADATA_KEY = "agentNativeRunDurationMs";
5
6
  const MAX_STORED_ATTACHMENT_CHARS = 60_000;
@@ -20,6 +21,10 @@ function isInternalContinuationError(event) {
20
21
  code === "http_408" ||
21
22
  code === "http_429" ||
22
23
  code === "http_500" ||
24
+ // The gateway's unhandled-500 envelope arriving in-stream. Without this the
25
+ // turn stored Builder's internal correlation id as the assistant's visible
26
+ // answer instead of a continuation.
27
+ code === BUILDER_GATEWAY_INTERNAL_ERROR_CODE ||
23
28
  code === "http_502" ||
24
29
  code === "http_503" ||
25
30
  code === "http_504" ||