@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.
- package/corpus/templates/analytics/actions/compose-dashboard.ts +5 -2
- package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +11 -5
- package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +89 -2
- package/corpus/templates/analytics/actions/update-dashboard.ts +14 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +89 -41
- package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +8 -3
- package/corpus/templates/analytics/server/lib/error-capture.ts +6 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +187 -44
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +44 -16
- package/dist/agent/engine/builder-engine.js +13 -5
- package/dist/agent/engine/error-detail.d.ts +17 -0
- package/dist/agent/engine/error-detail.js +27 -0
- package/dist/agent/engine/types.d.ts +10 -0
- package/dist/agent/engine/types.js +3 -0
- package/dist/agent/production-agent.js +7 -1
- package/dist/agent/run-manager.js +8 -0
- package/dist/agent/thread-data-builder.js +5 -0
- package/dist/client/error-format.js +15 -0
- package/dist/client/sse-event-processor.js +4 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/localization/core-messages/ar-SA.js +1 -0
- package/dist/localization/core-messages/de-DE.js +1 -0
- package/dist/localization/core-messages/en-US.d.ts +1 -0
- package/dist/localization/core-messages/en-US.js +1 -0
- package/dist/localization/core-messages/es-ES.js +1 -0
- package/dist/localization/core-messages/fr-FR.js +1 -0
- package/dist/localization/core-messages/hi-IN.js +1 -0
- package/dist/localization/core-messages/ja-JP.js +1 -0
- package/dist/localization/core-messages/ko-KR.js +1 -0
- package/dist/localization/core-messages/pt-BR.js +1 -0
- package/dist/localization/core-messages/zh-CN.js +1 -0
- package/dist/localization/core-messages/zh-TW.js +1 -0
- package/dist/localization/core-messages.d.ts +1 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/release-migrations.js +4 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/workspace-connections/migrations.d.ts +18 -0
- package/dist/workspace-connections/migrations.js +153 -0
- 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
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
1100
|
-
const
|
|
1101
|
-
|
|
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:
|
|
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" ||
|