dsh-agent-speedup 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -19,13 +19,14 @@ Within that 22%, three different problems exist, and each capability targets one
19
19
 
20
20
  | Capability | Problem it solves | Measured |
21
21
  |---|---|---|
22
- | **Cache** | Same read-only call repeated | wall **−13.5%** on the release-candidate run; **−46%** in the earlier calibration on the same task shape |
23
- | **Prefetch** | Files the model will ask for right after a `glob` | 12/12 prefetch stored, 11 subsequent hits, 28 s of execution avoided across 6 runs |
24
- | **Shell concurrency** | Multiple readonly shell commands serialized | 5 pwsh calls in 19.4 s → concurrent judgment in 8 ms |
22
+ | **Cache** | Same read-only call repeated | wall **−13.5%** (T4) and **−10.5%** (T5), release build; an earlier calibration on the same task shape measured **−46%** |
23
+ | **Prefetch** | Files the model will ask for right after a `glob` | 12/12 prefetch calls completed, results served from cache on the model's next reads (ledger: 14 hits, 28.1 s of instrumented execution avoided across 6 runs) |
24
+ | **Shell concurrency** | Multiple readonly shell commands serialized | 4 pwsh calls: **7,103 ms serialized → 317 ms concurrent (22×)**, identical task and model |
25
25
 
26
26
  And two things it deliberately does not fix: on local millisecond-scale tools the plugin is
27
27
  invisible (tools were 0.1–9.4% of wall in our runs), and DeepSeek Harness already parallel-batches
28
- concurrency-safe tools — 6 reads completed within 21 ms in our baseline, with no plugin installed.
28
+ concurrency-safe tools — in our baseline 7 tool calls (6 reads + 1 shell) were dispatched within
29
+ 19 ms with no plugin installed, and 5 adjacent tool results landed within 100 ms of each other.
29
30
 
30
31
  Full method, sample sizes, and the findings that argue against this plugin:
31
32
  [docs/BENCHMARK.md](docs/BENCHMARK.md).
@@ -70,8 +71,9 @@ handled identically.
70
71
  ### Shell concurrency
71
72
 
72
73
  dsh's `bash` and `pwsh` tools declare no `isConcurrencySafe`, so every shell call runs exclusively —
73
- even five readonly `Get-ChildItem` calls take turns (measured: 19.4 s for what should be one
74
- parallel window). This capability attaches a conservative classifier that marks a command
74
+ four readonly `Get-*` calls that should share one parallel window instead took 7.1 s
75
+ (measured: per-call gaps of 2.2–2.5 s). With this capability enabled, the same four calls completed
76
+ in 317 ms. The capability attaches a conservative classifier that marks a command
75
77
  concurrency-safe only when every segment matches a readonly whitelist and no write pattern appears.
76
78
 
77
79
  **This is off by default.** A classifier mistake means two shell commands with side effects run at
@@ -108,14 +110,32 @@ All keys with zod defaults; a patch override replaces the whole object.
108
110
  | `shellTools` | `["bash","pwsh"]` | Shell tools to patch |
109
111
  | `debugLogFile` | unset | Write scheduling events here |
110
112
 
113
+ ## Safety
114
+
115
+ Read-only tools only. `bash`, `pwsh`, `write` and `edit` are passed through untouched and are never
116
+ cached or prefetched. Error results, including guard denials, are not remembered. Cache and
117
+ in-flight prefetches are cleared when the plugin unloads (in-flight prefetch aborts included).
118
+
119
+ Two known boundaries, found in independent review:
120
+
121
+ - **Working-directory assumption.** Cache keys and stat checks resolve relative paths against the
122
+ process working directory, while dsh's `read` resolves against the agent's working directory. In
123
+ headless mode (one process per workspace) these coincide. If an agent `cd`s elsewhere, entries
124
+ whose file cannot be stat'd are simply not cached (fail-safe), but same-named files across
125
+ different agent working directories are not disambiguated.
126
+ - **Cache hits short-circuit the waterfall.** A hit returns without calling `next()`, which also
127
+ skips `tools/execute` listeners registered by plugins loaded after this one. If you run other
128
+ execute-stage plugins, load them before this one.
129
+
111
130
  ## 中文
112
131
 
113
132
  给 DeepSeek Harness 用的工具执行效率套件,三个能力各自对应一类可实测的问题:
114
133
 
115
- - **缓存**:只读工具重复调用直接命中(重复读场景实测免掉 28 秒真实执行 / 6 轮)
116
- - **预取**:`glob` 之后预读文件,模型开口时结果已在(实测 12/12 预取全部完成并命中)
117
- - **只读 shell 并发**:dsh 原生会把 shell 命令串行(实测 5 条毫秒级命令跑了 19.4 秒),
118
- 本插件给只读命令打上并发标记,让它们进入 dsh 原生并行批(默认关闭,需显式开启)
134
+ - **缓存**:只读工具重复调用直接命中(发布候选回归实测免掉 28 秒真实执行 / 6 轮)
135
+ - **预取**:`glob` 之后预读文件,模型开口时结果已在(实测 12/12 预取完成、结果全部从缓存供给)
136
+ - **只读 shell 并发**:dsh 原生会把 shell 命令串行(实测 4 条毫秒级命令串行 7.1 秒,
137
+ 开启后 317 毫秒完成,22 倍),本插件给只读命令打上并发标记,
138
+ 让它们进入 dsh 原生并行批(默认关闭,需显式开启)
119
139
 
120
140
  前提要清楚:模型延迟占关键路径的 87%,工具占 22%。本地毫秒级工具上这个插件无感;
121
141
  收益集中在"慢工具 + 重复调用"场景。数字、方法和反面结果在
package/docs/BENCHMARK.md CHANGED
@@ -66,9 +66,14 @@ Mechanism-level confirmation, independent of wall time:
66
66
  - T5: prefetch launched 4 of 4 files and the model's subsequent four reads all hit
67
67
  (`speculative: true`), with zero prefetch failures.
68
68
 
69
- A later run of the same suite after the plugin was refactored for release reproduced the mechanism
70
- (per-call tool time −49.7% on T4, −79.4% on T5) but a smaller wall-time delta (−7.4% and −17.5%),
71
- which is consistent with model-side variance at this sample size.
69
+ Two follow-up regression runs, with the plugin versions they belong to (kept separate because the
70
+ numbers are not interchangeable):
71
+
72
+ - **0.1.x plugin** (−49.7% per-call tool time on T4, −79.4% on T5; wall −7.4% and −17.5%): measured
73
+ before the suite rewrite. Wall deltas at this sample size were dominated by model-side variance.
74
+ - **0.2.0 release candidate** (this package): T4 wall −14.7%, T5 wall −10.5%, ledger showing
75
+ 14 cache hits and 28.1 s of instrumented execution avoided across 6 runs. Confirms the mechanism
76
+ survived the rewrite; the wall deltas differ from 0.1.x within model-side variance.
72
77
 
73
78
  ## Two results that argue against the plugin
74
79
 
package/lib/index.js CHANGED
@@ -74,15 +74,21 @@ function normPath(value) {
74
74
  }
75
75
  }
76
76
 
77
- /** 稳定键:工具名 + 规范化参数(键序固定) */
77
+ /** 稳定键:工具名 + 规范化参数(键序固定)。
78
+ * 只有明确的路径类参数才做路径归一——grep 的 pattern 是正则语义,
79
+ * 对它做 resolve/relative 会把 `a\db` 与 `a/db` 归一成同一键(假命中)。 */
78
80
  function keyOf(tool, args) {
79
81
  const normalized = {};
80
82
  for (const k of Object.keys(args ?? {}).sort()) {
81
- normalized[k] = typeof args[k] === "string" ? normPath(args[k]) : args[k];
83
+ const v = args[k];
84
+ normalized[k] = typeof v === "string" && PATH_ARGS.has(k) ? normPath(v) : v;
82
85
  }
83
86
  return `${tool}|${JSON.stringify(normalized)}`;
84
87
  }
85
88
 
89
+ /** 已知承载文件路径的参数名;白名单外的字符串一律原样入键 */
90
+ const PATH_ARGS = new Set(["file_path", "path", "dir", "directory", "cwd", "file", "target"]);
91
+
86
92
  /** 文件 stat 指纹;失败返回 undefined(无法校验) */
87
93
  function statFingerprint(path) {
88
94
  try {
@@ -162,12 +168,21 @@ export function apply(ctx, config) {
162
168
  if (result?.isError) { stats.miss++; return result; }
163
169
  const filePath = exec.arguments?.file_path;
164
170
  const execMs = Date.now() - t0;
171
+ // 带路径的条目必须拿到 stat 指纹才可缓存:指纹取不到说明该路径不在本进程 cwd
172
+ // 视野内(agent 工作目录与进程 cwd 不一致),缓存它有跨目录内容污染风险。
173
+ // glob/grep 等无 file_path 的条目标记 volatile,由写意图与 shell 调用触发整体失效。
174
+ let fp = null;
175
+ if (typeof filePath === "string") {
176
+ fp = statFingerprint(normPath(filePath));
177
+ if (fp === undefined) { stats.miss++; return result; }
178
+ }
165
179
  cache.set(key, {
166
180
  result,
167
181
  spec: prefetching.has(key),
168
182
  execMs,
183
+ volatile: typeof filePath !== "string", // glob/grep 等无指纹条目:写意图/shell 调用后整体失效
169
184
  ...(typeof filePath === "string" ? { path: normPath(filePath) } : {}),
170
- ...(typeof filePath === "string" ? { fingerprint: statFingerprint(normPath(filePath)) } : {}),
185
+ ...(fp !== null ? { fingerprint: fp } : {}),
171
186
  });
172
187
  stats.miss++;
173
188
  log({ kind: "cache-miss", tool: exec.name, key, speculative: prefetching.has(key), execMs });
@@ -175,6 +190,8 @@ export function apply(ctx, config) {
175
190
  });
176
191
 
177
192
  // ================= 能力 2:推测预取 =================
193
+ /** 在途预取的控制器:卸载时统一 abort,防止 teardown 后仍写缓存/日志 */
194
+ const inflightControllers = new Set();
178
195
  const prefetch = (paths, tool) => {
179
196
  const targets = [];
180
197
  for (const p of paths) {
@@ -182,34 +199,42 @@ export function apply(ctx, config) {
182
199
  const key = keyOf(tool, { file_path: p });
183
200
  if (cache.has(key) || prefetching.has(key)) continue;
184
201
  prefetching.add(key);
185
- targets.push({ path: p, key });
202
+ targets.push({ path: p, key, startedAt: 0 });
186
203
  }
187
204
  for (const t of targets) {
188
205
  stats.speculativeLaunched++;
189
206
  log({ kind: "spec-start", tool, path: t.path });
190
- const settle = (fn) => { prefetching.delete(t.key); fn(); };
207
+ const controller = new AbortController();
208
+ inflightControllers.add(controller);
209
+ t.startedAt = Date.now();
210
+ const settle = (fn) => { prefetching.delete(t.key); inflightControllers.delete(controller); fn(); };
191
211
  try {
192
212
  ctx.tools
193
213
  .execute({
194
214
  name: tool,
195
215
  arguments: { file_path: t.path },
196
216
  callId: `spec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
197
- signal: new AbortController().signal,
217
+ signal: controller.signal,
198
218
  })
199
219
  .then((result) => settle(() => {
220
+ if (controller.signal.aborted) return;
200
221
  if (result?.isError) { stats.speculativeFailed++; log({ kind: "spec-fail", path: t.path, reason: "error-result" }); return; }
201
222
  if (cache.has(t.key)) { log({ kind: "spec-done", path: t.path, stored: false, note: "real-call-won" }); return; }
202
- cache.set(t.key, { result, spec: true, execMs: 0, path: t.path, fingerprint: statFingerprint(t.path) });
203
- log({ kind: "spec-done", path: t.path, stored: true });
223
+ cache.set(t.key, { result, spec: true, execMs: Date.now() - t.startedAt, path: t.path, fingerprint: statFingerprint(t.path) });
224
+ log({ kind: "spec-done", path: t.path, stored: true, execMs: Date.now() - t.startedAt });
204
225
  }))
205
226
  .catch((e) => settle(() => { stats.speculativeFailed++; log({ kind: "spec-fail", path: t.path, error: String(e).slice(0, 160) }); }));
206
227
  } catch (e) {
207
228
  settle(() => { stats.speculativeFailed++; log({ kind: "spec-fail", path: t.path, error: String(e).slice(0, 160) }); });
208
229
  }
230
+ t.startedAt = Date.now();
209
231
  }
210
232
  };
211
233
 
212
234
  ctx.on("tools/post-execute", async (exec, result, next) => {
235
+ // shell 调用后清空 volatile 条目:bash/pwsh 可能写了文件且不发 fs 事件,
236
+ // glob/grep 的无指纹条目此时已不可信(R1a Issue 1 的 shell 侧兜底)
237
+ if (cfg.shellTools.includes(exec?.name)) dropVolatile("shell-call");
213
238
  if (cfg.speculate && cfg.speculateOn.has(exec?.name) && result != null) {
214
239
  const paths = pathsFromResult(result, cfg.speculateRootPrefix);
215
240
  if (paths.length) prefetch(paths, SPECULATE_READ_OF[exec.name] ?? "read");
@@ -218,21 +243,38 @@ export function apply(ctx, config) {
218
243
  });
219
244
 
220
245
  // ================= 能力 2.5:写意图即时失效 =================
221
- // 只监听写意图。fs/observed 会在每次成功的 read 上触发,若在此失效会把刚写入的
222
- // 缓存条目立刻删掉。bash/pwsh 的写入不发事件,由 stat 指纹校验兜底。
246
+ // ⚠ 契约:fs/write-intent 与 fs/edit-intent 是 waterfall(不是 emit),监听器签名
247
+ // (target, exec, next),返回值就是写入意图。必须 return next(),否则:
248
+ // - 默认顺序下(fs-observation-policy 先注册且不调 next)本监听器永远不执行;
249
+ // - 顺序翻转时返回 undefined 会禁用宿主 fs-observation-policy 的 CAS 写入保护。
250
+ // 两条失效规则:
251
+ // a) 条目路径与写意图匹配 → 精确删除;
252
+ // b) volatile 条目(glob/grep 等无 file_path 指纹的)→ 任何写意图都清空——
253
+ // 其正确性前提是"目录内容未变",无法用单文件 stat 校验。
254
+ // bash/pwsh 的写入不发事件,由 stat 校验 + shell 调用后清空 volatile 兜底。
255
+ // (fs/observed 故意不监听:它与 stat 指纹完全冗余,且随每次读取触发。)
256
+ const dropVolatile = (reason) => {
257
+ let dropped = 0;
258
+ for (const [k, entry] of cache) {
259
+ if (entry.volatile) { cache.delete(k); dropped++; }
260
+ }
261
+ if (dropped) log({ kind: "cache-volatile-drop", reason, dropped });
262
+ };
223
263
  const invalidateByTarget = (target) => {
224
264
  const key = target?.targetKey ?? target?.displayPath;
225
- if (!key) return;
226
- const normalized = normPath(String(key));
227
- for (const [k, entry] of cache) {
228
- if (entry.path !== undefined && normPath(entry.path) === normalized) {
229
- cache.delete(k);
230
- log({ kind: "cache-invalidated", key: k });
265
+ if (key) {
266
+ const normalized = normPath(String(key));
267
+ for (const [k, entry] of cache) {
268
+ if (entry.path !== undefined && normPath(entry.path) === normalized) {
269
+ cache.delete(k);
270
+ log({ kind: "cache-invalidated", key: k });
271
+ }
231
272
  }
232
273
  }
274
+ dropVolatile("write-intent");
233
275
  };
234
- ctx.on("fs/write-intent", (target) => invalidateByTarget(target));
235
- ctx.on("fs/edit-intent", (target) => invalidateByTarget(target));
276
+ ctx.on("fs/write-intent", (target, exec, next) => { invalidateByTarget(target); return next(); });
277
+ ctx.on("fs/edit-intent", (target, exec, next) => { invalidateByTarget(target); return next(); });
236
278
 
237
279
  // ================= 能力 3:只读 shell 并发 =================
238
280
  const shellPatched = new Set();
@@ -272,6 +314,8 @@ export function apply(ctx, config) {
272
314
  const bootTimers = [100, 500, 1500].map((ms) => setTimeout(patchShellConcurrency, ms));
273
315
  ctx.effect(() => () => {
274
316
  for (const t of bootTimers) clearTimeout(t);
317
+ for (const c of inflightControllers) c.abort(new Error("plugin unloaded"));
318
+ inflightControllers.clear();
275
319
  cache.clear();
276
320
  prefetching.clear();
277
321
  }, "agent-speedup teardown");
@@ -25,6 +25,8 @@ const READONLY_PREFIXES = [
25
25
  const WRITE_PATTERNS = [
26
26
  /\s>\s*\S/, // 输出重定向
27
27
  /\s>>\s*\S/, // 追加重定向
28
+ /\s\d>\s*\S/, // fd 重定向到文件(2>err.log)
29
+ /\bdelete\b|\b-delete\b|-exec\b|-execdir\b|\bfd\s+(-x|--exec)\b/i, // find -delete/-exec、fd -x
28
30
  /\b(rm|rmdir|del|erase|mv|move|cp|copy|install|uninstall)\b/i,
29
31
  /\b(mkdir|md|touch|new-item|set-content|add-content|out-file)\b/i,
30
32
  /\b(sed\s+-i|tee|truncate|chmod|chown|chgrp)\b/i,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-speedup",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Tool-execution efficiency suite for DeepSeek Harness: read-only result cache, speculative prefetch, and concurrent readonly shell commands. Honest scope: helps when tools are slow and calls repeat; model latency dominates otherwise.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -41,8 +41,12 @@
41
41
  "@deepseek-ai/dsh-tools": ">=0.2.0-rc.0"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
- "@deepseek-ai/cordis": { "optional": true },
45
- "@deepseek-ai/dsh-tools": { "optional": true }
44
+ "@deepseek-ai/cordis": {
45
+ "optional": true
46
+ },
47
+ "@deepseek-ai/dsh-tools": {
48
+ "optional": true
49
+ }
46
50
  },
47
51
  "scripts": {
48
52
  "test": "node --test test/"