dsh-decision-tools 0.0.0-stage → 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 +10 -0
- package/LICENSE +21 -0
- package/README.md +234 -2
- package/README.zh.md +235 -0
- package/THIRD_PARTY_NOTICES +16 -0
- package/assets/decision-usage/SKILL.md +71 -0
- package/cordis.patch.yml +49 -0
- package/docs/architecture-current.md +349 -0
- package/docs/threshold-calibration.md +112 -0
- package/docs/use-cases.md +182 -0
- package/lib/browser.js +1014 -0
- package/lib/client.js +352 -0
- package/lib/config-bus.js +133 -0
- package/lib/configs.js +58 -0
- package/lib/decision-entry.js +15 -0
- package/lib/deliver.js +44 -0
- package/lib/density.js +88 -0
- package/lib/escalate.js +328 -0
- package/lib/event-log.js +159 -0
- package/lib/index.js +75 -0
- package/lib/monitor-entry.js +15 -0
- package/lib/monitor-tool.js +648 -0
- package/lib/monitor.js +1232 -0
- package/lib/notify.js +209 -0
- package/lib/ollama.js +199 -0
- package/lib/parameters.js +183 -0
- package/lib/pipe.js +525 -0
- package/lib/plan.js +73 -0
- package/lib/render.js +193 -0
- package/lib/scan.js +151 -0
- package/lib/skill.js +88 -0
- package/lib/source.js +362 -0
- package/lib/tool.js +1331 -0
- package/lib/validate.js +934 -0
- package/lib/wire.js +76 -0
- package/package.json +85 -4
- package/scripts/link-peers.mjs +55 -0
- package/scripts/list-models.mjs +50 -0
- package/scripts/rebuild-node-modules.mjs +109 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* monitor 工具的模型可见面(计划 Task 6):把 Monitor 链接到 DSH 的 tools 服务上。
|
|
3
|
+
*
|
|
4
|
+
* 三条宿主侧硬契约(本机真机实测):
|
|
5
|
+
* 1. `output.render` 必须返回**内容块数组** `[{type:'text',text}]` —— 宿主拿到 render 结果后直接
|
|
6
|
+
* 做 `content.some(block => block.type === 'image')`,返回字符串会让整次工具调用崩。
|
|
7
|
+
* 2. `parameters` 用 DSH **紧凑方言**(ParameterSchemaSpec):`required: true` 是逐属性注解、
|
|
8
|
+
* object 节点必须显式写 `additionalProperties`;由 `defineTool` 归一成原生 JSON Schema
|
|
9
|
+
* (**不要手写原生 JSON Schema**)。
|
|
10
|
+
* 3. render 必须是**全函数**:宿主把 render 的异常包成 ToolOutputError,于是一次**成功**的调用
|
|
11
|
+
* 会变成工具失败 ⇒ 本文件的 render 一律退化成占位符,绝不抛。
|
|
12
|
+
*
|
|
13
|
+
* 冻结边界(Lead 裁定):
|
|
14
|
+
* - 输入一律走 `validateMonitorArgs(args, { config })` 归一;**钳制不在本模块做**(在 validate.js)。
|
|
15
|
+
* - `clampNotes` **只透传**:本模块一行 notes 文案都不新增(由工具层拼)。
|
|
16
|
+
* - `file`/`glob` 采样**必须**用注入的 `deps.read`(decision 用的同一个 reader)⇒ 本文件不再实现
|
|
17
|
+
* 任何文件/glob 读取语义;缺 `deps.read` 时给出可行动报错,不静默退化。
|
|
18
|
+
* - `decide` / `clock` / `sleep` / `notifier` 全部注入;测试一律注入假时钟(零真实墙钟)。
|
|
19
|
+
* - 工具名取 `config.monitorToolName`,缺省 `monitor`;自递归防护由上游委托链负责,不在这里。
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-decision-tools/monitor-tool
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { defineTool, valueSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools';
|
|
25
|
+
import { createSources, runMonitor, startBackgroundMonitor, tailOf, probeProperty } from './monitor.js';
|
|
26
|
+
import { validateMonitorArgs, DecisionValidationError } from './validate.js';
|
|
27
|
+
import { DecisionSourceError } from './source.js';
|
|
28
|
+
|
|
29
|
+
/** 模型看到的工具名(可被 config.monitorToolName 覆盖)。 */
|
|
30
|
+
export const MONITOR_TOOL_DEFAULT_NAME = 'monitor';
|
|
31
|
+
|
|
32
|
+
const NL = String.fromCharCode(10);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 常驻工具描述(注入预算 ≤1900 B,由 test/monitor-tool.test.mjs 守卫)。
|
|
36
|
+
* 常驻成本要精打细算:机制细节住在 spec 与 skill 里,这里只留「是什么 / 怎么用 / 硬边界」。
|
|
37
|
+
*/
|
|
38
|
+
export const TOOL_DESCRIPTION = [
|
|
39
|
+
'Wait for a condition by repeatedly sampling a source and asking the local decision model.',
|
|
40
|
+
'A probing wait primitive, not a scheduler: it samples on an interval and asks "has the event happened"',
|
|
41
|
+
'against your rubric. It returns when countThreshold is reached (or the deadline expires); timestamps are',
|
|
42
|
+
'recorded, never decisive.',
|
|
43
|
+
'- countMode: cumulative | consecutive (streak resets on a miss). continuous returns the accumulated count (not hit); notifyEvery = notice cadence.',
|
|
44
|
+
'',
|
|
45
|
+
'Usage',
|
|
46
|
+
'- source selects what to sample: {kind:"job"} tails a background job (omit id to watch the newest job of this',
|
|
47
|
+
' session); {kind:"file",path} and {kind:"glob",pattern} read workspace-relative text through the same reader',
|
|
48
|
+
' as decision; {kind:"command",argv} and {kind:"events",types} work when the deployment injects them.',
|
|
49
|
+
'- Counting is level-based (no debounce/cooldown); countThreshold:3 means roughly three hit probes.',
|
|
50
|
+
'- rubric is the one shared criterion — write it once. hitThreshold (default 0.5) is the per-probe probability',
|
|
51
|
+
' that counts as a hit.',
|
|
52
|
+
'- intervalMs is raised to the deployment floor; timeoutMs is the self-managed deadline (the host does not',
|
|
53
|
+
' preempt a blocking call), and a cancelled call returns early with the count so far.',
|
|
54
|
+
'- Empty source: returns immediately, no model call.',
|
|
55
|
+
'- background:true registers a host job and returns immediately with jobId; read it later with job_output.',
|
|
56
|
+
' notify:"inbox" also sends a notice when the threshold is reached (coalesced and rate-limited by the plugin).',
|
|
57
|
+
'',
|
|
58
|
+
'Hard boundaries',
|
|
59
|
+
'- These are PROBABILITIES, not proof. Never let a hit be the only basis for a pass/allow decision.',
|
|
60
|
+
'- On a hit the plugin NOTIFIES only: it never kills, retries, dispatches or cancels anything — that stays',
|
|
61
|
+
' your call.',
|
|
62
|
+
'- The counter lives in memory only: it starts at 0 on every call and is lost when the host restarts.',
|
|
63
|
+
].join(NL);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 参数契约(紧凑方言)。与 `MONITOR_LIMITS.allowedKeys` 一一对应:校验层拒绝的键这里不该出现,
|
|
67
|
+
* 反之亦然(两端漂移由 test/monitor-tool.test.mjs 的键集守卫盯住)。
|
|
68
|
+
*/
|
|
69
|
+
export const PARAMETERS = {
|
|
70
|
+
description: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
required: true,
|
|
73
|
+
description: 'Required non-empty run description; used as the background job label.',
|
|
74
|
+
},
|
|
75
|
+
rubric: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
required: true,
|
|
78
|
+
description: 'The ONE shared criterion: what counts as "the target event has happened".',
|
|
79
|
+
},
|
|
80
|
+
source: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
required: true,
|
|
84
|
+
description: 'What to sample on every probe.',
|
|
85
|
+
properties: {
|
|
86
|
+
kind: { type: 'string', required: true, enum: ['job', 'file', 'glob', 'command', 'events'], description: 'Source kind.' },
|
|
87
|
+
id: { type: 'string', description: 'job: watch this job id (default: the newest job of this session).' },
|
|
88
|
+
select: { type: 'string', enum: ['newest', 'oldest'], description: 'job: which job when id is omitted.' },
|
|
89
|
+
jobKind: { type: 'string', description: 'job: only consider jobs of this kind.' },
|
|
90
|
+
path: { type: 'string', description: 'file: workspace-relative path.' },
|
|
91
|
+
startLine: { type: 'number', description: 'file: 1-based first line.' },
|
|
92
|
+
endLine: { type: 'number', description: 'file: 1-based last line.' },
|
|
93
|
+
pattern: { type: 'string', description: 'glob: workspace-relative pattern.' },
|
|
94
|
+
limit: { type: 'number', description: 'glob/events: max files (glob) / max entries after type filter (events).' },
|
|
95
|
+
argv: { type: 'array', items: { type: 'string' }, description: 'command: argv (needs deployment support).' },
|
|
96
|
+
types: { type: 'array', items: { type: 'string' }, description: 'events: event names (needs deployment support).' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
type: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
enum: ['noul'],
|
|
102
|
+
description: 'Only noul: the hit decision reads P(true); choice/score have no hit semantics.',
|
|
103
|
+
},
|
|
104
|
+
criteria: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
additionalProperties: true,
|
|
107
|
+
description: 'noul only: optional false/true descriptions.',
|
|
108
|
+
},
|
|
109
|
+
hitThreshold: { type: 'number', description: 'Per-probe P(true) that counts as a hit (default 0.5).' },
|
|
110
|
+
tailBytes: { type: 'number', description: 'Bytes read per probe (default 4096, hard cap 65536).' },
|
|
111
|
+
mode: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
enum: ['once', 'continuous'],
|
|
114
|
+
description: 'once: return when the threshold is reached (default). continuous: keep watching until the deadline.',
|
|
115
|
+
},
|
|
116
|
+
intervalMs: { type: 'number', description: 'Probe interval (default 5000); raised to the deployment minimum.' },
|
|
117
|
+
timeoutMs: { type: 'number', description: 'Self-managed deadline (default 120000); never smaller than intervalMs.' },
|
|
118
|
+
background: { type: 'boolean', description: 'Register a host job and return jobId immediately.' },
|
|
119
|
+
countThreshold: { type: 'number', description: 'once: return after this many hits (default 1). continuous ignores it.' },
|
|
120
|
+
countMode: { type: 'string', enum: ['cumulative', 'consecutive'], description: 'Counting mode: cumulative total, or consecutive streak (a miss resets). Default cumulative.' },
|
|
121
|
+
notifyEvery: { type: 'number', description: 'continuous only: send a notice every N hits (default 1).' },
|
|
122
|
+
notify: { type: 'string', enum: ['none', 'inbox'], description: 'inbox (background:true only): send a notice at each trigger (once: threshold; continuous: every notifyEvery hits).' },
|
|
123
|
+
coalesce: { type: 'boolean', description: 'Merge same-kind notices (default true).' },
|
|
124
|
+
maxNotificationsPerMinute: { type: 'number', description: 'Notice rate limit per minute (default 6).' },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 返回值 schema(同样走紧凑方言)。`required: true` 只标**两个分支都必然返回**的键:
|
|
129
|
+
* 后台分支没有 hit/count(那些要等作业跑完,从 job_output 取)。
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ **本 schema 与 `execute` 的返回键必须逐键一致**,因为宿主 `createSuccessResult` 会对每个成功返回值跑
|
|
132
|
+
* `validateJsonSchemaValue(tool.output.schema, value, "value")`,有 violation 就抛 ToolOutputError ——
|
|
133
|
+
* 顶层是 `additionalProperties: false`,所以**少声明一个 execute 真会返回的键 = 每次成功都被当失败**。
|
|
134
|
+
* 守卫:test/monitor-tool.test.mjs 用宿主自己的 `valueSchemaSpecToJsonSchema` + `validateJsonSchemaValue`
|
|
135
|
+
* 跑「execute 产出值 vs 本 schema」,前台与后台两种形状都验。
|
|
136
|
+
*/
|
|
137
|
+
export const OUTPUT_SCHEMA = {
|
|
138
|
+
type: 'object',
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
properties: {
|
|
141
|
+
description: { type: 'string', required: true },
|
|
142
|
+
background: { type: 'boolean', required: true, description: 'true: a job was registered; read it with job_output.' },
|
|
143
|
+
mode: { type: 'string', required: true, description: 'once | continuous.' },
|
|
144
|
+
source: { type: 'json', required: true, description: 'The normalized sampling source.' },
|
|
145
|
+
clampNotes: { type: 'array', items: { type: 'string' }, required: true, description: 'Clamp notices from the validator; the tool layer owns the final notes.' },
|
|
146
|
+
notes: { type: 'array', items: { type: 'string' }, required: true, description: 'Model-facing notes (clamp notices unless the deployment composes them).' },
|
|
147
|
+
jobId: { type: 'json', description: 'background only: the registered job id.' },
|
|
148
|
+
hit: { type: 'boolean', description: 'Foreground: did the count reach the threshold.' },
|
|
149
|
+
count: { type: 'number', description: 'Foreground: accumulated hits. continuous returns this instead of hit.' },
|
|
150
|
+
streak: { type: 'number', description: 'Current consecutive-hit streak (resets on a miss).' },
|
|
151
|
+
ep: { type: 'json', description: 'Expected probability: once = Σ(hit P)/countThreshold (missing hits 0); continuous = mean of trigger P (trigger P = Σ(window hit P)/notifyEvery; 0 triggers = 0).' },
|
|
152
|
+
triggers: { type: 'number', description: 'How many times the trigger cadence fired.' },
|
|
153
|
+
hitProbability: { type: 'json', description: 'P of the last hit; null if none.' },
|
|
154
|
+
countThreshold: { type: 'number' },
|
|
155
|
+
countMode: { type: 'string', description: 'cumulative | consecutive.' },
|
|
156
|
+
notifyEvery: { type: 'number', description: 'continuous notice cadence.' },
|
|
157
|
+
at: { type: 'json', description: 'Epoch ms of the returned probe; recorded only.' },
|
|
158
|
+
atIso: { type: 'json' },
|
|
159
|
+
firstAt: { type: 'json' },
|
|
160
|
+
lastAt: { type: 'json' },
|
|
161
|
+
elapsedMs: { type: 'number' },
|
|
162
|
+
samples: { type: 'number', description: 'Probes performed.' },
|
|
163
|
+
hitSamples: { type: 'number', description: 'Probes that counted as a hit.' },
|
|
164
|
+
level: { type: 'boolean', description: 'Verdict of the last probe.' },
|
|
165
|
+
evidence: { type: 'json', description: 'Bounded tail of the sample that produced the hit.' },
|
|
166
|
+
probability: { type: 'json', description: 'P(true) of the last probe — the value compared against hitThreshold.' },
|
|
167
|
+
confidence: { type: 'json' },
|
|
168
|
+
intervalMs: { type: 'number', description: 'Effective probe interval.' },
|
|
169
|
+
timeoutMs: { type: 'number', description: 'Effective deadline.' },
|
|
170
|
+
aborted: { type: 'boolean' },
|
|
171
|
+
timedOut: { type: 'boolean' },
|
|
172
|
+
emptySource: { type: 'boolean', description: 'Foreground: the source returned empty text, so the probe returned immediately without a model call.' },
|
|
173
|
+
errors: { type: 'number', description: 'Failed samples/judgements (they never abort the monitor).' },
|
|
174
|
+
cost: { type: 'object', additionalProperties: true },
|
|
175
|
+
coverage: { type: 'object', additionalProperties: true },
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 宿主实际强校验用的标准 JSON Schema(由上面的紧凑方言编译而来)。
|
|
181
|
+
* 与 `hostDefinition.output.schema` 同源;导出给测试与文档当作机器契约。
|
|
182
|
+
*/
|
|
183
|
+
export const OUTPUT_JSON_SCHEMA = valueSchemaSpecToJsonSchema(OUTPUT_SCHEMA);
|
|
184
|
+
|
|
185
|
+
/** 有限数原样成串;否则退化占位(render 里绝不出现 undefined / NaN)。 */
|
|
186
|
+
function formatNumber(value) {
|
|
187
|
+
return Number.isFinite(value) ? String(value) : '—';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 只接受非空字符串,其余退化占位。 */
|
|
191
|
+
function formatText(value) {
|
|
192
|
+
return typeof value === 'string' && value !== '' ? value : '—';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 会话 cwd:与 decision 同源(宿主自己的 fs 工具用 exec.agent.session.header.cwd)。 */
|
|
196
|
+
function sessionCwd(exec) {
|
|
197
|
+
const header = exec && exec.agent && exec.agent.session && exec.agent.session.header;
|
|
198
|
+
// `header` 可能是 **null**(审查 P3 实测:裸取 .cwd 抛 TypeError,且发生在工具入口、无文案)。
|
|
199
|
+
return header === undefined || header === null ? undefined : header.cwd;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 本会话 id(job 采样源枚举必须带它):deps.sessionId → agent.id → session.id → header.sessionId。 */
|
|
203
|
+
function resolveSessionId(ctx, exec, deps) {
|
|
204
|
+
if (typeof deps.sessionId === 'string' && deps.sessionId !== '') return deps.sessionId;
|
|
205
|
+
const agent = exec && exec.agent;
|
|
206
|
+
if (agent && typeof agent.id === 'string' && agent.id !== '') return agent.id;
|
|
207
|
+
const session = agent && agent.session;
|
|
208
|
+
if (session && typeof session.id === 'string' && session.id !== '') return session.id;
|
|
209
|
+
const header = session && session.header;
|
|
210
|
+
if (header && typeof header.sessionId === 'string' && header.sessionId !== '') return header.sessionId;
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 把 monitor 的 source 折成 source.js 认识的描述符(只挑白名单键,避免 unknown key 报错)。 */
|
|
215
|
+
function toDescriptor(source) {
|
|
216
|
+
if (source.kind === 'file') {
|
|
217
|
+
return {
|
|
218
|
+
kind: 'file',
|
|
219
|
+
path: source.path,
|
|
220
|
+
...(source.startLine === undefined ? {} : { startLine: source.startLine }),
|
|
221
|
+
...(source.endLine === undefined ? {} : { endLine: source.endLine }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
kind: 'glob',
|
|
226
|
+
pattern: source.pattern,
|
|
227
|
+
...(source.limit === undefined ? {} : { limit: source.limit }),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 造采样源:**job / command / events 复用 lib/monitor.js 的采样器**,
|
|
233
|
+
* 而 **file / glob 一律走注入的 `deps.read`**(decision 用的同一个 reader)。
|
|
234
|
+
*
|
|
235
|
+
* 这样本文件**没有任何文件/glob 读取实现**:注入的 reader 负责描述符校验、工作区根围栏与
|
|
236
|
+
* 单文件/glob 两种越限语义;这里只做尾部窗口与形状归一。
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* 预检里哪些失败算「**永远好不了**」⇒ 当场抛;其余留给探测循环重试。
|
|
240
|
+
*
|
|
241
|
+
* - `invalid_request`:描述符形状不合法(`..` 段 / 绝对路径 / 未知键 / 行范围倒挂)——不会随时间变好;
|
|
242
|
+
* - `source_outside_root`:解析后落在工作区根之外——同样不会变好。
|
|
243
|
+
*
|
|
244
|
+
* 其余 kind(`source_missing` / `source_too_large` / 普通 I/O 错)都可能是瞬态:**等文件出现**
|
|
245
|
+
* 正是 P1 的用例(对作业源同理,作业也可能稍后才出现),所以它们必须继续由探测循环重试。
|
|
246
|
+
*/
|
|
247
|
+
function isFatalSourceError(error) {
|
|
248
|
+
const kind = error && error.kind;
|
|
249
|
+
return kind === 'invalid_request' || kind === 'source_outside_root' || kind === 'source_too_large';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function createMonitorSources(ctx, options = {}) {
|
|
253
|
+
const deps = options.deps === undefined ? {} : options.deps;
|
|
254
|
+
const core = createSources(ctx, {
|
|
255
|
+
sessionId: options.sessionId,
|
|
256
|
+
cwd: options.cwd,
|
|
257
|
+
runCommand: deps.runCommand,
|
|
258
|
+
eventLog: deps.eventLog,
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
async read(source, readOptions = {}) {
|
|
262
|
+
const spec = source === null || typeof source !== 'object' ? {} : source;
|
|
263
|
+
if (spec.kind !== 'file' && spec.kind !== 'glob') return core.read(source, readOptions);
|
|
264
|
+
if (typeof deps.read !== 'function') {
|
|
265
|
+
throw new DecisionSourceError(
|
|
266
|
+
'file/glob 采样源需要注入 deps.read(decision 用的同一个 reader)——本工具不实现第二份'
|
|
267
|
+
+ '读取语义。可行动项:在 lib/tool.js 的 apply 里把 createSourceReader(ctx) 的 read 作为'
|
|
268
|
+
+ ' deps.read 传给 registerMonitorTool;或改用 source.kind="job"。',
|
|
269
|
+
{ kind: 'invalid_request' },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const descriptor = toDescriptor(spec);
|
|
273
|
+
const out = await deps.read(descriptor, { signal: readOptions.signal, cwd: options.cwd });
|
|
274
|
+
const fullText = String(out && out.state !== undefined ? out.state : '');
|
|
275
|
+
const windowText = tailOf(fullText, readOptions.tailBytes);
|
|
276
|
+
const fullBytes = Buffer.byteLength(fullText, 'utf8');
|
|
277
|
+
const windowBytes = Buffer.byteLength(windowText, 'utf8');
|
|
278
|
+
const meta = out && out.source !== undefined ? out.source : {};
|
|
279
|
+
const totalBytes = Number.isFinite(meta.bytes) ? meta.bytes : fullBytes;
|
|
280
|
+
return {
|
|
281
|
+
kind: spec.kind,
|
|
282
|
+
text: windowText,
|
|
283
|
+
bytes: windowBytes,
|
|
284
|
+
totalBytes,
|
|
285
|
+
truncated: meta.truncated === true || windowBytes < totalBytes,
|
|
286
|
+
files: meta.files,
|
|
287
|
+
descriptor,
|
|
288
|
+
source: meta,
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `output.render` 的实现:**全函数**(绝不抛),返回内容块数组 `[{type:'text',text}]`。
|
|
296
|
+
* 内容块契约是宿主硬要求(它直接对数组做 `some(block => block.type === 'image')`)。
|
|
297
|
+
*/
|
|
298
|
+
export function renderMonitor(_args, value) {
|
|
299
|
+
try {
|
|
300
|
+
const v = value === null || typeof value !== 'object' ? {} : value;
|
|
301
|
+
const lines = [];
|
|
302
|
+
if (v.emptySource === true) {
|
|
303
|
+
lines.push('monitor: source returned empty — returned immediately, no model call (samples ' + formatText(v.samples) + ').');
|
|
304
|
+
}
|
|
305
|
+
if (v.background === true) {
|
|
306
|
+
const continuous = v.mode === 'continuous';
|
|
307
|
+
const cadence = continuous
|
|
308
|
+
? 'notifyEvery ' + formatNumber(v.notifyEvery)
|
|
309
|
+
: 'threshold ' + formatNumber(v.countThreshold);
|
|
310
|
+
lines.push('monitor: background job registered — jobId ' + formatText(v.jobId)
|
|
311
|
+
+ ' (mode ' + formatText(v.mode) + ', countMode ' + formatText(v.countMode) + ', ' + cadence + ')');
|
|
312
|
+
lines.push(continuous
|
|
313
|
+
? 'Read it later with job_output (streamed progress); notices fire every ' + formatNumber(v.notifyEvery) + ' hit(s).'
|
|
314
|
+
: 'Read it later with job_output (streamed progress); a notice fires when the threshold is reached.');
|
|
315
|
+
} else {
|
|
316
|
+
const continuous = v.mode === 'continuous';
|
|
317
|
+
const hit = v.hit === true;
|
|
318
|
+
if (continuous) {
|
|
319
|
+
lines.push('monitor continuous: ' + formatNumber(v.count) + ' hits'
|
|
320
|
+
+ (v.countMode === 'consecutive' ? ' (streak ' + formatNumber(v.streak) + ')' : '')
|
|
321
|
+
+ ' · probes ' + formatNumber(v.samples) + ' · elapsed ' + formatNumber(v.elapsedMs) + 'ms');
|
|
322
|
+
} else {
|
|
323
|
+
lines.push('monitor ' + (hit ? 'HIT' : 'no hit') + ': ' + formatNumber(v.count) + '/' + formatNumber(v.countThreshold)
|
|
324
|
+
+ ' hits · probes ' + formatNumber(v.samples) + ' · hit probes ' + formatNumber(v.hitSamples)
|
|
325
|
+
+ ' · elapsed ' + formatNumber(v.elapsedMs) + 'ms');
|
|
326
|
+
}
|
|
327
|
+
lines.push('last probe P(true) = ' + formatNumber(v.probability) + ' · confidence ' + formatNumber(v.confidence)
|
|
328
|
+
+ ' · level ' + formatText(v.level === true ? 'true' : (v.level === false ? 'false' : v.level)));
|
|
329
|
+
lines.push('at ' + formatText(v.atIso) + ' (epoch ' + formatNumber(v.at) + 'ms) — recorded only, never a decision input');
|
|
330
|
+
lines.push('first probe ' + formatNumber(v.firstAt) + 'ms · last probe ' + formatNumber(v.lastAt)
|
|
331
|
+
+ 'ms · interval ' + formatNumber(v.intervalMs) + 'ms · deadline ' + formatNumber(v.timeoutMs) + 'ms');
|
|
332
|
+
if (v.aborted === true) lines.push(continuous ? 'cancelled by the caller' : 'cancelled by the caller before the threshold was reached');
|
|
333
|
+
if (v.timedOut === true) lines.push(continuous ? 'deadline reached' : 'deadline reached before the threshold was reached');
|
|
334
|
+
if (Number.isFinite(v.errors) && v.errors > 0) lines.push('failed probes: ' + formatNumber(v.errors) + ' (they never abort the monitor)');
|
|
335
|
+
const evidence = typeof v.evidence === 'string' && v.evidence !== '' ? v.evidence : '(none — no hit yet)';
|
|
336
|
+
lines.push('evidence (bounded tail of the hitting sample):' + NL + evidence);
|
|
337
|
+
if (hit || (continuous && Number.isFinite(v.count) && v.count > 0)) {
|
|
338
|
+
lines.push('Nothing was acted on: this call only reports. Kill / retry / dispatch stay your call.');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const notes = Array.isArray(v.notes) ? v.notes : [];
|
|
342
|
+
const seen = new Set();
|
|
343
|
+
for (const note of notes) {
|
|
344
|
+
if (typeof note === 'string' && note !== '' && !seen.has(note)) {
|
|
345
|
+
seen.add(note);
|
|
346
|
+
lines.push(note);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// clampNotes 是**只透传**的数据;若工具层没把它们并进 notes,这里补印一遍(不重复、不新增文案)。
|
|
350
|
+
const clamps = Array.isArray(v.clampNotes) ? v.clampNotes : [];
|
|
351
|
+
for (const note of clamps) {
|
|
352
|
+
if (typeof note === 'string' && note !== '' && !seen.has(note)) {
|
|
353
|
+
seen.add(note);
|
|
354
|
+
lines.push(note);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return [{ type: 'text', text: lines.join(NL) }];
|
|
358
|
+
} catch (error) {
|
|
359
|
+
// render 抛错会被宿主包成 ToolOutputError,把一次**成功**的调用变成失败 ⇒ 一律退化。
|
|
360
|
+
return [{ type: 'text', text: 'monitor: (render degraded — the canonical value is in the tool result)' }];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 把 monitor 注册成模型可见工具。
|
|
366
|
+
*
|
|
367
|
+
* @param {any} ctx 宿主上下文(需要 tools.register;job 采样源另需 jobs/sessions)
|
|
368
|
+
* @param {object} [options]
|
|
369
|
+
* @param {object} [options.config] 插件配置(读 `monitorToolName` 与各 monitor 默认值)
|
|
370
|
+
* @param {() => object} [options.resolveConfig] 动态配置读取(给了就优先于 config)
|
|
371
|
+
* @param {object} [options.deps] 注入面:`decide`(必填)、`read`(file/glob 必填)、`notifier`/`deliver`、
|
|
372
|
+
* `clock`、`sleep`、`composeNotes`、`onProgress`、`onHit`、`register`(false = 只造不注册)、
|
|
373
|
+
* `sessionId`、`owner`、`agent`、`runCommand`、`eventLog`、`maxProbes`
|
|
374
|
+
* @returns {{ name: string, definition: object, hostDefinition: object, registered: boolean }}
|
|
375
|
+
* `definition` 是**紧凑方言**的原始定义(自测预算与结构用);`hostDefinition` 是 `defineTool` 归一后
|
|
376
|
+
* **可直接交给 `ctx.tools.register`** 的对象;`registered` 表示本次调用是否真的注册了。
|
|
377
|
+
*/
|
|
378
|
+
export function registerMonitorTool(ctx, options = {}) {
|
|
379
|
+
const config = options.config === undefined ? {} : options.config;
|
|
380
|
+
const deps = options.deps === undefined ? {} : options.deps;
|
|
381
|
+
const readConfig = () => {
|
|
382
|
+
if (typeof options.resolveConfig !== 'function') return config;
|
|
383
|
+
const resolved = options.resolveConfig();
|
|
384
|
+
return resolved === undefined || resolved === null ? config : resolved;
|
|
385
|
+
};
|
|
386
|
+
const cfg = readConfig();
|
|
387
|
+
const name = typeof cfg.monitorToolName === 'string' && cfg.monitorToolName.trim() !== ''
|
|
388
|
+
? cfg.monitorToolName
|
|
389
|
+
: MONITOR_TOOL_DEFAULT_NAME;
|
|
390
|
+
|
|
391
|
+
const definition = {
|
|
392
|
+
name,
|
|
393
|
+
description: TOOL_DESCRIPTION,
|
|
394
|
+
parameters: PARAMETERS,
|
|
395
|
+
output: { schema: OUTPUT_SCHEMA, render: renderMonitor },
|
|
396
|
+
/**
|
|
397
|
+
* 前台:跑探测循环并返回计数结果;后台:注册宿主作业并立刻返回 jobId。
|
|
398
|
+
* 参数一律经 `validateMonitorArgs` 归一(钳制不在这里做)。
|
|
399
|
+
*/
|
|
400
|
+
async execute(args, exec) {
|
|
401
|
+
const normalized = validateMonitorArgs(args, { config: readConfig() });
|
|
402
|
+
const clampNotes = Array.isArray(normalized.clampNotes) ? normalized.clampNotes.slice() : [];
|
|
403
|
+
// notes:**只透传**。工具层给了 composeNotes 就用它的结果,否则原样等于 clampNotes
|
|
404
|
+
// —— 本文件一行 notes 文案都不新增。
|
|
405
|
+
const composed = typeof deps.composeNotes === 'function'
|
|
406
|
+
? await deps.composeNotes({ args, normalized, clampNotes })
|
|
407
|
+
: clampNotes;
|
|
408
|
+
const notes = Array.isArray(composed) ? composed : clampNotes;
|
|
409
|
+
const base = {
|
|
410
|
+
description: normalized.description,
|
|
411
|
+
mode: normalized.mode,
|
|
412
|
+
source: normalized.source,
|
|
413
|
+
countMode: normalized.countMode,
|
|
414
|
+
notifyEvery: normalized.notifyEvery,
|
|
415
|
+
countThreshold: normalized.countThreshold,
|
|
416
|
+
clampNotes,
|
|
417
|
+
notes,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const userDecide = deps.decide;
|
|
421
|
+
if (typeof userDecide !== 'function') {
|
|
422
|
+
throw new TypeError('monitor 需要注入 deps.decide((text, meta) => ({ probability }))——判据引擎不在本模块里。');
|
|
423
|
+
}
|
|
424
|
+
// rubric / type / criteria / hitThreshold 是**每次调用**从 args 来的,而 decide 是注册时注入的
|
|
425
|
+
// ⇒ 必须把本次生效的判据随 meta 一起交给它(monitor.js 的 meta 只有 index/at/source/sample)。
|
|
426
|
+
// 只用 (text) 的包装(如判据已预绑定)照常工作:多出来的第二个实参会被忽略。
|
|
427
|
+
// 注意:validateMonitorArgs 只**归一/钳制**,不回抄 rubric/type/criteria(那三键原样留在 args 里,
|
|
428
|
+
// 由 decision 内核自己校验)⇒ 判据三键从 args 取,钳制过的那几个从 normalized 取。
|
|
429
|
+
const raw = args === null || typeof args !== 'object' ? {} : args;
|
|
430
|
+
// P1(2026-10-19 真机):后台作业的生命周期**长于**启动它的调用/轮次。把 exec.signal 透传给
|
|
431
|
+
// 每一次探测的 decide ⇒ 调用返回时 signal 一 abort,之后每个 probe 都在 callSystemOne 里失败,
|
|
432
|
+
// 被计成 errors(结果只剩 hit=false,和“事件没发生”无法区分)。后台的取消由 monitor 作业
|
|
433
|
+
// 自己的 controller(runMonitor 的 signal)负责;这里只在前台转发 exec.signal。
|
|
434
|
+
const callSignal = normalized.background === true
|
|
435
|
+
? undefined
|
|
436
|
+
: (exec === undefined ? undefined : exec.signal);
|
|
437
|
+
const decide = (text, meta) => userDecide(text, {
|
|
438
|
+
...meta,
|
|
439
|
+
rubric: raw.rubric,
|
|
440
|
+
type: raw.type,
|
|
441
|
+
criteria: raw.criteria,
|
|
442
|
+
hitThreshold: normalized.hitThreshold,
|
|
443
|
+
args: raw,
|
|
444
|
+
normalized,
|
|
445
|
+
// 后台:meta.signal 是 monitor 作业自己的 controller(runMonitor 传入);
|
|
446
|
+
// 绝不再透传启动它的 exec.signal(P1:调用返回即 abort,会把后续 probe 全打成 errors)。
|
|
447
|
+
signal: meta && meta.signal ? meta.signal : callSignal,
|
|
448
|
+
});
|
|
449
|
+
// **配置错误与描述符错误必须当场失败**:探测循环刻意容忍「这次读不到」的瞬态失败
|
|
450
|
+
// (P1 的用例就是「等到文件里出现某行」),但「根本没注入服务」与「描述符永远不合法」
|
|
451
|
+
// 一旦被循环吞掉,就会变成等到 deadline 后一句 no hit —— 那等于把配置错误伪装成
|
|
452
|
+
// 「事件没发生」(verifier 的 P1 探针:坏 path 跑满默认 120 s)。
|
|
453
|
+
const sourceKind = normalized.source && normalized.source.kind;
|
|
454
|
+
if ((sourceKind === 'file' || sourceKind === 'glob') && typeof deps.read !== 'function') {
|
|
455
|
+
throw new DecisionSourceError(
|
|
456
|
+
'source.kind=' + String(sourceKind) + ' 需要注入 deps.read(decision 用的同一个 reader):'
|
|
457
|
+
+ '本工具不实现第二份文件/glob 读取语义。可行动项:在 lib/tool.js 的 apply 里把'
|
|
458
|
+
+ ' createSourceReader(ctx) 的 read 作为 deps.read 传给 registerMonitorTool;或改用 source.kind="job"。',
|
|
459
|
+
{ kind: 'invalid_request' },
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (sourceKind === 'command' && typeof deps.runCommand !== 'function') {
|
|
463
|
+
throw new DecisionSourceError(
|
|
464
|
+
'source.kind=command 需要在装配层注入 deps.runCommand(source, { tailBytes, signal }):本工具不自行 spawn 子进程。',
|
|
465
|
+
{ kind: 'invalid_request' },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
// monitorEventLog 不是已注册服务 ⇒ 读它同样 probe-safe(Cordis 代理读未 inject 的键会抛)。
|
|
469
|
+
const jobsForEvents = probeProperty(ctx, 'jobs');
|
|
470
|
+
const canProjectJobs = jobsForEvents !== undefined && jobsForEvents !== null
|
|
471
|
+
&& typeof jobsForEvents.list === 'function'
|
|
472
|
+
&& typeof resolveSessionId(ctx, exec, deps) === 'string';
|
|
473
|
+
if (sourceKind === 'events' && deps.eventLog === undefined && probeProperty(ctx, 'monitorEventLog') === undefined && !canProjectJobs) {
|
|
474
|
+
throw new DecisionSourceError(
|
|
475
|
+
'source.kind=events 需要数据源:deps.eventLog、ctx.monitorEventLog,或可用的 ctx.jobs.list(sessionId)。',
|
|
476
|
+
{ kind: 'invalid_request' },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const clock = typeof deps.clock === 'function' ? deps.clock : () => Date.now();
|
|
480
|
+
const sleep = typeof deps.sleep === 'function'
|
|
481
|
+
? deps.sleep
|
|
482
|
+
: (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
483
|
+
const signal = exec === undefined ? undefined : exec.signal;
|
|
484
|
+
const sessionId = resolveSessionId(ctx, exec, deps);
|
|
485
|
+
|
|
486
|
+
// task-34:`sessionId` 与**本次调用的 agent** 都只有在**执行期**才知道,而 lib/monitor.js 的
|
|
487
|
+
// `resolveAgent` 只认 `deps.agent` / `deps.sessionId` ⇒ 两者必须随每次调用注入下去。否则:
|
|
488
|
+
// ① 拿不到真实 agent ⇒ 算不出 idle(后台命中通知永远不会唤醒,只会静默 inject);
|
|
489
|
+
// ② 装配层的 deliver 用同一个 sessionId 解析目标会话 ⇒ 少了它就送不到触发它的那个会话。
|
|
490
|
+
// 优先级:显式注入的 `deps.agent`(测试/特殊装配)> 本次 `exec.agent`。
|
|
491
|
+
const callAgent = deps.agent === undefined ? (exec === undefined ? undefined : exec.agent) : deps.agent;
|
|
492
|
+
const monitorDeps = { ...deps, sessionId, agent: callAgent };
|
|
493
|
+
// task-34(续):`deliver` 是装配期**静态**注入的闭包,而投递目标(agent)只有**执行期**才知道,
|
|
494
|
+
// 且 notify.js 的契约只给 `(mode, payload)`(payload 里也没有 sessionId)⇒ 装配层无从反查。
|
|
495
|
+
// 由这里把两者**作为第三个实参**一起交出去(多一个实参不破坏两参契约):
|
|
496
|
+
// 装配层用 `info.agent` 直接投递,或退回 `info.sessionId` 经 ctx.agents.get() 反查;取不到就静默返回。
|
|
497
|
+
monitorDeps.deliver = typeof deps.deliver === 'function'
|
|
498
|
+
? (mode, payload) => deps.deliver(mode, payload, { agent: callAgent, sessionId })
|
|
499
|
+
: deps.deliver;
|
|
500
|
+
// task-34(续):`runCommand` 与 deliver 同形 —— 它也是装配期注入的,而委托执行上游工具需要**调用者上下文**。
|
|
501
|
+
// 真机三态实测(Lead):委托 exec 只给 `{agent}` ⇒ git_bash 与 pwsh **一起** 8/8 errors;什么都不给 ⇒
|
|
502
|
+
// git_bash 命中、pwsh 仍 8/8 errors。原因是管道转发的是 `agent: caller.agent` **与** `parent: caller.token`
|
|
503
|
+
// (lib/pipe.js:314-315)—— **缺 token 时宿主直接拒收**。所以「半个上下文」比「没有上下文」更糟:
|
|
504
|
+
// 必须把**整份 exec** 交出去,由装配层决定怎么用(它只在 info.exec 存在时整份透传、只覆盖 signal)。
|
|
505
|
+
monitorDeps.runCommand = typeof deps.runCommand === 'function'
|
|
506
|
+
? (commandSource, commandOptions) => deps.runCommand(commandSource, commandOptions, { agent: callAgent, sessionId, exec })
|
|
507
|
+
: deps.runCommand;
|
|
508
|
+
|
|
509
|
+
// 采样源必须在**包装之后**建:command 源的 runner 是从这里注入的 deps 里取的(`deps.runCommand`),
|
|
510
|
+
// 建早了就等于把未包装的原函数交给采样器,agent 照样到不了装配层。
|
|
511
|
+
const sources = createMonitorSources(ctx, { deps: monitorDeps, cwd: sessionCwd(exec), sessionId });
|
|
512
|
+
|
|
513
|
+
// **描述符级预检**(file/glob):用**同一个注入的 reader** 试读一次,让「形状/越界」这类
|
|
514
|
+
// 永远好不了的错误直接外抛。走 reader 而不是自己校验描述符,有两个不可替代的好处:
|
|
515
|
+
// ① 报错文案由 reader 改写(`material.*` → `source.*`),模型照文案改参会改对地方;
|
|
516
|
+
// ② 不产生第二份描述符校验实现。
|
|
517
|
+
// 代价是每次调用多读一次源(一次,不是每轮探测一次),换取配置错误当场暴露。
|
|
518
|
+
// command **不做预检** —— 试读会真的执行一次命令(有副作用)。
|
|
519
|
+
if (sourceKind === 'file' || sourceKind === 'glob') {
|
|
520
|
+
try {
|
|
521
|
+
await sources.read(normalized.source, { tailBytes: normalized.tailBytes, signal });
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (isFatalSourceError(error)) throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// `notify:'inbox'` 只有后台分支会消费(前台没有作业可挂通知),此前是**静默忽略**:
|
|
528
|
+
// 调用方以为通知会来,实际什么都没有。按「配置错误必须当场失败」的口径改成可行动错误。
|
|
529
|
+
if (normalized.notify === 'inbox' && normalized.background !== true) {
|
|
530
|
+
throw new DecisionSourceError(
|
|
531
|
+
'notify:"inbox" 只在 background:true 时生效(通知随宿主作业的生命周期投递)。'
|
|
532
|
+
+ '可行动项:加 background:true 并用 job_output 取结果;或去掉 notify 改用前台返回值。',
|
|
533
|
+
{ kind: 'invalid_request' },
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (normalized.background === true) {
|
|
537
|
+
// validateMonitorArgs 只归一/钳制,不回抄判据三键(rubric/type/criteria 留在 raw 里);
|
|
538
|
+
// 命中通知要写明「在等什么」,所以把本次调用的判据一并交给后台作业。
|
|
539
|
+
const started = startBackgroundMonitor(ctx, {
|
|
540
|
+
...normalized,
|
|
541
|
+
rubric: raw.rubric,
|
|
542
|
+
type: raw.type,
|
|
543
|
+
criteria: raw.criteria,
|
|
544
|
+
}, {
|
|
545
|
+
decide,
|
|
546
|
+
sources,
|
|
547
|
+
clock,
|
|
548
|
+
sleep,
|
|
549
|
+
// P1(审查 task-35):默认 `notify:'none'` 时此前**照样把 notifier 交出去** ⇒ 后台仍在发通知。
|
|
550
|
+
// 只有显式 `notify:'inbox'` 才建通知路径;`coalesce:false` 直接把合并窗置 0(此前该参数是死的)。
|
|
551
|
+
notify: normalized.notify === 'inbox' ? deps.notifier : undefined,
|
|
552
|
+
// P2(2026-10-19 真机):notify:'none' 时此前仍把 deliver 交下去 ⇒ startBackgroundMonitor
|
|
553
|
+
// 用 deliver 兜底建 notifier,命中照样发通知。投递路径只对显式 inbox 交付。
|
|
554
|
+
deliver: normalized.notify === 'inbox' ? monitorDeps.deliver : undefined,
|
|
555
|
+
agent: monitorDeps.agent,
|
|
556
|
+
sessionId: monitorDeps.sessionId,
|
|
557
|
+
owner: deps.owner === undefined ? sessionId : deps.owner,
|
|
558
|
+
coalesceWindowMs: normalized.coalesce === false ? 0 : normalized.coalesceWindowMs,
|
|
559
|
+
maxPerMinute: normalized.maxNotificationsPerMinute,
|
|
560
|
+
onProgress: deps.onProgress,
|
|
561
|
+
onHit: deps.onHit,
|
|
562
|
+
});
|
|
563
|
+
return { ...base, background: true, jobId: started.jobId };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const result = await runMonitor({
|
|
567
|
+
sources,
|
|
568
|
+
decide,
|
|
569
|
+
clock,
|
|
570
|
+
sleep,
|
|
571
|
+
signal,
|
|
572
|
+
args: { ...normalized, onProgress: deps.onProgress, maxProbes: deps.maxProbes },
|
|
573
|
+
});
|
|
574
|
+
// `emptyReason` 是 monitor 的内部字段:解构掉,改写成一句人类可读的 note。
|
|
575
|
+
const { emptyReason, ...resultOut } = result;
|
|
576
|
+
const finalNotes = [...base.notes];
|
|
577
|
+
if (result.emptySource === true) {
|
|
578
|
+
finalNotes.push('source 返回空内容 ⇒ 已短路返回(samples=' + result.samples + '),'
|
|
579
|
+
+ (result.samples > 1 ? '之前可能已调用 Decision。' : '未调用 Decision。')
|
|
580
|
+
+ (typeof emptyReason === 'string' && emptyReason !== '' ? '原因:' + emptyReason : ''));
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
...base,
|
|
584
|
+
notes: finalNotes,
|
|
585
|
+
background: false,
|
|
586
|
+
...(result.hit === undefined ? {} : { hit: result.hit }),
|
|
587
|
+
count: result.count,
|
|
588
|
+
streak: result.streak,
|
|
589
|
+
ep: result.ep,
|
|
590
|
+
triggers: result.triggers,
|
|
591
|
+
hitProbability: result.hitProbability,
|
|
592
|
+
countThreshold: result.countThreshold,
|
|
593
|
+
countMode: result.countMode,
|
|
594
|
+
notifyEvery: result.notifyEvery,
|
|
595
|
+
at: result.at,
|
|
596
|
+
atIso: result.atIso,
|
|
597
|
+
firstAt: result.firstAt,
|
|
598
|
+
lastAt: result.lastAt,
|
|
599
|
+
elapsedMs: result.elapsedMs,
|
|
600
|
+
samples: result.samples,
|
|
601
|
+
hitSamples: result.hitSamples,
|
|
602
|
+
level: result.level,
|
|
603
|
+
probability: result.probability,
|
|
604
|
+
confidence: result.confidence,
|
|
605
|
+
evidence: result.evidence,
|
|
606
|
+
intervalMs: result.intervalMs,
|
|
607
|
+
timeoutMs: result.timeoutMs,
|
|
608
|
+
aborted: result.aborted,
|
|
609
|
+
timedOut: result.timedOut,
|
|
610
|
+
emptySource: result.emptySource === true,
|
|
611
|
+
errors: result.errors,
|
|
612
|
+
// cost:只用**注入时钟**测得的探测次数与时长,不声称任何 token 数(判定在 deps.decide 里)。
|
|
613
|
+
cost: {
|
|
614
|
+
probes: result.samples,
|
|
615
|
+
elapsedMs: result.elapsedMs,
|
|
616
|
+
intervalMs: result.intervalMs,
|
|
617
|
+
timeoutMs: result.timeoutMs,
|
|
618
|
+
},
|
|
619
|
+
coverage: {
|
|
620
|
+
probes: result.samples,
|
|
621
|
+
hits: result.count,
|
|
622
|
+
errors: result.errors,
|
|
623
|
+
aborted: result.aborted,
|
|
624
|
+
timedOut: result.timedOut,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const hostDefinition = defineTool(definition);
|
|
631
|
+
let registered = false;
|
|
632
|
+
if (deps.register !== false) {
|
|
633
|
+
const registrar = typeof deps.register === 'function'
|
|
634
|
+
? deps.register
|
|
635
|
+
: (() => {
|
|
636
|
+
const tools = probeProperty(ctx, 'tools');
|
|
637
|
+
return tools && typeof probeProperty(tools, 'register') === 'function'
|
|
638
|
+
? (built) => tools.register(built)
|
|
639
|
+
: null;
|
|
640
|
+
})();
|
|
641
|
+
if (registrar !== null) {
|
|
642
|
+
registrar(hostDefinition);
|
|
643
|
+
registered = true;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { name: definition.name, definition, hostDefinition, registered };
|
|
647
|
+
}
|
|
648
|
+
|