@catheadowl/dsh-eval 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/README.i18n.yaml +2 -2
- package/README.md +2 -5
- package/README.zh.md +2 -5
- package/bin/dsh-eval.mjs +344 -335
- package/bin/dsh-review.mjs +179 -154
- package/docs/README.md +3 -2
- package/docs/cross-turn.md +66 -0
- package/docs/experimental.md +4 -3
- package/docs/host-wiring.md +27 -2
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +34 -1
- package/docs/review.md +15 -10
- package/docs/runner-api.md +1 -2
- package/package.json +8 -2
- package/src/adapters/dsh/review.mjs +99 -52
- package/src/assertions.mjs +499 -389
- package/src/discovery.mjs +190 -166
- package/src/driver/multi-turn-driver.mjs +166 -0
- package/src/experiment/review.mjs +118 -118
- package/src/experimental.mjs +6 -1
- package/src/index.mjs +4 -0
- package/src/mock/mock-adapter.mjs +87 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +13 -0
- package/src/report.mjs +5 -0
- package/src/review-report.mjs +45 -1
- package/src/runner.mjs +35 -30
- package/src/sandbox.mjs +62 -1
- package/src/tool-validation.mjs +85 -77
- package/src/trace.mjs +590 -218
package/bin/dsh-review.mjs
CHANGED
|
@@ -1,154 +1,179 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Execute model-independent `*.review.mjs` experiments through dsh headless.
|
|
4
|
-
*
|
|
5
|
-
* Dry-run materializes live observations without touching dsh. Real runs write
|
|
6
|
-
* the shared task plus each independent review answer beside the experiment:
|
|
7
|
-
* `.runs/<experiment id>/`.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import { dirname, join, resolve } from 'node:path'
|
|
12
|
-
import { pathToFileURL } from 'node:url'
|
|
13
|
-
import { materializeReviewExperiment } from '../src/experiment/review.mjs'
|
|
14
|
-
import { runDshReviewExperiment } from '../src/adapters/dsh/review.mjs'
|
|
15
|
-
import { discoverFiles } from '../src/discovery.mjs'
|
|
16
|
-
import { loadEvalConfig } from '../src/config.mjs'
|
|
17
|
-
import { resolveDshCliChain } from '../src/cli.mjs'
|
|
18
|
-
import { renderReviewReport } from '../src/review-report.mjs'
|
|
19
|
-
|
|
20
|
-
function usage(error) {
|
|
21
|
-
const message = [
|
|
22
|
-
'usage: dsh-review [--dry-run] [--runs N] [--profile NAME (default: headless) --repo DIR] [--timeout MS] <*.review.mjs or directories...>',
|
|
23
|
-
' --profile/--repo may come from a dsh-eval.config.mjs found upward from cwd; flags override it.',
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
process.
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (arg === '--
|
|
38
|
-
if (arg === '--
|
|
39
|
-
if (arg === '--
|
|
40
|
-
if (arg === '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Execute model-independent `*.review.mjs` experiments through dsh headless.
|
|
4
|
+
*
|
|
5
|
+
* Dry-run materializes live observations without touching dsh. Real runs write
|
|
6
|
+
* the shared task plus each independent review answer beside the experiment:
|
|
7
|
+
* `.runs/<experiment id>/`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join, resolve } from 'node:path'
|
|
12
|
+
import { pathToFileURL } from 'node:url'
|
|
13
|
+
import { materializeReviewExperiment } from '../src/experiment/review.mjs'
|
|
14
|
+
import { runDshReviewExperiment } from '../src/adapters/dsh/review.mjs'
|
|
15
|
+
import { discoverFiles } from '../src/discovery.mjs'
|
|
16
|
+
import { loadEvalConfig } from '../src/config.mjs'
|
|
17
|
+
import { resolveDshCliChain } from '../src/cli.mjs'
|
|
18
|
+
import { renderReviewReport } from '../src/review-report.mjs'
|
|
19
|
+
|
|
20
|
+
function usage(error) {
|
|
21
|
+
const message = [
|
|
22
|
+
'usage: dsh-review [--dry-run] [--runs N] [--profile NAME (default: headless) --repo DIR] [--timeout MS] [--keep-plugin-rows] <*.review.mjs or directories...>',
|
|
23
|
+
' --profile/--repo may come from a dsh-eval.config.mjs found upward from cwd; flags override it.',
|
|
24
|
+
' blank environment by default: every out-of-tree plugin row in the staged profile is disabled;',
|
|
25
|
+
' --keep-plugin-rows opts back into the host profile plugin face (e.g. reviewing a plugin\'s own gates).',
|
|
26
|
+
].join('\n')
|
|
27
|
+
if (error) process.stderr.write(`${error}\n${message}\n`)
|
|
28
|
+
else process.stdout.write(`${message}\n`)
|
|
29
|
+
process.exit(error ? 2 : 0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const options = { dryRun: false, runs: undefined, timeoutMs: undefined, profile: undefined, repo: undefined, keepPluginRows: false }
|
|
34
|
+
const paths = []
|
|
35
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
36
|
+
const arg = argv[index]
|
|
37
|
+
if (arg === '--dry-run') { options.dryRun = true; continue }
|
|
38
|
+
if (arg === '--runs') { options.runs = Number(argv[++index]); continue }
|
|
39
|
+
if (arg === '--profile') { options.profile = argv[++index]; continue }
|
|
40
|
+
if (arg === '--repo') { options.repo = argv[++index]; continue }
|
|
41
|
+
if (arg === '--timeout') { options.timeoutMs = Number(argv[++index]); continue }
|
|
42
|
+
if (arg === '--keep-plugin-rows') { options.keepPluginRows = true; continue }
|
|
43
|
+
if (arg === '-h' || arg === '--help') usage()
|
|
44
|
+
paths.push(arg)
|
|
45
|
+
}
|
|
46
|
+
if (paths.length === 0) usage('error: at least one review experiment path is required')
|
|
47
|
+
if (options.runs !== undefined && (!Number.isInteger(options.runs) || options.runs < 1)) usage('error: --runs must be a positive integer')
|
|
48
|
+
if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1)) usage('error: --timeout must be a positive integer')
|
|
49
|
+
return { options, paths }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function discover(path) {
|
|
53
|
+
return discoverFiles(path, '.review.mjs')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadExperiment(file) {
|
|
57
|
+
const module = await import(pathToFileURL(file).href)
|
|
58
|
+
const experiment = module.default
|
|
59
|
+
if (experiment?.kind !== 'review') {
|
|
60
|
+
throw new Error(`${file}: default export must come from defineReviewExperiment(...)`)
|
|
61
|
+
}
|
|
62
|
+
return { ...experiment, __file: file }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function artifactDir(experiment) {
|
|
66
|
+
return join(dirname(experiment.__file), '.runs', experiment.id)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeMaterialized(experiment, materialized, extra = {}, reviewResult = undefined) {
|
|
70
|
+
const output = artifactDir(experiment)
|
|
71
|
+
mkdirSync(output, { recursive: true })
|
|
72
|
+
writeFileSync(join(output, 'task.txt'), materialized.task, 'utf8')
|
|
73
|
+
writeFileSync(join(output, 'observations.md'), materialized.observations, 'utf8')
|
|
74
|
+
writeFileSync(join(output, 'run.json'), JSON.stringify({
|
|
75
|
+
experimentId: experiment.id,
|
|
76
|
+
summary: experiment.summary,
|
|
77
|
+
rubric: String(experiment.rubric),
|
|
78
|
+
...extra,
|
|
79
|
+
}, null, 2), 'utf8')
|
|
80
|
+
writeFileSync(join(output, 'review-report.md'), renderReviewReport({
|
|
81
|
+
experiment,
|
|
82
|
+
result: reviewResult,
|
|
83
|
+
observations: materialized.observations,
|
|
84
|
+
adapter: extra.adapter,
|
|
85
|
+
profile: extra.profile,
|
|
86
|
+
}), 'utf8')
|
|
87
|
+
return output
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { options, paths } = parseArgs(process.argv.slice(2))
|
|
91
|
+
|
|
92
|
+
// Config merge: flags win over a `dsh-eval.config.mjs` found
|
|
93
|
+
// upward from cwd; profile falls back to the sterile default `headless`.
|
|
94
|
+
const { config } = await loadEvalConfig(process.cwd())
|
|
95
|
+
const profile = options.profile ?? config.profile ?? 'headless'
|
|
96
|
+
// CLI resolution (C6): `--repo` flag > resolution layer (node_modules) >
|
|
97
|
+
// config repo key (legacy). Dry-run never boots the CLI, so resolve lazily.
|
|
98
|
+
let cli = { cliPath: undefined }
|
|
99
|
+
if (!options.dryRun) {
|
|
100
|
+
try {
|
|
101
|
+
const resolved = resolveDshCliChain({ repoFlag: options.repo, configRepo: config.repo })
|
|
102
|
+
cli = { cliPath: resolved.cli }
|
|
103
|
+
} catch (error) {
|
|
104
|
+
usage(`error: ${error.message}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const path of paths) {
|
|
109
|
+
if (!existsSync(resolve(path))) usage(`error: no such experiment path: ${path}`)
|
|
110
|
+
}
|
|
111
|
+
const files = paths.flatMap(path => discover(path)).sort()
|
|
112
|
+
if (files.length === 0) usage('error: no *.review.mjs experiment files found')
|
|
113
|
+
|
|
114
|
+
let failures = 0
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
let experiment
|
|
117
|
+
try {
|
|
118
|
+
experiment = await loadExperiment(file)
|
|
119
|
+
if (options.dryRun) {
|
|
120
|
+
const materialized = await materializeReviewExperiment(experiment)
|
|
121
|
+
const output = writeMaterialized(experiment, materialized, { adapter: null, dryRun: true })
|
|
122
|
+
process.stdout.write(`DRY ${experiment.id}: ${output}\n`)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
process.stdout.write(`RUN ${experiment.id} (${options.runs ?? experiment.defaultRuns} reviews)...\n`)
|
|
127
|
+
const result = await runDshReviewExperiment(experiment, {
|
|
128
|
+
profile,
|
|
129
|
+
cliPath: cli.cliPath,
|
|
130
|
+
runs: options.runs,
|
|
131
|
+
timeoutMs: options.timeoutMs,
|
|
132
|
+
keepPluginRows: options.keepPluginRows,
|
|
133
|
+
})
|
|
134
|
+
const output = writeMaterialized(experiment, result, {
|
|
135
|
+
adapter: 'dsh-headless',
|
|
136
|
+
profile,
|
|
137
|
+
runs: result.runs,
|
|
138
|
+
// Machine-readable boundary accounting per run: `checked` (the request
|
|
139
|
+
// headers were inspected) vs `not-executed` (no session artifact, so the
|
|
140
|
+
// reviewer's tool face was never verified) — the report and run-N.txt
|
|
141
|
+
// carry the same fact in prose (EVAL-021).
|
|
142
|
+
toolBoundaries: result.attempts.map(attempt => ({
|
|
143
|
+
run: attempt.index,
|
|
144
|
+
status: attempt.result?.toolValidation?.status ?? 'not-reported',
|
|
145
|
+
...(attempt.result?.traceGap === undefined ? {} : { reason: attempt.result.traceGap }),
|
|
146
|
+
})),
|
|
147
|
+
}, result)
|
|
148
|
+
for (const attempt of result.attempts) {
|
|
149
|
+
const payload = attempt.result ?? {}
|
|
150
|
+
// run-N.txt is the reviewer's ANSWER (trace-derived, splice-proof),
|
|
151
|
+
// falling back to stdout for executors/trace-less runs; the raw final
|
|
152
|
+
// message stays in run-N.stdout.txt when it differs from the answer,
|
|
153
|
+
// the full session transcript in run-N.stderr.txt. A run whose tool
|
|
154
|
+
// boundary was never verified says so at the TOP of this file: it is
|
|
155
|
+
// what a grader opens first, and its answer is the fallback one.
|
|
156
|
+
const boundaryNotice = payload.toolValidation?.status === 'not-executed'
|
|
157
|
+
? `[tool-boundary: NOT EXECUTED — ${payload.traceGap ?? 'no session trace materialized'}]\n\n`
|
|
158
|
+
: ''
|
|
159
|
+
if (payload.answer !== undefined) writeFileSync(join(output, `run-${attempt.index}.txt`), boundaryNotice + payload.answer, 'utf8')
|
|
160
|
+
else if (payload.stdout !== undefined) writeFileSync(join(output, `run-${attempt.index}.txt`), boundaryNotice + payload.stdout, 'utf8')
|
|
161
|
+
if (payload.stdout !== undefined && payload.answer !== undefined && payload.stdout !== payload.answer) {
|
|
162
|
+
writeFileSync(join(output, `run-${attempt.index}.stdout.txt`), payload.stdout, 'utf8')
|
|
163
|
+
}
|
|
164
|
+
if (payload.stderr) writeFileSync(join(output, `run-${attempt.index}.stderr.txt`), payload.stderr, 'utf8')
|
|
165
|
+
if (payload.toolBoundaryEvidence) writeFileSync(join(output, `run-${attempt.index}.tool-boundary-evidence.json`), payload.toolBoundaryEvidence, 'utf8')
|
|
166
|
+
if (!attempt.ok) {
|
|
167
|
+
failures += 1
|
|
168
|
+
writeFileSync(join(output, `run-${attempt.index}.error.txt`), attempt.error, 'utf8')
|
|
169
|
+
process.stderr.write(`FAIL ${experiment.id} run ${attempt.index}: ${attempt.error}\n`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (result.attempts.every(attempt => attempt.ok)) process.stdout.write(`DONE ${experiment.id}: ${output}\n`)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
failures += 1
|
|
175
|
+
process.stderr.write(`FAIL ${file}: ${error.message}\n`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
process.exit(failures === 0 ? 0 : 1)
|
package/docs/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: dsh-eval 文档索引——安装与宿主接线、review、matcher 全集、disableRows/rowConfig 契约、intent case 规约、报告结构与已知问题的路由表
|
|
2
|
+
description: dsh-eval 文档索引——安装与宿主接线、review、matcher 全集、disableRows/rowConfig/followups 契约、intent case 规约、报告结构与已知问题的路由表
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# dsh-eval · docs index
|
|
@@ -7,10 +7,11 @@ description: dsh-eval 文档索引——安装与宿主接线、review、matcher
|
|
|
7
7
|
| 文档 | 主题 |
|
|
8
8
|
|---|---|
|
|
9
9
|
| [host-wiring.md](host-wiring.md) | 安装与宿主接线:peer 依赖(dsh-llm)、构建 CLI、profile、凭证、spawn 要求 |
|
|
10
|
-
| [review.md](review.md) | comprehension review
|
|
10
|
+
| [review.md](review.md) | comprehension review:实验定义、空白环境 reviewer、产物清单、六条评审规则 |
|
|
11
11
|
| [matchers.md](matchers.md) | trace matcher 与 mock helper 全集 |
|
|
12
12
|
| [disablerows.md](disablerows.md) | `disableRows` 与 turn-close 门禁边界契约 |
|
|
13
13
|
| [rowconfig.md](rowconfig.md) | `rowConfig` 行 config 覆写契约(整段替换语义、形状限制、与 disableRows 分工) |
|
|
14
|
+
| [cross-turn.md](cross-turn.md) | `followups` 跨轮异步驱动契约(driver 行换装、子 agent settle 等待、mock 单 cursor 编排、有界重派断言) |
|
|
14
15
|
| [intent-cases.md](intent-cases.md) | real 意图 case 规约:何时写、断言面、守卫、CI 语义 |
|
|
15
16
|
| [report.md](report.md) | 机器可读报告(`--format json` / `--report`)结构 |
|
|
16
17
|
| [runner-api.md](runner-api.md) | 程序化 runner API:`runEvalCase` options 契约、EvalRunResult 字段、跨档取 `cliPath` |
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: followups 跨轮驱动契约——case 声明 followups 后 overlay 换装 eval 多轮 driver、后台子 agent 的 settle 等待语义、mock 脚本跨轮共享 cursor 的编排规则与 bounded-redispatch 断言面
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# followups:跨轮异步驱动
|
|
6
|
+
|
|
7
|
+
单轮 behavior case 的驱动模型覆盖不了 defer 自愈闭环:「turn 收尾派发 fork 子 agent → 子 agent 落盘修复 → **下一轮** stop 重扫转绿(或每轮一次有界重派)」。case 声明 `followups` 即启用跨轮驱动。
|
|
8
|
+
|
|
9
|
+
## 为什么是 driver 行而不是 CLI resume
|
|
10
|
+
|
|
11
|
+
实测定案(「跨轮异步修复驱动」FR 调研,见开发仓的 eval 状态板记录):
|
|
12
|
+
|
|
13
|
+
- headless runner 在主 agent 首次 idle 即退出,**进程退出会把 in-process 后台子 agent 当场 abort**(fire-and-forget 子 agent 存活窗口 = driver 的生命周期);
|
|
14
|
+
- dsh CLI 无 resume 表面(one task per invocation;resume 只在 cordis config 层)。
|
|
15
|
+
|
|
16
|
+
因此 `followups` 的实现是 overlay 换装:disable `headless-runner` 行 + insert 本包的 `eval-multi-turn-driver`(`src/driver/multi-turn-driver.mjs`,file:// 挂载,与 mock adapter 同机制)。driver 语义:
|
|
17
|
+
|
|
18
|
+
1. 以 case `task` 驱动 turn 1,等主 agent idle;
|
|
19
|
+
2. 每个 followup 之前,**等待后台子 agent settle**(全局 `session/event` 投影:subagent 会话自 `turn/start` 起 pending、`turn/end` 止;空集后再过 250ms 静默宽限才放行;上限 `settleTimeoutMs`,默认 60s,超时报错退出);
|
|
20
|
+
3. 提交 followup(普通 user message)驱动下一轮;
|
|
21
|
+
4. 最后一轮收尾后**同样等待一次 settle**——最后一轮的 turn close 也会派发(defer gate 每次失败 stop 都派 fixer),不等就会在退出时静默截断它们;「已派发 ⇒ 可观测结局」对每一轮成立;
|
|
22
|
+
5. flush、按 headless 同款输出契约退出(最后一条非空 assistant 文本到 stdout;最后 turn `completed` → exit 0)。
|
|
23
|
+
|
|
24
|
+
## case 声明
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
export default {
|
|
28
|
+
id: 'gates-mock-defer-self-heal',
|
|
29
|
+
mode: 'mock',
|
|
30
|
+
disableRows: [], // gate 交互 case 需显式装载 gates 行
|
|
31
|
+
task: '…turn 1 任务…',
|
|
32
|
+
followups: ['rescan now'], // 每项 = 一个额外驱动轮
|
|
33
|
+
settleTimeoutMs: 30_000, // 可选;等后台子 agent 的上限
|
|
34
|
+
script: { steps: [/* 跨轮共享 cursor,见下 */] },
|
|
35
|
+
expect: [/* matchers */],
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
校验:`followups` 必须是非空 string[];`settleTimeoutMs`(可选)必须是正有限数。仅声明 `settleTimeoutMs` 而无 `followups` 无效(不校验、不生效)。
|
|
40
|
+
|
|
41
|
+
## mock 脚本编排:单 cursor 跨轮共享
|
|
42
|
+
|
|
43
|
+
mock adapter 是单实例、单 cursor:**每次模型调用按序吃一步,不区分轮次与 会话**。跨轮 case 的 steps 是全运行编排——主 turn 步骤、子 agent 步骤、后续轮步骤交错排列:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
steps: [
|
|
47
|
+
textStep('turn 1 done'), // 主 turn 1 收尾 → turn close 派发 fixer
|
|
48
|
+
textStep('fixer child answer'), // fixer 子 agent 的首个(唯一)模型调用
|
|
49
|
+
textStep('turn 2 done'), // followup 轮的收尾
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
注意:turn-close blocking gate 的 splice 反馈步骤(见 [disablerows.md](disablerows.md))同样消耗 cursor——编排跨轮脚本时先数清每轮会被 splice 几步。
|
|
54
|
+
|
|
55
|
+
## 断言面
|
|
56
|
+
|
|
57
|
+
- 派发与完成:`subagentDispatched` / `subagentCompleted`(见 [matchers.md](matchers.md) 派发面);
|
|
58
|
+
- **有界重派节律**:`subagentDispatchCount(matcher, expected)`——精确断言匹配 label 的派发总数(「每轮恰一次、N 轮共 N 次、不更多」);
|
|
59
|
+
- 跨轮重扫:`userMessages` 投影带全部轮次的 `user/message`,`userMessageTextIncludes` / `userMessageTextExcludes` 按 `source` 断言某轮是否被 gate steer(例如 turn 2 重扫转绿 → 无新的 gates steer)。
|
|
60
|
+
|
|
61
|
+
## 边界
|
|
62
|
+
|
|
63
|
+
- **settle 宽限是启发式,不是 barrier**:250ms 静默宽限可被「慢派发」击穿——若某插件的 turn-close 派发在主 agent idle 后 >250ms 才发出子 `turn/start`,driver 会空集放行、followup 先行(mock 面表现为脚本错步、通常 loud;real 面表现为轮次与子 agent 并发交叉)。宿主没有「派发完成」事件可用,这是对不存在信号的保守近似。
|
|
64
|
+
- **时间预算叠加**:driver 的 settle 等待(默认每轮上限 60s)叠加在 case `timeoutMs`(runner 的 spawn 超时)之内。轮数 × settle 上限若超过 `timeoutMs`,先到的是 spawn 超时(结果 `timedOut`)而非 settle 报错——多轮 case 应按轮数放大 `timeoutMs`。
|
|
65
|
+
- `subagentCompleted` 的「完成」以子会话产出非空 assistant 文本为准;被早夭 abort 的子日志可能不含 `subagent/descriptor`(label 投影为空)——见 matchers.md 派发面的早夭边界。要断言「每个派发都跑完」(而非任一跑完),用 `subagentCompletedCount`。
|
|
66
|
+
- real 模式下 `followups` 同样可用(driver 不依赖 mock),但轮次内容非确定——real 意图 case 通常不需要它。
|
package/docs/experimental.md
CHANGED
|
@@ -20,16 +20,17 @@ import { resolveDshCliChain } from '@catheadowl/dsh-eval/experimental'
|
|
|
20
20
|
| `stageProfileStore` | 把真实 profile store junction 感知地暂存进沙箱 home(沙箱机制) |
|
|
21
21
|
| `buildOverlayYaml` | 由片段拼装 dsh overlay YAML(整段发射器) |
|
|
22
22
|
| `overlayDisableRows` | 生成 `disabled: true` 的行禁用 overlay 片段 |
|
|
23
|
-
| `parseSessionLog` | 解析一条未压缩 JSONL session artifact 为 `{ header, events }` |
|
|
23
|
+
| `parseSessionLog` | 解析一条未压缩 JSONL session artifact 为 `{ header, events }`;header 的 `version` 戳不在已知代际集合内即抛错 |
|
|
24
24
|
| `buildTrace` | 把 session 事件投影为 matcher 使用的 trace 对象 |
|
|
25
|
-
| `
|
|
25
|
+
| `collectSessionTrace` | 收集一个 run 的 trace 与「为什么没有 trace」的 seam 诊断:返回 `{ trace, gap }`,`gap` 文案含实际候选文件名与代际嫌疑(唯一的收集入口) |
|
|
26
|
+
| `KNOWN_SESSION_FORMAT_VERSIONS` | 本包接受的 session 格式代际集合(`parseSessionLog` 的准入面;宿主新增代际时与本包重验同步) |
|
|
26
27
|
| `executeReviewExperiment` | 用给定 executor 执行抽象 review 实验 |
|
|
27
28
|
| `materializeReviewExperiment` | 把实验定义物化为产物目录 |
|
|
28
29
|
| `renderObservationSections` | 标准 observation renderer(自定义 executor 用) |
|
|
29
30
|
| `OBSERVATIONS_PLACEHOLDER` | prompt 中的观测占位符常量(必须恰好出现一次) |
|
|
30
31
|
| `createDshHeadlessReviewExecutor` | 构造 dsh headless review executor |
|
|
31
32
|
| `runDshReviewExperiment` | 端到端跑一个 dsh review 实验 |
|
|
32
|
-
| `validateToolBoundary` | 校验 trace 满足 turn-close
|
|
33
|
+
| `validateToolBoundary` | 校验 trace 满足 turn-close 工具边界契约;结果带 `status`(`checked` / `not-executed`),无 trace 时**不是**通过 |
|
|
33
34
|
| `renderToolBoundaryEvidence` | 渲染边界校验的机器可读证据 |
|
|
34
35
|
|
|
35
36
|
新公开能力先进本入口;稳定后经明确决策才升入包根入口(升入即接受 semver 义务)。
|
package/docs/host-wiring.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: 安装与宿主接线——dsh-llm peer 的三形解析结局与 junction 步骤、构建 CLI 与 profile/凭证/spawn
|
|
2
|
+
description: 安装与宿主接线——dsh-llm peer 的三形解析结局与 junction 步骤、构建 CLI 与 profile/凭证/spawn 三类运行前置、本包对宿主 session seam 的四处硬断言及其执法面(artifact 代际命名 / header 代际戳 / 拼接帧容器 / snapshotEvents 读取面),以及 mock 模式依赖的宿主 LLM adapter 线上契约(prepareCall,自带覆写对 peer 实例代差免疫)。
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# 安装与宿主接线
|
|
@@ -56,11 +56,36 @@ node -e "console.log(require('fs').existsSync('node_modules/@deepseek-ai/dsh/lib
|
|
|
56
56
|
|
|
57
57
|
`false` = 解析层缺 CLI:先把上述 junction 重建为指向宿主检出;仍 `false` 则宿主检出未构建(先构建宿主)。这类 junction 维护是机器相关的开发环境事务,不入库,由各开发环境自行承接(同上文 peer 接线的 gitignore 纪律)。behavior 与 review 的真实运行都从定位到的 CLI spawn dsh 本体。
|
|
58
58
|
|
|
59
|
+
## 宿主 session seam:本包硬断言的四处,坏了多是具名失败
|
|
60
|
+
|
|
61
|
+
behavior 与 review 的证据都取自**真实 dsh 会话的产物与进程内日志**,因此本包直接断言宿主的几处 session seam。它们随宿主演进时不会有编译期提示,所以每一处都配一行**执法面**(表里的符号就是);宿主检出更新后、动本包引用它们的文档前,先按本节对源码重新验证:
|
|
62
|
+
|
|
63
|
+
| 断言 | 宿主依据 | 本包执法面 | 坏了长什么样 |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| 会话 artifact 按**格式代**命名:v0 是 `session.jsonl`,之后每代带小写数字(当前 `session.v3.jsonl`);`compression: none` 时无 `.zstd` 后缀 | `session-persistence-jsonl/src/format.ts` 的 `generationLogFilename`,配 `core/session/src/types.ts` 的 `SESSION_FORMAT_VERSION` | `isSessionLogFilename`(命名判定)+ `collectSessionTrace`(收集)⇒ behavior 失败文案 / review 记账里的 `traceGap` | 只按 v0 名收集 ⇒ 一条日志都收不到;失败文案列出**实际扫到的候选文件名**并写明「宿主 artifact 命名可能已换代」,不再只报 `no session trace materialized` |
|
|
66
|
+
| 会话 header 的 `version` 戳是宿主对逻辑代际的声明(当前 v3) | 同上;已发布的代际链见 `session-format-catalog/src/generated.ts`(codecs v0–v3、`currentVersion: 3`) | `KNOWN_SESSION_FORMAT_VERSIONS`(`parseSessionLog` 入口准入) | 未知代际 ⇒ 解析当场拒绝并报出版本号(`session header version vN is not a known generation`),不再把各投影字段静默降级成空数组 |
|
|
67
|
+
| 会话日志是**拼接帧容器**(宿主默认 zstd),须逐帧扫描 | `session-persistence-jsonl/src/zstd.ts` 的帧扫描 | eval overlay 固定 `compression: none` + `packChunks: false`(`src/overlay.mjs`) | 整文件一次解压 ⇒ `ZSTD_error_prefix_unknown`(第二帧魔数被当输入) |
|
|
68
|
+
| 进程内读 durable 事件的 API 是 `Session#snapshotEvents()`(不可变冻结快照);早期的 `session.events` getter 已被删除 | `core/session/src/index.ts` 的 `snapshotEvents` | driver 行(`src/driver/multi-turn-driver.mjs`)直接调用,没有回退路径 | 属性访问得到 `undefined` ⇒ 进程内消费者抛 `agent.session.events is not iterable`,整个 headless run 直接死 |
|
|
69
|
+
|
|
70
|
+
命名行与代际行的读取面由本包的 eval overlay 固定(`compression: none` + `packChunks: false`),所以每轮 run 的 artifact 是**明文逐事件**布局;命名判定、代际准入与收集入口都在 `src/trace.mjs`(`isSessionLogFilename` / `KNOWN_SESSION_FORMAT_VERSIONS` / `collectSessionTrace`),behavior runner 与 review adapter 共用同一个收集入口,缺 artifact 时各自把 `traceGap` 带进失败文案与产物记账。帧容器行走 overlay 固定;最后一行是 driver 行读日志时直接依赖的方法。
|
|
71
|
+
|
|
72
|
+
> **维护触发器**:宿主 session 格式、持久化命名或 `Session` 读取面变更 ⇒ 先按上表对 vendored 检出重新验证断言,再更新本篇与引用它们的源码/认知。前两行现在是**机械的**——命名或代际戳变了,跑一条 case 就红在具名文案上(候选文件名 / 版本号);后两行仍只有真跑一条 case 才会暴露。
|
|
73
|
+
|
|
74
|
+
### 宿主 LLM adapter 线上契约(mock 模式专用)
|
|
75
|
+
|
|
76
|
+
mock 模式经 `eval-mock-llm` 插件(`src/mock/mock-adapter.mjs`)注册 `EvalMockAdapter`,它 `extends` 的 `LlmAdapter` 基类解析自**本包的 peer 实例**——该实例可以落后于驱动它的宿主运行时(实测:宿主 0.1.5-rc.2 的 LLM 服务对已注册 adapter 新增 `registration.adapter.prepareCall(...)` 调用面时,本地 peer 还是 0.0.1-rc.1,继承面缺失,全部 mock run 死在启动期)。因此适配器**自带** `prepareCall` 覆写、不依赖继承面在不在:形状镜像宿主基类默认契约(`PreparedAdapterCall`——`{ model: resolveModel(...), stream: options => this.stream(options) }`,model 元数据与派发入口绑定同一代适配器)。
|
|
77
|
+
|
|
78
|
+
| 断言 | 宿主依据 | 本包执法面 | 坏了长什么样 |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| 宿主 LLM 服务经 `prepareCall` 派发每次模型调用:adapter 级 `prepareCall(provider, model, signal)` 返回 `{ model, stream }`(一次性句柄,防 HMR 混代) | `packages/llm/llm/src/index.ts` 的 `registration.adapter.prepareCall(...)` 调用与 `LlmAdapter` 基类(`PreparedAdapterCall`) | `EvalMockAdapter` 自带 `prepareCall` 覆写(`src/mock/mock-adapter.mjs`)——真跑一条 mock case 即红在具名错误串上 | mock run 空转:无 session 事件、workspace 未落、final text 空,stderr 带 `registration.adapter.<method> is not a function` |
|
|
81
|
+
|
|
82
|
+
> **维护触发器**:宿主 adapter 线上契约演进(新增/改签名线上方法)⇒ 先对宿主 `packages/llm/llm/src/index.ts` 重验 `EvalMockAdapter` 的自带面(`prepareCall` / `resolveModel` / `stream` 的形状与语义),再同 commit 改本节与 `src/mock/mock-adapter.mjs`。
|
|
83
|
+
|
|
59
84
|
## 环境面:profile 与插件安装
|
|
60
85
|
|
|
61
86
|
- 被测插件须已装进所选 profile:`dsh plugin --profile <profile> add <插件目录>`;
|
|
62
87
|
- 每条 behavior case 在隔离的临时 `DSH_HOME` 与 workspace 中启动 dsh,通过 `--patch` 把 session JSONL 定向到本次 run——不污染真实 profile store;
|
|
63
|
-
- review
|
|
88
|
+
- review 默认空白环境:任意 profile 均可,树外插件行由 overlay 枚举禁用(详见 [review.md](review.md))。
|
|
64
89
|
|
|
65
90
|
## 凭证
|
|
66
91
|
|
package/docs/known-issues.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: 已知问题——real case 在 staged home 的 REQUEST_EXTENSION 失败(嫌疑 plugin-package-inventory-deepseek
|
|
2
|
+
description: 已知问题——real case 在 staged home 的 REQUEST_EXTENSION 失败(嫌疑 plugin-package-inventory-deepseek)、DSH file sandbox 下 dsh-review spawn EPERM 及 disableRows 兜底
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# 已知问题
|
|
@@ -13,3 +13,11 @@ description: 已知问题——real case 在 staged home 的 REQUEST_EXTENSION
|
|
|
13
13
|
**框架侧兜底**:case 或 config 声明 `disableRows: ['plugin-package-inventory-deepseek']` 按行禁用该插件(机制见 [disablerows.md](disablerows.md))。
|
|
14
14
|
|
|
15
15
|
**追踪**:上游修复落地即删本条(不留僵尸条目)。
|
|
16
|
+
|
|
17
|
+
## DSH file sandbox 下 `dsh-review` spawn headless 子进程 `EPERM`(未解)
|
|
18
|
+
|
|
19
|
+
**症状**:在 DSH file sandbox(受限运行面)内运行 `dsh-review`,首次 spawn headless 子进程即报 `spawn EPERM`,escalated retry 后可运行。
|
|
20
|
+
|
|
21
|
+
**定性**:沙箱运行面约束,非框架缺陷——沙箱拒绝 spawn 时换宿主侧终端或升级运行面即可(与本包维护规则「沙箱拒绝 spawn 时在宿主侧终端跑,不绕测试」同族)。登记供沙箱内调用方知晓。
|
|
22
|
+
|
|
23
|
+
**追踪**:开发侧状态板按名登记为「2026-09-06 · dsh-review 沙箱 spawn EPERM 已知约束」(按名对齐);运行面行为变化时同删。
|
package/docs/matchers.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: trace matcher 与 mock helper
|
|
2
|
+
description: trace matcher 与 mock helper 全集——工具面/文本面/输入面/派发面断言语义(toolCalled 到 subagentCompletedCount)、投影普查(trace.census)与 toolCallStep/textStep 脚本构件
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Trace matchers 与 mock helpers
|
|
@@ -18,6 +18,8 @@ description: trace matcher 与 mock helper 全集——工具面/文本面/输
|
|
|
18
18
|
| `finalText` | 最后一个组装 assistant 文本(无则 `''`) |
|
|
19
19
|
| `userMessages` | `{ seq, source, text }[]`(`source` 原样透传:任务 prompt `{ kind: 'user' }`,插件 steer `{ kind: 'plugin', plugin }`) |
|
|
20
20
|
| `requestHeaders` | `{ seq, reason, system, toolNames }[]`(组装后 system prompt + 挂载工具名) |
|
|
21
|
+
| `subagentChildren` | `{ sessionId, parentSession, delegationDepth, label, mode, provider, assistantTexts, finalText }[]`——每个子 agent 独立 session 日志一条;身份(label/mode/provider)取子日志首条 version-3 的 `subagent/descriptor` 事件(镜像宿主 `foldSubagentDescriptor` 的首条权威语义),`finalText` 是子会话自己的最后一条非空 assistant 文本(无则 `''` = 派发了但没答) |
|
|
22
|
+
| `census` | 投影普查(只报数,不判定):`{ eventTypeCounts, projectionLengths, projectionSkipped: { main, children }, projectionFieldGaps, subagent: { mainLogDescriptorEvents, supportedDescriptors, children } }`。语义见下「投影普查」节;手搓 trace(不经 `buildTrace`)时可为 `undefined` |
|
|
21
23
|
| `sessions` / `sessionId` | 原始解析结果 `{ header, events }[]` 与主 session id |
|
|
22
24
|
|
|
23
25
|
`runEvalCase` 返回的 `result.trace` 即此形状(无 session 日志时为 `undefined`;字段语义见 [runner-api.md](runner-api.md))。
|
|
@@ -43,6 +45,37 @@ description: trace matcher 与 mock helper 全集——工具面/文本面/输
|
|
|
43
45
|
- `toolMounted(name)`:工具出现在某个 request/header 的挂载列表;
|
|
44
46
|
- `userMessageTextIncludes(source, substring)` / `userMessageTextExcludes(source, substring)`:按 `source` 过滤的 `user/message` 文本含/不含子串。`source` 用字符串/RegExp 匹配 `plugin` 名(如 steer 生产方),或谓词取整个 `source`——steer 在持久化日志里没有专名事件(`agent.steer()` 落为 `user/message`),区分靠 `source`(插件 steer 为 `{ kind: 'plugin', plugin: '<id>' }`,任务 prompt 为 `{ kind: 'user' }`)。
|
|
45
47
|
|
|
48
|
+
## 派发面(子 agent)
|
|
49
|
+
|
|
50
|
+
主 session 日志不含派发事件(宿主不向父 session 写),但子 agent 的独立 session 日志与主日志同 persistence root,随 run 一并被收集——派发面投影即来自这些子日志:
|
|
51
|
+
|
|
52
|
+
- `subagentDispatched(label)`:至少一个子 agent 以匹配的 label 派发。`label` 用字符串/RegExp 匹配子会话的 `subagent/descriptor` label(如 `gates:fix:<gate>`、前缀 `/^gates:fix:/`),或谓词取整个子记录(可按 `mode`/`provider`/`delegationDepth` 匹配);
|
|
53
|
+
- `subagentCompleted(label)`:匹配的子 agent 产出了答案——其自身日志含至少一条非空 assistant 文本(不区分中止/正常收束:日志层无 subagent 完成事件,产出过文本即算)。只派发未应答(子日志存在但无产出)不通过。
|
|
54
|
+
- `subagentDispatchCount(label, expected)`:匹配 label 的派发**总数**恰为 `expected`——有界重派节律断言(「每轮恰一次、不更多」),配合跨轮驱动([cross-turn.md](cross-turn.md))。
|
|
55
|
+
- `subagentCompletedCount(label, expected)`:匹配 label 且**跑完**(产出非空 assistant 文本)的子 agent 恰为 `expected` 个。`subagentCompleted` 任一跑完即过;本 matcher 钉死每个派发的结局——「已派发 ⇒ 可观测结局」的跨轮 case 里,任一被截断的子 agent 都判负。
|
|
56
|
+
|
|
57
|
+
边界:子会话产物(独立 JSONL)经 `subagentChildren` 记录进入断言面(身份 + 子自身文本);子会话内部的工具调用**不**并入主投影的 `toolCalls`/`toolResults`(那属于主会话行为面),需要时经 `sessions` 原始日志自行投影。
|
|
58
|
+
|
|
59
|
+
## 投影普查(`trace.census`)
|
|
60
|
+
|
|
61
|
+
宽松投影(tolerant reader)的补救面:宿主事件 payload 演进时 `buildTrace` 不抛错,只把字段填成空值或丢掉整条记录。空投影会让负向断言真空通过——`toolNotCalled`、`userMessageTextExcludes`、以及 `subagentDispatchCount` / `subagentCompletedCount` 的 `expected === 0` 档都判 ok。**普查只报数,不判定**:它让「宿主日志里本来就没有这类事件」与「有事件但投影丢掉了」在报告里可分,是否降级由人判读。
|
|
62
|
+
|
|
63
|
+
三个信号,对应三种坏法:
|
|
64
|
+
|
|
65
|
+
| 信号 | 看什么 |
|
|
66
|
+
|---|---|
|
|
67
|
+
| 主 session 事件(`eventTypeCounts` / `projectionLengths` / `projectionSkipped.main`) | 主日志(`buildTrace` 的投影输入)逐事件类型计数(任何类型,含插件扩展类型);五个投影的长度;以及**每个投影上「计数 − 长度 > 0」的差额**(`projectionSkipped.main`,按投影字段名)——记录被丢了的档 |
|
|
68
|
+
| **字段级缺口**(`projectionFieldGaps`) | 记录**留住了但字段读不到**的事件,按缺什么计数:`toolCallWithoutName` / `toolCallWithoutCallId` / `toolResultWithoutCallId` / `headerWithoutSystem` / `headerWithoutToolNames`。`tool/call`、`tool/result`、`request/header` 是 1:1 投影(计数 − 长度恒为 0),宿主搬字段时只在这里可见。**不计数**:`request/header` 的 `tools` 数组整个缺失(与真空列表投影一致) |
|
|
69
|
+
| 子会话(`census.subagent`) | `subagentChildren` 的输入面:`children[]` 逐条给该子日志的 `subagent/descriptor` 事件数、其中 `version === 3` 的条数(`supportedDescriptors`,**数事件不是数子会话**)**以及折叠出的身份**(`label` / `mode` / `provider`);`projectionSkipped.children` 两个身份计数——`withoutIdentity`(三项全缺)与 `withoutLabel`(`label` 缺,哪怕 mode/provider 有)。`mainLogDescriptorEvents` 是**主日志自己**的 `subagent/descriptor` 事件数(现宿主把 descriptor 写进子日志,这个数通常为 0)。**子日志不是主日志**,`eventTypeCounts` 不统计它们 |
|
|
70
|
+
|
|
71
|
+
判读要点:
|
|
72
|
+
|
|
73
|
+
- **差额 ≠ 缺陷**。`assistant/message`、`user/message` 的**空文本消息是设计上整条丢弃**(保护「组装文本」投影语义),这类差额属合法,普查不替你做白名单;
|
|
74
|
+
- **两类信号别混**:`projectionSkipped.main` 看「记录被丢了」,`projectionFieldGaps` 看「记录在、字段没了」。后者正是 `toolNotCalled` 最危险的形态——调用记录还在、`name` 为 `undefined`,`nameMatches` 对任何 matcher 都不命中,负向断言照绿;
|
|
75
|
+
- **身份缺失型降级**:某子日志 `descriptorEvents > 0` 而 `supportedDescriptors === 0`,即它进了 `subagentChildren` 但身份全空;但**只要 `label` 缺**(`withoutLabel`),按 label 匹配的 `*Count(label, 0)` 就会真空通过——哪怕 `supportedDescriptors` 看起来健康、mode/provider 都在。两个计数分开报就是为了这个档;
|
|
76
|
+
- **子会话集合是启发式**:`subagentChildren` 收「header 带 `parentSession`」的日志,而宿主对 fork/resume/seed 日志也写这个字段——它们会以「无身份子记录」出现在普查里。这是集合的性质,不是本次降级(日志层无法复现宿主的 agent 链所有权判定);
|
|
77
|
+
- 只出现在**运行面**:`--format json` 的每条 case 记录(`census` 字段,pass 与 fail 都带;**无 trace 的记录没有**)与 `.runs/<id>/trace.json` 的 `trace.census`;**文本输出零新增**(逐字节输出契约不动),失败文案也不带计数。
|
|
78
|
+
|
|
46
79
|
## Mock script helpers
|
|
47
80
|
|
|
48
81
|
- `toolCallStep(name, args)`:一步「模型调工具」,结束于 tool-calls;
|