chainlesschain 0.166.136 → 0.166.139

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.
@@ -4,6 +4,7 @@ import { isToolRecoveryPaused } from "./tool-recovery-result.js";
4
4
  import { hostToolOutputIdentity } from "./tool-result-preview.js";
5
5
  import { serializeRetainedContext } from "./retained-context-preview.js";
6
6
  const MAX_FILES = 12;
7
+ const MAX_UNDELIVERED_READS = 6;
7
8
 
8
9
  function keyFor(filePath, args) {
9
10
  return JSON.stringify([
@@ -94,6 +95,44 @@ export class ReadFileLoopGuard {
94
95
  retainContext(context);
95
96
  }
96
97
  }
98
+ for (const entry of this.progress.values()) {
99
+ for (const pending of entry.pendingPages) {
100
+ if (this.isPageVisible(pending.page)) {
101
+ entry.confirmedSpans = mergeSpan(
102
+ entry.confirmedSpans,
103
+ pending.page.readSpan,
104
+ );
105
+ if (pending.page.readSpan.end >= entry.confirmedCursorEnd) {
106
+ entry.confirmedCursorEnd = pending.page.readSpan.end;
107
+ entry.confirmedCursor = {
108
+ lastReadRange: pending.page.range,
109
+ nextRead: pending.page.nextRead || null,
110
+ };
111
+ }
112
+ } else {
113
+ entry.undeliveredReads++;
114
+ if (pending.reread) entry.rereads.delete(pending.reread);
115
+ if (pending.restoring) entry.contextRestoreOffered = false;
116
+ }
117
+ }
118
+ entry.pendingPages = [];
119
+ // Execution reserves ranges synchronously for parallel reads. Only
120
+ // source actually selected for the provider becomes historical coverage.
121
+ entry.spans = entry.confirmedSpans.map((span) => [...span]);
122
+ if (entry.summary) {
123
+ const through =
124
+ entry.confirmedSpans[0]?.[0] === 0 ? entry.confirmedSpans[0][1] : 0;
125
+ entry.summary.readThrough = through;
126
+ entry.summary.reachedEnd = through >= entry.total;
127
+ entry.summary.pendingDelivery = false;
128
+ if (entry.confirmedCursor)
129
+ Object.assign(entry.summary, entry.confirmedCursor);
130
+ else {
131
+ delete entry.summary.lastReadRange;
132
+ entry.summary.nextRead = null;
133
+ }
134
+ }
135
+ }
97
136
  }
98
137
 
99
138
  isPageVisible(page) {
@@ -103,8 +142,29 @@ export class ReadFileLoopGuard {
103
142
  keyFor(visible.path, {}) === keyFor(page.path, {}) &&
104
143
  visible.fileVersion === page.fileVersion &&
105
144
  typeof page.content === "string" &&
106
- page.content.length > 0 &&
107
- visible.content.includes(page.content),
145
+ (page.content.length > 0 || page.readSpan?.total === 0) &&
146
+ ((visible.readSpan &&
147
+ page.readSpan &&
148
+ visible.readSpan.total === page.readSpan.total &&
149
+ visible.readSpan.start <= page.readSpan.start &&
150
+ visible.readSpan.end >= page.readSpan.end &&
151
+ visible.readSpan.end - visible.readSpan.start >=
152
+ visible.content.length &&
153
+ visible.readSpan.end - visible.readSpan.start <=
154
+ visible.content.length + 1 &&
155
+ page.readSpan.end - page.readSpan.start >= page.content.length &&
156
+ page.readSpan.end - page.readSpan.start <=
157
+ page.content.length + 1 &&
158
+ visible.content.slice(
159
+ page.readSpan.start - visible.readSpan.start,
160
+ page.readSpan.start -
161
+ visible.readSpan.start +
162
+ page.content.length,
163
+ ) === page.content) ||
164
+ (!visible.readSpan &&
165
+ !visible.contentTruncated &&
166
+ JSON.stringify(visible.range) === JSON.stringify(page.range) &&
167
+ visible.content === page.content)),
108
168
  ) === true
109
169
  );
110
170
  }
@@ -166,6 +226,11 @@ export class ReadFileLoopGuard {
166
226
  entry = {
167
227
  fileVersion,
168
228
  spans: [],
229
+ confirmedSpans: [],
230
+ pendingPages: [],
231
+ undeliveredReads: 0,
232
+ confirmedCursor: null,
233
+ confirmedCursorEnd: 0,
169
234
  rereads: new Set(),
170
235
  summary: null,
171
236
  excerpts: [],
@@ -184,15 +249,27 @@ export class ReadFileLoopGuard {
184
249
  readProgress: { newContent: false },
185
250
  };
186
251
  }
252
+ if (entry.undeliveredReads >= MAX_UNDELIVERED_READS) {
253
+ return {
254
+ path: filePath,
255
+ fileVersion,
256
+ code: "CC_READ_SOURCE_NOT_DELIVERED",
257
+ error:
258
+ "Repeated file reads did not deliver source content to the model. Report this limitation or use a focused search; do not claim the file was read.",
259
+ contentOmitted: true,
260
+ reachedEnd: false,
261
+ readRecovery: { action: "source-unavailable" },
262
+ };
263
+ }
187
264
  let span = page.readSpan;
265
+ let reread;
266
+ let restoringPage = false;
188
267
  const covered = entry.spans.some(
189
268
  ([start, end]) => start <= span.start && end >= span.end,
190
269
  );
191
- let targeted =
192
- Number(args.offset) > 0 &&
193
- Number(args.limit) > 0 &&
194
- Number(args.limit) <= 200 &&
195
- !page.truncated;
270
+ const explicitRange =
271
+ Number(args.offset) > 0 && Number(args.limit) > 0 && !page.truncated;
272
+ let targeted = explicitRange;
196
273
  const coveredPrefix = entry.spans.some(
197
274
  ([start, end]) => start <= span.start && end > span.start,
198
275
  );
@@ -221,6 +298,7 @@ export class ReadFileLoopGuard {
221
298
  span = page.readSpan;
222
299
  targeted = true;
223
300
  entry.contextRestoreOffered = true;
301
+ restoringPage = true;
224
302
  }
225
303
  }
226
304
  if (covered || (coveredPrefix && !targeted)) {
@@ -240,13 +318,14 @@ export class ReadFileLoopGuard {
240
318
  entry.rereads.size < 3
241
319
  ) {
242
320
  entry.rereads.add(fingerprint);
321
+ reread = fingerprint;
243
322
  page.readRecovery = {
244
323
  action: restoring ? "context-restore" : "targeted-review",
245
324
  };
246
325
  if (restoring) page.readRecovery.fileVersion = fileVersion;
247
326
  } else {
248
327
  const unread = entry.spans[0]?.[0] === 0 ? entry.spans[0][1] : 0;
249
- if (unread < span.total) {
328
+ if (unread < span.total && !explicitRange) {
250
329
  const requestedRange = page.range;
251
330
  page = continuePage(unread);
252
331
  if (page.error || !page.readSpan) return page;
@@ -257,6 +336,24 @@ export class ReadFileLoopGuard {
257
336
  };
258
337
  newContent = true;
259
338
  } else {
339
+ const confirmedEnd =
340
+ entry.confirmedSpans[0]?.[0] === 0 ? entry.confirmedSpans[0][1] : 0;
341
+ if (
342
+ this.visiblePages !== null &&
343
+ !entry.confirmedSpans.some(
344
+ ([start, end]) => start <= span.start && end >= span.end,
345
+ )
346
+ ) {
347
+ return {
348
+ path: filePath,
349
+ fileVersion,
350
+ contentOmitted: true,
351
+ reachedEnd: false,
352
+ readProgress: { newContent: false },
353
+ readRecovery: { action: "pending-delivery" },
354
+ hint: "This range has a read pending in the current tool batch. Historical source coverage is recorded only after content reaches the model.",
355
+ };
356
+ }
260
357
  return {
261
358
  path: filePath,
262
359
  fileVersion,
@@ -264,7 +361,7 @@ export class ReadFileLoopGuard {
264
361
  hashed: args.hashed === true,
265
362
  alreadyRead: true,
266
363
  contentOmitted: true,
267
- reachedEnd: true,
364
+ reachedEnd: confirmedEnd >= span.total,
268
365
  readProgress: { newContent: false },
269
366
  readRecovery: { action: "use-findings" },
270
367
  ...(entry.summary?.outline
@@ -272,17 +369,36 @@ export class ReadFileLoopGuard {
272
369
  : {}),
273
370
  recoveryRead: {
274
371
  path: filePath,
275
- offset: 1,
372
+ offset: explicitRange ? page.range.startLine : 1,
276
373
  limit: 80,
277
374
  ...(args.hashed === true ? { hashed: true } : {}),
278
375
  ...(args.raw === true ? { raw: true } : {}),
279
376
  },
280
- hint: "The unchanged file was read earlier, but historical coverage does not mean its source is visible now. Use any source excerpts actually present in this request. For missing details, use search_files or read_file with a specific offset and limit (at most 80 lines); recoveryRead is a bounded starting range, not permission to restart a full scan. Reading the file is not task completion.",
377
+ hint: "This requested range was read earlier, but historical coverage does not mean its source is visible now. The runtime has not substituted an unrelated file range. Use source actually present in this request; recover missing details with a focused search or the bounded recoveryRead. Reading the file is not task completion.",
281
378
  };
282
379
  }
283
380
  }
284
381
  }
285
382
  entry.spans = mergeSpan(entry.spans, page.readSpan);
383
+ entry.total = page.readSpan.total;
384
+ if (this.visiblePages === null) {
385
+ // Direct tool consumers without provider selection keep synchronous
386
+ // semantics. Agent runs observe the initial request before any reads.
387
+ entry.confirmedSpans = entry.spans.map((span) => [...span]);
388
+ } else {
389
+ entry.pendingPages.push({
390
+ page: {
391
+ path: page.path,
392
+ fileVersion: page.fileVersion,
393
+ content: page.content,
394
+ readSpan: { ...page.readSpan },
395
+ ...(page.range ? { range: { ...page.range } } : {}),
396
+ ...(page.nextRead ? { nextRead: { ...page.nextRead } } : {}),
397
+ },
398
+ reread,
399
+ restoring: restoringPage,
400
+ });
401
+ }
286
402
  entry.lastReadRequest = this.visibleRequest;
287
403
  // Coverage alone cannot restore source code after compaction. Keep bounded
288
404
  // observed pages for every file type, not only large Markdown outlines.
@@ -302,10 +418,12 @@ export class ReadFileLoopGuard {
302
418
  ].slice(-2);
303
419
  }
304
420
  const allRead =
305
- entry.spans[0]?.[0] === 0 && entry.spans[0][1] >= page.readSpan.total;
421
+ entry.confirmedSpans[0]?.[0] === 0 &&
422
+ entry.confirmedSpans[0][1] >= page.readSpan.total;
306
423
  // A read of the tail alone must never imply coverage of earlier sections.
307
424
  const previousEnd = entry.summary?.readThrough || 0;
308
- const updateCursor = page.readSpan.end >= previousEnd;
425
+ const updateCursor =
426
+ this.visiblePages === null && page.readSpan.end >= previousEnd;
309
427
  entry.summary = {
310
428
  ...entry.summary,
311
429
  path: filePath,
@@ -317,8 +435,16 @@ export class ReadFileLoopGuard {
317
435
  }
318
436
  : {}),
319
437
  reachedEnd: allRead,
438
+ ...(this.visiblePages !== null ? { pendingDelivery: true } : {}),
320
439
  ...(page.outline ? { outline: page.outline } : {}),
321
440
  };
441
+ if (updateCursor) {
442
+ entry.confirmedCursorEnd = page.readSpan.end;
443
+ entry.confirmedCursor = {
444
+ lastReadRange: page.range,
445
+ nextRead: page.nextRead || null,
446
+ };
447
+ }
322
448
  this.progress.delete(key);
323
449
  this.progress.set(key, entry);
324
450
  // Discard pathological fragmented coverage instead of inventing coverage.
@@ -336,6 +462,13 @@ export class ReadFileLoopGuard {
336
462
  // as another duplicate would immediately schedule another "one-turn"
337
463
  // pause and prevent read_file from ever becoming available again.
338
464
  if (isToolRecoveryPaused(result)) return;
465
+ if (
466
+ tool === "read_file" &&
467
+ result?.code === "CC_READ_SOURCE_NOT_DELIVERED"
468
+ ) {
469
+ this.repeatedBatches = Math.max(this.repeatedBatches, 6);
470
+ return;
471
+ }
339
472
  if (
340
473
  !this.batch ||
341
474
  result?.error ||
@@ -1,3 +1,5 @@
1
+ import { recoverCompleteSourceRead } from "./source-read-recovery.js";
2
+
1
3
  /** Build a bounded read result with a cursor describing what was actually read. */
2
4
  export function buildReadFilePage(
3
5
  rendered,
@@ -61,7 +63,7 @@ export function buildReadFilePage(
61
63
  const charEnd = truncated
62
64
  ? charStart + count
63
65
  : lines.slice(0, end).reduce((n, line) => n + line.length + 1, 0);
64
- return {
66
+ const page = {
65
67
  path: filePath,
66
68
  ...(fileVersion ? { fileVersion } : {}),
67
69
  ...(offset || limit || column > 1 || truncated
@@ -96,6 +98,7 @@ export function buildReadFilePage(
96
98
  hashed: args.hashed === true,
97
99
  content,
98
100
  };
101
+ return recoverCompleteSourceRead(page, rendered, args, maxChars);
99
102
  }
100
103
 
101
104
  /** A bounded navigation index, not a substitute for reading the cited lines. */
@@ -0,0 +1,89 @@
1
+ import { parse } from "acorn";
2
+ import { canProjectHostToolExcerpt } from "./tool-result-preview.js";
3
+
4
+ /**
5
+ * A cut source container is not safe evidence. Recover its complete top-level
6
+ * statements from the very same read snapshot, with explicit range metadata.
7
+ * This does not exempt source from either projection or provider sanitization.
8
+ */
9
+ export function recoverCompleteSourceRead(page, source, args, maxChars) {
10
+ if (
11
+ typeof page?.content !== "string" ||
12
+ typeof page.fileVersion !== "string" ||
13
+ !/\.(?:[cm]?js)$/iu.test(page.path || "") ||
14
+ args.hashed === true ||
15
+ !page.range ||
16
+ !page.readSpan ||
17
+ source.length > 512 * 1024 ||
18
+ canProjectHostToolExcerpt(page.content, "read_file")
19
+ )
20
+ return page;
21
+
22
+ let program;
23
+ let tokens = 0;
24
+ try {
25
+ program = parse(source, {
26
+ ecmaVersion: "latest",
27
+ sourceType: "module",
28
+ allowReturnOutsideFunction: true,
29
+ onToken() {
30
+ if (++tokens > 32768)
31
+ throw new RangeError("Source recovery token bound");
32
+ },
33
+ });
34
+ } catch {
35
+ // Malformed or oversized source retains the ordinary omission boundary.
36
+ return page;
37
+ }
38
+ const statements = program.body.filter(
39
+ (node) => node.start < page.readSpan.end && node.end > page.readSpan.start,
40
+ );
41
+ if (!statements.length) return page;
42
+ const start = Math.min(
43
+ page.readSpan.start,
44
+ source.lastIndexOf("\n", statements[0].start - 1) + 1,
45
+ );
46
+ const followingNewline = source.indexOf("\n", statements.at(-1).end);
47
+ const end = Math.max(
48
+ page.readSpan.end,
49
+ followingNewline < 0 ? source.length : followingNewline + 1,
50
+ );
51
+ const content = source.slice(start, end);
52
+ if (
53
+ content === page.content ||
54
+ !canProjectHostToolExcerpt(content, "read_file")
55
+ )
56
+ return page;
57
+ const startLine = 1 + (source.slice(0, start).match(/\n/gu) || []).length;
58
+ const endLine =
59
+ startLine +
60
+ (content.match(/\n/gu) || []).length -
61
+ (content.endsWith("\n") ? 1 : 0);
62
+ const recovered = {
63
+ ...page,
64
+ content,
65
+ range: { startLine, endLine, totalLines: page.range.totalLines },
66
+ readSpan: { start, end, total: source.length },
67
+ sourceRecovery: {
68
+ action: "complete-source-container",
69
+ fileVersion: page.fileVersion,
70
+ requestedRange: { ...page.range },
71
+ requestedSpan: { ...page.readSpan },
72
+ note: "The requested excerpt cut a source container. These complete surrounding statements come from the same file version; the range and readSpan describe the content actually returned.",
73
+ },
74
+ };
75
+ delete recovered.truncated;
76
+ delete recovered.size;
77
+ delete recovered.nextRead;
78
+ delete recovered.hint;
79
+ if (end < source.length) {
80
+ recovered.nextRead = { ...args, offset: endLine + 1, column: 1 };
81
+ recovered.hint = "Continue with nextRead only if more source is needed.";
82
+ }
83
+ // The read guard, host telemetry and delivery envelope still add metadata.
84
+ // Reuse the ordinary page's headroom: an almost-full recovery would be
85
+ // projected back to an omission by the final cap before reaching a provider.
86
+ return JSON.stringify(recovered).length <= Math.max(0, maxChars - 2048)
87
+ ? recovered
88
+ : page;
89
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  createEvidenceJsonTextBoundary,
3
3
  AGENT_CODE_TEXT_RESULT_TOOLS,
4
+ EVIDENCE_JSON_POLICY,
4
5
  } from "./evolution/evidence-json-text.js";
5
6
  import { createHash } from "node:crypto";
6
7
 
@@ -32,8 +33,12 @@ const PREVIEW_HINT =
32
33
  "Tool output truncated. Narrow the request: filter/count locally, select fields, search_files, or read_file with offset/limit. Omitted text is not full-output evidence.";
33
34
 
34
35
  /** Check only host-created excerpts; original uncut tool text still reaches ingress. */
35
- function completeExcerpt(text, tool) {
36
- const scan = createEvidenceJsonTextBoundary((message) => new Error(message));
36
+ export function canProjectHostToolExcerpt(text, tool) {
37
+ const scan = createEvidenceJsonTextBoundary((message) => new Error(message), {
38
+ ...EVIDENCE_JSON_POLICY,
39
+ sourceLiteralPolicy: "explicit-source-containers-v1",
40
+ maxSourceTokens: 32768,
41
+ });
37
42
  const visit = (value, layer) => {
38
43
  if (typeof value === "string") return project(value, layer);
39
44
  if (Array.isArray(value)) return value.map((child) => visit(child, layer));
@@ -48,6 +53,7 @@ function completeExcerpt(text, tool) {
48
53
  scan(value, {
49
54
  layer,
50
55
  codeText: AGENT_CODE_TEXT_RESULT_TOOLS.includes(tool),
56
+ sourceText: layer === 0,
51
57
  visit,
52
58
  plain: (plain) => plain,
53
59
  equal: (left, right) => JSON.stringify(left) === JSON.stringify(right),
@@ -233,9 +239,13 @@ export function safeHostToolResultForModel(tool, result) {
233
239
  let safe = result;
234
240
  for (const field of fields) {
235
241
  const text = result[field];
236
- if (typeof text !== "string" || completeExcerpt(text, tool)) continue;
242
+ if (typeof text !== "string" || canProjectHostToolExcerpt(text, tool))
243
+ continue;
237
244
  if (safe === result) safe = { ...result };
238
- safe[field] = omitted(text.length);
245
+ safe[field] =
246
+ tool === "read_file"
247
+ ? `Text omitted (${text.length} chars); this excerpt could not be safely projected. Read a complete surrounding statement or container; expand the range or read the full file within existing size limits when necessary. Omitted content has not been inspected.`
248
+ : omitted(text.length);
239
249
  safe[`${field}Omitted`] = true;
240
250
  safe[`${field}OmittedChars`] = text.length;
241
251
  }
@@ -259,7 +269,10 @@ export function boundedCommandOutput(output, max, tool = "run_code") {
259
269
  output,
260
270
  Math.max(1, Math.floor(max / 2)),
261
271
  );
262
- if (JSON.stringify(preview).length <= max && completeExcerpt(preview, tool))
272
+ if (
273
+ JSON.stringify(preview).length <= max &&
274
+ canProjectHostToolExcerpt(preview, tool)
275
+ )
263
276
  return preview;
264
277
  } else if (!/[{}\[\]"']/u.test(output)) {
265
278
  let low = 0;
@@ -3544,6 +3544,7 @@ async function startAgentReplInWorkspaceOwned(
3544
3544
  });
3545
3545
  if (feature("PROMPT_COMPRESSOR") || contextMemoryCutover.canonical) {
3546
3546
  _compressor = new PromptCompressor({
3547
+ contextMemoryModelWindowTokens: options.contextMemoryModelWindowTokens,
3547
3548
  model,
3548
3549
  provider,
3549
3550
  llmQuery: async (prompt) => {
@@ -9852,6 +9853,7 @@ async function startAgentReplInWorkspaceOwned(
9852
9853
  usageEvents,
9853
9854
  thinking: reasoning,
9854
9855
  } = await agentLoop(messages, {
9856
+ contextMemoryModelWindowTokens: options.contextMemoryModelWindowTokens,
9855
9857
  ...captureSkillRuntimeDependencies(options),
9856
9858
  ...liveOpts,
9857
9859
  waitForOutput: _replOutputFlow.wait,