@twinsearth/dsh-master 0.0.0-stage → 3.2.9

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 (97) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +93 -2
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +389 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/RELEASE-v3.2.8.md +27 -0
  45. package/docs/RELEASE-v3.2.9.md +29 -0
  46. package/docs/REVIEW-v3.2.7.md +121 -0
  47. package/docs/ROADMAP.md +106 -0
  48. package/docs/SECURITY.md +153 -0
  49. package/docs/SMOKE-3.2.5.md +77 -0
  50. package/docs/SMOKE-3.2.7.md +73 -0
  51. package/docs/SUPERAGENT.md +141 -0
  52. package/docs/WORKER-AGENT-LOOP.md +139 -0
  53. package/icon.svg +9 -0
  54. package/locale/en.json +6 -0
  55. package/locale/zh.json +6 -0
  56. package/package.json +79 -4
  57. package/src/contract/client.js +86 -0
  58. package/src/contract/constants.js +79 -0
  59. package/src/contract/envelope.js +83 -0
  60. package/src/contract/index.js +19 -0
  61. package/src/contract/negotiate.js +106 -0
  62. package/src/contract/server.js +77 -0
  63. package/src/contract/transports.js +319 -0
  64. package/src/index.ts +265 -0
  65. package/src/kernel.ts +190 -0
  66. package/src/plugins/cost-optimizer.ts +155 -0
  67. package/src/plugins/dual-manager.ts +252 -0
  68. package/src/plugins/superagent-router.ts +465 -0
  69. package/src/plugins/superagent-worker.ts +85 -0
  70. package/src/superagent/bridge-tokens.ts +236 -0
  71. package/src/superagent/dispatch.ts +197 -0
  72. package/src/superagent/feishu.ts +104 -0
  73. package/src/superagent/github.ts +111 -0
  74. package/src/superagent/pr-delivery.ts +308 -0
  75. package/src/superagent/router.ts +201 -0
  76. package/src/superagent/stream-adapter.ts +184 -0
  77. package/src/superagent/stream-tokens.ts +142 -0
  78. package/src/superagent/ticket-store.ts +104 -0
  79. package/src/superagent/trust.ts +139 -0
  80. package/src/superagent/types.ts +135 -0
  81. package/src/superagent/worker.ts +100 -0
  82. package/src/superagent/writeback.ts +154 -0
  83. package/tsconfig.host.build.json +12 -0
  84. package/tsconfig.host.json +21 -0
  85. package/tsconfig.worker.build.json +13 -0
  86. package/tsconfig.worker.json +17 -0
  87. package/types/superagent.d.ts +99 -0
  88. package/worker/agent-loop.ts +524 -0
  89. package/worker/errors.ts +47 -0
  90. package/worker/github-pr.ts +65 -0
  91. package/worker/index.ts +334 -0
  92. package/worker/llm-http.ts +359 -0
  93. package/worker/main.ts +45 -0
  94. package/worker/prompt.ts +39 -0
  95. package/worker/sink-http.ts +159 -0
  96. package/worker/tools.ts +531 -0
  97. package/worker/types.ts +247 -0
@@ -0,0 +1,524 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * SuperAgent Worker 的 Agent Loop(v3.2.2)。
6
+ *
7
+ * 状态机:
8
+ * initializing → thinking → (executing_tool → thinking)* → reporting → done
9
+ * └→ failed(任何不可恢复错误)
10
+ *
11
+ * 为什么是 while(true):LLM 的“该不该继续”只由模型输出(是否还有 tool_calls)决定,
12
+ * 循环条件本身不带业务判断;所有退出都必须经过下面的硬约束之一:
13
+ * 1. 模型不再返回 tool_calls → reporting/done;
14
+ * 2. maxSteps 用尽 → failed(max-steps);
15
+ * 3. maxTokens 用尽 → failed(token-budget-exceeded);
16
+ * 4. maxWallClockMs 用尽 → failed(wall-clock-exceeded);
17
+ * 5. 连续相同工具调用超过阈值 → failed(dead-loop-detected);
18
+ * 6. LLM/上下文/工具不可恢复错误 → failed(<code>)。
19
+ *
20
+ * 工具失败不会抛穿循环:作为 `执行报错: …` 文本回灌给模型,让模型自行纠错;
21
+ * 只有预算/循环/墙钟这类“停机条件”才终止整个 Worker。
22
+ */
23
+
24
+ import type {
25
+ ChatMessage,
26
+ LLMResponse,
27
+ RouterTicketLike,
28
+ TicketPayload,
29
+ WorkerDeps,
30
+ WorkerLimits,
31
+ WorkerPhase,
32
+ WorkerResult,
33
+ WorkerSnapshot,
34
+ WorkerStep,
35
+ } from './types.ts'
36
+ import { DEFAULT_LIMITS } from './types.ts'
37
+ import { WorkerError, WorkerLimitError, WorkerTicketError, toFailure } from './errors.ts'
38
+ import { buildSystemPrompt, buildUserPrompt } from './prompt.ts'
39
+ import { createToolRegistry } from './tools.ts'
40
+
41
+ /**
42
+ * 周期检测(v3.2.7 审计收口):识别「重复序列」而不是只看连续相同。
43
+ *
44
+ * 对最近窗口内**末尾**的序列做判定:若长度 p(1..maxPeriod)的块连续重复 ≥minRepeats 次,
45
+ * 即判定为同一循环。这样 A/B/A/B、A/B/C/A/B/C 这类交替都能在第 minRepeats 个周期就被抓住,
46
+ * 不依赖"连续相同"或宽松阈值。
47
+ *
48
+ * @param {string[]} signatures 最近调用签名(按时间顺序)
49
+ * @param {{minRepeats?:number, maxPeriod?:number}} [opts]
50
+ * @returns {{period:number, repeats:number, sample:string} | null}
51
+ */
52
+ export function detectRepeatingCycle(
53
+ signatures: string[],
54
+ opts: { minRepeats?: number; maxPeriod?: number; maxIdenticalRepeats?: number } = {},
55
+ ): { period: number; repeats: number; sample: string } | null {
56
+ const maxPeriod = Math.max(1, Number(opts.maxPeriod ?? 8) || 8)
57
+ const list = Array.isArray(signatures) ? signatures : []
58
+ for (let period = 1; period <= maxPeriod; period += 1) {
59
+ // 周期 1(纯连续相同)必须遵守规格:> maxIdenticalToolRepeats 才算死循环;
60
+ // 周期 >= 2 用 3 个完整周期(minRepeats),用于抓 A/B/A/B 之类的交替。
61
+ const minRepeats = period === 1
62
+ ? Math.max(2, Number(opts.maxIdenticalRepeats ?? 3) + 1)
63
+ : Math.max(2, Number(opts.minRepeats ?? (Number(opts.maxIdenticalRepeats ?? 3) + 1)) || 3)
64
+ const need = period * minRepeats
65
+ if (list.length < need) continue
66
+ const tail = list.slice(-need)
67
+ const block = tail.slice(0, period)
68
+ let same = true
69
+ for (let i = period; i < need; i += 1) {
70
+ if (tail[i] !== block[i % period]) { same = false; break }
71
+ }
72
+ if (same) return { period, repeats: minRepeats, sample: block.join(' | ') }
73
+ }
74
+ return null
75
+ }
76
+
77
+ const ABSOLUTE_LIMIT_CEILING: WorkerLimits = {
78
+ maxSteps: 200,
79
+ maxTokens: 10_000_000,
80
+ maxWallClockMs: 24 * 60 * 60_000,
81
+ llmTimeoutMs: 10 * 60_000,
82
+ toolTimeoutMs: 10 * 60_000,
83
+ maxToolCallsPerStep: 32,
84
+ maxIdenticalToolRepeats: 100,
85
+ maxToolResultChars: 100_000,
86
+ maxContextChars: 5_000_000,
87
+ }
88
+
89
+ function mergeLimits(partial: Partial<WorkerLimits> | undefined): WorkerLimits {
90
+ const out: WorkerLimits = { ...DEFAULT_LIMITS }
91
+ if (partial && typeof partial === 'object') {
92
+ for (const key of Object.keys(DEFAULT_LIMITS) as (keyof WorkerLimits)[]) {
93
+ const value = partial[key]
94
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
95
+ out[key] = Math.min(Math.floor(value), ABSOLUTE_LIMIT_CEILING[key])
96
+ }
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** 通用的 secret 脱敏(步骤/日志/最终报告);具体 key 值由 redactValues 兜底。 */
103
+ export function redactSecrets(text: string, extraValues: string[] = []): string {
104
+ let out = String(text ?? '')
105
+ for (const value of extraValues) {
106
+ const secret = String(value ?? '')
107
+ if (secret.length >= 8) out = out.split(secret).join('[REDACTED]')
108
+ }
109
+ return out
110
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]')
111
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{10,}\b/g, '[REDACTED]')
112
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{10,}\b/g, '[REDACTED]')
113
+ .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
114
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [REDACTED]')
115
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]')
116
+ }
117
+
118
+ /**
119
+ * 兼容 v3.2.1 Router Ticket 与 v3.2.2 TicketPayload。
120
+ * Router 的 mandate.scope 是 threadId(用于派发/授权快照),Worker 的
121
+ * mandate.scope 需要是 workspace 内路径;这里若发现 scope 非路径则保留
122
+ * Router 原 scope 并让工具层退回 workspace(见 tools.resolveMandateRoot)。
123
+ */
124
+ export function normalizeTicket(input: RouterTicketLike | TicketPayload | Record<string, unknown>): TicketPayload {
125
+ const raw = input && typeof input === 'object' ? input as Record<string, unknown> : {}
126
+ const mandateRaw = raw.mandate && typeof raw.mandate === 'object' ? raw.mandate as Record<string, unknown> : {}
127
+ const ticketId = String(raw.ticketId ?? raw.id ?? '').trim()
128
+ const source = String(raw.source ?? raw.sourceId ?? '').trim()
129
+ const taskDescription = String(raw.taskDescription ?? raw.payload ?? '').trim()
130
+ const scope = String(mandateRaw.scope ?? '').trim()
131
+ if (!ticketId) throw new WorkerTicketError('ticketId is required')
132
+ if (!source) throw new WorkerTicketError('source is required')
133
+ if (!taskDescription) throw new WorkerTicketError('taskDescription is required')
134
+ if (!scope) throw new WorkerTicketError('mandate.scope is required')
135
+ return {
136
+ ticketId,
137
+ source,
138
+ taskDescription,
139
+ mandate: {
140
+ scope,
141
+ allowShell: mandateRaw.allowShell === true,
142
+ policyVersion: mandateRaw.policyVersion === undefined ? 'v1.0' : String(mandateRaw.policyVersion),
143
+ strength: mandateRaw.strength === 'explicit' || mandateRaw.strength === 'inferred' ? mandateRaw.strength : undefined,
144
+ },
145
+ }
146
+ }
147
+
148
+ /** 校验 LLM 返回的 tool_calls 结构;malformed 的调用仍回灌错误文本给模型。 */
149
+ function validateToolCalls(response: LLMResponse): Array<{ id: string; name: string; args: string }> {
150
+ const message = response?.message ?? { role: 'assistant', content: '' }
151
+ const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []
152
+ return calls.map((call, index) => {
153
+ const id = String(call?.id ?? `call_${index + 1}`)
154
+ const fn = call?.function && typeof call.function === 'object' ? call.function : undefined
155
+ return {
156
+ id,
157
+ name: String(fn?.name ?? ''),
158
+ args: String(fn?.arguments ?? '{}'),
159
+ }
160
+ })
161
+ }
162
+
163
+ export class SuperAgentWorker {
164
+ llm: WorkerDeps['llm']
165
+ tools: WorkerDeps['tools']
166
+ workspace: string
167
+ baseMandate: WorkerDeps['mandate']
168
+ logger: WorkerDeps['logger']
169
+ sink: WorkerDeps['sink']
170
+ limits: WorkerLimits
171
+ clock: () => number
172
+ model: string | undefined
173
+ redactValues: string[]
174
+ effectiveMandate: TicketPayload['mandate']
175
+
176
+ steps: WorkerStep[] = []
177
+ tokenUsage = 0
178
+ phase: WorkerPhase = 'initializing'
179
+ startedAt = 0
180
+ finalAnswer = ''
181
+ private history: ChatMessage[] = []
182
+ private prDelivery: WorkerResult['pr'] | undefined
183
+ /** 本步是否已收到 provider 的真实流式 delta(true 时不再分块模拟)。 */
184
+ private streamedThisStep = false
185
+ private registry: ReturnType<typeof createToolRegistry>
186
+
187
+ constructor(deps: WorkerDeps) {
188
+ if (!deps || !deps.llm || typeof deps.llm.chat !== 'function') throw new Error('SuperAgentWorker requires deps.llm.chat')
189
+ if (!deps.workspace) throw new Error('SuperAgentWorker requires deps.workspace')
190
+ this.llm = deps.llm
191
+ this.tools = deps.tools ?? []
192
+ this.workspace = deps.workspace
193
+ this.baseMandate = deps.mandate
194
+ this.effectiveMandate = deps.mandate
195
+ this.logger = deps.logger
196
+ this.sink = deps.sink
197
+ this.limits = mergeLimits(deps.limits)
198
+ this.clock = typeof deps.clock === 'function' ? deps.clock : () => Date.now()
199
+ this.model = deps.model
200
+ this.redactValues = Array.isArray(deps.redactValues) ? deps.redactValues.filter((value) => typeof value === 'string') : []
201
+ this.registry = createToolRegistry(this.tools)
202
+ }
203
+
204
+ private redact(text: string): string {
205
+ return redactSecrets(text, this.redactValues)
206
+ }
207
+
208
+ /** 主入口:永远返回 WorkerResult(不把内部异常抛给宿主/Router)。 */
209
+ async execute(input: RouterTicketLike | TicketPayload | Record<string, unknown>): Promise<WorkerResult> {
210
+ this.startedAt = this.clock()
211
+ this.steps = []
212
+ this.tokenUsage = 0
213
+ this.finalAnswer = ''
214
+ this.prDelivery = undefined
215
+ let ticket: TicketPayload
216
+ try {
217
+ ticket = normalizeTicket(input)
218
+ this.prDelivery = (input as { pr?: WorkerResult['pr'] }).pr
219
+ this.ticketId = ticket.ticketId
220
+ // v3.2.3:Ticket 的 Mandate 必须与构造 Worker 时的授权一致;不一致直接拒绝。
221
+ if (String(ticket.mandate.scope) !== String(this.baseMandate.scope)) {
222
+ throw new WorkerError('mandate-mismatch', 'ticket mandate.scope does not match the worker mandate')
223
+ }
224
+ // 权限取交集:Ticket 可以进一步收窄,但不能扩大构造时的授权。
225
+ this.effectiveMandate = {
226
+ ...ticket.mandate,
227
+ allowShell: this.baseMandate.allowShell === true && ticket.mandate.allowShell === true,
228
+ }
229
+ } catch (error) {
230
+ const failure = toFailure(error)
231
+ this.setPhase('failed')
232
+ this.record({ type: 'error', content: `${failure.code}: ${failure.message}` })
233
+ return this.result(ticketIdOf(input), false, failure)
234
+ }
235
+
236
+ this.history = [
237
+ { role: 'system', content: buildSystemPrompt(ticket) },
238
+ { role: 'user', content: buildUserPrompt(ticket) },
239
+ ]
240
+
241
+ try {
242
+ this.setPhase('initializing')
243
+ this.record({ type: 'phase', content: 'initializing' })
244
+ let stepIndex = 0
245
+ // 规格判定:连续相同工具调用 > maxIdenticalToolRepeats → dead-loop。
246
+ let lastSignature = ''
247
+ let signatureRepeats = 0
248
+ // 补充判定(P2 审计):滑动窗口内的同签名次数,用于抓 A/B/A/B 交替;
249
+ // 阈值取 max(6, 2×连续阈值),比规格更宽松,避免误杀“合法重复几次再换”的行为。
250
+ const signatureWindow: string[] = []
251
+ const WINDOW_SIZE = 40
252
+ const WINDOW_THRESHOLD = Math.max(6, Number(this.limits.maxIdenticalToolRepeats) * 2)
253
+ let totalToolCalls = 0
254
+ const maxTotalToolCalls = Math.min(
255
+ this.limits.maxSteps * this.limits.maxToolCallsPerStep,
256
+ 1000,
257
+ )
258
+
259
+ // eslint-disable-next-line no-constant-condition
260
+ while (true) {
261
+ this.assertWallClock()
262
+ if (stepIndex >= this.limits.maxSteps) {
263
+ throw new WorkerLimitError('max-steps', `Worker 超出最大循环次数 (${this.limits.maxSteps}),判定为死循环,强制终止。`)
264
+ }
265
+ stepIndex += 1
266
+ this.setPhase('thinking')
267
+ this.log(`[Worker] === 循环 Step ${stepIndex}/${this.limits.maxSteps} ===`)
268
+ this.assertContextBudget()
269
+
270
+ const response = await this.callLLM()
271
+ const rawContent = String(response.message?.content ?? '')
272
+ if (rawContent.length > this.limits.maxContextChars) {
273
+ throw new WorkerLimitError('context-limit-exceeded', `单条模型响应长度 ${rawContent.length} 超过上限 ${this.limits.maxContextChars}`)
274
+ }
275
+ const usage = response.usage
276
+ let used = Number(usage?.totalTokens ?? ((usage?.promptTokens ?? 0) + (usage?.completionTokens ?? 0))) || 0
277
+ if (used <= 0) {
278
+ // provider 不返回 usage 时的兜底估算,防止 Token 预算被绕过。
279
+ used = Math.ceil((JSON.stringify(this.history).length + rawContent.length) / 4)
280
+ }
281
+ this.tokenUsage += used
282
+ if (this.tokenUsage > this.limits.maxTokens) {
283
+ throw new WorkerLimitError('token-budget-exceeded', `超出最大 Token 预算 (${this.limits.maxTokens}),强制终止。`)
284
+ }
285
+
286
+ const assistantMessage: ChatMessage = {
287
+ role: 'assistant',
288
+ content: this.redact(rawContent),
289
+ tool_calls: Array.isArray(response.message?.tool_calls) ? response.message.tool_calls : undefined,
290
+ }
291
+ this.history.push(assistantMessage)
292
+
293
+ if (assistantMessage.content) {
294
+ this.stream(assistantMessage.content)
295
+ this.record({ type: 'thought', content: assistantMessage.content })
296
+ }
297
+
298
+ const toolCalls = validateToolCalls(response)
299
+ if (toolCalls.length === 0) {
300
+ this.finalAnswer = String(assistantMessage.content ?? '').trim() || '任务执行完成,无返回内容。'
301
+ this.setPhase('reporting')
302
+ this.record({ type: 'final_answer', content: this.finalAnswer })
303
+ this.setPhase('done')
304
+ return this.result(ticket.ticketId, true)
305
+ }
306
+ if (toolCalls.length > this.limits.maxToolCallsPerStep) {
307
+ throw new WorkerLimitError('too-many-tool-calls', `单轮工具调用数 ${toolCalls.length} 超过上限 ${this.limits.maxToolCallsPerStep}`)
308
+ }
309
+
310
+ for (const call of toolCalls) {
311
+ const signature = `${call.name}:${call.args}`
312
+ totalToolCalls += 1
313
+ if (totalToolCalls > maxTotalToolCalls) {
314
+ throw new WorkerLimitError('tool-call-budget-exceeded', `工具调用总数 ${totalToolCalls} 超过上限 ${maxTotalToolCalls},判定为失控循环。`)
315
+ }
316
+ // ① 规格判定:连续相同工具调用(同名+同参)超过阈值
317
+ if (signature === lastSignature) {
318
+ signatureRepeats += 1
319
+ } else {
320
+ lastSignature = signature
321
+ signatureRepeats = 1
322
+ }
323
+ if (signatureRepeats > this.limits.maxIdenticalToolRepeats) {
324
+ throw new WorkerLimitError('dead-loop-detected', `连续重复调用同一工具 ${signatureRepeats} 次(${call.name}),判定为死循环。`)
325
+ }
326
+ // ② 补充判定:滑动窗口内同签名次数(不要求连续,A/B/A/B 交替也会累计)
327
+ signatureWindow.push(signature)
328
+ if (signatureWindow.length > WINDOW_SIZE) signatureWindow.shift()
329
+ const sameInWindow = signatureWindow.filter((item) => item === signature).length
330
+ if (sameInWindow > WINDOW_THRESHOLD) {
331
+ throw new WorkerLimitError('dead-loop-detected', `最近 ${signatureWindow.length} 次调用中同一工具/参数出现 ${sameInWindow} 次(${call.name}),判定为死循环。`)
332
+ }
333
+ // ③ 周期判定(cycle-dead-loop):A/B/A/B、A/B/C/A/B/C 交替在第 3 个周期即判死循环
334
+ const cycle = detectRepeatingCycle(signatureWindow, { maxPeriod: 8, maxIdenticalRepeats: this.limits.maxIdenticalToolRepeats })
335
+ if (cycle) {
336
+ throw new WorkerLimitError('dead-loop-detected', `检测到周期为 ${cycle.period} 的重复调用序列(重复 ${cycle.repeats} 次:[${cycle.sample}]),判定为死循环(cycle-dead-loop)。`)
337
+ }
338
+
339
+ this.record({ type: 'tool_call', content: call.args, toolName: call.name, toolArgs: safeParseArgs(call.args) })
340
+ this.setPhase('executing_tool')
341
+ this.stream(undefined, call.name)
342
+ const result = await this.executeTool(call)
343
+ const safeResult = this.redact(result)
344
+ const truncated = safeResult.length > this.limits.maxToolResultChars
345
+ ? safeResult.slice(0, this.limits.maxToolResultChars) + `\n…[工具结果截断,原长 ${safeResult.length} 字符]`
346
+ : safeResult
347
+ this.record({ type: 'tool_result', content: truncated, toolName: call.name })
348
+ this.history.push({
349
+ role: 'tool',
350
+ tool_call_id: call.id,
351
+ name: call.name,
352
+ content: truncated,
353
+ })
354
+ this.setPhase('thinking')
355
+ }
356
+ // 循环继续:带着 tool 结果再次询问模型。
357
+ }
358
+ } catch (error) {
359
+ const failure = toFailure(error)
360
+ this.setPhase('failed')
361
+ this.log(`[Worker] 循环终止: ${failure.code} ${failure.message}`)
362
+ this.record({ type: 'error', content: `${failure.code}: ${failure.message}` })
363
+ return this.result(ticket.ticketId, false, failure)
364
+ }
365
+ }
366
+
367
+ /** 最近一步(供宿主轮询推送)。 */
368
+ getLatestStep(): WorkerStep | null {
369
+ return this.steps.length > 0 ? this.steps[this.steps.length - 1] : null
370
+ }
371
+
372
+ /** 崩溃恢复现场:把已走步骤/预算一起交给 Router/会话仓。 */
373
+ snapshot(): WorkerSnapshot {
374
+ return {
375
+ ticketId: this.ticketId,
376
+ phase: this.phase,
377
+ steps: this.steps.map((step) => ({ ...step })),
378
+ tokenUsage: this.tokenUsage,
379
+ elapsedMs: Math.max(0, this.clock() - this.startedAt),
380
+ finalAnswer: this.finalAnswer,
381
+ }
382
+ }
383
+
384
+ private ticketId = ''
385
+
386
+ private result(ticketId: string, ok: boolean, error?: { code: string; message: string; fatal: boolean }): WorkerResult {
387
+ return {
388
+ ok,
389
+ ticketId,
390
+ phase: this.phase,
391
+ finalAnswer: this.finalAnswer,
392
+ steps: this.steps.map((step) => ({ ...step })),
393
+ tokenUsage: this.tokenUsage,
394
+ elapsedMs: Math.max(0, this.clock() - this.startedAt),
395
+ ...(this.prDelivery ? { pr: this.prDelivery } : {}),
396
+ error,
397
+ }
398
+ }
399
+
400
+ private assertWallClock(): void {
401
+ const elapsed = this.clock() - this.startedAt
402
+ if (elapsed > this.limits.maxWallClockMs) {
403
+ throw new WorkerLimitError('wall-clock-exceeded', `Worker 超出最大墙钟时间 (${this.limits.maxWallClockMs}ms)`)
404
+ }
405
+ }
406
+
407
+ private assertContextBudget(): void {
408
+ const chars = JSON.stringify(this.history).length
409
+ if (chars > this.limits.maxContextChars) {
410
+ throw new WorkerLimitError('context-limit-exceeded', `上下文长度 ${chars} 超过上限 ${this.limits.maxContextChars},停止以避免 OOM/超长请求。`)
411
+ }
412
+ }
413
+
414
+ private async callLLM(): Promise<LLMResponse> {
415
+ const controller = new AbortController()
416
+ let timer: ReturnType<typeof setTimeout> | undefined
417
+ this.streamedThisStep = false
418
+ const timeout = new Promise<never>((_, reject) => {
419
+ timer = setTimeout(() => {
420
+ controller.abort()
421
+ reject(new WorkerError('llm-timeout', `LLM 调用超时(>${this.limits.llmTimeoutMs}ms)`))
422
+ }, this.limits.llmTimeoutMs)
423
+ })
424
+ const request = {
425
+ model: this.model,
426
+ messages: this.history,
427
+ tools: this.registry.schemas(),
428
+ signal: controller.signal,
429
+ }
430
+ try {
431
+ // 真实 streaming:provider 支持时逐 token 回调宿主;否则退回一次性调用。
432
+ const pending = typeof this.llm.chatStream === 'function'
433
+ ? this.llm.chatStream(request, (delta) => {
434
+ if (delta?.content) this.streamedThisStep = true
435
+ try { this.sink?.stream?.(delta) } catch { /* sink 不得打断流 */ }
436
+ })
437
+ : this.llm.chat(request)
438
+ const response = await Promise.race([pending, timeout])
439
+ if (!response || typeof response !== 'object' || !response.message) {
440
+ throw new WorkerError('llm-invalid-response', 'LLM 返回空响应或缺少 message')
441
+ }
442
+ return response
443
+ } catch (error) {
444
+ if (error instanceof WorkerError) throw error
445
+ throw new WorkerError('llm-call-failed', error instanceof Error ? error.message : String(error))
446
+ } finally {
447
+ if (timer) clearTimeout(timer)
448
+ }
449
+ }
450
+
451
+ private async executeTool(call: { id: string; name: string; args: string }): Promise<string> {
452
+ const controller = new AbortController()
453
+ try {
454
+ return await this.registry.execute(
455
+ { name: call.name, arguments: call.args },
456
+ {
457
+ workspace: this.workspace,
458
+ signal: controller.signal,
459
+ timeoutMs: this.limits.toolTimeoutMs,
460
+ mandate: this.effectiveMandate,
461
+ },
462
+ )
463
+ } catch (error) {
464
+ // 理论上 registry 已把工具异常转成文本;这里兜底确保不打断循环。
465
+ return `执行报错: ${error instanceof Error ? error.message : String(error)}`
466
+ }
467
+ }
468
+
469
+ private setPhase(phase: WorkerPhase): void {
470
+ if (this.phase === phase) return
471
+ this.phase = phase
472
+ try { this.sink?.phase?.(phase) } catch { /* sink 不得影响循环 */ }
473
+ try { this.logger?.info?.(`[Worker] phase=${phase}`) } catch { /* ignore */ }
474
+ }
475
+
476
+ private record(step: Omit<WorkerStep, 'stepIndex' | 'timestamp' | 'tokenUsage'> & { stepIndex?: number }): void {
477
+ const full: WorkerStep = {
478
+ stepIndex: step.stepIndex ?? this.steps.length + 1,
479
+ type: step.type,
480
+ // v3.2.5 P1-3:所有步骤内容统一脱敏(含 error/thought/final_answer),
481
+ // 避免异常消息里的密钥绕过 tool_result 处的单点脱敏。
482
+ content: this.redact(String(step.content ?? '')),
483
+ toolName: step.toolName,
484
+ toolArgs: step.toolArgs,
485
+ timestamp: this.clock(),
486
+ tokenUsage: this.tokenUsage,
487
+ }
488
+ this.steps.push(full)
489
+ try { this.sink?.step?.(full) } catch { /* ignore */ }
490
+ }
491
+
492
+ private stream(content?: string, toolName?: string): void {
493
+ try {
494
+ if (content) {
495
+ // 本步由 provider 真实流式输出过 → 不重复模拟;否则按块模拟兜底。
496
+ if (this.streamedThisStep) return
497
+ for (let index = 0; index < content.length; index += 240) {
498
+ this.sink?.stream?.({ content: content.slice(index, index + 240) })
499
+ }
500
+ } else if (toolName) {
501
+ this.sink?.stream?.({ toolName })
502
+ }
503
+ } catch { /* ignore */ }
504
+ }
505
+
506
+ private log(message: string): void {
507
+ try { this.sink?.log?.(message) } catch { /* ignore */ }
508
+ try { this.logger?.info?.(message) } catch { /* ignore */ }
509
+ }
510
+ }
511
+
512
+ function ticketIdOf(input: RouterTicketLike | TicketPayload | Record<string, unknown>): string {
513
+ const raw = input && typeof input === 'object' ? input as Record<string, unknown> : {}
514
+ return String(raw.ticketId ?? raw.id ?? '')
515
+ }
516
+
517
+ function safeParseArgs(args: string): Record<string, unknown> | undefined {
518
+ try {
519
+ const parsed = JSON.parse(String(args ?? '{}'))
520
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined
521
+ } catch {
522
+ return undefined
523
+ }
524
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /** Worker 可分类错误:Router/宿主据此决定是否重试或报警。 */
5
+ export class WorkerError extends Error {
6
+ code: string
7
+ fatal: boolean
8
+
9
+ constructor(code: string, message: string, fatal = true) {
10
+ super(message)
11
+ this.name = 'WorkerError'
12
+ this.code = code
13
+ this.fatal = fatal
14
+ }
15
+ }
16
+
17
+ export class WorkerLimitError extends WorkerError {
18
+ constructor(code: string, message: string) {
19
+ super(code, message, true)
20
+ this.name = 'WorkerLimitError'
21
+ }
22
+ }
23
+
24
+ export class WorkerTicketError extends WorkerError {
25
+ constructor(message: string) {
26
+ super('ticket-invalid', message, true)
27
+ this.name = 'WorkerTicketError'
28
+ }
29
+ }
30
+
31
+ export class MandateViolationError extends WorkerError {
32
+ constructor(message: string) {
33
+ super('mandate-violation', message, false)
34
+ this.name = 'MandateViolationError'
35
+ }
36
+ }
37
+
38
+ /** 把 unknown 转成可序列化的失败对象。 */
39
+ export function toFailure(error: unknown): { code: string; message: string; fatal: boolean } {
40
+ if (error instanceof WorkerError) {
41
+ return { code: error.code, message: error.message, fatal: error.fatal }
42
+ }
43
+ if (error instanceof Error) {
44
+ return { code: 'worker-failed', message: error.message, fatal: true }
45
+ }
46
+ return { code: 'worker-failed', message: String(error), fatal: true }
47
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * Worker 的 GitHub PR 客户端(v3.2.5)。
6
+ *
7
+ * 仅做“创建 PR”的 API 调用:分支必须已由 Worker/宿主推送到 GitHub;
8
+ * token 来自宿主注入的 DSH_MASTER_GITHUB_TOKEN,不写日志、不进状态。
9
+ * create_pr 工具默认没有 client(fail-closed),只有配置完整时才可用。
10
+ */
11
+
12
+ export interface GithubPrOptions {
13
+ token: string
14
+ repo: string
15
+ apiBase?: string
16
+ baseBranch?: string
17
+ fetchImpl?: typeof fetch
18
+ }
19
+
20
+ export interface PullRequestInput {
21
+ title: string
22
+ body: string
23
+ branch?: string
24
+ base?: string
25
+ }
26
+
27
+ export interface PullRequestClient {
28
+ createPullRequest(input: PullRequestInput): Promise<string>
29
+ }
30
+
31
+ export function createGithubPrClient(options: GithubPrOptions): PullRequestClient {
32
+ const token = String(options.token ?? '').trim()
33
+ const repo = String(options.repo ?? '').trim()
34
+ if (!token) throw new Error('github pr client requires token')
35
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error('github pr client requires repo owner/name')
36
+ const apiBase = String(options.apiBase ?? 'https://api.github.com').replace(/\/+$/, '')
37
+ const defaultBase = String(options.baseBranch ?? 'main')
38
+ const fetchImpl = options.fetchImpl ?? fetch
39
+ return {
40
+ async createPullRequest(input: PullRequestInput): Promise<string> {
41
+ const branch = String(input?.branch ?? '').trim()
42
+ if (!branch) throw new Error('create_pr requires a branch (push it first)')
43
+ const response = await fetchImpl(`${apiBase}/repos/${repo}/pulls`, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'content-type': 'application/json',
47
+ accept: 'application/vnd.github+json',
48
+ authorization: `Bearer ${token}`,
49
+ 'user-agent': 'dsh-master-worker',
50
+ },
51
+ body: JSON.stringify({
52
+ title: String(input?.title ?? '').slice(0, 200),
53
+ body: String(input?.body ?? '').slice(0, 20_000),
54
+ head: branch,
55
+ base: String(input?.base ?? defaultBase),
56
+ }),
57
+ })
58
+ const text = await response.text()
59
+ if (!response.ok) throw new Error(`github pr create failed: HTTP ${response.status} ${text.slice(0, 200)}`)
60
+ let parsed: { html_url?: string; number?: number } = {}
61
+ try { parsed = JSON.parse(text) } catch { /* ignore */ }
62
+ return String(parsed.html_url ?? `PR #${parsed.number ?? '?'}`)
63
+ },
64
+ }
65
+ }