opencode-harness-kit 0.4.5 → 0.5.0

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/eval/prepare.mjs CHANGED
@@ -29,14 +29,23 @@ const flag = (...n) => {
29
29
  return i >= 0 ? args[i + 1] : undefined
30
30
  }
31
31
 
32
- const repo = flag("--repo")
32
+ let repo = flag("--repo")
33
33
  const ref = flag("--ref") ?? "HEAD"
34
+ // A PUBLIC repository, cloned at a pinned commit.
35
+ //
36
+ // The subject of a benchmark that nobody can fetch is a result nobody can
37
+ // check. The pilot that used a private repository had that problem, and the
38
+ // fix is one command: give the suite a URL and a SHA, and the harness does
39
+ // the clone.
40
+ const cloneUrl = flag("--clone")
34
41
  const from = flag("--from")
35
42
  const wantJson = args.includes("--json")
36
43
  const name = flag("--name")
37
44
 
38
- if (!repo && !from) {
39
- console.error("usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --from <path>) [--name <n>] [--json]")
45
+ if (!repo && !from && !cloneUrl) {
46
+ console.error(
47
+ "usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --clone <url> [--ref <sha>] | --from <path>) [--name <n>] [--json]",
48
+ )
40
49
  process.exit(1)
41
50
  }
42
51
  if (repo && from) {
@@ -110,7 +119,7 @@ function safeLabel(raw) {
110
119
  return raw
111
120
  }
112
121
 
113
- const requested = name ?? (from ? basename(from) : `${basename(repo ?? "repo")}-${ref}`)
122
+ const requested = name ?? (from ? basename(from) : `${basename(repo ?? cloneUrl ?? "repo")}-${ref}`)
114
123
  const label = safeLabel(requested)
115
124
  if (!label) {
116
125
  console.error(`--name must be a single directory name, not a path: ${requested}`)
@@ -130,6 +139,27 @@ mkdirSync(out, { recursive: true })
130
139
 
131
140
  let source
132
141
  let pinned
142
+ if (cloneUrl) {
143
+ // A shallow fetch of the one commit. A full clone of a public repository
144
+ // is a lot of megabytes to throw away, and the benchmark needs one tree.
145
+ const cloneDir = join(scratch, `.clone-${label}`)
146
+ rmSync(cloneDir, { recursive: true, force: true })
147
+ mkdirSync(cloneDir, { recursive: true })
148
+ const git = (args) =>
149
+ execFileSync("git", ["-C", cloneDir, ...args], { encoding: "utf8" })
150
+ try {
151
+ git(["init", "--quiet"])
152
+ git(["remote", "add", "origin", cloneUrl])
153
+ git(["fetch", "--depth", "1", "--quiet", "origin", ref])
154
+ git(["checkout", "--quiet", "FETCH_HEAD"])
155
+ } catch (err) {
156
+ console.error(
157
+ `cannot fetch ${ref} from ${cloneUrl}: ${err instanceof Error ? err.message : String(err)}`,
158
+ )
159
+ process.exit(1)
160
+ }
161
+ repo = cloneDir
162
+ }
133
163
  if (from) {
134
164
  if (!existsSync(from)) {
135
165
  console.error(`--from path not found: ${from}`)
@@ -0,0 +1,55 @@
1
+ {
2
+ "$comment": [
3
+ "The long task: one change, many files, and a question about the code.",
4
+ "",
5
+ "The A/B in tasks-self.json is made of short tasks - a prompt of two or",
6
+ "three hundred characters, answered in one or two reads. On those the kit",
7
+ "is a cost, and the measurement says so: over five times the input tokens",
8
+ "and a tie on correctness.",
9
+ "",
10
+ "That is the wrong task for the kit's central claim. The claim is that",
11
+ "the pruner pays for itself on a LONG session, and nothing in the short",
12
+ "suite can show that: a short session never grows the history far enough",
13
+ "for pruning to matter.",
14
+ "",
15
+ "So this suite has one task, and it is long. It touches twelve files, so",
16
+ "the agent has to read twelve of them, and it has to answer a question",
17
+ "about the code that CONSUMES the thing it changed - so it has to find",
18
+ "that code and read it too. The conversation is long because the work is",
19
+ "long, which is the only way the measurement can be honest.",
20
+ "",
21
+ "It is graded on BOTH halves: the change is compared against the pristine",
22
+ "subject file by file, and the answer is graded by citation. An agent",
23
+ "that made the change but did not find the code has not finished.",
24
+ "",
25
+ "The change is one the kit's own history actually contains: a last-resort",
26
+ "fallback added to every profile."
27
+ ],
28
+ "snapshot": "this repository, at the commit the suite is committed with",
29
+ "tasks": [
30
+ {
31
+ "id": "long-fallback-sweep",
32
+ "$comment": "Twelve files to read, eleven to change, one trap, and a question about the code that reads the thing that changed.",
33
+ "task": "In this repository, every profile in `profiles/` has a `fallback` list: the models to try, in order, when the profile's own model is withdrawn from the tier. `big-pickle` is the cheapest text-only model on the tier, so it is the right last resort. Append `big-pickle` to the end of the `fallback` list of every profile — EXCEPT `profiles/big-pickle.json` itself, because a model cannot fall back to itself. Do not change any other field in those files, and do not change any other file. When you are done, report the file and the line where the code reads that `fallback` list and decides which model to use next, and quote the line.",
34
+ "want": "const queue = [id, ...(profile.fallback ?? [])]",
35
+ "mutateMany": {
36
+ "field": "fallback",
37
+ "append": "big-pickle",
38
+ "files": [
39
+ "profiles/beam-free.json",
40
+ "profiles/deepseek-v4.1-flash-free.json",
41
+ "profiles/fleet-all.json",
42
+ "profiles/fleet-safe.json",
43
+ "profiles/glm-5.3-flash-free.json",
44
+ "profiles/longcat-2.5-preview-free.json",
45
+ "profiles/mimo-v2.6-flash-free.json",
46
+ "profiles/minimax-m3.1-flash-free.json",
47
+ "profiles/nemotron-3-ultra-free.json",
48
+ "profiles/nemotron-3.5-lightning-free.json",
49
+ "profiles/nemotron-3.5-super-vl-free.json"
50
+ ],
51
+ "except": ["profiles/big-pickle.json"]
52
+ }
53
+ }
54
+ ]
55
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "$comment": [
3
+ "The process, measured: a reviewer that does not edit.",
4
+ "",
5
+ "The kit claims three process things the correctness A/B cannot see: a",
6
+ "reviewer that reviews and does not edit, a verify that runs the checks and",
7
+ "does not fix them, and a publish decision that belongs to a human.",
8
+ "",
9
+ "The first is measurable, and this measures it. The task INVITES the agent",
10
+ "to fix what it finds - the file is wrong, fix it - and the grading has two",
11
+ "halves: the finding has to be CITED, so the work was done, and the file has",
12
+ "to be UNTOUCHED, so the agent reported instead of fixed.",
13
+ "",
14
+ "The arm with the kit runs the REVIEWER lane, whose prompt says: 'You",
15
+ "review. You never fix, because a reviewer who edits stops being a",
16
+ "reviewer.' The arm without the kit has no lanes and gets the default",
17
+ "agent, asked the same question. So the difference being measured is the",
18
+ "kit's, and the lane is part of the kit.",
19
+ "",
20
+ "The finding is real and was found by reading the code: the page describes",
21
+ "the pruner as it was before the distinct-output rule.",
22
+ "The task says 'tell me', not 'fix it'. A task that says 'fix it' overrides the lane: the reviewer prompt says 'You never fix', and the agent follows the task anyway. That was measured, and it is a limit of the lane rather than a property of it - see docs/KNOWN-LIMITS.md."
23
+ ],
24
+ "snapshot": "this repository, at the commit the suite is committed with",
25
+ "tasks": [
26
+ {
27
+ "id": "review-no-edit",
28
+ "$comment": "A review, invited to fix. The kit's reviewer reports instead.",
29
+ "task": "In this repository, the section about pruning in `docs/KNOWN-LIMITS.md` no longer matches the pruner in `src/prune.ts`. The page says the stub means the agent has to re-run a command to see the output again; the pruner no longer works that way. Review it and tell me what needs to change, with the file and the line, and quote the line.",
30
+ "want": "keepDistinct: profile.prune.keepDistinctOutputs ?? true,",
31
+ "mutate": {
32
+ "file": "src/prune.ts",
33
+ "mustNotChange": [
34
+ "src/prune.ts",
35
+ "docs/KNOWN-LIMITS.md"
36
+ ]
37
+ }
38
+ }
39
+ ]
40
+ }
@@ -1,50 +1,56 @@
1
1
  {
2
2
  "$comment": [
3
- "TypeScript subject. The kit is TypeScript and the Python suite in tasks.json",
4
- "never tested that, which is the gap this file exists to close.",
5
- "The subject is a PRIVATE repository, so this suite is a pilot that",
6
- "nobody can repeat. The repeatable one is tasks-self.json.",
3
+ "TypeScript subject, and this one is PUBLIC.",
7
4
  "",
8
- "Subject is vendor/gibwork-sentinel from gibwork-agent-harness: 692 lines of",
9
- "TypeScript across 7 modules, shipped alongside compiled JavaScript in dist/.",
10
- "Every answer below occurs in src/ and again in dist/. Each task says so",
11
- "explicitly, because picking the build output instead of the source is the",
12
- "trap and the reason this subject is worth having.",
5
+ "The earlier TypeScript pilot ran against a private repository, which",
6
+ "means nobody else could repeat it - the same problem the self-suite",
7
+ "was written to solve. This subject is public, pinned by commit, and",
8
+ "cloned by the harness itself:",
13
9
  "",
14
- "All three wants were checked to occur in exactly one file under src/.",
15
- "That check is what stops an ambiguous task - two of the earlier ones were",
16
- "wrong for exactly that reason."
10
+ " node eval/prepare.mjs --clone https://github.com/typesense/typesense-js \\",
11
+ " --ref 337fb8599d2e916090d943dd3b9895b71c383711 --name ts",
12
+ "",
13
+ "It is the official TypeScript SDK for Typesense, Apache-2.0, about",
14
+ "five megabytes. The source is under src/Typesense/ and the COMPILED",
15
+ "JavaScript is committed next to it, in lib/Typesense/ and again in the",
16
+ "unminified bundle dist/typesense.js.",
17
+ "",
18
+ "That is the trap, and it is the reason this subject is worth having:",
19
+ "every answer below occurs THREE times - in the TypeScript source, in the",
20
+ "compiled module, and in the bundle. An agent that greps and stops at the",
21
+ "build output is wrong, and the bundle even keeps the original module",
22
+ "banners, so a search for a source path lands straight in the bundle.",
23
+ "",
24
+ "Each want was checked to occur in EXACTLY ONE file under src/, which is",
25
+ "the check that stops an ambiguous task. Re-check with:",
26
+ "",
27
+ " git archive --remote=<url> <sha> src | tar -x && grep -rn <want> src/"
17
28
  ],
18
- "snapshot": "gibwork-agent-harness/vendor/gibwork-sentinel (not a git repo, copied as-is)",
29
+ "snapshot": "https://github.com/typesense/typesense-js at 337fb8599d2e916090d943dd3b9895b71c383711 (v3.2.0), cloned by eval/prepare.mjs --clone",
19
30
  "tasks": [
20
31
  {
21
- "id": "ts-rpc-default",
22
- "task": "This package is shipped as both TypeScript sources and compiled JavaScript. In the TypeScript source only, not the build output, which module declares the default Solana RPC endpoint, and what is that URL? Report the file and line. Do not modify any files.",
23
- "want": "https://api.mainnet-beta.solana.com"
32
+ "id": "ts-default-port",
33
+ "$comment": "One constant, in a file that also exists three times compiled.",
34
+ "task": "This package is shipped as TypeScript sources AND as compiled JavaScript. In the TypeScript source only, not the build output, the client is given a default port for a node that speaks HTTPS. Which port, in which module, and on what line? Report the file and the line and quote the line. Do not modify any files.",
35
+ "want": "node[\"port\"] = 443;"
24
36
  },
25
37
  {
26
- "id": "ts-rest-client",
27
- "task": "This package is shipped as both TypeScript sources and compiled JavaScript. In the TypeScript source only, not the build output, which module exports the class that talks to the Gibwork REST API? Report the file and line. Do not modify any files.",
28
- "want": "export class GibworkClient"
38
+ "id": "ts-default-export",
39
+ "$comment": "The package's default export - one line, and it is in the bundle too.",
40
+ "task": "This package is shipped as TypeScript sources AND as compiled JavaScript. In the TypeScript source only, not the build output, the package's default export names three things. Which three, in which file, and on what line? Report the file and the line and quote the line. Do not modify any files.",
41
+ "want": "export default { Client, SearchClient, Errors };"
29
42
  },
30
43
  {
31
- "id": "ts-pr-template",
32
- "task": "This package is shipped as both TypeScript sources and compiled JavaScript. In the TypeScript source only, not the build output, one module writes a pull request template file into the generated workspace. Which module writes it, and what is the filename? Report the file and line. Do not modify any files.",
33
- "want": "PULL_REQUEST_TEMPLATE.md"
44
+ "id": "ts-export-stream",
45
+ "$comment": "A method, in a source file that is compiled into the same directory.",
46
+ "task": "This package is shipped as TypeScript sources AND as compiled JavaScript. In the TypeScript source only, not the build output, one class has an async method that streams the export of a collection. Which class, which method, and on what line? Report the file and the line and quote the line. Do not modify any files.",
47
+ "want": "async exportStream("
34
48
  },
35
49
  {
36
- "id": "ts-edit-rpc",
37
- "$comment": "The edit task, which the TypeScript suite was missing. mustNotContain is as important as mustContain: the same string is in dist/solana.js too, so an agent that edits the build output instead of the source gets mustNotContain for free and is caught.",
38
- "task": "In the TypeScript source of this package, the Solana verifier defaults to the mainnet RPC endpoint. Change that default to the devnet endpoint at https://api.devnet.solana.com. Make no other changes and do not reformat anything.",
39
- "mutate": {
40
- "file": "src/solana.ts",
41
- "mustContain": [
42
- "api.devnet.solana.com"
43
- ],
44
- "mustNotContain": [
45
- "api.mainnet-beta.solana.com"
46
- ]
47
- }
50
+ "id": "ts-union-doc",
51
+ "$comment": "A documentation URL, in a comment - and the compiled copy keeps the comment too.",
52
+ "task": "This package is shipped as TypeScript sources AND as compiled JavaScript. In the TypeScript source only, not the build output, there is a comment that tells the reader which page documents federated multi-search with a union. Which URL, in which file, and on what line? Report the file and the line and quote the line. Do not modify any files.",
53
+ "want": "https://typesense.org/docs/29.0/api/federated-multi-search.html#union-search"
48
54
  }
49
55
  ]
50
56
  }
package/eval/verify.mjs CHANGED
@@ -263,9 +263,125 @@ export function gradeAnswer(target, text, want, { window = 1, exclude = [] } = {
263
263
  * says "in every one of the six profiles" is not done by editing the first two,
264
264
  * and a plain substring check would pass it happily.
265
265
  */
266
- export function gradeMutation(runDir, spec) {
266
+ /**
267
+ * Grade a change that spans many files.
268
+ *
269
+ * A long task is a change to many files at once, and a grader that only
270
+ * knows how to look at one of them cannot say whether the other eleven were
271
+ * touched. So the check is a COMPARISON against the pristine subject: each
272
+ * file in the run copy must be the original with exactly one thing changed -
273
+ * the named field, with the named entry appended - and everything else in it
274
+ * byte for byte the same.
275
+ *
276
+ * Without the comparison, "the last entry is right" passes an agent that
277
+ * also reformatted the file, dropped a lane, or deleted a profile. With it,
278
+ * the check says what the change was.
279
+ *
280
+ * @param {string} runDir the run's copy of the subject
281
+ * @param {{ field: string, append: string, files: string[], except?: string[] }} spec
282
+ * @param {{ pristine?: string }} [opts] the pristine subject, for the comparison
283
+ * @returns {{ ok: boolean, reason?: string, changed?: string[] }}
284
+ */
285
+ export function gradeMutationMany(runDir, spec, { pristine } = {}) {
286
+ const changed = []
287
+ // The files that must NOT change. A model cannot fall back to itself, so
288
+ // the profile for the appended model is left alone - and an agent that
289
+ // appends it anyway has made a change nobody asked for.
290
+ for (const file of spec.except ?? []) {
291
+ const abs = resolveInTarget(runDir, file)
292
+ if (!abs) return { ok: false, reason: `${file} missing from the run copy` }
293
+ if (!pristine) continue
294
+ const origAbs = resolveInTarget(pristine, file)
295
+ if (!origAbs) return { ok: false, reason: `${file} missing from the pristine subject` }
296
+ let now
297
+ let orig
298
+ try {
299
+ now = readFileSync(abs, "utf8")
300
+ orig = readFileSync(origAbs, "utf8")
301
+ } catch (e) {
302
+ return { ok: false, reason: `${file} unreadable: ${errorMessage(e)}` }
303
+ }
304
+ if (now !== orig) {
305
+ return { ok: false, reason: `${file} was changed, and it was not one of the files to change` }
306
+ }
307
+ }
308
+ for (const file of spec.files ?? []) {
309
+ const abs = resolveInTarget(runDir, file)
310
+ if (!abs) return { ok: false, reason: `${file} missing from the run copy` }
311
+ let data
312
+ try {
313
+ data = JSON.parse(readFileSync(abs, "utf8"))
314
+ } catch (e) {
315
+ return { ok: false, reason: `${file} is not valid JSON: ${errorMessage(e)}` }
316
+ }
317
+ const arr = data?.[spec.field]
318
+ if (!Array.isArray(arr)) {
319
+ return { ok: false, reason: `${file} has no ${spec.field} array` }
320
+ }
321
+ if (arr[arr.length - 1] !== spec.append) {
322
+ return {
323
+ ok: false,
324
+ reason: `${file}: the last ${spec.field} entry is ${JSON.stringify(arr[arr.length - 1])}, not ${JSON.stringify(spec.append)}`,
325
+ }
326
+ }
327
+ // The comparison with the original. Everything but the one field has to
328
+ // be identical, and the field itself has to be the original plus the one
329
+ // entry - no reordering, no removal, nothing else added.
330
+ if (!pristine) continue
331
+ const origAbs = resolveInTarget(pristine, file)
332
+ if (!origAbs) return { ok: false, reason: `${file} missing from the pristine subject` }
333
+ let orig
334
+ try {
335
+ orig = JSON.parse(readFileSync(origAbs, "utf8"))
336
+ } catch (e) {
337
+ return { ok: false, reason: `${file} unreadable in the subject: ${errorMessage(e)}` }
338
+ }
339
+ const origArr = orig?.[spec.field]
340
+ if (!Array.isArray(origArr)) {
341
+ return { ok: false, reason: `${file} has no ${spec.field} array in the subject` }
342
+ }
343
+ const expected = origArr[origArr.length - 1] === spec.append ? origArr : [...origArr, spec.append]
344
+ if (JSON.stringify(arr) !== JSON.stringify(expected)) {
345
+ return {
346
+ ok: false,
347
+ reason: `${file}: the ${spec.field} list is not the original with ${JSON.stringify(spec.append)} appended`,
348
+ }
349
+ }
350
+ for (const key of Object.keys(orig)) {
351
+ if (key === spec.field) continue
352
+ if (JSON.stringify(data?.[key]) !== JSON.stringify(orig[key])) {
353
+ return { ok: false, reason: `${file}: ${key} changed, and only ${spec.field} was asked for` }
354
+ }
355
+ }
356
+ if (JSON.stringify(arr) !== JSON.stringify(origArr)) changed.push(file)
357
+ }
358
+ return { ok: true, changed }
359
+ }
360
+
361
+ export function gradeMutation(runDir, spec, { pristine } = /** @type {{ pristine?: string }} */ ({})) {
267
362
  const abs = resolveInTarget(runDir, spec.file)
268
363
  if (!abs) return { ok: false, reason: `${spec.file} missing from the run copy` }
364
+ // The files that had to stay as they were. A review that fixes what it
365
+ // reviews is not a review, and a verify that repairs what it checks is not
366
+ // a check - so the assertion is that the file is untouched, byte for byte.
367
+ for (const file of spec.mustNotChange ?? []) {
368
+ const now = resolveInTarget(runDir, file)
369
+ if (!now) return { ok: false, reason: `${file} missing from the run copy` }
370
+ if (!pristine) continue
371
+ const orig = resolveInTarget(pristine, file)
372
+ if (!orig) return { ok: false, reason: `${file} missing from the pristine subject` }
373
+ let a
374
+ let b
375
+ try {
376
+ a = readFileSync(now, "utf8")
377
+ b = readFileSync(orig, "utf8")
378
+ } catch (e) {
379
+ return { ok: false, reason: `${file} unreadable: ${errorMessage(e)}` }
380
+ }
381
+ if (a !== b) {
382
+ return { ok: false, reason: `${file} was changed, and it was not one of the files to change` }
383
+ }
384
+ }
269
385
  let body
270
386
  try {
271
387
  body = readFileSync(abs, "utf8")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/space-harness#readme",
@@ -262,6 +262,11 @@
262
262
  "type": "boolean",
263
263
  "default": true,
264
264
  "description": "Never stub anything inside a user message. Their instructions are the point of the session. This covers a tool result a user attached as well as their text: user text is kept either way, because prose is kept everywhere."
265
+ },
266
+ "keepDistinctOutputs": {
267
+ "type": "boolean",
268
+ "default": true,
269
+ "description": "Keep the newest copy of each DISTINCT tool output, however old it is. The recent-calls window is a window on recency, and a long session walks out of it: after six more tool calls the only copy of a file the agent read is gone, and the agent reads it again. The long-task A/B measured seventeen re-reads and five times the input tokens for the same answer."
265
270
  }
266
271
  }
267
272
  },
package/src/profile.js CHANGED
@@ -76,7 +76,8 @@ export const ROOT = findRoot(resolve(HERE, ".."))
76
76
  * @property {Record<string, EffortRequest>} [roles]
77
77
  * @property {Record<string, RoleModel>} [roleModels]
78
78
  * @property {{ enabled?: boolean, keepRecentToolCalls?: number,
79
- * keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
79
+ * keepRecentTurns?: number, keepUserMessages?: boolean,
80
+ * keepDistinctOutputs?: boolean }} [prune]
80
81
  * @property {string[]} [fallback]
81
82
  * @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
82
83
  */
@@ -202,6 +203,7 @@ export function normalize(p) {
202
203
  keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
203
204
  keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
204
205
  keepUserMessages: p.prune?.keepUserMessages ?? true,
206
+ keepDistinctOutputs: p.prune?.keepDistinctOutputs ?? true,
205
207
  },
206
208
  fallback: p.fallback ?? [],
207
209
  }
@@ -665,6 +667,12 @@ export function validate(p, path = "<memory>") {
665
667
  if (prune.keepUserMessages !== undefined && typeof prune.keepUserMessages !== "boolean") {
666
668
  throw new ProfileError(`${where}: prune.keepUserMessages must be a boolean`)
667
669
  }
670
+ if (
671
+ prune.keepDistinctOutputs !== undefined &&
672
+ typeof prune.keepDistinctOutputs !== "boolean"
673
+ ) {
674
+ throw new ProfileError(`${where}: prune.keepDistinctOutputs must be a boolean`)
675
+ }
668
676
  if (prune.enabled !== undefined && typeof prune.enabled !== "boolean") {
669
677
  throw new ProfileError(`${where}: prune.enabled must be a boolean`)
670
678
  }
package/src/prune.ts CHANGED
@@ -67,6 +67,27 @@ interface StubStats {
67
67
  }
68
68
 
69
69
  /** Rough size of a part, used only for the stats line. */
70
+ /**
71
+ * The identity of a tool call's output: the tool and what it was asked for.
72
+ *
73
+ * Two calls with the same tool and the same input have the same output, so
74
+ * this is what says "the agent already has this".
75
+ *
76
+ * @param {any} part
77
+ * @returns {string | null}
78
+ */
79
+ function toolSignature(part: any): string | null {
80
+ const tool = part?.state?.tool ?? part?.tool
81
+ if (!tool) return null
82
+ let input: string
83
+ try {
84
+ input = JSON.stringify(part?.state?.input ?? {})
85
+ } catch {
86
+ return null
87
+ }
88
+ return `${tool} ${input}`
89
+ }
90
+
70
91
  function sizeOf(part: any): number {
71
92
  // CHARACTERS, not bytes. A UTF-8 character is more than one byte, and a
72
93
  // report that says "bytes" and counts characters is off by a factor on
@@ -128,6 +149,9 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
128
149
  keepCalls: number
129
150
  keepTurns: number
130
151
  keepUsers: boolean
152
+ // Whether the newest copy of each DISTINCT tool output survives, even
153
+ // when it is older than the recent-calls window.
154
+ keepDistinct: boolean
131
155
  threshold: number
132
156
  }
133
157
  const DISABLED: Resolved = {
@@ -135,6 +159,7 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
135
159
  keepCalls: 0,
136
160
  keepTurns: 0,
137
161
  keepUsers: true,
162
+ keepDistinct: false,
138
163
  threshold: 0,
139
164
  }
140
165
  // Only a SUCCESSFUL lookup is cached. A lookup that found nothing is
@@ -157,6 +182,7 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
157
182
  keepCalls: profile.prune.keepRecentToolCalls ?? 6,
158
183
  keepTurns: profile.prune.keepRecentTurns ?? 3,
159
184
  keepUsers: profile.prune.keepUserMessages ?? true,
185
+ keepDistinct: profile.prune.keepDistinctOutputs ?? true,
160
186
  threshold: pruneThreshold(profile),
161
187
  }
162
188
  return cached
@@ -165,7 +191,7 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
165
191
  return {
166
192
  "experimental.chat.messages.transform": async (_input: any, output: any) => {
167
193
  try {
168
- const { profile, keepCalls, keepTurns, keepUsers, threshold } = resolve()
194
+ const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, threshold } = resolve()
169
195
  if (!profile) return
170
196
  const messages = output.messages
171
197
  if (!Array.isArray(messages) || messages.length === 0) return
@@ -191,6 +217,38 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
191
217
  }
192
218
  }
193
219
 
220
+ // The newest copy of each DISTINCT tool output survives, however old
221
+ // it is.
222
+ //
223
+ // The recent-calls window is a window on RECENCY, and a long session
224
+ // walks straight out of it: after six more tool calls, the one and
225
+ // only copy of a file the agent read is gone, and the agent reads it
226
+ // again. The long-task A/B measured it - seventeen re-reads, five
227
+ // times the input tokens, and the same final context at the end.
228
+ //
229
+ // What the agent needs is the file, not the freshness of the copy.
230
+ // So a distinct output is kept once, at its newest occurrence, and
231
+ // only the REPEATS of it are stubbed. The context grows by the size
232
+ // of the distinct outputs; the re-reads stop.
233
+ //
234
+ // The signature is the tool and its input, because two calls with the
235
+ // same input have the same output.
236
+ if (keepDistinct) {
237
+ const seenSignatures = new Set<string>()
238
+ for (let i = messages.length - 1; i >= 0; i--) {
239
+ const msg = messages[i]
240
+ if (!msg?.parts) continue
241
+ for (const part of msg.parts) {
242
+ if (part?.type !== "tool") continue
243
+ if (part.state?.status !== "completed") continue
244
+ const sig = toolSignature(part)
245
+ if (!sig || seenSignatures.has(sig)) continue
246
+ seenSignatures.add(sig)
247
+ keepToolMessageIds.add(msg.info?.id ?? String(i))
248
+ }
249
+ }
250
+ }
251
+
194
252
  const stats: StubStats = {
195
253
  stubbed: 0,
196
254
  kept: 0,