@nexrall/code-core 1.4.8 → 1.4.9

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.
@@ -71,8 +71,17 @@ export declare function ledgerSummary(ledger: ProgressLedger): string;
71
71
  * serialised body schema-clean while remaining idempotent across repeat calls.
72
72
  *
73
73
  * Returns the number of bytes reclaimed (0 if nothing was prunable).
74
+ *
75
+ * `minReclaimBytes` (default 0): if the TOTAL prunable amount is below this, the
76
+ * function makes NO changes and returns 0. This is a cache-safety gate — pruning
77
+ * even one old block changes the request prefix and invalidates the message-level
78
+ * prompt cache, so a tiny prune would bust the cache (re-write at 1.25×) for
79
+ * almost no size win. Measuring first, then applying only if worthwhile, keeps
80
+ * the "don't bust cache for a trivial gain" contract truly atomic (the old code
81
+ * mutated first and let the caller decide, which had already invalidated the
82
+ * cache by the time the caller declined).
74
83
  */
75
- export declare function pruneOldToolResults(messages: Message[]): number;
84
+ export declare function pruneOldToolResults(messages: Message[], minReclaimBytes?: number): number;
76
85
  /** Rough token estimate for a resumed transcript — no API round-trip needed. */
77
86
  export declare function estimateTokensRough(messages: Message[]): number;
78
87
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAsKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AA8RD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAoDN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA6B5D;AAmBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAmB/D;AAsHD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CA2DlB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CAulBpB"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAsKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AA4TD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAoDN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA6B5D;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,SAAI,GAAG,MAAM,CAgCpF;AAsHD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CAqElB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CAumBpB"}
@@ -416,7 +416,37 @@ const MODEL_CONTEXT_TOKENS = {
416
416
  pro: 1000000,
417
417
  ultra: 1000000,
418
418
  };
419
- const AUTO_COMPACT_THRESHOLD = 0.8; // compact when prompt > 80% of the window
419
+ // ── Compaction thresholds (cost control) ─────────────────────────────────────
420
+ // Two independent triggers, deliberately at DIFFERENT levels:
421
+ //
422
+ // • PRUNE threshold (cheap, lossy-but-structure-preserving, NO model call):
423
+ // fires EARLY. Every turn a large history is resent, cache-read alone
424
+ // (0.10× input) is still billed on the whole prefix — on a 700K-token
425
+ // session that is real money accruing per turn long before the 1M wall.
426
+ // Anthropic's own server-side compaction defaults its trigger to 150K
427
+ // input tokens (docs: compact_20260112 default trigger 150000). We mirror
428
+ // that intent: start shedding already-consumed tool_result bulk at ~35% of
429
+ // a 1M window (~350K tokens) so the per-turn cache-read bill stops growing,
430
+ // WITHOUT paying for a summariser model call and WITHOUT dropping any turn
431
+ // (pruneOldToolResults keeps every tool_use/tool_result pair intact).
432
+ //
433
+ // • SUMMARISE threshold (expensive: a real model call, lossy: drops whole
434
+ // turns): stays LATE, because summarise-of-summarise is the main cause of an
435
+ // agent "forgetting" earlier work. Only when cheap pruning can't keep the
436
+ // prompt under this line do we fall through to summarisation.
437
+ //
438
+ // Both are overridable via env for power users / tests.
439
+ function envFraction(name, fallback) {
440
+ const v = Number(process.env[name]);
441
+ return Number.isFinite(v) && v > 0 && v < 1 ? v : fallback;
442
+ }
443
+ // Prune early (~35% of window), summarise late (~80% of window).
444
+ const AUTO_PRUNE_THRESHOLD = envFraction('NEXRALL_PRUNE_THRESHOLD', 0.35);
445
+ const AUTO_COMPACT_THRESHOLD = envFraction('NEXRALL_COMPACT_THRESHOLD', 0.8); // summarise when prompt > 80% of the window
446
+ // Only bother pruning if it reclaims a meaningful amount — a tiny prune busts
447
+ // the message-level prompt cache (the pruned prefix changes) for little gain,
448
+ // so we require at least this many bytes reclaimed before accepting a prune.
449
+ const PRUNE_MIN_RECLAIM_BYTES = 256 * 1024; // 256 KB
420
450
  const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
421
451
  // Byte-level safety net, independent of the token estimate.
422
452
  //
@@ -675,12 +705,23 @@ const PRUNE_MARKER_TAIL = ' pruned to conserve context. Re-run the tool if you n
675
705
  * serialised body schema-clean while remaining idempotent across repeat calls.
676
706
  *
677
707
  * Returns the number of bytes reclaimed (0 if nothing was prunable).
708
+ *
709
+ * `minReclaimBytes` (default 0): if the TOTAL prunable amount is below this, the
710
+ * function makes NO changes and returns 0. This is a cache-safety gate — pruning
711
+ * even one old block changes the request prefix and invalidates the message-level
712
+ * prompt cache, so a tiny prune would bust the cache (re-write at 1.25×) for
713
+ * almost no size win. Measuring first, then applying only if worthwhile, keeps
714
+ * the "don't bust cache for a trivial gain" contract truly atomic (the old code
715
+ * mutated first and let the caller decide, which had already invalidated the
716
+ * cache by the time the caller declined).
678
717
  */
679
- function pruneOldToolResults(messages) {
718
+ function pruneOldToolResults(messages, minReclaimBytes = 0) {
680
719
  const cutoff = messages.length - PRUNE_KEEP_RECENT;
681
720
  if (cutoff <= 1)
682
721
  return 0;
683
- let reclaimed = 0;
722
+ // Collect prunable blocks + measure the total reclaim WITHOUT mutating yet.
723
+ const targets = [];
724
+ let total = 0;
684
725
  for (let i = 0; i < cutoff; i++) {
685
726
  const m = messages[i];
686
727
  if (!Array.isArray(m.content))
@@ -695,10 +736,19 @@ function pruneOldToolResults(messages) {
695
736
  continue; // already small
696
737
  const head = text.slice(0, PRUNE_STUB_KEEP_CHARS);
697
738
  const omitted = text.length - head.length;
698
- b.content = `${head}${PRUNE_MARKER}${omitted} chars of earlier tool output${PRUNE_MARKER_TAIL}`;
699
- reclaimed += omitted;
739
+ targets.push({ block: b, head, omitted });
740
+ total += omitted;
700
741
  }
701
742
  }
743
+ // Cache-safety gate: not worth busting the prompt cache for a trivial reclaim.
744
+ if (total < minReclaimBytes)
745
+ return 0;
746
+ // Worthwhile — apply the stubs.
747
+ let reclaimed = 0;
748
+ for (const { block, head, omitted } of targets) {
749
+ block.content = `${head}${PRUNE_MARKER}${omitted} chars of earlier tool output${PRUNE_MARKER_TAIL}`;
750
+ reclaimed += omitted;
751
+ }
702
752
  return reclaimed;
703
753
  }
704
754
  /**
@@ -825,13 +875,20 @@ async function compactMessagesForResume(messages, opts) {
825
875
  const contextWindow = MODEL_CONTEXT_TOKENS[opts.model ?? 'turbo'] ?? 1000000;
826
876
  let bodyBytes = estimateBodyBytes(messages);
827
877
  let tokenGuess = estimateTokensRough(messages);
828
- const overThreshold = () => tokenGuess > contextWindow * AUTO_COMPACT_THRESHOLD || bodyBytes > MAX_BODY_BYTES;
829
- if (!overThreshold())
878
+ // Prune fires at the EARLY threshold (mirrors the in-loop guard); summarisation
879
+ // only at the late one. On resume this matters most: a stored session is resent
880
+ // whole on the first turn, so shedding old tool_result bulk up front is exactly
881
+ // what stops that first message being billed at full size.
882
+ const overPruneThreshold = () => tokenGuess > contextWindow * AUTO_PRUNE_THRESHOLD || bodyBytes > MAX_BODY_BYTES;
883
+ const overCompactThreshold = () => tokenGuess > contextWindow * AUTO_COMPACT_THRESHOLD || bodyBytes > MAX_BODY_BYTES;
884
+ if (!overPruneThreshold())
830
885
  return false;
831
886
  let compacted = false;
832
- // Cheap pass first — shrinks old tool_result blocks with no model call.
887
+ // Cheap pass first — shrinks old tool_result blocks with no model call. The
888
+ // reclaim floor is enforced atomically inside pruneOldToolResults (measures
889
+ // first, mutates only if worthwhile), so a declined prune leaves the cache intact.
833
890
  if (messages.length > PRUNE_KEEP_RECENT + 2) {
834
- const reclaimed = pruneOldToolResults(messages);
891
+ const reclaimed = pruneOldToolResults(messages, PRUNE_MIN_RECLAIM_BYTES);
835
892
  if (reclaimed > 0) {
836
893
  bodyBytes = estimateBodyBytes(messages);
837
894
  tokenGuess = estimateTokensRough(messages);
@@ -839,11 +896,13 @@ async function compactMessagesForResume(messages, opts) {
839
896
  opts.onNotice?.(`\n\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of older tool output before resuming this chat.\n`);
840
897
  }
841
898
  }
842
- // If still over threshold, fall through to summarising compaction — same
843
- // mechanism the in-loop guard uses, so this can safely loop (a single
844
- // summarisation pass may still leave a very long session over threshold).
899
+ // If still over the LATE (summarise) threshold, fall through to summarising
900
+ // compaction — same mechanism the in-loop guard uses, so this can safely loop
901
+ // (a single summarisation pass may still leave a very long session over it).
902
+ // A session between the prune and summarise thresholds is left as-is after the
903
+ // cheap prune: no model call needed, prefix already shrunk.
845
904
  let guard = 0;
846
- while (overThreshold() && messages.length > COMPACT_KEEP_MIN + 2 && guard < 5) {
905
+ while (overCompactThreshold() && messages.length > COMPACT_KEEP_MIN + 2 && guard < 5) {
847
906
  guard += 1;
848
907
  // No ledger at resume time — the ledger is per-run, in-memory, and would
849
908
  // have been created fresh anyway since this is a new process/run. The
@@ -938,27 +997,43 @@ async function runAgentLoop(initialMessages, options) {
938
997
  for (; iteration < budget; iteration++) {
939
998
  if (options.abortSignal?.aborted)
940
999
  break;
941
- // Auto-compact: summarise older history before the next stream so we never
942
- // hit the context-window wall — or the backend body-size limit — mid-task.
943
- // Runs at a turn boundary only. Two independent triggers:
944
- // 1. TOKEN pressure the last request's prompt crossed 80% of the window.
945
- // 2. BYTE pressure — the serialised body has grown past MAX_BODY_BYTES.
946
- // The byte trigger is what catches tool-heavy runs whose body balloons past
947
- // the server's 413 limit while the token count still looks fine (and it fires
948
- // even on turn 0 of a resumed large session, where lastPromptTokens is 0).
1000
+ // Auto-compact: keep the resent prompt small BEFORE the next stream so we
1001
+ // never hit the context-window wall — or the backend body-size limit — and,
1002
+ // just as importantly, so we stop paying cache-read on an ever-growing prefix
1003
+ // every single turn on a long task. Runs at a turn boundary only.
1004
+ //
1005
+ // THREE nested triggers, cheapest/earliest first:
1006
+ // 1. PRUNE pressure prompt crossed AUTO_PRUNE_THRESHOLD (~35% of window)
1007
+ // OR the body crossed MAX_BODY_BYTES. Handled by the CHEAP, structure-
1008
+ // preserving prune (no model call, keeps every turn). This is the big
1009
+ // cost win: it fires ~2× earlier than summarisation used to, shedding
1010
+ // already-consumed tool_result bulk (read_file/bash/grep output the
1011
+ // model has long since acted on) so the per-turn cache-read bill stops
1012
+ // compounding well before the old 80% wall.
1013
+ // 2. SUMMARISE pressure — prompt crossed AUTO_COMPACT_THRESHOLD (~80%) or
1014
+ // the body is STILL over MAX_BODY_BYTES after pruning. Only then do we
1015
+ // pay for a summariser call + drop whole turns (kept late on purpose:
1016
+ // summarise-of-summarise is what makes an agent "forget" earlier work).
1017
+ // The byte trigger also fires even on turn 0 of a resumed large session,
1018
+ // where lastPromptTokens is 0.
949
1019
  let bodyBytes = estimateBodyBytes(messages);
1020
+ const prunePressure = lastPromptTokens > contextWindow * AUTO_PRUNE_THRESHOLD;
950
1021
  const tokenPressure = lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD;
951
1022
  let bytePressure = bodyBytes > MAX_BODY_BYTES;
952
- // Byte pressure first tries the CHEAP, structure-preserving prune (no model
953
- // call, keeps every turn). Only if that isn't enough do we fall through to
954
- // summarisation below. This keeps long runs coherent summarise-of-summarise
955
- // is the main cause of an agent "forgetting" what it did earlier.
956
- if (autoCompact && !compacting && bytePressure && messages.length > PRUNE_KEEP_RECENT + 2) {
957
- const reclaimed = pruneOldToolResults(messages);
1023
+ // Cheap prune first on token OR byte pressure. Require a meaningful reclaim
1024
+ // (PRUNE_MIN_RECLAIM_BYTES): a tiny prune would bust the message-level prompt
1025
+ // cache (the pruned prefix changes) for almost no benefit. pruneOldToolResults
1026
+ // is idempotent, so once the old bulk is stubbed this simply no-ops until new
1027
+ // large tool_results age past PRUNE_KEEP_RECENT.
1028
+ if (autoCompact && !compacting && (prunePressure || bytePressure) && messages.length > PRUNE_KEEP_RECENT + 2) {
1029
+ // The gate lives INSIDE pruneOldToolResults now (atomic: it measures the
1030
+ // total first and mutates nothing if it's below the floor), so a declined
1031
+ // prune never invalidates the prompt cache.
1032
+ const reclaimed = pruneOldToolResults(messages, PRUNE_MIN_RECLAIM_BYTES);
958
1033
  if (reclaimed > 0) {
959
1034
  bodyBytes = estimateBodyBytes(messages);
960
1035
  bytePressure = bodyBytes > MAX_BODY_BYTES;
961
- options.onText(`\n\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of older tool output to conserve context.\n`);
1036
+ options.onText(`\n\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of already-processed tool output to keep this chat cheap to continue.\n`);
962
1037
  }
963
1038
  }
964
1039
  if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
@@ -1,5 +1,28 @@
1
1
  import type { Message, SSEEvent, AuthConfig, EnvContext, EditorContext } from '../types';
2
2
  export declare const API_BASE = "https://api.nexrall.com";
3
+ /**
4
+ * Decide the final assistant `content` array to store in history for a completed
5
+ * turn. Pure + exported so it can be unit-tested without a live SSE stream.
6
+ *
7
+ * - `rebuilt`: the client's own reconstruction from streamed parts (text + tool_use,
8
+ * in stream order). This is what we've always stored.
9
+ * - `rawContent`: Anthropic's authoritative content array for the turn (thinking
10
+ * already stripped by the backend), when available.
11
+ *
12
+ * Rule: if the raw content contains ANY block beyond plain text / tool_use — i.e.
13
+ * server-side blocks like `server_tool_use`, `tool_search_tool_result` (with its
14
+ * nested `tool_reference`s) or `web_search_tool_result` — we MUST preserve the raw
15
+ * array verbatim so those blocks round-trip back to the API on the next request.
16
+ * Dropping them breaks the paired server_tool_use/result the API expects AND, for
17
+ * tool search, makes discovered (deferred) tools be forgotten → re-searched every
18
+ * turn. For an ordinary turn (only text/tool_use) we keep the rebuilt array so the
19
+ * common path is byte-identical to before (zero regression).
20
+ */
21
+ export declare function chooseFinalContent<T extends {
22
+ type?: string;
23
+ }>(rebuilt: T[], rawContent: Array<{
24
+ type?: string;
25
+ }> | null | undefined): T[];
3
26
  export interface StreamChatOptions {
4
27
  model: string;
5
28
  env?: EnvContext;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAKrH,eAAO,MAAM,QAAQ,4BAA4B,CAAC;AAiBlD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA0BD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA0alB;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAKrH,eAAO,MAAM,QAAQ,4BAA4B,CAAC;AAIlD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAML;AAeD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA0BD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAgclB;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.API_BASE = void 0;
7
+ exports.chooseFinalContent = chooseFinalContent;
7
8
  exports.streamChat = streamChat;
8
9
  exports.getBalance = getBalance;
9
10
  exports.exchangeVscodeCode = exchangeVscodeCode;
@@ -14,6 +15,30 @@ const index_1 = require("../auth/index");
14
15
  // ─── Constants ────────────────────────────────────────────────────────────────
15
16
  exports.API_BASE = 'https://api.nexrall.com';
16
17
  // ─── Helpers ──────────────────────────────────────────────────────────────────
18
+ /**
19
+ * Decide the final assistant `content` array to store in history for a completed
20
+ * turn. Pure + exported so it can be unit-tested without a live SSE stream.
21
+ *
22
+ * - `rebuilt`: the client's own reconstruction from streamed parts (text + tool_use,
23
+ * in stream order). This is what we've always stored.
24
+ * - `rawContent`: Anthropic's authoritative content array for the turn (thinking
25
+ * already stripped by the backend), when available.
26
+ *
27
+ * Rule: if the raw content contains ANY block beyond plain text / tool_use — i.e.
28
+ * server-side blocks like `server_tool_use`, `tool_search_tool_result` (with its
29
+ * nested `tool_reference`s) or `web_search_tool_result` — we MUST preserve the raw
30
+ * array verbatim so those blocks round-trip back to the API on the next request.
31
+ * Dropping them breaks the paired server_tool_use/result the API expects AND, for
32
+ * tool search, makes discovered (deferred) tools be forgotten → re-searched every
33
+ * turn. For an ordinary turn (only text/tool_use) we keep the rebuilt array so the
34
+ * common path is byte-identical to before (zero regression).
35
+ */
36
+ function chooseFinalContent(rebuilt, rawContent) {
37
+ if (!Array.isArray(rawContent))
38
+ return rebuilt;
39
+ const hasServerSideBlocks = rawContent.some((b) => b && b.type !== 'text' && b.type !== 'tool_use');
40
+ return hasServerSideBlocks ? rawContent : rebuilt;
41
+ }
17
42
  function authHeaders() {
18
43
  const token = (0, index_1.getToken)();
19
44
  if (!token) {
@@ -279,28 +304,47 @@ async function streamChat(messages, options, onEvent) {
279
304
  break;
280
305
  }
281
306
  case 'message_complete': {
282
- // Build the complete assistant message from accumulated parts
307
+ // Anthropic's stop_reason (and the full raw content array) travel inside
308
+ // the nested `message` object the backend forwards verbatim (routes/code.js's
309
+ // `sendEvent(res, {type:'message_complete', message})` — `message` is the raw
310
+ // Anthropic SDK message with thinking already stripped, which always has
311
+ // stop_reason). 'max_tokens' means the model's output was cut off mid-
312
+ // generation — if the last content block is a tool_use, its `input` may be a
313
+ // truncated JSON object that still happened to parse (e.g. a multi_edit whose
314
+ // `edits` array lost its last, still-in-progress element, or came out empty/
315
+ // missing entirely) without any error at all. The agent loop uses this to
316
+ // refuse executing that block blindly instead of silently applying a partial edit.
317
+ const nestedMessage = evt.message;
318
+ const stopReason = typeof nestedMessage?.stop_reason === 'string' ? nestedMessage.stop_reason : null;
319
+ // Build the complete assistant message from accumulated parts (the common
320
+ // path: text + tool_use, in the client's own order).
283
321
  const contentBlocks = [];
284
322
  const fullText = textParts.join('');
285
323
  if (fullText) {
286
324
  contentBlocks.push({ type: 'text', text: fullText });
287
325
  }
288
326
  contentBlocks.push(...toolUseBlocks);
289
- // Anthropic's stop_reason travels inside the nested `message` object the
290
- // backend forwards verbatim (routes/code.js's `sendEvent(res, {type:
291
- // 'message_complete', message})` `message` is the raw Anthropic SDK
292
- // message, which always has stop_reason). 'max_tokens' means the model's
293
- // output was cut off mid-generation if the last content block is a
294
- // tool_use, its `input` may be a truncated JSON object that still happened
295
- // to parse (e.g. a multi_edit whose `edits` array lost its last, still-
296
- // in-progress element, or came out empty/missing entirely) without any
297
- // error at all. The agent loop uses this to refuse executing that block
298
- // blindly instead of silently applying a partial edit.
299
- const nestedMessage = evt.message;
300
- const stopReason = typeof nestedMessage?.stop_reason === 'string' ? nestedMessage.stop_reason : null;
327
+ // TOOL-SEARCH / SERVER-SIDE-BLOCK PRESERVATION.
328
+ // The rebuild above keeps ONLY text + tool_use. That is lossy for any turn
329
+ // that also contains server-side blocks `server_tool_use`,
330
+ // `tool_search_tool_result` (and its nested `tool_reference`s), or a
331
+ // `web_search_tool_result`. Those blocks MUST round-trip back to the API
332
+ // verbatim on the next request or (a) the API rejects the follow-up (it
333
+ // expects the paired server_tool_use/result to be present) and (b) for tool
334
+ // search specifically, the discovered (deferred) tools are FORGOTTEN, forcing
335
+ // Claude to re-search every turn the exact opposite of the token saving.
336
+ //
337
+ // Preserve server-side blocks (tool_search_tool_result / server_tool_use /
338
+ // web_search_tool_result) verbatim when present; otherwise keep the rebuilt
339
+ // array. See chooseFinalContent for the full rationale. Zero regression for
340
+ // an ordinary text/tool_use turn.
341
+ const rawContent = Array.isArray(nestedMessage?.content)
342
+ ? nestedMessage.content
343
+ : null;
344
+ const finalContent = chooseFinalContent(contentBlocks, rawContent);
301
345
  completedMessage = {
302
346
  role: 'assistant',
303
- content: contentBlocks,
347
+ content: finalContent,
304
348
  stopReason,
305
349
  };
306
350
  onEvent({ type: 'message_complete', message: completedMessage });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.4.8",
3
+ "version": "1.4.9",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",