@twinsearth/dsh-master 3.2.10 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +4 -0
  2. package/client.js +50 -0
  3. package/dist-host/contract/client.js +7 -0
  4. package/dist-host/contract/constants.js +16 -2
  5. package/dist-host/contract/index.js +1 -0
  6. package/dist-host/contract/relay.js +239 -0
  7. package/dist-host/cost/attribution.js +183 -0
  8. package/dist-host/cost/audit.js +163 -0
  9. package/dist-host/cost/budget.js +211 -0
  10. package/dist-host/cost/bus.js +95 -0
  11. package/dist-host/cost/cert-chain.js +100 -0
  12. package/dist-host/cost/experiment.js +132 -0
  13. package/dist-host/cost/fang-relay.js +329 -0
  14. package/dist-host/cost/gate.js +189 -0
  15. package/dist-host/cost/host-surface.js +80 -0
  16. package/dist-host/cost/index.js +345 -0
  17. package/dist-host/cost/ledger-report.js +193 -0
  18. package/dist-host/cost/ledger.js +401 -0
  19. package/dist-host/cost/metrics.js +198 -0
  20. package/dist-host/cost/milestones.js +209 -0
  21. package/dist-host/cost/modules/index.js +5 -0
  22. package/dist-host/cost/modules/manifests.js +130 -0
  23. package/dist-host/cost/policy.js +276 -0
  24. package/dist-host/cost/report.js +156 -0
  25. package/dist-host/cost/reuse-report.js +128 -0
  26. package/dist-host/cost/rsi.js +367 -0
  27. package/dist-host/cost/toolface.js +147 -0
  28. package/dist-host/cost/types.js +31 -0
  29. package/dist-host/kernel.js +28 -2
  30. package/dist-host/plugins/cost-optimizer.js +329 -27
  31. package/dist-host/plugins/superagent-router.js +104 -1
  32. package/dist-host/superagent/types.js +8 -0
  33. package/dist-worker/agent-loop.js +449 -7
  34. package/dist-worker/baseline-fit.js +66 -0
  35. package/dist-worker/budget-guard.js +93 -0
  36. package/dist-worker/ccs.js +99 -0
  37. package/dist-worker/cost-hooks.js +115 -0
  38. package/dist-worker/cost-modules.js +330 -0
  39. package/dist-worker/cost-spi.js +3 -0
  40. package/dist-worker/edg.js +372 -0
  41. package/dist-worker/gdr.js +159 -0
  42. package/dist-worker/index.js +80 -2
  43. package/dist-worker/llm-http.js +20 -0
  44. package/dist-worker/sink-http.js +1 -0
  45. package/dist-worker/spill.js +115 -0
  46. package/dist-worker/tool-batch.js +66 -0
  47. package/dist-worker/tool-compact.js +275 -0
  48. package/dist-worker/tool-disclosure.js +90 -0
  49. package/dist-worker/tools.js +16 -0
  50. package/docs/ARCHITECTURE.md +24 -0
  51. package/docs/CHANGELOG.md +66 -0
  52. package/docs/DESIGN-v3.3.0-cost-optimization.md +1058 -0
  53. package/docs/EXPERIMENT-M5-fang-relay.md +117 -0
  54. package/docs/EXPERIMENT-M5-real-ab.md +87 -0
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/package.json +2 -2
  57. package/src/contract/client.js +7 -0
  58. package/src/contract/constants.js +17 -2
  59. package/src/contract/index.js +1 -0
  60. package/src/contract/relay.js +223 -0
  61. package/src/cost/attribution.ts +186 -0
  62. package/src/cost/audit.ts +170 -0
  63. package/src/cost/budget.ts +226 -0
  64. package/src/cost/bus.ts +97 -0
  65. package/src/cost/cert-chain.ts +102 -0
  66. package/src/cost/experiment.ts +136 -0
  67. package/src/cost/fang-relay.ts +318 -0
  68. package/src/cost/gate.ts +191 -0
  69. package/src/cost/host-surface.ts +97 -0
  70. package/src/cost/index.ts +348 -0
  71. package/src/cost/ledger-report.ts +186 -0
  72. package/src/cost/ledger.ts +385 -0
  73. package/src/cost/metrics.ts +193 -0
  74. package/src/cost/milestones.ts +206 -0
  75. package/src/cost/modules/index.ts +13 -0
  76. package/src/cost/modules/manifests.ts +129 -0
  77. package/src/cost/policy.ts +276 -0
  78. package/src/cost/report.ts +158 -0
  79. package/src/cost/reuse-report.ts +131 -0
  80. package/src/cost/rsi.ts +364 -0
  81. package/src/cost/toolface.ts +151 -0
  82. package/src/cost/types.ts +298 -0
  83. package/src/kernel.ts +27 -2
  84. package/src/plugins/cost-optimizer.ts +313 -26
  85. package/src/plugins/superagent-router.ts +96 -1
  86. package/src/superagent/types.ts +70 -0
  87. package/worker/agent-loop.ts +444 -7
  88. package/worker/baseline-fit.ts +80 -0
  89. package/worker/budget-guard.ts +156 -0
  90. package/worker/ccs.ts +202 -0
  91. package/worker/cost-hooks.ts +133 -0
  92. package/worker/cost-modules.ts +356 -0
  93. package/worker/cost-spi.ts +283 -0
  94. package/worker/edg.ts +424 -0
  95. package/worker/gdr.ts +233 -0
  96. package/worker/index.ts +84 -3
  97. package/worker/llm-http.ts +18 -0
  98. package/worker/sink-http.ts +5 -2
  99. package/worker/spill.ts +133 -0
  100. package/worker/tool-batch.ts +124 -0
  101. package/worker/tool-compact.ts +317 -0
  102. package/worker/tool-disclosure.ts +131 -0
  103. package/worker/tools.ts +553 -531
  104. package/worker/types.ts +191 -0
@@ -0,0 +1,156 @@
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 · Worker 侧预算闸(设计 §5.2 的 ① 检查点)。
6
+ *
7
+ * 规格:
8
+ * * 检查点**只在 ① beforeModelCall**:调用模型之前先问「这一步还付得起吗」;
9
+ * * 付不起 ⇒ **fail-closed**,直接 `budget-exhausted`,绝不「先跑着看」;
10
+ * * `degrade` 档只允许把本次调用降级到**白名单内**的廉价模型,未命中白名单一律 hard-stop;
11
+ * * 拒绝时**不扣额度**(避免把失败的尝试也记成消耗)。
12
+ *
13
+ * 默认关闭(`enabled: false`):3.2.x 的 `maxTokens` 事后硬上限仍然生效,
14
+ * 本闸是 3.3.0 的**事前**闸,启用后行为从「超支后终止」变成「超支前拒绝」。
15
+ * 启用方式:`WorkerDeps.budget` 或环境变量 `DSH_MASTER_BUDGET_TOKENS`。
16
+ */
17
+
18
+ export type BudgetAction = 'hard-stop' | 'degrade'
19
+
20
+ export interface WorkerBudgetOptions {
21
+ enabled?: boolean
22
+ /** task 级 token 上限。 */
23
+ limitTokens?: number
24
+ /** 每次调用预留的 token(默认按 history 字符数 / 4 动态估算)。 */
25
+ reserveTokens?: number
26
+ onExhaust?: BudgetAction
27
+ /** degrade 允许的廉价模型白名单(空 ⇒ degrade 无效,一律 hard-stop)。 */
28
+ degradeModels?: string[]
29
+ /** degrade 目标模型;必须在白名单内。 */
30
+ degradeTo?: string
31
+ }
32
+
33
+ export interface BudgetDecision {
34
+ ok: boolean
35
+ code?: string
36
+ message?: string
37
+ degraded?: boolean
38
+ /** degrade 时本次调用应使用的模型。 */
39
+ model?: string
40
+ consumedTokens: number
41
+ limitTokens: number | null
42
+ remainingTokens: number | null
43
+ requested: number
44
+ onExhaust: BudgetAction
45
+ at: number
46
+ }
47
+
48
+ export interface WorkerBudgetGuard {
49
+ readonly enabled: boolean
50
+ readonly consumedTokens: number
51
+ /** ①:调用模型前判定;返回拒绝或降级。 */
52
+ beforeModelCall(input: { model?: string; historyChars: number; stepIndex?: number }): BudgetDecision
53
+ /** 调用失败时退回预留额度(保持「失败不算消耗」)。 */
54
+ refund(tokens: number): void
55
+ /** 调用结束后按真实 usage 结算(多退少补)。 */
56
+ settle(tokens: number): void
57
+ describe(): { enabled: boolean; limitTokens: number | null; consumedTokens: number; onExhaust: BudgetAction; degradeTo: string | null; degradeModels: string[] }
58
+ }
59
+
60
+ const nonNegativeInt = (value: unknown): number => {
61
+ const n = Number(value)
62
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
63
+ }
64
+
65
+ export function createBudgetGuard(options: WorkerBudgetOptions = {}): WorkerBudgetGuard {
66
+ const enabled = options.enabled === true
67
+ const limitTokens = options.limitTokens === undefined ? null : nonNegativeInt(options.limitTokens) || null
68
+ const reserveTokens = options.reserveTokens === undefined ? null : nonNegativeInt(options.reserveTokens)
69
+ const onExhaust: BudgetAction = options.onExhaust === 'degrade' ? 'degrade' : 'hard-stop'
70
+ const degradeModels = (Array.isArray(options.degradeModels) ? options.degradeModels : [])
71
+ .map((item) => String(item ?? '').trim())
72
+ .filter(Boolean)
73
+ const degradeTo = options.degradeTo === undefined ? null : String(options.degradeTo)
74
+ const degradeAllowed = onExhaust === 'degrade' && degradeTo !== null && degradeModels.includes(degradeTo)
75
+ let consumedTokens = 0
76
+ /** 尚未释放的预留(settle/refund 用它避免重复记账)。 */
77
+ let reservedTokens = 0
78
+
79
+ function decide(requested: number, at = Date.now()): BudgetDecision {
80
+ const effective: BudgetAction = degradeAllowed ? 'degrade' : 'hard-stop'
81
+ const base = {
82
+ consumedTokens,
83
+ limitTokens,
84
+ remainingTokens: limitTokens === null ? null : Math.max(0, limitTokens - consumedTokens),
85
+ requested,
86
+ onExhaust: effective,
87
+ at,
88
+ }
89
+ if (!enabled || limitTokens === null) {
90
+ return { ok: true, ...base }
91
+ }
92
+ const remaining = limitTokens - consumedTokens
93
+ if (requested <= remaining) {
94
+ consumedTokens += requested
95
+ return { ok: true, ...base, consumedTokens, remainingTokens: limitTokens - consumedTokens }
96
+ }
97
+ if (degradeAllowed && degradeTo !== null) {
98
+ // 降级也要付得起:预留额度放进额度内才放行,否则仍然 fail-closed。
99
+ if (requested <= remaining) {
100
+ consumedTokens += requested
101
+ return { ok: true, degraded: true, model: degradeTo, ...base, consumedTokens, remainingTokens: limitTokens - consumedTokens }
102
+ }
103
+ return {
104
+ ok: false,
105
+ code: 'budget-exhausted',
106
+ message: `预算不足:剩余 ${Math.max(0, remaining)} token,需 ${requested},且降级模型 ${degradeTo} 也无法容纳`,
107
+ ...base,
108
+ }
109
+ }
110
+ return {
111
+ ok: false,
112
+ code: 'budget-exhausted',
113
+ message: `预算不足:剩余 ${Math.max(0, remaining)} token,需 ${requested} token(fail-closed,不启动模型调用)`,
114
+ ...base,
115
+ }
116
+ }
117
+
118
+ function beforeModelCall(input: { model?: string; historyChars: number; stepIndex?: number }): BudgetDecision {
119
+ // 预留值:显式配置优先;否则按「历史字符/4 + 固定输出余量」估算,宁高不低。
120
+ const estimate = reserveTokens !== null
121
+ ? reserveTokens
122
+ : Math.max(1, Math.ceil(Math.max(0, Number(input?.historyChars) || 0) / 4) + 256)
123
+ const decision = decide(estimate)
124
+ // 降级决策不消耗额外额度(预留已在 decide 内计入)。
125
+ if (decision.ok) reservedTokens += estimate
126
+ return decision
127
+ }
128
+
129
+ function refund(tokens: number): void {
130
+ const amount = nonNegativeInt(tokens)
131
+ consumedTokens = Math.max(0, consumedTokens - amount)
132
+ // 预留随之释放,否则后续 settle 会把同一份额度重复记账。
133
+ reservedTokens = Math.max(0, reservedTokens - amount)
134
+ }
135
+
136
+ function settle(tokens: number): void {
137
+ const actual = nonNegativeInt(tokens)
138
+ // 结算:先退掉**尚未释放**的预留,再按真实用量记账(实际可能为 0)。
139
+ // 之前这里写的是 `- 0`(死代码),会把预留与真实用量重复计入,导致额度被提前耗尽。
140
+ consumedTokens = Math.max(0, consumedTokens - reservedTokens) + actual
141
+ reservedTokens = 0
142
+ }
143
+
144
+ function describe() {
145
+ return {
146
+ enabled,
147
+ limitTokens,
148
+ consumedTokens,
149
+ onExhaust: degradeAllowed ? onExhaust : 'hard-stop' as BudgetAction,
150
+ degradeTo: degradeAllowed ? degradeTo : null,
151
+ degradeModels: [...degradeModels],
152
+ }
153
+ }
154
+
155
+ return Object.freeze({ enabled, get consumedTokens() { return consumedTokens }, beforeModelCall, refund, settle, describe })
156
+ }
package/worker/ccs.ts ADDED
@@ -0,0 +1,202 @@
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 · B3 `ccs`(认证回调替换,设计 §7)。
6
+ *
7
+ * 命中后的替换**必须走未改动的原生派发器**(`worker/tools.ts` 的同一个入口):
8
+ * 权限校验、Mandate、取消信号、超时、审计全部保留 —— 本模块只是一次「正常的工具调用」,
9
+ * 唯一的区别是**它替模型说出了这次调用**。
10
+ *
11
+ * 硬约束(逐条落实现):
12
+ * * **禁止直接调 fs/进程**:本模块不 import fs/child_process,唯一出口是 `registry.execute`;
13
+ * * **禁止绕过工具准入**:用与正常调用完全相同的 `ToolExecutionContext`(含 mandate/signal/timeout);
14
+ * * **禁止绕过 budget 检查**:派发前先过 `budgetGuard.beforeModelCall`,不通过一律不派发;
15
+ * * 派发器抛错 / 返回拒绝或报错 ⇒ 证书标 `dispatched: false` + `error`,**回落慢模型**,
16
+ * 绝不吞错继续(宁可不省,也不能假装成功)。
17
+ */
18
+
19
+ import type { EdgEdge } from './edg.ts'
20
+ import type { WorkerBudgetGuard } from './budget-guard.ts'
21
+
22
+ /** 原生派发器的唯一入口形态(与 `worker/tools.ts` 的 `execute` 一致)。 */
23
+ export interface NativeDispatcher {
24
+ execute(call: { name: string; arguments: string }, context: {
25
+ workspace: string
26
+ signal: AbortSignal
27
+ timeoutMs: number
28
+ mandate: { scope: string; allowShell: boolean; policyVersion?: string; strength?: 'explicit' | 'inferred' }
29
+ }): Promise<string>
30
+ }
31
+
32
+ export interface CcsCert {
33
+ edgeId: string
34
+ toolName: string
35
+ schemaHash: string
36
+ guardExpr: string
37
+ receiptDigest: string
38
+ /** 被跳过的慢模型回调 id(§5.5 证书字段)。 */
39
+ replacedCallbackId: string
40
+ viaNativeDispatcher: true
41
+ taskId: string
42
+ step: number
43
+ /** 是否真的经原生派发器执行(失败时为 false,并带 error)。 */
44
+ dispatched: boolean
45
+ error?: string
46
+ /** 派发器返回的内容摘要(可为空;不落全文,避免把回执再存一遍)。 */
47
+ resultDigest?: string
48
+ resultChars?: number
49
+ savedTokens?: number
50
+ moduleId?: string
51
+ at: number
52
+ }
53
+
54
+ export interface CcsInput {
55
+ edge: EdgEdge
56
+ /** 确定性提取出的参数(由 GDR 第 ⑤ 环产出)。 */
57
+ args: Record<string, unknown>
58
+ receipt: { toolName: string; text: string; digest: string }
59
+ /** 被替换掉的慢回调 id。 */
60
+ replacedCallbackId: string
61
+ taskId: string
62
+ step: number
63
+ workspace: string
64
+ mandate: { scope: string; allowShell: boolean; policyVersion?: string; strength?: 'explicit' | 'inferred' }
65
+ signal: AbortSignal
66
+ timeoutMs: number
67
+ moduleId?: string
68
+ /** 命中带来的预估节省(用于证书归因)。 */
69
+ savedTokens?: number
70
+ }
71
+
72
+ export interface CcsResult {
73
+ ok: boolean
74
+ certificate: CcsCert
75
+ /** 派发器返回的原始文本(成功时);失败时为 null。 */
76
+ text: string | null
77
+ /** 是否需要回落慢模型(失败或未派发)。 */
78
+ fallback: boolean
79
+ }
80
+
81
+ export interface CcsOptions {
82
+ dispatcher: NativeDispatcher
83
+ /** 预算闸:派发前必须通过(不通过就不派发)。 */
84
+ budget?: WorkerBudgetGuard | null
85
+ clock?: () => number
86
+ }
87
+
88
+ const tokensOf = (text: string): number => (text.length > 0 ? Math.ceil(text.length / 4) : 0)
89
+
90
+ /** 派发结果是否算「失败」:抛错、系统拒绝、执行报错都算。 */
91
+ function resultFailed(text: string): string | null {
92
+ const value = String(text ?? '')
93
+ if (value.startsWith('【系统拒绝】')) return 'tool-denied'
94
+ if (value.startsWith('执行报错')) return 'tool-error'
95
+ return null
96
+ }
97
+
98
+ export interface Ccs {
99
+ /**
100
+ * 经原生派发器执行一次替换。
101
+ * 任何不满足前提/任何异常 ⇒ `dispatched:false`,调用方必须回落慢模型。
102
+ */
103
+ substitute(input: CcsInput): Promise<CcsResult>
104
+ stats(): { attempted: number; dispatched: number; failed: number; fallbacks: number }
105
+ }
106
+
107
+ export function createCcs(options: CcsOptions): Ccs {
108
+ if (!options?.dispatcher || typeof options.dispatcher.execute !== 'function') {
109
+ throw new Error('ccs requires the native dispatcher (worker/tools.ts execute)')
110
+ }
111
+ const clock = typeof options.clock === 'function' ? options.clock : () => Date.now()
112
+ const budget = options.budget ?? null
113
+ let attempted = 0
114
+ let dispatched = 0
115
+ let failed = 0
116
+ let fallbacks = 0
117
+
118
+ function cert(input: CcsInput, fields: Partial<CcsCert>): CcsCert {
119
+ return {
120
+ edgeId: String(input.edge?.id ?? ''),
121
+ toolName: String(input.edge?.toolName ?? ''),
122
+ schemaHash: String(input.edge?.schemaHash ?? ''),
123
+ guardExpr: String(input.edge?.guard?.expr ?? ''),
124
+ receiptDigest: String(input.receipt?.digest ?? ''),
125
+ replacedCallbackId: String(input.replacedCallbackId ?? ''),
126
+ viaNativeDispatcher: true,
127
+ taskId: String(input.taskId ?? ''),
128
+ step: Number(input.step ?? 0),
129
+ dispatched: false,
130
+ moduleId: input.moduleId ?? 'b3-ccs',
131
+ savedTokens: Math.max(0, Number(input.savedTokens ?? 0) || 0),
132
+ at: clock(),
133
+ ...fields,
134
+ }
135
+ }
136
+
137
+ async function substitute(input: CcsInput): Promise<CcsResult> {
138
+ attempted += 1
139
+ // ⓪ 取消信号:替换路径不得绕过取消(I8)。
140
+ if (input.signal?.aborted === true) {
141
+ failed += 1
142
+ fallbacks += 1
143
+ return { ok: false, text: null, fallback: true, certificate: cert(input, { dispatched: false, error: 'aborted' }) }
144
+ }
145
+ // ① 预算闸:替模型做的这次调用同样要付得起(禁止绕过 budget 检查)。
146
+ if (budget) {
147
+ const decision = budget.beforeModelCall({ historyChars: 0 })
148
+ if (!decision.ok) {
149
+ failed += 1
150
+ fallbacks += 1
151
+ return { ok: false, text: null, fallback: true, certificate: cert(input, { dispatched: false, error: decision.code ?? 'budget-exhausted' }) }
152
+ }
153
+ // 替模型省下的这次调用不产生模型 token:把刚扣的预留退回去。
154
+ budget.refund(decision.requested)
155
+ }
156
+
157
+ // ② 经**未改动的原生派发器**执行:权限/Mandate/取消/超时/审计全部照旧。
158
+ let text: string
159
+ try {
160
+ text = await options.dispatcher.execute(
161
+ { name: input.edge.toolName, arguments: JSON.stringify(input.args ?? {}) },
162
+ {
163
+ workspace: input.workspace,
164
+ signal: input.signal,
165
+ timeoutMs: Math.max(1, Number(input.timeoutMs) || 1),
166
+ mandate: input.mandate,
167
+ },
168
+ )
169
+ } catch (error) {
170
+ failed += 1
171
+ fallbacks += 1
172
+ const message = error instanceof Error ? error.message : String(error)
173
+ return { ok: false, text: null, fallback: true, certificate: cert(input, { dispatched: false, error: `dispatch-threw:${message}` }) }
174
+ }
175
+
176
+ // ③ 派发器返回的「拒绝/报错」也算失败:不吞错、不假装成功。
177
+ const failure = resultFailed(text)
178
+ if (failure) {
179
+ failed += 1
180
+ fallbacks += 1
181
+ return { ok: false, text: null, fallback: true, certificate: cert(input, { dispatched: false, error: failure }) }
182
+ }
183
+
184
+ dispatched += 1
185
+ return {
186
+ ok: true,
187
+ text,
188
+ fallback: false,
189
+ certificate: cert(input, {
190
+ dispatched: true,
191
+ resultDigest: `sha256:${tokensOf(text).toString(36)}`,
192
+ resultChars: text.length,
193
+ }),
194
+ }
195
+ }
196
+
197
+ function stats() {
198
+ return { attempted, dispatched, failed, fallbacks }
199
+ }
200
+
201
+ return Object.freeze({ substitute, stats })
202
+ }
@@ -0,0 +1,133 @@
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 成本切点实现(M1,observe-only)。
6
+ *
7
+ * 四个钩子只把结构化事件交给 sink.cost(宿主侧由 HTTP sink 转发、Router 落库;
8
+ * 进程内由 Cordis 事件总线消费)。任何异常都被吞掉——成本观测不得影响 Worker
9
+ * 的控制流、预算判定或工具执行(设计 §9 I6:模块异常 fail-open 并留痕)。
10
+ */
11
+
12
+ import type {
13
+ CostHookEvent,
14
+ CostHooks,
15
+ LLMUsage,
16
+ WorkerCostUsage,
17
+ WorkerSink,
18
+ } from './types.ts'
19
+
20
+ const nonNegative = (value: unknown): number => {
21
+ const n = Number(value)
22
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
23
+ }
24
+
25
+ /** 把 provider 的 usage 归一成 {input, output, total};缺 total 时用 input+output。 */
26
+ export function normalizeCostUsage(usage?: LLMUsage): WorkerCostUsage {
27
+ const input = nonNegative(usage?.promptTokens)
28
+ const output = nonNegative(usage?.completionTokens)
29
+ const total = nonNegative(usage?.totalTokens) || input + output
30
+ return { input, output, total }
31
+ }
32
+
33
+ export interface CostHookOptions {
34
+ sink?: WorkerSink
35
+ clock?: () => number
36
+ }
37
+
38
+ export function createCostHooks(options: CostHookOptions = {}): CostHooks {
39
+ const clock = typeof options.clock === 'function' ? options.clock : () => Date.now()
40
+ const emit = (event: CostHookEvent): void => {
41
+ const sink = options.sink
42
+ if (!sink || typeof sink.cost !== 'function') return
43
+ try {
44
+ sink.cost(event)
45
+ } catch {
46
+ /* 成本观测失败不得影响控制流 */
47
+ }
48
+ }
49
+
50
+ const hooks: CostHooks = {
51
+ beforeModelCall(input: { ticketId: string; stepIndex: number; model?: string; historyChars: number }) {
52
+ emit({
53
+ point: 'beforeModelCall',
54
+ at: clock(),
55
+ ticketId: String(input?.ticketId ?? ''),
56
+ stepIndex: Number(input?.stepIndex) || 0,
57
+ model: input?.model === undefined ? undefined : String(input.model),
58
+ historyChars: nonNegative(input?.historyChars),
59
+ })
60
+ },
61
+ afterModelResponse(input: { ticketId: string; stepIndex: number; usage?: WorkerCostUsage; latencyMs: number; estimated?: boolean }) {
62
+ emit({
63
+ point: 'afterModelResponse',
64
+ at: clock(),
65
+ ticketId: String(input?.ticketId ?? ''),
66
+ stepIndex: Number(input?.stepIndex) || 0,
67
+ usage: input?.usage ?? normalizeCostUsage(),
68
+ latencyMs: nonNegative(input?.latencyMs),
69
+ estimated: input?.estimated === true,
70
+ })
71
+ },
72
+ beforeToolDispatch(input: { ticketId: string; stepIndex: number; toolName: string; argsChars: number }) {
73
+ emit({
74
+ point: 'beforeToolDispatch',
75
+ at: clock(),
76
+ ticketId: String(input?.ticketId ?? ''),
77
+ stepIndex: Number(input?.stepIndex) || 0,
78
+ toolName: String(input?.toolName ?? ''),
79
+ argsChars: nonNegative(input?.argsChars),
80
+ })
81
+ },
82
+ afterToolReceipt(input: { ticketId: string; stepIndex: number; toolName: string; resultChars: number; truncated: boolean; compaction?: CostHookEvent['compaction'] }) {
83
+ emit({
84
+ point: 'afterToolReceipt',
85
+ at: clock(),
86
+ ticketId: String(input?.ticketId ?? ''),
87
+ stepIndex: Number(input?.stepIndex) || 0,
88
+ toolName: String(input?.toolName ?? ''),
89
+ resultChars: nonNegative(input?.resultChars),
90
+ truncated: input?.truncated === true,
91
+ compaction: input?.compaction,
92
+ })
93
+ },
94
+ degraded(input: { ticketId: string; stepIndex: number; module: string; phase: 'beforeDispatch' | 'afterReceipt' | 'author' | 'guard' | 'substitute' | 'beforeModelCall' | 'afterModelResponse'; message: string; count: number }) {
95
+ emit({
96
+ point: 'degraded',
97
+ at: clock(),
98
+ ticketId: String(input?.ticketId ?? ''),
99
+ stepIndex: Number(input?.stepIndex) || 0,
100
+ toolName: input?.module === undefined ? undefined : String(input.module),
101
+ degraded: {
102
+ module: String(input?.module ?? ''),
103
+ phase: input?.phase ?? 'transform',
104
+ message: String(input?.message ?? ''),
105
+ count: nonNegative(input?.count) || 1,
106
+ },
107
+ })
108
+ },
109
+ }
110
+ return Object.freeze(hooks)
111
+ }
112
+
113
+ type HookArgs<K extends keyof CostHooks> = Parameters<CostHooks[K]>
114
+
115
+ /** 合并宿主注入的覆盖实现:先跑默认实现,再跑覆盖;任一失败都不影响另一个。 */
116
+ export function mergeCostHooks(base: CostHooks, override?: Partial<CostHooks> | null): CostHooks {
117
+ if (!override) return base
118
+ const chain = <K extends keyof CostHooks>(key: K) => {
119
+ const extra = override[key]
120
+ if (typeof extra !== 'function') return base[key]
121
+ return ((...args: HookArgs<K>): void => {
122
+ try { (base[key] as (...a: HookArgs<K>) => void)(...args) } catch { /* ignore */ }
123
+ try { (extra as (...a: HookArgs<K>) => void)(...args) } catch { /* ignore */ }
124
+ }) as CostHooks[K]
125
+ }
126
+ return Object.freeze({
127
+ beforeModelCall: chain('beforeModelCall'),
128
+ afterModelResponse: chain('afterModelResponse'),
129
+ beforeToolDispatch: chain('beforeToolDispatch'),
130
+ afterToolReceipt: chain('afterToolReceipt'),
131
+ degraded: chain('degraded'),
132
+ })
133
+ }