@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
@@ -23,6 +23,7 @@
23
23
 
24
24
  import type {
25
25
  ChatMessage,
26
+ CostHooks,
26
27
  LLMResponse,
27
28
  RouterTicketLike,
28
29
  TicketPayload,
@@ -37,6 +38,48 @@ import { DEFAULT_LIMITS } from './types.ts'
37
38
  import { WorkerError, WorkerLimitError, WorkerTicketError, toFailure } from './errors.ts'
38
39
  import { buildSystemPrompt, buildUserPrompt } from './prompt.ts'
39
40
  import { createToolRegistry } from './tools.ts'
41
+ import { createCostHooks, mergeCostHooks, normalizeCostUsage } from './cost-hooks.ts'
42
+ import { createModuleRegistry, createCompactModule } from './cost-modules.ts'
43
+ import { createBudgetGuard } from './budget-guard.ts'
44
+ import type { WorkerBudgetGuard, WorkerBudgetOptions } from './budget-guard.ts'
45
+ import type { ModuleRegistry } from './cost-spi.ts'
46
+ import { createDisclosureState, createLoadToolsTool } from './tool-disclosure.ts'
47
+ import type { DisclosureOptions, DisclosureState } from './tool-disclosure.ts'
48
+ import { createBatchAdvisor } from './tool-batch.ts'
49
+ import type { BatchAdvisor } from './tool-batch.ts'
50
+ import { createCcs } from './ccs.ts'
51
+ import type { Ccs, CcsCert } from './ccs.ts'
52
+ import { createGdr } from './gdr.ts'
53
+ import { schemaHashOf } from './edg.ts'
54
+ import type { EdgGraph } from './edg.ts'
55
+
56
+ /** §6 A2:从 deps/env 读取渐进式纰漏配置(默认关闭 ⇒ 全量工具面,fail-open)。 */
57
+ function disclosureOptions(deps: WorkerDeps): DisclosureOptions {
58
+ const config = deps.disclosure
59
+ if (config && typeof config === 'object') return config
60
+ if (typeof process === 'undefined' || !process.env) return { enabled: false }
61
+ if (process.env.DSH_MASTER_DISCLOSURE !== 'on') return { enabled: false }
62
+ return {
63
+ enabled: true,
64
+ allowlist: String(process.env.DSH_MASTER_DISCLOSURE_ALLOWLIST ?? '').split(',').map((item) => item.trim()).filter(Boolean),
65
+ }
66
+ }
67
+
68
+ /** §5.2:从环境变量读取 ① 预算闸配置(未配置 ⇒ 关闭,沿用 3.2.x 的事后硬上限)。 */
69
+ function budgetOptionsFromEnv(): WorkerBudgetOptions {
70
+ if (typeof process === 'undefined' || !process.env) return {}
71
+ const env = process.env
72
+ const limit = Number(env.DSH_MASTER_BUDGET_TOKENS)
73
+ if (!Number.isFinite(limit) || limit <= 0) return {}
74
+ const list = String(env.DSH_MASTER_BUDGET_DEGRADE_MODELS ?? '').split(',').map((item) => item.trim()).filter(Boolean)
75
+ return {
76
+ enabled: true,
77
+ limitTokens: Math.floor(limit),
78
+ onExhaust: env.DSH_MASTER_BUDGET_ON_EXHAUST === 'degrade' ? 'degrade' : 'hard-stop',
79
+ degradeModels: list,
80
+ degradeTo: env.DSH_MASTER_BUDGET_DEGRADE_TO,
81
+ }
82
+ }
40
83
 
41
84
  /**
42
85
  * 周期检测(v3.2.7 审计收口):识别「重复序列」而不是只看连续相同。
@@ -183,6 +226,50 @@ export class SuperAgentWorker {
183
226
  /** 本步是否已收到 provider 的真实流式 delta(true 时不再分块模拟)。 */
184
227
  private streamedThisStep = false
185
228
  private registry: ReturnType<typeof createToolRegistry>
229
+ /** 3.3.0:四个成本切点(默认转发 sink.cost;可被 deps.costHooks 覆盖/叠加)。 */
230
+ costHooks: CostHooks
231
+ /** 3.3.0 模块层:A/B 类模块的唯一中介(Agent Loop 不认识具体模块)。 */
232
+ modules: ModuleRegistry
233
+ /** 本次任务的 A1 归因汇总(保留原始字符口径,供 WorkerResult 上报)。 */
234
+ compactionStats = { evaluated: 0, applied: 0, originalChars: 0, compactChars: 0, savedChars: 0 }
235
+ /** 本次任务的降级留痕(模块 → 阶段 → 最近一次事件)。 */
236
+ degradedEvents = new Map<string, { module: string; phase: string; message: string; count: number }>() /** 实测 / 估算 token 分开累计(设计 §5.1:报表不得混算)。 */
237
+ measuredUsageTokens = 0
238
+ estimatedUsageTokens = 0
239
+ /** 设计 §5.2:① 预算闸(默认关闭,opt-in 后从「超支后终止」变为「超支前拒绝」)。 */
240
+ budget: WorkerBudgetGuard
241
+ /** §6 A2:渐进式纰漏状态(常驻集合 + 按需加载)。 */
242
+ disclosure: DisclosureState
243
+ /** §6 A3:批量调用引导与形状纠偏(只追加提示,不阻断不替换)。 */
244
+ batch: BatchAdvisor
245
+ /** §7 B3:替换执行器(只有配置了原生派发器时才存在)。 */
246
+ ccs: Ccs | null = null
247
+ /** §7 B2:守卫匹配器(始终存在,换成空图时不命中)。 */
248
+ gdr = createGdr()
249
+ /** §7 B3:本次任务的决策图(作者步骤/模板产出)。 */
250
+ edgGraph: EdgGraph | null = null
251
+ /** §7 B3:哪些边已完成(依赖检查用)。 */
252
+ completedEdges = new Set<string>()
253
+ /** §7 B3:本次任务发生的替换证书。 */
254
+ substitutions: CcsCert[] = []
255
+ /** §7 B3:被跳过的慢模型回调次数。 */
256
+ /** I8:外部取消信号(替换路径同样受其约束)。 */
257
+ cancelSignal: AbortSignal | undefined
258
+ skippedModelCalls = 0
259
+ /** 各工具当前 schema 哈希(B2 第②环)。 */
260
+ schemaHashes: Record<string, string> = {}
261
+ /** 替换策略:off / shadow(算证据不替换)/ enforce(白名单)。 */
262
+ ccsPolicy: { mode: 'off' | 'shadow' | 'enforce'; allowed: boolean; reason: string } = { mode: 'off', allowed: false, reason: 'disabled' }
263
+ /** M3:每次守卫匹配尝试(命中与未命中都记,作为命中率分母)。 */
264
+ reuseAttempts: Array<{ toolName: string; hit: boolean; reason: string | null; stage: string | null; edgeId: string | null; wouldSaveTokens: number; stepIndex: number }> = []
265
+ /** M3:shadow 下算出的「本可替换」证书(不派发、不进审计链)。 */
266
+ shadowCertificates: CcsCert[] = []
267
+ /** 计划用于替换时「跳过一次模型调用」的预留 token(归因用)。 */
268
+ ccsReserveTokens = 0
269
+ /** degrade 档选中的廉价模型(仅本次调用生效)。 */
270
+ private callModel: string | undefined
271
+ /** 当前循环步号(degraded 事件需要携带,注册表回调早于 stepIndex 提升)。 */
272
+ private currentStepIndex = 0
186
273
 
187
274
  constructor(deps: WorkerDeps) {
188
275
  if (!deps || !deps.llm || typeof deps.llm.chat !== 'function') throw new Error('SuperAgentWorker requires deps.llm.chat')
@@ -198,7 +285,70 @@ export class SuperAgentWorker {
198
285
  this.clock = typeof deps.clock === 'function' ? deps.clock : () => Date.now()
199
286
  this.model = deps.model
200
287
  this.redactValues = Array.isArray(deps.redactValues) ? deps.redactValues.filter((value) => typeof value === 'string') : []
201
- this.registry = createToolRegistry(this.tools)
288
+ this.cancelSignal = deps.signal
289
+ // §6 A2:渐进式纰漏——常驻工具面收敛,高级工具按需加载(默认关闭 ⇒ 全量工具面)。
290
+ this.disclosure = createDisclosureState(disclosureOptions(deps))
291
+ this.batch = createBatchAdvisor({
292
+ enabled: deps.batch?.enabled !== false,
293
+ mode: deps.batch?.mode,
294
+ maxBatchPerTurn: deps.batch?.maxBatchPerTurn ?? deps.limits?.maxToolCallsPerStep ?? this.limits.maxToolCallsPerStep,
295
+ })
296
+ const toolList = this.disclosure.enabled
297
+ ? [...this.tools, createLoadToolsTool(this.disclosure, this.tools)]
298
+ : this.tools
299
+ this.registry = createToolRegistry(toolList)
300
+ // §7 B3:只有显式给了决策图 + enforce 策略时才装替换器(默认不装 ⇒ 行为与 3.2.x 一致)。
301
+ const ccsConfig = deps.ccs
302
+ if (ccsConfig?.graph && Array.isArray(ccsConfig.graph.edges) && ccsConfig.graph.edges.length > 0) {
303
+ this.edgGraph = ccsConfig.graph
304
+ for (const tool of toolList) {
305
+ // 与 B1 的 schemaHashOf 同一算法:图里的 schemaHash 才能与运行时逐字对上。
306
+ this.schemaHashes[tool.name] = schemaHashOf(tool.name, tool.parameters, tool.description)
307
+ }
308
+ // I3:破坏性工具默认不入池 —— 图里属于破坏性工具的边在这里被剔除(除非显式放行)。
309
+ if (ccsConfig.allowDestructive !== true) {
310
+ const kept = this.edgGraph.edges.filter((edge) => !this.registry.destructive(edge.toolName))
311
+ if (kept.length !== this.edgGraph.edges.length) {
312
+ this.edgGraph = { ...this.edgGraph, edges: kept }
313
+ }
314
+ }
315
+ this.ccsReserveTokens = Math.max(0, Number(ccsConfig.reserveTokens ?? 0) || 0)
316
+ const enforce = ccsConfig.mode === 'enforce'
317
+ const allowlist = Array.isArray(ccsConfig.allowlist) ? ccsConfig.allowlist.map((item) => String(item)) : []
318
+ const allowlisted = allowlist.length === 0 ? true : allowlist.includes(String(ccsConfig.forkId ?? this.ticketId))
319
+ const shadow = ccsConfig.mode === 'shadow'
320
+ this.ccsPolicy = enforce && allowlisted
321
+ ? { mode: 'enforce', allowed: true, reason: 'enforce' }
322
+ : shadow
323
+ ? { mode: 'shadow', allowed: false, reason: 'shadow-only' }
324
+ : { mode: 'off', allowed: false, reason: enforce ? 'allowlist-miss' : 'not-enforce' }
325
+ }
326
+ this.costHooks = mergeCostHooks(createCostHooks({ sink: this.sink, clock: this.clock }), deps.costHooks)
327
+ const compactMode = deps.compactMode ?? (typeof process !== 'undefined' ? process.env.DSH_MASTER_COMPACT_MODE : undefined)
328
+ this.budget = createBudgetGuard(deps.budget ?? budgetOptionsFromEnv())
329
+ // §7 B3:替换器必须在 budget 就绪之后创建(它要在派发前过预算闸)。
330
+ if (this.ccsPolicy.allowed) {
331
+ this.ccs = createCcs({
332
+ dispatcher: { execute: (call, context) => this.registry.execute(call, context) },
333
+ budget: this.budget,
334
+ clock: this.clock,
335
+ })
336
+ }
337
+ this.modules = createModuleRegistry({
338
+ modules: [createCompactModule(compactMode, { workspace: this.workspace }), ...(deps.costModules ?? [])],
339
+ // 目标 2.1-5:模块异常必须留痕(degraded 事件),且不中断 Agent Loop。
340
+ onDegraded: (event) => {
341
+ this.degradedEvents.set(`${event.module}|${event.phase}`, { ...event })
342
+ this.costHooks.degraded({
343
+ ticketId: this.ticketId,
344
+ stepIndex: this.currentStepIndex,
345
+ module: event.module,
346
+ phase: event.phase,
347
+ message: event.message,
348
+ count: event.count,
349
+ })
350
+ },
351
+ })
202
352
  }
203
353
 
204
354
  private redact(text: string): string {
@@ -212,6 +362,12 @@ export class SuperAgentWorker {
212
362
  this.tokenUsage = 0
213
363
  this.finalAnswer = ''
214
364
  this.prDelivery = undefined
365
+ this.compactionStats = { evaluated: 0, applied: 0, originalChars: 0, compactChars: 0, savedChars: 0 }
366
+ this.degradedEvents.clear()
367
+ this.measuredUsageTokens = 0
368
+ this.estimatedUsageTokens = 0
369
+ this.callModel = undefined
370
+ this.currentStepIndex = 0
215
371
  let ticket: TicketPayload
216
372
  try {
217
373
  ticket = normalizeTicket(input)
@@ -237,6 +393,11 @@ export class SuperAgentWorker {
237
393
  { role: 'system', content: buildSystemPrompt(ticket) },
238
394
  { role: 'user', content: buildUserPrompt(ticket) },
239
395
  ]
396
+ // §6 A3:首轮一次教学——写进**历史**(不是系统提示词),之后不再重复。
397
+ if (this.batch.status().state === 'active' && !this.batch.taught()) {
398
+ this.history.push({ role: 'user', content: this.batch.guidance() })
399
+ this.batch.markTaught()
400
+ }
240
401
 
241
402
  try {
242
403
  this.setPhase('initializing')
@@ -263,10 +424,34 @@ export class SuperAgentWorker {
263
424
  throw new WorkerLimitError('max-steps', `Worker 超出最大循环次数 (${this.limits.maxSteps}),判定为死循环,强制终止。`)
264
425
  }
265
426
  stepIndex += 1
427
+ this.currentStepIndex = stepIndex
266
428
  this.setPhase('thinking')
267
429
  this.log(`[Worker] === 循环 Step ${stepIndex}/${this.limits.maxSteps} ===`)
268
430
  this.assertContextBudget()
269
431
 
432
+ const modelStartedAt = this.clock()
433
+ // 设计 §5.2:预算检查点**只在 ①**,且 fail-closed(付不起就不启动调用)。
434
+ const budgetDecision = this.budget.beforeModelCall({
435
+ model: this.model,
436
+ historyChars: JSON.stringify(this.history).length,
437
+ stepIndex,
438
+ })
439
+ if (!budgetDecision.ok) {
440
+ throw new WorkerLimitError('budget-exhausted', budgetDecision.message ?? '预算不足,拒绝启动模型调用')
441
+ }
442
+ this.callModel = budgetDecision.degraded === true && budgetDecision.model ? budgetDecision.model : undefined
443
+ this.costHooks.beforeModelCall({
444
+ ticketId: this.ticketId,
445
+ stepIndex,
446
+ model: this.callModel ?? this.model,
447
+ historyChars: JSON.stringify(this.history).length,
448
+ })
449
+ this.modules.beforeModelCall({
450
+ ticketId: this.ticketId,
451
+ stepIndex,
452
+ model: this.callModel ?? this.model,
453
+ historyChars: JSON.stringify(this.history).length,
454
+ })
270
455
  const response = await this.callLLM()
271
456
  const rawContent = String(response.message?.content ?? '')
272
457
  if (rawContent.length > this.limits.maxContextChars) {
@@ -274,11 +459,30 @@ export class SuperAgentWorker {
274
459
  }
275
460
  const usage = response.usage
276
461
  let used = Number(usage?.totalTokens ?? ((usage?.promptTokens ?? 0) + (usage?.completionTokens ?? 0))) || 0
462
+ let usedEstimated = false
277
463
  if (used <= 0) {
278
- // provider 不返回 usage 时的兜底估算,防止 Token 预算被绕过。
464
+ // provider 不返回 usage 时的兜底估算,防止 Token 预算被绕过;报表里必须标 estimated。
279
465
  used = Math.ceil((JSON.stringify(this.history).length + rawContent.length) / 4)
466
+ usedEstimated = true
280
467
  }
281
468
  this.tokenUsage += used
469
+ if (usedEstimated) this.estimatedUsageTokens += used
470
+ else this.measuredUsageTokens += used
471
+ const normalized = normalizeCostUsage(usage)
472
+ this.costHooks.afterModelResponse({
473
+ ticketId: this.ticketId,
474
+ stepIndex,
475
+ usage: normalized.total > 0 ? normalized : { input: 0, output: 0, total: used },
476
+ latencyMs: Math.max(0, this.clock() - modelStartedAt),
477
+ estimated: usedEstimated,
478
+ })
479
+ this.modules.afterModelResponse({
480
+ ticketId: this.ticketId,
481
+ stepIndex,
482
+ usage: normalized.total > 0 ? normalized : { input: 0, output: 0, total: used },
483
+ latencyMs: Math.max(0, this.clock() - modelStartedAt),
484
+ estimated: usedEstimated,
485
+ })
282
486
  if (this.tokenUsage > this.limits.maxTokens) {
283
487
  throw new WorkerLimitError('token-budget-exceeded', `超出最大 Token 预算 (${this.limits.maxTokens}),强制终止。`)
284
488
  }
@@ -296,6 +500,12 @@ export class SuperAgentWorker {
296
500
  }
297
501
 
298
502
  const toolCalls = validateToolCalls(response)
503
+ // §6 A3:只做形状纠偏(追加提示),从不阻断、从不改写模型程序。
504
+ const batchReview = this.batch.review(toolCalls)
505
+ if (batchReview.correction) {
506
+ this.history.push({ role: 'user', content: batchReview.correction })
507
+ this.log(`[Worker] A3 批量纠偏: ${batchReview.reason}`)
508
+ }
299
509
  if (toolCalls.length === 0) {
300
510
  this.finalAnswer = String(assistantMessage.content ?? '').trim() || '任务执行完成,无返回内容。'
301
511
  this.setPhase('reporting')
@@ -339,8 +549,71 @@ export class SuperAgentWorker {
339
549
  this.record({ type: 'tool_call', content: call.args, toolName: call.name, toolArgs: safeParseArgs(call.args) })
340
550
  this.setPhase('executing_tool')
341
551
  this.stream(undefined, call.name)
342
- const result = await this.executeTool(call)
343
- const safeResult = this.redact(result)
552
+ this.costHooks.beforeToolDispatch({
553
+ ticketId: this.ticketId,
554
+ stepIndex,
555
+ toolName: call.name,
556
+ argsChars: String(call.args ?? '').length,
557
+ })
558
+ // ③ 派发前:A 类模块可改写工具名/参数,或在 enforce 档屏蔽该工具。
559
+ const dispatch = this.modules.beforeToolDispatch({
560
+ ticketId: this.ticketId,
561
+ stepIndex,
562
+ call: { id: call.id, name: call.name, args: safeParseArgs(call.args) ?? {} },
563
+ })
564
+ let result: string
565
+ let dispatchedName = call.name
566
+ if (dispatch.hidden) {
567
+ // 屏蔽:不执行工具,回一条结构化拒绝给模型(可读、可恢复、不进死循环判定)。
568
+ result = `【模块屏蔽】${dispatch.hiddenBy} 已隐藏工具 ${call.name}(${dispatch.reason});请改用其它可用工具或直接给出结论。`
569
+ this.record({ type: 'tool_result', content: result, toolName: call.name })
570
+ } else {
571
+ dispatchedName = dispatch.call.name
572
+ result = await this.executeTool(
573
+ dispatchedName === call.name
574
+ ? call
575
+ : { ...call, name: dispatchedName, args: JSON.stringify(dispatch.call.args ?? {}) },
576
+ )
577
+ }
578
+ const transformed = this.modules.afterToolReceipt({
579
+ ticketId: this.ticketId,
580
+ stepIndex,
581
+ toolName: dispatchedName,
582
+ args: safeParseArgs(call.args),
583
+ text: result,
584
+ })
585
+ const toolResultText = transformed.text
586
+ const a1 = transformed.attributions.find((item) => item.module === 'a1-compact')
587
+ if (a1) {
588
+ const stats = this.compactionStats
589
+ stats.evaluated += 1
590
+ stats.originalChars += a1.originalChars
591
+ stats.compactChars += a1.applied ? a1.resultChars : a1.originalChars
592
+ stats.savedChars += a1.applied ? Math.max(0, a1.savedChars) : 0
593
+ if (a1.applied) stats.applied += 1
594
+ }
595
+ {
596
+ const potentialChars = a1 ? Math.max(0, Number((a1.meta as { wouldSaveChars?: number } | undefined)?.wouldSaveChars ?? a1.savedChars) || 0) : 0
597
+ this.costHooks.afterToolReceipt({
598
+ ticketId: this.ticketId,
599
+ stepIndex,
600
+ toolName: call.name,
601
+ resultChars: toolResultText.length,
602
+ truncated: toolResultText.length > this.limits.maxToolResultChars,
603
+ compaction: {
604
+ module: 'a1-compact',
605
+ mode: a1?.mode ?? 'off',
606
+ applicable: Boolean(a1),
607
+ applied: a1?.applied === true,
608
+ originalChars: result.length,
609
+ compactChars: toolResultText.length,
610
+ // shadow 时这里是「本可节省」;是否真的省下由 applied 表示(账本只认 applied)。
611
+ savedChars: potentialChars,
612
+ estimatedTokens: potentialChars > 0 ? Math.ceil(potentialChars / 4) : 0,
613
+ },
614
+ })
615
+ }
616
+ const safeResult = this.redact(toolResultText)
344
617
  const truncated = safeResult.length > this.limits.maxToolResultChars
345
618
  ? safeResult.slice(0, this.limits.maxToolResultChars) + `\n…[工具结果截断,原长 ${safeResult.length} 字符]`
346
619
  : safeResult
@@ -353,6 +626,14 @@ export class SuperAgentWorker {
353
626
  })
354
627
  this.setPhase('thinking')
355
628
  }
629
+
630
+ // §7 B3:尝试用已存决策图替代「下一次慢模型回调」。
631
+ // 只有 enforce + 策略放行 + GDR 命中 + 派发成功才真的跳过;任何失败都回落慢模型。
632
+ const substituted = await this.trySubstitute(stepIndex)
633
+ if (substituted) {
634
+ this.setPhase('thinking')
635
+ continue
636
+ }
356
637
  // 循环继续:带着 tool 结果再次询问模型。
357
638
  }
358
639
  } catch (error) {
@@ -364,7 +645,101 @@ export class SuperAgentWorker {
364
645
  }
365
646
  }
366
647
 
367
- /** 最近一步(供宿主轮询推送)。 */
648
+ /** §7 B3:用决策图替代一次慢模型回调;成功返回 true(已注入历史),失败返回 false。 */
649
+ private async trySubstitute(stepIndex: number): Promise<boolean> {
650
+ // M3:shadow 也做守卫匹配并产出「本可节省」证据,但**从不替换**。
651
+ if (!this.edgGraph || this.ccsPolicy.mode === 'off') return false
652
+ const lastTool = [...this.steps].reverse().find((step) => step.type === 'tool_result')
653
+ if (!lastTool || !lastTool.toolName) return false
654
+ const receipt = {
655
+ toolName: lastTool.toolName,
656
+ args: lastTool.toolArgs ?? {},
657
+ text: String(lastTool.content ?? ''),
658
+ digest: `sha256:${this.currentStepIndex.toString(36)}:${String(lastTool.content ?? '').length.toString(36)}`,
659
+ stepIndex,
660
+ ticketId: this.ticketId,
661
+ }
662
+ const guard = this.gdr.match({
663
+ receipt,
664
+ graph: this.edgGraph,
665
+ completed: [...this.completedEdges],
666
+ schemaHashOf: (toolName) => this.schemaHashes[toolName],
667
+ estimateSavedTokens: () => this.ccsReserveTokens,
668
+ })
669
+ // 每次复用尝试都留痕(命中与未命中都算分母)。
670
+ this.reuseAttempts.push({
671
+ toolName: receipt.toolName,
672
+ hit: guard.hit === true,
673
+ reason: guard.reason ?? null,
674
+ stage: guard.stage ?? null,
675
+ edgeId: guard.edgeId ?? null,
676
+ wouldSaveTokens: guard.hit === true ? Math.max(0, Number(guard.wouldSaveTokens ?? this.ccsReserveTokens) || 0) : 0,
677
+ stepIndex,
678
+ })
679
+ if (!guard.hit || !guard.edgeId || !guard.args) return false
680
+ const edge = this.edgGraph.edges.find((item) => item.id === guard.edgeId)
681
+ if (!edge) return false
682
+
683
+ const replacedCallbackId = `cb_${this.ticketId}_${stepIndex}_${guard.edgeId}`
684
+ // M3 shadow:只产证书,不派发、不跳过模型调用(证书标 dispatched:false,不进审计链)。
685
+ if (this.ccsPolicy.mode !== 'enforce' || !this.ccs) {
686
+ this.shadowCertificates.push({
687
+ edgeId: edge.id,
688
+ toolName: edge.toolName,
689
+ schemaHash: edge.schemaHash,
690
+ guardExpr: edge.guard.expr,
691
+ receiptDigest: receipt.digest,
692
+ replacedCallbackId,
693
+ viaNativeDispatcher: true,
694
+ taskId: this.ticketId,
695
+ step: stepIndex,
696
+ dispatched: false,
697
+ error: 'shadow-not-dispatched',
698
+ savedTokens: Math.max(0, Number(guard.wouldSaveTokens ?? this.ccsReserveTokens) || 0),
699
+ moduleId: 'b3-ccs',
700
+ at: this.clock(),
701
+ })
702
+ return false
703
+ }
704
+ const controller = new AbortController()
705
+ const abort = () => controller.abort()
706
+ if (this.cancelSignal) {
707
+ if (this.cancelSignal.aborted) controller.abort()
708
+ else this.cancelSignal.addEventListener('abort', abort, { once: true })
709
+ }
710
+ const result = await this.ccs.substitute({
711
+ edge,
712
+ args: guard.args,
713
+ receipt,
714
+ replacedCallbackId,
715
+ taskId: this.ticketId,
716
+ step: stepIndex,
717
+ workspace: this.workspace,
718
+ mandate: { ...this.effectiveMandate },
719
+ signal: controller.signal,
720
+ timeoutMs: this.limits.toolTimeoutMs,
721
+ savedTokens: this.ccsReserveTokens,
722
+ })
723
+ this.substitutions.push(result.certificate)
724
+ if (!result.ok || result.fallback || result.text === null) {
725
+ // 失败 ⇒ 记账留痕,然后**回落慢模型**(不吞错继续)。
726
+ this.log(`[Worker] B3 替换失败(${result.certificate.error ?? 'unknown'}),回落慢模型`)
727
+ return false
728
+ }
729
+ this.completedEdges.add(edge.id)
730
+ this.skippedModelCalls += 1
731
+ // 注入「这次调用本来会由模型发出」的两条消息,从而跳过这次模型推理。
732
+ this.history.push({
733
+ role: 'assistant',
734
+ content: '',
735
+ tool_calls: [{ id: replacedCallbackId, type: 'function', function: { name: edge.toolName, arguments: JSON.stringify(guard.args) } }],
736
+ })
737
+ const safe = this.redact(result.text)
738
+ this.history.push({ role: 'tool', tool_call_id: replacedCallbackId, name: edge.toolName, content: safe })
739
+ this.record({ type: 'tool_result', content: safe, toolName: edge.toolName })
740
+ try { this.sink?.cost?.({ point: 'afterToolReceipt', at: this.clock(), ticketId: this.ticketId, stepIndex, toolName: edge.toolName, resultChars: safe.length, truncated: false }) } catch { /* ignore */ }
741
+ return true
742
+ }
368
743
  getLatestStep(): WorkerStep | null {
369
744
  return this.steps.length > 0 ? this.steps[this.steps.length - 1] : null
370
745
  }
@@ -393,6 +768,67 @@ export class SuperAgentWorker {
393
768
  tokenUsage: this.tokenUsage,
394
769
  elapsedMs: Math.max(0, this.clock() - this.startedAt),
395
770
  ...(this.prDelivery ? { pr: this.prDelivery } : {}),
771
+ cost: {
772
+ compaction: {
773
+ mode: this.modules.describe().find((item) => item.id === 'a1-compact')?.mode ?? 'off',
774
+ ...this.compactionStats,
775
+ estimatedTokens: this.compactionStats.savedChars > 0 ? Math.ceil(this.compactionStats.savedChars / 4) : 0,
776
+ },
777
+ ...(this.degradedEvents.size > 0
778
+ ? {
779
+ degraded: {
780
+ count: [...this.degradedEvents.values()].reduce((sum, event) => sum + event.count, 0),
781
+ events: [...this.degradedEvents.values()].map((event) => ({ ...event })),
782
+ },
783
+ }
784
+ : {}),
785
+ tokens: {
786
+ measured: this.measuredUsageTokens,
787
+ estimated: this.estimatedUsageTokens,
788
+ },
789
+ disclosure: ((): { enabled: boolean; residentCount: number; loaded: string[]; loadEvents: number; schemaTokens: number; schemaCount: number } => {
790
+ const stats = this.disclosure.stats()
791
+ const visible = this.disclosure.visible(this.registry.names())
792
+ return {
793
+ enabled: this.disclosure.enabled,
794
+ residentCount: this.disclosure.enabled ? visible.length : this.registry.names().length,
795
+ loaded: stats.loaded,
796
+ loadEvents: stats.loadEvents,
797
+ schemaTokens: stats.schemaTokens,
798
+ schemaCount: stats.schemaCount,
799
+ }
800
+ })(),
801
+ batch: (() => {
802
+ const status = this.batch.status()
803
+ const stats = this.batch.stats()
804
+ return { state: status.state, reason: status.reason, mode: status.mode, taught: stats.taught, corrections: stats.corrections }
805
+ })(),
806
+ ccs: (() => {
807
+ const guardStats = this.gdr.stats()
808
+ return {
809
+ mode: this.ccsPolicy.mode,
810
+ skippedModelCalls: this.skippedModelCalls,
811
+ attempts: this.reuseAttempts.map((attempt) => ({ ...attempt })),
812
+ shadowCertificates: this.shadowCertificates.map((cert) => ({ ...cert })),
813
+ substitutions: this.substitutions.map((cert) => ({ ...cert })),
814
+ guard: { matches: guardStats.matches, hits: guardStats.hits, hitRate: guardStats.hitRate, byReason: { ...guardStats.byReason } },
815
+ }
816
+ })(),
817
+ ...((): { toolPrompt?: WorkerResult['cost'] extends undefined ? never : { moduleTokens: number; moduleSchemas: number; schemaTokens: number; schemaCount: number } } => {
818
+ // §6:工具面常驻成本 = 模块自报 + 实际注入 schema(chars/4)。
819
+ const moduleCost = this.modules.promptCost()
820
+ const schemas = this.registry.schemas()
821
+ const schemaChars = JSON.stringify(schemas).length
822
+ return {
823
+ toolPrompt: {
824
+ moduleTokens: moduleCost.toolTokens,
825
+ moduleSchemas: moduleCost.schemaCount,
826
+ schemaTokens: Math.ceil(schemaChars / 4),
827
+ schemaCount: schemas.length,
828
+ },
829
+ }
830
+ })(),
831
+ },
396
832
  error,
397
833
  }
398
834
  }
@@ -422,9 +858,10 @@ export class SuperAgentWorker {
422
858
  }, this.limits.llmTimeoutMs)
423
859
  })
424
860
  const request = {
425
- model: this.model,
861
+ model: this.callModel ?? this.model,
426
862
  messages: this.history,
427
- tools: this.registry.schemas(),
863
+ // A2:只注入当前可见的工具 schema(常驻 + 已加载);关闭时不做任何过滤。
864
+ tools: this.registry.schemas().filter((schema) => this.disclosure.visible(this.registry.names()).includes(schema.name)),
428
865
  signal: controller.signal,
429
866
  }
430
867
  try {
@@ -0,0 +1,80 @@
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 · A1 归因拟合式(设计 §5.4,**冻结参数**)。
6
+ *
7
+ * 归因算法(工具面):
8
+ *
9
+ * savedTokens = baselineOutputTokens(toolName, 原始长度) − actualOutputTokens(紧凑长度)
10
+ *
11
+ * `baselineOutputTokens` 由本文件的**固定拟合式**给出,参数在
12
+ * `docs/DESIGN-v3.3.0-cost-optimization.md` §5.4.1 冻结;**变更参数必须同步升版本号
13
+ * 并在 CHANGELOG 声明**,否则 `test/worker-baseline-fit.test.ts` 会失败。
14
+ *
15
+ * 公式(v1):
16
+ *
17
+ * baseline = ceil((chars + overheadChars[toolName]) / charsPerToken)
18
+ * actual = ceil(chars / charsPerToken)
19
+ *
20
+ * 其中 overhead 只补偿工具回执的**结构性固定开销**(如 shell 回执的 `STDOUT:/STDERR:` 头),
21
+ * 不是「估算魔法」:它必须能从工具的固定输出模板直接数出来。
22
+ */
23
+
24
+ /** 拟合式版本;改参数必须同时改这里(测试会锁死这一约定)。 */
25
+ export const A1_FIT_VERSION = 'a1-fit-v1'
26
+
27
+ export interface A1FitParams {
28
+ version: string
29
+ charsPerToken: number
30
+ /** 每个工具的固定结构开销(字符)。必须是「模板里数得出来」的常量。 */
31
+ overheadChars: Record<string, number>
32
+ defaultOverheadChars: number
33
+ }
34
+
35
+ export const A1_FIT_PARAMS: A1FitParams = Object.freeze({
36
+ version: A1_FIT_VERSION,
37
+ charsPerToken: 4,
38
+ overheadChars: Object.freeze({
39
+ fs_read_file: 0,
40
+ fs_list_dir: 0,
41
+ fs_write_file: 0,
42
+ // exec_shell_in_vm 的回执固定带 "STDOUT:\n"(8)+ "\nSTDERR:\n"(9)+ 末尾换行(1)
43
+ exec_shell_in_vm: 18,
44
+ }) as Record<string, number>,
45
+ defaultOverheadChars: 0,
46
+ })
47
+
48
+ const nonNegative = (value: unknown): number => {
49
+ const n = Number(value)
50
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
51
+ }
52
+
53
+ function overheadOf(toolName: string): number {
54
+ const key = String(toolName ?? '')
55
+ const table = A1_FIT_PARAMS.overheadChars
56
+ return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : A1_FIT_PARAMS.defaultOverheadChars
57
+ }
58
+
59
+ /** baseline:不经 A1 时模型会看到的回执 token 数(拟合式,v1 冻结)。 */
60
+ export function baselineOutputTokens(toolName: string, rawChars: number): number {
61
+ const chars = nonNegative(rawChars)
62
+ if (chars === 0) return 0
63
+ return Math.ceil((chars + overheadOf(toolName)) / A1_FIT_PARAMS.charsPerToken)
64
+ }
65
+
66
+ /** actual:紧凑后模型实际看到的回执 token 数。 */
67
+ export function actualOutputTokens(compactChars: number): number {
68
+ const chars = nonNegative(compactChars)
69
+ if (chars === 0) return 0
70
+ return Math.ceil(chars / A1_FIT_PARAMS.charsPerToken)
71
+ }
72
+
73
+ /**
74
+ * 单次工具的归因节省(永不为负:紧凑后更长时记 0,并由调用方标 `applied: false`)。
75
+ */
76
+ export function attributedSavedTokens(toolName: string, rawChars: number, compactChars: number): number {
77
+ const baseline = baselineOutputTokens(toolName, rawChars)
78
+ const actual = actualOutputTokens(compactChars)
79
+ return Math.max(0, baseline - actual)
80
+ }