@mindstudio-ai/remy 0.1.329 → 0.1.331

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/headless.js CHANGED
@@ -100,6 +100,87 @@ function resolveConfig(flags) {
100
100
  return { apiKey, baseUrl: baseUrl2, appId };
101
101
  }
102
102
 
103
+ // src/loopGuard.ts
104
+ var PARAGRAPH_REPEAT_THRESHOLD = 6;
105
+ var MIN_PARAGRAPH_CHARS = 80;
106
+ var CHUNK_SIZE = 60;
107
+ var CHUNK_WINDOW = 5e3;
108
+ var CHUNK_REPEAT_THRESHOLD = 10;
109
+ var LONE_HEADER = /^\*{1,3}[^*].*\*{1,3}$/;
110
+ function normalizeParagraph(seg) {
111
+ const trimmed = seg.trim();
112
+ if (!trimmed || LONE_HEADER.test(trimmed)) {
113
+ return null;
114
+ }
115
+ const key = trimmed.toLowerCase().replace(/\s+/g, " ");
116
+ if (key.length < MIN_PARAGRAPH_CHARS) {
117
+ return null;
118
+ }
119
+ return key;
120
+ }
121
+ var RepetitionDetector = class {
122
+ // Paragraph detector: incomplete tail awaiting its closing blank line, plus
123
+ // occurrence counts of completed, normalized paragraphs.
124
+ paragraphBuffer = "";
125
+ paragraphCounts = /* @__PURE__ */ new Map();
126
+ // Rolling-chunk detector: the trailing window of raw fed text.
127
+ window = "";
128
+ fired = false;
129
+ /**
130
+ * Feed the next streamed text fragment. Returns a signal the first time a
131
+ * loop is recognized, then null forever after (the caller aborts on the
132
+ * first signal; further feeds are harmless no-ops).
133
+ */
134
+ feed(text) {
135
+ if (this.fired || !text) {
136
+ return null;
137
+ }
138
+ return this.feedParagraphs(text) ?? this.feedChunks(text);
139
+ }
140
+ feedParagraphs(text) {
141
+ this.paragraphBuffer += text;
142
+ const segments = this.paragraphBuffer.split(/\n\s*\n/);
143
+ this.paragraphBuffer = segments.pop() ?? "";
144
+ for (const seg of segments) {
145
+ const key = normalizeParagraph(seg);
146
+ if (!key) {
147
+ continue;
148
+ }
149
+ const count = (this.paragraphCounts.get(key) ?? 0) + 1;
150
+ this.paragraphCounts.set(key, count);
151
+ if (count >= PARAGRAPH_REPEAT_THRESHOLD) {
152
+ this.fired = true;
153
+ return {
154
+ kind: "paragraph",
155
+ repeats: count,
156
+ sample: seg.trim().slice(0, 160)
157
+ };
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ feedChunks(text) {
163
+ this.window = (this.window + text).slice(-CHUNK_WINDOW);
164
+ if (this.window.length < CHUNK_SIZE * CHUNK_REPEAT_THRESHOLD) {
165
+ return null;
166
+ }
167
+ const tail = this.window.slice(-CHUNK_SIZE);
168
+ if (!tail.trim()) {
169
+ return null;
170
+ }
171
+ const occurrences = this.window.split(tail).length - 1;
172
+ if (occurrences >= CHUNK_REPEAT_THRESHOLD) {
173
+ this.fired = true;
174
+ return {
175
+ kind: "chunk",
176
+ repeats: occurrences,
177
+ sample: tail.replace(/\s+/g, " ").trim().slice(0, 160)
178
+ };
179
+ }
180
+ return null;
181
+ }
182
+ };
183
+
103
184
  // src/api.ts
104
185
  var log2 = createLogger("api");
105
186
  async function* streamChat(params) {
@@ -311,17 +392,51 @@ async function* streamChatWithRetry(params, options) {
311
392
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
312
393
  const buffer = [];
313
394
  let retryableFailure = false;
314
- for await (const event of streamChat(params)) {
315
- if (event.type === "error") {
316
- if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
317
- options?.onRetry?.(attempt, event.error);
318
- retryableFailure = true;
319
- break;
395
+ const detector = options?.detectRepetition ? new RepetitionDetector() : null;
396
+ const streamAbort = new AbortController();
397
+ const onCallerAbort = () => streamAbort.abort();
398
+ if (params.signal) {
399
+ if (params.signal.aborted) {
400
+ streamAbort.abort();
401
+ } else {
402
+ params.signal.addEventListener("abort", onCallerAbort, { once: true });
403
+ }
404
+ }
405
+ try {
406
+ for await (const event of streamChat({
407
+ ...params,
408
+ signal: streamAbort.signal
409
+ })) {
410
+ if (event.type === "error") {
411
+ if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
412
+ options?.onRetry?.(attempt, event.error);
413
+ retryableFailure = true;
414
+ break;
415
+ }
416
+ yield event;
417
+ return;
320
418
  }
321
- yield event;
322
- return;
419
+ if (detector && (event.type === "text" || event.type === "thinking")) {
420
+ const loop = detector.feed(event.text);
421
+ if (loop) {
422
+ log2.warn("Repetition loop detected \u2014 aborting call", {
423
+ requestId: params.requestId,
424
+ kind: loop.kind,
425
+ repeats: loop.repeats
426
+ });
427
+ streamAbort.abort();
428
+ yield {
429
+ type: "error",
430
+ error: "Response stopped: the model was repeating its reasoning without making progress.",
431
+ code: "repetition_loop"
432
+ };
433
+ return;
434
+ }
435
+ }
436
+ buffer.push(event);
323
437
  }
324
- buffer.push(event);
438
+ } finally {
439
+ params.signal?.removeEventListener("abort", onCallerAbort);
325
440
  }
326
441
  if (retryableFailure) {
327
442
  if (params.signal?.aborted) {
@@ -543,6 +658,7 @@ var TEXT_MODELS = {
543
658
  "kimi-k3": { forceCompactAt: 85e4 },
544
659
  "deepseek-v4-flash-0731": { forceCompactAt: 85e4 },
545
660
  "deepseek-v4-pro": { forceCompactAt: 85e4 },
661
+ "deepseek-v4.1-flash": { forceCompactAt: 85e4 },
546
662
  "qwen3.8-2.4t-a95b-deepinfra": { forceCompactAt: 2e5 },
547
663
  // 262K window
548
664
  "qwen3.8-27b-deepinfra": { forceCompactAt: 2e5 },
@@ -3031,6 +3147,13 @@ var queryDatabaseTool = {
3031
3147
  // src/usageLedger.ts
3032
3148
  import fs16 from "fs";
3033
3149
  var LEDGER_FILE = ".logs/usage.ndjson";
3150
+ function thinkingTokensFromBilling(billingEvents, outputTokens) {
3151
+ if (!billingEvents?.length) {
3152
+ return 0;
3153
+ }
3154
+ const billedResponseUnits = billingEvents.filter((e) => e.eventType.endsWith("-response")).reduce((sum, e) => sum + e.numUnits, 0);
3155
+ return Math.max(0, billedResponseUnits - outputTokens);
3156
+ }
3034
3157
  var fd = null;
3035
3158
  function nanoToDollars(nano) {
3036
3159
  return typeof nano === "number" ? nano / 1e9 : void 0;
@@ -8961,6 +9084,13 @@ function getActionChain(startName) {
8961
9084
  }
8962
9085
 
8963
9086
  // src/errors.ts
9087
+ var OVERFLOW_PATTERN = /input token count.*exceeds|exceeds the maximum number of tokens|prompt is too long|context[_ ]length[_ ]exceeded|maximum context length|HTTP 413|payload too large|request entity too large/i;
9088
+ function isContextOverflowError(message, code) {
9089
+ if (code === "context_overflow") {
9090
+ return true;
9091
+ }
9092
+ return OVERFLOW_PATTERN.test(message);
9093
+ }
8964
9094
  var patterns = [
8965
9095
  [
8966
9096
  /Network error/i,
@@ -8980,6 +9110,14 @@ var patterns = [
8980
9110
  "The AI service is temporarily unavailable. Please try again."
8981
9111
  ],
8982
9112
  [/Stream stalled/i, "The connection was interrupted. Please try again."],
9113
+ [
9114
+ // A too-large-context failure that survived the automatic compact-and-retry
9115
+ // (or came from an older server). Kept ahead of the generic fallback so a
9116
+ // raw provider string (e.g. Gemini's INVALID_ARGUMENT JSON) never reaches
9117
+ // the user.
9118
+ OVERFLOW_PATTERN,
9119
+ "This conversation outgrew the model's context window. Remy compacted it and tried again; if you keep seeing this, run /compact or start a new conversation."
9120
+ ],
8983
9121
  [
8984
9122
  /content filter|Output blocked/i,
8985
9123
  "The AI model's content moderation filter blocked this response. These are usually false positives, we apologize for the interruption. Rephrasing your request typically fixes this."
@@ -9284,8 +9422,33 @@ async function runTurn(params) {
9284
9422
  let lastCallInputTokens = 0;
9285
9423
  let lastCallCacheCreation = 0;
9286
9424
  let lastCallCacheRead = 0;
9287
- let abnormalStopRecoveries = 0;
9288
- const MAX_ABNORMAL_STOP_RECOVERIES = 2;
9425
+ let recoveries = 0;
9426
+ const MAX_RECOVERIES = 2;
9427
+ let midTurnCompactions = 0;
9428
+ const MAX_MID_TURN_COMPACTIONS = 2;
9429
+ let overflowRecovered = false;
9430
+ const compactNow = async (reason) => {
9431
+ log15.warn("Compacting mid-turn", {
9432
+ requestId,
9433
+ reason,
9434
+ lastCallInputTokens
9435
+ });
9436
+ onEvent({ type: "status", message: "Compacting the conversation\u2026" });
9437
+ try {
9438
+ await triggerCompaction(state, apiConfig, {
9439
+ blocking: true,
9440
+ requestId,
9441
+ model,
9442
+ origin: "gate"
9443
+ });
9444
+ applyPendingSummaries(state);
9445
+ } catch (err) {
9446
+ log15.error("Mid-turn compaction failed", {
9447
+ requestId,
9448
+ error: err?.message ?? String(err)
9449
+ });
9450
+ }
9451
+ };
9289
9452
  const statusWatcher = isFirstMessage ? { stop() {
9290
9453
  }, pause() {
9291
9454
  }, resume() {
@@ -9432,6 +9595,7 @@ async function runTurn(params) {
9432
9595
  onEvent({ type: "tool_input_delta", id, name, result: content });
9433
9596
  }
9434
9597
  }
9598
+ let streamError = null;
9435
9599
  try {
9436
9600
  for await (const event of streamChatWithRetry(
9437
9601
  {
@@ -9450,9 +9614,13 @@ async function runTurn(params) {
9450
9614
  onRetry: (attempt) => {
9451
9615
  onEvent({
9452
9616
  type: "status",
9453
- message: `Lost connection, retrying (attempt ${attempt + 2} of 3)`
9617
+ message: `Lost connection, retrying (attempt ${attempt + 2} of ${MAX_RETRIES})`
9454
9618
  });
9455
- }
9619
+ },
9620
+ // Watch the streamed reasoning/text for a runaway repetition loop
9621
+ // and abort the in-flight call early (RPT-1225). Surfaces as a
9622
+ // `repetition_loop` error handled below.
9623
+ detectRepetition: true
9456
9624
  }
9457
9625
  )) {
9458
9626
  if (signal?.aborted) {
@@ -9476,7 +9644,7 @@ async function runTurn(params) {
9476
9644
  emitTextBlockSnapshot2(false);
9477
9645
  break;
9478
9646
  }
9479
- case "thinking":
9647
+ case "thinking": {
9480
9648
  if (event.text === "") {
9481
9649
  thinkingBlockStartTimes.push(event.ts);
9482
9650
  if (textBlockOpen) {
@@ -9486,6 +9654,7 @@ async function runTurn(params) {
9486
9654
  }
9487
9655
  onEvent({ type: "thinking", text: event.text });
9488
9656
  break;
9657
+ }
9489
9658
  case "thinking_complete": {
9490
9659
  const startedAt = thinkingBlockStartTimes[thinkingCompleteCount] ?? event.ts;
9491
9660
  contentBlocks.push({
@@ -9583,6 +9752,10 @@ async function runTurn(params) {
9583
9752
  outputTokens: event.usage.outputTokens,
9584
9753
  cacheCreationTokens: event.usage.cacheCreationTokens,
9585
9754
  cacheReadTokens: event.usage.cacheReadTokens,
9755
+ thinkingTokens: thinkingTokensFromBilling(
9756
+ event.billingEvents,
9757
+ event.usage.outputTokens
9758
+ ) || void 0,
9586
9759
  cost: nanoToDollars(event.cost),
9587
9760
  billingEvents: event.billingEvents,
9588
9761
  durationMs: Date.now() - iterStart,
@@ -9592,13 +9765,11 @@ async function runTurn(params) {
9592
9765
  });
9593
9766
  break;
9594
9767
  case "error":
9595
- statusWatcher.stop();
9596
- onEvent({
9597
- type: "error",
9598
- error: friendlyError(event.error),
9599
- ...event.code ? { code: event.code } : {}
9600
- });
9601
- return;
9768
+ streamError = { error: event.error, code: event.code };
9769
+ break;
9770
+ }
9771
+ if (streamError) {
9772
+ break;
9602
9773
  }
9603
9774
  }
9604
9775
  } catch (err) {
@@ -9643,6 +9814,51 @@ async function runTurn(params) {
9643
9814
  saveSession(state);
9644
9815
  return;
9645
9816
  }
9817
+ if (streamError) {
9818
+ const { error, code } = streamError;
9819
+ if (code === "repetition_loop") {
9820
+ if (recoveries < MAX_RECOVERIES && !signal?.aborted) {
9821
+ recoveries++;
9822
+ log15.warn("Repetition loop \u2014 nudging model to continue", {
9823
+ requestId,
9824
+ attempt: recoveries
9825
+ });
9826
+ const nudge = "Your previous response was stopped because it kept repeating the same reasoning without making progress. Do not deliberate further \u2014 take the next concrete action now: make the tool call or give the answer.";
9827
+ state.messages.push({
9828
+ role: "user",
9829
+ content: nudge,
9830
+ hidden: true
9831
+ });
9832
+ onEvent({ type: "user_message", text: nudge, hidden: true });
9833
+ continue;
9834
+ }
9835
+ statusWatcher.stop();
9836
+ saveSession(state);
9837
+ log15.warn("Repetition loop over recovery cap \u2014 ending turn", {
9838
+ requestId
9839
+ });
9840
+ onEvent({
9841
+ type: "error",
9842
+ error: "The model kept repeating itself without making progress, so Remy stopped the turn to avoid runaway cost. Try again, or rephrase your request.",
9843
+ lastCallInputTokens
9844
+ });
9845
+ return;
9846
+ }
9847
+ if (isContextOverflowError(error, code) && !overflowRecovered && !signal?.aborted) {
9848
+ overflowRecovered = true;
9849
+ await compactNow("context overflow");
9850
+ continue;
9851
+ }
9852
+ statusWatcher.stop();
9853
+ saveSession(state);
9854
+ onEvent({
9855
+ type: "error",
9856
+ error: friendlyError(error),
9857
+ ...code ? { code } : {},
9858
+ lastCallInputTokens
9859
+ });
9860
+ return;
9861
+ }
9646
9862
  if (contentBlocks.length > 0) {
9647
9863
  state.messages.push({
9648
9864
  role: "assistant",
@@ -9662,14 +9878,14 @@ async function runTurn(params) {
9662
9878
  });
9663
9879
  }
9664
9880
  const toolCalls = getToolCalls(contentBlocks);
9665
- if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && abnormalStopRecoveries < MAX_ABNORMAL_STOP_RECOVERIES && !signal?.aborted) {
9666
- abnormalStopRecoveries++;
9881
+ if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && recoveries < MAX_RECOVERIES && !signal?.aborted) {
9882
+ recoveries++;
9667
9883
  log15.warn("Abnormal stop \u2014 nudging model to continue", {
9668
9884
  requestId,
9669
9885
  stopReason,
9670
- attempt: abnormalStopRecoveries
9886
+ attempt: recoveries
9671
9887
  });
9672
- const nudge = "Your previous response was cut off \u2014 it degenerated into repeated text or hit the output limit, and the repeated portion was removed. Reassess where you are in the task and continue from where you left off. Prefer a tool call over restating what you were about to do.";
9888
+ const nudge = "Your previous response was cut off before you finished \u2014 it hit the output limit (often from over-long reasoning) or degenerated into repeated text, and the unusable part was removed. Stop deliberating, reassess where you are, and continue with a concrete tool call rather than restating your plan.";
9673
9889
  state.messages.push({ role: "user", content: nudge, hidden: true });
9674
9890
  onEvent({ type: "user_message", text: nudge, hidden: true });
9675
9891
  continue;
@@ -9862,6 +10078,11 @@ async function runTurn(params) {
9862
10078
  isToolError: r.isError
9863
10079
  });
9864
10080
  }
10081
+ const { forceCompactAt } = getContextLimits(parentModel);
10082
+ if (lastCallInputTokens > forceCompactAt && midTurnCompactions < MAX_MID_TURN_COMPACTIONS && !signal?.aborted) {
10083
+ midTurnCompactions++;
10084
+ await compactNow(`context ${lastCallInputTokens} > ${forceCompactAt}`);
10085
+ }
9865
10086
  if (takeSteering && !signal?.aborted) {
9866
10087
  const injected = (await takeSteering()).filter(
9867
10088
  (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
@@ -10911,6 +11132,10 @@ var HeadlessSession = class {
10911
11132
  );
10912
11133
  return;
10913
11134
  case "error":
11135
+ if (typeof e.lastCallInputTokens === "number") {
11136
+ this.sessionStats.lastContextSize = e.lastCallInputTokens;
11137
+ this.persistStats();
11138
+ }
10914
11139
  this.emit(
10915
11140
  "error",
10916
11141
  { error: e.error, ...e.code ? { code: e.code } : {} },
package/dist/index.js CHANGED
@@ -84,6 +84,93 @@ var init_logger = __esm({
84
84
  }
85
85
  });
86
86
 
87
+ // src/loopGuard.ts
88
+ function normalizeParagraph(seg) {
89
+ const trimmed = seg.trim();
90
+ if (!trimmed || LONE_HEADER.test(trimmed)) {
91
+ return null;
92
+ }
93
+ const key = trimmed.toLowerCase().replace(/\s+/g, " ");
94
+ if (key.length < MIN_PARAGRAPH_CHARS) {
95
+ return null;
96
+ }
97
+ return key;
98
+ }
99
+ var PARAGRAPH_REPEAT_THRESHOLD, MIN_PARAGRAPH_CHARS, CHUNK_SIZE, CHUNK_WINDOW, CHUNK_REPEAT_THRESHOLD, LONE_HEADER, RepetitionDetector;
100
+ var init_loopGuard = __esm({
101
+ "src/loopGuard.ts"() {
102
+ "use strict";
103
+ PARAGRAPH_REPEAT_THRESHOLD = 6;
104
+ MIN_PARAGRAPH_CHARS = 80;
105
+ CHUNK_SIZE = 60;
106
+ CHUNK_WINDOW = 5e3;
107
+ CHUNK_REPEAT_THRESHOLD = 10;
108
+ LONE_HEADER = /^\*{1,3}[^*].*\*{1,3}$/;
109
+ RepetitionDetector = class {
110
+ // Paragraph detector: incomplete tail awaiting its closing blank line, plus
111
+ // occurrence counts of completed, normalized paragraphs.
112
+ paragraphBuffer = "";
113
+ paragraphCounts = /* @__PURE__ */ new Map();
114
+ // Rolling-chunk detector: the trailing window of raw fed text.
115
+ window = "";
116
+ fired = false;
117
+ /**
118
+ * Feed the next streamed text fragment. Returns a signal the first time a
119
+ * loop is recognized, then null forever after (the caller aborts on the
120
+ * first signal; further feeds are harmless no-ops).
121
+ */
122
+ feed(text) {
123
+ if (this.fired || !text) {
124
+ return null;
125
+ }
126
+ return this.feedParagraphs(text) ?? this.feedChunks(text);
127
+ }
128
+ feedParagraphs(text) {
129
+ this.paragraphBuffer += text;
130
+ const segments = this.paragraphBuffer.split(/\n\s*\n/);
131
+ this.paragraphBuffer = segments.pop() ?? "";
132
+ for (const seg of segments) {
133
+ const key = normalizeParagraph(seg);
134
+ if (!key) {
135
+ continue;
136
+ }
137
+ const count = (this.paragraphCounts.get(key) ?? 0) + 1;
138
+ this.paragraphCounts.set(key, count);
139
+ if (count >= PARAGRAPH_REPEAT_THRESHOLD) {
140
+ this.fired = true;
141
+ return {
142
+ kind: "paragraph",
143
+ repeats: count,
144
+ sample: seg.trim().slice(0, 160)
145
+ };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+ feedChunks(text) {
151
+ this.window = (this.window + text).slice(-CHUNK_WINDOW);
152
+ if (this.window.length < CHUNK_SIZE * CHUNK_REPEAT_THRESHOLD) {
153
+ return null;
154
+ }
155
+ const tail = this.window.slice(-CHUNK_SIZE);
156
+ if (!tail.trim()) {
157
+ return null;
158
+ }
159
+ const occurrences = this.window.split(tail).length - 1;
160
+ if (occurrences >= CHUNK_REPEAT_THRESHOLD) {
161
+ this.fired = true;
162
+ return {
163
+ kind: "chunk",
164
+ repeats: occurrences,
165
+ sample: tail.replace(/\s+/g, " ").trim().slice(0, 160)
166
+ };
167
+ }
168
+ return null;
169
+ }
170
+ };
171
+ }
172
+ });
173
+
87
174
  // src/api.ts
88
175
  async function* streamChat(params) {
89
176
  const { baseUrl: baseUrl2, apiKey, signal, requestId, model, ...rest } = params;
@@ -286,17 +373,51 @@ async function* streamChatWithRetry(params, options) {
286
373
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
287
374
  const buffer = [];
288
375
  let retryableFailure = false;
289
- for await (const event of streamChat(params)) {
290
- if (event.type === "error") {
291
- if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
292
- options?.onRetry?.(attempt, event.error);
293
- retryableFailure = true;
294
- break;
376
+ const detector = options?.detectRepetition ? new RepetitionDetector() : null;
377
+ const streamAbort = new AbortController();
378
+ const onCallerAbort = () => streamAbort.abort();
379
+ if (params.signal) {
380
+ if (params.signal.aborted) {
381
+ streamAbort.abort();
382
+ } else {
383
+ params.signal.addEventListener("abort", onCallerAbort, { once: true });
384
+ }
385
+ }
386
+ try {
387
+ for await (const event of streamChat({
388
+ ...params,
389
+ signal: streamAbort.signal
390
+ })) {
391
+ if (event.type === "error") {
392
+ if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
393
+ options?.onRetry?.(attempt, event.error);
394
+ retryableFailure = true;
395
+ break;
396
+ }
397
+ yield event;
398
+ return;
295
399
  }
296
- yield event;
297
- return;
400
+ if (detector && (event.type === "text" || event.type === "thinking")) {
401
+ const loop = detector.feed(event.text);
402
+ if (loop) {
403
+ log.warn("Repetition loop detected \u2014 aborting call", {
404
+ requestId: params.requestId,
405
+ kind: loop.kind,
406
+ repeats: loop.repeats
407
+ });
408
+ streamAbort.abort();
409
+ yield {
410
+ type: "error",
411
+ error: "Response stopped: the model was repeating its reasoning without making progress.",
412
+ code: "repetition_loop"
413
+ };
414
+ return;
415
+ }
416
+ }
417
+ buffer.push(event);
298
418
  }
299
- buffer.push(event);
419
+ } finally {
420
+ params.signal?.removeEventListener("abort", onCallerAbort);
300
421
  }
301
422
  if (retryableFailure) {
302
423
  if (params.signal?.aborted) {
@@ -375,6 +496,7 @@ var init_api = __esm({
375
496
  "src/api.ts"() {
376
497
  "use strict";
377
498
  init_logger();
499
+ init_loopGuard();
378
500
  log = createLogger("api");
379
501
  MAX_RETRIES = 5;
380
502
  INITIAL_BACKOFF_MS = 1e3;
@@ -1543,6 +1665,13 @@ var init_sentinel = __esm({
1543
1665
 
1544
1666
  // src/usageLedger.ts
1545
1667
  import fs9 from "fs";
1668
+ function thinkingTokensFromBilling(billingEvents, outputTokens) {
1669
+ if (!billingEvents?.length) {
1670
+ return 0;
1671
+ }
1672
+ const billedResponseUnits = billingEvents.filter((e) => e.eventType.endsWith("-response")).reduce((sum, e) => sum + e.numUnits, 0);
1673
+ return Math.max(0, billedResponseUnits - outputTokens);
1674
+ }
1546
1675
  function nanoToDollars(nano) {
1547
1676
  return typeof nano === "number" ? nano / 1e9 : void 0;
1548
1677
  }
@@ -2191,6 +2320,7 @@ var init_surfaces = __esm({
2191
2320
  "kimi-k3": { forceCompactAt: 85e4 },
2192
2321
  "deepseek-v4-flash-0731": { forceCompactAt: 85e4 },
2193
2322
  "deepseek-v4-pro": { forceCompactAt: 85e4 },
2323
+ "deepseek-v4.1-flash": { forceCompactAt: 85e4 },
2194
2324
  "qwen3.8-2.4t-a95b-deepinfra": { forceCompactAt: 2e5 },
2195
2325
  // 262K window
2196
2326
  "qwen3.8-27b-deepinfra": { forceCompactAt: 2e5 },
@@ -9311,6 +9441,12 @@ var init_resolve = __esm({
9311
9441
  });
9312
9442
 
9313
9443
  // src/errors.ts
9444
+ function isContextOverflowError(message, code) {
9445
+ if (code === "context_overflow") {
9446
+ return true;
9447
+ }
9448
+ return OVERFLOW_PATTERN.test(message);
9449
+ }
9314
9450
  function friendlyError(raw) {
9315
9451
  for (const [pattern, message] of patterns) {
9316
9452
  if (pattern.test(raw)) {
@@ -9319,10 +9455,11 @@ function friendlyError(raw) {
9319
9455
  }
9320
9456
  return `Something went wrong: ${raw}`;
9321
9457
  }
9322
- var patterns;
9458
+ var OVERFLOW_PATTERN, patterns;
9323
9459
  var init_errors = __esm({
9324
9460
  "src/errors.ts"() {
9325
9461
  "use strict";
9462
+ OVERFLOW_PATTERN = /input token count.*exceeds|exceeds the maximum number of tokens|prompt is too long|context[_ ]length[_ ]exceeded|maximum context length|HTTP 413|payload too large|request entity too large/i;
9326
9463
  patterns = [
9327
9464
  [
9328
9465
  /Network error/i,
@@ -9342,6 +9479,14 @@ var init_errors = __esm({
9342
9479
  "The AI service is temporarily unavailable. Please try again."
9343
9480
  ],
9344
9481
  [/Stream stalled/i, "The connection was interrupted. Please try again."],
9482
+ [
9483
+ // A too-large-context failure that survived the automatic compact-and-retry
9484
+ // (or came from an older server). Kept ahead of the generic fallback so a
9485
+ // raw provider string (e.g. Gemini's INVALID_ARGUMENT JSON) never reaches
9486
+ // the user.
9487
+ OVERFLOW_PATTERN,
9488
+ "This conversation outgrew the model's context window. Remy compacted it and tried again; if you keep seeing this, run /compact or start a new conversation."
9489
+ ],
9345
9490
  [
9346
9491
  /content filter|Output blocked/i,
9347
9492
  "The AI model's content moderation filter blocked this response. These are usually false positives, we apologize for the interruption. Rephrasing your request typically fixes this."
@@ -9987,8 +10132,33 @@ async function runTurn(params) {
9987
10132
  let lastCallInputTokens = 0;
9988
10133
  let lastCallCacheCreation = 0;
9989
10134
  let lastCallCacheRead = 0;
9990
- let abnormalStopRecoveries = 0;
9991
- const MAX_ABNORMAL_STOP_RECOVERIES = 2;
10135
+ let recoveries = 0;
10136
+ const MAX_RECOVERIES = 2;
10137
+ let midTurnCompactions = 0;
10138
+ const MAX_MID_TURN_COMPACTIONS = 2;
10139
+ let overflowRecovered = false;
10140
+ const compactNow = async (reason) => {
10141
+ log14.warn("Compacting mid-turn", {
10142
+ requestId,
10143
+ reason,
10144
+ lastCallInputTokens
10145
+ });
10146
+ onEvent({ type: "status", message: "Compacting the conversation\u2026" });
10147
+ try {
10148
+ await triggerCompaction(state, apiConfig, {
10149
+ blocking: true,
10150
+ requestId,
10151
+ model,
10152
+ origin: "gate"
10153
+ });
10154
+ applyPendingSummaries(state);
10155
+ } catch (err) {
10156
+ log14.error("Mid-turn compaction failed", {
10157
+ requestId,
10158
+ error: err?.message ?? String(err)
10159
+ });
10160
+ }
10161
+ };
9992
10162
  const statusWatcher = isFirstMessage ? { stop() {
9993
10163
  }, pause() {
9994
10164
  }, resume() {
@@ -10135,6 +10305,7 @@ async function runTurn(params) {
10135
10305
  onEvent({ type: "tool_input_delta", id, name, result: content });
10136
10306
  }
10137
10307
  }
10308
+ let streamError = null;
10138
10309
  try {
10139
10310
  for await (const event of streamChatWithRetry(
10140
10311
  {
@@ -10153,9 +10324,13 @@ async function runTurn(params) {
10153
10324
  onRetry: (attempt) => {
10154
10325
  onEvent({
10155
10326
  type: "status",
10156
- message: `Lost connection, retrying (attempt ${attempt + 2} of 3)`
10327
+ message: `Lost connection, retrying (attempt ${attempt + 2} of ${MAX_RETRIES})`
10157
10328
  });
10158
- }
10329
+ },
10330
+ // Watch the streamed reasoning/text for a runaway repetition loop
10331
+ // and abort the in-flight call early (RPT-1225). Surfaces as a
10332
+ // `repetition_loop` error handled below.
10333
+ detectRepetition: true
10159
10334
  }
10160
10335
  )) {
10161
10336
  if (signal?.aborted) {
@@ -10179,7 +10354,7 @@ async function runTurn(params) {
10179
10354
  emitTextBlockSnapshot2(false);
10180
10355
  break;
10181
10356
  }
10182
- case "thinking":
10357
+ case "thinking": {
10183
10358
  if (event.text === "") {
10184
10359
  thinkingBlockStartTimes.push(event.ts);
10185
10360
  if (textBlockOpen) {
@@ -10189,6 +10364,7 @@ async function runTurn(params) {
10189
10364
  }
10190
10365
  onEvent({ type: "thinking", text: event.text });
10191
10366
  break;
10367
+ }
10192
10368
  case "thinking_complete": {
10193
10369
  const startedAt = thinkingBlockStartTimes[thinkingCompleteCount] ?? event.ts;
10194
10370
  contentBlocks.push({
@@ -10286,6 +10462,10 @@ async function runTurn(params) {
10286
10462
  outputTokens: event.usage.outputTokens,
10287
10463
  cacheCreationTokens: event.usage.cacheCreationTokens,
10288
10464
  cacheReadTokens: event.usage.cacheReadTokens,
10465
+ thinkingTokens: thinkingTokensFromBilling(
10466
+ event.billingEvents,
10467
+ event.usage.outputTokens
10468
+ ) || void 0,
10289
10469
  cost: nanoToDollars(event.cost),
10290
10470
  billingEvents: event.billingEvents,
10291
10471
  durationMs: Date.now() - iterStart,
@@ -10295,13 +10475,11 @@ async function runTurn(params) {
10295
10475
  });
10296
10476
  break;
10297
10477
  case "error":
10298
- statusWatcher.stop();
10299
- onEvent({
10300
- type: "error",
10301
- error: friendlyError(event.error),
10302
- ...event.code ? { code: event.code } : {}
10303
- });
10304
- return;
10478
+ streamError = { error: event.error, code: event.code };
10479
+ break;
10480
+ }
10481
+ if (streamError) {
10482
+ break;
10305
10483
  }
10306
10484
  }
10307
10485
  } catch (err) {
@@ -10346,6 +10524,51 @@ async function runTurn(params) {
10346
10524
  saveSession(state);
10347
10525
  return;
10348
10526
  }
10527
+ if (streamError) {
10528
+ const { error, code } = streamError;
10529
+ if (code === "repetition_loop") {
10530
+ if (recoveries < MAX_RECOVERIES && !signal?.aborted) {
10531
+ recoveries++;
10532
+ log14.warn("Repetition loop \u2014 nudging model to continue", {
10533
+ requestId,
10534
+ attempt: recoveries
10535
+ });
10536
+ const nudge = "Your previous response was stopped because it kept repeating the same reasoning without making progress. Do not deliberate further \u2014 take the next concrete action now: make the tool call or give the answer.";
10537
+ state.messages.push({
10538
+ role: "user",
10539
+ content: nudge,
10540
+ hidden: true
10541
+ });
10542
+ onEvent({ type: "user_message", text: nudge, hidden: true });
10543
+ continue;
10544
+ }
10545
+ statusWatcher.stop();
10546
+ saveSession(state);
10547
+ log14.warn("Repetition loop over recovery cap \u2014 ending turn", {
10548
+ requestId
10549
+ });
10550
+ onEvent({
10551
+ type: "error",
10552
+ error: "The model kept repeating itself without making progress, so Remy stopped the turn to avoid runaway cost. Try again, or rephrase your request.",
10553
+ lastCallInputTokens
10554
+ });
10555
+ return;
10556
+ }
10557
+ if (isContextOverflowError(error, code) && !overflowRecovered && !signal?.aborted) {
10558
+ overflowRecovered = true;
10559
+ await compactNow("context overflow");
10560
+ continue;
10561
+ }
10562
+ statusWatcher.stop();
10563
+ saveSession(state);
10564
+ onEvent({
10565
+ type: "error",
10566
+ error: friendlyError(error),
10567
+ ...code ? { code } : {},
10568
+ lastCallInputTokens
10569
+ });
10570
+ return;
10571
+ }
10349
10572
  if (contentBlocks.length > 0) {
10350
10573
  state.messages.push({
10351
10574
  role: "assistant",
@@ -10365,14 +10588,14 @@ async function runTurn(params) {
10365
10588
  });
10366
10589
  }
10367
10590
  const toolCalls = getToolCalls(contentBlocks);
10368
- if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && abnormalStopRecoveries < MAX_ABNORMAL_STOP_RECOVERIES && !signal?.aborted) {
10369
- abnormalStopRecoveries++;
10591
+ if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && recoveries < MAX_RECOVERIES && !signal?.aborted) {
10592
+ recoveries++;
10370
10593
  log14.warn("Abnormal stop \u2014 nudging model to continue", {
10371
10594
  requestId,
10372
10595
  stopReason,
10373
- attempt: abnormalStopRecoveries
10596
+ attempt: recoveries
10374
10597
  });
10375
- const nudge = "Your previous response was cut off \u2014 it degenerated into repeated text or hit the output limit, and the repeated portion was removed. Reassess where you are in the task and continue from where you left off. Prefer a tool call over restating what you were about to do.";
10598
+ const nudge = "Your previous response was cut off before you finished \u2014 it hit the output limit (often from over-long reasoning) or degenerated into repeated text, and the unusable part was removed. Stop deliberating, reassess where you are, and continue with a concrete tool call rather than restating your plan.";
10376
10599
  state.messages.push({ role: "user", content: nudge, hidden: true });
10377
10600
  onEvent({ type: "user_message", text: nudge, hidden: true });
10378
10601
  continue;
@@ -10565,6 +10788,11 @@ async function runTurn(params) {
10565
10788
  isToolError: r.isError
10566
10789
  });
10567
10790
  }
10791
+ const { forceCompactAt } = getContextLimits(parentModel);
10792
+ if (lastCallInputTokens > forceCompactAt && midTurnCompactions < MAX_MID_TURN_COMPACTIONS && !signal?.aborted) {
10793
+ midTurnCompactions++;
10794
+ await compactNow(`context ${lastCallInputTokens} > ${forceCompactAt}`);
10795
+ }
10568
10796
  if (takeSteering && !signal?.aborted) {
10569
10797
  const injected = (await takeSteering()).filter(
10570
10798
  (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
@@ -10606,6 +10834,7 @@ var init_agent = __esm({
10606
10834
  init_sentinel();
10607
10835
  init_trigger2();
10608
10836
  init_surfaces();
10837
+ init_trigger();
10609
10838
  init_toolRegistry();
10610
10839
  init_historyLimits();
10611
10840
  log14 = createLogger("agent");
@@ -11958,6 +12187,10 @@ var init_headless = __esm({
11958
12187
  );
11959
12188
  return;
11960
12189
  case "error":
12190
+ if (typeof e.lastCallInputTokens === "number") {
12191
+ this.sessionStats.lastContextSize = e.lastCallInputTokens;
12192
+ this.persistStats();
12193
+ }
11961
12194
  this.emit(
11962
12195
  "error",
11963
12196
  { error: e.error, ...e.code ? { code: e.code } : {} },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.329",
3
+ "version": "0.1.331",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",