@agent-native/core 0.84.49 → 0.84.52
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/README.md +1 -1
- package/corpus/core/CHANGELOG.md +20 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/run-store.ts +33 -6
- package/corpus/core/src/client/AssistantChat.tsx +84 -23
- package/corpus/core/src/client/agent-chat-adapter.ts +59 -9
- package/corpus/core/src/client/blocks/library/DiffBlock.tsx +7 -5
- package/corpus/core/src/client/session-replay.ts +170 -47
- package/corpus/core/src/client/sse-event-processor.ts +106 -29
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +5 -0
- package/corpus/templates/analytics/changelog/2026-07-02-agent-chat-can-keep-working-through-longer-data-queries-inst.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-session-replay-playback-recovers-from-failed-snapshot-uploads.md +6 -0
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +9 -5
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +3 -0
- package/corpus/templates/plan/changelog/2026-07-02-agent-chat-can-keep-working-through-longer-visual-plan-updat.md +6 -0
- package/corpus/templates/plan/netlify.toml +3 -0
- package/corpus/templates/plan/server/plugins/agent-chat.ts +4 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +30 -6
- package/dist/agent/run-store.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +64 -13
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +50 -9
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/DiffBlock.js +6 -5
- package/dist/client/blocks/library/DiffBlock.js.map +1 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +134 -39
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +29 -3
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +72 -18
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +7 -7
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ type ReplayEvent = Record<string, unknown>;
|
|
|
8
8
|
type QueuedReplayEvent = {
|
|
9
9
|
json: string;
|
|
10
10
|
timestampMs: number;
|
|
11
|
+
type: number | null;
|
|
11
12
|
};
|
|
12
13
|
type ReplayStopFn = () => void;
|
|
13
14
|
export type SessionReplayUrlMatcher =
|
|
@@ -48,6 +49,7 @@ interface SessionReplayState {
|
|
|
48
49
|
/** Pre-serialized + scrubbed event JSON strings, ready to splice at flush. */
|
|
49
50
|
queue: QueuedReplayEvent[];
|
|
50
51
|
queuedBytes: number;
|
|
52
|
+
retryBatches: QueuedReplayEvent[][];
|
|
51
53
|
flushTimer: number | null;
|
|
52
54
|
maxDurationTimer: number | null;
|
|
53
55
|
flushing: boolean;
|
|
@@ -195,6 +197,7 @@ const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000;
|
|
|
195
197
|
const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
|
|
196
198
|
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
|
|
197
199
|
const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
|
|
200
|
+
const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
|
|
198
201
|
const URL_LIKE_KEYS = new Set([
|
|
199
202
|
"url",
|
|
200
203
|
"uri",
|
|
@@ -219,6 +222,7 @@ function getState(): SessionReplayState {
|
|
|
219
222
|
sequence: 0,
|
|
220
223
|
queue: [],
|
|
221
224
|
queuedBytes: 0,
|
|
225
|
+
retryBatches: [],
|
|
222
226
|
flushTimer: null,
|
|
223
227
|
maxDurationTimer: null,
|
|
224
228
|
flushing: false,
|
|
@@ -613,11 +617,10 @@ function enqueueReplayEvent(
|
|
|
613
617
|
state.queue.push({
|
|
614
618
|
json: serialized,
|
|
615
619
|
timestampMs: replayEventTimestampMs(event),
|
|
620
|
+
type: typeof event.type === "number" ? event.type : null,
|
|
616
621
|
});
|
|
617
622
|
state.queuedBytes += estimatedBytes;
|
|
618
|
-
|
|
619
|
-
void flushSessionReplay("max-events");
|
|
620
|
-
}
|
|
623
|
+
flushQueuedReplayIfNeeded(state);
|
|
621
624
|
}
|
|
622
625
|
|
|
623
626
|
function replayExtraProperties(
|
|
@@ -668,11 +671,18 @@ function replayPropertiesForUpload(
|
|
|
668
671
|
return properties;
|
|
669
672
|
}
|
|
670
673
|
|
|
674
|
+
interface ReplayUploadPayload {
|
|
675
|
+
body: string;
|
|
676
|
+
replayId: string;
|
|
677
|
+
sessionId: string;
|
|
678
|
+
sequence: number;
|
|
679
|
+
}
|
|
680
|
+
|
|
671
681
|
function buildReplayBody(
|
|
672
682
|
state: SessionReplayState,
|
|
673
683
|
reason: string,
|
|
674
684
|
events: QueuedReplayEvent[],
|
|
675
|
-
):
|
|
685
|
+
): ReplayUploadPayload | null {
|
|
676
686
|
const options = state.options;
|
|
677
687
|
if (!options || !state.replayId) return null;
|
|
678
688
|
const sessionId = getAnalyticsSessionId();
|
|
@@ -713,19 +723,17 @@ function buildReplayBody(
|
|
|
713
723
|
timestamp: new Date().toISOString(),
|
|
714
724
|
properties,
|
|
715
725
|
};
|
|
716
|
-
state.sequence += 1;
|
|
717
|
-
persistReplaySequence(
|
|
718
|
-
sessionId,
|
|
719
|
-
state.replayId,
|
|
720
|
-
state.startedAtMs,
|
|
721
|
-
state.sequence,
|
|
722
|
-
);
|
|
723
726
|
// Events are already serialized+scrubbed JSON strings; splice them into the
|
|
724
727
|
// envelope without re-serializing the (potentially large) events array.
|
|
725
728
|
const envelopeJson = JSON.stringify(envelope);
|
|
726
|
-
return
|
|
727
|
-
.
|
|
728
|
-
|
|
729
|
+
return {
|
|
730
|
+
body: `${envelopeJson.slice(0, -1)},"events":[${events
|
|
731
|
+
.map((event) => event.json)
|
|
732
|
+
.join(",")}]}`,
|
|
733
|
+
replayId: state.replayId,
|
|
734
|
+
sessionId,
|
|
735
|
+
sequence: state.sequence,
|
|
736
|
+
};
|
|
729
737
|
}
|
|
730
738
|
|
|
731
739
|
interface ReplayUploadBody {
|
|
@@ -745,13 +753,6 @@ function isCrossOriginReplayEndpoint(endpoint: string): boolean {
|
|
|
745
753
|
}
|
|
746
754
|
}
|
|
747
755
|
|
|
748
|
-
function replayUploadByteLength(body: string): number {
|
|
749
|
-
if (typeof TextEncoder !== "undefined") {
|
|
750
|
-
return new TextEncoder().encode(body).byteLength;
|
|
751
|
-
}
|
|
752
|
-
return body.length;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
756
|
async function gzipReplayBody(body: string): Promise<Blob | null> {
|
|
756
757
|
if (
|
|
757
758
|
typeof CompressionStream === "undefined" ||
|
|
@@ -792,39 +793,68 @@ async function buildReplayUploadBody(body: string): Promise<ReplayUploadBody> {
|
|
|
792
793
|
};
|
|
793
794
|
}
|
|
794
795
|
|
|
796
|
+
function replayUploadBodyBytes(body: BodyInit): number {
|
|
797
|
+
if (typeof body === "string") {
|
|
798
|
+
if (typeof TextEncoder !== "undefined") {
|
|
799
|
+
return new TextEncoder().encode(body).byteLength;
|
|
800
|
+
}
|
|
801
|
+
return body.length;
|
|
802
|
+
}
|
|
803
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
804
|
+
return body.size;
|
|
805
|
+
}
|
|
806
|
+
if (body instanceof ArrayBuffer) {
|
|
807
|
+
return body.byteLength;
|
|
808
|
+
}
|
|
809
|
+
if (ArrayBuffer.isView(body)) {
|
|
810
|
+
return body.byteLength;
|
|
811
|
+
}
|
|
812
|
+
return MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES + 1;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function canUseReplayKeepalive(body: BodyInit): boolean {
|
|
816
|
+
return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
|
|
817
|
+
}
|
|
818
|
+
|
|
795
819
|
async function sendReplayUpload(
|
|
796
820
|
options: NormalizedSessionReplayOptions,
|
|
797
821
|
body: string,
|
|
822
|
+
callbacks: { beforeKeepaliveUpload?: () => void } = {},
|
|
798
823
|
): Promise<void> {
|
|
799
824
|
if (isCrossOriginReplayEndpoint(options.endpoint)) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
await fetch(options.endpoint, {
|
|
825
|
+
const canUseKeepalive = canUseReplayKeepalive(body);
|
|
826
|
+
if (canUseKeepalive) callbacks.beforeKeepaliveUpload?.();
|
|
827
|
+
const response = await fetch(options.endpoint, {
|
|
805
828
|
method: "POST",
|
|
806
829
|
body,
|
|
807
|
-
keepalive:
|
|
808
|
-
replayUploadByteLength(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES,
|
|
830
|
+
keepalive: canUseKeepalive,
|
|
809
831
|
headers: { "Content-Type": "text/plain;charset=UTF-8" },
|
|
810
|
-
})
|
|
832
|
+
});
|
|
833
|
+
if (!response.ok) {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`Session replay upload failed with HTTP ${response.status}`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
811
838
|
return;
|
|
812
839
|
}
|
|
813
840
|
|
|
814
841
|
const upload = await buildReplayUploadBody(body);
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
819
|
-
await fetch(options.endpoint, {
|
|
842
|
+
const canUseKeepalive = canUseReplayKeepalive(upload.body);
|
|
843
|
+
if (canUseKeepalive) callbacks.beforeKeepaliveUpload?.();
|
|
844
|
+
const response = await fetch(options.endpoint, {
|
|
820
845
|
method: "POST",
|
|
821
846
|
body: upload.body,
|
|
822
|
-
keepalive:
|
|
847
|
+
keepalive: canUseKeepalive,
|
|
823
848
|
headers: {
|
|
824
849
|
...upload.headers,
|
|
825
850
|
"X-Agent-Native-Analytics-Key": options.publicKey,
|
|
826
851
|
},
|
|
827
|
-
})
|
|
852
|
+
});
|
|
853
|
+
if (!response.ok) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
`Session replay upload failed with HTTP ${response.status}`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
828
858
|
}
|
|
829
859
|
|
|
830
860
|
function isFinalFlushReason(reason: string): boolean {
|
|
@@ -838,26 +868,118 @@ function isFinalFlushReason(reason: string): boolean {
|
|
|
838
868
|
].includes(reason);
|
|
839
869
|
}
|
|
840
870
|
|
|
871
|
+
function shouldReserveSequenceBeforeKeepalive(reason: string): boolean {
|
|
872
|
+
return (
|
|
873
|
+
reason === "pagehide" ||
|
|
874
|
+
reason === "beforeunload" ||
|
|
875
|
+
reason === "visibility-hidden"
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function hasFullSnapshot(events: QueuedReplayEvent[]): boolean {
|
|
880
|
+
return events.some((event) => event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function hasPendingReplayBatch(state: SessionReplayState): boolean {
|
|
884
|
+
return state.retryBatches.length > 0 || state.queue.length > 0;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function shouldFlushQueuedReplay(state: SessionReplayState): boolean {
|
|
888
|
+
if (!state.options || state.queue.length === 0) return false;
|
|
889
|
+
return (
|
|
890
|
+
hasFullSnapshot(state.queue) ||
|
|
891
|
+
state.queue.length >= state.options.maxEventsPerBatch ||
|
|
892
|
+
state.queuedBytes >= state.options.maxBatchBytes
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function flushQueuedReplayIfNeeded(state: SessionReplayState): void {
|
|
897
|
+
const options = state.options;
|
|
898
|
+
if (!options) return;
|
|
899
|
+
if (state.retryBatches.length > 0) return;
|
|
900
|
+
if (!shouldFlushQueuedReplay(state)) return;
|
|
901
|
+
const reason = hasFullSnapshot(state.queue)
|
|
902
|
+
? "full-snapshot"
|
|
903
|
+
: state.queue.length >= options.maxEventsPerBatch
|
|
904
|
+
? "max-events"
|
|
905
|
+
: "max-bytes";
|
|
906
|
+
void flushSessionReplay(reason);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function queuedReplayBytes(events: QueuedReplayEvent[]): number {
|
|
910
|
+
return events.reduce((total, event) => total + event.json.length, 0);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function restoreReplayEvents(
|
|
914
|
+
state: SessionReplayState,
|
|
915
|
+
events: QueuedReplayEvent[],
|
|
916
|
+
): void {
|
|
917
|
+
state.retryBatches.unshift(events);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function advanceReplaySequence(
|
|
921
|
+
state: SessionReplayState,
|
|
922
|
+
payload: ReplayUploadPayload,
|
|
923
|
+
): void {
|
|
924
|
+
if (state.replayId !== payload.replayId) return;
|
|
925
|
+
state.sequence = Math.max(state.sequence, payload.sequence + 1);
|
|
926
|
+
persistReplaySequence(
|
|
927
|
+
payload.sessionId,
|
|
928
|
+
payload.replayId,
|
|
929
|
+
state.startedAtMs,
|
|
930
|
+
state.sequence,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function rollbackReplaySequenceReservation(
|
|
935
|
+
state: SessionReplayState,
|
|
936
|
+
payload: ReplayUploadPayload,
|
|
937
|
+
): void {
|
|
938
|
+
if (state.replayId !== payload.replayId) return;
|
|
939
|
+
if (state.sequence !== payload.sequence + 1) return;
|
|
940
|
+
state.sequence = payload.sequence;
|
|
941
|
+
persistReplaySequence(
|
|
942
|
+
payload.sessionId,
|
|
943
|
+
payload.replayId,
|
|
944
|
+
state.startedAtMs,
|
|
945
|
+
state.sequence,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
841
949
|
export async function flushSessionReplay(reason = "manual"): Promise<void> {
|
|
842
950
|
const state = getState();
|
|
843
|
-
if (!state.options || state
|
|
844
|
-
const events = state.
|
|
845
|
-
state.queuedBytes =
|
|
846
|
-
const
|
|
847
|
-
if (!
|
|
848
|
-
state
|
|
849
|
-
state.queuedBytes += events.reduce(
|
|
850
|
-
(total, event) => total + event.json.length,
|
|
851
|
-
0,
|
|
852
|
-
);
|
|
951
|
+
if (!state.options || !hasPendingReplayBatch(state) || state.flushing) return;
|
|
952
|
+
const events = state.retryBatches.shift() ?? state.queue.splice(0);
|
|
953
|
+
state.queuedBytes = queuedReplayBytes(state.queue);
|
|
954
|
+
const payload = buildReplayBody(state, reason, events);
|
|
955
|
+
if (!payload || !state.options) {
|
|
956
|
+
restoreReplayEvents(state, events);
|
|
853
957
|
return;
|
|
854
958
|
}
|
|
855
959
|
state.flushing = true;
|
|
960
|
+
let uploaded = false;
|
|
961
|
+
let reservedSequence = false;
|
|
856
962
|
try {
|
|
857
|
-
await sendReplayUpload(state.options, body
|
|
963
|
+
await sendReplayUpload(state.options, payload.body, {
|
|
964
|
+
beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
|
|
965
|
+
? () => {
|
|
966
|
+
advanceReplaySequence(state, payload);
|
|
967
|
+
reservedSequence = true;
|
|
968
|
+
}
|
|
969
|
+
: undefined,
|
|
970
|
+
});
|
|
971
|
+
if (!reservedSequence) advanceReplaySequence(state, payload);
|
|
972
|
+
uploaded = true;
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
|
|
975
|
+
restoreReplayEvents(state, events);
|
|
976
|
+
console.warn("[session-replay] upload failed", error);
|
|
858
977
|
} finally {
|
|
859
978
|
state.flushing = false;
|
|
860
979
|
}
|
|
980
|
+
if (uploaded && hasPendingReplayBatch(state)) {
|
|
981
|
+
flushQueuedReplayIfNeeded(state);
|
|
982
|
+
}
|
|
861
983
|
}
|
|
862
984
|
|
|
863
985
|
function installUrlMonitor(state: SessionReplayState): void {
|
|
@@ -1002,6 +1124,7 @@ async function startSessionReplayRecorder(
|
|
|
1002
1124
|
state.sequence = replaySession.sequence;
|
|
1003
1125
|
state.queue = [];
|
|
1004
1126
|
state.queuedBytes = 0;
|
|
1127
|
+
state.retryBatches = [];
|
|
1005
1128
|
state.stopRecorder = null;
|
|
1006
1129
|
state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
|
|
1007
1130
|
? { ...initialProperties }
|
|
@@ -151,11 +151,18 @@ export const SSE_ACTION_PREPARATION_STALL_TIMEOUT_MS = 90_000;
|
|
|
151
151
|
export interface SSEStreamOptions {
|
|
152
152
|
/**
|
|
153
153
|
* Durable background runs have their own server-side liveness budget and
|
|
154
|
-
* heartbeat. While one is active, keepalive-only periods
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* heartbeat. While one is active, generic keepalive-only periods keep the
|
|
155
|
+
* client attached. Tool-input preparation is stricter: real byte progress
|
|
156
|
+
* keeps long payloads alive, but zero-byte/silent preparation still recovers
|
|
157
|
+
* so one stuck action cannot pin the chat forever.
|
|
157
158
|
*/
|
|
158
159
|
durableBackgroundRun?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Optional caller-owned preparation watchdog state. Passing the same object
|
|
162
|
+
* across reconnect reads keeps a stuck action preparation from getting a
|
|
163
|
+
* fresh stall budget every time the browser reattaches to the same run.
|
|
164
|
+
*/
|
|
165
|
+
preparingActionState?: PreparingActionState;
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
type ActivityTrailEntry = AgentActivityTrailEntry;
|
|
@@ -176,8 +183,9 @@ type PreparingActionEntry = {
|
|
|
176
183
|
lastProgressAt?: number;
|
|
177
184
|
};
|
|
178
185
|
|
|
179
|
-
type PreparingActionState = {
|
|
186
|
+
export type PreparingActionState = {
|
|
180
187
|
entries?: Map<string, PreparingActionEntry>;
|
|
188
|
+
toolEntries?: Map<string, PreparingActionEntry>;
|
|
181
189
|
};
|
|
182
190
|
|
|
183
191
|
function formatProgressBytes(bytes: number): string {
|
|
@@ -278,6 +286,24 @@ function findPendingToolCallIndex(
|
|
|
278
286
|
return -1;
|
|
279
287
|
}
|
|
280
288
|
|
|
289
|
+
function findCompletedToolCallIndex(
|
|
290
|
+
content: ContentPart[],
|
|
291
|
+
toolCallId?: string,
|
|
292
|
+
): number {
|
|
293
|
+
if (!toolCallId) return -1;
|
|
294
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
295
|
+
const part = content[i];
|
|
296
|
+
if (
|
|
297
|
+
part.type === "tool-call" &&
|
|
298
|
+
part.toolCallId === toolCallId &&
|
|
299
|
+
part.result !== undefined
|
|
300
|
+
) {
|
|
301
|
+
return i;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return -1;
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
function appendActivityTrail(
|
|
282
308
|
trail: ActivityTrailEntry[],
|
|
283
309
|
next: ActivityTrailEntry,
|
|
@@ -293,6 +319,37 @@ function appendActivityTrail(
|
|
|
293
319
|
}
|
|
294
320
|
}
|
|
295
321
|
|
|
322
|
+
function refreshPreparingToolEntry(state: PreparingActionState, tool: string) {
|
|
323
|
+
const remainingEntries = [...(state.entries?.values() ?? [])].filter(
|
|
324
|
+
(entry) => entry.tool === tool,
|
|
325
|
+
);
|
|
326
|
+
if (remainingEntries.length === 0) {
|
|
327
|
+
state.toolEntries?.delete(tool);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const deadlineBasis = (entry: PreparingActionEntry) =>
|
|
331
|
+
entry.lastProgressAt ?? entry.startedAt ?? Number.POSITIVE_INFINITY;
|
|
332
|
+
const oldestEntry = remainingEntries.reduce((oldest, entry) =>
|
|
333
|
+
deadlineBasis(entry) < deadlineBasis(oldest) ? entry : oldest,
|
|
334
|
+
);
|
|
335
|
+
const lastProgressBytes = remainingEntries.reduce<number | undefined>(
|
|
336
|
+
(max, entry) =>
|
|
337
|
+
entry.lastProgressBytes === undefined
|
|
338
|
+
? max
|
|
339
|
+
: Math.max(max ?? 0, entry.lastProgressBytes),
|
|
340
|
+
undefined,
|
|
341
|
+
);
|
|
342
|
+
const toolEntries =
|
|
343
|
+
state.toolEntries ?? new Map<string, PreparingActionEntry>();
|
|
344
|
+
state.toolEntries = toolEntries;
|
|
345
|
+
toolEntries.set(tool, {
|
|
346
|
+
tool,
|
|
347
|
+
startedAt: oldestEntry.startedAt,
|
|
348
|
+
lastProgressAt: oldestEntry.lastProgressAt,
|
|
349
|
+
lastProgressBytes,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
296
353
|
function updatePreparingActionState(
|
|
297
354
|
state: PreparingActionState,
|
|
298
355
|
ev: SSEEvent,
|
|
@@ -301,7 +358,8 @@ function updatePreparingActionState(
|
|
|
301
358
|
if (ev.type === "activity" && isPreparingActionActivity(ev)) {
|
|
302
359
|
const tool = ev.tool?.trim() || undefined;
|
|
303
360
|
if (!tool) return false;
|
|
304
|
-
const
|
|
361
|
+
const id = ev.id?.trim();
|
|
362
|
+
const key = id || tool;
|
|
305
363
|
const entries = state.entries ?? new Map<string, PreparingActionEntry>();
|
|
306
364
|
state.entries = entries;
|
|
307
365
|
let entry = entries.get(key);
|
|
@@ -314,22 +372,35 @@ function updatePreparingActionState(
|
|
|
314
372
|
};
|
|
315
373
|
entries.set(key, entry);
|
|
316
374
|
}
|
|
375
|
+
const toolEntries =
|
|
376
|
+
state.toolEntries ?? new Map<string, PreparingActionEntry>();
|
|
377
|
+
state.toolEntries = toolEntries;
|
|
378
|
+
let toolEntry = toolEntries.get(tool);
|
|
379
|
+
if (!toolEntry) {
|
|
380
|
+
toolEntry = {
|
|
381
|
+
tool,
|
|
382
|
+
startedAt: now,
|
|
383
|
+
lastProgressAt: undefined,
|
|
384
|
+
lastProgressBytes: undefined,
|
|
385
|
+
};
|
|
386
|
+
toolEntries.set(tool, toolEntry);
|
|
387
|
+
}
|
|
317
388
|
const progressBytes = activityProgressBytes(ev);
|
|
318
389
|
const previousBytes = entry.lastProgressBytes ?? 0;
|
|
390
|
+
let madeProgress = false;
|
|
319
391
|
if (progressBytes !== undefined) {
|
|
320
392
|
entry.lastProgressBytes = Math.max(previousBytes, progressBytes);
|
|
393
|
+
toolEntry.lastProgressBytes = Math.max(
|
|
394
|
+
toolEntry.lastProgressBytes ?? 0,
|
|
395
|
+
progressBytes,
|
|
396
|
+
);
|
|
397
|
+
madeProgress = id ? progressBytes > previousBytes : progressBytes > 0;
|
|
321
398
|
}
|
|
322
|
-
if (
|
|
323
|
-
if (progressBytes !== undefined && progressBytes > 0) {
|
|
324
|
-
entry.lastProgressAt = now;
|
|
325
|
-
return true;
|
|
326
|
-
}
|
|
327
|
-
return false;
|
|
328
|
-
}
|
|
329
|
-
if (progressBytes !== undefined && progressBytes > previousBytes) {
|
|
399
|
+
if (madeProgress) {
|
|
330
400
|
// A byte increase is proof the model is still streaming this action's
|
|
331
401
|
// argument. Repeated zero-byte prep activity is only a heartbeat.
|
|
332
402
|
entry.lastProgressAt = now;
|
|
403
|
+
toolEntry.lastProgressAt = now;
|
|
333
404
|
return true;
|
|
334
405
|
}
|
|
335
406
|
return false;
|
|
@@ -348,29 +419,31 @@ function updatePreparingActionState(
|
|
|
348
419
|
const tool = ev.tool?.trim();
|
|
349
420
|
const id = ev.id?.trim();
|
|
350
421
|
for (const [key, entry] of state.entries ?? []) {
|
|
351
|
-
if ((id && key === id) || (!id && entry.tool === tool)) {
|
|
422
|
+
if ((id && key === id) || (!id && tool && entry.tool === tool)) {
|
|
352
423
|
state.entries?.delete(key);
|
|
353
424
|
}
|
|
354
425
|
}
|
|
426
|
+
if (tool) {
|
|
427
|
+
refreshPreparingToolEntry(state, tool);
|
|
428
|
+
}
|
|
355
429
|
} else {
|
|
356
430
|
state.entries?.clear();
|
|
431
|
+
state.toolEntries?.clear();
|
|
357
432
|
}
|
|
358
433
|
}
|
|
359
434
|
return undefined;
|
|
360
435
|
}
|
|
361
436
|
|
|
362
|
-
function hasStalledPreparingAction(
|
|
363
|
-
state: PreparingActionState,
|
|
364
|
-
now: number,
|
|
365
|
-
options?: SSEStreamOptions,
|
|
366
|
-
) {
|
|
367
|
-
if (options?.durableBackgroundRun === true) return false;
|
|
437
|
+
function hasStalledPreparingAction(state: PreparingActionState, now: number) {
|
|
368
438
|
// Fire only when a tool input has gone SILENT — no further streaming deltas
|
|
369
439
|
// for the whole window — never merely because a large input has been
|
|
370
440
|
// streaming for a long time. `lastProgressAt` advances on every delta
|
|
371
441
|
// heartbeat, so an actively-streaming large output keeps resetting this and
|
|
372
442
|
// survives; a genuinely stuck prep (keepalive-only, no deltas) trips it.
|
|
373
|
-
for (const entry of
|
|
443
|
+
for (const entry of [
|
|
444
|
+
...(state.toolEntries?.values() ?? []),
|
|
445
|
+
...(state.entries?.values() ?? []),
|
|
446
|
+
]) {
|
|
374
447
|
if (
|
|
375
448
|
entry.startedAt !== undefined &&
|
|
376
449
|
now - (entry.lastProgressAt ?? entry.startedAt) >=
|
|
@@ -787,6 +860,9 @@ export function processEvent(
|
|
|
787
860
|
if (ev.type === "tool_start") {
|
|
788
861
|
const args = (ev.input ?? {}) as Record<string, string>;
|
|
789
862
|
const tool = ev.tool ?? "unknown";
|
|
863
|
+
if (findCompletedToolCallIndex(content, ev.id) >= 0) {
|
|
864
|
+
return { action: "continue" };
|
|
865
|
+
}
|
|
790
866
|
if (typeof window !== "undefined") {
|
|
791
867
|
window.dispatchEvent(
|
|
792
868
|
new CustomEvent("agent-native:tool-start", {
|
|
@@ -868,6 +944,9 @@ export function processEvent(
|
|
|
868
944
|
// so a tool_done frame with an undefined tool name still matches its
|
|
869
945
|
// pending tool-call entry instead of leaving it forever unresolved.
|
|
870
946
|
const doneTool = ev.tool ?? "unknown";
|
|
947
|
+
if (findCompletedToolCallIndex(content, ev.id) >= 0) {
|
|
948
|
+
return { action: "continue" };
|
|
949
|
+
}
|
|
871
950
|
if (typeof window !== "undefined") {
|
|
872
951
|
window.dispatchEvent(
|
|
873
952
|
new CustomEvent("agent-native:tool-done", {
|
|
@@ -1219,7 +1298,8 @@ export async function* readSSEStream(
|
|
|
1219
1298
|
let buf = "";
|
|
1220
1299
|
let lastMeaningfulEventAt = Date.now();
|
|
1221
1300
|
const activityTrail: ActivityTrailEntry[] = [];
|
|
1222
|
-
const preparingActionState: PreparingActionState =
|
|
1301
|
+
const preparingActionState: PreparingActionState =
|
|
1302
|
+
options?.preparingActionState ?? {};
|
|
1223
1303
|
const processEventState: ProcessEventState = {
|
|
1224
1304
|
completedToolsAfterLastAssistantText: new Set(),
|
|
1225
1305
|
};
|
|
@@ -1340,9 +1420,7 @@ export async function* readSSEStream(
|
|
|
1340
1420
|
);
|
|
1341
1421
|
|
|
1342
1422
|
if (result) yield withStreamMetadata(result);
|
|
1343
|
-
if (
|
|
1344
|
-
hasStalledPreparingAction(preparingActionState, Date.now(), options)
|
|
1345
|
-
) {
|
|
1423
|
+
if (hasStalledPreparingAction(preparingActionState, Date.now())) {
|
|
1346
1424
|
throw new AgentAutoContinueSignal({
|
|
1347
1425
|
reason: "no_progress",
|
|
1348
1426
|
activityTrail: [...activityTrail],
|
|
@@ -1413,7 +1491,8 @@ export async function readSSEStreamRaw(
|
|
|
1413
1491
|
let buf = "";
|
|
1414
1492
|
let lastMeaningfulEventAt = Date.now();
|
|
1415
1493
|
const activityTrail: ActivityTrailEntry[] = [];
|
|
1416
|
-
const preparingActionState: PreparingActionState =
|
|
1494
|
+
const preparingActionState: PreparingActionState =
|
|
1495
|
+
options?.preparingActionState ?? {};
|
|
1417
1496
|
const processEventState: ProcessEventState = {
|
|
1418
1497
|
completedToolsAfterLastAssistantText: new Set(),
|
|
1419
1498
|
};
|
|
@@ -1525,9 +1604,7 @@ export async function readSSEStreamRaw(
|
|
|
1525
1604
|
: { reason: "stream_ended", activityTrail: [...activityTrail] },
|
|
1526
1605
|
);
|
|
1527
1606
|
}
|
|
1528
|
-
if (
|
|
1529
|
-
hasStalledPreparingAction(preparingActionState, Date.now(), options)
|
|
1530
|
-
) {
|
|
1607
|
+
if (hasStalledPreparingAction(preparingActionState, Date.now())) {
|
|
1531
1608
|
onUpdate(contentSnapshot(content));
|
|
1532
1609
|
throw new AgentAutoContinueSignal({
|
|
1533
1610
|
reason: "no_progress",
|
|
@@ -429,6 +429,11 @@ function ReplayPlayer({
|
|
|
429
429
|
if (events.length < 2) {
|
|
430
430
|
throw new Error(t("sessions.noReplayEvents"));
|
|
431
431
|
}
|
|
432
|
+
if (
|
|
433
|
+
!events.some((event) => event.type === RRWEB_EVENT_TYPE.FullSnapshot)
|
|
434
|
+
) {
|
|
435
|
+
throw new Error(t("sessions.noReplayEvents"));
|
|
436
|
+
}
|
|
432
437
|
setStatus("loading");
|
|
433
438
|
setError(null);
|
|
434
439
|
await import("@rrweb/replay/dist/style.css");
|
|
@@ -9,11 +9,14 @@ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
|
|
|
9
9
|
NITRO_PRESET = "netlify"
|
|
10
10
|
NPM_CONFIG_PRODUCTION = "false"
|
|
11
11
|
AGENT_PROD_CODE_EXECUTION = "sandboxed"
|
|
12
|
+
AGENT_CHAT_DURABLE_BACKGROUND = "true"
|
|
12
13
|
|
|
13
14
|
# A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
|
|
14
15
|
# Hosted agent chat uses a soft timeout before this function timeout so the
|
|
15
16
|
# hidden auto-continuation path can hand off before Netlify kills the function.
|
|
16
17
|
# If AGENT_RUN_SOFT_TIMEOUT_MS is customized, keep it below this timeout.
|
|
18
|
+
# Agent chat runs are dispatched to the emitted Netlify background function;
|
|
19
|
+
# the normal server timeout still protects non-background HTTP handlers.
|
|
17
20
|
[functions."*"]
|
|
18
21
|
timeout = 75
|
|
19
22
|
|
|
@@ -152,7 +152,8 @@ const MAX_REPLAY_BLOB_REF_LENGTH = 16 * 1024;
|
|
|
152
152
|
const MAX_REPLAY_METADATA_BYTES = 16 * 1024;
|
|
153
153
|
const MAX_REPLAY_EVENTS_PER_CHUNK = 1_000;
|
|
154
154
|
const MAX_REPLAY_EVENTS_READ = 10_000;
|
|
155
|
-
const MAX_REPLAY_EVENTS_RESPONSE_BYTES =
|
|
155
|
+
const MAX_REPLAY_EVENTS_RESPONSE_BYTES =
|
|
156
|
+
MAX_BLOB_REPLAY_CHUNK_BYTES + 512 * 1024;
|
|
156
157
|
const DEFAULT_SESSION_RECORDINGS_LIMIT = 50;
|
|
157
158
|
const MAX_SESSION_RECORDINGS_LIMIT = 100;
|
|
158
159
|
const DEFAULT_REPLAY_RETENTION_DAYS = 30;
|
|
@@ -550,6 +551,12 @@ async function storeReplayChunkBlob(
|
|
|
550
551
|
503,
|
|
551
552
|
);
|
|
552
553
|
}
|
|
554
|
+
if (chunk.byteLength > MAX_INLINE_REPLAY_CHUNK_BYTES) {
|
|
555
|
+
throw replayError(
|
|
556
|
+
"Session replay chunk is too large for inline SQL fallback. Configure private blob storage for full snapshot playback.",
|
|
557
|
+
503,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
553
560
|
warnInlineReplayFallback();
|
|
554
561
|
return chunk;
|
|
555
562
|
}
|
|
@@ -590,10 +597,7 @@ function normalizeReplayChunk(rawValue: unknown): NormalizedSessionReplayChunk {
|
|
|
590
597
|
storageKind === "inline"
|
|
591
598
|
? Buffer.byteLength(inlineData ?? "", "utf8")
|
|
592
599
|
: (replayInteger(raw.byteLength ?? raw.bytes) ?? 0);
|
|
593
|
-
const maxBytes =
|
|
594
|
-
storageKind === "inline"
|
|
595
|
-
? MAX_INLINE_REPLAY_CHUNK_BYTES
|
|
596
|
-
: MAX_BLOB_REPLAY_CHUNK_BYTES;
|
|
600
|
+
const maxBytes = MAX_BLOB_REPLAY_CHUNK_BYTES;
|
|
597
601
|
if (byteLength <= 0 || byteLength > maxBytes) {
|
|
598
602
|
throw replayError(
|
|
599
603
|
`Replay ${storageKind} chunks must be between 1 and ${maxBytes} bytes`,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../lib/scoped-settings";
|
|
29
29
|
|
|
30
30
|
const DATA_DICT_PREFIX = "data-dict-";
|
|
31
|
+
const ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS = 13 * 60_000;
|
|
31
32
|
|
|
32
33
|
const INITIAL_TOOL_NAMES = [
|
|
33
34
|
"view-screen",
|
|
@@ -170,6 +171,8 @@ export default createAgentChatPlugin({
|
|
|
170
171
|
// Operators deploying to trusted internal environments can set
|
|
171
172
|
// AGENT_PROD_CODE_EXECUTION=trusted to also enable bash/read/edit/write.
|
|
172
173
|
codeExecution: { production: "sandboxed" },
|
|
174
|
+
durableBackgroundRuns: true,
|
|
175
|
+
runSoftTimeoutMs: ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS,
|
|
173
176
|
resolveOrgId: async (event) => {
|
|
174
177
|
const ctx = await getOrgContext(event);
|
|
175
178
|
return ctx.orgId;
|
|
@@ -8,8 +8,11 @@ functions = "templates/plan/.netlify/functions-internal"
|
|
|
8
8
|
GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
|
|
9
9
|
NITRO_PRESET = "netlify"
|
|
10
10
|
NPM_CONFIG_PRODUCTION = "false"
|
|
11
|
+
AGENT_CHAT_DURABLE_BACKGROUND = "true"
|
|
11
12
|
|
|
12
13
|
# A2A/MCP calls may include plan generation, artifact serialization, and
|
|
13
14
|
# feedback polling. Keep this aligned with other agent-heavy hosted templates.
|
|
15
|
+
# Agent chat runs are dispatched to the emitted Netlify background function;
|
|
16
|
+
# the normal server timeout still protects non-background HTTP handlers.
|
|
14
17
|
[functions."*"]
|
|
15
18
|
timeout = 75
|