@twinsearth/dsh-master 3.2.10 → 3.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/README.md +3 -0
- package/client.js +50 -0
- package/dist-host/cost/attribution.js +183 -0
- package/dist-host/cost/audit.js +163 -0
- package/dist-host/cost/budget.js +211 -0
- package/dist-host/cost/bus.js +95 -0
- package/dist-host/cost/cert-chain.js +100 -0
- package/dist-host/cost/experiment.js +132 -0
- package/dist-host/cost/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +312 -0
- package/dist-host/cost/ledger-report.js +193 -0
- package/dist-host/cost/ledger.js +401 -0
- package/dist-host/cost/metrics.js +198 -0
- package/dist-host/cost/milestones.js +171 -0
- package/dist-host/cost/modules/index.js +5 -0
- package/dist-host/cost/modules/manifests.js +130 -0
- package/dist-host/cost/policy.js +276 -0
- package/dist-host/cost/report.js +156 -0
- package/dist-host/cost/reuse-report.js +128 -0
- package/dist-host/cost/rsi.js +367 -0
- package/dist-host/cost/toolface.js +147 -0
- package/dist-host/cost/types.js +31 -0
- package/dist-host/kernel.js +28 -2
- package/dist-host/plugins/cost-optimizer.js +277 -27
- package/dist-host/plugins/superagent-router.js +88 -1
- package/dist-host/superagent/types.js +8 -0
- package/dist-worker/agent-loop.js +449 -7
- package/dist-worker/baseline-fit.js +66 -0
- package/dist-worker/budget-guard.js +93 -0
- package/dist-worker/ccs.js +99 -0
- package/dist-worker/cost-hooks.js +115 -0
- package/dist-worker/cost-modules.js +330 -0
- package/dist-worker/cost-spi.js +3 -0
- package/dist-worker/edg.js +372 -0
- package/dist-worker/gdr.js +159 -0
- package/dist-worker/index.js +80 -2
- package/dist-worker/llm-http.js +20 -0
- package/dist-worker/sink-http.js +1 -0
- package/dist-worker/spill.js +115 -0
- package/dist-worker/tool-batch.js +66 -0
- package/dist-worker/tool-compact.js +275 -0
- package/dist-worker/tool-disclosure.js +90 -0
- package/dist-worker/tools.js +16 -0
- package/docs/ARCHITECTURE.md +21 -0
- package/docs/CHANGELOG.md +50 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
- package/docs/EXPERIMENT-M5-real-ab.md +82 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +1 -1
- package/src/cost/attribution.ts +186 -0
- package/src/cost/audit.ts +170 -0
- package/src/cost/budget.ts +226 -0
- package/src/cost/bus.ts +97 -0
- package/src/cost/cert-chain.ts +102 -0
- package/src/cost/experiment.ts +136 -0
- package/src/cost/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +316 -0
- package/src/cost/ledger-report.ts +186 -0
- package/src/cost/ledger.ts +385 -0
- package/src/cost/metrics.ts +193 -0
- package/src/cost/milestones.ts +170 -0
- package/src/cost/modules/index.ts +13 -0
- package/src/cost/modules/manifests.ts +129 -0
- package/src/cost/policy.ts +276 -0
- package/src/cost/report.ts +158 -0
- package/src/cost/reuse-report.ts +131 -0
- package/src/cost/rsi.ts +364 -0
- package/src/cost/toolface.ts +151 -0
- package/src/cost/types.ts +298 -0
- package/src/kernel.ts +27 -2
- package/src/plugins/cost-optimizer.ts +271 -26
- package/src/plugins/superagent-router.ts +82 -1
- package/src/superagent/types.ts +70 -0
- package/worker/agent-loop.ts +444 -7
- package/worker/baseline-fit.ts +80 -0
- package/worker/budget-guard.ts +156 -0
- package/worker/ccs.ts +202 -0
- package/worker/cost-hooks.ts +133 -0
- package/worker/cost-modules.ts +356 -0
- package/worker/cost-spi.ts +283 -0
- package/worker/edg.ts +424 -0
- package/worker/gdr.ts +233 -0
- package/worker/index.ts +84 -3
- package/worker/llm-http.ts +18 -0
- package/worker/sink-http.ts +5 -2
- package/worker/spill.ts +133 -0
- package/worker/tool-batch.ts +124 -0
- package/worker/tool-compact.ts +317 -0
- package/worker/tool-disclosure.ts +131 -0
- package/worker/tools.ts +553 -531
- package/worker/types.ts +191 -0
package/worker/edg.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 3.3.0 · B1 `edg`(可执行决策图,设计 §7)。
|
|
6
|
+
*
|
|
7
|
+
* 一条边 = 「精确动作 + 确定性参数来源 + 守卫」,这是 HarnessMM 的核心资产:
|
|
8
|
+
* 慢模型只需在**作者步骤**写一次,之后由守卫命中直接复用。
|
|
9
|
+
*
|
|
10
|
+
* 硬约束(写死在实现里):
|
|
11
|
+
* * `schemaHash` 与当前工具 schema 不一致 ⇒ **整图作废**(不得复用旧决策);
|
|
12
|
+
* * 参数只能来自回执的**确定性提取**(literal / regex / jsonpath)——
|
|
13
|
+
* 解析不出来就直接判定该边不可用(**禁止模型幻觉参数**);
|
|
14
|
+
* * `destructive: true` 的边**默认不入池**(validate 直接拒);
|
|
15
|
+
* * 生命周期 `draft → validated → active → exhausted → archived`,非法跃迁一律拒绝;
|
|
16
|
+
* * 图来源两种:慢模型作者步骤(`parseDraftFromModel`)或人工/AI 预置模板库。
|
|
17
|
+
*
|
|
18
|
+
* 作者成本**单独记**:`phase:'authoring'` + `account:'governance'`(由宿主记账,见 cost-optimizer)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
export type EdgState = 'draft' | 'validated' | 'active' | 'exhausted' | 'archived'
|
|
24
|
+
export type ArgSourceKind = 'literal' | 'regex' | 'jsonpath'
|
|
25
|
+
export type GuardKind = 'regex' | 'jsonpath' | 'equals'
|
|
26
|
+
export type EdgSource = 'author' | 'template'
|
|
27
|
+
|
|
28
|
+
export interface EdgArgSource {
|
|
29
|
+
arg: string
|
|
30
|
+
from: ArgSourceKind
|
|
31
|
+
expr: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EdgGuard {
|
|
35
|
+
kind: GuardKind
|
|
36
|
+
expr: string
|
|
37
|
+
negate?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EdgEdge {
|
|
41
|
+
id: string
|
|
42
|
+
toolName: string
|
|
43
|
+
schemaHash: string
|
|
44
|
+
argSource: EdgArgSource[]
|
|
45
|
+
guard: EdgGuard
|
|
46
|
+
destructive: boolean
|
|
47
|
+
dependsOn?: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface EdgGraph {
|
|
51
|
+
id: string
|
|
52
|
+
taskId: string
|
|
53
|
+
createdAt: number
|
|
54
|
+
source: EdgSource
|
|
55
|
+
state: EdgState
|
|
56
|
+
model?: string
|
|
57
|
+
/** 冻结后才可被 enforce 引用(§7 B4:RSI 产出候选,冻结后上线)。 */
|
|
58
|
+
frozen: boolean
|
|
59
|
+
edges: EdgEdge[]
|
|
60
|
+
/** 作者步骤消耗的 token(记 governance,不算节省) */
|
|
61
|
+
authorTokens: number
|
|
62
|
+
reason?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 当前工具 schema 的稳定哈希(tools 列表变化即整图作废)。 */
|
|
66
|
+
export function schemaHashOf(toolName: string, parameters: unknown, description?: string): string {
|
|
67
|
+
const body = JSON.stringify({ toolName, parameters: parameters ?? {}, description: description ?? '' })
|
|
68
|
+
return `sha256:${crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tokensOf = (text: string): number => (text.length > 0 ? Math.ceil(text.length / 4) : 0)
|
|
72
|
+
|
|
73
|
+
/* ------------------------------------------------------------------ *
|
|
74
|
+
* 确定性提取(禁止幻觉参数)
|
|
75
|
+
* ------------------------------------------------------------------ */
|
|
76
|
+
|
|
77
|
+
/** 极简 JSONPath:`$.a.b` / `$.a[0].b`(够用即止,不做完整实现)。 */
|
|
78
|
+
export function jsonPathGet(value: unknown, expr: string): unknown {
|
|
79
|
+
const path = String(expr ?? '').trim()
|
|
80
|
+
if (!path.startsWith('$')) return undefined
|
|
81
|
+
const segments: Array<string | number> = []
|
|
82
|
+
const body = path.slice(1)
|
|
83
|
+
const re = /\.([A-Za-z_$][\w$]*)|\[(\d+)\]/gu
|
|
84
|
+
let match: RegExpExecArray | null
|
|
85
|
+
let consumed = 0
|
|
86
|
+
while ((match = re.exec(body)) !== null) {
|
|
87
|
+
if (match.index !== consumed) return undefined
|
|
88
|
+
consumed = match.index + match[0].length
|
|
89
|
+
segments.push(match[2] === undefined ? match[1] : Number(match[2]))
|
|
90
|
+
}
|
|
91
|
+
if (consumed !== body.length) return undefined
|
|
92
|
+
let current: unknown = value
|
|
93
|
+
for (const segment of segments) {
|
|
94
|
+
if (current === null || current === undefined) return undefined
|
|
95
|
+
if (typeof segment === 'number') {
|
|
96
|
+
if (!Array.isArray(current)) return undefined
|
|
97
|
+
current = current[segment]
|
|
98
|
+
} else {
|
|
99
|
+
if (typeof current !== 'object') return undefined
|
|
100
|
+
current = (current as Record<string, unknown>)[segment]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return current
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 把回执文本按需解析成 JSON(失败返回 undefined,不猜)。 */
|
|
107
|
+
function asJson(text: string): unknown {
|
|
108
|
+
const raw = String(text ?? '')
|
|
109
|
+
try { return JSON.parse(raw) } catch { /* 继续尝试提取 JSON 片段 */ }
|
|
110
|
+
// 回执常带前缀(如 shell 的 STDOUT: 或说明行):在**确定性位置**截取首个 { 到末个 }
|
|
111
|
+
// 再解析。这仍然是确定性的文本提取,不是猜测。
|
|
112
|
+
const start = raw.indexOf('{')
|
|
113
|
+
const end = raw.lastIndexOf('}')
|
|
114
|
+
if (start < 0 || end <= start) return undefined
|
|
115
|
+
try { return JSON.parse(raw.slice(start, end + 1)) } catch { return undefined }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 解析单个 argSource。**失败返回 undefined**(调用方据此判定该边不可用),
|
|
120
|
+
* 绝不回退到"模型给的参数"或默认值。
|
|
121
|
+
*/
|
|
122
|
+
export function resolveArg(source: EdgArgSource, receiptText: string): unknown {
|
|
123
|
+
const expr = String(source?.expr ?? '')
|
|
124
|
+
if (source?.from === 'literal') {
|
|
125
|
+
// literal 就是字面量本身(不做变量替换,避免"看起来像模板"的幻觉空间)
|
|
126
|
+
return expr
|
|
127
|
+
}
|
|
128
|
+
if (source?.from === 'regex') {
|
|
129
|
+
// expr 形如 `/(?<v>...)/` 或裸正则;取第一个捕获组,没有捕获组则取整个匹配。
|
|
130
|
+
const trimmed = expr.trim()
|
|
131
|
+
const body = trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0
|
|
132
|
+
? trimmed.slice(1, trimmed.lastIndexOf('/'))
|
|
133
|
+
: trimmed
|
|
134
|
+
let re: RegExp
|
|
135
|
+
try { re = new RegExp(body, 'u') } catch { return undefined }
|
|
136
|
+
const match = re.exec(receiptText)
|
|
137
|
+
if (!match) return undefined
|
|
138
|
+
return match.groups?.value ?? match[1] ?? match[0]
|
|
139
|
+
}
|
|
140
|
+
if (source?.from === 'jsonpath') {
|
|
141
|
+
const json = asJson(receiptText)
|
|
142
|
+
if (json === undefined) return undefined
|
|
143
|
+
return jsonPathGet(json, expr)
|
|
144
|
+
}
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 解析整条边的参数;任一参数解析失败 ⇒ null(该边不可用于本次回执)。 */
|
|
149
|
+
export function resolveEdgeArgs(edge: EdgEdge, receiptText: string): Record<string, unknown> | null {
|
|
150
|
+
const args: Record<string, unknown> = {}
|
|
151
|
+
for (const source of edge.argSource) {
|
|
152
|
+
const value = resolveArg(source, receiptText)
|
|
153
|
+
if (value === undefined) return null
|
|
154
|
+
args[source.arg] = value
|
|
155
|
+
}
|
|
156
|
+
return args
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ------------------------------------------------------------------ *
|
|
160
|
+
* 守卫
|
|
161
|
+
* ------------------------------------------------------------------ */
|
|
162
|
+
|
|
163
|
+
/** 守卫求值:命中返回 true。表达式非法 ⇒ false(宁可不命中,也不误替换)。 */
|
|
164
|
+
export function evaluateGuard(guard: EdgGuard, receiptText: string): boolean {
|
|
165
|
+
if (!guard || typeof guard !== 'object') return false
|
|
166
|
+
let result = false
|
|
167
|
+
if (guard.kind === 'regex') {
|
|
168
|
+
const trimmed = String(guard.expr ?? '').trim()
|
|
169
|
+
const body = trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0
|
|
170
|
+
? trimmed.slice(1, trimmed.lastIndexOf('/'))
|
|
171
|
+
: trimmed
|
|
172
|
+
try { result = new RegExp(body, 'u').test(receiptText) } catch { return false }
|
|
173
|
+
} else if (guard.kind === 'equals') {
|
|
174
|
+
result = receiptText.trim() === String(guard.expr ?? '').trim()
|
|
175
|
+
} else if (guard.kind === 'jsonpath') {
|
|
176
|
+
const json = asJson(receiptText)
|
|
177
|
+
if (json === undefined) return false
|
|
178
|
+
const value = jsonPathGet(json, String(guard.expr ?? ''))
|
|
179
|
+
result = value !== undefined && value !== null && value !== false && value !== '' && value !== 0
|
|
180
|
+
} else {
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
return guard.negate === true ? !result : result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------------ *
|
|
187
|
+
* 图 store 与生命周期
|
|
188
|
+
* ------------------------------------------------------------------ */
|
|
189
|
+
|
|
190
|
+
const LEGAL_TRANSITIONS: Record<EdgState, EdgState[]> = {
|
|
191
|
+
draft: ['validated', 'archived'],
|
|
192
|
+
validated: ['active', 'archived'],
|
|
193
|
+
active: ['exhausted', 'archived'],
|
|
194
|
+
exhausted: ['archived', 'active'],
|
|
195
|
+
archived: ['active'],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ValidateOptions {
|
|
199
|
+
/** 当前工具 schema 哈希表:工具名 → schemaHash。 */
|
|
200
|
+
schemas: Record<string, string>
|
|
201
|
+
/** 允许入池的工具(默认全部)。 */
|
|
202
|
+
allowedTools?: string[]
|
|
203
|
+
/** 允许破坏性动作入池(默认 false)。 */
|
|
204
|
+
allowDestructive?: boolean
|
|
205
|
+
maxEdges?: number
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface EdgStore {
|
|
209
|
+
addDraft(input: { taskId: string; edges: Array<Partial<EdgEdge> & { id?: string }>; source?: EdgSource; model?: string; authorText?: string }): EdgGraph
|
|
210
|
+
validate(graphId: string, options: ValidateOptions): { ok: boolean; state: EdgState; reason?: string }
|
|
211
|
+
activate(graphId: string): { ok: boolean; state: EdgState; reason?: string }
|
|
212
|
+
exhaust(graphId: string, reason?: string): { ok: boolean; state: EdgState }
|
|
213
|
+
archive(graphId: string): { ok: boolean; state: EdgState }
|
|
214
|
+
get(graphId: string): EdgGraph | null
|
|
215
|
+
list(state?: EdgState): EdgGraph[]
|
|
216
|
+
/** schema 变化的整图作废:返回被作废的图 id 列表。 */
|
|
217
|
+
invalidateBySchema(schemas: Record<string, string>): string[]
|
|
218
|
+
/** 作者成本合计(记 governance 用)。 */
|
|
219
|
+
authorCost(): { graphs: number; authorTokens: number }
|
|
220
|
+
reset(): void
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createEdgStore(options: { now?: () => number; idFactory?: () => string } = {}): EdgStore {
|
|
224
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
225
|
+
let seq = 0
|
|
226
|
+
const idFactory = typeof options.idFactory === 'function' ? options.idFactory : () => `edg_${now().toString(36)}_${(seq += 1).toString(36)}`
|
|
227
|
+
const graphs = new Map<string, EdgGraph>()
|
|
228
|
+
|
|
229
|
+
const clone = (graph: EdgGraph): EdgGraph => Object.freeze({ ...graph, edges: graph.edges.map((edge) => Object.freeze({ ...edge, argSource: edge.argSource.map((item) => ({ ...item })) })) }) as EdgGraph
|
|
230
|
+
|
|
231
|
+
function transition(graph: EdgGraph, to: EdgState, reason?: string): { ok: boolean; state: EdgState; reason?: string } {
|
|
232
|
+
if (!LEGAL_TRANSITIONS[graph.state].includes(to)) {
|
|
233
|
+
return { ok: false, state: graph.state, reason: `illegal-transition:${graph.state}->${to}` }
|
|
234
|
+
}
|
|
235
|
+
graph.state = to
|
|
236
|
+
if (reason !== undefined) graph.reason = reason
|
|
237
|
+
return { ok: true, state: graph.state }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function addDraft(input: { taskId: string; edges: Array<Partial<EdgEdge> & { id?: string }>; source?: EdgSource; model?: string; authorText?: string }): EdgGraph {
|
|
241
|
+
const edges = (Array.isArray(input?.edges) ? input.edges : []).map((edge, index) => Object.freeze({
|
|
242
|
+
id: String(edge?.id ?? `edge_${index + 1}`),
|
|
243
|
+
toolName: String(edge?.toolName ?? ''),
|
|
244
|
+
schemaHash: String(edge?.schemaHash ?? ''),
|
|
245
|
+
argSource: Array.isArray(edge?.argSource) ? edge.argSource.map((item) => Object.freeze({ arg: String(item?.arg ?? ''), from: String(item?.from ?? 'literal') as ArgSourceKind, expr: String(item?.expr ?? '') })) : [],
|
|
246
|
+
guard: Object.freeze({
|
|
247
|
+
kind: String(edge?.guard?.kind ?? 'regex') as GuardKind,
|
|
248
|
+
expr: String(edge?.guard?.expr ?? ''),
|
|
249
|
+
negate: edge?.guard?.negate === true,
|
|
250
|
+
}),
|
|
251
|
+
destructive: edge?.destructive === true,
|
|
252
|
+
dependsOn: Array.isArray(edge?.dependsOn) ? edge.dependsOn.map((item) => String(item)) : undefined,
|
|
253
|
+
}))
|
|
254
|
+
const graph: EdgGraph = {
|
|
255
|
+
id: idFactory(),
|
|
256
|
+
taskId: String(input?.taskId ?? ''),
|
|
257
|
+
createdAt: now(),
|
|
258
|
+
source: input?.source === 'template' ? 'template' : 'author',
|
|
259
|
+
state: 'draft',
|
|
260
|
+
model: input?.model === undefined ? undefined : String(input.model),
|
|
261
|
+
frozen: false,
|
|
262
|
+
edges,
|
|
263
|
+
authorTokens: tokensOf(String(input?.authorText ?? '')),
|
|
264
|
+
}
|
|
265
|
+
graphs.set(graph.id, graph)
|
|
266
|
+
return clone(graph)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validate(graphId: string, opts: ValidateOptions): { ok: boolean; state: EdgState; reason?: string } {
|
|
270
|
+
const graph = graphs.get(String(graphId))
|
|
271
|
+
if (!graph) return { ok: false, state: 'draft', reason: 'graph-not-found' }
|
|
272
|
+
const allowed = Array.isArray(opts?.allowedTools) && opts.allowedTools.length > 0 ? new Set(opts.allowedTools) : null
|
|
273
|
+
const maxEdges = Math.max(1, Number(opts?.maxEdges ?? 32) || 32)
|
|
274
|
+
const ids = new Set(graph.edges.map((edge) => edge.id))
|
|
275
|
+
for (const edge of graph.edges) {
|
|
276
|
+
if (!edge.toolName) return { ok: false, state: graph.state, reason: 'edge-missing-tool' }
|
|
277
|
+
if (allowed && !allowed.has(edge.toolName)) return { ok: false, state: graph.state, reason: `tool-not-allowed:${edge.toolName}` }
|
|
278
|
+
// schema 校验:拿不到当前哈希 ⇒ 视为不一致(fail-closed)
|
|
279
|
+
const current = opts?.schemas?.[edge.toolName]
|
|
280
|
+
if (!current || current !== edge.schemaHash) return { ok: false, state: graph.state, reason: `schema-mismatch:${edge.toolName}` }
|
|
281
|
+
for (const source of edge.argSource) {
|
|
282
|
+
if (!source.arg) return { ok: false, state: graph.state, reason: 'arg-source-missing-arg' }
|
|
283
|
+
if (!['literal', 'regex', 'jsonpath'].includes(source.from)) return { ok: false, state: graph.state, reason: `arg-source-invalid:${source.from}` }
|
|
284
|
+
}
|
|
285
|
+
if (!['regex', 'jsonpath', 'equals'].includes(edge.guard.kind)) return { ok: false, state: graph.state, reason: `guard-kind-invalid:${edge.guard.kind}` }
|
|
286
|
+
// 破坏性动作默认不入池
|
|
287
|
+
if (edge.destructive && opts?.allowDestructive !== true) return { ok: false, state: graph.state, reason: `destructive-not-allowed:${edge.toolName}` }
|
|
288
|
+
for (const dep of edge.dependsOn ?? []) {
|
|
289
|
+
if (!ids.has(dep)) return { ok: false, state: graph.state, reason: `dependency-missing:${dep}` }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (graph.edges.length === 0) return { ok: false, state: graph.state, reason: 'graph-empty' }
|
|
293
|
+
if (graph.edges.length > maxEdges) return { ok: false, state: graph.state, reason: 'graph-too-large' }
|
|
294
|
+
return transition(graph, 'validated')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function activate(graphId: string) {
|
|
298
|
+
const graph = graphs.get(String(graphId))
|
|
299
|
+
if (!graph) return { ok: false, state: 'draft' as EdgState, reason: 'graph-not-found' }
|
|
300
|
+
return transition(graph, 'active')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function exhaust(graphId: string, reason?: string) {
|
|
304
|
+
const graph = graphs.get(String(graphId))
|
|
305
|
+
if (!graph) return { ok: false, state: 'draft' as EdgState }
|
|
306
|
+
return transition(graph, 'exhausted', reason)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function archive(graphId: string) {
|
|
310
|
+
const graph = graphs.get(String(graphId))
|
|
311
|
+
if (!graph) return { ok: false, state: 'draft' as EdgState }
|
|
312
|
+
return transition(graph, 'archived')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** schema 变化 ⇒ 整图作废(有边的 schemaHash 与当前不符即整图归档)。 */
|
|
316
|
+
function invalidateBySchema(schemas: Record<string, string>): string[] {
|
|
317
|
+
const invalidated: string[] = []
|
|
318
|
+
for (const graph of graphs.values()) {
|
|
319
|
+
if (graph.state === 'archived') continue
|
|
320
|
+
const bad = graph.edges.find((edge) => schemas?.[edge.toolName] !== edge.schemaHash)
|
|
321
|
+
if (!bad) continue
|
|
322
|
+
graph.state = 'archived'
|
|
323
|
+
graph.reason = `schema-drift:${bad.toolName}`
|
|
324
|
+
invalidated.push(graph.id)
|
|
325
|
+
}
|
|
326
|
+
return invalidated
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function get(graphId: string): EdgGraph | null {
|
|
330
|
+
const graph = graphs.get(String(graphId))
|
|
331
|
+
return graph ? clone(graph) : null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function list(state?: EdgState): EdgGraph[] {
|
|
335
|
+
return [...graphs.values()].filter((graph) => (state === undefined ? true : graph.state === state)).map((graph) => clone(graph))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function authorCost(): { graphs: number; authorTokens: number } {
|
|
339
|
+
let authorTokens = 0
|
|
340
|
+
let count = 0
|
|
341
|
+
for (const graph of graphs.values()) {
|
|
342
|
+
if (graph.source !== 'author') continue
|
|
343
|
+
count += 1
|
|
344
|
+
authorTokens += graph.authorTokens
|
|
345
|
+
}
|
|
346
|
+
return { graphs: count, authorTokens }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function reset(): void {
|
|
350
|
+
graphs.clear()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return Object.freeze({ addDraft, validate, activate, exhaust, archive, get, list, invalidateBySchema, authorCost, reset })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ------------------------------------------------------------------ *
|
|
357
|
+
* 作者步骤:从慢模型输出里抽候选图(确定性解析,不猜)
|
|
358
|
+
* ------------------------------------------------------------------ */
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 从模型输出中抽取 `{ "edges": [...] }` JSON 块。
|
|
362
|
+
* 只接受 `edges` 数组里的合法字段;解析不出来返回 null(**不生成空图、不猜参数**)。
|
|
363
|
+
*/
|
|
364
|
+
export function parseDraftFromModel(text: string): { edges: Array<Partial<EdgEdge> & { id?: string }>; authorText: string } | null {
|
|
365
|
+
const raw = String(text ?? '')
|
|
366
|
+
if (raw.trim() === '') return null
|
|
367
|
+
const blocks = [...raw.matchAll(/```(?:json)?\s*([\s\S]*?)```/gu)].map((match) => match[1])
|
|
368
|
+
const candidates = blocks.length > 0 ? blocks : [raw]
|
|
369
|
+
for (const candidate of candidates) {
|
|
370
|
+
const start = candidate.indexOf('{')
|
|
371
|
+
const end = candidate.lastIndexOf('}')
|
|
372
|
+
if (start < 0 || end <= start) continue
|
|
373
|
+
let parsed: unknown
|
|
374
|
+
try { parsed = JSON.parse(candidate.slice(start, end + 1)) } catch { continue }
|
|
375
|
+
if (!parsed || typeof parsed !== 'object') continue
|
|
376
|
+
const edges = (parsed as { edges?: unknown }).edges
|
|
377
|
+
if (!Array.isArray(edges) || edges.length === 0) continue
|
|
378
|
+
const cleaned = edges
|
|
379
|
+
.filter((edge): edge is Record<string, unknown> => Boolean(edge) && typeof edge === 'object')
|
|
380
|
+
.map((edge) => ({
|
|
381
|
+
toolName: String(edge.toolName ?? ''),
|
|
382
|
+
schemaHash: String(edge.schemaHash ?? ''),
|
|
383
|
+
argSource: Array.isArray(edge.argSource) ? edge.argSource as EdgArgSource[] : [],
|
|
384
|
+
guard: (edge.guard && typeof edge.guard === 'object' ? edge.guard : { kind: 'regex', expr: '' }) as EdgGuard,
|
|
385
|
+
destructive: edge.destructive === true,
|
|
386
|
+
dependsOn: Array.isArray(edge.dependsOn) ? edge.dependsOn.map((item) => String(item)) : undefined,
|
|
387
|
+
}))
|
|
388
|
+
.filter((edge) => edge.toolName !== '')
|
|
389
|
+
if (cleaned.length === 0) continue
|
|
390
|
+
return { edges: cleaned, authorText: raw }
|
|
391
|
+
}
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* ------------------------------------------------------------------ *
|
|
396
|
+
* 预置模板库(可选来源)
|
|
397
|
+
* ------------------------------------------------------------------ */
|
|
398
|
+
|
|
399
|
+
export interface EdgTemplate {
|
|
400
|
+
id: string
|
|
401
|
+
description: string
|
|
402
|
+
edges: Array<Partial<EdgEdge> & { id?: string }>
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface TemplateLibrary {
|
|
406
|
+
list(): EdgTemplate[]
|
|
407
|
+
find(id: string): EdgTemplate | null
|
|
408
|
+
/** 按工具名挑模板(供作者步骤参考)。 */
|
|
409
|
+
forTool(toolName: string): EdgTemplate[]
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function createTemplateLibrary(templates: EdgTemplate[] = []): TemplateLibrary {
|
|
413
|
+
const items = (Array.isArray(templates) ? templates : []).filter((item) => item && typeof item === 'object' && item.id)
|
|
414
|
+
return Object.freeze({
|
|
415
|
+
list: () => items.map((item) => ({ ...item, edges: item.edges.map((edge) => ({ ...edge })) })),
|
|
416
|
+
find: (id: string) => {
|
|
417
|
+
const found = items.find((item) => item.id === String(id))
|
|
418
|
+
return found ? { ...found, edges: found.edges.map((edge) => ({ ...edge })) } : null
|
|
419
|
+
},
|
|
420
|
+
forTool: (toolName: string) => items
|
|
421
|
+
.filter((item) => item.edges.some((edge) => String(edge.toolName ?? '') === String(toolName)))
|
|
422
|
+
.map((item) => ({ ...item, edges: item.edges.map((edge) => ({ ...edge })) })),
|
|
423
|
+
})
|
|
424
|
+
}
|
package/worker/gdr.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 3.3.0 · B2 `gdr`(守卫决策复用,设计 §7)。
|
|
6
|
+
*
|
|
7
|
+
* 「守卫」二字的全部含义就是**匹配顺序**,任一环不满足即 miss、回落慢模型:
|
|
8
|
+
*
|
|
9
|
+
* ① 工具名 → ② schemaHash → ③ 依赖边完成 → ④ 守卫表达式 → ⑤ 参数可确定性提取
|
|
10
|
+
*
|
|
11
|
+
* 只有五步全过才算 `hit`。miss 的原因必须**可归因**(`schema-drift` / `guard-miss` /
|
|
12
|
+
* `arg-unextractable` / `dependency-incomplete` / `tool-mismatch`),
|
|
13
|
+
* 命中率与未命中分布进报表,用于 RSI 改进(B4)。
|
|
14
|
+
*
|
|
15
|
+
* 安全取向:任何一步异常/歧义都判 miss —— 宁可回落慢模型,也不误替换。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Edg, EdgEdge, GuardVerdict } from './cost-spi.ts'
|
|
19
|
+
import { evaluateGuard, resolveEdgeArgs } from './edg.ts'
|
|
20
|
+
|
|
21
|
+
export type GdrStage = 'toolName' | 'schemaHash' | 'dependencies' | 'guard' | 'args'
|
|
22
|
+
|
|
23
|
+
export type GdrMissReason =
|
|
24
|
+
| 'no-edge'
|
|
25
|
+
| 'tool-mismatch'
|
|
26
|
+
| 'schema-drift'
|
|
27
|
+
| 'dependency-incomplete'
|
|
28
|
+
| 'guard-miss'
|
|
29
|
+
| 'arg-unextractable'
|
|
30
|
+
|
|
31
|
+
export interface GdrMatch extends GuardVerdict {
|
|
32
|
+
hit: boolean
|
|
33
|
+
edgeId?: string
|
|
34
|
+
reason?: GdrMissReason
|
|
35
|
+
/** 在第几环失败(命中时为 null)。 */
|
|
36
|
+
stage: GdrStage | null
|
|
37
|
+
receiptDigest: string
|
|
38
|
+
/** 命中时:确定性提取出的参数(可直接交给原生派发器)。 */
|
|
39
|
+
args?: Record<string, unknown>
|
|
40
|
+
wouldSaveTokens?: number
|
|
41
|
+
at: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GdrMatchInput {
|
|
45
|
+
receipt: { toolName: string; text: string; digest: string }
|
|
46
|
+
graph: Edg
|
|
47
|
+
/** 本任务已完成的边 id(依赖检查用)。 */
|
|
48
|
+
completed?: Set<string> | string[]
|
|
49
|
+
/** 当前工具 schema 哈希(缺省用边自身的 schemaHash ⇒ 视为一致)。 */
|
|
50
|
+
schemaHashOf?: (toolName: string) => string | undefined
|
|
51
|
+
/** 命中后的收益估算(供 shadow 对照;不给则不计)。 */
|
|
52
|
+
estimateSavedTokens?: (edge: EdgEdge, args: Record<string, unknown>) => number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GdrStats {
|
|
56
|
+
matches: number
|
|
57
|
+
hits: number
|
|
58
|
+
hitRate: number
|
|
59
|
+
byReason: Record<GdrMissReason, number>
|
|
60
|
+
byTool: Record<string, { matches: number; hits: number }>
|
|
61
|
+
byStage: Record<GdrStage, number>
|
|
62
|
+
lastAt: number | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const EMPTY_REASONS = (): Record<GdrMissReason, number> => ({
|
|
66
|
+
'no-edge': 0,
|
|
67
|
+
'tool-mismatch': 0,
|
|
68
|
+
'schema-drift': 0,
|
|
69
|
+
'dependency-incomplete': 0,
|
|
70
|
+
'guard-miss': 0,
|
|
71
|
+
'arg-unextractable': 0,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const EMPTY_STAGES = (): Record<GdrStage, number> => ({
|
|
75
|
+
toolName: 0,
|
|
76
|
+
schemaHash: 0,
|
|
77
|
+
dependencies: 0,
|
|
78
|
+
guard: 0,
|
|
79
|
+
args: 0,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
export interface Gdr {
|
|
83
|
+
match(input: GdrMatchInput): GdrMatch
|
|
84
|
+
stats(): GdrStats
|
|
85
|
+
/** 报表用:命中率 + miss 分布 + 每个工具表现(RSI 的输入)。 */
|
|
86
|
+
report(): { stats: GdrStats; suggestions: string[] }
|
|
87
|
+
reset(): void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createGdr(options: { now?: () => number } = {}): Gdr {
|
|
91
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
92
|
+
let matches = 0
|
|
93
|
+
let hits = 0
|
|
94
|
+
let lastAt: number | null = null
|
|
95
|
+
const byReason = EMPTY_REASONS()
|
|
96
|
+
const byStage = EMPTY_STAGES()
|
|
97
|
+
const byTool = new Map<string, { matches: number; hits: number }>()
|
|
98
|
+
|
|
99
|
+
function toolRow(toolName: string) {
|
|
100
|
+
if (!byTool.has(toolName)) byTool.set(toolName, { matches: 0, hits: 0 })
|
|
101
|
+
return byTool.get(toolName)!
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function miss(reason: GdrMissReason, stage: GdrStage, receiptDigest: string, toolName: string): GdrMatch {
|
|
105
|
+
matches += 1
|
|
106
|
+
byReason[reason] += 1
|
|
107
|
+
byStage[stage] += 1
|
|
108
|
+
const row = toolRow(toolName)
|
|
109
|
+
row.matches += 1
|
|
110
|
+
lastAt = now()
|
|
111
|
+
return { hit: false, reason, stage, receiptDigest, at: lastAt }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 五步匹配。**顺序不可调换**:先便宜的后昂贵的,且 schema 漂移必须早于守卫判定——
|
|
116
|
+
* 否则旧图可能在新 schema 上"命中",那是最危险的误替换。
|
|
117
|
+
*/
|
|
118
|
+
function match(input: GdrMatchInput): GdrMatch {
|
|
119
|
+
const receipt = input?.receipt ?? { toolName: '', text: '', digest: '' }
|
|
120
|
+
const graph = input?.graph
|
|
121
|
+
const toolName = String(receipt.toolName ?? '')
|
|
122
|
+
const digest = String(receipt.digest ?? '')
|
|
123
|
+
const edges = Array.isArray(graph?.edges) ? graph.edges : []
|
|
124
|
+
|
|
125
|
+
// ① 工具名
|
|
126
|
+
if (edges.length === 0) return miss('no-edge', 'toolName', digest, toolName)
|
|
127
|
+
const candidates = edges.filter((edge) => String(edge?.toolName ?? '') === toolName)
|
|
128
|
+
if (candidates.length === 0) return miss('tool-mismatch', 'toolName', digest, toolName)
|
|
129
|
+
|
|
130
|
+
const completed = input?.completed instanceof Set
|
|
131
|
+
? input.completed
|
|
132
|
+
: new Set(Array.isArray(input?.completed) ? input.completed : [])
|
|
133
|
+
|
|
134
|
+
// 逐条候选走完 ②③④⑤;任一条全过即命中。
|
|
135
|
+
let firstMiss: { reason: GdrMissReason; stage: GdrStage } | null = null
|
|
136
|
+
for (const edge of candidates) {
|
|
137
|
+
// ② schemaHash:漂移即整图作废(不匹配就直接 miss,绝不继续)
|
|
138
|
+
const current = typeof input?.schemaHashOf === 'function' ? input.schemaHashOf(edge.toolName) : undefined
|
|
139
|
+
if (current !== undefined && current !== edge.schemaHash) {
|
|
140
|
+
if (!firstMiss) firstMiss = { reason: 'schema-drift', stage: 'schemaHash' }
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
// ③ 依赖边完成
|
|
144
|
+
const deps = Array.isArray(edge.dependsOn) ? edge.dependsOn : []
|
|
145
|
+
if (deps.some((dep) => !completed.has(String(dep)))) {
|
|
146
|
+
if (!firstMiss) firstMiss = { reason: 'dependency-incomplete', stage: 'dependencies' }
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
// ④ 守卫表达式
|
|
150
|
+
if (!evaluateGuard(edge.guard, String(receipt.text ?? ''))) {
|
|
151
|
+
if (!firstMiss) firstMiss = { reason: 'guard-miss', stage: 'guard' }
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
// ⑤ 参数可确定性提取
|
|
155
|
+
const args = resolveEdgeArgs(edge, String(receipt.text ?? ''))
|
|
156
|
+
if (args === null) {
|
|
157
|
+
if (!firstMiss) firstMiss = { reason: 'arg-unextractable', stage: 'args' }
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
// 五步全过 ⇒ 命中
|
|
161
|
+
matches += 1
|
|
162
|
+
hits += 1
|
|
163
|
+
const row = toolRow(toolName)
|
|
164
|
+
row.matches += 1
|
|
165
|
+
row.hits += 1
|
|
166
|
+
lastAt = now()
|
|
167
|
+
const wouldSaveTokens = typeof input?.estimateSavedTokens === 'function'
|
|
168
|
+
? Math.max(0, Number(input.estimateSavedTokens(edge, args)) || 0)
|
|
169
|
+
: 0
|
|
170
|
+
return {
|
|
171
|
+
hit: true,
|
|
172
|
+
edgeId: edge.id,
|
|
173
|
+
stage: null,
|
|
174
|
+
receiptDigest: digest,
|
|
175
|
+
args,
|
|
176
|
+
wouldSaveTokens,
|
|
177
|
+
at: lastAt,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const reason = firstMiss ?? { reason: 'guard-miss' as GdrMissReason, stage: 'guard' as GdrStage }
|
|
182
|
+
return miss(reason.reason, reason.stage, digest, toolName)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function stats(): GdrStats {
|
|
186
|
+
return {
|
|
187
|
+
matches,
|
|
188
|
+
hits,
|
|
189
|
+
hitRate: matches > 0 ? hits / matches : 0,
|
|
190
|
+
byReason: { ...byReason },
|
|
191
|
+
byTool: Object.fromEntries([...byTool.entries()].map(([tool, row]) => [tool, { ...row }])),
|
|
192
|
+
byStage: { ...byStage },
|
|
193
|
+
lastAt,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 报表 + 可执行的改进建议(喂给 RSI:哪一环最该优化)。 */
|
|
198
|
+
function report(): { stats: GdrStats; suggestions: string[] } {
|
|
199
|
+
const current = stats()
|
|
200
|
+
const suggestions: string[] = []
|
|
201
|
+
if (current.matches === 0) suggestions.push('样本为空:先跑 shadow 累积回执再评估')
|
|
202
|
+
if (current.byReason['schema-drift'] > 0) {
|
|
203
|
+
suggestions.push(`schema-drift ${current.byReason['schema-drift']} 次:优先复算 schemaHash 并回收旧图`)
|
|
204
|
+
}
|
|
205
|
+
if (current.byReason['guard-miss'] > 0) {
|
|
206
|
+
suggestions.push(`guard-miss ${current.byReason['guard-miss']} 次:守卫表达式过严或回执形态变化`)
|
|
207
|
+
}
|
|
208
|
+
if (current.byReason['arg-unextractable'] > 0) {
|
|
209
|
+
suggestions.push(`arg-unextractable ${current.byReason['arg-unextractable']} 次:参数来源应改为 jsonpath/regex 可命中的字段`)
|
|
210
|
+
}
|
|
211
|
+
if (current.byReason['dependency-incomplete'] > 0) {
|
|
212
|
+
suggestions.push(`dependency-incomplete ${current.byReason['dependency-incomplete']} 次:依赖链过长,考虑拆分图`)
|
|
213
|
+
}
|
|
214
|
+
if (current.byReason['tool-mismatch'] > 0) {
|
|
215
|
+
suggestions.push(`tool-mismatch ${current.byReason['tool-mismatch']} 次:图里缺少该工具的边`)
|
|
216
|
+
}
|
|
217
|
+
if (current.matches >= 20 && current.hitRate < 0.5) {
|
|
218
|
+
suggestions.push(`命中率 ${(current.hitRate * 100).toFixed(1)}% 偏低:建议先扩充图或放宽守卫`)
|
|
219
|
+
}
|
|
220
|
+
return { stats: current, suggestions }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function reset(): void {
|
|
224
|
+
matches = 0
|
|
225
|
+
hits = 0
|
|
226
|
+
lastAt = null
|
|
227
|
+
Object.assign(byReason, EMPTY_REASONS())
|
|
228
|
+
Object.assign(byStage, EMPTY_STAGES())
|
|
229
|
+
byTool.clear()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return Object.freeze({ match, stats, report, reset })
|
|
233
|
+
}
|