dsh-agent-speedup 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-tool-scheduler contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # dsh-agent-speedup
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Tool-execution efficiency suite for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
4
+ read-only result caching, speculative prefetch, and concurrent readonly shell commands — each with a
5
+ measured, bounded scope.
6
+
7
+ [![npm](https://img.shields.io/npm/v/dsh-agent-speedup)](https://www.npmjs.com/package/dsh-agent-speedup)
8
+ [![CI](https://img.shields.io/github/actions/workflow/status/HJ1H/dsh-agent-speedup/ci.yml)](https://github.com/HJ1H/dsh-agent-speedup/actions)
9
+ [![license](https://img.shields.io/npm/l/dsh-agent-speedup)](LICENSE)
10
+
11
+ ## The honest premise
12
+
13
+ In our measurements across 6 real agent sessions (2,517 tool calls, 1,311 minutes), model latency
14
+ (TTFT + thinking + generation) takes **87.2%** of the critical path, tool execution **22.1%**. This
15
+ plugin works inside that 22% — nothing here reduces the number of model round trips, which is where
16
+ the other 78% lives.
17
+
18
+ Within that 22%, three different problems exist, and each capability targets one:
19
+
20
+ | Capability | Problem it solves | Measured |
21
+ |---|---|---|
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
+
26
+ And two things it deliberately does not fix: on local millisecond-scale tools the plugin is
27
+ invisible (tools were 0.1–9.4% of wall in our runs), and DeepSeek Harness already parallel-batches
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.
30
+
31
+ Full method, sample sizes, and the findings that argue against this plugin:
32
+ [docs/BENCHMARK.md](docs/BENCHMARK.md).
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ dsh plugin --profile web add dsh-agent-speedup
38
+ ```
39
+
40
+ Node 22+, dsh 0.2.x, `pnpm` on PATH (Windows: pnpm 10 — pnpm 12 fails its store lock on some setups).
41
+
42
+ The bundled `cordis.patch.yml` activates caching and prefetch immediately. Shell concurrency is
43
+ **off by default** — see the safety note below.
44
+
45
+ ### Overriding config: replace, don't merge
46
+
47
+ A dsh patch entry with the same `id` **replaces the whole config object** — it does not merge. If
48
+ you override one key, you must repeat every key you care about (the zod defaults fill in the rest,
49
+ but your intent may differ from the defaults). The bundled defaults are in
50
+ [cordis.patch.yml](cordis.patch.yml).
51
+
52
+ ## The three capabilities
53
+
54
+ ### Cache
55
+
56
+ Read-only tools (`read`, `glob`, `grep`, `read_image`, extendable) are cached by tool name plus
57
+ normalized arguments. On a hit the plugin returns the stored result without calling `next()` — the
58
+ real dispatch never happens.
59
+
60
+ Entries carry a stat fingerprint (`size` + `mtimeMs`) checked on every hit, so a file changed by
61
+ any means invalidates — including `bash`/`pwsh` writes, which emit no filesystem events.
62
+ `fs/write-intent` and `fs/edit-intent` add immediate invalidation for the editor path.
63
+
64
+ ### Prefetch
65
+
66
+ When a `glob` result comes back, the plugin pre-executes the same-family read tool for the first N
67
+ paths, in the background. Those results land in the same cache, so when the model actually asks,
68
+ nothing runs. Prefetch calls go through the ordinary pipeline, so guard denials and errors are
69
+ handled identically.
70
+
71
+ ### Shell concurrency
72
+
73
+ dsh's `bash` and `pwsh` tools declare no `isConcurrencySafe`, so every shell call runs exclusively —
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
77
+ concurrency-safe only when every segment matches a readonly whitelist and no write pattern appears.
78
+
79
+ **This is off by default.** A classifier mistake means two shell commands with side effects run at
80
+ the same time. Enable it only if you accept that risk:
81
+
82
+ ```yaml
83
+ - id: agent-speedup
84
+ config:
85
+ cache: true # repeat everything below — replace semantics!
86
+ speculate: true
87
+ prefetchN: 3
88
+ speculateOn: [glob]
89
+ shellConcurrency: true
90
+ shellTools: [bash, pwsh]
91
+ ```
92
+
93
+ The classifier is deliberately strict: `npm test`, `cargo build`, `python script.py` are all treated
94
+ as side-effecting (they may write), and unknown commands are rejected rather than guessed.
95
+
96
+ ## Configuration
97
+
98
+ All keys with zod defaults; a patch override replaces the whole object.
99
+
100
+ | Key | Default | |
101
+ |---|---|---|
102
+ | `cache` | `true` | Read-only result cache |
103
+ | `readonlyTools` | `[]` | Extra tool names to cache |
104
+ | `validateStat` | `true` | Stat fingerprint check on every hit |
105
+ | `speculate` | `true` | Speculative prefetch |
106
+ | `speculateOn` | `["glob"]` | Tools whose results trigger prefetch |
107
+ | `prefetchN` | `3` | Max files per prefetch (1–64) |
108
+ | `speculateRootPrefix` | `""` | Only prefetch under this prefix |
109
+ | `shellConcurrency` | `false` | Readonly shell commands join dsh's parallel batch |
110
+ | `shellTools` | `["bash","pwsh"]` | Shell tools to patch |
111
+ | `debugLogFile` | unset | Write scheduling events here |
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
+
130
+ ## 中文
131
+
132
+ 给 DeepSeek Harness 用的工具执行效率套件,三个能力各自对应一类可实测的问题:
133
+
134
+ - **缓存**:只读工具重复调用直接命中(发布候选回归实测免掉 28 秒真实执行 / 6 轮)
135
+ - **预取**:`glob` 之后预读文件,模型开口时结果已在(实测 12/12 预取完成、结果全部从缓存供给)
136
+ - **只读 shell 并发**:dsh 原生会把 shell 命令串行(实测 4 条毫秒级命令串行 7.1 秒,
137
+ 开启后 317 毫秒完成,22 倍),本插件给只读命令打上并发标记,
138
+ 让它们进入 dsh 原生并行批(默认关闭,需显式开启)
139
+
140
+ 前提要清楚:模型延迟占关键路径的 87%,工具占 22%。本地毫秒级工具上这个插件无感;
141
+ 收益集中在"慢工具 + 重复调用"场景。数字、方法和反面结果在
142
+ [docs/BENCHMARK.md](docs/BENCHMARK.md)。
143
+
144
+ **配置覆盖是整体替换不是合并**——覆盖一个字段时请把要的字段全部写上。
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,15 @@
1
+ # dsh-agent-speedup 默认挂载
2
+ #
3
+ # 默认值策略(依据 docs/BENCHMARK.md 实测):
4
+ # cache/speculate 开启 —— 有实测收益(重复读 -46%,预取 4/4 命中)
5
+ # shellConcurrency 关闭 —— 判定错误会导致并发副作用,须用户显式开启并理解风险
6
+ - insert:
7
+ - id: agent-speedup
8
+ name: dsh-agent-speedup
9
+ config:
10
+ cache: true
11
+ speculate: true
12
+ prefetchN: 3
13
+ speculateOn: [glob]
14
+ shellConcurrency: false
15
+ shellTools: [bash, pwsh]
@@ -0,0 +1,108 @@
1
+ # Benchmark method and results
2
+
3
+ Measured 2026-11-01 / 02 on DeepSeek Harness 0.2.1-rc.5, Windows, `dsh headless` one process per task.
4
+ This document exists so the numbers in the main README have a stated method, a stated sample size,
5
+ and a stated set of conditions under which they do **not** hold.
6
+
7
+ ## Cross-session survey (the reason the scope is stated this way)
8
+
9
+ Before building, we analyzed 6 real agent sessions — 42 transcripts, 1,311 minutes, 2,517 tool
10
+ calls, all subagent workloads on this machine:
11
+
12
+ - Model streaming time: **87.2%** of the union critical path; tool execution windows: **22.1%**.
13
+ - Bash is the dominant tool everywhere: 75.9–83.8% of calls per session.
14
+ - **The model already batches through the shell**: 39.6% of bash commands contain ≥2 operations,
15
+ and 22–44% of commands carry shell loops. A tool-layer batch primitive largely duplicates what
16
+ the model already does.
17
+ - Same-message multi-calls exist (25.7% of turns) but 87.7% of them run serialized — and their
18
+ total span is only 26.8 min across all sessions, bounding the concurrency win at **1.9%**.
19
+ - WebFetch failed 14/14 in one session (proxy), which forced the agent to hand-roll `node fetch.mjs`
20
+ and run 64 single-URL rounds — a batch-fetch tool would have helped there, but that is a
21
+ network-egress problem, not a missing-tool problem.
22
+
23
+ Consequence for this plugin: the capabilities target **repeat calls, prefetchable reads, and
24
+ serial readonly shell commands** — not a general "make agents fast" claim. The model round trips
25
+ that dominate wall time are out of scope for any tool-layer plugin.
26
+
27
+ ## Setup
28
+
29
+ Every cell is the same five-task fixture, run three times, wall time measured from process spawn to
30
+ the final event, median reported.
31
+
32
+ - **Fixture**: six deterministic files (four small JS modules, `package.json`, `README.md`).
33
+ - **Tasks T1–T3** use the real tools (`read`, `glob`) — these are the "fast local tool" case.
34
+ - **Tasks T4–T5** use a benchmark instrument (`slow_read`, `slow_glob`) with a fixed 2 s artificial
35
+ delay per call, because on this stack the fast tools are too cheap to measure against the model.
36
+ A guard disables the fast tools during those tasks so the instrument is actually exercised.
37
+ - **Attribution** comes from two sources: timings stamped on the `--json` event stream, and the
38
+ plugin's own debug ledger (`debugLogFile`), which records `execMs` per miss and `avoidedMs` per hit.
39
+
40
+ ## Where the time goes
41
+
42
+ | Workload | Tool share of wall time |
43
+ |---|---|
44
+ | Fast local tools (T1–T3) | 0.1% – 9.4% |
45
+ | Slow instrument, 2 s per call (T4–T5) | 10% – 19% |
46
+
47
+ Model latency dominates in every configuration we measured. On a free-tier gateway the first token
48
+ alone ranged 2–10 s and varied by ±5–20 s between otherwise identical runs. That variance is larger
49
+ than most of the effects being measured, which is why the sample size below matters.
50
+
51
+ ## Results
52
+
53
+ Same task, plugin off versus on, three runs per cell, median wall time.
54
+
55
+ | Model / endpoint | Task | Off | On | Δ |
56
+ |---|---|---|---|---|
57
+ | deepseek-v4.1-flash @ opencode zen (paid) | T4: 3 reads, one repeated | 29.9 s | 16.0 s | **−46%** |
58
+ | deepseek-v4.1-flash @ opencode zen (paid) | T5: glob + 4 reads | 15.9 s | 14.0 s | −12% |
59
+ | sensenova-6.8-flash-lite (free tier) | T4: 3 reads, one repeated | 32.2 s | 25.4 s | −21% |
60
+ | sensenova-6.8-flash-lite (free tier) | T5: glob + 4 reads | 22.9 s | 22.8 s | −0.4% |
61
+
62
+ Mechanism-level confirmation, independent of wall time:
63
+
64
+ - T4: every run recorded exactly one `cache-hit` with `avoidedMs ≈ 2006` — the 2 s instrument delay
65
+ was actually skipped, not merely overlapped.
66
+ - T5: prefetch launched 4 of 4 files and the model's subsequent four reads all hit
67
+ (`speculative: true`), with zero prefetch failures.
68
+
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.
77
+
78
+ ## Two results that argue against the plugin
79
+
80
+ These were found during the work and are reported because they bound the claim.
81
+
82
+ **dsh already parallelises.** Concurrency-safe tools are dispatched in batches. In baseline runs the
83
+ model issued four `slow_read` calls at once and they completed in a single 2 s window. Prefetch
84
+ therefore saves the window it overlaps, not the sum of the read times. This is why T5 gains less
85
+ than T4 in every configuration.
86
+
87
+ **Stronger models rewrite paths.** With `deepseek-v4.1-flash`, two of three runs issued absolute
88
+ paths where the prefetcher had used relative ones. Without key normalization the cache would never
89
+ have hit. With normalization it does, but this episode is a reminder that a deterministic prefetcher
90
+ has to agree with the model's argument formatting, and models differ.
91
+
92
+ ## Limits
93
+
94
+ - n=3 per cell. Only the T4 improvement is consistent; the rest are within noise.
95
+ - One process per task, so the cache never survives between runs. Cross-run persistence is not
96
+ implemented and not measured.
97
+ - The prefetcher is a rule (`glob` result → first N same-family reads), not a learned predictor. Its
98
+ 4/4 hit rate reflects the task shape. For reference, PASTE — which mines patterns from traces —
99
+ reports 27.8% top-1 prediction accuracy and relies on multiple candidates.
100
+ - Side-effecting tools are out of scope entirely: no dry-run rewriting, no overlay commit, no
101
+ speculative `bash`.
102
+
103
+ ## Prior art
104
+
105
+ [PASTE](https://arxiv.org/abs/2603.18897) ·
106
+ [TomasuLLM](https://arxiv.org/abs/2609.38201) ·
107
+ [SPAgent](https://arxiv.org/abs/2511.20048) ·
108
+ [LLMCompiler](https://arxiv.org/abs/2312.04511)
package/lib/index.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { z } from "zod";
2
+
3
+ /** Cordis 插件标识 */
4
+ export declare const name: "tool-scheduler";
5
+ /** 依赖的宿主服务 */
6
+ export declare const inject: readonly ["tools"];
7
+
8
+ /** 插件配置 schema */
9
+ export declare const Config: z.ZodObject<{
10
+ cache: z.ZodDefault<z.ZodBoolean>;
11
+ speculate: z.ZodDefault<z.ZodBoolean>;
12
+ prefetchN: z.ZodDefault<z.ZodNumber>;
13
+ readonlyTools: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
+ speculateOn: z.ZodDefault<z.ZodArray<z.ZodString>>;
15
+ speculateRootPrefix: z.ZodDefault<z.ZodString>;
16
+ validateStat: z.ZodDefault<z.ZodBoolean>;
17
+ debugLogFile: z.ZodOptional<z.ZodString>;
18
+ }>;
19
+
20
+ export type SchedulerConfig = z.infer<typeof Config>;
21
+
22
+ /** Cordis 插件入口 */
23
+ export declare function apply(ctx: unknown, config: SchedulerConfig): void;
24
+
25
+ /** 纯函数(导出以便单测) */
26
+ export declare function normPath(value: unknown): string;
27
+ export declare function keyOf(tool: string, args: Record<string, unknown> | undefined): string;
28
+ export declare function pathsFromResult(result: unknown, filter?: string): string[];
package/lib/index.js ADDED
@@ -0,0 +1,328 @@
1
+ // dsh-agent-speedup — DeepSeek Harness 工具执行效率套件
2
+ //
3
+ // 三个独立能力,各自标注适用域与实测收益(数据见 docs/BENCHMARK.md):
4
+ //
5
+ // 1. cache 只读工具结果缓存(重复调用直接命中)
6
+ // 适用域:同一只读调用重复发生。实测:重复读场景 wall -46%(付费 DeepSeek 栈)。
7
+ // 本地毫秒级工具负载无感(工具仅占 wall 0.1–9.4%)。
8
+ //
9
+ // 2. prefetch glob 族结果 → 后台预读同族文件(结果进同一缓存)
10
+ // 适用域:glob 之后紧接多个 read 的序列。实测:4/4 预取命中。
11
+ //
12
+ // 3. shellConcurrency 只读 shell 命令标记为并发安全,进入 dsh 原生并行批
13
+ // 适用域:同一 assistant 消息里有多条只读命令。实测:Phase 0 基线显示
14
+ // 5 个 pwsh 调用被串行执行(19.4s,实际都是毫秒级命令);启用后 4 条
15
+ // 只读命令在 8ms 内完成并发判定并同批执行。
16
+ //
17
+ // 安全边界(对所有能力一致):
18
+ // - 副作用工具(bash/pwsh/write/edit)永不缓存、永不预取
19
+ // - shell 并发判定保守优先:命令逐段命中只读白名单且不含写入模式才放行
20
+ // - 错误结果不进缓存;缓存条目带 stat 指纹,命中前校验
21
+ //
22
+ // 定位声明:推测性工具执行由 PASTE(arXiv 2603.18897)、TomasuLLM(arXiv 2609.38201)
23
+ // 先行提出;本包是该思路在 DeepSeek Harness 上的工程化实现,不主张新机制。
24
+ import { appendFileSync, mkdirSync, statSync } from "node:fs";
25
+ import { dirname, relative, resolve as resolvePath } from "node:path";
26
+ import { z } from "zod";
27
+ import { classifyCommand } from "./util/classify-command.js";
28
+
29
+ /** Cordis 插件标识 */
30
+ const name = "agent-speedup";
31
+ /** 依赖的宿主服务 */
32
+ const inject = ["tools"];
33
+
34
+ /** 配置 schema(Web UI 设置页与 loader 校验共用) */
35
+ const Config = z.object({
36
+ /** 只读工具结果缓存开关 */
37
+ cache: z.boolean().default(true),
38
+ /** 额外纳入缓存白名单的工具名 */
39
+ readonlyTools: z.array(z.string()).default([]),
40
+ /** 命中前校验文件 stat,文件变化即失效 */
41
+ validateStat: z.boolean().default(true),
42
+
43
+ /** 推测预取开关 */
44
+ speculate: z.boolean().default(true),
45
+ /** 触发推测的工具名(glob 族) */
46
+ speculateOn: z.array(z.string()).default(["glob"]),
47
+ /** 单次推测最多预取的文件数 */
48
+ prefetchN: z.number().int().min(1).max(64).default(3),
49
+ /** 仅预取以此前缀开头的路径(防 node_modules 漫灌);空 = 不限 */
50
+ speculateRootPrefix: z.string().default(""),
51
+
52
+ /** 只读 shell 命令进入 dsh 原生并行批(默认关:判定错误会有并发副作用风险) */
53
+ shellConcurrency: z.boolean().default(false),
54
+ /** 参与并发判定的 shell 工具名 */
55
+ shellTools: z.array(z.string()).default(["bash", "pwsh"]),
56
+
57
+ /** 调试:调度事件写入此文件(默认关闭,不写用户目录) */
58
+ debugLogFile: z.string().optional(),
59
+ });
60
+
61
+ const DEFAULT_READONLY = ["read", "glob", "grep", "read_image"];
62
+ /** glob 族 → 同族读取工具 */
63
+ const SPECULATE_READ_OF = { glob: "read", slow_glob: "slow_read" };
64
+
65
+ /** 路径规范化:分隔符统一 + resolve 后相对化到 cwd。
66
+ * 强模型常把相对路径 absolutize,不归一会让预取与真实调用的键对不上。 */
67
+ function normPath(value) {
68
+ const raw = String(value ?? "");
69
+ if (!/[\\/]/.test(raw)) return raw.replaceAll("\\", "/");
70
+ try {
71
+ return relative(process.cwd(), resolvePath(raw)).replaceAll("\\", "/") || raw;
72
+ } catch {
73
+ return raw.replaceAll("\\", "/");
74
+ }
75
+ }
76
+
77
+ /** 稳定键:工具名 + 规范化参数(键序固定)。
78
+ * 只有明确的路径类参数才做路径归一——grep 的 pattern 是正则语义,
79
+ * 对它做 resolve/relative 会把 `a\db` 与 `a/db` 归一成同一键(假命中)。 */
80
+ function keyOf(tool, args) {
81
+ const normalized = {};
82
+ for (const k of Object.keys(args ?? {}).sort()) {
83
+ const v = args[k];
84
+ normalized[k] = typeof v === "string" && PATH_ARGS.has(k) ? normPath(v) : v;
85
+ }
86
+ return `${tool}|${JSON.stringify(normalized)}`;
87
+ }
88
+
89
+ /** 已知承载文件路径的参数名;白名单外的字符串一律原样入键 */
90
+ const PATH_ARGS = new Set(["file_path", "path", "dir", "directory", "cwd", "file", "target"]);
91
+
92
+ /** 文件 stat 指纹;失败返回 undefined(无法校验) */
93
+ function statFingerprint(path) {
94
+ try {
95
+ const s = statSync(path);
96
+ return `${s.size}:${Math.floor(s.mtimeMs)}`;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /** 从工具结果文本抽取候选文件路径 */
103
+ function pathsFromResult(result, filter) {
104
+ let text = "";
105
+ if (typeof result === "string") text = result;
106
+ else if (Array.isArray(result?.content)) text = result.content.map((b) => b?.text ?? "").join("\n");
107
+ else if (typeof result?.text === "string") text = result.text;
108
+ else if (Array.isArray(result?.files)) text = result.files.join("\n");
109
+ else {
110
+ try { text = JSON.stringify(result); } catch { return []; }
111
+ }
112
+ return text
113
+ .split(/\r?\n/)
114
+ .map((s) => normPath(String(s).replace(/^["[]+|["\]]+$/g, "").trim()))
115
+ .filter((s) => /\.(js|ts|mjs|cjs|json|md|txt|py|go|rs|java|yml|yaml|toml)$/i.test(s) && !s.endsWith("/"))
116
+ .filter((s) => !filter || s.startsWith(filter));
117
+ }
118
+
119
+ export function apply(ctx, config) {
120
+ const cfg = {
121
+ ...config,
122
+ readonlyTools: new Set([...DEFAULT_READONLY, ...(config.readonlyTools ?? [])]),
123
+ speculateOn: new Set(config.speculateOn ?? ["glob"]),
124
+ };
125
+
126
+ /** key -> { result, spec, execMs, path?, fingerprint? } */
127
+ const cache = new Map();
128
+ const prefetching = new Set();
129
+ const stats = { hit: 0, hitSpeculative: 0, stale: 0, miss: 0, speculativeLaunched: 0, speculativeFailed: 0 };
130
+ let logReady = false;
131
+
132
+ const log = (entry) => {
133
+ if (!cfg.debugLogFile) return;
134
+ try {
135
+ if (!logReady) { mkdirSync(dirname(cfg.debugLogFile), { recursive: true }); logReady = true; }
136
+ appendFileSync(cfg.debugLogFile, JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
137
+ } catch { /* 观测失败不影响调度 */ }
138
+ };
139
+
140
+ /** 命中校验:带路径指纹且开启 validateStat 时必须一致 */
141
+ const lookup = (key) => {
142
+ const entry = cache.get(key);
143
+ if (entry === undefined) return undefined;
144
+ if (cfg.validateStat && entry.path !== undefined && entry.fingerprint !== undefined) {
145
+ if (statFingerprint(entry.path) !== entry.fingerprint) {
146
+ cache.delete(key);
147
+ stats.stale++;
148
+ log({ kind: "cache-stale", key });
149
+ return undefined;
150
+ }
151
+ }
152
+ return entry;
153
+ };
154
+
155
+ // ================= 能力 1:缓存 =================
156
+ ctx.on("tools/execute", async (exec, next) => {
157
+ if (!cfg.cache || !cfg.readonlyTools.has(exec?.name)) return next();
158
+ const key = keyOf(exec.name, exec.arguments);
159
+ const t0 = Date.now();
160
+ const hit = lookup(key);
161
+ if (hit !== undefined) {
162
+ stats.hit++;
163
+ if (hit.spec) stats.hitSpeculative++;
164
+ log({ kind: "cache-hit", tool: exec.name, key, speculative: hit.spec, avoidedMs: hit.execMs ?? null });
165
+ return hit.result;
166
+ }
167
+ const result = await next();
168
+ if (result?.isError) { stats.miss++; return result; }
169
+ const filePath = exec.arguments?.file_path;
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
+ }
179
+ cache.set(key, {
180
+ result,
181
+ spec: prefetching.has(key),
182
+ execMs,
183
+ volatile: typeof filePath !== "string", // glob/grep 等无指纹条目:写意图/shell 调用后整体失效
184
+ ...(typeof filePath === "string" ? { path: normPath(filePath) } : {}),
185
+ ...(fp !== null ? { fingerprint: fp } : {}),
186
+ });
187
+ stats.miss++;
188
+ log({ kind: "cache-miss", tool: exec.name, key, speculative: prefetching.has(key), execMs });
189
+ return result;
190
+ });
191
+
192
+ // ================= 能力 2:推测预取 =================
193
+ /** 在途预取的控制器:卸载时统一 abort,防止 teardown 后仍写缓存/日志 */
194
+ const inflightControllers = new Set();
195
+ const prefetch = (paths, tool) => {
196
+ const targets = [];
197
+ for (const p of paths) {
198
+ if (targets.length >= cfg.prefetchN) break;
199
+ const key = keyOf(tool, { file_path: p });
200
+ if (cache.has(key) || prefetching.has(key)) continue;
201
+ prefetching.add(key);
202
+ targets.push({ path: p, key, startedAt: 0 });
203
+ }
204
+ for (const t of targets) {
205
+ stats.speculativeLaunched++;
206
+ log({ kind: "spec-start", tool, path: t.path });
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(); };
211
+ try {
212
+ ctx.tools
213
+ .execute({
214
+ name: tool,
215
+ arguments: { file_path: t.path },
216
+ callId: `spec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
217
+ signal: controller.signal,
218
+ })
219
+ .then((result) => settle(() => {
220
+ if (controller.signal.aborted) return;
221
+ if (result?.isError) { stats.speculativeFailed++; log({ kind: "spec-fail", path: t.path, reason: "error-result" }); return; }
222
+ if (cache.has(t.key)) { log({ kind: "spec-done", path: t.path, stored: false, note: "real-call-won" }); return; }
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 });
225
+ }))
226
+ .catch((e) => settle(() => { stats.speculativeFailed++; log({ kind: "spec-fail", path: t.path, error: String(e).slice(0, 160) }); }));
227
+ } catch (e) {
228
+ settle(() => { stats.speculativeFailed++; log({ kind: "spec-fail", path: t.path, error: String(e).slice(0, 160) }); });
229
+ }
230
+ t.startedAt = Date.now();
231
+ }
232
+ };
233
+
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");
238
+ if (cfg.speculate && cfg.speculateOn.has(exec?.name) && result != null) {
239
+ const paths = pathsFromResult(result, cfg.speculateRootPrefix);
240
+ if (paths.length) prefetch(paths, SPECULATE_READ_OF[exec.name] ?? "read");
241
+ }
242
+ return next();
243
+ });
244
+
245
+ // ================= 能力 2.5:写意图即时失效 =================
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
+ };
263
+ const invalidateByTarget = (target) => {
264
+ const key = target?.targetKey ?? target?.displayPath;
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
+ }
272
+ }
273
+ }
274
+ dropVolatile("write-intent");
275
+ };
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(); });
278
+
279
+ // ================= 能力 3:只读 shell 并发 =================
280
+ const shellPatched = new Set();
281
+ const patchShellConcurrency = () => {
282
+ if (!cfg.shellConcurrency) return;
283
+ for (const toolName of cfg.shellTools) {
284
+ if (shellPatched.has(toolName)) continue;
285
+ const tool = ctx.tools?.get?.(toolName);
286
+ if (!tool) continue;
287
+ if (typeof tool.isConcurrencySafe === "function") {
288
+ shellPatched.add(toolName);
289
+ log({ kind: "shell-concurrency-skip", tool: toolName, reason: "already-declared" });
290
+ continue;
291
+ }
292
+ try {
293
+ tool.isConcurrencySafe = (args) => classifyCommand(args?.command).readonly;
294
+ shellPatched.add(toolName);
295
+ log({ kind: "shell-concurrency-patched", tool: toolName });
296
+ } catch (e) {
297
+ shellPatched.add(toolName);
298
+ log({ kind: "shell-concurrency-failed", tool: toolName, error: String(e).slice(0, 160) });
299
+ }
300
+ }
301
+ };
302
+ // 瀑布契约:(exec, next),必须返回 next() 否则截断链条
303
+ ctx.on("tools/pre-execute", (exec, next) => {
304
+ patchShellConcurrency();
305
+ // 判定日志:每次 shell 调用都记录分类结果
306
+ if (cfg.shellConcurrency && cfg.shellTools.includes(exec?.name)) {
307
+ const verdict = classifyCommand(exec.arguments?.command);
308
+ log({ kind: "shell-concurrency-judge", tool: exec.name, readonly: verdict.readonly, reason: verdict.reason });
309
+ }
310
+ return next();
311
+ });
312
+
313
+ // ================= 生命周期 =================
314
+ const bootTimers = [100, 500, 1500].map((ms) => setTimeout(patchShellConcurrency, ms));
315
+ ctx.effect(() => () => {
316
+ for (const t of bootTimers) clearTimeout(t);
317
+ for (const c of inflightControllers) c.abort(new Error("plugin unloaded"));
318
+ inflightControllers.clear();
319
+ cache.clear();
320
+ prefetching.clear();
321
+ }, "agent-speedup teardown");
322
+
323
+ log({ kind: "plugin-boot", cfg: { cache: cfg.cache, speculate: cfg.speculate, prefetchN: cfg.prefetchN, shellConcurrency: cfg.shellConcurrency } });
324
+ }
325
+
326
+ export { Config, inject, name };
327
+ // 纯函数导出,供单测使用
328
+ export { keyOf, normPath, pathsFromResult };
@@ -0,0 +1,66 @@
1
+ // 只读命令分类器:判断一条 shell 命令是否有副作用
2
+ // 保守优先:拿不准时返回 false(当作有副作用,串行执行)
3
+
4
+ /** 明确只读的命令/子命令(前缀匹配) */
5
+ const READONLY_PREFIXES = [
6
+ // POSIX
7
+ "ls", "cat", "head", "tail", "stat", "wc", "pwd", "echo", "which", "whereis",
8
+ "file", "du", "df", "tree", "realpath", "basename", "dirname", "date", "uname",
9
+ "grep", "rg", "ag", "ack", "find", "fd", "locate",
10
+ "git log", "git status", "git diff", "git show", "git branch", "git remote",
11
+ "git ls-files", "git rev-parse", "git describe", "git blame", "git tag -l",
12
+ "npm ls", "npm list", "npm view", "npm info", "npm outdated",
13
+ "node --version", "node -v", "python --version", "go version", "cargo --version",
14
+ "docker ps", "docker images", "docker inspect", "kubectl get", "kubectl describe",
15
+ // PowerShell
16
+ "get-childitem", "get-location", "get-content", "get-item", "get-itemproperty",
17
+ "select-string", "measure-object", "test-path", "resolve-path", "get-command",
18
+ "write-output", "write-host", "get-process", "get-service", "get-date",
19
+ // 纯只读的管道筛选器(PowerShell 惯用)
20
+ "select-object", "where-object", "sort-object", "group-object", "format-table",
21
+ "format-list", "convertto-json", "convertfrom-json", "out-string", "foreach-object",
22
+ ];
23
+
24
+ /** 出现即判定为有副作用的模式(优先级最高) */
25
+ const WRITE_PATTERNS = [
26
+ /\s>\s*\S/, // 输出重定向
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
30
+ /\b(rm|rmdir|del|erase|mv|move|cp|copy|install|uninstall)\b/i,
31
+ /\b(mkdir|md|touch|new-item|set-content|add-content|out-file)\b/i,
32
+ /\b(sed\s+-i|tee|truncate|chmod|chown|chgrp)\b/i,
33
+ /\b(npm (install|i|uninstall|update|ci)|pnpm (add|install|remove)|yarn (add|install)|pip install|cargo install|go install)\b/i,
34
+ /\b(git (commit|push|pull|merge|rebase|reset|checkout|switch|stash|clean|add|clone|init))\b/i,
35
+ /\b(docker (run|build|rm|rmi|stop|kill|pull|push)|kubectl (apply|delete|create|patch|scale))\b/i,
36
+ /\b(api|curl|wget|invoke-webrequest|invoke-restmethod)\b/i,
37
+ /\b(remove-item|start-process|stop-process|kill|taskkill|shutdown|reboot)\b/i,
38
+ /\b(eval|exec|source\s+\S|\.\s+\S+\.(sh|ps1))\b/i,
39
+ ];
40
+
41
+ /** 命令分隔符:任一段有副作用即整体有副作用 */
42
+ const SEGMENT_SPLIT = /\s*(?:;|&&|\|\||\||\n)\s*/;
43
+
44
+ /**
45
+ * @param {string} command 完整命令行
46
+ * @returns {{readonly: boolean, reason: string}}
47
+ */
48
+ export function classifyCommand(command) {
49
+ const raw = String(command ?? "").trim();
50
+ if (!raw) return { readonly: false, reason: "empty" };
51
+ // 有副作用特征 → 立即判 false
52
+ for (const p of WRITE_PATTERNS) {
53
+ if (p.test(raw)) return { readonly: false, reason: `write-pattern ${p.source.slice(0, 30)}` };
54
+ }
55
+ // 拆分后每一段都必须是只读命令
56
+ const segs = raw.split(SEGMENT_SPLIT).map((s) => s.trim()).filter(Boolean);
57
+ if (!segs.length) return { readonly: false, reason: "no-segment" };
58
+ for (const seg of segs) {
59
+ const lower = seg.toLowerCase();
60
+ // 忽略变量赋值前缀 FOO=bar cmd
61
+ const cmdPart = lower.replace(/^[a-z_][a-z0-9_]*=\S*\s+/g, "");
62
+ const ok = READONLY_PREFIXES.some((p) => cmdPart === p || cmdPart.startsWith(p + " ") || cmdPart.startsWith(p + "|"));
63
+ if (!ok) return { readonly: false, reason: `not-whitelisted: ${seg.slice(0, 40)}` };
64
+ }
65
+ return { readonly: true, reason: "all-segments-readonly" };
66
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "dsh-agent-speedup",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
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
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./package.json": "./package.json",
13
+ "./cordis.patch.yml": "./cordis.patch.yml"
14
+ },
15
+ "files": [
16
+ "lib/index.js",
17
+ "lib/index.d.ts",
18
+ "lib/util/classify-command.js",
19
+ "cordis.patch.yml",
20
+ "docs/BENCHMARK.md",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "deepseek-harness",
28
+ "cordis",
29
+ "agent",
30
+ "tool-scheduling",
31
+ "speculative-execution",
32
+ "caching",
33
+ "concurrency",
34
+ "performance"
35
+ ],
36
+ "dependencies": {
37
+ "zod": "^4.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/cordis": ">=4.0.0",
41
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@deepseek-ai/cordis": {
45
+ "optional": true
46
+ },
47
+ "@deepseek-ai/dsh-tools": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "scripts": {
52
+ "test": "node --test test/"
53
+ },
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ }
58
+ },
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/HJ1H/dsh-agent-speedup.git"
62
+ },
63
+ "homepage": "https://github.com/HJ1H/dsh-agent-speedup#readme",
64
+ "bugs": {
65
+ "url": "https://github.com/HJ1H/dsh-agent-speedup/issues"
66
+ },
67
+ "engines": {
68
+ "node": ">=22"
69
+ },
70
+ "license": "MIT"
71
+ }