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 +30 -10
- package/docs/BENCHMARK.md +8 -3
- package/lib/index.js +62 -18
- package/lib/util/classify-command.js +2 -0
- package/package.json +7 -3
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%**
|
|
23
|
-
| **Prefetch** | Files the model will ask for right after a `glob` | 12/12 prefetch
|
|
24
|
-
| **Shell concurrency** | Multiple readonly shell commands serialized |
|
|
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 —
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
-
|
|
116
|
-
- **预取**:`glob` 之后预读文件,模型开口时结果已在(实测 12/12
|
|
117
|
-
- **只读 shell 并发**:dsh 原生会把 shell 命令串行(实测
|
|
118
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
222
|
-
//
|
|
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 (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
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": {
|
|
45
|
-
|
|
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/"
|