@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.
- package/README.md +4 -0
- package/client.js +50 -0
- package/dist-host/contract/client.js +7 -0
- package/dist-host/contract/constants.js +16 -2
- package/dist-host/contract/index.js +1 -0
- package/dist-host/contract/relay.js +239 -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/fang-relay.js +329 -0
- package/dist-host/cost/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +345 -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 +209 -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 +329 -27
- package/dist-host/plugins/superagent-router.js +104 -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 +24 -0
- package/docs/CHANGELOG.md +66 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1058 -0
- package/docs/EXPERIMENT-M5-fang-relay.md +117 -0
- package/docs/EXPERIMENT-M5-real-ab.md +87 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +2 -2
- package/src/contract/client.js +7 -0
- package/src/contract/constants.js +17 -2
- package/src/contract/index.js +1 -0
- package/src/contract/relay.js +223 -0
- 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/fang-relay.ts +318 -0
- package/src/cost/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +348 -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 +206 -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 +313 -26
- package/src/plugins/superagent-router.ts +96 -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
|
@@ -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
|
+
}
|