@dcrays/dcgchat-test 0.6.8 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/index.ts +35 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +35 -8
- package/src/agent.ts +568 -0
- package/src/bot.ts +646 -0
- package/src/channel/index.ts +329 -0
- package/src/channel/outboundMedia.ts +144 -0
- package/src/channel/outboundTarget.ts +62 -0
- package/src/channel/uploadMediaUrl.ts +76 -0
- package/src/cron/message.ts +114 -0
- package/src/cron/params.ts +164 -0
- package/src/cron/toolGuard.ts +466 -0
- package/src/cron/types.ts +15 -0
- package/src/gateway/index.ts +430 -0
- package/src/gateway/security.ts +95 -0
- package/src/gateway/socket.ts +256 -0
- package/src/libs/ali-oss-6.23.0.tgz +0 -0
- package/src/libs/axios-1.13.6.tgz +0 -0
- package/src/libs/md5-2.3.0.tgz +0 -0
- package/src/libs/mime-types-3.0.2.tgz +0 -0
- package/src/libs/unzipper-0.12.3.tgz +0 -0
- package/src/libs/ws-8.19.0.tgz +0 -0
- package/src/monitor.ts +269 -0
- package/src/request/api.ts +80 -0
- package/src/request/oss.ts +200 -0
- package/src/request/request.ts +191 -0
- package/src/request/userInfo.ts +44 -0
- package/src/session.ts +28 -0
- package/src/skill.ts +114 -0
- package/src/tool.ts +603 -0
- package/src/tools/messageTool.ts +334 -0
- package/src/tools/toolCallGuard.ts +327 -0
- package/src/transport.ts +217 -0
- package/src/types.ts +139 -0
- package/src/utils/agentErrors.ts +116 -0
- package/src/utils/constant.ts +60 -0
- package/src/utils/env-config.ts +19 -0
- package/src/utils/formatLlmInputEvent.ts +48 -0
- package/src/utils/gatewayMsgHandler.ts +147 -0
- package/src/utils/global.ts +309 -0
- package/src/utils/inboundTurnState.ts +66 -0
- package/src/utils/log.ts +77 -0
- package/src/utils/mediaAttached.ts +244 -0
- package/src/utils/mediaEmitter.ts +54 -0
- package/src/utils/outboundAssistantText.ts +117 -0
- package/src/utils/params.ts +104 -0
- package/src/utils/passTxt.ts +4 -0
- package/src/utils/resolveRegisterConfig.ts +33 -0
- package/src/utils/searchFile.ts +228 -0
- package/src/utils/sessionState.ts +137 -0
- package/src/utils/sessionTermination.ts +228 -0
- package/src/utils/streamMerge.ts +150 -0
- package/src/utils/subagentRunMap.ts +71 -0
- package/src/utils/undiciFetchInterceptor.ts +346 -0
- package/src/utils/workspaceFilePaths.ts +18 -0
- package/src/utils/wsMessageHandler.ts +124 -0
- package/src/utils/zipExtract.ts +97 -0
- package/src/utils/zipPath.ts +24 -0
- package/index.js +0 -302
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { AnyAgentTool } from 'openclaw/plugin-sdk'
|
|
4
|
+
import { jsonResult } from 'openclaw/plugin-sdk/channel-actions'
|
|
5
|
+
import { sendDcgchatMedia } from '..//channel/index.js'
|
|
6
|
+
import { isSessionActiveForTool } from '../tool.js'
|
|
7
|
+
import { dcgLogger } from '../utils/log.js'
|
|
8
|
+
import { getOutboundMsgParams } from '../utils/params.js'
|
|
9
|
+
import { emitOutboundAssistantText } from '../utils/outboundAssistantText.js'
|
|
10
|
+
import { mediaPathDedupeKey } from '../utils/global.js'
|
|
11
|
+
import { expandTildePath } from '../utils/workspaceFilePaths.js'
|
|
12
|
+
import { hasCronSessionKeyMap, normalizeSessionTarget } from '../channel/outboundTarget.js'
|
|
13
|
+
|
|
14
|
+
/** 与 `registerTool` 工厂入参一致(主包未导出 `OpenClawPluginToolContext` 时仅用所需字段)。 */
|
|
15
|
+
export type DcgchatMessageToolContext = {
|
|
16
|
+
sessionKey?: string
|
|
17
|
+
workspaceDir?: string
|
|
18
|
+
allowedPaths?: string[]
|
|
19
|
+
/** 通道配置 `allowedAttachmentExtensions`:在插件内置扩展名之外额外允许发送的附件后缀(如 `.py`、`.ipynb`),项可写 `.py` 或 `py`。 */
|
|
20
|
+
allowedAttachmentExtensions?: string[]
|
|
21
|
+
deliveryContext?: { to?: string }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 统一为 POSIX 风格斜杠,便于跨平台判断(不改变语义,仅用于匹配)。 */
|
|
25
|
+
function toPosixPath(p: string): string {
|
|
26
|
+
return path.normalize(p.trim()).replace(/\\/g, '/')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
|
|
30
|
+
function isPathInsideDir(filepath: string, rootDir: string): boolean {
|
|
31
|
+
const root = path.resolve(rootDir)
|
|
32
|
+
const resolved = path.resolve(filepath)
|
|
33
|
+
const rel = path.relative(root, resolved)
|
|
34
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 允许发送的路径:
|
|
40
|
+
* - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
|
|
41
|
+
* - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
|
|
42
|
+
*/
|
|
43
|
+
function isSafePath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
|
|
44
|
+
// Check workspaceDir
|
|
45
|
+
const ws = workspaceDir?.trim()
|
|
46
|
+
if (ws && isPathInsideDir(filepath, ws)) return true
|
|
47
|
+
|
|
48
|
+
// Check allowedPaths from config
|
|
49
|
+
if (allowedPaths?.length) {
|
|
50
|
+
for (const allowed of allowedPaths) {
|
|
51
|
+
if (isPathInsideDir(filepath, allowed)) return true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check legacy mounts
|
|
56
|
+
const p = toPosixPath(filepath)
|
|
57
|
+
if (p.startsWith('/workspace/') || p === '/workspace') return true
|
|
58
|
+
if (p.startsWith('/mobook/') || p === '/mobook') return true
|
|
59
|
+
return p.includes('mobook') || p.includes('workspace')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 同一路径在 Windows 上可能大小写不同;媒体 key 与 channel/global 出站去重对齐 */
|
|
63
|
+
function pathKey(filepath: string): string {
|
|
64
|
+
return mediaPathDedupeKey(filepath.trim())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fileType1 = ['.webp', '.gif', '.bmp', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.rtf', '.odt', '.json']
|
|
68
|
+
const fileType2 = [
|
|
69
|
+
'.xml',
|
|
70
|
+
'.csv',
|
|
71
|
+
'.yaml',
|
|
72
|
+
'.yml',
|
|
73
|
+
'.html',
|
|
74
|
+
'.htm',
|
|
75
|
+
'.md',
|
|
76
|
+
'.markdown',
|
|
77
|
+
'.css',
|
|
78
|
+
'.js',
|
|
79
|
+
'.ts',
|
|
80
|
+
'.py',
|
|
81
|
+
'.pyi',
|
|
82
|
+
'.ipynb',
|
|
83
|
+
'.png',
|
|
84
|
+
'.jpg',
|
|
85
|
+
'.jpeg'
|
|
86
|
+
]
|
|
87
|
+
const fileType3 = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.exe', '.dmg', '.pkg', '.apk', '.ipa', '.log', '.dat', '.bin']
|
|
88
|
+
const fileType4 = ['.svg', '.ico', '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm']
|
|
89
|
+
const DEFAULT_SAFE_EXTENSIONS = new Set([...fileType1, ...fileType2, ...fileType3, ...fileType4])
|
|
90
|
+
|
|
91
|
+
function normalizeAttachmentExt(raw: string): string | null {
|
|
92
|
+
const t = raw.trim().toLowerCase()
|
|
93
|
+
if (!t) return null
|
|
94
|
+
return t.startsWith('.') ? t : `.${t}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildSafeExtensions(extra?: string[]): Set<string> {
|
|
98
|
+
const set = new Set(DEFAULT_SAFE_EXTENSIONS)
|
|
99
|
+
for (const e of extra ?? []) {
|
|
100
|
+
const n = normalizeAttachmentExt(e)
|
|
101
|
+
if (n && n !== '.') set.add(n)
|
|
102
|
+
}
|
|
103
|
+
return set
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type DcgchatMessageToolArgs = {
|
|
107
|
+
target?: string
|
|
108
|
+
content?: string
|
|
109
|
+
media?: { file?: string }[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const messageToolParameters = {
|
|
113
|
+
type: 'object',
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
properties: {
|
|
116
|
+
target: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description: '目标会话键(sessionKey),必须与当前会话 SessionKey 一致,禁止填写 userId。'
|
|
119
|
+
},
|
|
120
|
+
content: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: '发送文本内容'
|
|
123
|
+
},
|
|
124
|
+
media: {
|
|
125
|
+
type: 'array',
|
|
126
|
+
description: '发送附件',
|
|
127
|
+
items: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
properties: {
|
|
131
|
+
file: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description:
|
|
134
|
+
'文件绝对路径:须在「当前 Agent 工作区」目录下(如 /root/.openclaw/workspace-xxx/output/28337/slices_result.json),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)'
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
required: ['file']
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
// 须至少提供正文或附件之一;用 anyOf(非 oneOf),否则同时带 content+media 时两个分支都满足会违反「恰好其一」而校验失败
|
|
142
|
+
anyOf: [{ required: ['content'] }, { required: ['media'] }]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */
|
|
146
|
+
function extractPaths(text: string | undefined, workspaceDir?: string): string[] {
|
|
147
|
+
if (!text) return []
|
|
148
|
+
const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
|
|
149
|
+
const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
|
|
150
|
+
const underWs: string[] = []
|
|
151
|
+
const ws = workspaceDir?.trim()
|
|
152
|
+
if (ws) {
|
|
153
|
+
const variants = new Set<string>()
|
|
154
|
+
variants.add(ws)
|
|
155
|
+
variants.add(toPosixPath(ws))
|
|
156
|
+
if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
|
|
157
|
+
for (const prefix of variants) {
|
|
158
|
+
if (!prefix) continue
|
|
159
|
+
let from = 0
|
|
160
|
+
while (from < text.length) {
|
|
161
|
+
const i = text.indexOf(prefix, from)
|
|
162
|
+
if (i === -1) break
|
|
163
|
+
let end = i + prefix.length
|
|
164
|
+
while (end < text.length && !/\s/.test(text[end])) end++
|
|
165
|
+
underWs.push(text.slice(i, end))
|
|
166
|
+
from = i + 1
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...new Set([...unix, ...win, ...underWs])]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isSafeFile(filepath: string, extensions: Set<string>) {
|
|
174
|
+
if (!fs.existsSync(filepath)) return false
|
|
175
|
+
const stat = fs.statSync(filepath)
|
|
176
|
+
if (!stat.isFile()) return false
|
|
177
|
+
if (stat.size === 0) return false
|
|
178
|
+
const ext = path.extname(filepath).toLowerCase()
|
|
179
|
+
return extensions.has(ext)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getSessionKey(pluginCtx: DcgchatMessageToolContext): string {
|
|
183
|
+
const { sessionKey = '', deliveryContext } = pluginCtx
|
|
184
|
+
const isCron = sessionKey?.indexOf(':cron:') >= 0
|
|
185
|
+
if (isCron) {
|
|
186
|
+
return normalizeSessionTarget(deliveryContext?.to ?? '')
|
|
187
|
+
}
|
|
188
|
+
return sessionKey
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 书灵墨宝出站消息工具:须符合 OpenClaw `AgentTool`(execute 返回 `AgentToolResult`)。
|
|
193
|
+
* 工具名使用 `dcgchat_message`,避免与核心内置 `message` 冲突。
|
|
194
|
+
* 通过注册时的 `OpenClawPluginToolContext.sessionKey` 出站,不再使用非标准的 `execute(args, ctx)`。
|
|
195
|
+
*/
|
|
196
|
+
export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext): AnyAgentTool {
|
|
197
|
+
const safeExtensions = buildSafeExtensions(pluginCtx.allowedAttachmentExtensions)
|
|
198
|
+
return {
|
|
199
|
+
name: 'dcgchat_message',
|
|
200
|
+
label: 'dcgchat_message',
|
|
201
|
+
description: `
|
|
202
|
+
向用户发送消息。
|
|
203
|
+
若传 target,target 必须是 sessionKey,不能是 userId。
|
|
204
|
+
如果发送附件:必须使用 media 字段(不依赖 inline buttons,勿要求用户开启 channels.*.capabilities.inlineButtons)。
|
|
205
|
+
文件路径须在当前 Agent 工作区目录下(随部署变化,如 ~/.openclaw/workspace-xxx/...),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)。
|
|
206
|
+
禁止在正文中直接输出可访问路径(应通过 media 发送)
|
|
207
|
+
`,
|
|
208
|
+
parameters: messageToolParameters,
|
|
209
|
+
execute: async (_toolCallId, args, signal) => {
|
|
210
|
+
const toolArgs = args as DcgchatMessageToolArgs
|
|
211
|
+
if (signal?.aborted) {
|
|
212
|
+
const err = new Error('Message send aborted')
|
|
213
|
+
err.name = 'AbortError'
|
|
214
|
+
throw err
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sessionKey = getSessionKey(pluginCtx)
|
|
218
|
+
if (!sessionKey) {
|
|
219
|
+
dcgLogger(`dcgchat_message: missing sessionKey, cannot send message`, 'error')
|
|
220
|
+
// terminate:避免宿主在会话已结束后仍多轮调用模型重试 message,产生无效 token
|
|
221
|
+
return { ...jsonResult({ error: '缺少 sessionKey,无法向当前会话发送消息' }), terminate: true }
|
|
222
|
+
}
|
|
223
|
+
const isCronSessionKey = hasCronSessionKeyMap(sessionKey)
|
|
224
|
+
// 与 channel.outbound.sendText 一致:无活跃入站轮次则不下发。否则 /stop 或 sendFinal 之后,
|
|
225
|
+
// 网关里晚结束的 run 仍会调起 message 工具,getOutboundMsgParams 已指向**新**用户 messageId,造成旧回复错挂到后一条上。
|
|
226
|
+
if (!isCronSessionKey && !isSessionActiveForTool(sessionKey)) {
|
|
227
|
+
dcgLogger(`dcgchat_message: skipped (session not active, likely stale run after /stop or turn end): ${sessionKey}`)
|
|
228
|
+
return {
|
|
229
|
+
...jsonResult({
|
|
230
|
+
success: false,
|
|
231
|
+
error: '当前会话无活跃入站轮次,已忽略发送;会话已结束,请勿再调用本工具。'
|
|
232
|
+
}),
|
|
233
|
+
terminate: true
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const sentFiles = new Set<string>()
|
|
239
|
+
const sentKeys = new Set<string>()
|
|
240
|
+
const workspaceDir = pluginCtx.workspaceDir
|
|
241
|
+
const allowedPaths = pluginCtx.allowedPaths
|
|
242
|
+
const mediaText = (toolArgs.content ?? '').trim()
|
|
243
|
+
let contentSentWithMedia = false
|
|
244
|
+
|
|
245
|
+
if (toolArgs.media?.length) {
|
|
246
|
+
const mediaUrls = []
|
|
247
|
+
for (const media of toolArgs.media) {
|
|
248
|
+
if (!media.file) continue
|
|
249
|
+
const filepath = expandTildePath(media.file)
|
|
250
|
+
if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
|
|
251
|
+
if (!isSafeFile(filepath, safeExtensions)) continue
|
|
252
|
+
const key = pathKey(filepath)
|
|
253
|
+
if (sentKeys.has(key)) continue
|
|
254
|
+
|
|
255
|
+
mediaUrls.push(filepath)
|
|
256
|
+
sentFiles.add(filepath)
|
|
257
|
+
sentKeys.add(key)
|
|
258
|
+
}
|
|
259
|
+
if (mediaUrls.length > 0) {
|
|
260
|
+
const text = isCronSessionKey && !contentSentWithMedia ? mediaText : undefined
|
|
261
|
+
if (text) contentSentWithMedia = true
|
|
262
|
+
|
|
263
|
+
await sendDcgchatMedia({ sessionKey, mediaUrls, text })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const fallbackPaths = extractPaths(toolArgs.content, workspaceDir)
|
|
268
|
+
const mediaUrls = []
|
|
269
|
+
for (const filepath of fallbackPaths) {
|
|
270
|
+
const normalizedFilepath = expandTildePath(filepath)
|
|
271
|
+
if (!isSafePath(normalizedFilepath, workspaceDir, allowedPaths)) {
|
|
272
|
+
dcgLogger(`非法路径:${normalizedFilepath}`, 'error')
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
if (!isSafeFile(normalizedFilepath, safeExtensions)) {
|
|
276
|
+
dcgLogger(`非法文件类型:${normalizedFilepath}`, 'error')
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
const key = pathKey(normalizedFilepath)
|
|
280
|
+
if (sentKeys.has(key)) continue
|
|
281
|
+
|
|
282
|
+
mediaUrls.push(normalizedFilepath)
|
|
283
|
+
sentFiles.add(normalizedFilepath)
|
|
284
|
+
sentKeys.add(key)
|
|
285
|
+
}
|
|
286
|
+
if (mediaUrls.length > 0) {
|
|
287
|
+
const text = isCronSessionKey && !contentSentWithMedia ? mediaText : undefined
|
|
288
|
+
if (text) contentSentWithMedia = true
|
|
289
|
+
await sendDcgchatMedia({ sessionKey, mediaUrls, text })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (toolArgs.media?.length && sentFiles.size === 0) {
|
|
293
|
+
dcgLogger(`dcgchat_message: no media sent`, 'error')
|
|
294
|
+
return jsonResult({
|
|
295
|
+
success: false,
|
|
296
|
+
error:
|
|
297
|
+
'未能发送任何附件:路径须位于当前 Agent 工作区,或为 /workspace/、/mobook/ 下的真实文件(非空、扩展名在白名单内)。',
|
|
298
|
+
sentMediaCount: 0
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let content = toolArgs.content ?? ''
|
|
303
|
+
for (const filepath of sentFiles) {
|
|
304
|
+
const posix = toPosixPath(filepath)
|
|
305
|
+
const variants = posix === filepath ? [filepath] : [filepath, posix]
|
|
306
|
+
const seen = new Set<string>()
|
|
307
|
+
for (const v of variants) {
|
|
308
|
+
if (!v || seen.has(v)) continue
|
|
309
|
+
seen.add(v)
|
|
310
|
+
content = content.split(v).join('')
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
content = content.trim()
|
|
314
|
+
if (contentSentWithMedia) content = ''
|
|
315
|
+
|
|
316
|
+
// 与 bot onPartialReply/deliver 共用 outboundAssistantText 快照增量,避免双发;NO_REPLY 场景由工具兜底正文
|
|
317
|
+
if (content.length > 0 && content !== 'NO_REPLY') {
|
|
318
|
+
const msgCtx = getOutboundMsgParams(sessionKey)
|
|
319
|
+
emitOutboundAssistantText(sessionKey, content, msgCtx)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return jsonResult({
|
|
323
|
+
success: true,
|
|
324
|
+
sentMediaCount: sentFiles.size
|
|
325
|
+
})
|
|
326
|
+
} catch (err) {
|
|
327
|
+
dcgLogger(`dcgchat_message: failed: ${String(err)}`, 'error')
|
|
328
|
+
return jsonResult({
|
|
329
|
+
error: err instanceof Error ? err.message : String(err)
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { mediaPathDedupeKey } from '../utils/global.js'
|
|
2
|
+
import { dcgLogger } from '../utils/log.js'
|
|
3
|
+
import { expandTildePath } from '../utils/workspaceFilePaths.js'
|
|
4
|
+
|
|
5
|
+
const LOG_TAG = 'tool-call-guard'
|
|
6
|
+
|
|
7
|
+
/** 与 OpenClaw `PluginHookBeforeToolCallResult` 对齐 */
|
|
8
|
+
export type DcgchatBeforeToolCallHookResult = {
|
|
9
|
+
params?: Record<string, unknown>
|
|
10
|
+
block?: boolean
|
|
11
|
+
blockReason?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_TRACKED_RUNS = 200
|
|
15
|
+
|
|
16
|
+
/** runId → 已下发的 message 正文 */
|
|
17
|
+
const sentMessageTextByRunId = new Map<string, Set<string>>()
|
|
18
|
+
/** runId → 已下发的 message 附件路径(规范化) */
|
|
19
|
+
const sentMessageFileByRunId = new Map<string, Set<string>>()
|
|
20
|
+
/** runId → 上一笔已成功完成的 FS 操作键(tool + 文件;仅拦截与上一笔相同的连续重复) */
|
|
21
|
+
const lastCompletedFsOpByRunId = new Map<string, string>()
|
|
22
|
+
|
|
23
|
+
const FS_LOOP_TOOLS = new Set(['read', 'write', 'edit', 'apply_patch'])
|
|
24
|
+
const TERMINATE_BLOCK_REASON = 'Tool round ended for this session (no active inbound turn). Do not retry; reply with NO_REPLY only.'
|
|
25
|
+
|
|
26
|
+
function pruneRunMap<T>(map: Map<string, T>, runId: string, factory: () => T): T {
|
|
27
|
+
let entry = map.get(runId)
|
|
28
|
+
if (!entry) {
|
|
29
|
+
if (map.size >= MAX_TRACKED_RUNS) {
|
|
30
|
+
const oldest = map.keys().next().value
|
|
31
|
+
if (oldest) map.delete(oldest)
|
|
32
|
+
}
|
|
33
|
+
entry = factory()
|
|
34
|
+
map.set(runId, entry)
|
|
35
|
+
}
|
|
36
|
+
return entry
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mergeBeforeToolCallResults(
|
|
40
|
+
...results: Array<DcgchatBeforeToolCallHookResult | undefined>
|
|
41
|
+
): DcgchatBeforeToolCallHookResult | undefined {
|
|
42
|
+
let paramsPatch: Record<string, unknown> | undefined
|
|
43
|
+
for (const r of results) {
|
|
44
|
+
if (!r) continue
|
|
45
|
+
if (r.block) return r
|
|
46
|
+
if (r.params) paramsPatch = r.params
|
|
47
|
+
}
|
|
48
|
+
return paramsPatch ? { params: paramsPatch } : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBuiltinMessageTool(toolName: string): boolean {
|
|
52
|
+
return toolName === 'message'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isDcgchatMessageTool(toolName: string): boolean {
|
|
56
|
+
return toolName === 'dcgchat_message'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isOutboundMessagingTool(toolName: string): boolean {
|
|
60
|
+
return isBuiltinMessageTool(toolName) || isDcgchatMessageTool(toolName)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isMessageSendAction(params: Record<string, unknown>): boolean {
|
|
64
|
+
const action = typeof params.action === 'string' ? params.action.trim().toLowerCase() : 'send'
|
|
65
|
+
return action === 'send'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractMessageSendText(params: Record<string, unknown>): string | null {
|
|
69
|
+
if (!isMessageSendAction(params)) return null
|
|
70
|
+
const message = typeof params.message === 'string' ? params.message.trim() : ''
|
|
71
|
+
return message || null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractDcgchatMessageSendText(params: Record<string, unknown>): string | null {
|
|
75
|
+
const content = typeof params.content === 'string' ? params.content.trim() : ''
|
|
76
|
+
if (!content || content === 'NO_REPLY') return null
|
|
77
|
+
return content
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractDcgchatMessageSendFileKeys(params: Record<string, unknown>): string[] {
|
|
81
|
+
const media = params.media
|
|
82
|
+
if (!Array.isArray(media)) return []
|
|
83
|
+
const keys: string[] = []
|
|
84
|
+
for (const item of media) {
|
|
85
|
+
if (!item || typeof item !== 'object') continue
|
|
86
|
+
const file = (item as { file?: string }).file
|
|
87
|
+
if (typeof file === 'string' && file.trim()) {
|
|
88
|
+
const k = mediaPathDedupeKey(file.trim())
|
|
89
|
+
if (k) keys.push(k)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...new Set(keys)]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractOutboundMessagingText(toolName: string, params: Record<string, unknown>): string | null {
|
|
96
|
+
if (isBuiltinMessageTool(toolName)) return extractMessageSendText(params)
|
|
97
|
+
if (isDcgchatMessageTool(toolName)) return extractDcgchatMessageSendText(params)
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractOutboundMessagingFileKeys(toolName: string, params: Record<string, unknown>): string[] {
|
|
102
|
+
if (isBuiltinMessageTool(toolName)) return extractMessageSendFileKeys(params)
|
|
103
|
+
if (isDcgchatMessageTool(toolName)) return extractDcgchatMessageSendFileKeys(params)
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isOutboundMessagingSend(toolName: string, params: Record<string, unknown>): boolean {
|
|
108
|
+
return extractOutboundMessagingText(toolName, params) !== null || extractOutboundMessagingFileKeys(toolName, params).length > 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 内置 message 的 filePath / path / media[].file */
|
|
112
|
+
function extractMessageSendFileKeys(params: Record<string, unknown>): string[] {
|
|
113
|
+
if (!isMessageSendAction(params)) return []
|
|
114
|
+
const keys: string[] = []
|
|
115
|
+
for (const field of ['filePath', 'path'] as const) {
|
|
116
|
+
const v = params[field]
|
|
117
|
+
if (typeof v === 'string' && v.trim()) {
|
|
118
|
+
const k = mediaPathDedupeKey(v.trim())
|
|
119
|
+
if (k) keys.push(k)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const media = params.media
|
|
123
|
+
if (Array.isArray(media)) {
|
|
124
|
+
for (const item of media) {
|
|
125
|
+
if (!item || typeof item !== 'object') continue
|
|
126
|
+
const file = (item as { file?: string }).file
|
|
127
|
+
if (typeof file === 'string' && file.trim()) {
|
|
128
|
+
const k = mediaPathDedupeKey(file.trim())
|
|
129
|
+
if (k) keys.push(k)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return [...new Set(keys)]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 会话非 running 时仍须执行 `before_tool_call` + guard(由 tool.ts 外层门控调用)。
|
|
138
|
+
* 仅出站 `message(send)` 需要拦截;其它 message 动作无需放行。
|
|
139
|
+
*/
|
|
140
|
+
export function shouldRunBeforeToolCallGuardWhenSessionInactive(event: {
|
|
141
|
+
toolName?: string
|
|
142
|
+
params?: Record<string, unknown>
|
|
143
|
+
}): boolean {
|
|
144
|
+
const toolName = event.toolName?.trim()
|
|
145
|
+
if (!toolName || !isOutboundMessagingTool(toolName)) return false
|
|
146
|
+
return isOutboundMessagingSend(toolName, event.params ?? {})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stableJsonKey(value: unknown): string {
|
|
150
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
151
|
+
if (Array.isArray(value)) return `[${value.map(stableJsonKey).join(',')}]`
|
|
152
|
+
const obj = value as Record<string, unknown>
|
|
153
|
+
const keys = Object.keys(obj).sort()
|
|
154
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJsonKey(obj[k])}`).join(',')}}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const FS_PATH_FIELDS = ['path', 'filePath', 'file_path', 'file'] as const
|
|
158
|
+
|
|
159
|
+
function normalizeFsPathParam(params: Record<string, unknown>): string | null {
|
|
160
|
+
for (const field of FS_PATH_FIELDS) {
|
|
161
|
+
const v = params[field]
|
|
162
|
+
if (typeof v === 'string' && v.trim()) {
|
|
163
|
+
const key = mediaPathDedupeKey(expandTildePath(v.trim()))
|
|
164
|
+
if (key) return key
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fsReadRangeKey(params: Record<string, unknown>): string {
|
|
171
|
+
const offset = params.offset ?? params.startLine ?? ''
|
|
172
|
+
const limit = params.limit ?? params.endLine ?? ''
|
|
173
|
+
return `${String(offset)}:${String(limit)}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 防循环操作键:同一工具 + 同一文件(read 另含 offset/limit)。
|
|
178
|
+
* 中间插入其它工具调用后会更新 last,允许对同文件再次执行同类操作。
|
|
179
|
+
*/
|
|
180
|
+
function fsLoopOpKey(toolName: string, params: Record<string, unknown>): string | null {
|
|
181
|
+
if (!FS_LOOP_TOOLS.has(toolName)) return null
|
|
182
|
+
const pathKey = normalizeFsPathParam(params)
|
|
183
|
+
if (toolName === 'read') {
|
|
184
|
+
if (!pathKey) return `read:${stableJsonKey(params)}`
|
|
185
|
+
return `read:${pathKey}:${fsReadRangeKey(params)}`
|
|
186
|
+
}
|
|
187
|
+
if (pathKey) return `${toolName}:${pathKey}`
|
|
188
|
+
return `${toolName}:${stableJsonKey(params)}`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function setLastCompletedFsOp(runId: string, opKey: string): void {
|
|
192
|
+
if (lastCompletedFsOpByRunId.size >= MAX_TRACKED_RUNS && !lastCompletedFsOpByRunId.has(runId)) {
|
|
193
|
+
const oldest = lastCompletedFsOpByRunId.keys().next().value
|
|
194
|
+
if (oldest) lastCompletedFsOpByRunId.delete(oldest)
|
|
195
|
+
}
|
|
196
|
+
lastCompletedFsOpByRunId.set(runId, opKey)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clearLastCompletedFsOp(runId: string): void {
|
|
200
|
+
lastCompletedFsOpByRunId.delete(runId)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function blockDuplicate(kind: string, runId: string, toolCallId: string | undefined, detail: string): DcgchatBeforeToolCallHookResult {
|
|
204
|
+
dcgLogger(`[${LOG_TAG}] block duplicate ${kind} runId=${runId} toolCallId=${toolCallId ?? ''} ${detail}`, 'log')
|
|
205
|
+
return {
|
|
206
|
+
block: true,
|
|
207
|
+
blockReason:
|
|
208
|
+
`Duplicate ${kind} in the current agent run (${detail}). ` +
|
|
209
|
+
'Skip this repeated tool call and continue with other steps; do not retry the same call.'
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function blockTerminateLike(runId: string, toolCallId: string | undefined, toolName: string): DcgchatBeforeToolCallHookResult {
|
|
214
|
+
dcgLogger(
|
|
215
|
+
`[${LOG_TAG}] block ${toolName} (session inactive, terminate-equivalent) runId=${runId} toolCallId=${toolCallId ?? ''}`,
|
|
216
|
+
'log'
|
|
217
|
+
)
|
|
218
|
+
return { block: true, blockReason: TERMINATE_BLOCK_REASON }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isCronSessionKey(sessionKey: string): boolean {
|
|
222
|
+
return sessionKey.includes(':cron:')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type ToolCallGuardBeforeContext = {
|
|
226
|
+
sessionKey: string
|
|
227
|
+
/** 与 dcgchat_message / channel.sendText 一致:无活跃入站轮次则不应再出站 */
|
|
228
|
+
sessionActive: boolean
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* before_tool_call:内置 message(正文/附件)去重;FS 工具仅拦截「与上一笔成功调用相同文件+相同操作」的连续重复;
|
|
233
|
+
* 无活跃会话时对出站 message 做 terminate 等效 block(对齐 dcgchat_message)。
|
|
234
|
+
*/
|
|
235
|
+
export function toolCallGuardBefore(
|
|
236
|
+
event: {
|
|
237
|
+
toolName?: string
|
|
238
|
+
params?: Record<string, unknown>
|
|
239
|
+
runId?: string
|
|
240
|
+
toolCallId?: string
|
|
241
|
+
},
|
|
242
|
+
ctx: ToolCallGuardBeforeContext
|
|
243
|
+
): DcgchatBeforeToolCallHookResult | undefined {
|
|
244
|
+
const toolName = event.toolName?.trim()
|
|
245
|
+
if (!toolName) return undefined
|
|
246
|
+
|
|
247
|
+
const runId = event.runId?.trim()
|
|
248
|
+
if (!runId) return undefined
|
|
249
|
+
|
|
250
|
+
const params = event.params ?? {}
|
|
251
|
+
|
|
252
|
+
if (isOutboundMessagingTool(toolName)) {
|
|
253
|
+
if (isOutboundMessagingSend(toolName, params) && !ctx.sessionActive && !isCronSessionKey(ctx.sessionKey)) {
|
|
254
|
+
return blockTerminateLike(runId, event.toolCallId, toolName)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const text = extractOutboundMessagingText(toolName, params)
|
|
258
|
+
if (text) {
|
|
259
|
+
const sent = sentMessageTextByRunId.get(runId)
|
|
260
|
+
if (sent?.has(text)) {
|
|
261
|
+
return blockDuplicate(`${toolName} text`, runId, event.toolCallId, `len=${text.length}`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const fileKeys = extractOutboundMessagingFileKeys(toolName, params)
|
|
266
|
+
if (fileKeys.length) {
|
|
267
|
+
const sentFiles = sentMessageFileByRunId.get(runId)
|
|
268
|
+
for (const fk of fileKeys) {
|
|
269
|
+
if (sentFiles?.has(fk)) {
|
|
270
|
+
return blockDuplicate(`${toolName} file`, runId, event.toolCallId, `path=${fk}`)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const opKey = fsLoopOpKey(toolName, params)
|
|
278
|
+
if (!opKey) return undefined
|
|
279
|
+
|
|
280
|
+
if (lastCompletedFsOpByRunId.get(runId) === opKey) {
|
|
281
|
+
return blockDuplicate(toolName, runId, event.toolCallId, 'consecutive same file operation')
|
|
282
|
+
}
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** after_tool_call:登记已成功完成的出站 message / 文件类工具,供后续 before 拦截 */
|
|
287
|
+
export function toolCallGuardAfter(event: {
|
|
288
|
+
toolName?: string
|
|
289
|
+
params?: Record<string, unknown>
|
|
290
|
+
runId?: string
|
|
291
|
+
error?: string
|
|
292
|
+
}): void {
|
|
293
|
+
if (event.error) return
|
|
294
|
+
|
|
295
|
+
const toolName = event.toolName?.trim()
|
|
296
|
+
if (!toolName) return
|
|
297
|
+
|
|
298
|
+
const runId = event.runId?.trim()
|
|
299
|
+
if (!runId) return
|
|
300
|
+
|
|
301
|
+
const params = event.params ?? {}
|
|
302
|
+
|
|
303
|
+
if (isOutboundMessagingTool(toolName)) {
|
|
304
|
+
const text = extractOutboundMessagingText(toolName, params)
|
|
305
|
+
if (text) {
|
|
306
|
+
pruneRunMap(sentMessageTextByRunId, runId, () => new Set()).add(text)
|
|
307
|
+
}
|
|
308
|
+
for (const fk of extractOutboundMessagingFileKeys(toolName, params)) {
|
|
309
|
+
pruneRunMap(sentMessageFileByRunId, runId, () => new Set()).add(fk)
|
|
310
|
+
}
|
|
311
|
+
clearLastCompletedFsOp(runId)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const opKey = fsLoopOpKey(toolName, params)
|
|
316
|
+
if (opKey) {
|
|
317
|
+
setLastCompletedFsOp(runId, opKey)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
clearLastCompletedFsOp(runId)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function mergeDcgchatBeforeToolCallHookResults(
|
|
324
|
+
...results: Array<DcgchatBeforeToolCallHookResult | undefined>
|
|
325
|
+
): DcgchatBeforeToolCallHookResult | undefined {
|
|
326
|
+
return mergeBeforeToolCallResults(...results)
|
|
327
|
+
}
|