@dcrays/dcgchat-test 0.6.12 → 0.7.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 (60) hide show
  1. package/index.js +306 -0
  2. package/openclaw.plugin.json +42 -12
  3. package/package.json +8 -35
  4. package/README.md +0 -167
  5. package/index.ts +0 -35
  6. package/src/agent.ts +0 -568
  7. package/src/bot.ts +0 -646
  8. package/src/channel/index.ts +0 -329
  9. package/src/channel/outboundMedia.ts +0 -144
  10. package/src/channel/outboundTarget.ts +0 -62
  11. package/src/channel/uploadMediaUrl.ts +0 -76
  12. package/src/cron/message.ts +0 -114
  13. package/src/cron/params.ts +0 -164
  14. package/src/cron/toolGuard.ts +0 -466
  15. package/src/cron/types.ts +0 -15
  16. package/src/gateway/index.ts +0 -430
  17. package/src/gateway/security.ts +0 -95
  18. package/src/gateway/socket.ts +0 -256
  19. package/src/libs/ali-oss-6.23.0.tgz +0 -0
  20. package/src/libs/axios-1.13.6.tgz +0 -0
  21. package/src/libs/md5-2.3.0.tgz +0 -0
  22. package/src/libs/mime-types-3.0.2.tgz +0 -0
  23. package/src/libs/unzipper-0.12.3.tgz +0 -0
  24. package/src/libs/ws-8.19.0.tgz +0 -0
  25. package/src/monitor.ts +0 -269
  26. package/src/request/api.ts +0 -80
  27. package/src/request/oss.ts +0 -200
  28. package/src/request/request.ts +0 -191
  29. package/src/request/userInfo.ts +0 -44
  30. package/src/session.ts +0 -28
  31. package/src/skill.ts +0 -114
  32. package/src/tool.ts +0 -603
  33. package/src/tools/messageTool.ts +0 -334
  34. package/src/tools/toolCallGuard.ts +0 -327
  35. package/src/transport.ts +0 -217
  36. package/src/types.ts +0 -139
  37. package/src/utils/agentErrors.ts +0 -116
  38. package/src/utils/constant.ts +0 -60
  39. package/src/utils/env-config.ts +0 -19
  40. package/src/utils/formatLlmInputEvent.ts +0 -48
  41. package/src/utils/gatewayMsgHandler.ts +0 -147
  42. package/src/utils/global.ts +0 -309
  43. package/src/utils/inboundTurnState.ts +0 -66
  44. package/src/utils/log.ts +0 -77
  45. package/src/utils/mediaAttached.ts +0 -244
  46. package/src/utils/mediaEmitter.ts +0 -54
  47. package/src/utils/outboundAssistantText.ts +0 -117
  48. package/src/utils/params.ts +0 -104
  49. package/src/utils/passTxt.ts +0 -4
  50. package/src/utils/resolveRegisterConfig.ts +0 -33
  51. package/src/utils/searchFile.ts +0 -228
  52. package/src/utils/sessionState.ts +0 -137
  53. package/src/utils/sessionTermination.ts +0 -228
  54. package/src/utils/streamMerge.ts +0 -150
  55. package/src/utils/subagentRunMap.ts +0 -71
  56. package/src/utils/undiciFetchInterceptor.ts +0 -346
  57. package/src/utils/workspaceFilePaths.ts +0 -18
  58. package/src/utils/wsMessageHandler.ts +0 -124
  59. package/src/utils/zipExtract.ts +0 -97
  60. package/src/utils/zipPath.ts +0 -24
package/src/bot.ts DELETED
@@ -1,646 +0,0 @@
1
- import path from 'node:path'
2
- import type { OpenClawConfig, ReplyPayload } from 'openclaw/plugin-sdk'
3
- import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline'
4
- import type { IMsgParams, InboundMessage } from './types.js'
5
- import {
6
- formatText,
7
- getSessionKey,
8
- getWorkspaceDir,
9
- isReviserExpert,
10
- setMsgStatus,
11
- clearSentMediaKeys,
12
- removeAllCronMessageIdsForSession,
13
- takeApartSessionKey,
14
- getDcgchatRuntime,
15
- getOpenClawConfig,
16
- isCollectionReviewer
17
- } from './utils/global.js'
18
- import { resolveAccount } from './channel/index.js'
19
- import { ensureExpertAgentWorkspaceBinding, isAgentInstalled, isAgentInstalling, retryDownloadAgent, waitForAgentInstall } from './agent.js'
20
- import { sendFinal, sendText as sendTextMsg, sendError, sendText } from './transport.js'
21
- import { dcgLogger } from './utils/log.js'
22
- import { agentErrorUserHint } from './utils/agentErrors.js'
23
- import { isInsufficientBalanceAssistantText } from './utils/passTxt.js'
24
- import { systemCommand, stopCommand, ignoreToolCommand, CHANNEL_ID, AFTER_STOP_SETTLE_MS, SUBAGENT_IDLE_TIMEOUT_MS } from './utils/constant.js'
25
- import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
26
- import { resetSubagentStateForRequesterSession, waitUntilSubagentsIdle, stringifyToolHookPayload, sendToolCallMessage } from './tool.js'
27
- import { createStreamMergeController } from './utils/streamMerge.js'
28
- import { createMediaEmitter } from './utils/mediaEmitter.js'
29
- import {
30
- beginDispatchAbortForInboundTurn,
31
- clearActiveRunIdForSession,
32
- clearSessionStreamSuppression,
33
- isSessionStreamSuppressed,
34
- releaseDispatchAbortIfCurrent,
35
- requestStopInterruption,
36
- runInboundTurnSequenced,
37
- setActiveRunIdForSession,
38
- waitForStopInterruption
39
- } from './utils/sessionTermination.js'
40
- import {
41
- buildAgentInboundBodySuffix,
42
- buildDcgchatInboundMediaPayload,
43
- normalizeInboundFileList,
44
- resolveMediaFromUrls,
45
- type DcgchatInboundMediaInfo
46
- } from './utils/mediaAttached.js'
47
- import {
48
- bumpInboundGeneration,
49
- clearStopSeqForSession,
50
- deleteStreamChunkIdxForSession,
51
- getInboundGeneration,
52
- getStopSeq,
53
- resetStreamChunkIdxForSession
54
- } from './utils/inboundTurnState.js'
55
- import {
56
- clearOutboundTextState,
57
- emitOutboundAssistantText,
58
- outboundHasEmittedAssistantText,
59
- outboundHasStreamedAssistantText,
60
- resetOutboundTextState,
61
- resetOutboundTextSnapshot
62
- } from './utils/outboundAssistantText.js'
63
-
64
- /**
65
- * 本轮入站**已正常收尾**时清理该 sessionKey 的内存态(params、去重、分片、msgStatus、子 agent 本地跟踪等)。
66
- * 不可在「stale」或已有新入站占用的场景调用,否则会删掉新一轮写入的 `paramsMessageMap`。
67
- * 保留入站 generation(单调计数,见 inboundTurnState);下一轮 turn 开始时会 bump,旧 handler 用 gen 不等判定 stale。
68
- */
69
- function clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey: string, messageId: string | undefined) {
70
- clearParamsMessage(dcgSessionKey)
71
- deleteStreamChunkIdxForSession(dcgSessionKey)
72
- clearSessionStreamSuppression(dcgSessionKey)
73
- clearActiveRunIdForSession(dcgSessionKey)
74
- setMsgStatus(dcgSessionKey, '')
75
- // 出站媒体去重 bucket:写入时为 `resolveOutboundMediaDedupeKey(messageId, sessionId, sessionKey)` 的结果,
76
- // 可能是 messageId / sessionId / sessionKey 之一。turn 收尾时按这三者都清一次,覆盖 messageId 为空的兜底场景。
77
- if (messageId) clearSentMediaKeys(messageId)
78
- const { sessionId: tookApartSessionId } = takeApartSessionKey(dcgSessionKey)
79
- if (tookApartSessionId) clearSentMediaKeys(tookApartSessionId)
80
- clearSentMediaKeys(dcgSessionKey)
81
- // cronMessageIdMap:sendDcgchatMedia 兜底路径会写一条永不消费的 entry(见 global.removeAllCronMessageIdsForSession 注释),
82
- // turn 收尾时整条队列即可视为失效。
83
- removeAllCronMessageIdsForSession(dcgSessionKey)
84
- resetSubagentStateForRequesterSession(dcgSessionKey)
85
- clearOutboundTextState(dcgSessionKey)
86
- }
87
-
88
- const DEFAULT_AGENT_ID = 'main'
89
- const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i
90
- const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/g
91
- const LEADING_AGENT_ID_DASH_RE = /^-+/
92
- const TRAILING_AGENT_ID_DASH_RE = /-+$/
93
-
94
- type HumanDelayConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig['agents']>['defaults']>['humanDelay']>
95
-
96
- function normalizeDcgAgentId(value: string | undefined): string {
97
- const trimmed = (value ?? '').trim()
98
- if (!trimmed) return DEFAULT_AGENT_ID
99
- const normalized = trimmed.toLowerCase()
100
- if (VALID_AGENT_ID_RE.test(trimmed)) return normalized
101
- return (
102
- normalized.replace(INVALID_AGENT_ID_CHARS_RE, '-').replace(LEADING_AGENT_ID_DASH_RE, '').replace(TRAILING_AGENT_ID_DASH_RE, '').slice(0, 64) ||
103
- DEFAULT_AGENT_ID
104
- )
105
- }
106
-
107
- function resolveDcgHumanDelayConfig(cfg: OpenClawConfig, agentId: string | undefined): HumanDelayConfig | undefined {
108
- const defaults = cfg.agents?.defaults?.humanDelay
109
- const normalizedAgentId = normalizeDcgAgentId(agentId)
110
- const overrides = cfg.agents?.list?.find((entry) => normalizeDcgAgentId(entry.id) === normalizedAgentId)?.humanDelay
111
- if (!defaults && !overrides) return undefined
112
- return {
113
- mode: overrides?.mode ?? defaults?.mode,
114
- minMs: overrides?.minMs ?? defaults?.minMs,
115
- maxMs: overrides?.maxMs ?? defaults?.maxMs
116
- }
117
- }
118
-
119
- const typingCallbacks = createTypingCallbacks({
120
- start: async () => {},
121
- onStartError: (err) => {
122
- dcgLogger(`typing start error: ${String(err)}`, 'error')
123
- }
124
- })
125
-
126
- function sendAbortFinalForInterruptedTurn(ctx: IMsgParams, stopMessageId: string | undefined, sessionKey: string): void {
127
- const interruptedMessageId = ctx.messageId?.trim()
128
- if (!interruptedMessageId || interruptedMessageId === stopMessageId?.trim()) return
129
- sendFinal(ctx, 'abort')
130
- }
131
-
132
- /** `/stop` 是 control message:立即 ack 当前消息,再用 stop barrier 终止旧 run 并阻塞后续普通消息。 */
133
- async function handleStopControlMessage(msg: InboundMessage, accountId: string, interruptedOutboundCtx: IMsgParams | undefined): Promise<void> {
134
- const config = getOpenClawConfig()
135
- if (!config) {
136
- return
137
- }
138
- const account = resolveAccount(config, accountId)
139
- const dcgSessionKey = getSessionKey(msg.content, account.accountId)
140
- const conversationId = msg.content.session_id?.trim()
141
- const mergedParams = {
142
- userId: msg._userId,
143
- botToken: msg.content.bot_token,
144
- sessionId: conversationId,
145
- messageId: msg.content.message_id,
146
- domainId: msg.content.domain_id,
147
- appId: config.channels?.[CHANNEL_ID]?.appId || 100,
148
- botId: msg.content.bot_id ?? '',
149
- agentId: msg.content.agent_id ?? '',
150
- sessionKey: dcgSessionKey,
151
- real_mobook: msg.content.real_mobook?.toString().trim()
152
- }
153
- setParamsMessage(dcgSessionKey, mergedParams)
154
- const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
155
- sendFinal(outboundCtx, 'stop')
156
- const { started, barrier } = requestStopInterruption(dcgSessionKey)
157
- if (started && interruptedOutboundCtx) {
158
- sendAbortFinalForInterruptedTurn(interruptedOutboundCtx, msg.content.message_id, dcgSessionKey)
159
- }
160
- dcgLogger(`stop control ack: sessionKey=${dcgSessionKey} messageId=${msg.content.message_id} started=${started}`)
161
- await barrier
162
- }
163
-
164
- /**
165
- * 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
166
- */
167
- export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
168
- const config = getOpenClawConfig()
169
- if (!config) {
170
- dcgLogger('no OpenClaw config available', 'error')
171
- return
172
- }
173
- const account = resolveAccount(config, accountId)
174
- const queueSessionKey = getSessionKey(msg.content, account.accountId)
175
- const queueText = (msg.content.text ?? '').trim()
176
- const isStopInbound = stopCommand.includes(queueText)
177
-
178
- const interruptedOutboundCtx = isStopInbound ? getEffectiveMsgParams(queueSessionKey) : undefined
179
- if (isStopInbound) {
180
- await handleStopControlMessage(msg, accountId, interruptedOutboundCtx)
181
- return
182
- }
183
-
184
- await waitForStopInterruption(queueSessionKey)
185
- await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
186
- }
187
-
188
- async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
189
- let config = getOpenClawConfig()
190
- if (!config) {
191
- return
192
- }
193
- const account = resolveAccount(config, accountId)
194
-
195
- const core = getDcgchatRuntime()
196
-
197
- const conversationId = msg.content.session_id?.trim()
198
- const real_mobook = msg.content.real_mobook?.toString().trim()
199
-
200
- const agentCloneCode = msg.content?.agent_clone_code?.trim() || ''
201
-
202
- if (agentCloneCode) {
203
- await ensureExpertAgentWorkspaceBinding(agentCloneCode)
204
- const latest = getOpenClawConfig()
205
- if (latest) config = latest
206
- }
207
-
208
- let route = core.channel.routing.resolveAgentRoute({
209
- cfg: config,
210
- channel: CHANNEL_ID,
211
- accountId: account.accountId,
212
- peer: { kind: 'direct', id: conversationId }
213
- })
214
-
215
- let effectiveAgentId = route.agentId
216
- const dcgSessionKey = getSessionKey(msg.content, account.accountId)
217
-
218
- const previousStopSeq = getStopSeq(dcgSessionKey)
219
-
220
- // 每轮入站先卸掉可能残留的流抑制(如 /stop 与 dispatch 收尾竞态),避免 deliver 全程静默 return
221
- clearSessionStreamSuppression(dcgSessionKey)
222
- const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
223
-
224
- const isAfterStop = previousStopSeq > 0 && !stopCommand.includes((msg.content.text ?? '').trim())
225
-
226
- const mergedParams = {
227
- userId: msg._userId,
228
- botToken: msg.content.bot_token,
229
- sessionId: conversationId,
230
- messageId: msg.content.message_id,
231
- domainId: msg.content.domain_id,
232
- appId: config.channels?.[CHANNEL_ID]?.appId || 100,
233
- botId: msg.content.bot_id ?? '',
234
- agentId: msg.content.agent_id ?? '',
235
- sessionKey: dcgSessionKey,
236
- real_mobook
237
- }
238
- setParamsMessage(dcgSessionKey, mergedParams)
239
- const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
240
- let agentDisplayName = null
241
-
242
- let text = msg.content.text?.trim()
243
-
244
- if (!text) {
245
- sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
246
- sendFinal(outboundCtx, 'not text')
247
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
248
- return
249
- }
250
-
251
- if (stopCommand.includes(text)) {
252
- sendFinal(outboundCtx, 'stop')
253
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
254
- return
255
- }
256
-
257
- // 校验 agent 是否安装。gateway 重连等场景下可能漏掉安装事件,导致
258
- // agent 文件不存在。若正在安装则等待结果;若未安装或安装失败,利用
259
- // 缓存的 URL 尝试一次重新下载(只试一次),仍失败则提示网络异常。
260
- if (agentCloneCode && dcgSessionKey.includes(agentCloneCode) && !isAgentInstalled(agentCloneCode)) {
261
- let agentReady = false
262
-
263
- if (isAgentInstalling(agentCloneCode)) {
264
- const installOk = await waitForAgentInstall(agentCloneCode)
265
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
266
- dcgLogger(
267
- `skip stale install result: sessionKey=${dcgSessionKey} inboundGen=${inboundGenAtStart} current=${getInboundGeneration(dcgSessionKey)}`
268
- )
269
- return
270
- }
271
- const isInstalled = isAgentInstalled(agentCloneCode)
272
- if (!installOk || !isInstalled) {
273
- agentReady = await retryDownloadAgent(agentCloneCode)
274
- } else {
275
- agentReady = true
276
- }
277
- } else {
278
- dcgLogger(`retry install because the agent not installed, ${agentCloneCode}`)
279
- agentReady = await retryDownloadAgent(agentCloneCode)
280
- }
281
-
282
- if (!agentReady || !isAgentInstalled(agentCloneCode)) {
283
- dcgLogger(`receive agentCloneCode, but agent not ready: ${agentCloneCode}`)
284
- sendTextMsg('抱歉,网络异常,请稍后重试', outboundCtx, { message_tags: { source: 'agent_not_found' }, is_finish: -1 })
285
- sendFinal(outboundCtx, 'agent_not_installed')
286
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
287
- return
288
- }
289
-
290
- await ensureExpertAgentWorkspaceBinding(agentCloneCode)
291
- const latestAfterInstall = getOpenClawConfig()
292
- if (latestAfterInstall) config = latestAfterInstall
293
- route = core.channel.routing.resolveAgentRoute({
294
- cfg: config,
295
- channel: CHANNEL_ID,
296
- accountId: account.accountId,
297
- peer: { kind: 'direct', id: conversationId }
298
- })
299
- effectiveAgentId = route.agentId
300
- agentDisplayName = config.agents?.list?.find((a) => a.id === effectiveAgentId)?.name || effectiveAgentId || null
301
- }
302
-
303
- try {
304
- let dispatchReplyErrorHandledByOnError = false
305
- if ((msg.content.skills_scope?.length ?? 0) > 0 && !ignoreToolCommand.includes(text?.trim()) && !agentCloneCode) {
306
- const workspaceDir = getWorkspaceDir()
307
- const skill = msg.content.skills_scope?.[0]
308
- if (skill?.skill_code) {
309
- const skillDir = path.join(workspaceDir, 'skills', skill.skill_code)
310
- const skillText = `用户选择使用此技能:"${skill.skill_code}",技能路径是:"${skillDir}",在目录下查找并`
311
- text = `${skillText} ${text}`
312
- }
313
- }
314
- // 处理用户上传的文件(兼容多种下行字段名与单文件对象)
315
- const files = normalizeInboundFileList(msg.content as Record<string, unknown>)
316
- let mediaListResolved: DcgchatInboundMediaInfo[] = []
317
- let mediaPayload: Record<string, unknown> = {}
318
- if (files.length > 0) {
319
- // 从 OSS 下载 附件内容
320
- mediaListResolved = await resolveMediaFromUrls(files, msg.content.bot_token)
321
- mediaPayload = buildDcgchatInboundMediaPayload(mediaListResolved)
322
- if (isCollectionReviewer(dcgSessionKey)) {
323
- text += `\n files=${JSON.stringify(files)}`
324
- }
325
- }
326
-
327
- const agentInboundText = buildAgentInboundBodySuffix(text, mediaListResolved)
328
-
329
- const ctxPayload = core.channel.inbound.buildContext({
330
- channel: CHANNEL_ID,
331
- accountId: route.accountId,
332
- provider: CHANNEL_ID,
333
- surface: CHANNEL_ID,
334
- messageId: msg.content.message_id,
335
- timestamp: Date.now(),
336
- from: dcgSessionKey,
337
- sender: {
338
- id: dcgSessionKey,
339
- name: agentDisplayName ?? undefined
340
- },
341
- conversation: {
342
- kind: 'direct',
343
- id: conversationId ?? dcgSessionKey
344
- },
345
- route: {
346
- agentId: effectiveAgentId ?? '',
347
- accountId: route.accountId,
348
- routeSessionKey: dcgSessionKey,
349
- dispatchSessionKey: dcgSessionKey
350
- },
351
- reply: {
352
- to: dcgSessionKey,
353
- originatingTo: dcgSessionKey
354
- },
355
- message: {
356
- body: agentInboundText,
357
- bodyForAgent: agentInboundText,
358
- rawBody: text,
359
- commandBody: text
360
- },
361
- access: {
362
- mentions: {
363
- canDetectMention: true,
364
- wasMentioned: true
365
- }
366
- },
367
- channelContext: {
368
- sender: {
369
- id: dcgSessionKey
370
- },
371
- chat: {
372
- id: conversationId ?? dcgSessionKey,
373
- sessionId: conversationId,
374
- domainId: msg.content.domain_id,
375
- appId: msg.content.app_id,
376
- botId: msg.content.bot_id,
377
- agentId: msg.content.agent_id
378
- }
379
- },
380
- media: mediaListResolved.map((m) => ({
381
- path: m.path,
382
- contentType: m.contentType || undefined,
383
- messageId: msg.content.message_id
384
- })),
385
- extra: {
386
- Target: dcgSessionKey,
387
- SourceTarget: dcgSessionKey,
388
- SuppressMessageReceivedHooks: true,
389
- ...mediaPayload
390
- }
391
- })
392
- const storePath = core.channel.session.resolveStorePath(config.session?.store, { agentId: effectiveAgentId })
393
-
394
- const mediaEmitter = createMediaEmitter({
395
- sessionKey: dcgSessionKey,
396
- onEmit: () => {
397
- hasEmittedAssistantMedia = true
398
- }
399
- })
400
- let hasEmittedAssistantMedia = false
401
- // bot 流式/deliver 与 dcgchat_message 共用 outboundAssistantText 快照,避免双发或漏发
402
- resetOutboundTextState(dcgSessionKey, inboundGenAtStart)
403
- let partialTextSnapshot = ''
404
- const streamMerge = createStreamMergeController({
405
- sessionKey: dcgSessionKey,
406
- inboundGenAtStart,
407
- outboundCtx
408
- })
409
-
410
- const prefixContext = createReplyPrefixContext({
411
- cfg: config,
412
- agentId: effectiveAgentId ?? '',
413
- channel: CHANNEL_ID,
414
- accountId: account.accountId
415
- })
416
-
417
- const createDcgDeliveryAdapter = () => ({
418
- deliver: async (payload: ReplyPayload, info: { kind: string }) => {
419
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) return
420
- if (isSessionStreamSuppressed(dcgSessionKey)) return
421
- if (isInsufficientBalanceAssistantText(payload?.text)) {
422
- dcgLogger(`[deliver]: pass text — skip raw insufficient-balance assistant line (plugin llm_output sends user copy)`)
423
- return
424
- }
425
- await mediaEmitter.emitFromPayload(payload)
426
- // 未真正发出过流式文本时,deliver/final 快照仍应作为正文兜底发送。
427
- if (payload?.text?.trim() && !outboundHasStreamedAssistantText(dcgSessionKey)) {
428
- emitOutboundAssistantText(dcgSessionKey, payload.text, outboundCtx, {
429
- isError: payload.isError === true,
430
- emitDelta: (delta) => streamMerge.queueDelta(delta)
431
- })
432
- }
433
- const p = payload as Record<string, unknown>
434
- const mediaCount = Array.isArray(p.mediaUrls) ? p.mediaUrls.length : p.mediaUrl ? 1 : 0
435
- dcgLogger(
436
- `[deliver]: kind=${info.kind}, text.length=${payload?.text?.length}, media.length=${mediaCount}, sessionId=${outboundCtx.sessionId}, ${formatText(payload?.text ?? '')}`
437
- )
438
- },
439
- onError: (err: unknown, info: { kind: string }) => {
440
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
441
- dcgLogger(`${info.kind} reply failed (stale handler, ignored): ${String(err)}`)
442
- return
443
- }
444
- dispatchReplyErrorHandledByOnError = true
445
- const suppressed = isSessionStreamSuppressed(dcgSessionKey)
446
- streamMerge.flush(true)
447
- const userHint = agentErrorUserHint(err)
448
- if (!suppressed && userHint) {
449
- sendText(userHint, outboundCtx)
450
- }
451
- sendFinal(outboundCtx, 'error')
452
- dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
453
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
454
- }
455
- })
456
-
457
- const createDcgReplyDispatcherOptions = () => ({
458
- responsePrefix: prefixContext.responsePrefix,
459
- responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
460
- humanDelay: resolveDcgHumanDelayConfig(config, effectiveAgentId),
461
- onReplyStart: async () => {},
462
- onIdle: () => {
463
- typingCallbacks.onIdle?.()
464
- }
465
- })
466
-
467
- // /stop 后立即发下一条时,宿主 reply run 可能尚未从 registry 释放 → 无 llm_input、秒 end。短延迟缓解竞态。
468
- if (isAfterStop) {
469
- if (AFTER_STOP_SETTLE_MS > 0) {
470
- dcgLogger(`after-stop settle: waiting ${AFTER_STOP_SETTLE_MS}ms before dispatch sessionKey=${dcgSessionKey}`)
471
- await new Promise<void>((r) => setTimeout(r, AFTER_STOP_SETTLE_MS))
472
- }
473
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) return
474
- clearStopSeqForSession(dcgSessionKey)
475
- clearSessionStreamSuppression(dcgSessionKey)
476
- }
477
-
478
- let dispatchAbort: AbortController | undefined
479
- try {
480
- resetStreamChunkIdxForSession(dcgSessionKey, 0)
481
- dispatchAbort = beginDispatchAbortForInboundTurn(dcgSessionKey)
482
- if (!ignoreToolCommand.includes(text?.trim())) {
483
- const { text: thinkingContent, callId } = stringifyToolHookPayload('message_received', {}, {}, 'lifecycle')
484
- sendToolCallMessage(dcgSessionKey, thinkingContent, callId, 0)
485
- }
486
- const dispatchReplyAttempt = async () => {
487
- const dispatcherOptions = createDcgReplyDispatcherOptions()
488
- const delivery = createDcgDeliveryAdapter()
489
- await core.channel.inbound.dispatchReply({
490
- cfg: config,
491
- channel: CHANNEL_ID,
492
- accountId: route.accountId,
493
- agentId: effectiveAgentId ?? '',
494
- routeSessionKey: dcgSessionKey,
495
- storePath,
496
- ctxPayload,
497
- recordInboundSession: core.channel.session.recordInboundSession,
498
- dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
499
- delivery,
500
- dispatcherOptions,
501
- record: {
502
- updateLastRoute: {
503
- sessionKey: dcgSessionKey,
504
- channel: CHANNEL_ID,
505
- to: dcgSessionKey,
506
- accountId: route.accountId
507
- },
508
- onRecordError: (err) => {
509
- dcgLogger(` session record error: ${String(err)}`, 'error')
510
- }
511
- },
512
- messageId: msg.content.message_id,
513
- replyOptions: {
514
- abortSignal: dispatchAbort!.signal,
515
- onModelSelected: prefixContext.onModelSelected,
516
- onAgentRunStart: (runId) => {
517
- setActiveRunIdForSession(dcgSessionKey, runId)
518
- },
519
- onPartialReply: async (payload: ReplyPayload) => {
520
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) return
521
- if (isSessionStreamSuppressed(dcgSessionKey)) return
522
- if (isInsufficientBalanceAssistantText(payload.text)) {
523
- return
524
- }
525
- const p = payload as Record<string, unknown>
526
- // --- Streaming text chunks / deltas ---
527
- if (p.replace === true) {
528
- resetOutboundTextSnapshot(dcgSessionKey)
529
- streamMerge.flush(true)
530
- if (typeof p.text === 'string' && p.text.trim()) {
531
- partialTextSnapshot = p.text
532
- emitOutboundAssistantText(dcgSessionKey, p.text, outboundCtx, {
533
- markStreamed: true,
534
- isError: payload.isError === true,
535
- emitDelta: (delta) => streamMerge.queueDelta(delta)
536
- })
537
- }
538
- } else if (typeof p.delta === 'string' && p.delta) {
539
- partialTextSnapshot += p.delta
540
- emitOutboundAssistantText(dcgSessionKey, partialTextSnapshot, outboundCtx, {
541
- markStreamed: true,
542
- isError: payload.isError === true,
543
- emitDelta: (delta) => streamMerge.queueDelta(delta)
544
- })
545
- } else if (payload.text) {
546
- partialTextSnapshot = payload.text
547
- emitOutboundAssistantText(dcgSessionKey, payload.text, outboundCtx, {
548
- markStreamed: true,
549
- isError: payload.isError === true,
550
- emitDelta: (delta) => streamMerge.queueDelta(delta)
551
- })
552
- } else {
553
- dcgLogger(`[onPartialReply]: no text (media/tool metadata) keys=${Object.keys(p).join(',')}`)
554
- }
555
- // --- Media from payload ---
556
- await mediaEmitter.emitFromPayload(payload)
557
- }
558
- }
559
- })
560
- }
561
- await dispatchReplyAttempt()
562
- streamMerge.flush(true)
563
- } catch (err: unknown) {
564
- dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
565
- if (getInboundGeneration(dcgSessionKey) === inboundGenAtStart && !dispatchReplyErrorHandledByOnError) {
566
- streamMerge.flush(true)
567
- if (!isSessionStreamSuppressed(dcgSessionKey)) {
568
- sendText(agentErrorUserHint(err) ?? '抱歉,这次对话执行时发生异常,请稍后重试。', outboundCtx)
569
- }
570
- sendFinal(outboundCtx, 'error')
571
- releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
572
- streamMerge.dispose()
573
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
574
- return
575
- }
576
- if (getInboundGeneration(dcgSessionKey) === inboundGenAtStart && dispatchReplyErrorHandledByOnError) {
577
- releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
578
- streamMerge.dispose()
579
- return
580
- }
581
- } finally {
582
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
583
- releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
584
- streamMerge.dispose()
585
- }
586
- }
587
-
588
- const inboundGenNow = getInboundGeneration(dcgSessionKey)
589
- if (inboundGenNow !== inboundGenAtStart) {
590
- streamMerge.dispose()
591
- dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
592
- return
593
- }
594
-
595
- if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
596
- if (isSessionStreamSuppressed(dcgSessionKey)) {
597
- clearSessionStreamSuppression(dcgSessionKey)
598
- }
599
- }
600
- clearSentMediaKeys(msg.content.message_id)
601
- try {
602
- try {
603
- await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: SUBAGENT_IDLE_TIMEOUT_MS, signal: dispatchAbort?.signal })
604
- } catch (err) {
605
- if (dispatchAbort?.signal.aborted || getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
606
- throw err
607
- }
608
- const message = err instanceof Error ? err.message : String(err)
609
- if (/^waitUntilSubagentsIdle timeout /u.test(message)) {
610
- dcgLogger(`subagent idle wait timeout, force cleanup: sessionKey=${dcgSessionKey}, err=${message}`, 'error')
611
- resetSubagentStateForRequesterSession(dcgSessionKey)
612
- } else {
613
- throw err
614
- }
615
- }
616
- // 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
617
- if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
618
- streamMerge.dispose()
619
- dcgLogger(
620
- `skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${getInboundGeneration(dcgSessionKey)})`
621
- )
622
- return
623
- }
624
- } finally {
625
- releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
626
- }
627
- streamMerge.flush(true)
628
- if (!outboundHasEmittedAssistantText(dcgSessionKey) && !hasEmittedAssistantMedia) {
629
- dcgLogger(`empty assistant reply: sessionKey=${dcgSessionKey}, messageId=${msg.content.message_id}`, 'error')
630
- }
631
- if (!isReviserExpert(dcgSessionKey)) {
632
- sendFinal(outboundCtx, 'end')
633
- } else {
634
- dcgLogger(`审校专家跳过终止 sessionKey=${dcgSessionKey}, messageId=${msg.content.message_id}`)
635
- }
636
- streamMerge.dispose()
637
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
638
- } catch (err) {
639
- dcgLogger(` handle message failed: ${String(err)}`, 'error')
640
- if (getInboundGeneration(dcgSessionKey) === inboundGenAtStart) {
641
- const rawErr = err instanceof Error ? err.message : String(err)
642
- sendError(agentErrorUserHint(err) ?? rawErr, outboundCtx)
643
- clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
644
- }
645
- }
646
- }