@twinsearth/dsh-master 3.2.10 → 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.
- package/README.md +3 -0
- package/client.js +50 -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/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +312 -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 +171 -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 +277 -27
- package/dist-host/plugins/superagent-router.js +88 -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 +21 -0
- package/docs/CHANGELOG.md +50 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
- package/docs/EXPERIMENT-M5-real-ab.md +82 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +1 -1
- 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/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +316 -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 +170 -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 +271 -26
- package/src/plugins/superagent-router.ts +82 -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
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 {
|
|
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
|
-
|
|
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
|
}
|
package/worker/llm-http.ts
CHANGED
|
@@ -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)
|
package/worker/sink-http.ts
CHANGED
|
@@ -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
|
},
|
package/worker/spill.ts
ADDED
|
@@ -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
|
+
}
|