dsh-root-planner-preset 1.1.5 → 1.1.7
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 +20 -2
- package/README.zh.md +13 -2
- package/cordis.patch.yml +3 -2
- package/package.json +1 -1
- package/persona.txt +3 -2
- package/release.mjs +26 -6
- package/tools/check-pwsh-background.mjs +91 -0
- package/tools/child-status.mjs +102 -0
- package/tools/count-tool-calls.mjs +78 -0
- package/tools/extract-offered-tools.mjs +69 -0
- package/tools/measure-read-overlap.mjs +134 -0
- package/verify-patch.mjs +24 -0
package/README.md
CHANGED
|
@@ -122,9 +122,27 @@ Then **start a new session** and pick **Root Planner** in the preset selector. F
|
|
|
122
122
|
|
|
123
123
|
**Verified**: the patch parses as loader YAML; the parsed persona is **byte-identical** to `persona.txt`; both delegation masks exclude every mutation entry point and name only tools the runtime reports as global; a clean checkout passes verification (`.gitattributes` pins LF, without which a Windows clone breaks the byte-identity check); the depth override reached the composed profile.
|
|
124
124
|
|
|
125
|
-
**
|
|
125
|
+
**Verified end to end on a real task** — a 24-descendant run over a large reverse-engineering directory tree. Every line below was read from the session logs' own persisted tool schemas, which are the registry's output rather than a model's recollection:
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
| Property | Evidence |
|
|
128
|
+
|---|---|
|
|
129
|
+
| The mask reaches every child | The root is offered **31** tools; depth-1 and depth-2 agents are offered **25**. The difference is exactly the six the mask omits: `workflow`, `present`, `job_output`, `job_list`, `job_kill`, `exit_plan_mode`. |
|
|
130
|
+
| Subagents really are workers | `write`, `edit`, `ops_replace` and `pwsh` are present at every depth and were used: 10 `write` and 60 `edit` from the root, 14 `write` from one depth-1 agent, 20 `write` from a depth-2 agent. |
|
|
131
|
+
| No subagent can leave a process running | `run_in_background` is absent from the `pwsh` schema at all three depths. (A naive log search says otherwise — the phrase also occurs in `ops_bash`'s own description, which is why `tools/check-pwsh-background.mjs` inspects the schema window instead.) |
|
|
132
|
+
| Recursion works | 12 direct children, 4 of which delegated onward to 12 grandchildren; the root itself made 14 `subagent` calls. |
|
|
133
|
+
| The depth cap holds | The tree stops at depth 2; no depth-3 session exists anywhere in the log directory. |
|
|
134
|
+
| The live-child cap is soft, not fatal | One `subagent limit reached (active child limit: 8)` during an 8-wide fan-out. Settlements returned slots as children finished, and every later delegation succeeded. |
|
|
135
|
+
|
|
136
|
+
**Corrected after the fact**: an earlier version of this file claimed the mask made
|
|
137
|
+
children leaf agents. It does not. `subagent` is registered per composed agent rather
|
|
138
|
+
than globally, so the mask can never name it — and because a restriction filters
|
|
139
|
+
globals only, a child keeps it. Recursion is bounded by `maxDepth`, not by the mask.
|
|
140
|
+
|
|
141
|
+
**How to check it yourself**: `tools/verify-session.mjs` proves which preset each
|
|
142
|
+
session ran and at what depth, but it does not read tool schemas. Use
|
|
143
|
+
`tools/extract-offered-tools.mjs` for the per-level tool list,
|
|
144
|
+
`tools/check-pwsh-background.mjs` for the process bound, and
|
|
145
|
+
`tools/count-tool-calls.mjs` for what a level actually called.
|
|
128
146
|
|
|
129
147
|
## Contributing
|
|
130
148
|
|
package/README.zh.md
CHANGED
|
@@ -102,9 +102,20 @@ dsh plugin --profile web add github:XiaoOu2024/dsh-root-planner-preset
|
|
|
102
102
|
|
|
103
103
|
**已验证**:补丁可被 loader 解析;解析出的人设与 `persona.txt` **逐字节相同**;两个委派工具的白名单排除了所有写入入口、且只包含**运行时报告的全局工具**;干净检出后校验通过(`.gitattributes` 已钉死 LF,否则 Windows 克隆会破坏逐字节校验);深度覆盖已落到合成配置。
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
**已在真实任务上端到端验证**——那次运行在一个大型逆向工程目录树上产生了 **24 个后代会话**。下面每一条都读自**会话日志自己持久化的工具 schema**,那是注册表的输出,不是模型的回忆:
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
| 性质 | 证据 |
|
|
108
|
+
|---|---|
|
|
109
|
+
| 白名单覆盖每一层 | 根被提供 **31** 个工具;深度 1 与深度 2 的 agent 都是 **25** 个。差额正好是白名单排除的那 6 个:`workflow`、`present`、`job_output`、`job_list`、`job_kill`、`exit_plan_mode`。 |
|
|
110
|
+
| 子代理确实是工人 | `write`、`edit`、`ops_replace`、`pwsh` 在每一层都在,而且真的被用了:根 10 次 `write` + 60 次 `edit`,某个深度 1 的子代理 14 次 `write`,某个深度 2 的孙代理 20 次 `write`。 |
|
|
111
|
+
| 子代理留不下运行中的进程 | `run_in_background` 在三个层级的 `pwsh` schema 里**都不存在**。(朴素的全日志搜索会给出相反答案——`ops_bash` 自己的描述里也有这个词,所以要用 `tools/check-pwsh-background.mjs` 看 schema 窗口。) |
|
|
112
|
+
| 递归成立 | 12 个直接子代理,其中 4 个继续派出了 12 个孙代理;根自己做了 14 次 `subagent` 调用。 |
|
|
113
|
+
| 深度上限有效 | 树停在深度 2;整个日志目录里不存在任何深度 3 的会话。 |
|
|
114
|
+
| 在世上限是软拒绝而非致命 | 8 路扇出时出现一次 `subagent limit reached (active child limit: 8)`;子代理完结时结算通知归还了槽位,此后每次委派都成功。 |
|
|
115
|
+
|
|
116
|
+
**一处事后更正**:本文件早期版本称"白名单让子代理成为叶子"。**并非如此。** `subagent` 是按每个被组合 Agent 注册、而非全局注册,所以白名单**永远无法写下它的名字**;而限制只过滤全局工具,因此子代理**保有此工具**。递归的边界是 `maxDepth`,不是白名单。
|
|
117
|
+
|
|
118
|
+
**想自己核**:`tools/verify-session.mjs` 能证明每个会话跑的是哪个预设、深度多少,但它不读工具 schema。逐层工具清单用 `tools/extract-offered-tools.mjs`,进程边界用 `tools/check-pwsh-background.mjs`,实际调用用 `tools/count-tool-calls.mjs`。
|
|
108
119
|
|
|
109
120
|
## 参与维护
|
|
110
121
|
|
package/cordis.patch.yml
CHANGED
|
@@ -52,8 +52,9 @@
|
|
|
52
52
|
9. Treat a summary as evidence, not a verdict. Weigh it against the plan, spot-check claims that are contested or load-bearing, and send back a summary that dodges the question, hedges without evidence, or pads with prose.
|
|
53
53
|
10. **Leave nothing running.** No servers, watchers, daemons, or detached processes — for you as much as for a subagent. Background execution is your shell's decision to offer, not a convenience to reach for: if a task seems to need a long-lived process, do it in the foreground, bound it with a timeout, or ask the user before starting anything that outlives the work.
|
|
54
54
|
11. Answer a subagent that reports a problem or an out-of-scope finding. Decide it, then reply with `send_message` so the child can continue: either the change is approved and who makes it, or it is out of scope and why, or what to do instead. An unanswered report leaves the child blocked on a decision only you can make. **A child that reports a clean result needs no reply** — silence is the correct response, so do not invent an acknowledgement.
|
|
55
|
-
12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself.
|
|
56
|
-
13.
|
|
55
|
+
12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself. **One writer per path, always:** if two subtasks genuinely need to modify the same file, run them one after the other rather than together, and never let two agents own the same output directory.
|
|
56
|
+
13. Reuse a child before spawning a new one. A finished child still holds everything it read, and `send_message` starts another turn in that same context — so a follow-up about the same material belongs there, not in a fresh agent that must read it all again. Sessions are isolated and a sibling cannot be told what another one already read, so overlapping reads across agents are a real cost you pay twice: once in tokens and once in the chance that two agents report the same file differently. When several subtasks need the same document, give them to one agent, or read it yourself and pass the conclusion down.
|
|
57
|
+
14. Decide who runs it, and do not hand a command down out of habit. Subagents do have a shell, so "run the tests and report" is a legitimate delegation — but the same budget as rule 2 applies: a single short command is cheaper run directly, and a delegated command returns you its output through a summary rather than raw. Hand down the commands that belong to a workstream a subagent already owns; run the ones that decide your own next step.
|
|
57
58
|
|
|
58
59
|
## Subagent rules
|
|
59
60
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-root-planner-preset",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH agent preset that outsources file reading to strictly read-only subagents, while the root agent keeps the plan and owns every file mutation.",
|
|
6
6
|
"license": "MIT",
|
package/persona.txt
CHANGED
|
@@ -31,8 +31,9 @@ Reading is not planning. Raw file contents and raw query results belong in the a
|
|
|
31
31
|
9. Treat a summary as evidence, not a verdict. Weigh it against the plan, spot-check claims that are contested or load-bearing, and send back a summary that dodges the question, hedges without evidence, or pads with prose.
|
|
32
32
|
10. **Leave nothing running.** No servers, watchers, daemons, or detached processes — for you as much as for a subagent. Background execution is your shell's decision to offer, not a convenience to reach for: if a task seems to need a long-lived process, do it in the foreground, bound it with a timeout, or ask the user before starting anything that outlives the work.
|
|
33
33
|
11. Answer a subagent that reports a problem or an out-of-scope finding. Decide it, then reply with `send_message` so the child can continue: either the change is approved and who makes it, or it is out of scope and why, or what to do instead. An unanswered report leaves the child blocked on a decision only you can make. **A child that reports a clean result needs no reply** — silence is the correct response, so do not invent an acknowledgement.
|
|
34
|
-
12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself.
|
|
35
|
-
13.
|
|
34
|
+
12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself. **One writer per path, always:** if two subtasks genuinely need to modify the same file, run them one after the other rather than together, and never let two agents own the same output directory.
|
|
35
|
+
13. Reuse a child before spawning a new one. A finished child still holds everything it read, and `send_message` starts another turn in that same context — so a follow-up about the same material belongs there, not in a fresh agent that must read it all again. Sessions are isolated and a sibling cannot be told what another one already read, so overlapping reads across agents are a real cost you pay twice: once in tokens and once in the chance that two agents report the same file differently. When several subtasks need the same document, give them to one agent, or read it yourself and pass the conclusion down.
|
|
36
|
+
14. Decide who runs it, and do not hand a command down out of habit. Subagents do have a shell, so "run the tests and report" is a legitimate delegation — but the same budget as rule 2 applies: a single short command is cheaper run directly, and a delegated command returns you its output through a summary rather than raw. Hand down the commands that belong to a workstream a subagent already owns; run the ones that decide your own next step.
|
|
36
37
|
|
|
37
38
|
## Subagent rules
|
|
38
39
|
|
package/release.mjs
CHANGED
|
@@ -271,13 +271,33 @@ if (dryRun || skipInstall) {
|
|
|
271
271
|
// ── CI ───────────────────────────────────────────────────────────────────────
|
|
272
272
|
if (!dryRun && !skipGh) {
|
|
273
273
|
step('Wait for CI on the pushed commit');
|
|
274
|
+
const repo = 'XiaoOu2024/dsh-root-planner-preset';
|
|
274
275
|
const sha = git('rev-parse', 'HEAD');
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
|
|
276
|
+
// `gh run watch <sha>` is wrong: the bare argument is a run ID, so a commit hash
|
|
277
|
+
// returns HTTP 404. Select the run by commit, then watch it by ID; if no run has
|
|
278
|
+
// registered yet, say so rather than reporting a failure that never happened.
|
|
279
|
+
let runId;
|
|
280
|
+
for (let attempt = 1; attempt <= 12 && runId === undefined; attempt += 1) {
|
|
281
|
+
const listed = run(
|
|
282
|
+
GH,
|
|
283
|
+
['run', 'list', '--repo', repo, '--commit', sha, '--limit', '1', '--json', 'databaseId'],
|
|
284
|
+
{ allowFailure: true },
|
|
285
|
+
);
|
|
286
|
+
try {
|
|
287
|
+
const [first] = JSON.parse(listed.trim() || '[]');
|
|
288
|
+
if (first?.databaseId !== undefined) runId = String(first.databaseId);
|
|
289
|
+
} catch {
|
|
290
|
+
/* the run has not registered yet */
|
|
291
|
+
}
|
|
292
|
+
if (runId === undefined) await new Promise((resolve) => setTimeout(resolve, 10_000));
|
|
293
|
+
}
|
|
294
|
+
if (runId === undefined) {
|
|
295
|
+
process.stdout.write(` no CI run registered for ${sha.slice(0, 8)} within 2 minutes; check it by hand\n`);
|
|
296
|
+
} else {
|
|
297
|
+
const waited = run(GH, ['run', 'watch', runId, '--repo', repo, '--exit-status'], { allowFailure: true });
|
|
298
|
+
const tail = waited.trim().split('\n').filter((line) => line.trim().length > 0).slice(-2);
|
|
299
|
+
process.stdout.write(` ${tail.join('\n ') || `run ${runId} finished`}\n`);
|
|
300
|
+
}
|
|
281
301
|
}
|
|
282
302
|
|
|
283
303
|
step(dryRun ? 'Dry run complete — nothing was changed' : `Released ${manifest.name}@${version}`);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Checks whether `run_in_background` still exists inside a session's `pwsh` tool
|
|
2
|
+
// schema — the one property this preset enforces structurally rather than by asking
|
|
3
|
+
// the model nicely.
|
|
4
|
+
//
|
|
5
|
+
// A whole-log substring search cannot answer it: the phrase also appears in
|
|
6
|
+
// `ops_bash`'s own description on this machine, so the naive check reports "present"
|
|
7
|
+
// for a session where the parameter has genuinely been removed.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node tools/check-pwsh-background.mjs <session-dir> [...]
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
14
|
+
|
|
15
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
16
|
+
function frameEnd(buf) {
|
|
17
|
+
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
18
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
19
|
+
}
|
|
20
|
+
return buf.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Decompress a concatenated-zstd log frame by frame. */
|
|
24
|
+
async function decompressAll(buf) {
|
|
25
|
+
let rest = buf;
|
|
26
|
+
let text = '';
|
|
27
|
+
while (rest.length > 0) {
|
|
28
|
+
const slice = rest.subarray(0, frameEnd(rest));
|
|
29
|
+
try {
|
|
30
|
+
text += await new Promise((resolve, reject) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
const dec = createZstdDecompress();
|
|
33
|
+
dec.on('data', (chunk) => chunks.push(chunk));
|
|
34
|
+
dec.on('error', reject);
|
|
35
|
+
dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
36
|
+
dec.end(slice);
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (slice.length >= rest.length) break;
|
|
42
|
+
rest = rest.subarray(slice.length);
|
|
43
|
+
}
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Find every `"name":"<tool>"` occurrence and return the window of text that follows
|
|
49
|
+
* it — close enough to hold that tool's own parameters object.
|
|
50
|
+
* @param text - whole decompressed log.
|
|
51
|
+
* @param tool - tool name to locate.
|
|
52
|
+
* @returns the following windows, longest first.
|
|
53
|
+
*/
|
|
54
|
+
function windowsAfter(text, tool) {
|
|
55
|
+
const needle = `\\"name\\":\\"${tool}\\"`;
|
|
56
|
+
const plain = `"name":"${tool}"`;
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const pattern of [needle, plain]) {
|
|
59
|
+
let from = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const at = text.indexOf(pattern, from);
|
|
62
|
+
if (at === -1) break;
|
|
63
|
+
out.push(text.slice(at, at + 4000));
|
|
64
|
+
from = at + pattern.length;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out.sort((a, b) => b.length - a.length);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const dir of process.argv.slice(2)) {
|
|
71
|
+
const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
|
|
72
|
+
const header = JSON.parse(text.split('\n')[0]);
|
|
73
|
+
|
|
74
|
+
const pwshWindows = windowsAfter(text, 'pwsh');
|
|
75
|
+
// The parameters object is the one that mentions the tool's own argument names.
|
|
76
|
+
const schemaWindow = pwshWindows.find((w) => w.includes('workdir') || w.includes('command')) ?? pwshWindows[0] ?? '';
|
|
77
|
+
const backgroundInSchema = /run_in_background/.test(schemaWindow);
|
|
78
|
+
const backgroundAnywhere = text.includes('run_in_background');
|
|
79
|
+
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
`\n${dir.split(/[\\/]/).pop().slice(0, 20)} depth=${header?.delegationDepth}\n` +
|
|
82
|
+
` "run_in_background" anywhere in the log : ${backgroundAnywhere}\n` +
|
|
83
|
+
` inside the pwsh schema window : ${backgroundInSchema}` +
|
|
84
|
+
`${backgroundInSchema ? ' <-- the parameter is still offered' : ' <-- removed, as intended'}\n` +
|
|
85
|
+
` pwsh schema window size : ${schemaWindow.length} chars\n`,
|
|
86
|
+
);
|
|
87
|
+
if (backgroundAnywhere && !backgroundInSchema) {
|
|
88
|
+
const at = text.indexOf('run_in_background');
|
|
89
|
+
process.stdout.write(` first mention context: …${text.slice(Math.max(0, at - 120), at + 60)}…\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Reports whether each child session in a run is finished or still active, from its
|
|
2
|
+
// own log — the question behind "subagent limit reached": are slots being held by
|
|
3
|
+
// children that already stopped, or by children that are genuinely still working?
|
|
4
|
+
//
|
|
5
|
+
// Usage: node tools/child-status.mjs <root-session-dir>
|
|
6
|
+
|
|
7
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
12
|
+
function frameEnd(buf) {
|
|
13
|
+
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
14
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
15
|
+
}
|
|
16
|
+
return buf.length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Decompress a concatenated-zstd log frame by frame. */
|
|
20
|
+
async function decompressAll(buf) {
|
|
21
|
+
let rest = buf;
|
|
22
|
+
let text = '';
|
|
23
|
+
while (rest.length > 0) {
|
|
24
|
+
const slice = rest.subarray(0, frameEnd(rest));
|
|
25
|
+
try {
|
|
26
|
+
text += await new Promise((resolve, reject) => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
const dec = createZstdDecompress();
|
|
29
|
+
dec.on('data', (chunk) => chunks.push(chunk));
|
|
30
|
+
dec.on('error', reject);
|
|
31
|
+
dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
32
|
+
dec.end(slice);
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (slice.length >= rest.length) break;
|
|
38
|
+
rest = rest.subarray(slice.length);
|
|
39
|
+
}
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rootDir = process.argv[2];
|
|
44
|
+
if (rootDir === undefined) {
|
|
45
|
+
process.stderr.write('usage: node tools/child-status.mjs <root-session-dir>\n');
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const base = join(rootDir, '..');
|
|
50
|
+
const rootId = rootDir.split(/[\\/]/).pop();
|
|
51
|
+
const rows = [];
|
|
52
|
+
|
|
53
|
+
for (const name of readdirSync(base)) {
|
|
54
|
+
const file = join(base, name, 'session.v4.jsonl.zstd');
|
|
55
|
+
try {
|
|
56
|
+
if (!statSync(file).isFile()) continue;
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const text = await decompressAll(readFileSync(file));
|
|
61
|
+
const events = text
|
|
62
|
+
.split('\n')
|
|
63
|
+
.filter((line) => line.trim().length > 0)
|
|
64
|
+
.flatMap((line) => {
|
|
65
|
+
try {
|
|
66
|
+
return [JSON.parse(line)];
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const header = events[0];
|
|
72
|
+
if (name !== rootId && header?.origin !== 'subagent') continue;
|
|
73
|
+
|
|
74
|
+
const types = new Map();
|
|
75
|
+
for (const event of events) types.set(event.type, (types.get(event.type) ?? 0) + 1);
|
|
76
|
+
|
|
77
|
+
const last = events[events.length - 1];
|
|
78
|
+
rows.push({
|
|
79
|
+
id: name,
|
|
80
|
+
depth: header?.delegationDepth,
|
|
81
|
+
mtime: statSync(file).mtime,
|
|
82
|
+
events: events.length,
|
|
83
|
+
lastType: last?.type,
|
|
84
|
+
lastTime: last?.time,
|
|
85
|
+
// A turn that ended and left nothing pending is the shape of a settled child.
|
|
86
|
+
turnEnds: types.get('turn/end') ?? 0,
|
|
87
|
+
hasInboxPending: types.has('agent/inbox/spliced') && !types.has('agent/inbox/claimed'),
|
|
88
|
+
cancels: types.get('agent/cancel') ?? 0,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
rows.sort((a, b) => (a.lastTime ?? 0) - (b.lastTime ?? 0));
|
|
93
|
+
const fmt = (ms) => (ms === undefined ? '?' : new Date(ms).toISOString().slice(11, 19));
|
|
94
|
+
|
|
95
|
+
process.stdout.write(`${rows.length} agent session(s) under ${base}\n\n`);
|
|
96
|
+
process.stdout.write('depth turnEnds events last event type last event time session\n');
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
`${String(row.depth ?? '-').padEnd(6)} ${String(row.turnEnds).padEnd(9)} ${String(row.events).padEnd(7)} ` +
|
|
100
|
+
`${String(row.lastType ?? '-').padEnd(25)} ${fmt(row.lastTime).padEnd(16)} ${row.id.slice(0, 20)}\n`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Counts the tool calls actually made in one session, by name.
|
|
2
|
+
//
|
|
3
|
+
// Written to settle a question that string counting cannot: which delegation tool a
|
|
4
|
+
// given level really called, and therefore which tools the child mask actually left
|
|
5
|
+
// visible. A raw occurrence count of "subagent" matches "subagent_fork" too, and the
|
|
6
|
+
// preset's own prose mentions both.
|
|
7
|
+
//
|
|
8
|
+
// Usage: node tools/count-tool-calls.mjs <session-dir> [...]
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
15
|
+
function frameEnd(buf) {
|
|
16
|
+
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
17
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
18
|
+
}
|
|
19
|
+
return buf.length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Decompress a concatenated-zstd log frame by frame. */
|
|
23
|
+
async function decompressAll(buf) {
|
|
24
|
+
let rest = buf;
|
|
25
|
+
let text = '';
|
|
26
|
+
while (rest.length > 0) {
|
|
27
|
+
const slice = rest.subarray(0, frameEnd(rest));
|
|
28
|
+
try {
|
|
29
|
+
text += await new Promise((resolve, reject) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
const dec = createZstdDecompress();
|
|
32
|
+
dec.on('data', (chunk) => chunks.push(chunk));
|
|
33
|
+
dec.on('error', reject);
|
|
34
|
+
dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
35
|
+
dec.end(slice);
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (slice.length >= rest.length) break;
|
|
41
|
+
rest = rest.subarray(slice.length);
|
|
42
|
+
}
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const dir of process.argv.slice(2)) {
|
|
47
|
+
const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
|
|
48
|
+
const events = text
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter((line) => line.trim().length > 0)
|
|
51
|
+
.flatMap((line) => {
|
|
52
|
+
try {
|
|
53
|
+
return [JSON.parse(line)];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const header = events[0];
|
|
60
|
+
const calls = new Map();
|
|
61
|
+
for (const event of events) {
|
|
62
|
+
if (event.type !== 'tool/call') continue;
|
|
63
|
+
const name = event.data?.name ?? event.data?.message?.name;
|
|
64
|
+
if (typeof name !== 'string') continue;
|
|
65
|
+
calls.set(name, (calls.get(name) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const name = dir.split(/[\\/]/).pop().slice(0, 20);
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
`\n${name} depth=${header?.delegationDepth} origin=${header?.origin} ${events.length} events\n`,
|
|
71
|
+
);
|
|
72
|
+
const rows = [...calls.entries()].sort((a, b) => b[1] - a[1]);
|
|
73
|
+
process.stdout.write(` ${rows.length} distinct tools called: ${rows.map(([t, n]) => `${t}×${n}`).join(', ') || '(none)'}\n`);
|
|
74
|
+
const delegation = rows.filter(([t]) => t.startsWith('subagent'));
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
` delegation calls: ${delegation.map(([t, n]) => `${t}×${n}`).join(', ') || '(none)'}\n`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Extracts the tool names a session was actually offered, from the tool schemas the
|
|
2
|
+
// session log persists.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: a subagent's own account of its tools is a language-model
|
|
5
|
+
// recollection, and it can be wrong in both directions (the first such report
|
|
6
|
+
// omitted `write` from its list while claiming to discuss it). The persisted
|
|
7
|
+
// schemas are the registry's own output, so they settle questions like "does the
|
|
8
|
+
// child mask really apply?" and "is run_in_background gone?" without guessing.
|
|
9
|
+
//
|
|
10
|
+
// Usage: node tools/extract-offered-tools.mjs <session-dir> [...]
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
15
|
+
|
|
16
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
17
|
+
function frameEnd(buf) {
|
|
18
|
+
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
19
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
20
|
+
}
|
|
21
|
+
return buf.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Decompress a concatenated-zstd log frame by frame. */
|
|
25
|
+
async function decompressAll(buf) {
|
|
26
|
+
let rest = buf;
|
|
27
|
+
let text = '';
|
|
28
|
+
while (rest.length > 0) {
|
|
29
|
+
const slice = rest.subarray(0, frameEnd(rest));
|
|
30
|
+
try {
|
|
31
|
+
text += await new Promise((resolve, reject) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
const dec = createZstdDecompress();
|
|
34
|
+
dec.on('data', (chunk) => chunks.push(chunk));
|
|
35
|
+
dec.on('error', reject);
|
|
36
|
+
dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
37
|
+
dec.end(slice);
|
|
38
|
+
});
|
|
39
|
+
} catch {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (slice.length >= rest.length) break;
|
|
43
|
+
rest = rest.subarray(slice.length);
|
|
44
|
+
}
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const dir of process.argv.slice(2)) {
|
|
49
|
+
const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
|
|
50
|
+
|
|
51
|
+
// A tool schema is an object carrying `name` plus `parameters` (and `description`).
|
|
52
|
+
// Collect every name that appears next to a parameters object anywhere in the log.
|
|
53
|
+
const names = new Set();
|
|
54
|
+
for (const match of text.matchAll(/\{\\?"name\\?":\\?"([a-zA-Z_][a-zA-Z0-9_]*)\\?":\{[^{}]*\\?"type\\?":\\?"object\\?"/g)) {
|
|
55
|
+
names.add(match[1]);
|
|
56
|
+
}
|
|
57
|
+
for (const match of text.matchAll(/\\?"name\\?":\\?"([a-zA-Z_][a-zA-Z0-9_]*)\\?",\\?"description\\?":/g)) {
|
|
58
|
+
names.add(match[1]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const header = JSON.parse(text.split('\n')[0]);
|
|
62
|
+
const listed = [...names].sort();
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
`\n${dir.split(/[\\/]/).pop().slice(0, 20)} depth=${header?.delegationDepth}\n ${listed.length} names: ${listed.join(', ')}\n`,
|
|
65
|
+
);
|
|
66
|
+
for (const probe of ['workflow', 'present', 'job_output', 'exit_plan_mode', 'subagent', 'subagent_fork', 'pwsh', 'write']) {
|
|
67
|
+
process.stdout.write(` ${probe.padEnd(16)} ${names.has(probe) ? 'present' : 'ABSENT'}\n`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Measures cross-agent read overlap in a real run: how many files were read by more
|
|
2
|
+
// than one agent, and how much redundant reading that represents.
|
|
3
|
+
//
|
|
4
|
+
// The question this answers: sessions are isolated, so a parent cannot pass "what I
|
|
5
|
+
// already read" to a sibling. Is that redundancy a rounding error or the dominant
|
|
6
|
+
// cost of the fan-out? Only the logs can say.
|
|
7
|
+
//
|
|
8
|
+
// Usage: node tools/measure-read-overlap.mjs <session-dir> [...]
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
15
|
+
function frameEnd(buf) {
|
|
16
|
+
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
17
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
18
|
+
}
|
|
19
|
+
return buf.length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Decompress a concatenated-zstd log frame by frame. */
|
|
23
|
+
async function decompressAll(buf) {
|
|
24
|
+
let rest = buf;
|
|
25
|
+
let text = '';
|
|
26
|
+
while (rest.length > 0) {
|
|
27
|
+
const slice = rest.subarray(0, frameEnd(rest));
|
|
28
|
+
try {
|
|
29
|
+
text += await new Promise((resolve, reject) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
const dec = createZstdDecompress();
|
|
32
|
+
dec.on('data', (chunk) => chunks.push(chunk));
|
|
33
|
+
dec.on('error', reject);
|
|
34
|
+
dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
35
|
+
dec.end(slice);
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (slice.length >= rest.length) break;
|
|
41
|
+
rest = rest.subarray(slice.length);
|
|
42
|
+
}
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const READ_TOOLS = new Set(['read', 'ops_inspect_local_file']);
|
|
47
|
+
|
|
48
|
+
/** Normalize a path argument so the same file spelled two ways counts once. */
|
|
49
|
+
function normalize(value) {
|
|
50
|
+
if (typeof value !== 'string') return undefined;
|
|
51
|
+
return value.replace(/\\/g, '/').toLowerCase().trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read one tool call's arguments. The log stores them as a JSON *string*, not as an
|
|
56
|
+
* object — reading `arguments.path` off it silently yields nothing, which made the
|
|
57
|
+
* first version of this tool report zero overlap on a run full of reads.
|
|
58
|
+
* @param data - the `tool/call` event's data.
|
|
59
|
+
* @returns the parsed arguments, or an empty object.
|
|
60
|
+
*/
|
|
61
|
+
function parseArguments(data) {
|
|
62
|
+
const raw = data?.arguments ?? data?.message?.arguments;
|
|
63
|
+
if (typeof raw === 'object' && raw !== null) return raw;
|
|
64
|
+
if (typeof raw !== 'string') return {};
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pull (agent, path) read pairs out of one session log. */
|
|
74
|
+
async function readsOf(dir) {
|
|
75
|
+
const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
|
|
76
|
+
const events = text
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter((line) => line.trim().length > 0)
|
|
79
|
+
.flatMap((line) => {
|
|
80
|
+
try {
|
|
81
|
+
return [JSON.parse(line)];
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const header = events[0];
|
|
87
|
+
const reads = [];
|
|
88
|
+
for (const event of events) {
|
|
89
|
+
if (event.type !== 'tool/call') continue;
|
|
90
|
+
const name = event.data?.name ?? event.data?.message?.name;
|
|
91
|
+
if (!READ_TOOLS.has(name)) continue;
|
|
92
|
+
const args = parseArguments(event.data);
|
|
93
|
+
const path = args.path ?? args.file_path;
|
|
94
|
+
const norm = normalize(path);
|
|
95
|
+
if (norm !== undefined) reads.push({ path: norm, depth: header?.delegationDepth });
|
|
96
|
+
}
|
|
97
|
+
return { id: dir.split(/[\\/]/).pop(), depth: header?.delegationDepth, reads };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sessions = [];
|
|
101
|
+
for (const dir of process.argv.slice(2)) sessions.push(await readsOf(dir));
|
|
102
|
+
|
|
103
|
+
/** How many distinct agents touched each file. */
|
|
104
|
+
const byPath = new Map();
|
|
105
|
+
for (const session of sessions) {
|
|
106
|
+
for (const path of new Set(session.reads.map((r) => r.path))) {
|
|
107
|
+
const list = byPath.get(path) ?? [];
|
|
108
|
+
list.push(session.id.slice(0, 8));
|
|
109
|
+
byPath.set(path, list);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const totalReads = sessions.reduce((sum, s) => sum + s.reads.length, 0);
|
|
114
|
+
const shared = [...byPath.entries()].filter(([, agents]) => agents.length > 1);
|
|
115
|
+
const sharedReads = sessions.reduce(
|
|
116
|
+
(sum, s) => sum + s.reads.filter((r) => (byPath.get(r.path)?.length ?? 0) > 1).length,
|
|
117
|
+
0,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
process.stdout.write(`\n${sessions.length} session(s), ${totalReads} read calls, ${byPath.size} distinct paths\n`);
|
|
121
|
+
process.stdout.write(
|
|
122
|
+
`paths read by more than one agent: ${shared.length} (${((shared.length / Math.max(1, byPath.size)) * 100).toFixed(1)}% of paths)\n`,
|
|
123
|
+
);
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
`read calls that hit a path another agent also read: ${sharedReads} (${((sharedReads / Math.max(1, totalReads)) * 100).toFixed(1)}% of reads)\n`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const worst = shared.sort((a, b) => b[1].length - a[1].length).slice(0, 12);
|
|
129
|
+
if (worst.length > 0) {
|
|
130
|
+
process.stdout.write('\nmost shared paths:\n');
|
|
131
|
+
for (const [path, agents] of worst) {
|
|
132
|
+
process.stdout.write(` ${String(agents.length)}× ${path.slice(-96)}\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
package/verify-patch.mjs
CHANGED
|
@@ -62,6 +62,30 @@ check(
|
|
|
62
62
|
'persona forbids long-running processes for subagents',
|
|
63
63
|
);
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Rule count, asserted on purpose.
|
|
67
|
+
*
|
|
68
|
+
* Byte-identity above only proves the patch matches `persona.txt` in the working
|
|
69
|
+
* tree. It says nothing about what was PUBLISHED: 1.1.6 shipped a package.json bump
|
|
70
|
+
* while the two collaboration rules added in the same session stayed in the working
|
|
71
|
+
* tree, so npm carried a 13-rule persona while the repository had 14. The byte check
|
|
72
|
+
* could not see that, because both files agreed with each other.
|
|
73
|
+
*
|
|
74
|
+
* Update this number in the same commit as the rule it counts, so the release is the
|
|
75
|
+
* act that carries it.
|
|
76
|
+
*/
|
|
77
|
+
const ROOT_RULE_COUNT = 14;
|
|
78
|
+
const rootSection = (personaRow?.config?.prefix ?? '').split('## Root agent rules')[1]?.split('## Subagent rules')[0] ?? '';
|
|
79
|
+
const rootRules = [...rootSection.matchAll(/^(\d+)\. /gm)].map((match) => Number(match[1]));
|
|
80
|
+
check(
|
|
81
|
+
rootRules.length === ROOT_RULE_COUNT,
|
|
82
|
+
`persona carries ${ROOT_RULE_COUNT} root rules (found ${rootRules.length})`,
|
|
83
|
+
);
|
|
84
|
+
check(
|
|
85
|
+
rootRules.every((value, index) => value === index + 1),
|
|
86
|
+
`root rule numbering is 1..${rootRules.length} with no gaps or repeats (found ${rootRules.join(',')})`,
|
|
87
|
+
);
|
|
88
|
+
|
|
65
89
|
const delegation = plugins.find((row) => row.id === 'delegation');
|
|
66
90
|
check(delegation?.group === true, 'delegation is a group row');
|
|
67
91
|
const delegationRows = delegation?.config ?? [];
|