@twinsearth/dsh-master 3.2.9 → 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.
Files changed (102) hide show
  1. package/README.md +39 -6
  2. package/client.js +51 -1
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +38 -11
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +89 -2
  27. package/dist-host/plugins/superagent-worker.js +1 -1
  28. package/dist-host/superagent/router.js +1 -1
  29. package/dist-host/superagent/types.js +8 -0
  30. package/dist-worker/agent-loop.js +449 -7
  31. package/dist-worker/baseline-fit.js +66 -0
  32. package/dist-worker/budget-guard.js +93 -0
  33. package/dist-worker/ccs.js +99 -0
  34. package/dist-worker/cost-hooks.js +115 -0
  35. package/dist-worker/cost-modules.js +330 -0
  36. package/dist-worker/cost-spi.js +3 -0
  37. package/dist-worker/edg.js +372 -0
  38. package/dist-worker/gdr.js +159 -0
  39. package/dist-worker/index.js +80 -2
  40. package/dist-worker/llm-http.js +20 -0
  41. package/dist-worker/sink-http.js +1 -0
  42. package/dist-worker/spill.js +115 -0
  43. package/dist-worker/tool-batch.js +66 -0
  44. package/dist-worker/tool-compact.js +275 -0
  45. package/dist-worker/tool-disclosure.js +90 -0
  46. package/dist-worker/tools.js +16 -0
  47. package/docs/ARCHITECTURE.md +32 -11
  48. package/docs/CHANGELOG.md +60 -0
  49. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  50. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  51. package/docs/RELEASE-v3.2.10.md +32 -0
  52. package/docs/ROADMAP.md +3 -3
  53. package/docs/SECURITY.md +9 -9
  54. package/docs/SUPERAGENT.md +5 -5
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/docs/WORKER-AGENT-LOOP.md +1 -1
  57. package/package.json +15 -3
  58. package/src/cost/attribution.ts +186 -0
  59. package/src/cost/audit.ts +170 -0
  60. package/src/cost/budget.ts +226 -0
  61. package/src/cost/bus.ts +97 -0
  62. package/src/cost/cert-chain.ts +102 -0
  63. package/src/cost/experiment.ts +136 -0
  64. package/src/cost/gate.ts +191 -0
  65. package/src/cost/host-surface.ts +97 -0
  66. package/src/cost/index.ts +316 -0
  67. package/src/cost/ledger-report.ts +186 -0
  68. package/src/cost/ledger.ts +385 -0
  69. package/src/cost/metrics.ts +193 -0
  70. package/src/cost/milestones.ts +170 -0
  71. package/src/cost/modules/index.ts +13 -0
  72. package/src/cost/modules/manifests.ts +129 -0
  73. package/src/cost/policy.ts +276 -0
  74. package/src/cost/report.ts +158 -0
  75. package/src/cost/reuse-report.ts +131 -0
  76. package/src/cost/rsi.ts +364 -0
  77. package/src/cost/toolface.ts +151 -0
  78. package/src/cost/types.ts +298 -0
  79. package/src/kernel.ts +37 -11
  80. package/src/plugins/cost-optimizer.ts +271 -26
  81. package/src/plugins/superagent-router.ts +83 -2
  82. package/src/plugins/superagent-worker.ts +1 -1
  83. package/src/superagent/router.ts +1 -1
  84. package/src/superagent/types.ts +70 -0
  85. package/worker/agent-loop.ts +444 -7
  86. package/worker/baseline-fit.ts +80 -0
  87. package/worker/budget-guard.ts +156 -0
  88. package/worker/ccs.ts +202 -0
  89. package/worker/cost-hooks.ts +133 -0
  90. package/worker/cost-modules.ts +356 -0
  91. package/worker/cost-spi.ts +283 -0
  92. package/worker/edg.ts +424 -0
  93. package/worker/gdr.ts +233 -0
  94. package/worker/index.ts +84 -3
  95. package/worker/llm-http.ts +18 -0
  96. package/worker/sink-http.ts +5 -2
  97. package/worker/spill.ts +133 -0
  98. package/worker/tool-batch.ts +124 -0
  99. package/worker/tool-compact.ts +317 -0
  100. package/worker/tool-disclosure.ts +131 -0
  101. package/worker/tools.ts +553 -531
  102. package/worker/types.ts +191 -0
@@ -4,6 +4,45 @@ import { DEFAULT_LIMITS } from "./types.js";
4
4
  import { WorkerError, WorkerLimitError, WorkerTicketError, toFailure } from "./errors.js";
5
5
  import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
6
6
  import { createToolRegistry } from "./tools.js";
7
+ import { createCostHooks, mergeCostHooks, normalizeCostUsage } from "./cost-hooks.js";
8
+ import { createModuleRegistry, createCompactModule } from "./cost-modules.js";
9
+ import { createBudgetGuard } from "./budget-guard.js";
10
+ import { createDisclosureState, createLoadToolsTool } from "./tool-disclosure.js";
11
+ import { createBatchAdvisor } from "./tool-batch.js";
12
+ import { createCcs } from "./ccs.js";
13
+ import { createGdr } from "./gdr.js";
14
+ import { schemaHashOf } from "./edg.js";
15
+ /** §6 A2:从 deps/env 读取渐进式纰漏配置(默认关闭 ⇒ 全量工具面,fail-open)。 */
16
+ function disclosureOptions(deps) {
17
+ const config = deps.disclosure;
18
+ if (config && typeof config === 'object')
19
+ return config;
20
+ if (typeof process === 'undefined' || !process.env)
21
+ return { enabled: false };
22
+ if (process.env.DSH_MASTER_DISCLOSURE !== 'on')
23
+ return { enabled: false };
24
+ return {
25
+ enabled: true,
26
+ allowlist: String(process.env.DSH_MASTER_DISCLOSURE_ALLOWLIST ?? '').split(',').map((item) => item.trim()).filter(Boolean),
27
+ };
28
+ }
29
+ /** §5.2:从环境变量读取 ① 预算闸配置(未配置 ⇒ 关闭,沿用 3.2.x 的事后硬上限)。 */
30
+ function budgetOptionsFromEnv() {
31
+ if (typeof process === 'undefined' || !process.env)
32
+ return {};
33
+ const env = process.env;
34
+ const limit = Number(env.DSH_MASTER_BUDGET_TOKENS);
35
+ if (!Number.isFinite(limit) || limit <= 0)
36
+ return {};
37
+ const list = String(env.DSH_MASTER_BUDGET_DEGRADE_MODELS ?? '').split(',').map((item) => item.trim()).filter(Boolean);
38
+ return {
39
+ enabled: true,
40
+ limitTokens: Math.floor(limit),
41
+ onExhaust: env.DSH_MASTER_BUDGET_ON_EXHAUST === 'degrade' ? 'degrade' : 'hard-stop',
42
+ degradeModels: list,
43
+ degradeTo: env.DSH_MASTER_BUDGET_DEGRADE_TO,
44
+ };
45
+ }
7
46
  /**
8
47
  * 周期检测(v3.2.7 审计收口):识别「重复序列」而不是只看连续相同。
9
48
  *
@@ -149,6 +188,50 @@ export class SuperAgentWorker {
149
188
  /** 本步是否已收到 provider 的真实流式 delta(true 时不再分块模拟)。 */
150
189
  streamedThisStep = false;
151
190
  registry;
191
+ /** 3.3.0:四个成本切点(默认转发 sink.cost;可被 deps.costHooks 覆盖/叠加)。 */
192
+ costHooks;
193
+ /** 3.3.0 模块层:A/B 类模块的唯一中介(Agent Loop 不认识具体模块)。 */
194
+ modules;
195
+ /** 本次任务的 A1 归因汇总(保留原始字符口径,供 WorkerResult 上报)。 */
196
+ compactionStats = { evaluated: 0, applied: 0, originalChars: 0, compactChars: 0, savedChars: 0 };
197
+ /** 本次任务的降级留痕(模块 → 阶段 → 最近一次事件)。 */
198
+ degradedEvents = new Map(); /** 实测 / 估算 token 分开累计(设计 §5.1:报表不得混算)。 */
199
+ measuredUsageTokens = 0;
200
+ estimatedUsageTokens = 0;
201
+ /** 设计 §5.2:① 预算闸(默认关闭,opt-in 后从「超支后终止」变为「超支前拒绝」)。 */
202
+ budget;
203
+ /** §6 A2:渐进式纰漏状态(常驻集合 + 按需加载)。 */
204
+ disclosure;
205
+ /** §6 A3:批量调用引导与形状纠偏(只追加提示,不阻断不替换)。 */
206
+ batch;
207
+ /** §7 B3:替换执行器(只有配置了原生派发器时才存在)。 */
208
+ ccs = null;
209
+ /** §7 B2:守卫匹配器(始终存在,换成空图时不命中)。 */
210
+ gdr = createGdr();
211
+ /** §7 B3:本次任务的决策图(作者步骤/模板产出)。 */
212
+ edgGraph = null;
213
+ /** §7 B3:哪些边已完成(依赖检查用)。 */
214
+ completedEdges = new Set();
215
+ /** §7 B3:本次任务发生的替换证书。 */
216
+ substitutions = [];
217
+ /** §7 B3:被跳过的慢模型回调次数。 */
218
+ /** I8:外部取消信号(替换路径同样受其约束)。 */
219
+ cancelSignal;
220
+ skippedModelCalls = 0;
221
+ /** 各工具当前 schema 哈希(B2 第②环)。 */
222
+ schemaHashes = {};
223
+ /** 替换策略:off / shadow(算证据不替换)/ enforce(白名单)。 */
224
+ ccsPolicy = { mode: 'off', allowed: false, reason: 'disabled' };
225
+ /** M3:每次守卫匹配尝试(命中与未命中都记,作为命中率分母)。 */
226
+ reuseAttempts = [];
227
+ /** M3:shadow 下算出的「本可替换」证书(不派发、不进审计链)。 */
228
+ shadowCertificates = [];
229
+ /** 计划用于替换时「跳过一次模型调用」的预留 token(归因用)。 */
230
+ ccsReserveTokens = 0;
231
+ /** degrade 档选中的廉价模型(仅本次调用生效)。 */
232
+ callModel;
233
+ /** 当前循环步号(degraded 事件需要携带,注册表回调早于 stepIndex 提升)。 */
234
+ currentStepIndex = 0;
152
235
  constructor(deps) {
153
236
  if (!deps || !deps.llm || typeof deps.llm.chat !== 'function')
154
237
  throw new Error('SuperAgentWorker requires deps.llm.chat');
@@ -165,7 +248,70 @@ export class SuperAgentWorker {
165
248
  this.clock = typeof deps.clock === 'function' ? deps.clock : () => Date.now();
166
249
  this.model = deps.model;
167
250
  this.redactValues = Array.isArray(deps.redactValues) ? deps.redactValues.filter((value) => typeof value === 'string') : [];
168
- this.registry = createToolRegistry(this.tools);
251
+ this.cancelSignal = deps.signal;
252
+ // §6 A2:渐进式纰漏——常驻工具面收敛,高级工具按需加载(默认关闭 ⇒ 全量工具面)。
253
+ this.disclosure = createDisclosureState(disclosureOptions(deps));
254
+ this.batch = createBatchAdvisor({
255
+ enabled: deps.batch?.enabled !== false,
256
+ mode: deps.batch?.mode,
257
+ maxBatchPerTurn: deps.batch?.maxBatchPerTurn ?? deps.limits?.maxToolCallsPerStep ?? this.limits.maxToolCallsPerStep,
258
+ });
259
+ const toolList = this.disclosure.enabled
260
+ ? [...this.tools, createLoadToolsTool(this.disclosure, this.tools)]
261
+ : this.tools;
262
+ this.registry = createToolRegistry(toolList);
263
+ // §7 B3:只有显式给了决策图 + enforce 策略时才装替换器(默认不装 ⇒ 行为与 3.2.x 一致)。
264
+ const ccsConfig = deps.ccs;
265
+ if (ccsConfig?.graph && Array.isArray(ccsConfig.graph.edges) && ccsConfig.graph.edges.length > 0) {
266
+ this.edgGraph = ccsConfig.graph;
267
+ for (const tool of toolList) {
268
+ // 与 B1 的 schemaHashOf 同一算法:图里的 schemaHash 才能与运行时逐字对上。
269
+ this.schemaHashes[tool.name] = schemaHashOf(tool.name, tool.parameters, tool.description);
270
+ }
271
+ // I3:破坏性工具默认不入池 —— 图里属于破坏性工具的边在这里被剔除(除非显式放行)。
272
+ if (ccsConfig.allowDestructive !== true) {
273
+ const kept = this.edgGraph.edges.filter((edge) => !this.registry.destructive(edge.toolName));
274
+ if (kept.length !== this.edgGraph.edges.length) {
275
+ this.edgGraph = { ...this.edgGraph, edges: kept };
276
+ }
277
+ }
278
+ this.ccsReserveTokens = Math.max(0, Number(ccsConfig.reserveTokens ?? 0) || 0);
279
+ const enforce = ccsConfig.mode === 'enforce';
280
+ const allowlist = Array.isArray(ccsConfig.allowlist) ? ccsConfig.allowlist.map((item) => String(item)) : [];
281
+ const allowlisted = allowlist.length === 0 ? true : allowlist.includes(String(ccsConfig.forkId ?? this.ticketId));
282
+ const shadow = ccsConfig.mode === 'shadow';
283
+ this.ccsPolicy = enforce && allowlisted
284
+ ? { mode: 'enforce', allowed: true, reason: 'enforce' }
285
+ : shadow
286
+ ? { mode: 'shadow', allowed: false, reason: 'shadow-only' }
287
+ : { mode: 'off', allowed: false, reason: enforce ? 'allowlist-miss' : 'not-enforce' };
288
+ }
289
+ this.costHooks = mergeCostHooks(createCostHooks({ sink: this.sink, clock: this.clock }), deps.costHooks);
290
+ const compactMode = deps.compactMode ?? (typeof process !== 'undefined' ? process.env.DSH_MASTER_COMPACT_MODE : undefined);
291
+ this.budget = createBudgetGuard(deps.budget ?? budgetOptionsFromEnv());
292
+ // §7 B3:替换器必须在 budget 就绪之后创建(它要在派发前过预算闸)。
293
+ if (this.ccsPolicy.allowed) {
294
+ this.ccs = createCcs({
295
+ dispatcher: { execute: (call, context) => this.registry.execute(call, context) },
296
+ budget: this.budget,
297
+ clock: this.clock,
298
+ });
299
+ }
300
+ this.modules = createModuleRegistry({
301
+ modules: [createCompactModule(compactMode, { workspace: this.workspace }), ...(deps.costModules ?? [])],
302
+ // 目标 2.1-5:模块异常必须留痕(degraded 事件),且不中断 Agent Loop。
303
+ onDegraded: (event) => {
304
+ this.degradedEvents.set(`${event.module}|${event.phase}`, { ...event });
305
+ this.costHooks.degraded({
306
+ ticketId: this.ticketId,
307
+ stepIndex: this.currentStepIndex,
308
+ module: event.module,
309
+ phase: event.phase,
310
+ message: event.message,
311
+ count: event.count,
312
+ });
313
+ },
314
+ });
169
315
  }
170
316
  redact(text) {
171
317
  return redactSecrets(text, this.redactValues);
@@ -177,6 +323,12 @@ export class SuperAgentWorker {
177
323
  this.tokenUsage = 0;
178
324
  this.finalAnswer = '';
179
325
  this.prDelivery = undefined;
326
+ this.compactionStats = { evaluated: 0, applied: 0, originalChars: 0, compactChars: 0, savedChars: 0 };
327
+ this.degradedEvents.clear();
328
+ this.measuredUsageTokens = 0;
329
+ this.estimatedUsageTokens = 0;
330
+ this.callModel = undefined;
331
+ this.currentStepIndex = 0;
180
332
  let ticket;
181
333
  try {
182
334
  ticket = normalizeTicket(input);
@@ -202,6 +354,11 @@ export class SuperAgentWorker {
202
354
  { role: 'system', content: buildSystemPrompt(ticket) },
203
355
  { role: 'user', content: buildUserPrompt(ticket) },
204
356
  ];
357
+ // §6 A3:首轮一次教学——写进**历史**(不是系统提示词),之后不再重复。
358
+ if (this.batch.status().state === 'active' && !this.batch.taught()) {
359
+ this.history.push({ role: 'user', content: this.batch.guidance() });
360
+ this.batch.markTaught();
361
+ }
205
362
  try {
206
363
  this.setPhase('initializing');
207
364
  this.record({ type: 'phase', content: 'initializing' });
@@ -223,9 +380,33 @@ export class SuperAgentWorker {
223
380
  throw new WorkerLimitError('max-steps', `Worker 超出最大循环次数 (${this.limits.maxSteps}),判定为死循环,强制终止。`);
224
381
  }
225
382
  stepIndex += 1;
383
+ this.currentStepIndex = stepIndex;
226
384
  this.setPhase('thinking');
227
385
  this.log(`[Worker] === 循环 Step ${stepIndex}/${this.limits.maxSteps} ===`);
228
386
  this.assertContextBudget();
387
+ const modelStartedAt = this.clock();
388
+ // 设计 §5.2:预算检查点**只在 ①**,且 fail-closed(付不起就不启动调用)。
389
+ const budgetDecision = this.budget.beforeModelCall({
390
+ model: this.model,
391
+ historyChars: JSON.stringify(this.history).length,
392
+ stepIndex,
393
+ });
394
+ if (!budgetDecision.ok) {
395
+ throw new WorkerLimitError('budget-exhausted', budgetDecision.message ?? '预算不足,拒绝启动模型调用');
396
+ }
397
+ this.callModel = budgetDecision.degraded === true && budgetDecision.model ? budgetDecision.model : undefined;
398
+ this.costHooks.beforeModelCall({
399
+ ticketId: this.ticketId,
400
+ stepIndex,
401
+ model: this.callModel ?? this.model,
402
+ historyChars: JSON.stringify(this.history).length,
403
+ });
404
+ this.modules.beforeModelCall({
405
+ ticketId: this.ticketId,
406
+ stepIndex,
407
+ model: this.callModel ?? this.model,
408
+ historyChars: JSON.stringify(this.history).length,
409
+ });
229
410
  const response = await this.callLLM();
230
411
  const rawContent = String(response.message?.content ?? '');
231
412
  if (rawContent.length > this.limits.maxContextChars) {
@@ -233,11 +414,32 @@ export class SuperAgentWorker {
233
414
  }
234
415
  const usage = response.usage;
235
416
  let used = Number(usage?.totalTokens ?? ((usage?.promptTokens ?? 0) + (usage?.completionTokens ?? 0))) || 0;
417
+ let usedEstimated = false;
236
418
  if (used <= 0) {
237
- // provider 不返回 usage 时的兜底估算,防止 Token 预算被绕过。
419
+ // provider 不返回 usage 时的兜底估算,防止 Token 预算被绕过;报表里必须标 estimated。
238
420
  used = Math.ceil((JSON.stringify(this.history).length + rawContent.length) / 4);
421
+ usedEstimated = true;
239
422
  }
240
423
  this.tokenUsage += used;
424
+ if (usedEstimated)
425
+ this.estimatedUsageTokens += used;
426
+ else
427
+ this.measuredUsageTokens += used;
428
+ const normalized = normalizeCostUsage(usage);
429
+ this.costHooks.afterModelResponse({
430
+ ticketId: this.ticketId,
431
+ stepIndex,
432
+ usage: normalized.total > 0 ? normalized : { input: 0, output: 0, total: used },
433
+ latencyMs: Math.max(0, this.clock() - modelStartedAt),
434
+ estimated: usedEstimated,
435
+ });
436
+ this.modules.afterModelResponse({
437
+ ticketId: this.ticketId,
438
+ stepIndex,
439
+ usage: normalized.total > 0 ? normalized : { input: 0, output: 0, total: used },
440
+ latencyMs: Math.max(0, this.clock() - modelStartedAt),
441
+ estimated: usedEstimated,
442
+ });
241
443
  if (this.tokenUsage > this.limits.maxTokens) {
242
444
  throw new WorkerLimitError('token-budget-exceeded', `超出最大 Token 预算 (${this.limits.maxTokens}),强制终止。`);
243
445
  }
@@ -252,6 +454,12 @@ export class SuperAgentWorker {
252
454
  this.record({ type: 'thought', content: assistantMessage.content });
253
455
  }
254
456
  const toolCalls = validateToolCalls(response);
457
+ // §6 A3:只做形状纠偏(追加提示),从不阻断、从不改写模型程序。
458
+ const batchReview = this.batch.review(toolCalls);
459
+ if (batchReview.correction) {
460
+ this.history.push({ role: 'user', content: batchReview.correction });
461
+ this.log(`[Worker] A3 批量纠偏: ${batchReview.reason}`);
462
+ }
255
463
  if (toolCalls.length === 0) {
256
464
  this.finalAnswer = String(assistantMessage.content ?? '').trim() || '任务执行完成,无返回内容。';
257
465
  this.setPhase('reporting');
@@ -295,8 +503,71 @@ export class SuperAgentWorker {
295
503
  this.record({ type: 'tool_call', content: call.args, toolName: call.name, toolArgs: safeParseArgs(call.args) });
296
504
  this.setPhase('executing_tool');
297
505
  this.stream(undefined, call.name);
298
- const result = await this.executeTool(call);
299
- const safeResult = this.redact(result);
506
+ this.costHooks.beforeToolDispatch({
507
+ ticketId: this.ticketId,
508
+ stepIndex,
509
+ toolName: call.name,
510
+ argsChars: String(call.args ?? '').length,
511
+ });
512
+ // ③ 派发前:A 类模块可改写工具名/参数,或在 enforce 档屏蔽该工具。
513
+ const dispatch = this.modules.beforeToolDispatch({
514
+ ticketId: this.ticketId,
515
+ stepIndex,
516
+ call: { id: call.id, name: call.name, args: safeParseArgs(call.args) ?? {} },
517
+ });
518
+ let result;
519
+ let dispatchedName = call.name;
520
+ if (dispatch.hidden) {
521
+ // 屏蔽:不执行工具,回一条结构化拒绝给模型(可读、可恢复、不进死循环判定)。
522
+ result = `【模块屏蔽】${dispatch.hiddenBy} 已隐藏工具 ${call.name}(${dispatch.reason});请改用其它可用工具或直接给出结论。`;
523
+ this.record({ type: 'tool_result', content: result, toolName: call.name });
524
+ }
525
+ else {
526
+ dispatchedName = dispatch.call.name;
527
+ result = await this.executeTool(dispatchedName === call.name
528
+ ? call
529
+ : { ...call, name: dispatchedName, args: JSON.stringify(dispatch.call.args ?? {}) });
530
+ }
531
+ const transformed = this.modules.afterToolReceipt({
532
+ ticketId: this.ticketId,
533
+ stepIndex,
534
+ toolName: dispatchedName,
535
+ args: safeParseArgs(call.args),
536
+ text: result,
537
+ });
538
+ const toolResultText = transformed.text;
539
+ const a1 = transformed.attributions.find((item) => item.module === 'a1-compact');
540
+ if (a1) {
541
+ const stats = this.compactionStats;
542
+ stats.evaluated += 1;
543
+ stats.originalChars += a1.originalChars;
544
+ stats.compactChars += a1.applied ? a1.resultChars : a1.originalChars;
545
+ stats.savedChars += a1.applied ? Math.max(0, a1.savedChars) : 0;
546
+ if (a1.applied)
547
+ stats.applied += 1;
548
+ }
549
+ {
550
+ const potentialChars = a1 ? Math.max(0, Number(a1.meta?.wouldSaveChars ?? a1.savedChars) || 0) : 0;
551
+ this.costHooks.afterToolReceipt({
552
+ ticketId: this.ticketId,
553
+ stepIndex,
554
+ toolName: call.name,
555
+ resultChars: toolResultText.length,
556
+ truncated: toolResultText.length > this.limits.maxToolResultChars,
557
+ compaction: {
558
+ module: 'a1-compact',
559
+ mode: a1?.mode ?? 'off',
560
+ applicable: Boolean(a1),
561
+ applied: a1?.applied === true,
562
+ originalChars: result.length,
563
+ compactChars: toolResultText.length,
564
+ // shadow 时这里是「本可节省」;是否真的省下由 applied 表示(账本只认 applied)。
565
+ savedChars: potentialChars,
566
+ estimatedTokens: potentialChars > 0 ? Math.ceil(potentialChars / 4) : 0,
567
+ },
568
+ });
569
+ }
570
+ const safeResult = this.redact(toolResultText);
300
571
  const truncated = safeResult.length > this.limits.maxToolResultChars
301
572
  ? safeResult.slice(0, this.limits.maxToolResultChars) + `\n…[工具结果截断,原长 ${safeResult.length} 字符]`
302
573
  : safeResult;
@@ -309,6 +580,13 @@ export class SuperAgentWorker {
309
580
  });
310
581
  this.setPhase('thinking');
311
582
  }
583
+ // §7 B3:尝试用已存决策图替代「下一次慢模型回调」。
584
+ // 只有 enforce + 策略放行 + GDR 命中 + 派发成功才真的跳过;任何失败都回落慢模型。
585
+ const substituted = await this.trySubstitute(stepIndex);
586
+ if (substituted) {
587
+ this.setPhase('thinking');
588
+ continue;
589
+ }
312
590
  // 循环继续:带着 tool 结果再次询问模型。
313
591
  }
314
592
  }
@@ -320,7 +598,109 @@ export class SuperAgentWorker {
320
598
  return this.result(ticket.ticketId, false, failure);
321
599
  }
322
600
  }
323
- /** 最近一步(供宿主轮询推送)。 */
601
+ /** §7 B3:用决策图替代一次慢模型回调;成功返回 true(已注入历史),失败返回 false。 */
602
+ async trySubstitute(stepIndex) {
603
+ // M3:shadow 也做守卫匹配并产出「本可节省」证据,但**从不替换**。
604
+ if (!this.edgGraph || this.ccsPolicy.mode === 'off')
605
+ return false;
606
+ const lastTool = [...this.steps].reverse().find((step) => step.type === 'tool_result');
607
+ if (!lastTool || !lastTool.toolName)
608
+ return false;
609
+ const receipt = {
610
+ toolName: lastTool.toolName,
611
+ args: lastTool.toolArgs ?? {},
612
+ text: String(lastTool.content ?? ''),
613
+ digest: `sha256:${this.currentStepIndex.toString(36)}:${String(lastTool.content ?? '').length.toString(36)}`,
614
+ stepIndex,
615
+ ticketId: this.ticketId,
616
+ };
617
+ const guard = this.gdr.match({
618
+ receipt,
619
+ graph: this.edgGraph,
620
+ completed: [...this.completedEdges],
621
+ schemaHashOf: (toolName) => this.schemaHashes[toolName],
622
+ estimateSavedTokens: () => this.ccsReserveTokens,
623
+ });
624
+ // 每次复用尝试都留痕(命中与未命中都算分母)。
625
+ this.reuseAttempts.push({
626
+ toolName: receipt.toolName,
627
+ hit: guard.hit === true,
628
+ reason: guard.reason ?? null,
629
+ stage: guard.stage ?? null,
630
+ edgeId: guard.edgeId ?? null,
631
+ wouldSaveTokens: guard.hit === true ? Math.max(0, Number(guard.wouldSaveTokens ?? this.ccsReserveTokens) || 0) : 0,
632
+ stepIndex,
633
+ });
634
+ if (!guard.hit || !guard.edgeId || !guard.args)
635
+ return false;
636
+ const edge = this.edgGraph.edges.find((item) => item.id === guard.edgeId);
637
+ if (!edge)
638
+ return false;
639
+ const replacedCallbackId = `cb_${this.ticketId}_${stepIndex}_${guard.edgeId}`;
640
+ // M3 shadow:只产证书,不派发、不跳过模型调用(证书标 dispatched:false,不进审计链)。
641
+ if (this.ccsPolicy.mode !== 'enforce' || !this.ccs) {
642
+ this.shadowCertificates.push({
643
+ edgeId: edge.id,
644
+ toolName: edge.toolName,
645
+ schemaHash: edge.schemaHash,
646
+ guardExpr: edge.guard.expr,
647
+ receiptDigest: receipt.digest,
648
+ replacedCallbackId,
649
+ viaNativeDispatcher: true,
650
+ taskId: this.ticketId,
651
+ step: stepIndex,
652
+ dispatched: false,
653
+ error: 'shadow-not-dispatched',
654
+ savedTokens: Math.max(0, Number(guard.wouldSaveTokens ?? this.ccsReserveTokens) || 0),
655
+ moduleId: 'b3-ccs',
656
+ at: this.clock(),
657
+ });
658
+ return false;
659
+ }
660
+ const controller = new AbortController();
661
+ const abort = () => controller.abort();
662
+ if (this.cancelSignal) {
663
+ if (this.cancelSignal.aborted)
664
+ controller.abort();
665
+ else
666
+ this.cancelSignal.addEventListener('abort', abort, { once: true });
667
+ }
668
+ const result = await this.ccs.substitute({
669
+ edge,
670
+ args: guard.args,
671
+ receipt,
672
+ replacedCallbackId,
673
+ taskId: this.ticketId,
674
+ step: stepIndex,
675
+ workspace: this.workspace,
676
+ mandate: { ...this.effectiveMandate },
677
+ signal: controller.signal,
678
+ timeoutMs: this.limits.toolTimeoutMs,
679
+ savedTokens: this.ccsReserveTokens,
680
+ });
681
+ this.substitutions.push(result.certificate);
682
+ if (!result.ok || result.fallback || result.text === null) {
683
+ // 失败 ⇒ 记账留痕,然后**回落慢模型**(不吞错继续)。
684
+ this.log(`[Worker] B3 替换失败(${result.certificate.error ?? 'unknown'}),回落慢模型`);
685
+ return false;
686
+ }
687
+ this.completedEdges.add(edge.id);
688
+ this.skippedModelCalls += 1;
689
+ // 注入「这次调用本来会由模型发出」的两条消息,从而跳过这次模型推理。
690
+ this.history.push({
691
+ role: 'assistant',
692
+ content: '',
693
+ tool_calls: [{ id: replacedCallbackId, type: 'function', function: { name: edge.toolName, arguments: JSON.stringify(guard.args) } }],
694
+ });
695
+ const safe = this.redact(result.text);
696
+ this.history.push({ role: 'tool', tool_call_id: replacedCallbackId, name: edge.toolName, content: safe });
697
+ this.record({ type: 'tool_result', content: safe, toolName: edge.toolName });
698
+ try {
699
+ this.sink?.cost?.({ point: 'afterToolReceipt', at: this.clock(), ticketId: this.ticketId, stepIndex, toolName: edge.toolName, resultChars: safe.length, truncated: false });
700
+ }
701
+ catch { /* ignore */ }
702
+ return true;
703
+ }
324
704
  getLatestStep() {
325
705
  return this.steps.length > 0 ? this.steps[this.steps.length - 1] : null;
326
706
  }
@@ -346,6 +726,67 @@ export class SuperAgentWorker {
346
726
  tokenUsage: this.tokenUsage,
347
727
  elapsedMs: Math.max(0, this.clock() - this.startedAt),
348
728
  ...(this.prDelivery ? { pr: this.prDelivery } : {}),
729
+ cost: {
730
+ compaction: {
731
+ mode: this.modules.describe().find((item) => item.id === 'a1-compact')?.mode ?? 'off',
732
+ ...this.compactionStats,
733
+ estimatedTokens: this.compactionStats.savedChars > 0 ? Math.ceil(this.compactionStats.savedChars / 4) : 0,
734
+ },
735
+ ...(this.degradedEvents.size > 0
736
+ ? {
737
+ degraded: {
738
+ count: [...this.degradedEvents.values()].reduce((sum, event) => sum + event.count, 0),
739
+ events: [...this.degradedEvents.values()].map((event) => ({ ...event })),
740
+ },
741
+ }
742
+ : {}),
743
+ tokens: {
744
+ measured: this.measuredUsageTokens,
745
+ estimated: this.estimatedUsageTokens,
746
+ },
747
+ disclosure: (() => {
748
+ const stats = this.disclosure.stats();
749
+ const visible = this.disclosure.visible(this.registry.names());
750
+ return {
751
+ enabled: this.disclosure.enabled,
752
+ residentCount: this.disclosure.enabled ? visible.length : this.registry.names().length,
753
+ loaded: stats.loaded,
754
+ loadEvents: stats.loadEvents,
755
+ schemaTokens: stats.schemaTokens,
756
+ schemaCount: stats.schemaCount,
757
+ };
758
+ })(),
759
+ batch: (() => {
760
+ const status = this.batch.status();
761
+ const stats = this.batch.stats();
762
+ return { state: status.state, reason: status.reason, mode: status.mode, taught: stats.taught, corrections: stats.corrections };
763
+ })(),
764
+ ccs: (() => {
765
+ const guardStats = this.gdr.stats();
766
+ return {
767
+ mode: this.ccsPolicy.mode,
768
+ skippedModelCalls: this.skippedModelCalls,
769
+ attempts: this.reuseAttempts.map((attempt) => ({ ...attempt })),
770
+ shadowCertificates: this.shadowCertificates.map((cert) => ({ ...cert })),
771
+ substitutions: this.substitutions.map((cert) => ({ ...cert })),
772
+ guard: { matches: guardStats.matches, hits: guardStats.hits, hitRate: guardStats.hitRate, byReason: { ...guardStats.byReason } },
773
+ };
774
+ })(),
775
+ ...(() => {
776
+ // §6:工具面常驻成本 = 模块自报 + 实际注入 schema(chars/4)。
777
+ const moduleCost = this.modules.promptCost();
778
+ const schemas = this.registry.schemas();
779
+ const schemaChars = JSON.stringify(schemas).length;
780
+ return {
781
+ toolPrompt: {
782
+ moduleTokens: moduleCost.toolTokens,
783
+ moduleSchemas: moduleCost.schemaCount,
784
+ schemaTokens: Math.ceil(schemaChars / 4),
785
+ schemaCount: schemas.length,
786
+ },
787
+ };
788
+ })(),
789
+ },
349
790
  error,
350
791
  };
351
792
  }
@@ -372,9 +813,10 @@ export class SuperAgentWorker {
372
813
  }, this.limits.llmTimeoutMs);
373
814
  });
374
815
  const request = {
375
- model: this.model,
816
+ model: this.callModel ?? this.model,
376
817
  messages: this.history,
377
- tools: this.registry.schemas(),
818
+ // A2:只注入当前可见的工具 schema(常驻 + 已加载);关闭时不做任何过滤。
819
+ tools: this.registry.schemas().filter((schema) => this.disclosure.visible(this.registry.names()).includes(schema.name)),
378
820
  signal: controller.signal,
379
821
  };
380
822
  try {
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /**
4
+ * 3.3.0 · A1 归因拟合式(设计 §5.4,**冻结参数**)。
5
+ *
6
+ * 归因算法(工具面):
7
+ *
8
+ * savedTokens = baselineOutputTokens(toolName, 原始长度) − actualOutputTokens(紧凑长度)
9
+ *
10
+ * `baselineOutputTokens` 由本文件的**固定拟合式**给出,参数在
11
+ * `docs/DESIGN-v3.3.0-cost-optimization.md` §5.4.1 冻结;**变更参数必须同步升版本号
12
+ * 并在 CHANGELOG 声明**,否则 `test/worker-baseline-fit.test.ts` 会失败。
13
+ *
14
+ * 公式(v1):
15
+ *
16
+ * baseline = ceil((chars + overheadChars[toolName]) / charsPerToken)
17
+ * actual = ceil(chars / charsPerToken)
18
+ *
19
+ * 其中 overhead 只补偿工具回执的**结构性固定开销**(如 shell 回执的 `STDOUT:/STDERR:` 头),
20
+ * 不是「估算魔法」:它必须能从工具的固定输出模板直接数出来。
21
+ */
22
+ /** 拟合式版本;改参数必须同时改这里(测试会锁死这一约定)。 */
23
+ export const A1_FIT_VERSION = 'a1-fit-v1';
24
+ export const A1_FIT_PARAMS = Object.freeze({
25
+ version: A1_FIT_VERSION,
26
+ charsPerToken: 4,
27
+ overheadChars: Object.freeze({
28
+ fs_read_file: 0,
29
+ fs_list_dir: 0,
30
+ fs_write_file: 0,
31
+ // exec_shell_in_vm 的回执固定带 "STDOUT:\n"(8)+ "\nSTDERR:\n"(9)+ 末尾换行(1)
32
+ exec_shell_in_vm: 18,
33
+ }),
34
+ defaultOverheadChars: 0,
35
+ });
36
+ const nonNegative = (value) => {
37
+ const n = Number(value);
38
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
39
+ };
40
+ function overheadOf(toolName) {
41
+ const key = String(toolName ?? '');
42
+ const table = A1_FIT_PARAMS.overheadChars;
43
+ return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : A1_FIT_PARAMS.defaultOverheadChars;
44
+ }
45
+ /** baseline:不经 A1 时模型会看到的回执 token 数(拟合式,v1 冻结)。 */
46
+ export function baselineOutputTokens(toolName, rawChars) {
47
+ const chars = nonNegative(rawChars);
48
+ if (chars === 0)
49
+ return 0;
50
+ return Math.ceil((chars + overheadOf(toolName)) / A1_FIT_PARAMS.charsPerToken);
51
+ }
52
+ /** actual:紧凑后模型实际看到的回执 token 数。 */
53
+ export function actualOutputTokens(compactChars) {
54
+ const chars = nonNegative(compactChars);
55
+ if (chars === 0)
56
+ return 0;
57
+ return Math.ceil(chars / A1_FIT_PARAMS.charsPerToken);
58
+ }
59
+ /**
60
+ * 单次工具的归因节省(永不为负:紧凑后更长时记 0,并由调用方标 `applied: false`)。
61
+ */
62
+ export function attributedSavedTokens(toolName, rawChars, compactChars) {
63
+ const baseline = baselineOutputTokens(toolName, rawChars);
64
+ const actual = actualOutputTokens(compactChars);
65
+ return Math.max(0, baseline - actual);
66
+ }