@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
package/worker/index.ts CHANGED
@@ -138,6 +138,7 @@ export function buildWorkerDeps(options: WorkerEnvOptions = {}): { ticket: Ticke
138
138
  limits: options.limits,
139
139
  model: String(options.model ?? env.WORKER_LLM_MODEL ?? '') || undefined,
140
140
  redactValues: secretValuesOf(env),
141
+ compactMode: String(env.DSH_MASTER_COMPACT_MODE ?? '') || undefined,
141
142
  },
142
143
  }
143
144
  }
@@ -151,6 +152,7 @@ function cordisSink(ctx: CordisLike): WorkerSink {
151
152
  step: (step) => { try { ctx.emit?.('superagent:step', step) } catch { /* ignore */ } },
152
153
  phase: (phase) => { try { ctx.emit?.('superagent:phase', { phase, at: Date.now() }) } catch { /* ignore */ } },
153
154
  stream: (delta) => { try { ctx.emit?.('superagent:stream', delta) } catch { /* ignore */ } },
155
+ cost: (event) => { try { ctx.emit?.('superagent:cost', event) } catch { /* ignore */ } },
154
156
  }
155
157
  }
156
158
 
@@ -166,17 +168,96 @@ function composeSink(local: WorkerSink, remote: WorkerSink | null): WorkerSink {
166
168
  try { (remoteFn as ((...a: unknown[]) => void) | undefined)?.(...args) } catch { /* ignore */ }
167
169
  }) as NonNullable<WorkerSink[K]>
168
170
  }
169
- return { log: wrap('log'), step: wrap('step'), phase: wrap('phase'), stream: wrap('stream') }
171
+ return { log: wrap('log'), step: wrap('step'), phase: wrap('phase'), stream: wrap('stream'), cost: wrap('cost') }
170
172
  }
171
173
 
172
174
  /** 归档真实 token 用量到 fork 内的 HarnessMM observe 服务;无该服务时不影响 Worker。 */
173
175
  export function recordWorkerCost(ctx: CordisLike, ticketId: string, result: WorkerResult): unknown {
174
176
  try {
175
- const cost = ctx.get?.('dshMasterCost') as { record?(input: Record<string, unknown>): unknown } | undefined
177
+ const cost = ctx.get?.('dshMasterCost') as {
178
+ record?(input: Record<string, unknown>): unknown
179
+ recordAttribution?(input: Record<string, unknown>): unknown
180
+ recordDegraded?(input: Record<string, unknown>): unknown
181
+ recordGovernance?(input: Record<string, unknown>): unknown
182
+ recordSubstitution?(input: Record<string, unknown>): unknown
183
+ } | undefined
176
184
  if (!cost || typeof cost.record !== 'function') return { ok: false, code: 'cost-service-unavailable' }
177
185
  const tokens = Math.max(0, Number(result.tokenUsage ?? 0) || 0)
186
+ const estimatedTokens = Math.max(0, Number(result.cost?.tokens?.estimated ?? 0) || 0)
178
187
  // 尚未接入 EGD/GDR 优化,优化前后相等,绝不虚报节省;只记录真实 token 与成功率。
179
- return cost.record({ taskId: ticketId, baselineTokens: tokens, optimizedTokens: tokens, success: result.ok === true })
188
+ // §5.1:实测与估算分别入账(estimatedTokens 由 Agent Loop 的兜底估算给出)。
189
+ const recorded = cost.record({
190
+ taskId: ticketId,
191
+ baselineTokens: tokens,
192
+ optimizedTokens: tokens,
193
+ estimatedTokens,
194
+ estimated: estimatedTokens > 0 && estimatedTokens >= tokens,
195
+ success: result.ok === true,
196
+ })
197
+ // 3.3.0 M2:A1 紧凑化的归因单独上报(shadow 只记「本可节省」,enforce 才入账)。
198
+ const compaction = result.cost?.compaction
199
+ let attribution: unknown = null
200
+ if (compaction && compaction.estimatedTokens > 0 && typeof cost.recordAttribution === 'function') {
201
+ attribution = cost.recordAttribution({
202
+ taskId: ticketId,
203
+ module: 'a1-compact',
204
+ savedTokens: compaction.estimatedTokens,
205
+ mode: compaction.mode,
206
+ meta: {
207
+ evaluated: compaction.evaluated,
208
+ applied: compaction.applied,
209
+ originalChars: compaction.originalChars,
210
+ compactChars: compaction.compactChars,
211
+ savedChars: compaction.savedChars,
212
+ },
213
+ })
214
+ }
215
+ // 目标 2.1-5:降级必须留痕(审计 + 事件总线),且绝不折算成节省。
216
+ let degraded: unknown = null
217
+ const degradedEvents = result.cost?.degraded?.events ?? []
218
+ const recordDegraded = cost.recordDegraded
219
+ if (degradedEvents.length > 0 && typeof recordDegraded === 'function') {
220
+ degraded = degradedEvents.map((event) => recordDegraded({
221
+ taskId: ticketId,
222
+ module: event.module,
223
+ phase: event.phase,
224
+ message: event.message,
225
+ count: event.count,
226
+ }))
227
+ }
228
+ // §6 A2:工具装载成本记 governance(与 execution 节省分开,报表并列)。
229
+ let governance: unknown = null
230
+ const disclosure = result.cost?.disclosure
231
+ const recordGovernance = cost.recordGovernance
232
+ if (disclosure && disclosure.loadEvents > 0 && disclosure.schemaTokens > 0 && typeof recordGovernance === 'function') {
233
+ governance = recordGovernance({
234
+ taskId: ticketId,
235
+ module: 'a2-disclosure',
236
+ tokens: disclosure.schemaTokens,
237
+ kind: 'tools.load',
238
+ meta: { loadEvents: disclosure.loadEvents, schemaCount: disclosure.schemaCount, loaded: disclosure.loaded },
239
+ })
240
+ }
241
+ // §7 B3:替换证书上报宿主(审计链 + 账本,用同一 taskId+step 关联)。
242
+ let substitutions: unknown = null
243
+ const certs = result.cost?.ccs?.substitutions ?? []
244
+ const recordSubstitution = cost.recordSubstitution
245
+ if (certs.length > 0 && typeof recordSubstitution === 'function') {
246
+ substitutions = certs.map((cert) => recordSubstitution({
247
+ edgeId: cert.edgeId,
248
+ toolName: cert.toolName,
249
+ schemaHash: cert.schemaHash,
250
+ guardExpr: cert.guardExpr,
251
+ receiptDigest: cert.receiptDigest,
252
+ replacedCallbackId: cert.replacedCallbackId,
253
+ viaNativeDispatcher: cert.viaNativeDispatcher,
254
+ taskId: cert.taskId || ticketId,
255
+ step: cert.step,
256
+ savedTokens: cert.savedTokens ?? 0,
257
+ moduleId: cert.moduleId ?? 'b3-ccs',
258
+ }))
259
+ }
260
+ return { recorded, attribution, degraded, governance, substitutions }
180
261
  } catch (error) {
181
262
  return { ok: false, code: 'cost-record-failed', error: String(error instanceof Error ? error.message : error) }
182
263
  }
@@ -11,6 +11,20 @@
11
11
 
12
12
  import type { ChatMessage, LLMClient, LLMRequest, LLMResponse, LLMStreamDelta, ToolCall } from './types.ts'
13
13
 
14
+ /**
15
+ * §11:从 provider 的 usage 里读缓存命中量。
16
+ * 兼容 `prompt_tokens_details.cached_tokens`(OpenAI 风格)与顶层 `cached_tokens`。
17
+ * **拿不到就返回 undefined**,不写 0 —— 否则报表会把"未知"伪装成"没有缓存命中"。
18
+ */
19
+ function readCachedTokens(usage: Record<string, unknown> | undefined): number | undefined {
20
+ if (!usage || typeof usage !== 'object') return undefined
21
+ const details = (usage as { prompt_tokens_details?: { cached_tokens?: unknown } }).prompt_tokens_details
22
+ const direct = (usage as { cached_tokens?: unknown }).cached_tokens
23
+ const raw = details?.cached_tokens ?? direct
24
+ if (raw === undefined || raw === null) return undefined
25
+ const value = Number(raw)
26
+ return Number.isFinite(value) && value >= 0 ? value : undefined
27
+ }
14
28
  export interface HttpLlmOptions {
15
29
  /** 例如 https://api.deepseek.com/v1;会拼接 /chat/completions。 */
16
30
  baseUrl: string
@@ -88,6 +102,8 @@ function fromWireResponse(payload: OpenAiResponse): LLMResponse {
88
102
  promptTokens: Number(payload.usage.prompt_tokens ?? 0),
89
103
  completionTokens: Number(payload.usage.completion_tokens ?? 0),
90
104
  totalTokens: Number(payload.usage.total_tokens ?? 0),
105
+ // §11:prompt cache 命中量(未返回就省略,不冒充 0 节省)。
106
+ ...(readCachedTokens(payload.usage) === undefined ? {} : { cachedTokens: readCachedTokens(payload.usage) }),
91
107
  }
92
108
  : undefined,
93
109
  finishReason: choice?.finish_reason,
@@ -147,10 +163,12 @@ function createStreamAccumulator(onDelta: (delta: LLMStreamDelta) => void) {
147
163
  if (typeof choice?.finish_reason === 'string') finishReason = choice.finish_reason
148
164
  if (chunk?.usage && typeof chunk.usage === 'object') {
149
165
  const raw = chunk.usage as Record<string, unknown>
166
+ const cached = readCachedTokens(raw)
150
167
  usage = {
151
168
  promptTokens: Number(raw.prompt_tokens ?? 0),
152
169
  completionTokens: Number(raw.completion_tokens ?? 0),
153
170
  totalTokens: Number(raw.total_tokens ?? 0),
171
+ ...(cached === undefined ? {} : { cachedTokens: cached }),
154
172
  }
155
173
  }
156
174
  return Boolean(chunk?.choices || chunk?.usage)
@@ -9,7 +9,7 @@
9
9
  * 网络失败不能影响 Agent Loop:队列满或请求失败时静默丢弃并记一条本地错误。
10
10
  */
11
11
 
12
- import type { WorkerPhase, WorkerResult, WorkerSink, WorkerStep } from './types.ts'
12
+ import type { CostHookEvent, WorkerPhase, WorkerResult, WorkerSink, WorkerStep } from './types.ts'
13
13
 
14
14
  export interface HttpSinkOptions {
15
15
  url: string
@@ -36,13 +36,15 @@ export interface HttpWorkerSink extends WorkerSink {
36
36
  step(step: WorkerStep): void
37
37
  stream(delta: { content?: string; toolName?: string }): void
38
38
  log(message: string): void
39
+ /** 3.3.0:成本切点事件,随普通批次发往 Router(与 step 同窗口)。 */
40
+ cost(event: CostHookEvent): void
39
41
  flush(): Promise<void>
40
42
  close(): Promise<void>
41
43
  done(result: WorkerResult): Promise<void>
42
44
  }
43
45
 
44
46
  interface SinkEvent {
45
- type: 'phase' | 'step' | 'stream' | 'log'
47
+ type: 'phase' | 'step' | 'stream' | 'log' | 'cost'
46
48
  payload: unknown
47
49
  at: number
48
50
  }
@@ -114,6 +116,7 @@ export function createHttpSink(options: HttpSinkOptions): HttpWorkerSink {
114
116
  step: (step) => enqueue({ type: 'step', payload: step, at: Date.now() }),
115
117
  stream: (delta) => enqueue({ type: 'stream', payload: delta, at: Date.now() }),
116
118
  log: (message) => enqueue({ type: 'log', payload: { message }, at: Date.now() }),
119
+ cost: (event) => enqueue({ type: 'cost', payload: event, at: Date.now() }),
117
120
  async flush() {
118
121
  while (queue.length > 0) await flush()
119
122
  },
@@ -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 · A1 spill 存储(设计 §6「强制约束」)。
6
+ *
7
+ * 「一次压缩后原文仍可经 spill 文件回读」——大输出先落盘,模型只看到摘要 + 回读路径,
8
+ * 避免「省 token 换瞎眼」。
9
+ *
10
+ * 安全边界:
11
+ * * 只能写在 `<workspace>/.dsh-master/spill/` 之内(路径由本模块生成,不接受外部传入);
12
+ * * 写入前由调用方**脱敏**(Agent Loop 传入已 redact 的文本);
13
+ * * 总量与单文件都有上限,超出后按最旧淘汰(有界,不会把分身磁盘写满);
14
+ * * 文件名只含 `ticketId` 的安全片段 + step + 序号,不反射工具参数。
15
+ */
16
+
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+
20
+ export interface SpillFile {
21
+ /** 相对 workspace 的路径(模型可直接用 fs_read_file 读)。 */
22
+ relPath: string
23
+ bytes: number
24
+ lines: number
25
+ }
26
+
27
+ export interface SpillStoreOptions {
28
+ workspace: string
29
+ /** 目录(相对 workspace);默认 `.dsh-master/spill`。 */
30
+ dir?: string
31
+ /** 单文件上限(字节);超过则拒绝落盘(避免把超大输出写爆磁盘)。 */
32
+ maxFileBytes?: number
33
+ /** 目录总量上限(字节);超出按最旧淘汰。 */
34
+ maxTotalBytes?: number
35
+ /** 保留文件数上限。 */
36
+ maxFiles?: number
37
+ clock?: () => number
38
+ }
39
+
40
+ export interface SpillStore {
41
+ /** 落盘一份原文;返回相对路径与体积。失败返回 null(调用方退回不落盘)。 */
42
+ save(input: { ticketId: string; stepIndex: number; toolName: string; text: string }): SpillFile | null
43
+ /** 读取已落盘的原文(测试/回读用)。 */
44
+ read(relPath: string): string | null
45
+ /** 目录统计。 */
46
+ stats(): { dir: string; files: number; bytes: number }
47
+ /** 清理本任务/全部(只在任务结束时调用)。 */
48
+ clear(): void
49
+ }
50
+
51
+ const safeSegment = (value: string, fallback: string): string => {
52
+ // 只保留字母数字与 _-:点号也剔除,确保文件名里不可能出现 `..` 片段。
53
+ const cleaned = String(value ?? '').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 48)
54
+ return cleaned === '' ? fallback : cleaned
55
+ }
56
+
57
+ export function createSpillStore(options: SpillStoreOptions): SpillStore {
58
+ const workspace = path.resolve(String(options.workspace))
59
+ const relDir = String(options.dir ?? path.join('.dsh-master', 'spill'))
60
+ const dir = path.resolve(workspace, relDir)
61
+ // 路径 jail:解析后必须仍在 workspace 内。
62
+ const inside = dir === workspace || dir.startsWith(`${workspace}${path.sep}`)
63
+ const maxFileBytes = Math.max(1024, Number(options.maxFileBytes ?? 512 * 1024) || 512 * 1024)
64
+ const maxTotalBytes = Math.max(maxFileBytes, Number(options.maxTotalBytes ?? 8 * 1024 * 1024) || 8 * 1024 * 1024)
65
+ const maxFiles = Math.max(1, Number(options.maxFiles ?? 40) || 40)
66
+ const clock = typeof options.clock === 'function' ? options.clock : () => Date.now()
67
+ let seq = 0
68
+
69
+ function listFiles(): Array<{ name: string; size: number; at: number }> {
70
+ try {
71
+ return fs.readdirSync(dir)
72
+ .map((name) => {
73
+ try {
74
+ const stat = fs.statSync(path.join(dir, name))
75
+ return stat.isFile() ? { name, size: stat.size, at: stat.mtimeMs } : null
76
+ } catch { return null }
77
+ })
78
+ .filter((item): item is { name: string; size: number; at: number } => item !== null)
79
+ } catch { return [] }
80
+ }
81
+
82
+ /** 淘汰:文件数 / 总字节超限时按最旧删除。 */
83
+ function evict(): void {
84
+ const files = listFiles().sort((a, b) => a.at - b.at)
85
+ let total = files.reduce((sum, file) => sum + file.size, 0)
86
+ let count = files.length
87
+ for (const file of files) {
88
+ if (count <= maxFiles && total <= maxTotalBytes) break
89
+ try { fs.rmSync(path.join(dir, file.name), { force: true }) } catch { /* ignore */ }
90
+ total -= file.size
91
+ count -= 1
92
+ }
93
+ }
94
+
95
+ function save(input: { ticketId: string; stepIndex: number; toolName: string; text: string }): SpillFile | null {
96
+ if (!inside) return null
97
+ const text = String(input?.text ?? '')
98
+ if (text.length === 0) return null
99
+ const bytes = Buffer.byteLength(text, 'utf8')
100
+ seq += 1
101
+ const name = `${safeSegment(input?.ticketId ?? '', 'ticket')}__s${Number(input?.stepIndex) || 0}__${seq}__${safeSegment(input?.toolName ?? 'tool', 'tool')}.txt`
102
+ const abs = path.resolve(dir, name)
103
+ if (!abs.startsWith(`${dir}${path.sep}`)) return null
104
+ try {
105
+ fs.mkdirSync(dir, { recursive: true })
106
+ // 单文件超限:拒绝落盘(调用方按「无法回读」降级,不静默截断原文)。
107
+ if (bytes > maxFileBytes) return null
108
+ fs.writeFileSync(abs, text, 'utf8')
109
+ evict()
110
+ return { relPath: path.relative(workspace, abs).split(path.sep).join('/'), bytes, lines: text.split('\n').length }
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ function read(relPath: string): string | null {
117
+ const abs = path.resolve(workspace, String(relPath ?? ''))
118
+ if (!abs.startsWith(`${workspace}${path.sep}`)) return null
119
+ try { return fs.readFileSync(abs, 'utf8') } catch { return null }
120
+ }
121
+
122
+ function stats() {
123
+ const files = listFiles()
124
+ return { dir: path.relative(workspace, dir).split(path.sep).join('/'), files: files.length, bytes: files.reduce((sum, file) => sum + file.size, 0) }
125
+ }
126
+
127
+ function clear(): void {
128
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
129
+ void clock
130
+ }
131
+
132
+ return Object.freeze({ save, read, stats, clear })
133
+ }
@@ -0,0 +1,124 @@
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 · A3 `tool-batch`(批量调用,设计 §6)。
6
+ *
7
+ * 形态借鉴 dsh-ops 的 `batchingGuidance`,但**只做引导与纠偏**:
8
+ * * **首轮一次教学**:把「可以在同一轮里发多个 tool_calls,它们按序执行」写进**历史**
9
+ * (一条 user 消息),**不改系统提示词**;
10
+ * * 之后只做**形状纠偏**(例如把多个操作塞进一个工具的数组参数、或一轮超出批量上限):
11
+ * 追加一条提示,**不阻断、不改写模型程序**;
12
+ * * 批量执行**复用现有工具实现**(`registry.execute`),不新开旁路;
13
+ * * 不支持 PTC 执行模式时,模块整体 `skipped: unsupported-host`(显式状态,不静默)。
14
+ *
15
+ * A 组共同底线:任何压缩/隐藏/批量都**不得改变工具的可观察语义** ——
16
+ * 本模块从不改写 tool_call、从不拦截执行,只往历史里追加文字。
17
+ */
18
+
19
+
20
+
21
+ export type BatchMode = 'native' | 'ptc'
22
+
23
+ export interface BatchOptions {
24
+ enabled?: boolean
25
+ /** 期望的执行模式;本项目 Worker 是原生多调用(native),PTC 未实现。 */
26
+ mode?: BatchMode
27
+ /** 单轮批量上限(与 WorkerLimits.maxToolCallsPerStep 对齐)。 */
28
+ maxBatchPerTurn?: number
29
+ }
30
+
31
+ export interface BatchStatus {
32
+ /** `active` = 引导与纠偏生效;`skipped` = 不做任何事(显式给出原因)。 */
33
+ state: 'active' | 'skipped'
34
+ reason: string | null
35
+ mode: BatchMode
36
+ maxBatchPerTurn: number
37
+ }
38
+
39
+ /** 归一化后的工具调用(与 Agent Loop 的 validateToolCalls 输出一致)。 */
40
+ export interface BatchCallView {
41
+ id: string
42
+ name: string
43
+ /** 原始 JSON 参数字符串(本模块只读,不改写)。 */
44
+ args: string
45
+ }
46
+
47
+ export interface BatchAdvisor {
48
+ status(): BatchStatus
49
+ /** 首轮教学文本(进历史;不含任何工具语义改写)。 */
50
+ guidance(): string
51
+ /**
52
+ * 形状纠偏:返回**要追加的提示文本**(不阻断执行)。
53
+ * 合规时返回 null。
54
+ */
55
+ review(calls: BatchCallView[]): { ok: boolean; correction: string | null; reason: string | null }
56
+ /** 本任务是否已教学过。 */
57
+ taught(): boolean
58
+ markTaught(): void
59
+ stats(): { taught: boolean; corrections: number }
60
+ }
61
+
62
+ /** 单次批量教学文本(一次性;之后不再重复注入)。 */
63
+ export const BATCH_GUIDANCE = [
64
+ '【批量调用提示(一次性)】',
65
+ '你可以在同一轮里返回多个 tool_calls:它们会按返回顺序依次执行,每个结果都会回灌给你。',
66
+ '把互不依赖的读取/检索/写入合并到同一轮,能减少往返次数;有依赖时必须分轮。',
67
+ '不要把一个列表塞进单个工具的参数里伪装成批量(例如把多个路径写进一个 path 字段);',
68
+ '请直接发多个 tool_calls。',
69
+ ].join('\n')
70
+
71
+ export function createBatchAdvisor(options: BatchOptions = {}): BatchAdvisor {
72
+ const mode: BatchMode = options.mode === 'ptc' ? 'ptc' : 'native'
73
+ const maxBatchPerTurn = Math.max(1, Number(options.maxBatchPerTurn ?? 4) || 4)
74
+ const enabled = options.enabled !== false
75
+ let taught = false
76
+ let corrections = 0
77
+
78
+ // 本项目 Worker 走 chat/HTTP 工具回路,没有 PTC(程序化工具调用)执行器:
79
+ // 请求 PTC ⇒ 显式 skipped,而不是偷偷降级成 native。
80
+ const unsupported = mode === 'ptc'
81
+ const status = (): BatchStatus => (enabled && !unsupported
82
+ ? { state: 'active', reason: null, mode, maxBatchPerTurn }
83
+ : { state: 'skipped', reason: unsupported ? 'unsupported-host' : 'disabled-by-config', mode, maxBatchPerTurn })
84
+
85
+ function review(calls: BatchCallView[]): { ok: boolean; correction: string | null; reason: string | null } {
86
+ if (status().state !== 'active') return { ok: true, correction: null, reason: null }
87
+ const list = Array.isArray(calls) ? calls : []
88
+ if (list.length > maxBatchPerTurn) {
89
+ corrections += 1
90
+ return {
91
+ ok: false,
92
+ reason: 'batch-too-large',
93
+ correction: `【批量形状纠偏】本轮 ${list.length} 个 tool_calls 超过单轮上限 ${maxBatchPerTurn};已全部按序执行,下一轮请拆分。`,
94
+ }
95
+ }
96
+ // 形状纠偏:单个工具的参数里出现「看起来像批量」的数组(>=2 项字符串),提示改发多个 tool_calls。
97
+ for (const call of list) {
98
+ let args: Record<string, unknown> = {}
99
+ try {
100
+ const parsed = JSON.parse(String(call?.args ?? '{}'))
101
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed as Record<string, unknown>
102
+ } catch { continue }
103
+ const arrayish = Object.entries(args).filter(([, value]) => Array.isArray(value) && value.length >= 2 && value.every((item) => typeof item === 'string'))
104
+ if (arrayish.length > 0 && list.length === 1) {
105
+ corrections += 1
106
+ return {
107
+ ok: false,
108
+ reason: 'array-faked-batch',
109
+ correction: `【批量形状纠偏】把多个值塞进了 ${call.name} 的 ${arrayish.map(([key]) => key).join('/')} 参数;这不会被执行器当成批量。下一轮请改为在同一轮返回多个 tool_calls。`,
110
+ }
111
+ }
112
+ }
113
+ return { ok: true, correction: null, reason: null }
114
+ }
115
+
116
+ return Object.freeze({
117
+ status,
118
+ guidance: () => BATCH_GUIDANCE,
119
+ review,
120
+ taught: () => taught,
121
+ markTaught: () => { taught = true },
122
+ stats: () => ({ taught, corrections }),
123
+ })
124
+ }