@swarmclawai/swarmclaw 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/next.config.ts +13 -2
- package/package.json +4 -2
- package/src/app/api/agents/[id]/thread/route.ts +9 -0
- package/src/app/api/agents/route.ts +4 -0
- package/src/app/api/agents/thread-route.test.ts +133 -0
- package/src/app/api/approvals/route.test.ts +148 -0
- package/src/app/api/canvas/[sessionId]/route.ts +3 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
- package/src/app/api/chats/[id]/devserver/route.ts +48 -7
- package/src/app/api/chats/[id]/messages/route.ts +42 -18
- package/src/app/api/chats/[id]/route.ts +1 -1
- package/src/app/api/chats/[id]/stop/route.ts +5 -4
- package/src/app/api/chats/route.ts +23 -2
- package/src/app/api/clawhub/install/route.ts +28 -8
- package/src/app/api/connectors/[id]/route.ts +46 -3
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/external-agents/route.test.ts +165 -0
- package/src/app/api/gateways/[id]/health/route.ts +27 -12
- package/src/app/api/gateways/[id]/route.ts +2 -0
- package/src/app/api/gateways/health-route.test.ts +135 -0
- package/src/app/api/gateways/route.ts +2 -0
- package/src/app/api/mcp-servers/route.test.ts +130 -0
- package/src/app/api/openclaw/deploy/route.ts +38 -5
- package/src/app/api/plugins/install/route.ts +46 -6
- package/src/app/api/plugins/marketplace/route.ts +48 -15
- package/src/app/api/preview-server/route.ts +26 -11
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- package/src/app/api/schedules/[id]/run/route.ts +4 -0
- package/src/app/api/schedules/route.test.ts +86 -0
- package/src/app/api/schedules/route.ts +6 -1
- package/src/app/api/secrets/[id]/route.ts +1 -0
- package/src/app/api/secrets/route.ts +2 -1
- package/src/app/api/settings/route.ts +2 -0
- package/src/app/api/setup/check-provider/route.test.ts +19 -0
- package/src/app/api/setup/check-provider/route.ts +40 -10
- package/src/app/api/skills/[id]/route.ts +12 -0
- package/src/app/api/skills/import/route.ts +14 -12
- package/src/app/api/skills/route.ts +13 -1
- package/src/app/api/tasks/[id]/route.ts +10 -1
- package/src/app/api/tasks/import/github/route.test.ts +65 -0
- package/src/app/api/tasks/import/github/route.ts +337 -0
- package/src/app/api/wallets/[id]/approve/route.ts +17 -3
- package/src/app/api/wallets/[id]/route.ts +79 -33
- package/src/app/api/wallets/[id]/send/route.ts +19 -33
- package/src/app/api/wallets/route.ts +78 -61
- package/src/app/api/webhooks/[id]/route.ts +33 -6
- package/src/app/api/webhooks/route.test.ts +272 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-card.tsx +9 -2
- package/src/components/agents/agent-chat-list.tsx +18 -2
- package/src/components/agents/agent-list.tsx +1 -0
- package/src/components/agents/agent-sheet.tsx +257 -38
- package/src/components/agents/inspector-panel.tsx +41 -0
- package/src/components/canvas/canvas-panel.tsx +236 -65
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-card.tsx +36 -13
- package/src/components/chat/chat-header.tsx +48 -16
- package/src/components/chat/chat-list.tsx +28 -4
- package/src/components/chat/checkpoint-timeline.tsx +50 -34
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/chat/message-bubble.tsx +208 -145
- package/src/components/chat/message-list.tsx +48 -19
- package/src/components/chatrooms/chatroom-message.tsx +2 -2
- package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
- package/src/components/connectors/connector-health.tsx +1 -1
- package/src/components/connectors/connector-list.tsx +7 -2
- package/src/components/connectors/connector-sheet.tsx +337 -148
- package/src/components/gateways/gateway-sheet.tsx +2 -2
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
- package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
- package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
- package/src/components/plugins/plugin-list.tsx +45 -9
- package/src/components/plugins/plugin-sheet.tsx +55 -7
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- package/src/components/providers/provider-list.tsx +2 -1
- package/src/components/providers/provider-sheet.tsx +21 -2
- package/src/components/schedules/schedule-card.tsx +25 -1
- package/src/components/schedules/schedule-sheet.tsx +44 -2
- package/src/components/secrets/secret-sheet.tsx +21 -2
- package/src/components/shared/agent-switch-dialog.tsx +12 -1
- package/src/components/shared/bottom-sheet.tsx +13 -3
- package/src/components/shared/command-palette.tsx +8 -1
- package/src/components/shared/confirm-dialog.tsx +19 -4
- package/src/components/shared/connector-platform-icon.test.ts +28 -0
- package/src/components/shared/connector-platform-icon.tsx +39 -6
- package/src/components/shared/settings/plugin-manager.tsx +29 -6
- package/src/components/shared/settings/section-capability-policy.tsx +45 -3
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/skills/skill-list.tsx +25 -0
- package/src/components/skills/skill-sheet.tsx +84 -12
- package/src/components/tasks/approvals-panel.tsx +289 -34
- package/src/components/tasks/task-board.tsx +410 -25
- package/src/components/tasks/task-card.tsx +66 -8
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
- package/src/components/wallets/wallet-panel.tsx +435 -90
- package/src/components/wallets/wallet-section.tsx +198 -48
- package/src/components/webhooks/webhook-sheet.tsx +22 -2
- package/src/lib/approval-display.ts +20 -0
- package/src/lib/canvas-content.ts +198 -0
- package/src/lib/chat-artifact-summary.ts +165 -0
- package/src/lib/chat-display.test.ts +91 -0
- package/src/lib/chat-display.ts +58 -0
- package/src/lib/chat-streaming-state.test.ts +47 -1
- package/src/lib/chat-streaming-state.ts +42 -0
- package/src/lib/ollama-model.ts +10 -0
- package/src/lib/openclaw-endpoint.test.ts +8 -0
- package/src/lib/openclaw-endpoint.ts +6 -1
- package/src/lib/plugin-install-cors.ts +46 -0
- package/src/lib/plugin-sources.test.ts +43 -0
- package/src/lib/plugin-sources.ts +77 -0
- package/src/lib/providers/ollama.ts +16 -6
- package/src/lib/providers/openclaw.test.ts +54 -0
- package/src/lib/providers/openclaw.ts +127 -11
- package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
- package/src/lib/schedule-dedupe.test.ts +66 -1
- package/src/lib/schedule-dedupe.ts +169 -12
- package/src/lib/schedule-origin.test.ts +20 -0
- package/src/lib/schedule-origin.ts +15 -0
- package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
- package/src/lib/server/agent-availability.ts +16 -0
- package/src/lib/server/agent-runtime-config.ts +12 -4
- package/src/lib/server/agent-thread-session.test.ts +51 -0
- package/src/lib/server/agent-thread-session.ts +7 -0
- package/src/lib/server/approval-match.ts +205 -0
- package/src/lib/server/approvals-auto-approve.test.ts +538 -1
- package/src/lib/server/approvals.ts +214 -1
- package/src/lib/server/assistant-control.test.ts +29 -0
- package/src/lib/server/assistant-control.ts +23 -0
- package/src/lib/server/build-llm.test.ts +79 -0
- package/src/lib/server/build-llm.ts +14 -4
- package/src/lib/server/canvas-content.test.ts +32 -0
- package/src/lib/server/canvas-content.ts +6 -0
- package/src/lib/server/capability-router.test.ts +33 -0
- package/src/lib/server/capability-router.ts +80 -19
- package/src/lib/server/chat-execution-advanced.test.ts +651 -0
- package/src/lib/server/chat-execution-disabled.test.ts +94 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
- package/src/lib/server/chat-execution.ts +378 -73
- package/src/lib/server/clawhub-client.test.ts +14 -8
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.test.ts +1147 -0
- package/src/lib/server/connectors/manager.ts +461 -137
- package/src/lib/server/connectors/pairing.ts +26 -5
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.test.ts +134 -0
- package/src/lib/server/connectors/whatsapp.ts +271 -47
- package/src/lib/server/context-manager.ts +6 -1
- package/src/lib/server/daemon-state.ts +84 -47
- package/src/lib/server/data-dir.test.ts +37 -0
- package/src/lib/server/data-dir.ts +20 -1
- package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
- package/src/lib/server/devserver-launch.test.ts +60 -0
- package/src/lib/server/devserver-launch.ts +85 -0
- package/src/lib/server/elevenlabs.test.ts +247 -1
- package/src/lib/server/elevenlabs.ts +147 -43
- package/src/lib/server/ethereum.ts +590 -0
- package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
- package/src/lib/server/eval/agent-regression.test.ts +18 -1
- package/src/lib/server/eval/agent-regression.ts +383 -11
- package/src/lib/server/evm-swap.ts +475 -0
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
- package/src/lib/server/heartbeat-service.ts +20 -11
- package/src/lib/server/heartbeat-wake.test.ts +112 -0
- package/src/lib/server/heartbeat-wake.ts +338 -57
- package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
- package/src/lib/server/main-agent-loop.test.ts +260 -0
- package/src/lib/server/main-agent-loop.ts +559 -14
- package/src/lib/server/mcp-client.test.ts +16 -0
- package/src/lib/server/mcp-client.ts +25 -0
- package/src/lib/server/memory-integration.test.ts +719 -0
- package/src/lib/server/memory-policy.test.ts +43 -0
- package/src/lib/server/memory-policy.ts +132 -0
- package/src/lib/server/memory-tiers.test.ts +60 -0
- package/src/lib/server/memory-tiers.ts +16 -0
- package/src/lib/server/ollama-runtime.ts +58 -0
- package/src/lib/server/openclaw-deploy.test.ts +109 -1
- package/src/lib/server/openclaw-deploy.ts +557 -81
- package/src/lib/server/openclaw-gateway.test.ts +131 -0
- package/src/lib/server/openclaw-gateway.ts +10 -4
- package/src/lib/server/openclaw-health.test.ts +35 -0
- package/src/lib/server/openclaw-health.ts +215 -47
- package/src/lib/server/orchestrator-lg.ts +3 -2
- package/src/lib/server/orchestrator.ts +2 -0
- package/src/lib/server/plugins-advanced.test.ts +351 -0
- package/src/lib/server/plugins.ts +211 -6
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-advanced.test.ts +528 -0
- package/src/lib/server/queue-followups.test.ts +409 -2
- package/src/lib/server/queue-reconcile.test.ts +128 -0
- package/src/lib/server/queue.ts +527 -68
- package/src/lib/server/scheduler.ts +29 -1
- package/src/lib/server/session-note.test.ts +36 -0
- package/src/lib/server/session-note.ts +42 -0
- package/src/lib/server/session-run-manager.ts +83 -4
- package/src/lib/server/session-tools/canvas.ts +14 -12
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.test.ts +138 -0
- package/src/lib/server/session-tools/connector.ts +366 -54
- package/src/lib/server/session-tools/context.ts +17 -3
- package/src/lib/server/session-tools/crud.ts +484 -84
- package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +102 -10
- package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
- package/src/lib/server/session-tools/discovery.ts +80 -12
- package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
- package/src/lib/server/session-tools/file.ts +43 -4
- package/src/lib/server/session-tools/human-loop.ts +35 -5
- package/src/lib/server/session-tools/index.ts +44 -9
- package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
- package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
- package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.test.ts +93 -0
- package/src/lib/server/session-tools/memory.ts +554 -75
- package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
- package/src/lib/server/session-tools/platform-access.test.ts +58 -0
- package/src/lib/server/session-tools/platform.ts +60 -19
- package/src/lib/server/session-tools/plugin-creator.ts +57 -1
- package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
- package/src/lib/server/session-tools/schedule.ts +6 -1
- package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
- package/src/lib/server/session-tools/shell.ts +22 -3
- package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
- package/src/lib/server/session-tools/wallet.ts +1374 -139
- package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
- package/src/lib/server/session-tools/web.ts +621 -70
- package/src/lib/server/skill-discovery.ts +128 -0
- package/src/lib/server/skill-eligibility.test.ts +84 -0
- package/src/lib/server/skill-eligibility.ts +95 -0
- package/src/lib/server/skill-prompt-budget.test.ts +102 -0
- package/src/lib/server/skill-prompt-budget.ts +125 -0
- package/src/lib/server/skills-normalize.test.ts +54 -0
- package/src/lib/server/skills-normalize.ts +372 -26
- package/src/lib/server/solana.ts +214 -29
- package/src/lib/server/storage.ts +65 -36
- package/src/lib/server/stream-agent-chat.test.ts +437 -2
- package/src/lib/server/stream-agent-chat.ts +957 -79
- package/src/lib/server/system-events.ts +1 -1
- package/src/lib/server/tool-aliases.ts +2 -0
- package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
- package/src/lib/server/tool-capability-policy.test.ts +24 -0
- package/src/lib/server/tool-capability-policy.ts +29 -1
- package/src/lib/server/tool-loop-detection.test.ts +105 -0
- package/src/lib/server/tool-loop-detection.ts +260 -0
- package/src/lib/server/tool-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +271 -0
- package/src/lib/server/wallet-execution.test.ts +198 -0
- package/src/lib/server/wallet-portfolio.test.ts +98 -0
- package/src/lib/server/wallet-portfolio.ts +724 -0
- package/src/lib/server/wallet-service.test.ts +57 -0
- package/src/lib/server/wallet-service.ts +213 -0
- package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
- package/src/lib/server/watch-jobs.ts +17 -2
- package/src/lib/server/workspace-context.ts +111 -0
- package/src/lib/skill-save-payload.test.ts +39 -0
- package/src/lib/skill-save-payload.ts +37 -0
- package/src/lib/tasks.ts +28 -0
- package/src/lib/tool-definitions.ts +2 -1
- package/src/lib/tool-event-summary.test.ts +30 -0
- package/src/lib/tool-event-summary.ts +37 -0
- package/src/lib/validation/schemas.ts +1 -0
- package/src/lib/wallet-transactions.test.ts +75 -0
- package/src/lib/wallet-transactions.ts +43 -0
- package/src/lib/wallet.test.ts +17 -0
- package/src/lib/wallet.ts +183 -0
- package/src/proxy.test.ts +31 -0
- package/src/proxy.ts +34 -2
- package/src/stores/use-chat-store.ts +15 -1
- package/src/types/index.ts +249 -14
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
import { ToolLoopTracker, hashToolInput, hashToolOutput } from './tool-loop-detection'
|
|
4
|
+
|
|
5
|
+
describe('ToolLoopTracker', () => {
|
|
6
|
+
it('returns null for normal non-repeating tool calls', () => {
|
|
7
|
+
const tracker = new ToolLoopTracker()
|
|
8
|
+
assert.equal(tracker.record('web_search', { query: 'weather london' }, 'Sunny, 20C'), null)
|
|
9
|
+
assert.equal(tracker.record('files', { action: 'write', path: '/tmp/test.json' }, 'OK'), null)
|
|
10
|
+
assert.equal(tracker.record('web_search', { query: 'weather paris' }, 'Cloudy, 15C'), null)
|
|
11
|
+
assert.equal(tracker.size, 3)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('detects generic repeat at warning threshold', () => {
|
|
15
|
+
const tracker = new ToolLoopTracker({ repeatWarn: 3, repeatCritical: 6 })
|
|
16
|
+
for (let i = 0; i < 2; i++) {
|
|
17
|
+
assert.equal(tracker.record('web_search', { query: 'same query' }, `result ${i}`), null)
|
|
18
|
+
}
|
|
19
|
+
const result = tracker.record('web_search', { query: 'same query' }, 'result 2')
|
|
20
|
+
assert.ok(result)
|
|
21
|
+
assert.equal(result.severity, 'warning')
|
|
22
|
+
assert.equal(result.detector, 'generic_repeat')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('detects generic repeat at critical threshold', () => {
|
|
26
|
+
const tracker = new ToolLoopTracker({ repeatWarn: 3, repeatCritical: 5, toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
|
|
27
|
+
for (let i = 0; i < 4; i++) {
|
|
28
|
+
tracker.record('web_search', { query: 'same' }, `result ${i}`)
|
|
29
|
+
}
|
|
30
|
+
const result = tracker.record('web_search', { query: 'same' }, 'result 4')
|
|
31
|
+
assert.ok(result)
|
|
32
|
+
assert.equal(result.severity, 'critical')
|
|
33
|
+
assert.equal(result.detector, 'generic_repeat')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('detects polling stall when same tool returns identical output', () => {
|
|
37
|
+
const tracker = new ToolLoopTracker({ pollWarn: 3, pollCritical: 5 })
|
|
38
|
+
// Different inputs but same output = polling stall
|
|
39
|
+
for (let i = 0; i < 2; i++) {
|
|
40
|
+
assert.equal(tracker.record('process', { action: 'poll', id: `run-${i}` }, 'status: running'), null)
|
|
41
|
+
}
|
|
42
|
+
const result = tracker.record('process', { action: 'poll', id: 'run-2' }, 'status: running')
|
|
43
|
+
assert.ok(result)
|
|
44
|
+
assert.equal(result.severity, 'warning')
|
|
45
|
+
assert.equal(result.detector, 'polling_stall')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('detects ping-pong between two tools', () => {
|
|
49
|
+
const tracker = new ToolLoopTracker({ pingPongWarn: 2, pingPongCritical: 4 })
|
|
50
|
+
// Simulate A-B-A-B with identical outputs
|
|
51
|
+
for (let i = 0; i < 2; i++) {
|
|
52
|
+
tracker.record('web_search', { query: 'find it' }, 'no results found')
|
|
53
|
+
tracker.record('web_fetch', { url: 'https://example.com' }, '404 not found')
|
|
54
|
+
}
|
|
55
|
+
// One more A to complete the 3rd pair-start
|
|
56
|
+
const result = tracker.record('web_search', { query: 'find it' }, 'no results found')
|
|
57
|
+
// The ping-pong detector checks the last pair against previous pairs
|
|
58
|
+
// After 4 calls (A-B-A-B) + 1 more A, we have 2 full A-B cycles with identical results
|
|
59
|
+
if (result) {
|
|
60
|
+
assert.equal(result.detector, 'ping_pong')
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('circuit breaker fires at absolute cap', () => {
|
|
65
|
+
const tracker = new ToolLoopTracker({ circuitBreaker: 5, repeatWarn: 100, repeatCritical: 100, toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
|
|
66
|
+
for (let i = 0; i < 4; i++) {
|
|
67
|
+
tracker.record('shell', { command: 'curl http://stuck.com' }, `err ${i}`)
|
|
68
|
+
}
|
|
69
|
+
const result = tracker.record('shell', { command: 'curl http://stuck.com' }, 'err 4')
|
|
70
|
+
assert.ok(result)
|
|
71
|
+
assert.equal(result.severity, 'critical')
|
|
72
|
+
assert.equal(result.detector, 'circuit_breaker')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not fire for varied tool calls even with many total calls', () => {
|
|
76
|
+
const tracker = new ToolLoopTracker({ toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
|
|
77
|
+
for (let i = 0; i < 20; i++) {
|
|
78
|
+
const result = tracker.record('web_search', { query: `query ${i}` }, `result ${i}`)
|
|
79
|
+
assert.equal(result, null, `Unexpected detection at call ${i}`)
|
|
80
|
+
}
|
|
81
|
+
assert.equal(tracker.size, 20)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('detects tool frequency when same tool is called too many times (any input)', () => {
|
|
85
|
+
const tracker = new ToolLoopTracker({ toolFrequencyWarn: 3, toolFrequencyCritical: 5 })
|
|
86
|
+
for (let i = 0; i < 2; i++) {
|
|
87
|
+
assert.equal(tracker.record('web_search', { query: `q${i}` }, `r${i}`), null)
|
|
88
|
+
}
|
|
89
|
+
const warn = tracker.record('web_search', { query: 'q2' }, 'r2')
|
|
90
|
+
assert.ok(warn)
|
|
91
|
+
assert.equal(warn.severity, 'warning')
|
|
92
|
+
assert.equal(warn.detector, 'tool_frequency')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('hash helpers', () => {
|
|
97
|
+
it('produces consistent hashes for same input', () => {
|
|
98
|
+
assert.equal(hashToolInput({ query: 'test' }), hashToolInput({ query: 'test' }))
|
|
99
|
+
assert.equal(hashToolOutput('hello world'), hashToolOutput('hello world'))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('produces different hashes for different input', () => {
|
|
103
|
+
assert.notEqual(hashToolInput({ query: 'a' }), hashToolInput({ query: 'b' }))
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool loop detection — modelled after OpenClaw's approach.
|
|
3
|
+
*
|
|
4
|
+
* Four detectors run on every on_tool_end event:
|
|
5
|
+
* 1. Generic repeat — same (name, inputHash) seen N+ times
|
|
6
|
+
* 2. Polling stall — repeated poll-like calls with identical output
|
|
7
|
+
* 3. Ping-pong — two tools alternating with identical results
|
|
8
|
+
* 4. Circuit breaker — absolute cap on identical calls regardless of type
|
|
9
|
+
*
|
|
10
|
+
* Each detector returns a severity: 'ok' | 'warning' | 'critical'.
|
|
11
|
+
* The caller decides what to do (log, inject guidance, abort).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'crypto'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface ToolCallRecord {
|
|
21
|
+
name: string
|
|
22
|
+
inputHash: string
|
|
23
|
+
outputHash: string
|
|
24
|
+
/** first 200 chars of output for logging */
|
|
25
|
+
outputPreview: string
|
|
26
|
+
timestamp: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type LoopSeverity = 'ok' | 'warning' | 'critical'
|
|
30
|
+
|
|
31
|
+
export interface LoopDetectionResult {
|
|
32
|
+
severity: LoopSeverity
|
|
33
|
+
detector: 'generic_repeat' | 'polling_stall' | 'ping_pong' | 'circuit_breaker' | 'tool_frequency'
|
|
34
|
+
message: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LoopDetectionThresholds {
|
|
38
|
+
/** Generic repeat: warn after this many identical (name, input) calls. Default 6. */
|
|
39
|
+
repeatWarn: number
|
|
40
|
+
/** Generic repeat: critical after this many. Default 12. */
|
|
41
|
+
repeatCritical: number
|
|
42
|
+
/** Polling stall: warn after N poll-like calls with identical output. Default 4. */
|
|
43
|
+
pollWarn: number
|
|
44
|
+
/** Polling stall: critical after this many. Default 8. */
|
|
45
|
+
pollCritical: number
|
|
46
|
+
/** Ping-pong: how many alternating-pair cycles trigger warning. Default 3. */
|
|
47
|
+
pingPongWarn: number
|
|
48
|
+
/** Ping-pong: critical after this many cycles. Default 5. */
|
|
49
|
+
pingPongCritical: number
|
|
50
|
+
/** Circuit breaker: absolute cap on any identical call. Default 20. */
|
|
51
|
+
circuitBreaker: number
|
|
52
|
+
/** Per-tool frequency: warn after this many calls to the same tool (any input). Default 5. */
|
|
53
|
+
toolFrequencyWarn: number
|
|
54
|
+
/** Per-tool frequency: critical after this many calls to the same tool (any input). Default 8. */
|
|
55
|
+
toolFrequencyCritical: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_THRESHOLDS: LoopDetectionThresholds = {
|
|
59
|
+
repeatWarn: 6,
|
|
60
|
+
repeatCritical: 12,
|
|
61
|
+
pollWarn: 4,
|
|
62
|
+
pollCritical: 8,
|
|
63
|
+
pingPongWarn: 3,
|
|
64
|
+
pingPongCritical: 5,
|
|
65
|
+
circuitBreaker: 20,
|
|
66
|
+
toolFrequencyWarn: 3,
|
|
67
|
+
toolFrequencyCritical: 5,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Hash helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function quickHash(input: string): string {
|
|
75
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function hashToolInput(input: unknown): string {
|
|
79
|
+
const str = typeof input === 'string' ? input : JSON.stringify(input ?? '')
|
|
80
|
+
return quickHash(str)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function hashToolOutput(output: unknown): string {
|
|
84
|
+
const str = typeof output === 'string' ? output : JSON.stringify(output ?? '')
|
|
85
|
+
return quickHash(str)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Tracker
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export class ToolLoopTracker {
|
|
93
|
+
private history: ToolCallRecord[] = []
|
|
94
|
+
private thresholds: LoopDetectionThresholds
|
|
95
|
+
|
|
96
|
+
constructor(thresholds?: Partial<LoopDetectionThresholds>) {
|
|
97
|
+
this.thresholds = { ...DEFAULT_THRESHOLDS, ...thresholds }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Record a completed tool call and run all detectors. */
|
|
101
|
+
record(name: string, input: unknown, output: unknown): LoopDetectionResult | null {
|
|
102
|
+
const inputHash = hashToolInput(input)
|
|
103
|
+
const outputStr = typeof output === 'string' ? output : JSON.stringify(output ?? '')
|
|
104
|
+
const outputHash = hashToolOutput(output)
|
|
105
|
+
const record: ToolCallRecord = {
|
|
106
|
+
name,
|
|
107
|
+
inputHash,
|
|
108
|
+
outputHash,
|
|
109
|
+
outputPreview: outputStr.slice(0, 200),
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
}
|
|
112
|
+
this.history.push(record)
|
|
113
|
+
|
|
114
|
+
// Run detectors in severity order (most severe first)
|
|
115
|
+
return this.checkCircuitBreaker(record)
|
|
116
|
+
?? this.checkToolFrequency(record)
|
|
117
|
+
?? this.checkGenericRepeat(record)
|
|
118
|
+
?? this.checkPollingStall(record)
|
|
119
|
+
?? this.checkPingPong()
|
|
120
|
+
?? null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get the full call history (for diagnostics). */
|
|
124
|
+
getHistory(): ReadonlyArray<ToolCallRecord> {
|
|
125
|
+
return this.history
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Total recorded calls. */
|
|
129
|
+
get size(): number {
|
|
130
|
+
return this.history.length
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
// Detectors
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
private checkToolFrequency(current: ToolCallRecord): LoopDetectionResult | null {
|
|
138
|
+
let count = 0
|
|
139
|
+
for (const r of this.history) {
|
|
140
|
+
if (r.name === current.name) count++
|
|
141
|
+
}
|
|
142
|
+
if (count >= this.thresholds.toolFrequencyCritical) {
|
|
143
|
+
return {
|
|
144
|
+
severity: 'critical',
|
|
145
|
+
detector: 'tool_frequency',
|
|
146
|
+
message: `Tool "${current.name}" called ${count} times this turn. Excessive repetition — wrap up with available results.`,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (count >= this.thresholds.toolFrequencyWarn) {
|
|
150
|
+
return {
|
|
151
|
+
severity: 'warning',
|
|
152
|
+
detector: 'tool_frequency',
|
|
153
|
+
message: `Tool "${current.name}" called ${count} times. Consider whether more calls are needed.`,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private checkCircuitBreaker(current: ToolCallRecord): LoopDetectionResult | null {
|
|
160
|
+
const key = `${current.name}:${current.inputHash}`
|
|
161
|
+
let count = 0
|
|
162
|
+
for (const r of this.history) {
|
|
163
|
+
if (`${r.name}:${r.inputHash}` === key) count++
|
|
164
|
+
}
|
|
165
|
+
if (count >= this.thresholds.circuitBreaker) {
|
|
166
|
+
return {
|
|
167
|
+
severity: 'critical',
|
|
168
|
+
detector: 'circuit_breaker',
|
|
169
|
+
message: `Circuit breaker: "${current.name}" called ${count} times with identical input. Halting to prevent runaway.`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private checkGenericRepeat(current: ToolCallRecord): LoopDetectionResult | null {
|
|
176
|
+
const key = `${current.name}:${current.inputHash}`
|
|
177
|
+
let count = 0
|
|
178
|
+
for (const r of this.history) {
|
|
179
|
+
if (`${r.name}:${r.inputHash}` === key) count++
|
|
180
|
+
}
|
|
181
|
+
if (count >= this.thresholds.repeatCritical) {
|
|
182
|
+
return {
|
|
183
|
+
severity: 'critical',
|
|
184
|
+
detector: 'generic_repeat',
|
|
185
|
+
message: `Tool "${current.name}" has been called ${count} times with the same input. This appears to be a stuck loop.`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (count >= this.thresholds.repeatWarn) {
|
|
189
|
+
return {
|
|
190
|
+
severity: 'warning',
|
|
191
|
+
detector: 'generic_repeat',
|
|
192
|
+
message: `Tool "${current.name}" has been called ${count} times with the same input. Consider a different approach.`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private checkPollingStall(current: ToolCallRecord): LoopDetectionResult | null {
|
|
199
|
+
// Look for recent sequential calls to the same tool with identical output
|
|
200
|
+
const recent = this.history.slice(-this.thresholds.pollCritical)
|
|
201
|
+
const pollRuns = recent.filter(
|
|
202
|
+
(r) => r.name === current.name && r.outputHash === current.outputHash,
|
|
203
|
+
)
|
|
204
|
+
if (pollRuns.length >= this.thresholds.pollCritical) {
|
|
205
|
+
return {
|
|
206
|
+
severity: 'critical',
|
|
207
|
+
detector: 'polling_stall',
|
|
208
|
+
message: `Polling stall: "${current.name}" returned identical output ${pollRuns.length} times consecutively. The polled resource is not changing.`,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (pollRuns.length >= this.thresholds.pollWarn) {
|
|
212
|
+
return {
|
|
213
|
+
severity: 'warning',
|
|
214
|
+
detector: 'polling_stall',
|
|
215
|
+
message: `Polling stall: "${current.name}" returned identical output ${pollRuns.length} times. The state may not be progressing.`,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private checkPingPong(): LoopDetectionResult | null {
|
|
222
|
+
const len = this.history.length
|
|
223
|
+
if (len < 4) return null
|
|
224
|
+
|
|
225
|
+
// Check if the last N calls form an A-B-A-B pattern with identical results
|
|
226
|
+
const last = this.history[len - 1]
|
|
227
|
+
const prev = this.history[len - 2]
|
|
228
|
+
if (last.name === prev.name) return null // same tool — not ping-pong
|
|
229
|
+
|
|
230
|
+
let cycles = 0
|
|
231
|
+
for (let i = len - 2; i >= 1; i -= 2) {
|
|
232
|
+
const a = this.history[i]
|
|
233
|
+
const b = this.history[i - 1]
|
|
234
|
+
if (
|
|
235
|
+
a.name === last.name && a.outputHash === last.outputHash
|
|
236
|
+
&& b.name === prev.name && b.outputHash === prev.outputHash
|
|
237
|
+
) {
|
|
238
|
+
cycles++
|
|
239
|
+
} else {
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (cycles >= this.thresholds.pingPongCritical) {
|
|
245
|
+
return {
|
|
246
|
+
severity: 'critical',
|
|
247
|
+
detector: 'ping_pong',
|
|
248
|
+
message: `Ping-pong: "${prev.name}" and "${last.name}" are alternating with identical results (${cycles} cycles). Breaking the loop.`,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (cycles >= this.thresholds.pingPongWarn) {
|
|
252
|
+
return {
|
|
253
|
+
severity: 'warning',
|
|
254
|
+
detector: 'ping_pong',
|
|
255
|
+
message: `Ping-pong: "${prev.name}" and "${last.name}" may be stuck in an alternating loop (${cycles} cycles).`,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
import { getPluginManager } from './plugins'
|
|
4
|
+
import { getEnabledToolPlanningView, getToolsForCapability, TOOL_CAPABILITY } from './tool-planning'
|
|
5
|
+
|
|
6
|
+
let seq = 0
|
|
7
|
+
|
|
8
|
+
function uniquePluginId(prefix: string): string {
|
|
9
|
+
seq += 1
|
|
10
|
+
return `${prefix}_${Date.now()}_${seq}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('tool-planning', () => {
|
|
14
|
+
it('collects core planning metadata for aliased built-in tools', () => {
|
|
15
|
+
const view = getEnabledToolPlanningView(['web_search', 'web_fetch', 'browser', 'manage_connectors'])
|
|
16
|
+
|
|
17
|
+
assert.deepEqual(view.displayToolIds, ['browser', 'manage_connectors', 'web'])
|
|
18
|
+
assert.deepEqual(getToolsForCapability(['web_search'], TOOL_CAPABILITY.researchSearch), ['web_search'])
|
|
19
|
+
assert.deepEqual(getToolsForCapability(['manage_connectors'], TOOL_CAPABILITY.deliveryVoiceNote), ['connector_message_tool'])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('collects planning metadata from custom plugin tools', () => {
|
|
23
|
+
const pluginId = uniquePluginId('planner_plugin')
|
|
24
|
+
getPluginManager().registerBuiltin(pluginId, {
|
|
25
|
+
name: 'Planner Plugin',
|
|
26
|
+
tools: [
|
|
27
|
+
{
|
|
28
|
+
name: 'custom_media_sender',
|
|
29
|
+
description: 'Send rendered media somewhere special.',
|
|
30
|
+
planning: {
|
|
31
|
+
capabilities: ['delivery.media', 'delivery.voice_note'],
|
|
32
|
+
disciplineGuidance: ['Use `custom_media_sender` for bespoke outbound media delivery.'],
|
|
33
|
+
},
|
|
34
|
+
parameters: { type: 'object', properties: {} },
|
|
35
|
+
execute: async () => 'ok',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const view = getEnabledToolPlanningView([pluginId])
|
|
41
|
+
assert.deepEqual(getToolsForCapability([pluginId], TOOL_CAPABILITY.deliveryMedia), ['custom_media_sender'])
|
|
42
|
+
assert.equal(view.disciplineGuidance.includes('Use `custom_media_sender` for bespoke outbound media delivery.'), true)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { PluginToolPlanning } from '@/types'
|
|
2
|
+
import { getPluginManager } from './plugins'
|
|
3
|
+
import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
|
|
4
|
+
|
|
5
|
+
export const TOOL_CAPABILITY = {
|
|
6
|
+
researchSearch: 'research.search',
|
|
7
|
+
researchFetch: 'research.fetch',
|
|
8
|
+
browserNavigate: 'browser.navigate',
|
|
9
|
+
browserCapture: 'browser.capture',
|
|
10
|
+
artifactPdf: 'artifact.pdf',
|
|
11
|
+
deliveryMessage: 'delivery.message',
|
|
12
|
+
deliveryMedia: 'delivery.media',
|
|
13
|
+
deliveryVoiceNote: 'delivery.voice_note',
|
|
14
|
+
walletInspect: 'wallet.inspect',
|
|
15
|
+
walletExecute: 'wallet.execute',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export interface ToolPlanningEntry {
|
|
19
|
+
toolName: string
|
|
20
|
+
capabilities: string[]
|
|
21
|
+
disciplineGuidance: string[]
|
|
22
|
+
requestMatchers: NonNullable<PluginToolPlanning['requestMatchers']>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolPlanningView {
|
|
26
|
+
displayToolIds: string[]
|
|
27
|
+
expandedPluginIds: string[]
|
|
28
|
+
entries: ToolPlanningEntry[]
|
|
29
|
+
disciplineGuidance: string[]
|
|
30
|
+
capabilityToTools: Map<string, string[]>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CORE_TOOL_PLANNING: Record<string, ToolPlanningEntry[]> = {
|
|
34
|
+
files: [
|
|
35
|
+
{
|
|
36
|
+
toolName: 'files',
|
|
37
|
+
capabilities: ['artifact.files'],
|
|
38
|
+
disciplineGuidance: [
|
|
39
|
+
'For `files`, include an explicit action whenever possible. Common patterns: `{"action":"list","dirPath":"."}`, `{"action":"read","filePath":"path/to/file.md"}`, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}`.',
|
|
40
|
+
],
|
|
41
|
+
requestMatchers: [],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
shell: [
|
|
45
|
+
{
|
|
46
|
+
toolName: 'shell',
|
|
47
|
+
capabilities: ['runtime.shell'],
|
|
48
|
+
disciplineGuidance: [
|
|
49
|
+
'For `shell`, use `{"action":"execute","command":"..."}` for commands and `{"action":"status","processId":"..."}` or `{"action":"log","processId":"..."}` for long-lived processes.',
|
|
50
|
+
],
|
|
51
|
+
requestMatchers: [],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
web: [
|
|
55
|
+
{
|
|
56
|
+
toolName: 'web_search',
|
|
57
|
+
capabilities: [TOOL_CAPABILITY.researchSearch],
|
|
58
|
+
disciplineGuidance: [
|
|
59
|
+
'For `web_search`, use `{"query":"..."}` to research fresh information. For current events, breaking news, or "latest" requests, start with `web_search` before summarizing.',
|
|
60
|
+
],
|
|
61
|
+
requestMatchers: [
|
|
62
|
+
{
|
|
63
|
+
capability: TOOL_CAPABILITY.researchSearch,
|
|
64
|
+
patterns: ['research', 'look up', 'find out', 'search for', 'compare', 'latest', 'news', 'headline', 'current event', 'recent update', 'update', 'updates', 'breaking', 'developments', 'keep watching', 'watch for', 'watching for', 'monitor', 'track', "what's new", 'what happened'],
|
|
65
|
+
forbidLiteralUrl: true,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
toolName: 'web_fetch',
|
|
71
|
+
capabilities: [TOOL_CAPABILITY.researchFetch],
|
|
72
|
+
disciplineGuidance: [
|
|
73
|
+
'For `web_fetch`, use `{"url":"https://..."}` to read a specific page or article after you know the URL.',
|
|
74
|
+
],
|
|
75
|
+
requestMatchers: [
|
|
76
|
+
{
|
|
77
|
+
capability: TOOL_CAPABILITY.researchFetch,
|
|
78
|
+
patterns: ['read', 'summarize', 'summarise', 'analyze', 'analyse', 'extract', 'review', 'article', 'page', 'url', 'link'],
|
|
79
|
+
requireLiteralUrl: true,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
browser: [
|
|
85
|
+
{
|
|
86
|
+
toolName: 'browser',
|
|
87
|
+
capabilities: [TOOL_CAPABILITY.browserNavigate, TOOL_CAPABILITY.browserCapture, TOOL_CAPABILITY.artifactPdf],
|
|
88
|
+
disciplineGuidance: [
|
|
89
|
+
'For `browser`, when the task includes a literal URL, pass that exact URL string to `{"action":"navigate","url":"..."}`. Do not invent placeholder URLs like `[Your URL]`, `Example_URL`, or `MockMailPage_URL`.',
|
|
90
|
+
'For `browser` form work, prefer `{"action":"fill_form","fields":[{"element":"#email","value":"user@example.com"},{"element":"#password","value":"..."}]}`. A shorthand `form` object keyed by input id/name also works for simple forms.',
|
|
91
|
+
'Use `browser` when the user asks for screenshots, visual proof, page capture, PDFs, or a rendered view of a page. `navigate` alone is not a screenshot.',
|
|
92
|
+
],
|
|
93
|
+
requestMatchers: [
|
|
94
|
+
{
|
|
95
|
+
capability: TOOL_CAPABILITY.browserNavigate,
|
|
96
|
+
patterns: ['browser', 'click', 'fill form', 'log in', 'login', 'navigate'],
|
|
97
|
+
requireLiteralUrl: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
capability: TOOL_CAPABILITY.browserCapture,
|
|
101
|
+
patterns: ['screenshot', 'screen shot', 'snapshot', 'page capture', 'visual proof', 'capture the page', 'rendered view'],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
capability: TOOL_CAPABILITY.artifactPdf,
|
|
105
|
+
patterns: ['pdf', 'save as pdf', 'export pdf'],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
manage_connectors: [
|
|
111
|
+
{
|
|
112
|
+
toolName: 'connector_message_tool',
|
|
113
|
+
capabilities: [TOOL_CAPABILITY.deliveryMessage, TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryVoiceNote],
|
|
114
|
+
disciplineGuidance: [
|
|
115
|
+
'For outbound delivery, inspect available channels with `connector_message_tool` using `{"action":"list_running"}` before claiming something cannot be sent.',
|
|
116
|
+
'Use `connector_message_tool` with `{"action":"send","message":"...","mediaPath":"..."}` for text/media and `{"action":"send_voice_note","voiceText":"..."}` for voice notes.',
|
|
117
|
+
'If no channel or recipient is configured, explain that connector/channel setup is missing rather than claiming the capability does not exist.',
|
|
118
|
+
],
|
|
119
|
+
requestMatchers: [
|
|
120
|
+
{
|
|
121
|
+
capability: TOOL_CAPABILITY.deliveryMessage,
|
|
122
|
+
patterns: ['send', 'share', 'deliver', 'message'],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
capability: TOOL_CAPABILITY.deliveryMedia,
|
|
126
|
+
patterns: ['screenshot', 'screen shot', 'snapshot', 'image', 'photo', 'send file', 'send a file', 'pdf', 'attachment'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
capability: TOOL_CAPABILITY.deliveryVoiceNote,
|
|
130
|
+
patterns: ['voice note', 'voice-note', 'voicenote', 'voice memo', 'voice message', 'audio note', 'audio update', 'ptt'],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
http_request: [
|
|
136
|
+
{
|
|
137
|
+
toolName: 'http_request',
|
|
138
|
+
capabilities: ['network.http'],
|
|
139
|
+
disciplineGuidance: [
|
|
140
|
+
'For `http_request`, send exact literal URLs from the task or from prior tool results. Keep JSON request bodies as raw JSON strings.',
|
|
141
|
+
],
|
|
142
|
+
requestMatchers: [],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
email: [
|
|
146
|
+
{
|
|
147
|
+
toolName: 'email',
|
|
148
|
+
capabilities: ['delivery.email'],
|
|
149
|
+
disciplineGuidance: [
|
|
150
|
+
'For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`. If delivery depends on SMTP setup, check `{"action":"status"}` before claiming success.',
|
|
151
|
+
],
|
|
152
|
+
requestMatchers: [],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
ask_human: [
|
|
156
|
+
{
|
|
157
|
+
toolName: 'ask_human',
|
|
158
|
+
capabilities: ['human.input'],
|
|
159
|
+
disciplineGuidance: [
|
|
160
|
+
'For `ask_human`, when a workflow needs a code, approval, or out-of-band value from a person, do not guess or keep re-submitting blank forms. Use `{"action":"request_input","question":"..."}` and, for durable pauses, `{"action":"wait_for_reply","correlationId":"..."}`.',
|
|
161
|
+
],
|
|
162
|
+
requestMatchers: [],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function dedupeStrings(values: string[]): string[] {
|
|
168
|
+
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim())))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizePlanningEntry(toolName: string, planning: PluginToolPlanning | null | undefined): ToolPlanningEntry | null {
|
|
172
|
+
if (!planning) return null
|
|
173
|
+
const capabilities = dedupeStrings(Array.isArray(planning.capabilities) ? planning.capabilities : [])
|
|
174
|
+
const disciplineGuidance = dedupeStrings(Array.isArray(planning.disciplineGuidance) ? planning.disciplineGuidance : [])
|
|
175
|
+
const requestMatchers = Array.isArray(planning.requestMatchers)
|
|
176
|
+
? planning.requestMatchers
|
|
177
|
+
.map((matcher) => ({
|
|
178
|
+
capability: typeof matcher?.capability === 'string' ? matcher.capability.trim() : '',
|
|
179
|
+
patterns: dedupeStrings(Array.isArray(matcher?.patterns) ? matcher.patterns : []),
|
|
180
|
+
requireLiteralUrl: matcher?.requireLiteralUrl === true,
|
|
181
|
+
forbidLiteralUrl: matcher?.forbidLiteralUrl === true,
|
|
182
|
+
}))
|
|
183
|
+
.filter((matcher) => matcher.capability || matcher.patterns.length > 0)
|
|
184
|
+
: []
|
|
185
|
+
if (!capabilities.length && !disciplineGuidance.length && !requestMatchers.length) return null
|
|
186
|
+
return {
|
|
187
|
+
toolName,
|
|
188
|
+
capabilities,
|
|
189
|
+
disciplineGuidance,
|
|
190
|
+
requestMatchers,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getEnabledToolPlanningView(enabledPlugins: string[]): ToolPlanningView {
|
|
195
|
+
const displayToolIds = dedupeStrings(enabledPlugins.map((toolId) => canonicalizePluginId(toolId))).sort()
|
|
196
|
+
const expandedPluginIds = dedupeStrings(expandPluginIds(enabledPlugins)).sort()
|
|
197
|
+
const entries: ToolPlanningEntry[] = []
|
|
198
|
+
|
|
199
|
+
for (const pluginId of expandedPluginIds) {
|
|
200
|
+
const coreEntries = CORE_TOOL_PLANNING[pluginId] || []
|
|
201
|
+
for (const entry of coreEntries) {
|
|
202
|
+
entries.push({
|
|
203
|
+
toolName: entry.toolName,
|
|
204
|
+
capabilities: [...entry.capabilities],
|
|
205
|
+
disciplineGuidance: [...entry.disciplineGuidance],
|
|
206
|
+
requestMatchers: [...entry.requestMatchers],
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const entry of getPluginManager().getTools(expandedPluginIds)) {
|
|
212
|
+
const planningEntry = normalizePlanningEntry(entry.tool.name, entry.tool.planning)
|
|
213
|
+
if (planningEntry) entries.push(planningEntry)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const disciplineSet = new Set<string>()
|
|
217
|
+
const capabilityToTools = new Map<string, Set<string>>()
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
for (const line of entry.disciplineGuidance) disciplineSet.add(line)
|
|
220
|
+
for (const capability of entry.capabilities) {
|
|
221
|
+
const current = capabilityToTools.get(capability) || new Set<string>()
|
|
222
|
+
current.add(entry.toolName)
|
|
223
|
+
capabilityToTools.set(capability, current)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
displayToolIds,
|
|
229
|
+
expandedPluginIds,
|
|
230
|
+
entries,
|
|
231
|
+
disciplineGuidance: Array.from(disciplineSet),
|
|
232
|
+
capabilityToTools: new Map(
|
|
233
|
+
Array.from(capabilityToTools.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]),
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function getToolsForCapability(enabledPlugins: string[], capability: string): string[] {
|
|
239
|
+
return getEnabledToolPlanningView(enabledPlugins).capabilityToTools.get(capability) || []
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getFirstToolForCapability(enabledPlugins: string[], capability: string): string | null {
|
|
243
|
+
return getToolsForCapability(enabledPlugins, capability)[0] || null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function matchToolCapabilitiesForMessage(
|
|
247
|
+
enabledPlugins: string[],
|
|
248
|
+
message: string,
|
|
249
|
+
): Map<string, string[]> {
|
|
250
|
+
const text = String(message || '').toLowerCase()
|
|
251
|
+
const hasLiteralUrl = /https?:\/\/[^\s<>"')]+/i.test(message)
|
|
252
|
+
const matches = new Map<string, Set<string>>()
|
|
253
|
+
|
|
254
|
+
for (const entry of getEnabledToolPlanningView(enabledPlugins).entries) {
|
|
255
|
+
for (const matcher of entry.requestMatchers) {
|
|
256
|
+
const patterns = Array.isArray(matcher.patterns) ? matcher.patterns : []
|
|
257
|
+
if (matcher.requireLiteralUrl === true && !hasLiteralUrl) continue
|
|
258
|
+
if (matcher.forbidLiteralUrl === true && hasLiteralUrl) continue
|
|
259
|
+
if (!patterns.length) continue
|
|
260
|
+
const matched = patterns.some((pattern) => text.includes(pattern.toLowerCase()))
|
|
261
|
+
if (!matched) continue
|
|
262
|
+
const capability = matcher.capability || entry.capabilities[0] || ''
|
|
263
|
+
if (!capability) continue
|
|
264
|
+
const current = matches.get(capability) || new Set<string>()
|
|
265
|
+
current.add(entry.toolName)
|
|
266
|
+
matches.set(capability, current)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return new Map(Array.from(matches.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]))
|
|
271
|
+
}
|