@swarmclawai/swarmclaw 0.9.4 → 0.9.6
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 +8 -8
- package/package.json +1 -1
- package/src/app/api/settings/route.ts +1 -0
- package/src/app/api/wallets/[id]/send/route.ts +10 -4
- package/src/app/api/wallets/route.ts +5 -2
- package/src/app/settings/page.tsx +9 -0
- package/src/components/wallets/wallet-panel.tsx +12 -1
- package/src/components/wallets/wallet-section.tsx +4 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
- package/src/lib/server/agents/main-agent-loop.ts +12 -1
- package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
- package/src/lib/server/agents/subagent-runtime.ts +115 -19
- package/src/lib/server/agents/subagent-swarm.ts +3 -3
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
- package/src/lib/server/chat-execution/chat-execution.ts +357 -152
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
- package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
- package/src/lib/server/connectors/contact-boundaries.ts +70 -8
- package/src/lib/server/connectors/manager.test.ts +129 -7
- package/src/lib/server/plugins.test.ts +263 -0
- package/src/lib/server/plugins.ts +406 -10
- package/src/lib/server/session-tools/context.ts +15 -1
- package/src/lib/server/session-tools/index.ts +42 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
- package/src/lib/server/session-tools/subagent.ts +3 -3
- package/src/lib/server/tool-loop-detection.test.ts +21 -0
- package/src/lib/server/tool-loop-detection.ts +79 -0
- package/src/lib/server/wallet/wallet-service.test.ts +25 -1
- package/src/lib/server/wallet/wallet-service.ts +13 -0
- package/src/types/index.ts +134 -1
- package/src/views/settings/section-wallets.tsx +35 -0
|
@@ -3,7 +3,24 @@ import path from 'path'
|
|
|
3
3
|
import crypto from 'crypto'
|
|
4
4
|
import { createRequire } from 'module'
|
|
5
5
|
import { spawn } from 'child_process'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
Plugin,
|
|
8
|
+
PluginHooks,
|
|
9
|
+
PluginMeta,
|
|
10
|
+
PluginToolDef,
|
|
11
|
+
PluginUIExtension,
|
|
12
|
+
PluginProviderExtension,
|
|
13
|
+
PluginConnectorExtension,
|
|
14
|
+
Session,
|
|
15
|
+
PluginPackageManager,
|
|
16
|
+
PluginDependencyInstallStatus,
|
|
17
|
+
PluginPromptBuildResult,
|
|
18
|
+
PluginToolCallResult,
|
|
19
|
+
PluginModelResolveResult,
|
|
20
|
+
PluginBeforeMessageWriteResult,
|
|
21
|
+
PluginSubagentSpawningResult,
|
|
22
|
+
Message,
|
|
23
|
+
} from '@/types'
|
|
7
24
|
import {
|
|
8
25
|
inferPluginInstallSourceFromUrl,
|
|
9
26
|
inferPluginPublisherSourceFromUrl,
|
|
@@ -101,6 +118,9 @@ interface PluginLogger {
|
|
|
101
118
|
type HookRegistrar = {
|
|
102
119
|
onAgentStart?: (fn: (...args: unknown[]) => unknown) => void
|
|
103
120
|
onAgentComplete?: (fn: (...args: unknown[]) => unknown) => void
|
|
121
|
+
onBeforeModelResolve?: (fn: (...args: unknown[]) => unknown) => void
|
|
122
|
+
onBeforePromptBuild?: (fn: (...args: unknown[]) => unknown) => void
|
|
123
|
+
onBeforeToolCall?: (fn: (...args: unknown[]) => unknown) => void
|
|
104
124
|
onToolCall?: (fn: (...args: unknown[]) => unknown) => void
|
|
105
125
|
onToolResult?: (fn: (...args: unknown[]) => unknown) => void
|
|
106
126
|
onMessage?: (fn: (...args: unknown[]) => unknown) => void
|
|
@@ -151,6 +171,69 @@ function isPluginSecretSettingValue(value: unknown): value is PluginSecretSettin
|
|
|
151
171
|
return rec.__pluginSecret === true && typeof rec.encrypted === 'string'
|
|
152
172
|
}
|
|
153
173
|
|
|
174
|
+
function concatOptionalTextSegments(...segments: Array<string | null | undefined>): string | undefined {
|
|
175
|
+
const normalized = segments
|
|
176
|
+
.map((segment) => (typeof segment === 'string' ? segment.trim() : ''))
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
return normalized.length > 0 ? normalized.join('\n\n') : undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function mergePromptBuildResults(
|
|
182
|
+
current: PluginPromptBuildResult | undefined,
|
|
183
|
+
next: PluginPromptBuildResult,
|
|
184
|
+
): PluginPromptBuildResult {
|
|
185
|
+
return {
|
|
186
|
+
systemPrompt: current?.systemPrompt ?? next.systemPrompt,
|
|
187
|
+
prependContext: concatOptionalTextSegments(current?.prependContext, next.prependContext),
|
|
188
|
+
prependSystemContext: concatOptionalTextSegments(current?.prependSystemContext, next.prependSystemContext),
|
|
189
|
+
appendSystemContext: concatOptionalTextSegments(current?.appendSystemContext, next.appendSystemContext),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mergeModelResolveResults(
|
|
194
|
+
current: PluginModelResolveResult | undefined,
|
|
195
|
+
next: PluginModelResolveResult,
|
|
196
|
+
): PluginModelResolveResult {
|
|
197
|
+
return {
|
|
198
|
+
providerOverride: next.providerOverride ?? current?.providerOverride,
|
|
199
|
+
modelOverride: next.modelOverride ?? current?.modelOverride,
|
|
200
|
+
apiEndpointOverride: next.apiEndpointOverride ?? current?.apiEndpointOverride,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isToolCallControlResult(value: unknown): value is PluginToolCallResult {
|
|
205
|
+
if (!isRecord(value)) return false
|
|
206
|
+
return 'input' in value || 'params' in value || 'block' in value || 'blockReason' in value || 'warning' in value
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isMessageLike(value: unknown): value is Message {
|
|
210
|
+
return isRecord(value)
|
|
211
|
+
&& (value.role === 'user' || value.role === 'assistant')
|
|
212
|
+
&& typeof value.text === 'string'
|
|
213
|
+
&& typeof value.time === 'number'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isBeforeMessageWriteResult(value: unknown): value is PluginBeforeMessageWriteResult {
|
|
217
|
+
if (!isRecord(value)) return false
|
|
218
|
+
return 'message' in value || 'block' in value
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isSubagentSpawningResult(value: unknown): value is PluginSubagentSpawningResult {
|
|
222
|
+
return isRecord(value) && (value.status === 'ok' || value.status === 'error')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function mergeToolCallInput(
|
|
226
|
+
currentInput: Record<string, unknown> | null,
|
|
227
|
+
nextInput: Record<string, unknown> | null | undefined,
|
|
228
|
+
): Record<string, unknown> | null {
|
|
229
|
+
if (nextInput === undefined) return currentInput
|
|
230
|
+
if (nextInput === null) return null
|
|
231
|
+
if (currentInput && typeof currentInput === 'object') {
|
|
232
|
+
return { ...currentInput, ...nextInput }
|
|
233
|
+
}
|
|
234
|
+
return nextInput
|
|
235
|
+
}
|
|
236
|
+
|
|
154
237
|
function hashPluginSource(content: string): string {
|
|
155
238
|
return crypto.createHash('sha256').update(content).digest('hex')
|
|
156
239
|
}
|
|
@@ -404,6 +487,18 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
404
487
|
const tools: PluginToolDef[] = []
|
|
405
488
|
|
|
406
489
|
const hookEventMap: Record<string, keyof PluginHooks> = {
|
|
490
|
+
'before_model_resolve': 'beforeModelResolve',
|
|
491
|
+
'before_prompt_build': 'beforePromptBuild',
|
|
492
|
+
'before_tool_call': 'beforeToolCall',
|
|
493
|
+
'llm_input': 'llmInput',
|
|
494
|
+
'llm_output': 'llmOutput',
|
|
495
|
+
'tool_result_persist': 'toolResultPersist',
|
|
496
|
+
'before_message_write': 'beforeMessageWrite',
|
|
497
|
+
'session_start': 'sessionStart',
|
|
498
|
+
'session_end': 'sessionEnd',
|
|
499
|
+
'subagent_spawning': 'subagentSpawning',
|
|
500
|
+
'subagent_spawned': 'subagentSpawned',
|
|
501
|
+
'subagent_ended': 'subagentEnded',
|
|
407
502
|
'agent:start': 'beforeAgentStart',
|
|
408
503
|
'agent:complete': 'afterAgentComplete',
|
|
409
504
|
'tool:call': 'beforeToolExec',
|
|
@@ -484,6 +579,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
484
579
|
const registrar = {
|
|
485
580
|
onAgentStart: (fn: (...args: unknown[]) => unknown) => { hooks.beforeAgentStart = fn as PluginHooks['beforeAgentStart'] },
|
|
486
581
|
onAgentComplete: (fn: (...args: unknown[]) => unknown) => { hooks.afterAgentComplete = fn as PluginHooks['afterAgentComplete'] },
|
|
582
|
+
onBeforePromptBuild: (fn: (...args: unknown[]) => unknown) => { hooks.beforePromptBuild = fn as PluginHooks['beforePromptBuild'] },
|
|
583
|
+
onBeforeToolCall: (fn: (...args: unknown[]) => unknown) => { hooks.beforeToolCall = fn as PluginHooks['beforeToolCall'] },
|
|
487
584
|
onToolCall: (fn: (...args: unknown[]) => unknown) => { hooks.beforeToolExec = fn as PluginHooks['beforeToolExec'] },
|
|
488
585
|
onToolResult: (fn: (...args: unknown[]) => unknown) => { hooks.afterToolExec = fn as PluginHooks['afterToolExec'] },
|
|
489
586
|
onMessage: (fn: (...args: unknown[]) => unknown) => { hooks.onMessage = fn as PluginHooks['onMessage'] },
|
|
@@ -1048,22 +1145,154 @@ class PluginManager {
|
|
|
1048
1145
|
}
|
|
1049
1146
|
}
|
|
1050
1147
|
|
|
1051
|
-
async
|
|
1052
|
-
params: {
|
|
1148
|
+
async runBeforePromptBuild(
|
|
1149
|
+
params: {
|
|
1150
|
+
session: Session
|
|
1151
|
+
prompt: string
|
|
1152
|
+
message: string
|
|
1153
|
+
history: import('@/types').Message[]
|
|
1154
|
+
messages: import('@/types').Message[]
|
|
1155
|
+
},
|
|
1053
1156
|
options?: HookExecutionOptions,
|
|
1054
|
-
): Promise<
|
|
1157
|
+
): Promise<PluginPromptBuildResult | null> {
|
|
1055
1158
|
this.load()
|
|
1056
1159
|
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1057
|
-
let
|
|
1160
|
+
let result: PluginPromptBuildResult | undefined
|
|
1161
|
+
|
|
1162
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1163
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1164
|
+
const hook = p.hooks.beforePromptBuild
|
|
1165
|
+
if (!hook) continue
|
|
1166
|
+
try {
|
|
1167
|
+
const next = await hook(params)
|
|
1168
|
+
if (next && typeof next === 'object' && !Array.isArray(next)) {
|
|
1169
|
+
result = mergePromptBuildResults(result, next as PluginPromptBuildResult)
|
|
1170
|
+
}
|
|
1171
|
+
this.markPluginSuccess(id)
|
|
1172
|
+
} catch (err: unknown) {
|
|
1173
|
+
log.error('plugins', 'beforePromptBuild hook failed', {
|
|
1174
|
+
pluginId: id,
|
|
1175
|
+
pluginName: p.meta.name,
|
|
1176
|
+
error: errorMessage(err),
|
|
1177
|
+
})
|
|
1178
|
+
this.markPluginFailure(id, 'hook.beforePromptBuild', err, true)
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
return result || null
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async runBeforeModelResolve(
|
|
1186
|
+
params: {
|
|
1187
|
+
session: Session
|
|
1188
|
+
prompt: string
|
|
1189
|
+
message: string
|
|
1190
|
+
provider: Session['provider']
|
|
1191
|
+
model: string
|
|
1192
|
+
apiEndpoint?: string | null
|
|
1193
|
+
},
|
|
1194
|
+
options?: HookExecutionOptions,
|
|
1195
|
+
): Promise<PluginModelResolveResult | null> {
|
|
1196
|
+
this.load()
|
|
1197
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1198
|
+
let result: PluginModelResolveResult | undefined
|
|
1058
1199
|
|
|
1059
1200
|
for (const [id, p] of this.plugins.entries()) {
|
|
1060
1201
|
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1061
|
-
const hook = p.hooks.
|
|
1202
|
+
const hook = p.hooks.beforeModelResolve
|
|
1062
1203
|
if (!hook) continue
|
|
1063
1204
|
try {
|
|
1064
|
-
const
|
|
1065
|
-
if (
|
|
1066
|
-
|
|
1205
|
+
const next = await hook(params)
|
|
1206
|
+
if (next && typeof next === 'object' && !Array.isArray(next)) {
|
|
1207
|
+
result = mergeModelResolveResults(result, next as PluginModelResolveResult)
|
|
1208
|
+
}
|
|
1209
|
+
this.markPluginSuccess(id)
|
|
1210
|
+
} catch (err: unknown) {
|
|
1211
|
+
log.error('plugins', 'beforeModelResolve hook failed', {
|
|
1212
|
+
pluginId: id,
|
|
1213
|
+
pluginName: p.meta.name,
|
|
1214
|
+
error: errorMessage(err),
|
|
1215
|
+
})
|
|
1216
|
+
this.markPluginFailure(id, 'hook.beforeModelResolve', err, true)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return result || null
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async runBeforeToolCall(
|
|
1224
|
+
params: {
|
|
1225
|
+
session: Session
|
|
1226
|
+
toolName: string
|
|
1227
|
+
input: Record<string, unknown> | null
|
|
1228
|
+
runId?: string
|
|
1229
|
+
toolCallId?: string
|
|
1230
|
+
},
|
|
1231
|
+
options?: HookExecutionOptions,
|
|
1232
|
+
): Promise<{ input: Record<string, unknown> | null; blockReason: string | null; warning: string | null }> {
|
|
1233
|
+
this.load()
|
|
1234
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1235
|
+
let currentInput = params.input
|
|
1236
|
+
let blockReason: string | null = null
|
|
1237
|
+
let warning: string | null = null
|
|
1238
|
+
|
|
1239
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1240
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1241
|
+
|
|
1242
|
+
const beforeToolCall = p.hooks.beforeToolCall
|
|
1243
|
+
if (beforeToolCall) {
|
|
1244
|
+
try {
|
|
1245
|
+
const result = await beforeToolCall({
|
|
1246
|
+
session: params.session,
|
|
1247
|
+
toolName: params.toolName,
|
|
1248
|
+
input: currentInput,
|
|
1249
|
+
runId: params.runId,
|
|
1250
|
+
toolCallId: params.toolCallId,
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
if (isToolCallControlResult(result)) {
|
|
1254
|
+
if (result.block === true) {
|
|
1255
|
+
blockReason = typeof result.blockReason === 'string' && result.blockReason.trim()
|
|
1256
|
+
? result.blockReason.trim()
|
|
1257
|
+
: 'Tool call blocked by plugin hook'
|
|
1258
|
+
}
|
|
1259
|
+
if (typeof result.warning === 'string' && result.warning.trim()) {
|
|
1260
|
+
warning = result.warning.trim()
|
|
1261
|
+
}
|
|
1262
|
+
currentInput = mergeToolCallInput(
|
|
1263
|
+
currentInput,
|
|
1264
|
+
isRecord(result.params)
|
|
1265
|
+
? result.params
|
|
1266
|
+
: isRecord(result.input)
|
|
1267
|
+
? result.input
|
|
1268
|
+
: result.input === null
|
|
1269
|
+
? null
|
|
1270
|
+
: undefined,
|
|
1271
|
+
)
|
|
1272
|
+
} else if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
1273
|
+
currentInput = result as Record<string, unknown>
|
|
1274
|
+
}
|
|
1275
|
+
this.markPluginSuccess(id)
|
|
1276
|
+
} catch (err: unknown) {
|
|
1277
|
+
log.error('plugins', 'beforeToolCall hook failed', {
|
|
1278
|
+
pluginId: id,
|
|
1279
|
+
pluginName: p.meta.name,
|
|
1280
|
+
toolName: params.toolName,
|
|
1281
|
+
error: errorMessage(err),
|
|
1282
|
+
})
|
|
1283
|
+
this.markPluginFailure(id, 'hook.beforeToolCall', err, true)
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const beforeToolExec = p.hooks.beforeToolExec
|
|
1288
|
+
if (blockReason) break
|
|
1289
|
+
if (!beforeToolExec) {
|
|
1290
|
+
continue
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
const legacyResult = await beforeToolExec({ toolName: params.toolName, input: currentInput })
|
|
1294
|
+
if (legacyResult && typeof legacyResult === 'object' && !Array.isArray(legacyResult)) {
|
|
1295
|
+
currentInput = legacyResult as Record<string, unknown>
|
|
1067
1296
|
}
|
|
1068
1297
|
this.markPluginSuccess(id)
|
|
1069
1298
|
} catch (err: unknown) {
|
|
@@ -1075,9 +1304,176 @@ class PluginManager {
|
|
|
1075
1304
|
})
|
|
1076
1305
|
this.markPluginFailure(id, 'hook.beforeToolExec', err, true)
|
|
1077
1306
|
}
|
|
1307
|
+
|
|
1308
|
+
if (blockReason) break
|
|
1078
1309
|
}
|
|
1079
1310
|
|
|
1080
|
-
return currentInput
|
|
1311
|
+
return { input: currentInput, blockReason, warning }
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async runToolResultPersist(
|
|
1315
|
+
params: {
|
|
1316
|
+
session: Session
|
|
1317
|
+
message: Message
|
|
1318
|
+
toolName?: string
|
|
1319
|
+
toolCallId?: string
|
|
1320
|
+
isSynthetic?: boolean
|
|
1321
|
+
},
|
|
1322
|
+
options?: HookExecutionOptions,
|
|
1323
|
+
): Promise<Message> {
|
|
1324
|
+
this.load()
|
|
1325
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1326
|
+
let currentMessage = params.message
|
|
1327
|
+
|
|
1328
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1329
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1330
|
+
const hook = p.hooks.toolResultPersist
|
|
1331
|
+
if (!hook) continue
|
|
1332
|
+
try {
|
|
1333
|
+
const result = await hook({
|
|
1334
|
+
session: params.session,
|
|
1335
|
+
message: currentMessage,
|
|
1336
|
+
toolName: params.toolName,
|
|
1337
|
+
toolCallId: params.toolCallId,
|
|
1338
|
+
isSynthetic: params.isSynthetic,
|
|
1339
|
+
})
|
|
1340
|
+
if (isMessageLike(result)) {
|
|
1341
|
+
currentMessage = result
|
|
1342
|
+
} else if (isRecord(result) && isMessageLike(result.message)) {
|
|
1343
|
+
currentMessage = result.message
|
|
1344
|
+
}
|
|
1345
|
+
this.markPluginSuccess(id)
|
|
1346
|
+
} catch (err: unknown) {
|
|
1347
|
+
log.error('plugins', 'toolResultPersist hook failed', {
|
|
1348
|
+
pluginId: id,
|
|
1349
|
+
pluginName: p.meta.name,
|
|
1350
|
+
error: errorMessage(err),
|
|
1351
|
+
})
|
|
1352
|
+
this.markPluginFailure(id, 'hook.toolResultPersist', err, true)
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return currentMessage
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async runBeforeMessageWrite(
|
|
1360
|
+
params: {
|
|
1361
|
+
session: Session
|
|
1362
|
+
message: Message
|
|
1363
|
+
phase?: 'user' | 'system' | 'assistant_partial' | 'assistant_final' | 'heartbeat'
|
|
1364
|
+
runId?: string
|
|
1365
|
+
},
|
|
1366
|
+
options?: HookExecutionOptions,
|
|
1367
|
+
): Promise<{ message: Message; block: boolean }> {
|
|
1368
|
+
this.load()
|
|
1369
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1370
|
+
let currentMessage = params.message
|
|
1371
|
+
let block = false
|
|
1372
|
+
|
|
1373
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1374
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1375
|
+
const hook = p.hooks.beforeMessageWrite
|
|
1376
|
+
if (!hook) continue
|
|
1377
|
+
try {
|
|
1378
|
+
const result = await hook({
|
|
1379
|
+
session: params.session,
|
|
1380
|
+
message: currentMessage,
|
|
1381
|
+
phase: params.phase,
|
|
1382
|
+
runId: params.runId,
|
|
1383
|
+
})
|
|
1384
|
+
if (isMessageLike(result)) {
|
|
1385
|
+
currentMessage = result
|
|
1386
|
+
} else if (isBeforeMessageWriteResult(result)) {
|
|
1387
|
+
if (isMessageLike(result.message)) currentMessage = result.message
|
|
1388
|
+
if (result.block === true) {
|
|
1389
|
+
block = true
|
|
1390
|
+
this.markPluginSuccess(id)
|
|
1391
|
+
break
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
this.markPluginSuccess(id)
|
|
1395
|
+
} catch (err: unknown) {
|
|
1396
|
+
log.error('plugins', 'beforeMessageWrite hook failed', {
|
|
1397
|
+
pluginId: id,
|
|
1398
|
+
pluginName: p.meta.name,
|
|
1399
|
+
error: errorMessage(err),
|
|
1400
|
+
})
|
|
1401
|
+
this.markPluginFailure(id, 'hook.beforeMessageWrite', err, true)
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return { message: currentMessage, block }
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async runSubagentSpawning(
|
|
1409
|
+
params: {
|
|
1410
|
+
parentSessionId?: string | null
|
|
1411
|
+
agentId: string
|
|
1412
|
+
agentName: string
|
|
1413
|
+
message: string
|
|
1414
|
+
cwd: string
|
|
1415
|
+
mode: 'run' | 'session'
|
|
1416
|
+
threadRequested: boolean
|
|
1417
|
+
},
|
|
1418
|
+
options?: HookExecutionOptions,
|
|
1419
|
+
): Promise<PluginSubagentSpawningResult> {
|
|
1420
|
+
this.load()
|
|
1421
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1422
|
+
|
|
1423
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1424
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1425
|
+
const hook = p.hooks.subagentSpawning
|
|
1426
|
+
if (!hook) continue
|
|
1427
|
+
try {
|
|
1428
|
+
const result = await hook(params)
|
|
1429
|
+
if (isSubagentSpawningResult(result) && result.status === 'error') {
|
|
1430
|
+
this.markPluginSuccess(id)
|
|
1431
|
+
return {
|
|
1432
|
+
status: 'error',
|
|
1433
|
+
error: typeof result.error === 'string' && result.error.trim()
|
|
1434
|
+
? result.error.trim()
|
|
1435
|
+
: 'Subagent spawn blocked by plugin hook',
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
this.markPluginSuccess(id)
|
|
1439
|
+
} catch (err: unknown) {
|
|
1440
|
+
log.error('plugins', 'subagentSpawning hook failed', {
|
|
1441
|
+
pluginId: id,
|
|
1442
|
+
pluginName: p.meta.name,
|
|
1443
|
+
error: errorMessage(err),
|
|
1444
|
+
})
|
|
1445
|
+
this.markPluginFailure(id, 'hook.subagentSpawning', err, true)
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return { status: 'ok' }
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
async runBeforeToolExec(
|
|
1453
|
+
params: { toolName: string; input: Record<string, unknown> | null },
|
|
1454
|
+
options?: HookExecutionOptions,
|
|
1455
|
+
): Promise<Record<string, unknown> | null> {
|
|
1456
|
+
const result = await this.runBeforeToolCall(
|
|
1457
|
+
{
|
|
1458
|
+
session: {
|
|
1459
|
+
id: 'plugin-hook-session',
|
|
1460
|
+
name: 'Plugin Hook Session',
|
|
1461
|
+
cwd: process.cwd(),
|
|
1462
|
+
user: 'system',
|
|
1463
|
+
// Synthetic fallback used only when no real session context is available.
|
|
1464
|
+
provider: 'openai',
|
|
1465
|
+
model: 'synthetic-hook-context',
|
|
1466
|
+
claudeSessionId: null,
|
|
1467
|
+
messages: [],
|
|
1468
|
+
createdAt: Date.now(),
|
|
1469
|
+
lastActiveAt: Date.now(),
|
|
1470
|
+
},
|
|
1471
|
+
toolName: params.toolName,
|
|
1472
|
+
input: params.input,
|
|
1473
|
+
},
|
|
1474
|
+
options,
|
|
1475
|
+
)
|
|
1476
|
+
return result.input
|
|
1081
1477
|
}
|
|
1082
1478
|
|
|
1083
1479
|
async transformText(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StructuredToolInterface } from '@langchain/core/tools'
|
|
2
|
-
import type { Agent } from '@/types'
|
|
2
|
+
import type { Agent, Session } from '@/types'
|
|
3
3
|
|
|
4
4
|
export const MAX_OUTPUT = 50 * 1024 // 50KB
|
|
5
5
|
export const MAX_FILE = 100 * 1024 // 100KB
|
|
@@ -7,6 +7,7 @@ export const MAX_FILE = 100 * 1024 // 100KB
|
|
|
7
7
|
export interface ToolContext {
|
|
8
8
|
agentId?: string | null
|
|
9
9
|
sessionId?: string | null
|
|
10
|
+
runId?: string | null
|
|
10
11
|
platformAssignScope?: 'self' | 'all'
|
|
11
12
|
mcpServerIds?: string[]
|
|
12
13
|
mcpDisabledTools?: string[]
|
|
@@ -15,6 +16,19 @@ export interface ToolContext {
|
|
|
15
16
|
projectName?: string | null
|
|
16
17
|
projectDescription?: string | null
|
|
17
18
|
memoryScopeMode?: 'auto' | 'all' | 'global' | 'agent' | 'session' | 'project' | null
|
|
19
|
+
beforeToolCall?: (params: {
|
|
20
|
+
session: Session
|
|
21
|
+
toolName: string
|
|
22
|
+
input: Record<string, unknown> | null
|
|
23
|
+
runId?: string | null
|
|
24
|
+
}) => Promise<ToolCallGuardResult | void> | ToolCallGuardResult | void
|
|
25
|
+
onToolCallWarning?: (params: { toolName: string; message: string }) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolCallGuardResult {
|
|
29
|
+
input?: Record<string, unknown> | null
|
|
30
|
+
blockReason?: string | null
|
|
31
|
+
warning?: string | null
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
/**
|
|
@@ -334,8 +334,9 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
|
|
|
334
334
|
name: 'Plugin Hook Session',
|
|
335
335
|
cwd,
|
|
336
336
|
user: 'system',
|
|
337
|
+
// Synthetic fallback used only for hook execution when no persisted session exists.
|
|
337
338
|
provider: 'openai',
|
|
338
|
-
model: '
|
|
339
|
+
model: 'synthetic-hook-context',
|
|
339
340
|
claudeSessionId: null,
|
|
340
341
|
messages: [],
|
|
341
342
|
createdAt: Date.now(),
|
|
@@ -353,19 +354,54 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
|
|
|
353
354
|
throw new DOMException('Tool execution aborted', 'AbortError')
|
|
354
355
|
}
|
|
355
356
|
const normalizedArgs = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
|
|
357
|
+
const hookSession = resolveCurrentSession() || buildFallbackHookSession()
|
|
356
358
|
// Enforce file access policy before execution
|
|
357
359
|
if (fileAccessPolicy) {
|
|
358
360
|
const denial = enforceFileAccessPolicy(candidate.name, normalizedArgs, cwd, fileAccessPolicy)
|
|
359
361
|
if (denial) return denial
|
|
360
362
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
+
let guardedArgs: Record<string, unknown> | null = normalizedArgs
|
|
364
|
+
if (ctx?.beforeToolCall) {
|
|
365
|
+
const guardResult = await ctx.beforeToolCall({
|
|
366
|
+
session: hookSession,
|
|
367
|
+
toolName: candidate.name,
|
|
368
|
+
input: guardedArgs,
|
|
369
|
+
runId: ctx.runId,
|
|
370
|
+
})
|
|
371
|
+
if (guardResult?.warning) {
|
|
372
|
+
ctx.onToolCallWarning?.({
|
|
373
|
+
toolName: candidate.name,
|
|
374
|
+
message: guardResult.warning,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
if (typeof guardResult?.blockReason === 'string' && guardResult.blockReason.trim()) {
|
|
378
|
+
throw new Error(guardResult.blockReason.trim())
|
|
379
|
+
}
|
|
380
|
+
if (guardResult && 'input' in guardResult) {
|
|
381
|
+
guardedArgs = guardResult.input === undefined ? guardedArgs : guardResult.input ?? null
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const hookResult = await pluginManager.runBeforeToolCall(
|
|
385
|
+
{
|
|
386
|
+
session: hookSession,
|
|
387
|
+
toolName: candidate.name,
|
|
388
|
+
input: guardedArgs,
|
|
389
|
+
runId: ctx?.runId || undefined,
|
|
390
|
+
},
|
|
363
391
|
{ enabledIds: activePlugins },
|
|
364
392
|
)
|
|
365
|
-
|
|
366
|
-
|
|
393
|
+
if (hookResult.warning) {
|
|
394
|
+
ctx?.onToolCallWarning?.({
|
|
395
|
+
toolName: candidate.name,
|
|
396
|
+
message: hookResult.warning,
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
if (hookResult.blockReason) {
|
|
400
|
+
throw new Error(hookResult.blockReason)
|
|
401
|
+
}
|
|
402
|
+
const effectiveArgs = hookResult.input ?? guardedArgs
|
|
403
|
+
const result = await candidate.invoke(effectiveArgs ?? {})
|
|
367
404
|
const outputText = typeof result === 'string' ? result : JSON.stringify(result)
|
|
368
|
-
const hookSession = resolveCurrentSession() || buildFallbackHookSession()
|
|
369
405
|
await pluginManager.runHook(
|
|
370
406
|
'afterToolExec',
|
|
371
407
|
{ session: hookSession, toolName: candidate.name, input: effectiveArgs, output: outputText },
|
|
@@ -75,6 +75,56 @@ describe('buildSessionTools signature', () => {
|
|
|
75
75
|
assert.ok(buildSessionTools.length >= 2, 'buildSessionTools should accept at least 2 params')
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
+
it('runs beforeToolCall hooks before invoking plugin tools', async () => {
|
|
79
|
+
const { buildSessionTools } = await import('./index')
|
|
80
|
+
const { getPluginManager } = await import('../plugins')
|
|
81
|
+
const pluginId = `tool_wrapper_${Date.now()}`
|
|
82
|
+
const toolName = `${pluginId}_echo`
|
|
83
|
+
|
|
84
|
+
getPluginManager().registerBuiltin(pluginId, {
|
|
85
|
+
name: 'Tool Wrapper Hook Test',
|
|
86
|
+
hooks: {
|
|
87
|
+
beforeToolCall: ({ input }) => {
|
|
88
|
+
if (input?.message === 'blocked') {
|
|
89
|
+
return { block: true, blockReason: 'blocked before invoke' }
|
|
90
|
+
}
|
|
91
|
+
return { params: { message: `${String(input?.message || '')}:patched` } }
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
tools: [
|
|
95
|
+
{
|
|
96
|
+
name: toolName,
|
|
97
|
+
description: 'Echo the message',
|
|
98
|
+
parameters: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
message: { type: 'string' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
execute: async (args) => String(args.message || ''),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const built = await buildSessionTools(process.cwd(), [pluginId], {
|
|
110
|
+
agentId: 'agent-test',
|
|
111
|
+
sessionId: 'session-test',
|
|
112
|
+
runId: 'run-test',
|
|
113
|
+
})
|
|
114
|
+
const wrapped = built.tools.find((entry) => entry.name === toolName)
|
|
115
|
+
assert.ok(wrapped, 'plugin tool should be built')
|
|
116
|
+
|
|
117
|
+
const ok = await wrapped!.invoke({ message: 'hello' })
|
|
118
|
+
assert.equal(ok, 'hello:patched')
|
|
119
|
+
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => wrapped!.invoke({ message: 'blocked' }),
|
|
122
|
+
/blocked before invoke/,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await built.cleanup()
|
|
126
|
+
})
|
|
127
|
+
|
|
78
128
|
it('sandbox builder exposes the local Node/Docker sandbox tools', async () => {
|
|
79
129
|
const { buildSandboxTools } = await import('./sandbox')
|
|
80
130
|
const bctx: import('./context').ToolBuildContext = {
|
|
@@ -245,7 +245,7 @@ async function handleBatch(args: Record<string, unknown>, ctx: ActionContext): P
|
|
|
245
245
|
: 'auto'
|
|
246
246
|
|
|
247
247
|
// Use spawnSwarm internally — batch is a simplified interface
|
|
248
|
-
const swarm = spawnSwarm({ tasks, executionMode }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
|
|
248
|
+
const swarm = await spawnSwarm({ tasks, executionMode }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
|
|
249
249
|
const jobIds = swarm.members
|
|
250
250
|
.filter((m) => !m.spawnError && m.handle)
|
|
251
251
|
.map((m) => m.handle.jobId)
|
|
@@ -303,7 +303,7 @@ async function handleSwarm(args: Record<string, unknown>, ctx: ActionContext): P
|
|
|
303
303
|
? args.executionMode
|
|
304
304
|
: 'auto'
|
|
305
305
|
|
|
306
|
-
const swarm = spawnSwarm({ tasks, executionMode }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
|
|
306
|
+
const swarm = await spawnSwarm({ tasks, executionMode }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
|
|
307
307
|
if (!waitForCompletion) {
|
|
308
308
|
const snapshot = getSwarmSnapshot(swarm.swarmId)
|
|
309
309
|
return JSON.stringify({
|
|
@@ -361,7 +361,7 @@ async function handleStart(args: Record<string, unknown>, ctx: ActionContext): P
|
|
|
361
361
|
const shareBrowserProfile = args.shareBrowserProfile === true || args.share_browser_profile === true
|
|
362
362
|
const waitForCompletion = args.waitForCompletion !== false && args.background !== true
|
|
363
363
|
|
|
364
|
-
const handle = spawnSubagent(
|
|
364
|
+
const handle = await spawnSubagent(
|
|
365
365
|
{ agentId, message, cwd, shareBrowserProfile, waitForCompletion },
|
|
366
366
|
{ sessionId: ctx.sessionId, cwd: ctx.cwd },
|
|
367
367
|
)
|
|
@@ -91,6 +91,27 @@ describe('ToolLoopTracker', () => {
|
|
|
91
91
|
assert.equal(warn.severity, 'warning')
|
|
92
92
|
assert.equal(warn.detector, 'tool_frequency')
|
|
93
93
|
})
|
|
94
|
+
|
|
95
|
+
it('previews critical repeats before another identical tool call executes', () => {
|
|
96
|
+
const tracker = new ToolLoopTracker({ repeatWarn: 2, repeatCritical: 3, toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
|
|
97
|
+
tracker.record('web_search', { query: 'same' }, 'result 1')
|
|
98
|
+
tracker.record('web_search', { query: 'same' }, 'result 2')
|
|
99
|
+
|
|
100
|
+
const preview = tracker.preview('web_search', { query: 'same' })
|
|
101
|
+
assert.ok(preview)
|
|
102
|
+
assert.equal(preview?.severity, 'critical')
|
|
103
|
+
assert.equal(preview?.detector, 'generic_repeat')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('previews tool overuse by frequency before the next call executes', () => {
|
|
107
|
+
const tracker = new ToolLoopTracker({ toolFrequencyWarn: 2, toolFrequencyCritical: 4 })
|
|
108
|
+
tracker.record('browser', { action: 'open', url: 'https://a.example' }, 'ok')
|
|
109
|
+
|
|
110
|
+
const preview = tracker.preview('browser', { action: 'open', url: 'https://b.example' })
|
|
111
|
+
assert.ok(preview)
|
|
112
|
+
assert.equal(preview?.severity, 'warning')
|
|
113
|
+
assert.equal(preview?.detector, 'tool_frequency')
|
|
114
|
+
})
|
|
94
115
|
})
|
|
95
116
|
|
|
96
117
|
describe('hash helpers', () => {
|