@nexrall/code-core 1.4.25 → 1.4.27
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/dist/agent/agentRegistry.d.ts +45 -0
- package/dist/agent/agentRegistry.d.ts.map +1 -0
- package/dist/agent/agentRegistry.js +138 -0
- package/dist/agent/loop.d.ts +42 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +305 -24
- package/dist/agent/planMode.d.ts +20 -0
- package/dist/agent/planMode.d.ts.map +1 -0
- package/dist/agent/planMode.js +252 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +409 -381
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/permissions/rules.d.ts.map +1 -1
- package/dist/permissions/rules.js +14 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/api/client.js
CHANGED
|
@@ -801,417 +801,445 @@ async function streamChat(messages, options, onEvent) {
|
|
|
801
801
|
const haveCompleteMessage = () => completedMessage !== null;
|
|
802
802
|
// Partial input accumulator keyed by tool_use id
|
|
803
803
|
const partialInputs = {};
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
// • AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
|
|
834
|
-
// is a genuine stall.
|
|
835
|
-
// Overridable so tests can reach the stall path in seconds instead of minutes.
|
|
836
|
-
// That path is where the subtlest bug in this file lives: `lastProgressAt` stops
|
|
837
|
-
// advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
|
|
838
|
-
// this watchdog — and only this one — can fire on a turn that already succeeded.
|
|
839
|
-
const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
|
|
840
|
-
const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
|
|
841
|
-
let sawModelEvent = false;
|
|
842
|
-
let lastDataAt = Date.now();
|
|
843
|
-
let lastProgressAt = Date.now();
|
|
844
|
-
const heartbeatWatchdog = setInterval(() => {
|
|
845
|
-
const now = Date.now();
|
|
846
|
-
// The turn already succeeded and we're only waiting on the trailing `done`.
|
|
847
|
-
// Finish with what we have rather than discarding a complete message and
|
|
848
|
-
// re-running the model.
|
|
804
|
+
// ─── Watchdog ownership ────────────────────────────────────────────────────
|
|
805
|
+
//
|
|
806
|
+
// Hoisted OUT of the promise executor so a single `finally` can guarantee the
|
|
807
|
+
// interval dies with the attempt. It used to be declared inside, and was cleared
|
|
808
|
+
// in nine separate branches — but not in the two `resolve()` paths inside the
|
|
809
|
+
// parser (the `[DONE]` sentinel and the `done` event), which are the NORMAL way a
|
|
810
|
+
// healthy turn ends. Those relied on `stream.on('end')` firing afterwards to do
|
|
811
|
+
// the cleanup; when a proxy or keep-alive holds the socket open, `end` never comes,
|
|
812
|
+
// so a 5-second interval outlived the turn. On a long run that is both a leak
|
|
813
|
+
// (one surviving timer per iteration, hundreds of iterations) and a correctness
|
|
814
|
+
// bug: the orphaned watchdog can still call stream.destroy() and reject() on an
|
|
815
|
+
// already-settled promise, i.e. tear down the NEXT thing using that socket.
|
|
816
|
+
//
|
|
817
|
+
// Nine clear sites are now belt-and-braces rather than load-bearing: clearInterval
|
|
818
|
+
// is idempotent, so leaving them costs nothing and each still stops the watchdog at
|
|
819
|
+
// the earliest possible moment.
|
|
820
|
+
let heartbeatWatchdog;
|
|
821
|
+
// NOTE: the promise body below is deliberately NOT re-indented for this `try`.
|
|
822
|
+
// Wrapping it cost one line; re-indenting ~400 lines would bury that one-line fix
|
|
823
|
+
// in a whitespace diff nobody can review.
|
|
824
|
+
try {
|
|
825
|
+
await new Promise((resolve, reject) => {
|
|
826
|
+
// Feed chunks from the response body into the SSE parser.
|
|
827
|
+
// Declared here so the heartbeat watchdog closure can reference it.
|
|
828
|
+
const stream = response.body;
|
|
829
|
+
// ── Heartbeat watchdog ────────────────────────────────────────────────────
|
|
830
|
+
// The backend sends ": ping" comments every 10 s. If we receive no data at
|
|
831
|
+
// all for 90 s the connection has silently dropped (proxy timeout, network
|
|
832
|
+
// blip). Reject so the caller sees an actionable error instead of hanging.
|
|
849
833
|
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
|
|
834
|
+
// Sizing: this must tolerate a burst of consecutive LOST pings, not just one.
|
|
835
|
+
// The old pairing (20 s ping / 45 s timeout) survived exactly two misses —
|
|
836
|
+
// routinely exceeded on flaky Wi-Fi, tethered 4G, or the seconds right after a
|
|
837
|
+
// laptop wakes, so a perfectly healthy connection got torn down and the turn
|
|
838
|
+
// restarted for nothing. At 10 s / 90 s we ride out eight consecutive misses
|
|
839
|
+
// while still detecting a truly dead socket faster in absolute terms than the
|
|
840
|
+
// proxy's own idle timeout.
|
|
841
|
+
const HEARTBEAT_TIMEOUT_MS = 90000;
|
|
842
|
+
// Progress watchdog. The backend's ": ping" keepalive (every 10 s) resets lastDataAt,
|
|
843
|
+
// so the heartbeat above only catches a fully dead connection — NOT an upstream model
|
|
844
|
+
// stall, where Anthropic stops emitting tokens but the backend keeps pinging. That case
|
|
845
|
+
// would otherwise hang on "Thinking…" forever. lastProgressAt is bumped ONLY on real
|
|
846
|
+
// model events (see parser below), never on pings, so a genuine stall trips this and
|
|
847
|
+
// surfaces a retryable error.
|
|
848
|
+
// Two-phase stall limits:
|
|
849
|
+
// • BEFORE the first model event, the upstream is doing prompt processing (time-to-
|
|
850
|
+
// first-token). On big contexts (1M window, cold prompt cache, large tool schemas)
|
|
851
|
+
// that phase emits NOTHING until message_start and can legitimately exceed 150 s.
|
|
852
|
+
// Killing it there was the "timeout 150s → retry instantly works" bug: the first
|
|
853
|
+
// attempt warmed the prompt cache, so the retry's TTFT was short. Allow 300 s.
|
|
854
|
+
// • AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
|
|
855
|
+
// is a genuine stall.
|
|
856
|
+
// Overridable so tests can reach the stall path in seconds instead of minutes.
|
|
857
|
+
// That path is where the subtlest bug in this file lives: `lastProgressAt` stops
|
|
858
|
+
// advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
|
|
859
|
+
// this watchdog — and only this one — can fire on a turn that already succeeded.
|
|
860
|
+
const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
|
|
861
|
+
const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
|
|
862
|
+
let sawModelEvent = false;
|
|
863
|
+
let lastDataAt = Date.now();
|
|
864
|
+
let lastProgressAt = Date.now();
|
|
865
|
+
heartbeatWatchdog = setInterval(() => {
|
|
866
|
+
const now = Date.now();
|
|
867
|
+
// The turn already succeeded and we're only waiting on the trailing `done`.
|
|
868
|
+
// Finish with what we have rather than discarding a complete message and
|
|
869
|
+
// re-running the model.
|
|
870
|
+
//
|
|
871
|
+
// The silence condition is essential, not decorative: the backend AWAITS its
|
|
872
|
+
// billing write before emitting `balance_status` and `done`, so the gap after
|
|
873
|
+
// `message_complete` is normal, expected, and can run for seconds on a slow DB.
|
|
874
|
+
// Firing on the next tick regardless would tear down a perfectly healthy socket
|
|
875
|
+
// and swallow the low-balance nudge — trading the bug this guard was written to
|
|
876
|
+
// fix for a different one. Wait out the full heartbeat timeout first; only then
|
|
877
|
+
// is the trailing frame genuinely never coming.
|
|
878
|
+
if (haveCompleteMessage() && now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
|
|
879
|
+
clearInterval(heartbeatWatchdog);
|
|
880
|
+
stream.destroy?.();
|
|
893
881
|
resolve();
|
|
894
882
|
return;
|
|
895
883
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
{
|
|
913
|
-
// Track the server-assigned frame id. This is the resume cursor: on a drop we
|
|
914
|
-
// hand it back via Last-Event-ID and receive exactly the frames we missed,
|
|
915
|
-
// instead of re-running the whole turn. Only present on resumable turns.
|
|
916
|
-
const frameId = event.id;
|
|
917
|
-
if (frameId) {
|
|
918
|
-
const n = Number(frameId);
|
|
919
|
-
if (Number.isFinite(n) && n > lastEventId)
|
|
920
|
-
lastEventId = n;
|
|
921
|
-
}
|
|
922
|
-
const raw = event.data;
|
|
923
|
-
if (!raw || raw === '[DONE]') {
|
|
924
|
-
resolve();
|
|
884
|
+
if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
|
|
885
|
+
clearInterval(heartbeatWatchdog);
|
|
886
|
+
stream.destroy?.();
|
|
887
|
+
// Same rule as the stall watchdog below: if nothing has been shown to the
|
|
888
|
+
// caller yet, a fresh attempt can't duplicate output, so retry transparently
|
|
889
|
+
// instead of surfacing a hard error. This is also the common path when the
|
|
890
|
+
// machine just woke from sleep — JS timers freeze during sleep, so on wake
|
|
891
|
+
// `now - lastDataAt` immediately reads as a large gap even though nothing is
|
|
892
|
+
// actually wrong with the connection; without this it used to hard-fail
|
|
893
|
+
// instead of silently reconnecting.
|
|
894
|
+
const recoverable = !emittedToCaller || !!allowRestartAfterRender;
|
|
895
|
+
const secs = Math.round(HEARTBEAT_TIMEOUT_MS / 1000);
|
|
896
|
+
reject(tagTransient(new Error(recoverable
|
|
897
|
+
? `Connection lost — no data received for ${secs} s. Reconnecting…`
|
|
898
|
+
: `Connection lost — no data received for ${secs} s. Retry your message.`)));
|
|
925
899
|
return;
|
|
926
900
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
901
|
+
const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
|
|
902
|
+
if (now - lastProgressAt > stallLimitMs) {
|
|
903
|
+
clearInterval(heartbeatWatchdog);
|
|
904
|
+
stream.destroy?.();
|
|
905
|
+
// Same rule as the heartbeat branch above, and MORE easily reached here: this
|
|
906
|
+
// watchdog reads `lastProgressAt`, which only real model events bump. Once
|
|
907
|
+
// `message_complete` lands, nothing bumps it again — so while the backend awaits
|
|
908
|
+
// its billing write, the heartbeat's `: ping` keeps `lastDataAt` fresh (both
|
|
909
|
+
// branches above correctly stay silent) but THIS clock runs out and reports a
|
|
910
|
+
// "stall" on a turn that already succeeded. Restarting there would discard a
|
|
911
|
+
// correct answer and re-bill the model call, repeatedly. The turn is complete;
|
|
912
|
+
// finish with it.
|
|
913
|
+
if (haveCompleteMessage()) {
|
|
914
|
+
resolve();
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
// If nothing has been shown to the caller yet, a fresh attempt can't duplicate
|
|
918
|
+
// output — tag it retryable so the outer loop transparently retries the turn
|
|
919
|
+
// instead of killing it. Once output has been emitted, a retry would duplicate
|
|
920
|
+
// rendered text/tool calls, so surface the stall as a terminal error.
|
|
921
|
+
reject(tagTransient(new Error(sawModelEvent
|
|
922
|
+
? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s).`
|
|
923
|
+
: `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process).`)));
|
|
924
|
+
return;
|
|
933
925
|
}
|
|
934
|
-
|
|
926
|
+
}, 5000);
|
|
927
|
+
const parser = (0, eventsource_parser_1.createParser)((event) => {
|
|
928
|
+
lastDataAt = Date.now(); // any SSE data (including ": ping" comments) resets the watchdog
|
|
929
|
+
// Skip reconnect-interval events
|
|
930
|
+
if (event.type !== 'event') {
|
|
935
931
|
return;
|
|
936
932
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
case 'text': {
|
|
947
|
-
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
948
|
-
textParts.push(text);
|
|
949
|
-
emittedToCaller = true;
|
|
950
|
-
emittedChars += text.length;
|
|
951
|
-
emittedAnythingAcrossAttempts = true;
|
|
952
|
-
emittedCharsAcrossAttempts += text.length;
|
|
953
|
-
onEvent({ type: 'text', text });
|
|
954
|
-
break;
|
|
933
|
+
{
|
|
934
|
+
// Track the server-assigned frame id. This is the resume cursor: on a drop we
|
|
935
|
+
// hand it back via Last-Event-ID and receive exactly the frames we missed,
|
|
936
|
+
// instead of re-running the whole turn. Only present on resumable turns.
|
|
937
|
+
const frameId = event.id;
|
|
938
|
+
if (frameId) {
|
|
939
|
+
const n = Number(frameId);
|
|
940
|
+
if (Number.isFinite(n) && n > lastEventId)
|
|
941
|
+
lastEventId = n;
|
|
955
942
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
name: typeof evt.name === 'string' ? evt.name : '',
|
|
961
|
-
input: evt.input ?? {},
|
|
962
|
-
};
|
|
963
|
-
// Handle streaming input deltas
|
|
964
|
-
if (typeof evt.input_json_delta === 'string') {
|
|
965
|
-
partialInputs[block.id] = (partialInputs[block.id] ?? '') + evt.input_json_delta;
|
|
966
|
-
try {
|
|
967
|
-
block.input = JSON.parse(partialInputs[block.id]);
|
|
968
|
-
}
|
|
969
|
-
catch {
|
|
970
|
-
// Input not fully streamed yet
|
|
971
|
-
return;
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
toolUseBlocks.push(block);
|
|
975
|
-
// Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
|
|
976
|
-
// preview that no consumer renders. The agent loop's `tool_use` case is a
|
|
977
|
-
// no-op and the UI's tool row is only drawn later, from `options.onToolUse`
|
|
978
|
-
// during execution — which happens after streamChat() returns and so never
|
|
979
|
-
// runs on a failed attempt. `emittedToCaller` is still set, conservatively,
|
|
980
|
-
// so a restart continues to require an explicit rollback handler.
|
|
981
|
-
emittedToCaller = true;
|
|
982
|
-
emittedAnythingAcrossAttempts = true;
|
|
983
|
-
onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
|
|
984
|
-
break;
|
|
943
|
+
const raw = event.data;
|
|
944
|
+
if (!raw || raw === '[DONE]') {
|
|
945
|
+
resolve();
|
|
946
|
+
return;
|
|
985
947
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
// `sendEvent(res, {type:'message_complete', message})` — `message` is the raw
|
|
990
|
-
// Anthropic SDK message with thinking already stripped, which always has
|
|
991
|
-
// stop_reason). 'max_tokens' means the model's output was cut off mid-
|
|
992
|
-
// generation — if the last content block is a tool_use, its `input` may be a
|
|
993
|
-
// truncated JSON object that still happened to parse (e.g. a multi_edit whose
|
|
994
|
-
// `edits` array lost its last, still-in-progress element, or came out empty/
|
|
995
|
-
// missing entirely) without any error at all. The agent loop uses this to
|
|
996
|
-
// refuse executing that block blindly instead of silently applying a partial edit.
|
|
997
|
-
const nestedMessage = evt.message;
|
|
998
|
-
const stopReason = typeof nestedMessage?.stop_reason === 'string' ? nestedMessage.stop_reason : null;
|
|
999
|
-
// Build the complete assistant message from accumulated parts (the common
|
|
1000
|
-
// path: text + tool_use, in the client's own order).
|
|
1001
|
-
const contentBlocks = [];
|
|
1002
|
-
const fullText = textParts.join('');
|
|
1003
|
-
if (fullText) {
|
|
1004
|
-
contentBlocks.push({ type: 'text', text: fullText });
|
|
1005
|
-
}
|
|
1006
|
-
contentBlocks.push(...toolUseBlocks);
|
|
1007
|
-
// TOOL-SEARCH / SERVER-SIDE-BLOCK PRESERVATION.
|
|
1008
|
-
// The rebuild above keeps ONLY text + tool_use. That is lossy for any turn
|
|
1009
|
-
// that also contains server-side blocks — `server_tool_use`,
|
|
1010
|
-
// `tool_search_tool_result` (and its nested `tool_reference`s), or a
|
|
1011
|
-
// `web_search_tool_result`. Those blocks MUST round-trip back to the API
|
|
1012
|
-
// verbatim on the next request or (a) the API rejects the follow-up (it
|
|
1013
|
-
// expects the paired server_tool_use/result to be present) and (b) for tool
|
|
1014
|
-
// search specifically, the discovered (deferred) tools are FORGOTTEN, forcing
|
|
1015
|
-
// Claude to re-search every turn — the exact opposite of the token saving.
|
|
1016
|
-
//
|
|
1017
|
-
// Preserve server-side blocks (tool_search_tool_result / server_tool_use /
|
|
1018
|
-
// web_search_tool_result) verbatim when present; otherwise keep the rebuilt
|
|
1019
|
-
// array. See chooseFinalContent for the full rationale. Zero regression for
|
|
1020
|
-
// an ordinary text/tool_use turn.
|
|
1021
|
-
const rawContent = Array.isArray(nestedMessage?.content)
|
|
1022
|
-
? nestedMessage.content
|
|
1023
|
-
: null;
|
|
1024
|
-
const finalContent = chooseFinalContent(contentBlocks, rawContent);
|
|
1025
|
-
completedMessage = {
|
|
1026
|
-
role: 'assistant',
|
|
1027
|
-
content: finalContent,
|
|
1028
|
-
stopReason,
|
|
1029
|
-
};
|
|
1030
|
-
onEvent({ type: 'message_complete', message: completedMessage });
|
|
1031
|
-
break;
|
|
948
|
+
let parsed;
|
|
949
|
+
try {
|
|
950
|
+
parsed = JSON.parse(raw);
|
|
1032
951
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
onEvent({
|
|
1036
|
-
type: 'usage',
|
|
1037
|
-
// Forwarded, not dropped: the backend tags the usage of an attempt it
|
|
1038
|
-
// billed but never completed (its stream `abort` path). Without this
|
|
1039
|
-
// the flag dies here and every consumer that sums usage silently folds
|
|
1040
|
-
// a discarded attempt's tokens into the successful turn's total.
|
|
1041
|
-
...(evt.partial === true ? { partial: true } : {}),
|
|
1042
|
-
// `replayed` means these tokens are being reported a SECOND time: the
|
|
1043
|
-
// turn completed and was billed on an earlier attempt whose `done` never
|
|
1044
|
-
// reached us, and the backend served this one from its idempotency cache
|
|
1045
|
-
// instead of re-running the model. Nothing new was charged, so a cost
|
|
1046
|
-
// display must not add them again.
|
|
1047
|
-
...(evt.replayed === true ? { replayed: true } : {}),
|
|
1048
|
-
usage: {
|
|
1049
|
-
input_tokens: evt.input_tokens,
|
|
1050
|
-
output_tokens: evt.output_tokens,
|
|
1051
|
-
cache_creation_input_tokens: typeof evt.cache_creation_input_tokens === 'number' ? evt.cache_creation_input_tokens : undefined,
|
|
1052
|
-
cache_read_input_tokens: typeof evt.cache_read_input_tokens === 'number' ? evt.cache_read_input_tokens : undefined,
|
|
1053
|
-
},
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
break;
|
|
952
|
+
catch {
|
|
953
|
+
return; // Skip malformed lines
|
|
1057
954
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
// contextWindow.js): it already safely trimmed/compacted the
|
|
1061
|
-
// history before sending, at a turn boundary that never breaks
|
|
1062
|
-
// a tool_use/tool_result pair. Purely informational — the
|
|
1063
|
-
// server has already handled it, so there is nothing actionable
|
|
1064
|
-
// for the user here. Deliberately NOT surfaced to the UI
|
|
1065
|
-
// (used to be forwarded as an `error` event, which made a
|
|
1066
|
-
// routine, already-handled trim look like a failure).
|
|
1067
|
-
break;
|
|
955
|
+
if (typeof parsed !== 'object' || parsed === null || !('type' in parsed)) {
|
|
956
|
+
return;
|
|
1068
957
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
958
|
+
const evt = parsed;
|
|
959
|
+
// Reaching here means a well-formed model event was parsed (": ping" keepalive
|
|
960
|
+
// comments never produce a ParsedEvent with data) — count it as real progress.
|
|
961
|
+
lastProgressAt = Date.now();
|
|
962
|
+
sawModelEvent = true;
|
|
963
|
+
// Real data is flowing again — clear any "reconnecting…" indicator the caller
|
|
964
|
+
// may be showing from an earlier retry on THIS same attempt.
|
|
965
|
+
clearRetryIfNeeded();
|
|
966
|
+
switch (evt.type) {
|
|
967
|
+
case 'text': {
|
|
968
|
+
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
969
|
+
textParts.push(text);
|
|
1072
970
|
emittedToCaller = true;
|
|
1073
971
|
emittedChars += text.length;
|
|
1074
972
|
emittedAnythingAcrossAttempts = true;
|
|
1075
973
|
emittedCharsAcrossAttempts += text.length;
|
|
1076
|
-
onEvent({ type: '
|
|
974
|
+
onEvent({ type: 'text', text });
|
|
975
|
+
break;
|
|
1077
976
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
977
|
+
case 'tool_use': {
|
|
978
|
+
const block = {
|
|
979
|
+
type: 'tool_use',
|
|
980
|
+
id: typeof evt.id === 'string' ? evt.id : '',
|
|
981
|
+
name: typeof evt.name === 'string' ? evt.name : '',
|
|
982
|
+
input: evt.input ?? {},
|
|
983
|
+
};
|
|
984
|
+
// Handle streaming input deltas
|
|
985
|
+
if (typeof evt.input_json_delta === 'string') {
|
|
986
|
+
partialInputs[block.id] = (partialInputs[block.id] ?? '') + evt.input_json_delta;
|
|
987
|
+
try {
|
|
988
|
+
block.input = JSON.parse(partialInputs[block.id]);
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
// Input not fully streamed yet
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
toolUseBlocks.push(block);
|
|
996
|
+
// Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
|
|
997
|
+
// preview that no consumer renders. The agent loop's `tool_use` case is a
|
|
998
|
+
// no-op and the UI's tool row is only drawn later, from `options.onToolUse`
|
|
999
|
+
// during execution — which happens after streamChat() returns and so never
|
|
1000
|
+
// runs on a failed attempt. `emittedToCaller` is still set, conservatively,
|
|
1001
|
+
// so a restart continues to require an explicit rollback handler.
|
|
1092
1002
|
emittedToCaller = true;
|
|
1093
|
-
emittedChars += text.length;
|
|
1094
1003
|
emittedAnythingAcrossAttempts = true;
|
|
1095
|
-
|
|
1096
|
-
|
|
1004
|
+
onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
|
|
1005
|
+
break;
|
|
1097
1006
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1007
|
+
case 'message_complete': {
|
|
1008
|
+
// Anthropic's stop_reason (and the full raw content array) travel inside
|
|
1009
|
+
// the nested `message` object the backend forwards verbatim (routes/code.js's
|
|
1010
|
+
// `sendEvent(res, {type:'message_complete', message})` — `message` is the raw
|
|
1011
|
+
// Anthropic SDK message with thinking already stripped, which always has
|
|
1012
|
+
// stop_reason). 'max_tokens' means the model's output was cut off mid-
|
|
1013
|
+
// generation — if the last content block is a tool_use, its `input` may be a
|
|
1014
|
+
// truncated JSON object that still happened to parse (e.g. a multi_edit whose
|
|
1015
|
+
// `edits` array lost its last, still-in-progress element, or came out empty/
|
|
1016
|
+
// missing entirely) without any error at all. The agent loop uses this to
|
|
1017
|
+
// refuse executing that block blindly instead of silently applying a partial edit.
|
|
1018
|
+
const nestedMessage = evt.message;
|
|
1019
|
+
const stopReason = typeof nestedMessage?.stop_reason === 'string' ? nestedMessage.stop_reason : null;
|
|
1020
|
+
// Build the complete assistant message from accumulated parts (the common
|
|
1021
|
+
// path: text + tool_use, in the client's own order).
|
|
1022
|
+
const contentBlocks = [];
|
|
1023
|
+
const fullText = textParts.join('');
|
|
1024
|
+
if (fullText) {
|
|
1025
|
+
contentBlocks.push({ type: 'text', text: fullText });
|
|
1026
|
+
}
|
|
1027
|
+
contentBlocks.push(...toolUseBlocks);
|
|
1028
|
+
// TOOL-SEARCH / SERVER-SIDE-BLOCK PRESERVATION.
|
|
1029
|
+
// The rebuild above keeps ONLY text + tool_use. That is lossy for any turn
|
|
1030
|
+
// that also contains server-side blocks — `server_tool_use`,
|
|
1031
|
+
// `tool_search_tool_result` (and its nested `tool_reference`s), or a
|
|
1032
|
+
// `web_search_tool_result`. Those blocks MUST round-trip back to the API
|
|
1033
|
+
// verbatim on the next request or (a) the API rejects the follow-up (it
|
|
1034
|
+
// expects the paired server_tool_use/result to be present) and (b) for tool
|
|
1035
|
+
// search specifically, the discovered (deferred) tools are FORGOTTEN, forcing
|
|
1036
|
+
// Claude to re-search every turn — the exact opposite of the token saving.
|
|
1037
|
+
//
|
|
1038
|
+
// Preserve server-side blocks (tool_search_tool_result / server_tool_use /
|
|
1039
|
+
// web_search_tool_result) verbatim when present; otherwise keep the rebuilt
|
|
1040
|
+
// array. See chooseFinalContent for the full rationale. Zero regression for
|
|
1041
|
+
// an ordinary text/tool_use turn.
|
|
1042
|
+
const rawContent = Array.isArray(nestedMessage?.content)
|
|
1043
|
+
? nestedMessage.content
|
|
1044
|
+
: null;
|
|
1045
|
+
const finalContent = chooseFinalContent(contentBlocks, rawContent);
|
|
1046
|
+
completedMessage = {
|
|
1047
|
+
role: 'assistant',
|
|
1048
|
+
content: finalContent,
|
|
1049
|
+
stopReason,
|
|
1050
|
+
};
|
|
1051
|
+
onEvent({ type: 'message_complete', message: completedMessage });
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
case 'usage': {
|
|
1055
|
+
if (typeof evt.input_tokens === 'number' && typeof evt.output_tokens === 'number') {
|
|
1056
|
+
onEvent({
|
|
1057
|
+
type: 'usage',
|
|
1058
|
+
// Forwarded, not dropped: the backend tags the usage of an attempt it
|
|
1059
|
+
// billed but never completed (its stream `abort` path). Without this
|
|
1060
|
+
// the flag dies here and every consumer that sums usage silently folds
|
|
1061
|
+
// a discarded attempt's tokens into the successful turn's total.
|
|
1062
|
+
...(evt.partial === true ? { partial: true } : {}),
|
|
1063
|
+
// `replayed` means these tokens are being reported a SECOND time: the
|
|
1064
|
+
// turn completed and was billed on an earlier attempt whose `done` never
|
|
1065
|
+
// reached us, and the backend served this one from its idempotency cache
|
|
1066
|
+
// instead of re-running the model. Nothing new was charged, so a cost
|
|
1067
|
+
// display must not add them again.
|
|
1068
|
+
...(evt.replayed === true ? { replayed: true } : {}),
|
|
1069
|
+
usage: {
|
|
1070
|
+
input_tokens: evt.input_tokens,
|
|
1071
|
+
output_tokens: evt.output_tokens,
|
|
1072
|
+
cache_creation_input_tokens: typeof evt.cache_creation_input_tokens === 'number' ? evt.cache_creation_input_tokens : undefined,
|
|
1073
|
+
cache_read_input_tokens: typeof evt.cache_read_input_tokens === 'number' ? evt.cache_read_input_tokens : undefined,
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
case 'context_warning': {
|
|
1080
|
+
// Server-side context-budget housekeeping (routes/code.js /
|
|
1081
|
+
// contextWindow.js): it already safely trimmed/compacted the
|
|
1082
|
+
// history before sending, at a turn boundary that never breaks
|
|
1083
|
+
// a tool_use/tool_result pair. Purely informational — the
|
|
1084
|
+
// server has already handled it, so there is nothing actionable
|
|
1085
|
+
// for the user here. Deliberately NOT surfaced to the UI
|
|
1086
|
+
// (used to be forwarded as an `error` event, which made a
|
|
1087
|
+
// routine, already-handled trim look like a failure).
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
case 'thinking': {
|
|
1091
|
+
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
1092
|
+
if (text) {
|
|
1093
|
+
emittedToCaller = true;
|
|
1094
|
+
emittedChars += text.length;
|
|
1095
|
+
emittedAnythingAcrossAttempts = true;
|
|
1096
|
+
emittedCharsAcrossAttempts += text.length;
|
|
1097
|
+
onEvent({ type: 'thinking', text });
|
|
1098
|
+
}
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
case 'thinking_progress': {
|
|
1102
|
+
// Without this case the live token counter never updated and long thinking
|
|
1103
|
+
// phases (e.g. after reading a large file) looked frozen on "Thinking…".
|
|
1104
|
+
const tokens = typeof evt.tokens === 'number' ? evt.tokens : 0;
|
|
1105
|
+
onEvent({ type: 'thinking_progress', tokens });
|
|
1106
|
+
break;
|
|
1141
1107
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1108
|
+
case 'thinking_delta': {
|
|
1109
|
+
// Live thinking text streamed token-by-token so a long thinking phase shows
|
|
1110
|
+
// content as it forms, not a single block dumped at message completion.
|
|
1111
|
+
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
1112
|
+
if (text) {
|
|
1113
|
+
emittedToCaller = true;
|
|
1114
|
+
emittedChars += text.length;
|
|
1115
|
+
emittedAnythingAcrossAttempts = true;
|
|
1116
|
+
emittedCharsAcrossAttempts += text.length;
|
|
1117
|
+
onEvent({ type: 'thinking_delta', text });
|
|
1118
|
+
}
|
|
1119
|
+
break;
|
|
1146
1120
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1121
|
+
case 'resumable': {
|
|
1122
|
+
// The backend numbers this turn's frames and buffers them, so a dropped
|
|
1123
|
+
// connection can reattach mid-response rather than re-running the turn.
|
|
1124
|
+
// Purely a capability announcement — nothing to show the user.
|
|
1125
|
+
serverResumable = true;
|
|
1126
|
+
break;
|
|
1151
1127
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1128
|
+
case 'balance_status': {
|
|
1129
|
+
const balance = typeof evt.balance === 'number' ? evt.balance : 0;
|
|
1130
|
+
const zero = !!evt.zero;
|
|
1131
|
+
onEvent({ type: 'balance_status', balance, zero });
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
case 'done': {
|
|
1135
|
+
onEvent({ type: 'done' });
|
|
1136
|
+
resolve();
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
case 'error': {
|
|
1140
|
+
const message = typeof evt.message === 'string' ? evt.message :
|
|
1141
|
+
typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
|
|
1142
|
+
// The GET /chat/resume tail loop sends this when the turn's Redis frame
|
|
1143
|
+
// buffer developed a gap (trimmed window, a swallowed flush error, or the
|
|
1144
|
+
// owning pod died) or the turn is simply gone — see routes/code.js. Unlike
|
|
1145
|
+
// `isRetryableStreamMsg` below, this is NOT a fuzzy text match: the backend
|
|
1146
|
+
// sets the flag explicitly, so it can never silently stop matching if either
|
|
1147
|
+
// side rewrites the English message. There is no cursor left to resume
|
|
1148
|
+
// from — a plain "retryable" resume-with-Last-Event-ID would just 410 again
|
|
1149
|
+
// — so this always needs a fresh turnId, which the outer loop mints via
|
|
1150
|
+
// `forceRestart`.
|
|
1151
|
+
const notResumable = evt.notResumable === true;
|
|
1152
|
+
// Transient upstream failure before any output → let the outer loop retry it
|
|
1153
|
+
// transparently instead of killing the turn (this is the "Overloaded" case).
|
|
1154
|
+
// But never retry once the turn's message has already been delivered in
|
|
1155
|
+
// full: a late error frame (e.g. the backend's billing write failing after
|
|
1156
|
+
// `message_complete`) must not discard a correct answer and re-bill the
|
|
1157
|
+
// model call. Surface it as a notice and finish with what we have.
|
|
1158
|
+
if (haveCompleteMessage()) {
|
|
1159
|
+
clearInterval(heartbeatWatchdog);
|
|
1160
|
+
onEvent({ type: 'error', message });
|
|
1161
|
+
resolve();
|
|
1162
|
+
}
|
|
1163
|
+
else if (notResumable && (!emittedToCaller || allowRestartAfterRender)) {
|
|
1164
|
+
clearInterval(heartbeatWatchdog);
|
|
1165
|
+
stream.destroy?.();
|
|
1166
|
+
reject(Object.assign(tagTransient(new Error(message)), { forceRestart: true }));
|
|
1167
|
+
}
|
|
1168
|
+
else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
|
|
1169
|
+
clearInterval(heartbeatWatchdog);
|
|
1170
|
+
stream.destroy?.();
|
|
1171
|
+
reject(tagTransient(new Error(message)));
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
// Terminal error. The three branches above each stop the watchdog
|
|
1175
|
+
// and tear the socket down; this one used to do neither, so a
|
|
1176
|
+
// fatal SSE error left a 5-second interval running and the
|
|
1177
|
+
// response body open for the lifetime of the process. The
|
|
1178
|
+
// rejection settles the promise either way, which is exactly why
|
|
1179
|
+
// the leak was invisible — nothing downstream ever noticed, it
|
|
1180
|
+
// just accumulated one timer per failed turn across a long session.
|
|
1181
|
+
clearInterval(heartbeatWatchdog);
|
|
1182
|
+
stream.destroy?.();
|
|
1183
|
+
onEvent({ type: 'error', message });
|
|
1184
|
+
reject(new Error(message));
|
|
1185
|
+
}
|
|
1186
|
+
break;
|
|
1164
1187
|
}
|
|
1165
|
-
break;
|
|
1166
1188
|
}
|
|
1167
1189
|
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
}
|
|
1189
|
-
resolve();
|
|
1190
|
-
});
|
|
1191
|
-
// Treat stream destruction from abort as a clean exit, not an error
|
|
1192
|
-
stream.on('error', (err) => {
|
|
1193
|
-
clearInterval(heartbeatWatchdog);
|
|
1194
|
-
if (abortSignal?.aborted || controller.signal.aborted) {
|
|
1195
|
-
resolve();
|
|
1196
|
-
return;
|
|
1197
|
-
}
|
|
1198
|
-
// Socket died AFTER the turn was fully delivered — the only thing still
|
|
1199
|
-
// outstanding was the trailing `done`. Treat as success: rejecting here would
|
|
1200
|
-
// throw away a complete answer and (with restart enabled) re-run and re-bill
|
|
1201
|
-
// the entire turn. This is the widest such window in practice, because the
|
|
1202
|
-
// backend does a DB write between `message_complete` and `done`.
|
|
1203
|
-
if (haveCompleteMessage()) {
|
|
1190
|
+
});
|
|
1191
|
+
stream.on('data', (chunk) => {
|
|
1192
|
+
lastDataAt = Date.now();
|
|
1193
|
+
if (abortSignal?.aborted) {
|
|
1194
|
+
stream.destroy?.();
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
parser.feed(chunk.toString('utf-8'));
|
|
1198
|
+
});
|
|
1199
|
+
stream.on('end', () => {
|
|
1200
|
+
clearInterval(heartbeatWatchdog);
|
|
1201
|
+
// A clean 'end' with ZERO model events means the connection was dropped before
|
|
1202
|
+
// the backend produced anything (deploy restart, proxy reset). Without this,
|
|
1203
|
+
// the fallback below returns an EMPTY assistant message and the agent loop
|
|
1204
|
+
// treats it as a silent no-op — the turn just vanishes. Nothing was emitted,
|
|
1205
|
+
// so a retry cannot duplicate output → tag retryable.
|
|
1206
|
+
if (!sawModelEvent && !completedMessage) {
|
|
1207
|
+
reject(Object.assign(new Error('Connection closed before the model responded. Retrying…'), { retryable: true }));
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1204
1210
|
resolve();
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1211
|
+
});
|
|
1212
|
+
// Treat stream destruction from abort as a clean exit, not an error
|
|
1213
|
+
stream.on('error', (err) => {
|
|
1214
|
+
clearInterval(heartbeatWatchdog);
|
|
1215
|
+
if (abortSignal?.aborted || controller.signal.aborted) {
|
|
1216
|
+
resolve();
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
// Socket died AFTER the turn was fully delivered — the only thing still
|
|
1220
|
+
// outstanding was the trailing `done`. Treat as success: rejecting here would
|
|
1221
|
+
// throw away a complete answer and (with restart enabled) re-run and re-bill
|
|
1222
|
+
// the entire turn. This is the widest such window in practice, because the
|
|
1223
|
+
// backend does a DB write between `message_complete` and `done`.
|
|
1224
|
+
if (haveCompleteMessage()) {
|
|
1225
|
+
resolve();
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
|
|
1229
|
+
// happen whenever the backend restarts mid-deploy or a proxy drops the socket.
|
|
1230
|
+
// They are exactly as transient as an overloaded_error — if nothing has been
|
|
1231
|
+
// emitted to the caller yet, retry the attempt transparently instead of
|
|
1232
|
+
// surfacing "Stream failed: Premature close" and killing the whole turn.
|
|
1233
|
+
reject(tagTransient(err));
|
|
1234
|
+
});
|
|
1213
1235
|
});
|
|
1214
|
-
}
|
|
1236
|
+
}
|
|
1237
|
+
finally {
|
|
1238
|
+
// The one guaranteed cleanup: covers the two parser `resolve()` paths that
|
|
1239
|
+
// clear nothing, plus any future exit path added without remembering to.
|
|
1240
|
+
if (heartbeatWatchdog !== undefined)
|
|
1241
|
+
clearInterval(heartbeatWatchdog);
|
|
1242
|
+
}
|
|
1215
1243
|
if (completedMessage) {
|
|
1216
1244
|
return completedMessage;
|
|
1217
1245
|
}
|