@dcrays/dcgchat 0.4.18 → 0.4.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bot.ts +88 -50
- package/src/channel.ts +151 -35
- package/src/cron.ts +6 -2
- package/src/cronToolCall.ts +44 -29
- package/src/sessionTermination.ts +154 -0
- package/src/tool.ts +15 -8
- package/src/transport.ts +2 -1
- package/src/utils/constant.ts +2 -2
- package/src/utils/gatewayMsgHanlder.ts +6 -1
- package/src/utils/global.ts +8 -8
package/package.json
CHANGED
package/src/bot.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import type { ReplyPayload } from 'openclaw/plugin-sdk'
|
|
3
4
|
import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
|
|
@@ -10,14 +11,24 @@ import {
|
|
|
10
11
|
getWorkspaceDir,
|
|
11
12
|
setMsgStatus
|
|
12
13
|
} from './utils/global.js'
|
|
13
|
-
import { resolveAccount, sendDcgchatMedia } from './channel.js'
|
|
14
|
+
import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
|
|
14
15
|
import { generateSignUrl } from './request/api.js'
|
|
15
16
|
import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
|
|
16
17
|
import { dcgLogger } from './utils/log.js'
|
|
17
|
-
import { channelInfo, systemCommand,
|
|
18
|
-
import { sendGatewayRpc } from './gateway/socket.js'
|
|
18
|
+
import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
|
|
19
19
|
import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
|
|
20
|
-
import {
|
|
20
|
+
import { waitUntilSubagentsIdle } from './tool.js'
|
|
21
|
+
import {
|
|
22
|
+
beginSupersedingUserTurn,
|
|
23
|
+
clearActiveRunIdForSession,
|
|
24
|
+
clearSessionStreamSuppression,
|
|
25
|
+
interruptLocalDispatchAndGateway,
|
|
26
|
+
isSessionStreamSuppressed,
|
|
27
|
+
preemptInboundQueueForStop,
|
|
28
|
+
releaseDispatchAbortIfCurrent,
|
|
29
|
+
runInboundTurnSequenced,
|
|
30
|
+
setActiveRunIdForSession
|
|
31
|
+
} from './sessionTermination.js'
|
|
21
32
|
|
|
22
33
|
type MediaInfo = {
|
|
23
34
|
path: string
|
|
@@ -30,14 +41,18 @@ type TFileInfo = { name: string; url: string }
|
|
|
30
41
|
|
|
31
42
|
const mediaMaxBytes = 300 * 1024 * 1024
|
|
32
43
|
|
|
33
|
-
/** 当前会话最近一次 agent run 的 runId(供观测;/stop 时对子会话与主会话使用不传 runId 的 chat.abort 以终止该键下全部活跃 run) */
|
|
34
|
-
const activeRunIdBySessionKey = new Map<string, string>()
|
|
35
|
-
|
|
36
44
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
45
|
+
* 每条入站消息(含 /stop)递增。用于丢弃「已被后续入站抢占」的处理在 dispatch 结束后的尾部逻辑。
|
|
46
|
+
* 仅 /stop 时的 epoch 无法覆盖「新用户消息抢占上一轮」的并发 WS 场景,会导致旧 handler 仍执行 sendFinal(end)、
|
|
47
|
+
* 而新消息被误判为 stale;且上一轮若未对网关 chat.abort,服务端 run 会继续,表现为「新消息秒结束、旧回复复活」。
|
|
39
48
|
*/
|
|
40
|
-
const
|
|
49
|
+
const inboundGenerationBySessionKey = new Map<string, number>()
|
|
50
|
+
|
|
51
|
+
function bumpInboundGeneration(sessionKey: string): number {
|
|
52
|
+
const n = (inboundGenerationBySessionKey.get(sessionKey) ?? 0) + 1
|
|
53
|
+
inboundGenerationBySessionKey.set(sessionKey, n)
|
|
54
|
+
return n
|
|
55
|
+
}
|
|
41
56
|
|
|
42
57
|
/** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
|
|
43
58
|
const streamChunkIdxBySessionKey = new Map<string, number>()
|
|
@@ -128,8 +143,11 @@ function buildMediaPayload(mediaList: MediaInfo[]): MediaPayload {
|
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
function resolveReplyMediaList(payload: ReplyPayload): string[] {
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
const p = payload as { mediaUrls?: unknown[]; mediaUrl?: unknown }
|
|
147
|
+
if (p.mediaUrls != null && Array.isArray(p.mediaUrls) && p.mediaUrls.length > 0) {
|
|
148
|
+
return normalizeOutboundMediaPaths(p.mediaUrls)
|
|
149
|
+
}
|
|
150
|
+
return normalizeOutboundMediaPaths(p.mediaUrl ?? null)
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
const typingCallbacks = createTypingCallbacks({
|
|
@@ -140,7 +158,7 @@ const typingCallbacks = createTypingCallbacks({
|
|
|
140
158
|
})
|
|
141
159
|
|
|
142
160
|
/**
|
|
143
|
-
* 处理一条用户消息,调用 Agent
|
|
161
|
+
* 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
|
|
144
162
|
*/
|
|
145
163
|
export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
|
|
146
164
|
const config = getOpenClawConfig()
|
|
@@ -149,6 +167,20 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
149
167
|
return
|
|
150
168
|
}
|
|
151
169
|
const account = resolveAccount(config, accountId)
|
|
170
|
+
const queueSessionKey = getSessionKey(msg.content, account.accountId)
|
|
171
|
+
const queueText = (msg.content.text ?? '').trim()
|
|
172
|
+
if (stopCommand.includes(queueText)) {
|
|
173
|
+
preemptInboundQueueForStop(queueSessionKey)
|
|
174
|
+
}
|
|
175
|
+
await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
|
|
179
|
+
const config = getOpenClawConfig()
|
|
180
|
+
if (!config) {
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const account = resolveAccount(config, accountId)
|
|
152
184
|
const userId = msg._userId.toString()
|
|
153
185
|
|
|
154
186
|
const core = getDcgchatRuntime()
|
|
@@ -167,6 +199,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
167
199
|
|
|
168
200
|
const effectiveAgentId = embeddedAgentId ?? route.agentId
|
|
169
201
|
const dcgSessionKey = getSessionKey(msg.content, account.accountId)
|
|
202
|
+
const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
|
|
170
203
|
|
|
171
204
|
const mergedParams = {
|
|
172
205
|
userId: msg._userId,
|
|
@@ -174,7 +207,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
174
207
|
sessionId: conversationId,
|
|
175
208
|
messageId: msg.content.message_id,
|
|
176
209
|
domainId: msg.content.domain_id,
|
|
177
|
-
appId:
|
|
210
|
+
appId: config.channels?.["dcgchat"]?.appId || 100,
|
|
178
211
|
botId: msg.content.bot_id ?? '',
|
|
179
212
|
agentId: msg.content.agent_id ?? '',
|
|
180
213
|
sessionKey: dcgSessionKey,
|
|
@@ -194,6 +227,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
194
227
|
if (!text) {
|
|
195
228
|
sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
|
|
196
229
|
sendFinal(outboundCtx, 'not text')
|
|
230
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
197
231
|
return
|
|
198
232
|
}
|
|
199
233
|
|
|
@@ -274,7 +308,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
274
308
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
|
|
275
309
|
onReplyStart: async () => {},
|
|
276
310
|
deliver: async (payload: ReplyPayload, info) => {
|
|
277
|
-
if (
|
|
311
|
+
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
278
312
|
const mediaList = resolveReplyMediaList(payload)
|
|
279
313
|
for (const mediaUrl of mediaList) {
|
|
280
314
|
const key = getMediaKey(mediaUrl)
|
|
@@ -287,9 +321,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
287
321
|
onError: (err: unknown, info: { kind: string }) => {
|
|
288
322
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
289
323
|
sendFinal(outboundCtx, 'error')
|
|
290
|
-
|
|
324
|
+
clearActiveRunIdForSession(dcgSessionKey)
|
|
291
325
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
292
|
-
const suppressed =
|
|
326
|
+
const suppressed = isSessionStreamSuppressed(dcgSessionKey)
|
|
293
327
|
dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
|
|
294
328
|
},
|
|
295
329
|
onIdle: () => {
|
|
@@ -297,10 +331,11 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
297
331
|
}
|
|
298
332
|
})
|
|
299
333
|
|
|
334
|
+
let dispatchAbort: AbortController | undefined
|
|
300
335
|
try {
|
|
301
|
-
if (!
|
|
302
|
-
sessionStreamSuppressed.delete(dcgSessionKey)
|
|
336
|
+
if (!stopCommand.includes(text?.trim())) {
|
|
303
337
|
streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
|
|
338
|
+
dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
|
|
304
339
|
}
|
|
305
340
|
|
|
306
341
|
if (systemCommand.includes(text?.trim())) {
|
|
@@ -315,40 +350,19 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
315
350
|
dispatcher,
|
|
316
351
|
replyOptions: {
|
|
317
352
|
...replyOptions,
|
|
353
|
+
abortSignal: dispatchAbort!.signal,
|
|
318
354
|
onModelSelected: prefixContext.onModelSelected,
|
|
319
355
|
onAgentRunStart: (runId) => {
|
|
320
|
-
|
|
356
|
+
setActiveRunIdForSession(dcgSessionKey, runId)
|
|
321
357
|
}
|
|
322
358
|
}
|
|
323
359
|
})
|
|
324
360
|
})
|
|
325
|
-
} else if (
|
|
326
|
-
dcgLogger(`interrupt command: ${text}`)
|
|
361
|
+
} else if (stopCommand.includes(text?.trim())) {
|
|
327
362
|
const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
|
|
328
|
-
|
|
329
|
-
sessionStreamSuppressed.add(dcgSessionKey)
|
|
330
|
-
// 网关侧彻底中止:子 agent 使用独立 sessionKey,仅 abort 主会话不会停子 run;自深到浅 abort 后代,再 abort 主会话;不传 runId 以终止该键下全部活跃 chat run
|
|
331
|
-
const descendantKeys = getDescendantSessionKeysForRequester(dcgSessionKey)
|
|
332
|
-
const abortSubKeys = [...descendantKeys].reverse()
|
|
333
|
-
if (abortSubKeys.length > 0) {
|
|
334
|
-
dcgLogger(`interrupt: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
|
|
335
|
-
}
|
|
336
|
-
for (const subKey of abortSubKeys) {
|
|
337
|
-
try {
|
|
338
|
-
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
|
|
339
|
-
} catch (e) {
|
|
340
|
-
dcgLogger(`chat.abort subagent ${subKey}: ${String(e)}`, 'error')
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
try {
|
|
344
|
-
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
|
|
345
|
-
} catch (e) {
|
|
346
|
-
dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
|
|
347
|
-
}
|
|
348
|
-
activeRunIdBySessionKey.delete(dcgSessionKey)
|
|
363
|
+
await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
|
|
349
364
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
350
365
|
clearSentMediaKeys(msg.content.message_id)
|
|
351
|
-
resetSubagentStateForRequesterSession(dcgSessionKey)
|
|
352
366
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
353
367
|
clearParamsMessage(dcgSessionKey)
|
|
354
368
|
sendText('会话已终止', outboundCtx)
|
|
@@ -381,12 +395,13 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
381
395
|
dispatcher,
|
|
382
396
|
replyOptions: {
|
|
383
397
|
...replyOptions,
|
|
398
|
+
abortSignal: dispatchAbort!.signal,
|
|
384
399
|
onModelSelected: prefixContext.onModelSelected,
|
|
385
400
|
onAgentRunStart: (runId) => {
|
|
386
|
-
|
|
401
|
+
setActiveRunIdForSession(dcgSessionKey, runId)
|
|
387
402
|
},
|
|
388
403
|
onPartialReply: async (payload: ReplyPayload) => {
|
|
389
|
-
if (
|
|
404
|
+
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
390
405
|
|
|
391
406
|
// --- Streaming text chunks ---
|
|
392
407
|
if (payload.text) {
|
|
@@ -424,17 +439,37 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
424
439
|
})
|
|
425
440
|
}
|
|
426
441
|
} catch (err: unknown) {
|
|
427
|
-
dcgLogger(`
|
|
442
|
+
dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
|
|
443
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
444
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
445
|
+
}
|
|
446
|
+
} finally {
|
|
447
|
+
releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const inboundGenNow = inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0
|
|
451
|
+
if (inboundGenNow !== inboundGenAtStart) {
|
|
452
|
+
dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
|
|
453
|
+
return
|
|
428
454
|
}
|
|
429
455
|
|
|
430
|
-
if (![...systemCommand, ...
|
|
431
|
-
if (
|
|
432
|
-
|
|
456
|
+
if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
|
|
457
|
+
if (isSessionStreamSuppressed(dcgSessionKey)) {
|
|
458
|
+
clearSessionStreamSuppression(dcgSessionKey)
|
|
433
459
|
}
|
|
434
460
|
}
|
|
435
461
|
clearSentMediaKeys(msg.content.message_id)
|
|
436
462
|
const storePath = core.channel.session.resolveStorePath(config.session?.store)
|
|
437
463
|
await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
|
|
464
|
+
// 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
|
|
465
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
|
|
466
|
+
dcgLogger(
|
|
467
|
+
`skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${inboundGenerationBySessionKey.get(dcgSessionKey)})`
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
clearActiveRunIdForSession(dcgSessionKey)
|
|
472
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
438
473
|
sendFinal(outboundCtx, 'end')
|
|
439
474
|
dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
|
|
440
475
|
core.channel.session
|
|
@@ -457,6 +492,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
457
492
|
})
|
|
458
493
|
} catch (err) {
|
|
459
494
|
dcgLogger(` handle message failed: ${String(err)}`, 'error')
|
|
495
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
496
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
497
|
+
}
|
|
460
498
|
sendError(err instanceof Error ? err.message : String(err), outboundCtx)
|
|
461
499
|
}
|
|
462
500
|
}
|
package/src/channel.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
1
3
|
import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
|
|
2
4
|
import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
|
|
3
5
|
import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
|
|
@@ -8,11 +10,14 @@ import {
|
|
|
8
10
|
getDcgchatRuntime,
|
|
9
11
|
getInfoBySessionKey,
|
|
10
12
|
getOpenClawConfig,
|
|
11
|
-
|
|
13
|
+
getWorkspaceDir,
|
|
14
|
+
hasSentMediaKey,
|
|
15
|
+
setCronMessageId
|
|
12
16
|
} from './utils/global.js'
|
|
13
17
|
import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
|
|
14
18
|
import { dcgLogger, setLogger } from './utils/log.js'
|
|
15
19
|
import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
|
|
20
|
+
import { isSessionActiveForTool } from './tool.js'
|
|
16
21
|
import { startDcgchatGatewaySocket } from './gateway/socket.js'
|
|
17
22
|
import { getCronJobsPath, readCronJob } from './cron.js'
|
|
18
23
|
|
|
@@ -101,45 +106,151 @@ function outboundChatId(rawTo: string | undefined, normalizedTo: string): string
|
|
|
101
106
|
return raw.indexOf('dcg-cron:') >= 0 ? raw : normalizedTo
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Core / message 工具可能传入:
|
|
111
|
+
* - 本地绝对路径字符串
|
|
112
|
+
* - 工作区虚拟路径(如 `/mobook/xxx`,需落到 `getWorkspaceDir()`)
|
|
113
|
+
* - `mediaUrls` 为 `{ file: string }[]` 或整段 JSON 字符串
|
|
114
|
+
*/
|
|
115
|
+
function resolveWorkspaceMediaPath(p: string): string {
|
|
116
|
+
const s = p.trim()
|
|
117
|
+
if (!s) return ''
|
|
118
|
+
if (fs.existsSync(s)) return path.normalize(s)
|
|
119
|
+
const rel = s.replace(/^[\\/]+/, '')
|
|
120
|
+
return path.normalize(path.join(getWorkspaceDir(), rel))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function collectOutboundMediaPaths(item: unknown, out: string[]): void {
|
|
124
|
+
if (item == null) return
|
|
125
|
+
if (typeof item === 'string') {
|
|
126
|
+
const t = item.trim()
|
|
127
|
+
if (!t) return
|
|
128
|
+
if (t.startsWith('[')) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(t) as unknown
|
|
131
|
+
collectOutboundMediaPaths(parsed, out)
|
|
132
|
+
return
|
|
133
|
+
} catch {
|
|
134
|
+
/* 非 JSON,按普通路径处理 */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
out.push(resolveWorkspaceMediaPath(t))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(item)) {
|
|
141
|
+
for (const el of item) collectOutboundMediaPaths(el, out)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (typeof item === 'object') {
|
|
145
|
+
const o = item as Record<string, unknown>
|
|
146
|
+
const raw = o.file ?? o.path ?? o.url
|
|
147
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
148
|
+
out.push(resolveWorkspaceMediaPath(raw))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 将出站 media 载荷统一为可 `fs` 访问的本地路径列表(去重保序) */
|
|
154
|
+
export function normalizeOutboundMediaPaths(raw: unknown): string[] {
|
|
155
|
+
const acc: string[] = []
|
|
156
|
+
collectOutboundMediaPaths(raw, acc)
|
|
157
|
+
const seen = new Set<string>()
|
|
158
|
+
const deduped: string[] = []
|
|
159
|
+
for (const p of acc) {
|
|
160
|
+
if (!p || seen.has(p)) continue
|
|
161
|
+
seen.add(p)
|
|
162
|
+
deduped.push(p)
|
|
163
|
+
}
|
|
164
|
+
return deduped
|
|
165
|
+
}
|
|
166
|
+
|
|
104
167
|
export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
|
|
168
|
+
const rawOpt = (opts.sessionKey ?? '').trim()
|
|
169
|
+
const strippedForCron = rawOpt.replace(/^dcg-cron:/i, '').trim()
|
|
170
|
+
const fromIsolatedCron = extractCronJobIdFromIsolatedSessionKey(strippedForCron) !== null
|
|
171
|
+
const fromDcgCronWrapper = rawOpt.toLowerCase().startsWith('dcg-cron:')
|
|
172
|
+
|
|
105
173
|
let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
|
|
106
174
|
sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
|
|
175
|
+
|
|
176
|
+
/** 定时自动执行未走 onRunCronJob,须与 finishedDcgchatCron 共用同一 messageId,否则附件与气泡错位 */
|
|
177
|
+
if (!opts.messageId?.trim() && (fromIsolatedCron || fromDcgCronWrapper) && !getCronMessageId(sessionKey)) {
|
|
178
|
+
setCronMessageId(sessionKey, `${Date.now()}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cronMid = getCronMessageId(sessionKey)
|
|
107
182
|
const baseCtx = getOutboundMsgParams(sessionKey)
|
|
108
|
-
const msgCtx = opts.messageId?.trim()
|
|
183
|
+
const msgCtx = opts.messageId?.trim()
|
|
184
|
+
? { ...baseCtx, messageId: opts.messageId.trim() }
|
|
185
|
+
: cronMid
|
|
186
|
+
? { ...baseCtx, messageId: cronMid }
|
|
187
|
+
: baseCtx
|
|
109
188
|
if (!isWsOpen()) {
|
|
110
|
-
dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
|
|
189
|
+
dcgLogger(`outbound media skipped -> ws not isWsOpen failed open: ${opts.mediaUrl ?? ''}`)
|
|
111
190
|
return
|
|
112
191
|
}
|
|
113
192
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
193
|
+
const expanded = normalizeOutboundMediaPaths(opts.mediaUrl)
|
|
194
|
+
if (expanded.length === 0) {
|
|
195
|
+
dcgLogger(
|
|
196
|
+
`dcgchat: sendMedia skipped (no resolvable path): ${typeof opts.mediaUrl === 'string' ? opts.mediaUrl : JSON.stringify(opts.mediaUrl)} sessionKey=${sessionKey}`,
|
|
197
|
+
'error'
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (expanded.length > 1) {
|
|
202
|
+
for (const single of expanded) {
|
|
203
|
+
await sendDcgchatMedia({ ...opts, mediaUrl: single })
|
|
204
|
+
}
|
|
118
205
|
return
|
|
119
206
|
}
|
|
120
|
-
|
|
121
|
-
|
|
207
|
+
const mediaUrl = expanded[0]
|
|
208
|
+
if (!mediaUrl || !msgCtx.sessionId) {
|
|
209
|
+
dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
// 判断文件存在
|
|
213
|
+
try {
|
|
214
|
+
if (!fs.existsSync(mediaUrl)) {
|
|
215
|
+
dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
|
|
122
220
|
}
|
|
123
221
|
|
|
124
|
-
|
|
222
|
+
if (mediaUrl && msgCtx.sessionId) {
|
|
223
|
+
if (hasSentMediaKey(msgCtx.sessionId, mediaUrl)) {
|
|
224
|
+
dcgLogger(`dcgchat: sendMedia skipped (hasSentMediaKey): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
addSentMediaKey(msgCtx.sessionId, mediaUrl)
|
|
228
|
+
}
|
|
125
229
|
|
|
230
|
+
const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
|
|
231
|
+
if (!msgCtx.sessionId) {
|
|
232
|
+
msgCtx.sessionId = sessionId
|
|
233
|
+
}
|
|
234
|
+
const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
|
|
235
|
+
const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
|
|
126
236
|
try {
|
|
127
237
|
const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat"]?.botToken ?? ''
|
|
128
238
|
const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
|
|
129
|
-
if (!msgCtx.
|
|
130
|
-
const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
|
|
131
|
-
msgCtx.sessionId = sessionId
|
|
239
|
+
if (!msgCtx.agentId) {
|
|
132
240
|
msgCtx.agentId = agentId
|
|
133
241
|
}
|
|
134
242
|
wsSendRaw(msgCtx, {
|
|
135
243
|
response: opts.text ?? '',
|
|
244
|
+
is_finish: notMessageId ? -1 : 0,
|
|
245
|
+
message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
|
|
136
246
|
files: [{ url, name: fileName }]
|
|
137
247
|
})
|
|
138
248
|
dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
|
|
139
249
|
} catch (error) {
|
|
140
250
|
wsSendRaw(msgCtx, {
|
|
141
251
|
response: opts.text ?? '',
|
|
142
|
-
message_tags: {
|
|
252
|
+
message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
|
|
253
|
+
is_finish: notMessageId ? -1 : 0,
|
|
143
254
|
files: [{ url: opts.mediaUrl ?? '', name: fileName }]
|
|
144
255
|
})
|
|
145
256
|
dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
|
|
@@ -287,24 +398,20 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
287
398
|
const outboundCtx = getOutboundMsgParams(to)
|
|
288
399
|
const content: Record<string, unknown> = { response: ctx.text }
|
|
289
400
|
if (!isCron) {
|
|
290
|
-
// messageId = getCronMessageId(to) || `${Date.now()}`
|
|
291
|
-
// const { sessionId, agentId } = getInfoBySessionKey(to)
|
|
292
|
-
// content.is_finish = -1
|
|
293
|
-
// content.message_tags = { source: 'cron' }
|
|
294
|
-
// const merged = mergeDefaultParams({
|
|
295
|
-
// agentId: agentId,
|
|
296
|
-
// sessionId: `${sessionId}`,
|
|
297
|
-
// messageId: messageId,
|
|
298
|
-
// real_mobook: !sessionId ? 1 : ''
|
|
299
|
-
// })
|
|
300
|
-
// wsSendRaw(merged, content)
|
|
301
|
-
// } else {
|
|
302
401
|
if (outboundCtx?.sessionId) {
|
|
402
|
+
// 入站 handler 已将本轮标为 running;若已 sendFinal(end) 则应为 finished。
|
|
403
|
+
// 否则网关/Core 晚到的正文会仍用「当前 params map」里的 messageId,错挂到后一条用户消息上。
|
|
404
|
+
if (!isSessionActiveForTool(to)) {
|
|
405
|
+
dcgLogger(`channel sendText dropped (session not active): to=${to}`)
|
|
406
|
+
return {
|
|
407
|
+
channel: "dcgchat",
|
|
408
|
+
messageId: '',
|
|
409
|
+
chatId: outboundChatId(ctx.to, to)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
303
412
|
messageId = outboundCtx?.messageId || `${Date.now()}`
|
|
304
413
|
const newCtx = { ...outboundCtx, messageId }
|
|
305
414
|
wsSendRaw(newCtx, content)
|
|
306
|
-
} else {
|
|
307
|
-
dcgLogger(`channel sendText to ${ctx.to} -> sessionId not found`, 'error')
|
|
308
415
|
}
|
|
309
416
|
}
|
|
310
417
|
}
|
|
@@ -320,11 +427,14 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
320
427
|
const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
|
|
321
428
|
const outboundCtx = getOutboundMsgParams(to)
|
|
322
429
|
const msgCtx = getParamsMessage(to) ?? outboundCtx
|
|
430
|
+
if (isCron && !getCronMessageId(to)) {
|
|
431
|
+
setCronMessageId(to, `${Date.now()}`)
|
|
432
|
+
}
|
|
323
433
|
const cronMsgId = getCronMessageId(to)
|
|
324
434
|
const fallbackMessageId = `${Date.now()}`
|
|
325
435
|
const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
|
|
326
|
-
|
|
327
|
-
if (!
|
|
436
|
+
const { sessionId } = getInfoBySessionKey(to)
|
|
437
|
+
if (!sessionId) {
|
|
328
438
|
dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
|
|
329
439
|
return {
|
|
330
440
|
channel: "dcgchat",
|
|
@@ -334,11 +444,17 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
334
444
|
}
|
|
335
445
|
|
|
336
446
|
dcgLogger(`channel sendMedia to ${ctx.to}`)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
447
|
+
|
|
448
|
+
const ctxExt = ctx as { mediaUrls?: unknown; mediaUrl?: string }
|
|
449
|
+
const rawMedia = ctxExt.mediaUrls ?? ctxExt.mediaUrl
|
|
450
|
+
const paths = normalizeOutboundMediaPaths(rawMedia)
|
|
451
|
+
for (const mediaUrl of paths) {
|
|
452
|
+
await sendDcgchatMedia({
|
|
453
|
+
sessionKey: to || '',
|
|
454
|
+
mediaUrl,
|
|
455
|
+
...(isCron ? { messageId } : {})
|
|
456
|
+
})
|
|
457
|
+
}
|
|
342
458
|
return {
|
|
343
459
|
channel: "dcgchat",
|
|
344
460
|
messageId,
|
package/src/cron.ts
CHANGED
|
@@ -135,7 +135,7 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
|
|
|
135
135
|
)
|
|
136
136
|
sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
|
|
137
137
|
}
|
|
138
|
-
export const finishedDcgchatCron = async (jobId: string, summary: string) => {
|
|
138
|
+
export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
|
|
139
139
|
const id = jobId?.trim()
|
|
140
140
|
if (!id) {
|
|
141
141
|
dcgLogger('finishedDcgchatCron: empty jobId', 'error')
|
|
@@ -159,7 +159,11 @@ export const finishedDcgchatCron = async (jobId: string, summary: string) => {
|
|
|
159
159
|
messageId: messageId || `${Date.now()}`,
|
|
160
160
|
real_mobook: !sessionId ? 1 : ''
|
|
161
161
|
})
|
|
162
|
-
|
|
162
|
+
const message_tags = { source: 'cron' } as Record<string, string | boolean>
|
|
163
|
+
if (hasFileOutput) {
|
|
164
|
+
message_tags.hasFile = true
|
|
165
|
+
}
|
|
166
|
+
wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
|
|
163
167
|
setTimeout(() => {
|
|
164
168
|
sendFinal(merged, 'cron send')
|
|
165
169
|
}, 200)
|
package/src/cronToolCall.ts
CHANGED
|
@@ -117,33 +117,44 @@ function needsBestEffort(delivery: CronDelivery): boolean {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* 深拷贝 params
|
|
120
|
+
* 深拷贝 params,在 delivery 上写入 dcg 路由(to / accountId / sessionKey)。
|
|
121
|
+
* bestEffort + 默认 channel 仅在 needsBestEffort 为真时写入(announce 且无 channel)。
|
|
122
|
+
*
|
|
123
|
+
* 说明:原先仅在 needsBestEffort 为真时才改 delivery,若 jobs 里已有 channel 会整段跳过,
|
|
124
|
+
* 导致 `delivery.to` 永远不会被本钩子写入;运行时若缺 to 就会一直 not-delivered。
|
|
121
125
|
*/
|
|
122
|
-
function
|
|
126
|
+
function patchCronDeliveryInParams(
|
|
127
|
+
params: Record<string, unknown>,
|
|
128
|
+
sk: string,
|
|
129
|
+
deliverySnapshot: CronDelivery
|
|
130
|
+
): Record<string, unknown> | null {
|
|
123
131
|
const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
|
|
124
|
-
const { agentId
|
|
125
|
-
|
|
132
|
+
const { agentId } = formatterSessionKey(sk)
|
|
133
|
+
const announceNoChannel = needsBestEffort(deliverySnapshot)
|
|
134
|
+
|
|
135
|
+
const apply = (d: CronDelivery) => {
|
|
136
|
+
d.to = `dcg-cron:${sk}`
|
|
137
|
+
if (agentId) d.accountId = agentId
|
|
138
|
+
if (announceNoChannel) {
|
|
139
|
+
d.bestEffort = true
|
|
140
|
+
d.channel = 'dcgchat-test'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
if (newParams.delivery && typeof newParams.delivery === 'object') {
|
|
127
|
-
|
|
128
|
-
;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
|
|
129
|
-
;(newParams.delivery as CronDelivery).accountId = agentId
|
|
130
|
-
;(newParams.delivery as CronDelivery).channel = "dcgchat"
|
|
145
|
+
apply(newParams.delivery as CronDelivery)
|
|
131
146
|
newParams.sessionKey = sk
|
|
132
147
|
return newParams
|
|
133
148
|
}
|
|
134
149
|
|
|
135
|
-
// job.delivery
|
|
136
150
|
const job = newParams.job as Record<string, unknown> | undefined
|
|
137
151
|
if (job?.delivery && typeof job.delivery === 'object') {
|
|
138
|
-
|
|
139
|
-
;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
|
|
140
|
-
;(newParams.delivery as CronDelivery).accountId = agentId
|
|
141
|
-
;(newParams.delivery as CronDelivery).channel = "dcgchat"
|
|
152
|
+
apply(job.delivery as CronDelivery)
|
|
142
153
|
newParams.sessionKey = sk
|
|
143
154
|
return newParams
|
|
144
155
|
}
|
|
145
156
|
|
|
146
|
-
return
|
|
157
|
+
return null
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
|
|
@@ -154,34 +165,38 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
|
|
|
154
165
|
const delivery = extractDelivery(params)
|
|
155
166
|
if (!delivery) {
|
|
156
167
|
dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
|
|
157
|
-
return
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
const newParams = patchCronDeliveryInParams(params, sk, delivery)
|
|
171
|
+
if (!newParams) {
|
|
172
|
+
dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) could not locate delivery on params clone, skip.`)
|
|
173
|
+
return undefined
|
|
158
174
|
}
|
|
159
|
-
|
|
175
|
+
|
|
176
|
+
const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
|
|
177
|
+
if (needsBestEffort(delivery)) {
|
|
160
178
|
dcgLogger(
|
|
161
|
-
`[${LOG_TAG}] cron call (${toolCallId}) delivery
|
|
162
|
-
`
|
|
179
|
+
`[${LOG_TAG}] cron call (${toolCallId}) patched delivery (announce, no channel): bestEffort=true, ` +
|
|
180
|
+
`delivery=${JSON.stringify(patched)}`
|
|
181
|
+
)
|
|
182
|
+
} else {
|
|
183
|
+
dcgLogger(
|
|
184
|
+
`[${LOG_TAG}] cron call (${toolCallId}) patched delivery.to / accountId (sessionKey=${sk}), ` +
|
|
185
|
+
`delivery=${JSON.stringify(patched)}`
|
|
163
186
|
)
|
|
164
|
-
return params
|
|
165
187
|
}
|
|
166
188
|
|
|
167
|
-
// ★ 核心:注入 bestEffort: true
|
|
168
|
-
const newParams = injectBestEffort(params, sk)
|
|
169
|
-
dcgLogger(
|
|
170
|
-
`[${LOG_TAG}] cron call (${toolCallId}) injected bestEffort=true ` +
|
|
171
|
-
`(mode=announce, no channel). delivery=${JSON.stringify(newParams.delivery || (newParams.job as Record<string, unknown>)?.delivery)}`
|
|
172
|
-
)
|
|
173
|
-
|
|
174
189
|
return { params: newParams }
|
|
175
190
|
} else if (toolName === 'exec') {
|
|
176
191
|
if (params.command.indexOf('cron create') > -1 || params.command.indexOf('cron add') > -1) {
|
|
177
192
|
const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
|
|
178
193
|
newParams.command =
|
|
179
|
-
params.command.replace('--json', '') + ` --session-key ${sk} --channel ${
|
|
194
|
+
params.command.replace('--json', '') + ` --session-key ${sk} --channel ${'dcgchat-test'} --to dcg-cron:${sk} --json`
|
|
180
195
|
return { params: newParams }
|
|
181
196
|
} else {
|
|
182
|
-
return
|
|
197
|
+
return undefined
|
|
183
198
|
}
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
return
|
|
201
|
+
return undefined
|
|
187
202
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
|
|
3
|
+
* 与 bot 的 generation、流式分片序号、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
|
|
4
|
+
* 入站串行队尾、activeRunId、chat.abort 子会话→主会话。
|
|
5
|
+
*/
|
|
6
|
+
import { sendGatewayRpc } from './gateway/socket.js'
|
|
7
|
+
import { sendFinal } from './transport.js'
|
|
8
|
+
import type { IMsgParams } from './types.js'
|
|
9
|
+
import { dcgLogger } from './utils/log.js'
|
|
10
|
+
import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession } from './tool.js'
|
|
11
|
+
|
|
12
|
+
// --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
|
|
13
|
+
|
|
14
|
+
/** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
|
|
15
|
+
const activeRunIdBySessionKey = new Map<string, string>()
|
|
16
|
+
|
|
17
|
+
/** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
|
|
18
|
+
const dispatchAbortBySessionKey = new Map<string, AbortController>()
|
|
19
|
+
|
|
20
|
+
/** 打断后抑制 deliver / onPartialReply 继续下发 */
|
|
21
|
+
const sessionStreamSuppressed = new Set<string>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
|
|
25
|
+
*/
|
|
26
|
+
const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
|
|
27
|
+
|
|
28
|
+
// --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
|
|
29
|
+
|
|
30
|
+
export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
|
|
31
|
+
activeRunIdBySessionKey.set(sessionKey, runId)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearActiveRunIdForSession(sessionKey: string): void {
|
|
35
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- 流抑制 ---
|
|
39
|
+
|
|
40
|
+
export function isSessionStreamSuppressed(sessionKey: string): boolean {
|
|
41
|
+
return sessionStreamSuppressed.has(sessionKey)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearSessionStreamSuppression(sessionKey: string): void {
|
|
45
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function markSessionStreamSuppressed(sessionKey: string): void {
|
|
49
|
+
sessionStreamSuppressed.add(sessionKey)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- 入站串行队列 ---
|
|
53
|
+
|
|
54
|
+
/** /stop 入队前:掐断当前 in-process,并重置队尾,使本条 stop 不必等待已被 abort 的长 turn */
|
|
55
|
+
export function preemptInboundQueueForStop(sessionKey: string): void {
|
|
56
|
+
const c = dispatchAbortBySessionKey.get(sessionKey)
|
|
57
|
+
if (c) {
|
|
58
|
+
c.abort()
|
|
59
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
60
|
+
}
|
|
61
|
+
inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
|
|
62
|
+
dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
|
|
66
|
+
export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
|
|
67
|
+
const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
|
|
68
|
+
const next = prev.catch(() => {}).then(run)
|
|
69
|
+
inboundTurnTailBySessionKey.set(sessionKey, next.catch(() => {}))
|
|
70
|
+
await next
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- 网关 abort ---
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
|
|
77
|
+
* supersede:仅当有子会话或 mainRunId 时发 RPC;interrupt:主会话始终 chat.abort。
|
|
78
|
+
*/
|
|
79
|
+
export async function abortGatewayRunsForSession(sessionKey: string, reason: 'interrupt' | 'supersede'): Promise<void> {
|
|
80
|
+
const prefix = reason === 'interrupt' ? 'interrupt' : 'supersede'
|
|
81
|
+
const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
|
|
82
|
+
const abortSubKeys = [...descendantKeys].reverse()
|
|
83
|
+
const mainRunId = activeRunIdBySessionKey.get(sessionKey)
|
|
84
|
+
|
|
85
|
+
if (reason === 'supersede' && abortSubKeys.length === 0 && !mainRunId) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (abortSubKeys.length > 0) {
|
|
90
|
+
dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
|
|
91
|
+
}
|
|
92
|
+
for (const subKey of abortSubKeys) {
|
|
93
|
+
try {
|
|
94
|
+
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const shouldMainAbort = reason === 'interrupt' || Boolean(mainRunId)
|
|
101
|
+
if (shouldMainAbort) {
|
|
102
|
+
try {
|
|
103
|
+
await sendGatewayRpc({
|
|
104
|
+
method: 'chat.abort',
|
|
105
|
+
params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
|
|
106
|
+
})
|
|
107
|
+
} catch (e) {
|
|
108
|
+
dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
113
|
+
resetSubagentStateForRequesterSession(sessionKey)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- 本地 dispatch AbortController ---
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 新一轮非 /stop 用户消息:清除流抑制、abort 上一轮 controller、网关 supersede,并安装新的 AbortController。
|
|
120
|
+
* 调用方须在之前或之后自行 `streamChunkIdxBySessionKey.set(sessionKey, 0)`。
|
|
121
|
+
*/
|
|
122
|
+
export async function beginSupersedingUserTurn(sessionKey: string): Promise<AbortController> {
|
|
123
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
124
|
+
dispatchAbortBySessionKey.get(sessionKey)?.abort()
|
|
125
|
+
await abortGatewayRunsForSession(sessionKey, 'supersede')
|
|
126
|
+
const ac = new AbortController()
|
|
127
|
+
dispatchAbortBySessionKey.set(sessionKey, ac)
|
|
128
|
+
return ac
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
|
|
132
|
+
export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
|
|
133
|
+
if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
|
|
134
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 用户发送 /stop:本地 abort、对「上一轮对话」发 abort final、标记流抑制、网关 interrupt。
|
|
140
|
+
* 之后的 clearSentMedia、params、UI「已终止」等由 bot 继续处理。
|
|
141
|
+
*/
|
|
142
|
+
export async function interruptLocalDispatchAndGateway(sessionKey: string, ctxForAbort: IMsgParams): Promise<void> {
|
|
143
|
+
dcgLogger(`interrupt command: sessionKey=${sessionKey}`)
|
|
144
|
+
const inFlight = dispatchAbortBySessionKey.get(sessionKey)
|
|
145
|
+
if (inFlight) {
|
|
146
|
+
dcgLogger(`interrupt: AbortController.abort() in-process run sessionKey=${sessionKey}`)
|
|
147
|
+
inFlight.abort()
|
|
148
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
149
|
+
}
|
|
150
|
+
const finalCtx = ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }
|
|
151
|
+
sendFinal(finalCtx, 'abort')
|
|
152
|
+
markSessionStreamSuppressed(sessionKey)
|
|
153
|
+
await abortGatewayRunsForSession(sessionKey, 'interrupt')
|
|
154
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -65,7 +65,7 @@ function resolveOutboundParamsForSession(sk: string) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
|
|
68
|
-
function isSessionActiveForTool(sk: string): boolean {
|
|
68
|
+
export function isSessionActiveForTool(sk: string): boolean {
|
|
69
69
|
const k = sk.trim()
|
|
70
70
|
if (!k) return false
|
|
71
71
|
if (getMsgStatus(k) === 'running') return true
|
|
@@ -325,6 +325,16 @@ function resolveHookSessionKey(
|
|
|
325
325
|
return (args?.sessionKey || '').trim()
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/** 定时触发时会话往往非 running,但仍需跑 before_tool_call 以注入 sessionKey / delivery(见 cronToolCall) */
|
|
329
|
+
function shouldRunBeforeToolCallWithoutRunningSession(event: { toolName?: string; params?: { command?: string } }): boolean {
|
|
330
|
+
if (event?.toolName === 'cron') return true
|
|
331
|
+
const cmd = event?.params?.command
|
|
332
|
+
if (event?.toolName === 'exec' && typeof cmd === 'string') {
|
|
333
|
+
return cmd.includes('cron create') || cmd.includes('cron add')
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
328
338
|
function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
|
|
329
339
|
if (eventName === 'subagent_spawned') {
|
|
330
340
|
const runId = typeof event?.runId === 'string' ? event.runId : ''
|
|
@@ -351,7 +361,9 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
351
361
|
trackSubagentLifecycle(item.event, event, args)
|
|
352
362
|
const sk = resolveHookSessionKey(item.event, args ?? {})
|
|
353
363
|
if (sk) {
|
|
354
|
-
|
|
364
|
+
const toolHooksOk =
|
|
365
|
+
isSessionActiveForTool(sk) || (item.event === 'before_tool_call' && shouldRunBeforeToolCallWithoutRunningSession(event))
|
|
366
|
+
if (toolHooksOk) {
|
|
355
367
|
if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
|
|
356
368
|
const { result: _result, ...rest } = event
|
|
357
369
|
dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
|
|
@@ -365,12 +377,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
365
377
|
...rest,
|
|
366
378
|
status: 'running'
|
|
367
379
|
})
|
|
368
|
-
sendToolCallMessage(
|
|
369
|
-
sk,
|
|
370
|
-
text,
|
|
371
|
-
event.toolCallId || event.runId || Date.now().toString(),
|
|
372
|
-
0
|
|
373
|
-
)
|
|
380
|
+
sendToolCallMessage(sk, text, event.toolCallId || event.runId || Date.now().toString(), 0)
|
|
374
381
|
return hookResult
|
|
375
382
|
}
|
|
376
383
|
const text = JSON.stringify({
|
package/src/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getWsConnection } from './utils/global.js'
|
|
1
|
+
import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
|
|
2
2
|
import { dcgLogger } from './utils/log.js'
|
|
3
3
|
import type { IMsgParams } from './types.js'
|
|
4
4
|
import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
|
|
@@ -170,6 +170,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
|
|
|
170
170
|
|
|
171
171
|
export function sendFinal(ctx: IMsgParams, tag: string): boolean {
|
|
172
172
|
dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
|
|
173
|
+
clearSentMediaKeys(ctx.sessionId)
|
|
173
174
|
return wsSend(ctx, { response: '', state: 'final' })
|
|
174
175
|
}
|
|
175
176
|
|
package/src/utils/constant.ts
CHANGED
|
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'production'
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
export const systemCommand = ['/new', '/status']
|
|
5
|
-
export const
|
|
5
|
+
export const stopCommand = ['/stop']
|
|
6
6
|
|
|
7
|
-
export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...
|
|
7
|
+
export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...stopCommand]
|
|
@@ -36,7 +36,12 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
|
|
|
36
36
|
sendDcgchatCron(p?.jobId as string)
|
|
37
37
|
}
|
|
38
38
|
if (p?.action === 'finished' && p?.status === 'ok') {
|
|
39
|
-
|
|
39
|
+
const hasFileOutput = p.delivered === true && p.deliveryStatus === 'delivered'
|
|
40
|
+
let summary = p?.summary as string
|
|
41
|
+
if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
|
|
42
|
+
summary = summary.replace('HEARTBEAT_OK', '')
|
|
43
|
+
}
|
|
44
|
+
finishedDcgchatCron(p?.jobId as string, summary, hasFileOutput)
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
} catch (error) {
|
package/src/utils/global.ts
CHANGED
|
@@ -82,11 +82,11 @@ const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
|
|
|
82
82
|
/** 已发送媒体去重:外层 messageId → 内层该会话下已发送的媒体 key(文件名) */
|
|
83
83
|
const sentMediaKeysBySession = new Map<string, Set<string>>()
|
|
84
84
|
|
|
85
|
-
function getSessionMediaSet(
|
|
86
|
-
let set = sentMediaKeysBySession.get(
|
|
85
|
+
function getSessionMediaSet(sessionId: string): Set<string> {
|
|
86
|
+
let set = sentMediaKeysBySession.get(sessionId)
|
|
87
87
|
if (!set) {
|
|
88
88
|
set = new Set<string>()
|
|
89
|
-
sentMediaKeysBySession.set(
|
|
89
|
+
sentMediaKeysBySession.set(sessionId, set)
|
|
90
90
|
}
|
|
91
91
|
return set
|
|
92
92
|
}
|
|
@@ -95,14 +95,14 @@ export function addSentMediaKey(messageId: string, url: string) {
|
|
|
95
95
|
getSessionMediaSet(messageId).add(getMediaKey(url))
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function hasSentMediaKey(
|
|
99
|
-
return sentMediaKeysBySession.get(
|
|
98
|
+
export function hasSentMediaKey(sessionId: string, url: string): boolean {
|
|
99
|
+
return sentMediaKeysBySession.get(sessionId)?.has(getMediaKey(url)) ?? false
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/** 不传 messageId 时清空全部会话;传入则只清空该会话 */
|
|
103
|
-
export function clearSentMediaKeys(
|
|
104
|
-
if (
|
|
105
|
-
sentMediaKeysBySession.delete(
|
|
103
|
+
export function clearSentMediaKeys(sessionId?: string) {
|
|
104
|
+
if (sessionId) {
|
|
105
|
+
sentMediaKeysBySession.delete(sessionId)
|
|
106
106
|
} else {
|
|
107
107
|
sentMediaKeysBySession.clear()
|
|
108
108
|
}
|