@twinsearth/dsh-master 0.0.0-stage → 3.2.8

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 (95) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +89 -2
  3. package/client.js +263 -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 +377 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/REVIEW-v3.2.7.md +121 -0
  45. package/docs/ROADMAP.md +106 -0
  46. package/docs/SECURITY.md +153 -0
  47. package/docs/SMOKE-3.2.5.md +77 -0
  48. package/docs/SMOKE-3.2.7.md +73 -0
  49. package/docs/SUPERAGENT.md +141 -0
  50. package/docs/WORKER-AGENT-LOOP.md +139 -0
  51. package/icon.svg +9 -0
  52. package/locale/en.json +6 -0
  53. package/locale/zh.json +6 -0
  54. package/package.json +79 -4
  55. package/src/contract/client.js +86 -0
  56. package/src/contract/constants.js +79 -0
  57. package/src/contract/envelope.js +83 -0
  58. package/src/contract/index.js +19 -0
  59. package/src/contract/negotiate.js +106 -0
  60. package/src/contract/server.js +77 -0
  61. package/src/contract/transports.js +319 -0
  62. package/src/index.ts +265 -0
  63. package/src/kernel.ts +190 -0
  64. package/src/plugins/cost-optimizer.ts +155 -0
  65. package/src/plugins/dual-manager.ts +252 -0
  66. package/src/plugins/superagent-router.ts +465 -0
  67. package/src/plugins/superagent-worker.ts +85 -0
  68. package/src/superagent/bridge-tokens.ts +236 -0
  69. package/src/superagent/dispatch.ts +197 -0
  70. package/src/superagent/feishu.ts +104 -0
  71. package/src/superagent/github.ts +111 -0
  72. package/src/superagent/pr-delivery.ts +308 -0
  73. package/src/superagent/router.ts +201 -0
  74. package/src/superagent/stream-adapter.ts +184 -0
  75. package/src/superagent/stream-tokens.ts +142 -0
  76. package/src/superagent/ticket-store.ts +104 -0
  77. package/src/superagent/trust.ts +139 -0
  78. package/src/superagent/types.ts +135 -0
  79. package/src/superagent/worker.ts +100 -0
  80. package/src/superagent/writeback.ts +154 -0
  81. package/tsconfig.host.build.json +12 -0
  82. package/tsconfig.host.json +21 -0
  83. package/tsconfig.worker.build.json +13 -0
  84. package/tsconfig.worker.json +17 -0
  85. package/types/superagent.d.ts +99 -0
  86. package/worker/agent-loop.ts +524 -0
  87. package/worker/errors.ts +47 -0
  88. package/worker/github-pr.ts +65 -0
  89. package/worker/index.ts +334 -0
  90. package/worker/llm-http.ts +359 -0
  91. package/worker/main.ts +45 -0
  92. package/worker/prompt.ts +39 -0
  93. package/worker/sink-http.ts +159 -0
  94. package/worker/tools.ts +531 -0
  95. package/worker/types.ts +247 -0
@@ -0,0 +1,334 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * dsh-superagent-worker preset 的 Cordis 入口(v3.2.3)。
6
+ *
7
+ * 运行在分身 VM 内。Router 派发时注入 `DSH_MASTER_TICKET`,同时注入:
8
+ * * `DSH_MASTER_STREAM_URL` / `DSH_MASTER_STREAM_TOKEN`:Worker→Router 实时事件通道;
9
+ * fork 宿主加载本 preset 后会自动跑一次 Agent Loop(可用 DSH_MASTER_WORKER_AUTORUN=0 关闭)。
10
+ *
11
+ * 角色安全:只允许 `DSH_FANG_ROLE=fork` 且非元宝运行态(`DSH_FANG_ESSENCE!=1`)执行;
12
+ * 在母体/元主即使误装也不会运行 Worker。
13
+ */
14
+
15
+ import type { TicketPayload, WorkerDeps, WorkerResult, WorkerSink } from './types.ts'
16
+ import { SuperAgentWorker, normalizeTicket } from './agent-loop.ts'
17
+ import { createSandboxTools } from './tools.ts'
18
+ import { createHttpLlmClient } from './llm-http.ts'
19
+ import { createHttpSink } from './sink-http.ts'
20
+ import { createGithubPrClient } from './github-pr.ts'
21
+
22
+ export const name = 'dsh-master-superagent-loop'
23
+ // 依赖房主核心服务:Cordis 会等 dshMaster 就绪后再 apply,保证本行能注册到核心。
24
+ export const inject = ['dshMaster']
25
+
26
+ export interface CordisLoggerLike {
27
+ info?(message: string): void
28
+ warn?(message: string): void
29
+ error?(message: string): void
30
+ }
31
+
32
+ export interface CordisLike {
33
+ logger?: CordisLoggerLike
34
+ llm?: { chat(request: unknown): Promise<unknown> }
35
+ tools?: { register?(tool: unknown): unknown; getSchemas?(names: string[]): unknown[]; execute?(name: string, args: unknown): unknown }
36
+ get?(name: string): unknown
37
+ on?(event: string, handler: (payload: unknown) => void): unknown
38
+ emit?(event: string, payload: unknown): void
39
+ effect?(dispose: () => void): void
40
+ }
41
+
42
+ export interface WorkerEnvOptions {
43
+ env?: Record<string, string | undefined>
44
+ workspace?: string
45
+ model?: string
46
+ llm?: WorkerDeps['llm']
47
+ limits?: WorkerDeps['limits']
48
+ sink?: WorkerSink
49
+ }
50
+
51
+ let autoStartedTicket = ''
52
+
53
+ function envOf(options: WorkerEnvOptions): Record<string, string | undefined> {
54
+ return options.env ?? process.env
55
+ }
56
+
57
+ /**
58
+ * VM 隔离证明:只有明确声明 `DSH_MASTER_VM_ISOLATION=attested` 才允许 Shell(P0-2 fail-closed)。
59
+ * 瓮城在已证明隔离的分身里注入该变量;普通进程不会自动获得。
60
+ */
61
+ export function vmIsolationAttested(env: Record<string, string | undefined>): boolean {
62
+ return String(env.DSH_MASTER_VM_ISOLATION ?? '').trim().toLowerCase() === 'attested'
63
+ }
64
+
65
+ function prClientFromEnv(env: Record<string, string | undefined>) {
66
+ const token = String(env.DSH_MASTER_GITHUB_TOKEN ?? '').trim()
67
+ const repo = String(env.DSH_MASTER_GITHUB_REPO ?? '').trim()
68
+ if (!token || !repo) return undefined
69
+ return createGithubPrClient({ token, repo, apiBase: env.DSH_MASTER_GITHUB_API_BASE, baseBranch: env.DSH_MASTER_GITHUB_BASE })
70
+ }
71
+
72
+ function consoleLogger(): WorkerDeps['logger'] {
73
+ return {
74
+ info: (message: string) => { process.stderr.write(message + '\n') },
75
+ warn: (message: string) => { process.stderr.write('[warn] ' + message + '\n') },
76
+ error: (message: string) => { process.stderr.write('[error] ' + message + '\n') },
77
+ }
78
+ }
79
+
80
+ /** 收集需要从日志/步骤中抹掉的具体 secret 值(key 只在 Worker 进程内使用)。 */
81
+ export function secretValuesOf(env: Record<string, string | undefined>): string[] {
82
+ // v3.2.5:不只列白名单——任何形如 *_API_KEY / *_TOKEN / *_SECRET / *_PASSWORD 的长值
83
+ // 都进脱敏表,避免新增凭据(DSH_MASTER_LLM_API_KEY / DSH_MASTER_GITHUB_TOKEN)漏脱敏。
84
+ const NAMED = [
85
+ 'WORKER_LLM_API_KEY',
86
+ 'DSH_MASTER_LLM_API_KEY',
87
+ 'DSH_MASTER_STREAM_TOKEN',
88
+ 'DSH_MASTER_GITHUB_TOKEN',
89
+ 'DSH_ADAPTER_TOKEN',
90
+ ]
91
+ const values = new Set<string>()
92
+ for (const key of NAMED) {
93
+ const value = env[key]
94
+ if (typeof value === 'string' && value.length >= 8) values.add(value)
95
+ }
96
+ for (const [key, value] of Object.entries(env)) {
97
+ if (typeof value !== 'string' || value.length < 8) continue
98
+ if (!/(_API_KEY|_APIKEY|_TOKEN|_SECRET|_PASSWORD)$/i.test(key)) continue
99
+ values.add(value)
100
+ }
101
+ return [...values]
102
+ }
103
+
104
+ /** 从环境变量构造 Worker 依赖;缺 Ticket/LLM 时抛可读错误。 */
105
+ export function buildWorkerDeps(options: WorkerEnvOptions = {}): { ticket: TicketPayload; deps: WorkerDeps } {
106
+ const env = envOf(options)
107
+ const raw = env.DSH_MASTER_TICKET
108
+ if (!raw) throw new Error('DSH_MASTER_TICKET is required to start a SuperAgent Worker')
109
+ let parsed: unknown
110
+ try {
111
+ parsed = parseTicketEnv(raw)
112
+ } catch {
113
+ throw new Error('DSH_MASTER_TICKET is not valid JSON')
114
+ }
115
+ const ticket = normalizeTicket(parsed as Record<string, unknown>)
116
+ const workspace = String(options.workspace ?? env.WORKER_WORKSPACE ?? env.DSH_MASTER_WORKSPACE ?? process.cwd())
117
+ const llm = options.llm ?? createHttpLlmClient({
118
+ baseUrl: String(env.WORKER_LLM_BASE_URL ?? env.DSH_MASTER_LLM_BASE_URL ?? ''),
119
+ apiKey: env.WORKER_LLM_API_KEY ?? env.DSH_MASTER_LLM_API_KEY,
120
+ model: String(options.model ?? env.WORKER_LLM_MODEL ?? env.DSH_MASTER_LLM_MODEL ?? 'deepseek-chat'),
121
+ timeoutMs: Number(env.WORKER_LLM_TIMEOUT_MS ?? 120_000),
122
+ })
123
+ const githubToken = String(env.DSH_MASTER_GITHUB_TOKEN ?? '').trim()
124
+ const githubRepo = String(env.DSH_MASTER_GITHUB_REPO ?? '').trim()
125
+ const prClient = githubToken && githubRepo
126
+ ? createGithubPrClient({ token: githubToken, repo: githubRepo, apiBase: env.DSH_MASTER_GITHUB_API_BASE, baseBranch: env.DSH_MASTER_GITHUB_BASE })
127
+ : undefined
128
+ const tools = createSandboxTools({ workspace, mandate: ticket.mandate, prClient, vmIsolation: vmIsolationAttested(env) })
129
+ return {
130
+ ticket,
131
+ deps: {
132
+ llm,
133
+ tools,
134
+ workspace,
135
+ mandate: ticket.mandate,
136
+ logger: options.sink ? undefined : consoleLogger(),
137
+ sink: options.sink,
138
+ limits: options.limits,
139
+ model: String(options.model ?? env.WORKER_LLM_MODEL ?? '') || undefined,
140
+ redactValues: secretValuesOf(env),
141
+ },
142
+ }
143
+ }
144
+
145
+ function cordisSink(ctx: CordisLike): WorkerSink {
146
+ return {
147
+ log: (message: string) => {
148
+ try { ctx.logger?.info?.(message) } catch { /* ignore */ }
149
+ try { ctx.emit?.('superagent:log', { message, at: Date.now() }) } catch { /* ignore */ }
150
+ },
151
+ step: (step) => { try { ctx.emit?.('superagent:step', step) } catch { /* ignore */ } },
152
+ phase: (phase) => { try { ctx.emit?.('superagent:phase', { phase, at: Date.now() }) } catch { /* ignore */ } },
153
+ stream: (delta) => { try { ctx.emit?.('superagent:stream', delta) } catch { /* ignore */ } },
154
+ }
155
+ }
156
+
157
+ /** 组合 sink:Cordis 事件 + Router HTTP 实时通道(任一失败不影响另一个)。 */
158
+ function composeSink(local: WorkerSink, remote: WorkerSink | null): WorkerSink {
159
+ if (!remote) return local
160
+ const wrap = <K extends keyof WorkerSink>(key: K) => {
161
+ const localFn = local[key]
162
+ const remoteFn = remote[key]
163
+ if (!localFn && !remoteFn) return undefined
164
+ return ((...args: Parameters<NonNullable<WorkerSink[K]>>) => {
165
+ try { (localFn as ((...a: unknown[]) => void) | undefined)?.(...args) } catch { /* ignore */ }
166
+ try { (remoteFn as ((...a: unknown[]) => void) | undefined)?.(...args) } catch { /* ignore */ }
167
+ }) as NonNullable<WorkerSink[K]>
168
+ }
169
+ return { log: wrap('log'), step: wrap('step'), phase: wrap('phase'), stream: wrap('stream') }
170
+ }
171
+
172
+ /** 归档真实 token 用量到 fork 内的 HarnessMM observe 服务;无该服务时不影响 Worker。 */
173
+ export function recordWorkerCost(ctx: CordisLike, ticketId: string, result: WorkerResult): unknown {
174
+ try {
175
+ const cost = ctx.get?.('dshMasterCost') as { record?(input: Record<string, unknown>): unknown } | undefined
176
+ if (!cost || typeof cost.record !== 'function') return { ok: false, code: 'cost-service-unavailable' }
177
+ const tokens = Math.max(0, Number(result.tokenUsage ?? 0) || 0)
178
+ // 尚未接入 EGD/GDR 优化,优化前后相等,绝不虚报节省;只记录真实 token 与成功率。
179
+ return cost.record({ taskId: ticketId, baselineTokens: tokens, optimizedTokens: tokens, success: result.ok === true })
180
+ } catch (error) {
181
+ return { ok: false, code: 'cost-record-failed', error: String(error instanceof Error ? error.message : error) }
182
+ }
183
+ }
184
+
185
+ /** 跑一次 Worker(不依赖 Cordis;供 main.ts / 宿主/测试复用)。 */
186
+ export async function runWorker(ticket: TicketPayload, deps: WorkerDeps): Promise<WorkerResult> {
187
+ const worker = new SuperAgentWorker(deps)
188
+ return await worker.execute(ticket)
189
+ }
190
+
191
+ /** 宿主服务查找(房主核心存在时才注册子插件;独立 fork 无核心也能跑循环)。 */
192
+ function masterOf(ctx: CordisLike): { registerSubplugin?: (descriptor: Record<string, unknown>) => unknown } | null {
193
+ try {
194
+ const candidate = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
195
+ ? (ctx as { get: (name: string) => unknown }).get('dshMaster')
196
+ : null
197
+ return candidate && typeof candidate === 'object' ? candidate as never : null
198
+ } catch {
199
+ return null
200
+ }
201
+ }
202
+
203
+ /** bundle 行的空字符串视为未配置(否则会覆盖 env 默认值)。 */
204
+ function optionalOf(value: unknown): string | undefined {
205
+ const text = String(value ?? '').trim()
206
+ return text ? text : undefined
207
+ }
208
+
209
+ /** 环境变量里的 Ticket JSON:剥离 BOM/空白后再解析(Windows 脚本写入常见 BOM)。 */
210
+ export function parseTicketEnv(raw: string): unknown {
211
+ return JSON.parse(String(raw ?? '').replace(/^\uFEFF/, '').trim())
212
+ }
213
+
214
+ export interface Config {
215
+ /** bundle 行配置:`enabled: false` 时不自动执行(仍保留事件入口)。 */
216
+ enabled?: boolean
217
+ /** 是否在检测到 Ticket 后自动跑循环;默认 true(可用 DSH_MASTER_WORKER_AUTORUN=0 关闭)。 */
218
+ autorun?: boolean
219
+ /** 覆盖 LLM 模型名(默认取 env)。 */
220
+ model?: string
221
+ /** 覆盖工作区(默认取 env / cwd)。 */
222
+ workspace?: string
223
+ }
224
+
225
+ /**
226
+ * Cordis preset 入口:有 Ticket 的 fork 自动跑 Worker;没有 Ticket 时只等待事件。
227
+ * 返回 {ready, autoRun, reason} 便于宿主/测试检查。
228
+ */
229
+ export function apply(ctx: CordisLike, config: Config = {}): { ready: boolean; autoRun: boolean; reason?: string } {
230
+ if (config && config.enabled === false) return { ready: false, autoRun: false, reason: 'disabled-by-config' }
231
+ const env = process.env
232
+ const raw = env.DSH_MASTER_TICKET
233
+ const emitError = (message: string, extra: Record<string, unknown> = {}) => {
234
+ try { ctx.logger?.error?.(message) } catch { /* ignore */ }
235
+ try { ctx.emit?.('superagent:worker:error', { error: message, ...extra }) } catch { /* ignore */ }
236
+ }
237
+
238
+ // 角色安全:Worker preset 只允许 fork(非元宝运行态)。与房主 ROLE_MATRIX 的
239
+ // master-superagent-loop 行一致,避免宿主状态把“跑过循环却显示未激活”。
240
+ const role = String(env.DSH_FANG_ROLE ?? '').trim()
241
+ const essence = String(env.DSH_FANG_ESSENCE ?? '') === '1' || String(env.DSH_FANG_TASK_ID ?? '').startsWith('rt-')
242
+ const effectiveRole = role === 'fork' ? (essence ? 'essence' : 'fork') : (essence ? 'essence' : (role || 'mother'))
243
+ if (effectiveRole !== 'fork') {
244
+ return { ready: false, autoRun: false, reason: `role-not-allowed:${effectiveRole}` }
245
+ }
246
+
247
+ // 向房主核心注册本行(核心存在时;重复注册不应打断 preset)。
248
+ try {
249
+ const master = masterOf(ctx)
250
+ if (master && typeof master.registerSubplugin === 'function') {
251
+ master.registerSubplugin({
252
+ id: 'superagent-loop',
253
+ name: 'dsh-master-superagent-loop',
254
+ capabilities: ['worker-agent-loop', 'llm-tool-loop', 'streaming-events', 'result-writeback'],
255
+ description: raw ? 'Worker Agent Loop 已接管工单(fork)' : 'Worker Agent Loop 就绪(等待工单)',
256
+ })
257
+ }
258
+ } catch { /* 已注册/无核心:不影响循环 */ }
259
+
260
+ if (!raw) {
261
+ // 普通 fork / 其他角色:只保留事件监听入口,不自动执行。
262
+ try {
263
+ const off = ctx.on?.('superagent:worker:start', (payload) => {
264
+ void (async () => {
265
+ const ticket = normalizeTicket(payload as Record<string, unknown>)
266
+ const { deps } = buildWorkerDeps({ sink: cordisSink(ctx) })
267
+ const result = await runWorker(ticket, deps)
268
+ try { ctx.emit?.('superagent:worker:done', { ticketId: ticket.ticketId, result }) } catch { /* ignore */ }
269
+ })().catch((error) => emitError(error instanceof Error ? error.message : String(error)))
270
+ })
271
+ if (typeof off === 'function') ctx.effect?.(off as () => void)
272
+ } catch { /* ignore */ }
273
+ return { ready: true, autoRun: false, reason: 'no-ticket' }
274
+ }
275
+
276
+ const autorun = config.autorun === false ? false : env.DSH_MASTER_WORKER_AUTORUN !== '0'
277
+ if (!autorun) return { ready: true, autoRun: false, reason: 'autorun-disabled' }
278
+
279
+ let ticket: TicketPayload
280
+ try {
281
+ ticket = normalizeTicket(parseTicketEnv(raw) as Record<string, unknown>)
282
+ } catch (error) {
283
+ emitError(`worker ticket invalid: ${error instanceof Error ? error.message : String(error)}`)
284
+ return { ready: false, autoRun: false, reason: 'ticket-invalid' }
285
+ }
286
+ if (autoStartedTicket === ticket.ticketId) return { ready: true, autoRun: false, reason: 'already-started' }
287
+ autoStartedTicket = ticket.ticketId
288
+
289
+ const remoteSink = (env.DSH_MASTER_STREAM_URL && env.DSH_MASTER_STREAM_TOKEN)
290
+ ? createHttpSink({
291
+ url: String(env.DSH_MASTER_STREAM_URL),
292
+ token: String(env.DSH_MASTER_STREAM_TOKEN),
293
+ ticketId: ticket.ticketId,
294
+ streamFlushMs: Number(optionalOf(env.DSH_MASTER_STREAM_FLUSH_MS)) || undefined,
295
+ log: (message) => { try { ctx.logger?.warn?.(message) } catch { /* ignore */ } },
296
+ })
297
+ : null
298
+ const sink = composeSink(cordisSink(ctx), remoteSink)
299
+
300
+ let deps: WorkerDeps
301
+ try {
302
+ deps = buildWorkerDeps({ sink, model: optionalOf(config.model), workspace: optionalOf(config.workspace) }).deps
303
+ } catch (error) {
304
+ autoStartedTicket = ''
305
+ emitError(`worker dependency failed: ${error instanceof Error ? error.message : String(error)}`, { ticketId: ticket.ticketId })
306
+ return { ready: false, autoRun: false, reason: 'deps-invalid' }
307
+ }
308
+
309
+ // v3.2.7(A 治根之二):fork 的 Host 启动期会抢占 CPU,导致已经按 260ms 节奏到达的
310
+ // SSE 分片在 socket 里积压后一次性排空(前端只能看到"跳变")。这里在进入循环前先让
311
+ // 宿主把插件加载/首帧渲染完成(默认 1200ms,可用 DSH_MASTER_WARMUP_MS 覆盖,0=关闭)。
312
+ const warmupMs = Math.max(0, Number(optionalOf(env.DSH_MASTER_WARMUP_MS) ?? 1200) || 0)
313
+ const warmup = warmupMs > 0 ? new Promise((resolve) => setTimeout(resolve, warmupMs)) : Promise.resolve()
314
+
315
+ void warmup
316
+ .then(() => runWorker(ticket, deps))
317
+ .then(async (result) => {
318
+ autoStartedTicket = ''
319
+ try { await remoteSink?.done(result) } catch { /* ignore */ }
320
+ try { await remoteSink?.close() } catch { /* ignore */ }
321
+ try { recordWorkerCost(ctx, ticket.ticketId, result) } catch { /* ignore */ }
322
+ try { ctx.emit?.('superagent:worker:done', { ticketId: ticket.ticketId, result }) } catch { /* ignore */ }
323
+ try {
324
+ ctx.logger?.info?.(`[worker] ${ticket.ticketId} ${result.ok ? 'done' : 'failed'} in ${result.elapsedMs}ms`)
325
+ } catch { /* ignore */ }
326
+ })
327
+ .catch((error) => {
328
+ autoStartedTicket = ''
329
+ emitError(error instanceof Error ? error.message : String(error), { ticketId: ticket.ticketId })
330
+ })
331
+ return { ready: true, autoRun: true }
332
+ }
333
+
334
+ export type { WorkerResult, WorkerDeps, TicketPayload }
@@ -0,0 +1,359 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * OpenAI 兼容 Chat Completions 的 LLM 适配器(v3.2.2)。
6
+ *
7
+ * Worker 运行在分身 VM 内,通过环境变量拿到模型端点/凭据;本适配器不做任何
8
+ * 日志打印或错误回显中的 key 泄露。Provider 只负责“模型调用”,工具执行仍在
9
+ * Worker 本地(沙箱内)。
10
+ */
11
+
12
+ import type { ChatMessage, LLMClient, LLMRequest, LLMResponse, LLMStreamDelta, ToolCall } from './types.ts'
13
+
14
+ export interface HttpLlmOptions {
15
+ /** 例如 https://api.deepseek.com/v1;会拼接 /chat/completions。 */
16
+ baseUrl: string
17
+ apiKey?: string
18
+ model: string
19
+ timeoutMs?: number
20
+ headers?: Record<string, string>
21
+ fetchImpl?: typeof fetch
22
+ }
23
+
24
+ interface OpenAiToolCall {
25
+ id?: string
26
+ type?: string
27
+ function?: { name?: string; arguments?: string }
28
+ }
29
+
30
+ interface OpenAiMessage {
31
+ content?: string | null
32
+ tool_calls?: OpenAiToolCall[]
33
+ }
34
+
35
+ interface OpenAiResponse {
36
+ choices?: Array<{ message?: OpenAiMessage; finish_reason?: string }>
37
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
38
+ error?: { message?: string }
39
+ }
40
+
41
+ function endpointOf(baseUrl: string): string {
42
+ const trimmed = String(baseUrl ?? '').trim().replace(/\/+$/, '')
43
+ if (!trimmed) throw new Error('WORKER_LLM_BASE_URL is required')
44
+ return trimmed.endsWith('/chat/completions') ? trimmed : `${trimmed}/chat/completions`
45
+ }
46
+
47
+ function toWireMessages(messages: ChatMessage[]): unknown[] {
48
+ return messages.map((message) => {
49
+ if (message.role === 'tool') {
50
+ return { role: 'tool', tool_call_id: message.tool_call_id, content: message.content ?? '' }
51
+ }
52
+ if (message.role === 'assistant' && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
53
+ return {
54
+ role: 'assistant',
55
+ content: message.content ?? null,
56
+ tool_calls: message.tool_calls.map((call) => ({
57
+ id: call.id,
58
+ type: 'function',
59
+ function: { name: call.function.name, arguments: call.function.arguments },
60
+ })),
61
+ }
62
+ }
63
+ return { role: message.role, content: message.content ?? '' }
64
+ })
65
+ }
66
+
67
+ function fromWireResponse(payload: OpenAiResponse): LLMResponse {
68
+ const choice = payload?.choices?.[0]
69
+ const message = choice?.message ?? {}
70
+ const calls: ToolCall[] = Array.isArray(message.tool_calls)
71
+ ? message.tool_calls.map((call, index) => ({
72
+ id: String(call?.id ?? `call_${index + 1}`),
73
+ type: 'function',
74
+ function: {
75
+ name: String(call?.function?.name ?? ''),
76
+ arguments: String(call?.function?.arguments ?? '{}'),
77
+ },
78
+ }))
79
+ : []
80
+ return {
81
+ message: {
82
+ role: 'assistant',
83
+ content: message.content ?? '',
84
+ tool_calls: calls.length > 0 ? calls : undefined,
85
+ },
86
+ usage: payload?.usage
87
+ ? {
88
+ promptTokens: Number(payload.usage.prompt_tokens ?? 0),
89
+ completionTokens: Number(payload.usage.completion_tokens ?? 0),
90
+ totalTokens: Number(payload.usage.total_tokens ?? 0),
91
+ }
92
+ : undefined,
93
+ finishReason: choice?.finish_reason,
94
+ }
95
+ }
96
+
97
+ interface StreamToolCallState {
98
+ id?: string
99
+ name?: string
100
+ arguments: string
101
+ }
102
+
103
+ /** 从一行 SSE 文本里取出 data 负载;返回 null 表示控制行。 */
104
+ function sseDataOf(line: string): string | null {
105
+ const trimmed = line.trim()
106
+ if (!trimmed || trimmed.startsWith(':')) return null
107
+ if (!trimmed.startsWith('data:')) return null
108
+ return trimmed.slice(5).trim()
109
+ }
110
+
111
+ /**
112
+ * 解析 OpenAI 兼容的流式 chunk 并聚合为完整响应。
113
+ * 同时把 content 增量通过 onDelta 实时回调;tool_calls 按 index 聚合片段。
114
+ */
115
+ function createStreamAccumulator(onDelta: (delta: LLMStreamDelta) => void) {
116
+ let content = ''
117
+ let finishReason: string | undefined
118
+ let usage: LLMResponse['usage']
119
+ const calls: StreamToolCallState[] = []
120
+ let seenToolName = ''
121
+
122
+ const applyChunk = (chunk: Record<string, unknown>): boolean => {
123
+ const choices = Array.isArray(chunk?.choices) ? chunk.choices as Array<Record<string, unknown>> : []
124
+ const choice = choices[0] ?? null
125
+ if (choice?.delta && typeof choice.delta === 'object') {
126
+ const delta = choice.delta as Record<string, unknown>
127
+ const piece = typeof delta.content === 'string' ? delta.content : ''
128
+ if (piece) {
129
+ content += piece
130
+ try { onDelta({ content: piece }) } catch { /* 回调异常不得打断流 */ }
131
+ }
132
+ const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls as Array<Record<string, unknown>> : []
133
+ for (const item of toolDeltas) {
134
+ const index = Number(item?.index ?? calls.length)
135
+ const slot = calls[index] ?? (calls[index] = { arguments: '' })
136
+ const fn = (item?.function ?? {}) as Record<string, unknown>
137
+ if (typeof item?.id === 'string' && item.id) slot.id = item.id
138
+ if (typeof fn.name === 'string' && fn.name) slot.name = fn.name
139
+ if (typeof fn.arguments === 'string' && fn.arguments) slot.arguments += fn.arguments
140
+ const name = slot.name ?? ''
141
+ if (name && name !== seenToolName) {
142
+ seenToolName = name
143
+ try { onDelta({ toolName: name }) } catch { /* ignore */ }
144
+ }
145
+ }
146
+ }
147
+ if (typeof choice?.finish_reason === 'string') finishReason = choice.finish_reason
148
+ if (chunk?.usage && typeof chunk.usage === 'object') {
149
+ const raw = chunk.usage as Record<string, unknown>
150
+ usage = {
151
+ promptTokens: Number(raw.prompt_tokens ?? 0),
152
+ completionTokens: Number(raw.completion_tokens ?? 0),
153
+ totalTokens: Number(raw.total_tokens ?? 0),
154
+ }
155
+ }
156
+ return Boolean(chunk?.choices || chunk?.usage)
157
+ }
158
+
159
+ return {
160
+ applyChunk,
161
+ /** 返回聚合后的 LLMResponse(tool_calls 只保留有 name 的)。 */
162
+ result(): LLMResponse {
163
+ const toolCalls: ToolCall[] = calls
164
+ .filter((slot) => Boolean(slot && slot.name))
165
+ .map((slot, index) => ({
166
+ id: String(slot.id ?? `call_${index + 1}`),
167
+ type: 'function',
168
+ function: { name: String(slot.name ?? ''), arguments: slot.arguments || '{}' },
169
+ }))
170
+ return {
171
+ message: {
172
+ role: 'assistant',
173
+ content,
174
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
175
+ },
176
+ usage,
177
+ finishReason,
178
+ }
179
+ },
180
+ get sawChunk(): boolean {
181
+ return content.length > 0 || calls.length > 0 || finishReason !== undefined
182
+ },
183
+ }
184
+ }
185
+
186
+ /** 逐块读取响应体并喂给 SSE 解析器;返回是否识别到任何 SSE 数据。 */
187
+ async function consumeSse(body: ReadableStream<Uint8Array> | null, accumulator: ReturnType<typeof createStreamAccumulator>): Promise<boolean> {
188
+ if (!body || typeof body.getReader !== 'function') return false
189
+ const reader = body.getReader()
190
+ const decoder = new TextDecoder()
191
+ let buffer = ''
192
+ let sawData = false
193
+ try {
194
+ while (true) {
195
+ const { done, value } = await reader.read()
196
+ if (done) break
197
+ buffer += decoder.decode(value, { stream: true })
198
+ // SSE 事件以空行分隔;兼容 \r\n。
199
+ let boundary = buffer.search(/\r?\n\r?\n/)
200
+ while (boundary >= 0) {
201
+ const rawEvent = buffer.slice(0, boundary)
202
+ buffer = buffer.slice(boundary + (buffer[boundary] === '\r' ? 4 : 2))
203
+ for (const line of rawEvent.split(/\r?\n/)) {
204
+ const data = sseDataOf(line)
205
+ if (!data) continue
206
+ if (data === '[DONE]') return true
207
+ sawData = true
208
+ try {
209
+ const chunk = JSON.parse(data)
210
+ accumulator.applyChunk(chunk)
211
+ } catch { /* 非 JSON 的 data 行忽略 */ }
212
+ }
213
+ boundary = buffer.search(/\r?\n\r?\n/)
214
+ }
215
+ }
216
+ // 收尾:没有空行结尾的最后一个事件。
217
+ const tail = buffer.trim()
218
+ if (tail) {
219
+ for (const line of tail.split(/\r?\n/)) {
220
+ const data = sseDataOf(line)
221
+ if (!data || data === '[DONE]') continue
222
+ sawData = true
223
+ try { accumulator.applyChunk(JSON.parse(data)) } catch { /* ignore */ }
224
+ }
225
+ }
226
+ } finally {
227
+ try { reader.releaseLock() } catch { /* ignore */ }
228
+ }
229
+ return sawData
230
+ }
231
+
232
+ /** 创建 HTTP LLM 客户端;调用方负责在 Worker 循环外层套超时。 */
233
+ export function createHttpLlmClient(options: HttpLlmOptions): LLMClient {
234
+ const url = endpointOf(options.baseUrl)
235
+ const model = String(options.model ?? '').trim()
236
+ if (!model) throw new Error('WORKER_LLM_MODEL is required')
237
+ const fetchImpl = options.fetchImpl ?? fetch
238
+ const timeoutMs = Math.max(1, Number(options.timeoutMs ?? 120_000) || 120_000)
239
+ return {
240
+ async chat(request: LLMRequest): Promise<LLMResponse> {
241
+ const controller = new AbortController()
242
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
243
+ const onAbort = () => controller.abort()
244
+ request.signal?.addEventListener('abort', onAbort, { once: true })
245
+ try {
246
+ const body: Record<string, unknown> = {
247
+ model: request.model ?? model,
248
+ messages: toWireMessages(request.messages),
249
+ stream: false,
250
+ }
251
+ if (Array.isArray(request.tools) && request.tools.length > 0) {
252
+ body.tools = request.tools.map((tool) => ({ type: 'function', function: tool }))
253
+ body.tool_choice = 'auto'
254
+ }
255
+ if (request.responseFormat?.type === 'json_object') body.response_format = { type: 'json_object' }
256
+ const headers: Record<string, string> = {
257
+ 'content-type': 'application/json',
258
+ accept: 'application/json',
259
+ ...(options.headers ?? {}),
260
+ }
261
+ if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`
262
+ const response = await fetchImpl(url, {
263
+ method: 'POST',
264
+ headers,
265
+ body: JSON.stringify(body),
266
+ signal: controller.signal,
267
+ })
268
+ const text = await response.text()
269
+ if (!response.ok) {
270
+ const detail = text.slice(0, 300)
271
+ throw new Error(`LLM HTTP ${response.status}: ${detail}`)
272
+ }
273
+ let parsed: OpenAiResponse
274
+ try {
275
+ parsed = JSON.parse(text) as OpenAiResponse
276
+ } catch {
277
+ throw new Error('LLM response is not valid JSON')
278
+ }
279
+ if (parsed.error?.message) throw new Error(`LLM error: ${parsed.error.message}`)
280
+ return fromWireResponse(parsed)
281
+ } finally {
282
+ clearTimeout(timer)
283
+ request.signal?.removeEventListener('abort', onAbort)
284
+ }
285
+ },
286
+
287
+ /**
288
+ * 真实流式调用:stream=true + SSE,边收边回调。
289
+ * provider 若忽略 stream 并返回普通 JSON,会自动退回聚合解析(不会重复回调)。
290
+ */
291
+ async chatStream(request: LLMRequest, onDelta: (delta: LLMStreamDelta) => void): Promise<LLMResponse> {
292
+ const controller = new AbortController()
293
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
294
+ const onAbort = () => controller.abort()
295
+ request.signal?.addEventListener('abort', onAbort, { once: true })
296
+ try {
297
+ const body: Record<string, unknown> = {
298
+ model: request.model ?? model,
299
+ messages: toWireMessages(request.messages),
300
+ stream: true,
301
+ stream_options: { include_usage: true },
302
+ }
303
+ if (Array.isArray(request.tools) && request.tools.length > 0) {
304
+ body.tools = request.tools.map((tool) => ({ type: 'function', function: tool }))
305
+ body.tool_choice = 'auto'
306
+ }
307
+ const headers: Record<string, string> = {
308
+ 'content-type': 'application/json',
309
+ accept: 'text/event-stream',
310
+ ...(options.headers ?? {}),
311
+ }
312
+ if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`
313
+ const response = await fetchImpl(url, {
314
+ method: 'POST',
315
+ headers,
316
+ body: JSON.stringify(body),
317
+ signal: controller.signal,
318
+ })
319
+ if (!response.ok) {
320
+ const detail = String(await response.text()).slice(0, 300)
321
+ throw new Error(`LLM HTTP ${response.status}: ${detail}`)
322
+ }
323
+ const accumulator = createStreamAccumulator(onDelta)
324
+ const contentType = String(response.headers?.get?.('content-type') ?? '')
325
+ // 声明为 SSE:边收边解析(真正的增量 streaming)。
326
+ if (contentType.includes('text/event-stream')) {
327
+ const sawData = await consumeSse(response.body as ReadableStream<Uint8Array> | null, accumulator)
328
+ if (!sawData) throw new Error('LLM stream ended without any SSE data')
329
+ return accumulator.result()
330
+ }
331
+ // 未声明 SSE(provider 忽略 stream 参数):整体读回,按 JSON 或 SSE 文本兜底。
332
+ const text = await response.text()
333
+ if (/^\s*data:/m.test(text)) {
334
+ for (const line of text.split(/\r?\n/)) {
335
+ const data = sseDataOf(line)
336
+ if (!data || data === '[DONE]') continue
337
+ try { accumulator.applyChunk(JSON.parse(data)) } catch { /* ignore */ }
338
+ }
339
+ if (accumulator.sawChunk) return accumulator.result()
340
+ }
341
+ let parsed: OpenAiResponse
342
+ try {
343
+ parsed = JSON.parse(text) as OpenAiResponse
344
+ } catch {
345
+ throw new Error('LLM response is not valid JSON')
346
+ }
347
+ if (parsed.error?.message) throw new Error(`LLM error: ${parsed.error.message}`)
348
+ const fallback = fromWireResponse(parsed)
349
+ if (fallback.message.content) {
350
+ try { onDelta({ content: fallback.message.content }) } catch { /* ignore */ }
351
+ }
352
+ return fallback
353
+ } finally {
354
+ clearTimeout(timer)
355
+ request.signal?.removeEventListener('abort', onAbort)
356
+ }
357
+ },
358
+ }
359
+ }