@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.
Files changed (32) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/src/app/api/settings/route.ts +1 -0
  4. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  5. package/src/app/api/wallets/route.ts +5 -2
  6. package/src/app/settings/page.tsx +9 -0
  7. package/src/components/wallets/wallet-panel.tsx +12 -1
  8. package/src/components/wallets/wallet-section.tsx +4 -1
  9. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  10. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  11. package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
  12. package/src/lib/server/agents/subagent-runtime.ts +115 -19
  13. package/src/lib/server/agents/subagent-swarm.ts +3 -3
  14. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
  15. package/src/lib/server/chat-execution/chat-execution.ts +357 -152
  16. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
  17. package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
  18. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  19. package/src/lib/server/connectors/contact-boundaries.ts +70 -8
  20. package/src/lib/server/connectors/manager.test.ts +129 -7
  21. package/src/lib/server/plugins.test.ts +263 -0
  22. package/src/lib/server/plugins.ts +406 -10
  23. package/src/lib/server/session-tools/context.ts +15 -1
  24. package/src/lib/server/session-tools/index.ts +42 -6
  25. package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
  26. package/src/lib/server/session-tools/subagent.ts +3 -3
  27. package/src/lib/server/tool-loop-detection.test.ts +21 -0
  28. package/src/lib/server/tool-loop-detection.ts +79 -0
  29. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  30. package/src/lib/server/wallet/wallet-service.ts +13 -0
  31. package/src/types/index.ts +134 -1
  32. 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 { Plugin, PluginHooks, PluginMeta, PluginToolDef, PluginUIExtension, PluginProviderExtension, PluginConnectorExtension, Session, PluginPackageManager, PluginDependencyInstallStatus } from '@/types'
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 runBeforeToolExec(
1052
- params: { toolName: string; input: Record<string, unknown> | null },
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<Record<string, unknown> | null> {
1157
+ ): Promise<PluginPromptBuildResult | null> {
1055
1158
  this.load()
1056
1159
  const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
1057
- let currentInput = params.input
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.beforeToolExec
1202
+ const hook = p.hooks.beforeModelResolve
1062
1203
  if (!hook) continue
1063
1204
  try {
1064
- const result = await hook({ toolName: params.toolName, input: currentInput })
1065
- if (result && typeof result === 'object' && !Array.isArray(result)) {
1066
- currentInput = result as Record<string, unknown>
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: 'unknown',
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
- const nextArgs = await pluginManager.runBeforeToolExec(
362
- { toolName: candidate.name, input: normalizedArgs },
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
- const effectiveArgs = nextArgs ?? normalizedArgs
366
- const result = await candidate.invoke(effectiveArgs)
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', () => {