@swarmclawai/swarmclaw 0.6.8 → 0.7.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 +70 -45
- package/next.config.ts +31 -6
- package/package.json +3 -2
- package/src/app/api/agents/[id]/thread/route.ts +1 -0
- package/src/app/api/agents/route.ts +18 -5
- package/src/app/api/approvals/route.ts +22 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
- package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
- package/src/app/api/memory/route.ts +36 -5
- package/src/app/api/notifications/route.ts +3 -0
- package/src/app/api/plugins/install/route.ts +57 -5
- package/src/app/api/plugins/marketplace/route.ts +73 -22
- package/src/app/api/plugins/route.ts +61 -1
- package/src/app/api/plugins/ui/route.ts +34 -0
- package/src/app/api/settings/route.ts +62 -0
- package/src/app/api/setup/doctor/route.ts +22 -5
- package/src/app/api/tasks/[id]/approve/route.ts +4 -3
- package/src/app/api/tasks/[id]/route.ts +11 -3
- package/src/app/api/tasks/route.ts +8 -2
- package/src/app/globals.css +27 -0
- package/src/app/page.tsx +10 -5
- package/src/cli/index.js +13 -0
- package/src/components/activity/activity-feed.tsx +9 -2
- package/src/components/agents/agent-avatar.tsx +5 -1
- package/src/components/agents/agent-card.tsx +55 -9
- package/src/components/agents/agent-sheet.tsx +86 -29
- package/src/components/agents/inspector-panel.tsx +1 -1
- package/src/components/auth/access-key-gate.tsx +63 -54
- package/src/components/auth/user-picker.tsx +37 -32
- package/src/components/chat/chat-area.tsx +11 -0
- package/src/components/chat/chat-header.tsx +69 -25
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/chat/code-block.tsx +3 -1
- package/src/components/chat/exec-approval-card.tsx +8 -1
- package/src/components/chat/message-bubble.tsx +164 -4
- package/src/components/chat/message-list.tsx +30 -4
- package/src/components/chat/session-approval-card.tsx +80 -0
- package/src/components/chat/streaming-bubble.tsx +6 -5
- package/src/components/chat/thinking-indicator.tsx +48 -12
- package/src/components/chat/tool-request-banner.tsx +39 -20
- package/src/components/chatrooms/chatroom-list.tsx +11 -4
- package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
- package/src/components/connectors/connector-list.tsx +33 -11
- package/src/components/connectors/connector-sheet.tsx +29 -6
- package/src/components/home/home-view.tsx +20 -14
- package/src/components/input/chat-input.tsx +22 -1
- package/src/components/knowledge/knowledge-list.tsx +17 -18
- package/src/components/knowledge/knowledge-sheet.tsx +9 -5
- package/src/components/layout/app-layout.tsx +73 -21
- package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
- package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
- package/src/components/memory/memory-list.tsx +20 -13
- package/src/components/plugins/plugin-list.tsx +213 -59
- package/src/components/plugins/plugin-sheet.tsx +119 -24
- package/src/components/projects/project-list.tsx +17 -9
- package/src/components/providers/provider-list.tsx +21 -6
- package/src/components/providers/provider-sheet.tsx +42 -25
- package/src/components/runs/run-list.tsx +17 -13
- package/src/components/schedules/schedule-card.tsx +10 -3
- package/src/components/schedules/schedule-list.tsx +2 -2
- package/src/components/schedules/schedule-sheet.tsx +19 -7
- package/src/components/secrets/secret-sheet.tsx +7 -2
- package/src/components/secrets/secrets-list.tsx +18 -5
- package/src/components/sessions/new-session-sheet.tsx +183 -376
- package/src/components/sessions/session-card.tsx +10 -2
- package/src/components/settings/gateway-connection-panel.tsx +9 -8
- package/src/components/shared/command-palette.tsx +13 -5
- package/src/components/shared/empty-state.tsx +20 -8
- package/src/components/shared/notification-center.tsx +134 -86
- package/src/components/shared/profile-sheet.tsx +4 -0
- package/src/components/shared/settings/plugin-manager.tsx +360 -135
- package/src/components/shared/settings/section-capability-policy.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
- package/src/components/skills/clawhub-browser.tsx +1 -0
- package/src/components/skills/skill-list.tsx +31 -12
- package/src/components/skills/skill-sheet.tsx +20 -7
- package/src/components/tasks/approvals-panel.tsx +170 -66
- package/src/components/tasks/task-board.tsx +20 -12
- package/src/components/tasks/task-card.tsx +21 -7
- package/src/components/tasks/task-column.tsx +4 -3
- package/src/components/tasks/task-list.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +130 -1
- package/src/components/ui/dialog.tsx +1 -0
- package/src/components/ui/sheet.tsx +1 -0
- package/src/components/usage/metrics-dashboard.tsx +66 -64
- package/src/components/wallets/wallet-panel.tsx +65 -41
- package/src/components/wallets/wallet-section.tsx +9 -3
- package/src/components/webhooks/webhook-list.tsx +21 -12
- package/src/components/webhooks/webhook-sheet.tsx +13 -3
- package/src/lib/approval-display.test.ts +45 -0
- package/src/lib/approval-display.ts +62 -0
- package/src/lib/clipboard.ts +38 -0
- package/src/lib/memory.ts +8 -0
- package/src/lib/providers/claude-cli.ts +5 -3
- package/src/lib/providers/index.ts +67 -21
- package/src/lib/runtime-loop.ts +3 -2
- package/src/lib/server/approvals.ts +150 -0
- package/src/lib/server/chat-execution.ts +223 -62
- package/src/lib/server/clawhub-client.ts +82 -6
- package/src/lib/server/connectors/manager.ts +27 -1
- package/src/lib/server/cost.test.ts +73 -0
- package/src/lib/server/cost.ts +165 -34
- package/src/lib/server/daemon-state.ts +42 -0
- package/src/lib/server/data-dir.ts +18 -1
- package/src/lib/server/integrity-monitor.ts +208 -0
- package/src/lib/server/llm-response-cache.test.ts +102 -0
- package/src/lib/server/llm-response-cache.ts +227 -0
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/main-session.ts +6 -3
- package/src/lib/server/mcp-conformance.test.ts +18 -0
- package/src/lib/server/mcp-conformance.ts +233 -0
- package/src/lib/server/memory-db.ts +180 -17
- package/src/lib/server/memory-retrieval.test.ts +56 -0
- package/src/lib/server/orchestrator-lg.ts +4 -1
- package/src/lib/server/orchestrator.ts +4 -3
- package/src/lib/server/plugins.ts +650 -142
- package/src/lib/server/process-manager.ts +18 -0
- package/src/lib/server/queue.ts +253 -11
- package/src/lib/server/runtime-settings.ts +9 -0
- package/src/lib/server/session-run-manager.test.ts +23 -0
- package/src/lib/server/session-run-manager.ts +11 -1
- package/src/lib/server/session-tools/canvas.ts +85 -50
- package/src/lib/server/session-tools/chatroom.ts +130 -127
- package/src/lib/server/session-tools/connector.ts +233 -454
- package/src/lib/server/session-tools/context-mgmt.ts +87 -105
- package/src/lib/server/session-tools/crud.ts +84 -7
- package/src/lib/server/session-tools/delegate.ts +351 -752
- package/src/lib/server/session-tools/discovery.ts +198 -0
- package/src/lib/server/session-tools/edit_file.ts +82 -0
- package/src/lib/server/session-tools/file-send.test.ts +39 -0
- package/src/lib/server/session-tools/file.ts +257 -425
- package/src/lib/server/session-tools/git.ts +87 -47
- package/src/lib/server/session-tools/http.ts +85 -33
- package/src/lib/server/session-tools/index.ts +205 -160
- package/src/lib/server/session-tools/memory.ts +152 -265
- package/src/lib/server/session-tools/monitor.ts +126 -0
- package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
- package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
- package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
- package/src/lib/server/session-tools/platform.ts +86 -0
- package/src/lib/server/session-tools/plugin-creator.ts +239 -0
- package/src/lib/server/session-tools/sample-ui.ts +97 -0
- package/src/lib/server/session-tools/sandbox.ts +175 -148
- package/src/lib/server/session-tools/schedule.ts +66 -31
- package/src/lib/server/session-tools/session-info.ts +104 -410
- package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
- package/src/lib/server/session-tools/shell.ts +171 -143
- package/src/lib/server/session-tools/subagent.ts +77 -77
- package/src/lib/server/session-tools/wallet.ts +182 -106
- package/src/lib/server/session-tools/web.ts +179 -349
- package/src/lib/server/storage.ts +24 -0
- package/src/lib/server/stream-agent-chat.ts +301 -244
- package/src/lib/server/task-quality-gate.test.ts +44 -0
- package/src/lib/server/task-quality-gate.ts +67 -0
- package/src/lib/server/task-validation.test.ts +78 -0
- package/src/lib/server/task-validation.ts +67 -2
- package/src/lib/server/tool-aliases.ts +68 -0
- package/src/lib/server/tool-capability-policy.ts +23 -5
- package/src/lib/tasks.ts +7 -1
- package/src/lib/tool-definitions.ts +23 -23
- package/src/lib/validation/schemas.ts +12 -0
- package/src/lib/view-routes.ts +2 -24
- package/src/stores/use-app-store.ts +23 -1
- package/src/types/index.ts +121 -7
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import type { Message } from '@/types'
|
|
4
|
+
import {
|
|
5
|
+
buildLlmResponseCacheKey,
|
|
6
|
+
clearLlmResponseCache,
|
|
7
|
+
getCachedLlmResponse,
|
|
8
|
+
resolveLlmResponseCacheConfig,
|
|
9
|
+
setCachedLlmResponse,
|
|
10
|
+
} from './llm-response-cache.ts'
|
|
11
|
+
|
|
12
|
+
const HISTORY: Message[] = [
|
|
13
|
+
{ role: 'user', text: 'Plan a release.', time: 1 },
|
|
14
|
+
{ role: 'assistant', text: 'Drafted plan.', time: 2 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
test('buildLlmResponseCacheKey is deterministic for equivalent payloads', () => {
|
|
18
|
+
const keyA = buildLlmResponseCacheKey({
|
|
19
|
+
provider: 'openai',
|
|
20
|
+
model: 'gpt-4o-mini',
|
|
21
|
+
apiEndpoint: 'https://api.openai.com/v1',
|
|
22
|
+
systemPrompt: 'System prompt',
|
|
23
|
+
message: 'hello',
|
|
24
|
+
history: HISTORY,
|
|
25
|
+
attachedFiles: ['a.txt', 'b.txt'],
|
|
26
|
+
})
|
|
27
|
+
const keyB = buildLlmResponseCacheKey({
|
|
28
|
+
provider: 'openai',
|
|
29
|
+
model: 'gpt-4o-mini',
|
|
30
|
+
apiEndpoint: 'https://api.openai.com/v1',
|
|
31
|
+
systemPrompt: ' System prompt ',
|
|
32
|
+
message: 'hello',
|
|
33
|
+
history: [...HISTORY],
|
|
34
|
+
attachedFiles: ['a.txt', 'b.txt'],
|
|
35
|
+
})
|
|
36
|
+
assert.equal(keyA, keyB)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('set/get cached responses returns hit and increments hit count', () => {
|
|
40
|
+
clearLlmResponseCache()
|
|
41
|
+
const config = { enabled: true, ttlMs: 60_000, maxEntries: 10 }
|
|
42
|
+
const keyInput = {
|
|
43
|
+
provider: 'openai',
|
|
44
|
+
model: 'gpt-4o',
|
|
45
|
+
message: 'status',
|
|
46
|
+
history: HISTORY,
|
|
47
|
+
}
|
|
48
|
+
setCachedLlmResponse(keyInput, 'cached answer', config, 1000)
|
|
49
|
+
const hit1 = getCachedLlmResponse(keyInput, config, 1500)
|
|
50
|
+
assert.ok(hit1)
|
|
51
|
+
assert.equal(hit1?.text, 'cached answer')
|
|
52
|
+
assert.equal(hit1?.hits, 1)
|
|
53
|
+
const hit2 = getCachedLlmResponse(keyInput, config, 1600)
|
|
54
|
+
assert.equal(hit2?.hits, 2)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('expired cache entry is not returned', () => {
|
|
58
|
+
clearLlmResponseCache()
|
|
59
|
+
const config = { enabled: true, ttlMs: 1000, maxEntries: 10 }
|
|
60
|
+
const keyInput = {
|
|
61
|
+
provider: 'openai',
|
|
62
|
+
model: 'gpt-4o',
|
|
63
|
+
message: 'status',
|
|
64
|
+
history: HISTORY,
|
|
65
|
+
}
|
|
66
|
+
setCachedLlmResponse(keyInput, 'stale', config, 1000)
|
|
67
|
+
const miss = getCachedLlmResponse(keyInput, config, 3001)
|
|
68
|
+
assert.equal(miss, null)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('cache evicts least recently used entries over maxEntries', () => {
|
|
72
|
+
clearLlmResponseCache()
|
|
73
|
+
const config = { enabled: true, ttlMs: 60_000, maxEntries: 2 }
|
|
74
|
+
const inputA = { provider: 'openai', model: 'gpt-4o', message: 'a', history: HISTORY }
|
|
75
|
+
const inputB = { provider: 'openai', model: 'gpt-4o', message: 'b', history: HISTORY }
|
|
76
|
+
const inputC = { provider: 'openai', model: 'gpt-4o', message: 'c', history: HISTORY }
|
|
77
|
+
setCachedLlmResponse(inputA, 'A', config, 1000)
|
|
78
|
+
setCachedLlmResponse(inputB, 'B', config, 1001)
|
|
79
|
+
// Touch A so B becomes LRU.
|
|
80
|
+
getCachedLlmResponse(inputA, config, 1002)
|
|
81
|
+
setCachedLlmResponse(inputC, 'C', config, 1003)
|
|
82
|
+
|
|
83
|
+
assert.equal(getCachedLlmResponse(inputB, config, 1004), null)
|
|
84
|
+
assert.equal(getCachedLlmResponse(inputA, config, 1004)?.text, 'A')
|
|
85
|
+
assert.equal(getCachedLlmResponse(inputC, config, 1004)?.text, 'C')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('resolveLlmResponseCacheConfig applies defaults and bounds', () => {
|
|
89
|
+
const fallback = resolveLlmResponseCacheConfig({})
|
|
90
|
+
assert.equal(fallback.enabled, true)
|
|
91
|
+
assert.equal(fallback.ttlMs, 900_000)
|
|
92
|
+
assert.equal(fallback.maxEntries, 500)
|
|
93
|
+
|
|
94
|
+
const custom = resolveLlmResponseCacheConfig({
|
|
95
|
+
responseCacheEnabled: false,
|
|
96
|
+
responseCacheTtlSec: 1,
|
|
97
|
+
responseCacheMaxEntries: 999999,
|
|
98
|
+
})
|
|
99
|
+
assert.equal(custom.enabled, false)
|
|
100
|
+
assert.equal(custom.ttlMs, 5000)
|
|
101
|
+
assert.equal(custom.maxEntries, 20_000)
|
|
102
|
+
})
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import type { AppSettings, Message } from '@/types'
|
|
3
|
+
|
|
4
|
+
export interface LlmResponseCacheConfig {
|
|
5
|
+
enabled: boolean
|
|
6
|
+
ttlMs: number
|
|
7
|
+
maxEntries: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LlmResponseCacheKeyInput {
|
|
11
|
+
provider: string
|
|
12
|
+
model: string
|
|
13
|
+
apiEndpoint?: string | null
|
|
14
|
+
systemPrompt?: string
|
|
15
|
+
message: string
|
|
16
|
+
imagePath?: string
|
|
17
|
+
imageUrl?: string
|
|
18
|
+
attachedFiles?: string[]
|
|
19
|
+
history: Message[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LlmResponseCacheHit {
|
|
23
|
+
key: string
|
|
24
|
+
text: string
|
|
25
|
+
provider: string
|
|
26
|
+
model: string
|
|
27
|
+
createdAt: number
|
|
28
|
+
ageMs: number
|
|
29
|
+
hits: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface LlmResponseCacheEntry {
|
|
33
|
+
key: string
|
|
34
|
+
text: string
|
|
35
|
+
provider: string
|
|
36
|
+
model: string
|
|
37
|
+
createdAt: number
|
|
38
|
+
expiresAt: number
|
|
39
|
+
hits: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_ENABLED = true
|
|
43
|
+
const DEFAULT_TTL_SEC = 15 * 60
|
|
44
|
+
const DEFAULT_MAX_ENTRIES = 500
|
|
45
|
+
|
|
46
|
+
const MIN_TTL_SEC = 5
|
|
47
|
+
const MAX_TTL_SEC = 7 * 24 * 3600
|
|
48
|
+
const MIN_ENTRIES = 1
|
|
49
|
+
const MAX_ENTRIES = 20_000
|
|
50
|
+
|
|
51
|
+
const responseCache = new Map<string, LlmResponseCacheEntry>()
|
|
52
|
+
|
|
53
|
+
function normalizeText(value: unknown): string {
|
|
54
|
+
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeList(value: unknown): string[] {
|
|
58
|
+
if (!Array.isArray(value)) return []
|
|
59
|
+
return value
|
|
60
|
+
.filter((entry): entry is string => typeof entry === 'string')
|
|
61
|
+
.map((entry) => entry.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
66
|
+
const parsed = typeof value === 'number'
|
|
67
|
+
? value
|
|
68
|
+
: typeof value === 'string'
|
|
69
|
+
? Number.parseInt(value, 10)
|
|
70
|
+
: Number.NaN
|
|
71
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
72
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBool(value: unknown, fallback: boolean): boolean {
|
|
76
|
+
if (typeof value === 'boolean') return value
|
|
77
|
+
if (typeof value === 'string') {
|
|
78
|
+
const normalized = value.trim().toLowerCase()
|
|
79
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
80
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
81
|
+
}
|
|
82
|
+
return fallback
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stableStringify(value: unknown): string {
|
|
86
|
+
if (value === null) return 'null'
|
|
87
|
+
const kind = typeof value
|
|
88
|
+
if (kind === 'number' || kind === 'boolean') return JSON.stringify(value)
|
|
89
|
+
if (kind === 'string') return JSON.stringify(value)
|
|
90
|
+
if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
|
|
91
|
+
if (kind === 'object') {
|
|
92
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
93
|
+
.filter(([, v]) => v !== undefined)
|
|
94
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
95
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
|
|
96
|
+
}
|
|
97
|
+
return JSON.stringify(String(value))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeHistory(history: Message[]): Array<Record<string, unknown>> {
|
|
101
|
+
return history.map((entry) => ({
|
|
102
|
+
role: entry.role,
|
|
103
|
+
text: normalizeText(entry.text),
|
|
104
|
+
kind: entry.kind || null,
|
|
105
|
+
imagePath: entry.imagePath || null,
|
|
106
|
+
imageUrl: entry.imageUrl || null,
|
|
107
|
+
attachedFiles: normalizeList(entry.attachedFiles),
|
|
108
|
+
replyToId: entry.replyToId || null,
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trimToCapacity(maxEntries: number): void {
|
|
113
|
+
while (responseCache.size > maxEntries) {
|
|
114
|
+
const oldestKey = responseCache.keys().next().value as string | undefined
|
|
115
|
+
if (!oldestKey) break
|
|
116
|
+
responseCache.delete(oldestKey)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function moveToMostRecent(key: string, entry: LlmResponseCacheEntry): void {
|
|
121
|
+
responseCache.delete(key)
|
|
122
|
+
responseCache.set(key, entry)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveLlmResponseCacheConfig(
|
|
126
|
+
settings?: AppSettings | Record<string, unknown> | null,
|
|
127
|
+
): LlmResponseCacheConfig {
|
|
128
|
+
const raw = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {}
|
|
129
|
+
const ttlSec = normalizeInt(raw.responseCacheTtlSec, DEFAULT_TTL_SEC, MIN_TTL_SEC, MAX_TTL_SEC)
|
|
130
|
+
const maxEntries = normalizeInt(raw.responseCacheMaxEntries, DEFAULT_MAX_ENTRIES, MIN_ENTRIES, MAX_ENTRIES)
|
|
131
|
+
const enabled = normalizeBool(raw.responseCacheEnabled, DEFAULT_ENABLED)
|
|
132
|
+
return {
|
|
133
|
+
enabled,
|
|
134
|
+
ttlMs: ttlSec * 1000,
|
|
135
|
+
maxEntries,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildLlmResponseCacheKey(input: LlmResponseCacheKeyInput): string {
|
|
140
|
+
const payload = {
|
|
141
|
+
provider: normalizeText(input.provider).toLowerCase(),
|
|
142
|
+
model: normalizeText(input.model),
|
|
143
|
+
apiEndpoint: normalizeText(input.apiEndpoint || ''),
|
|
144
|
+
systemPrompt: normalizeText(input.systemPrompt || ''),
|
|
145
|
+
message: normalizeText(input.message),
|
|
146
|
+
imagePath: normalizeText(input.imagePath || ''),
|
|
147
|
+
imageUrl: normalizeText(input.imageUrl || ''),
|
|
148
|
+
attachedFiles: normalizeList(input.attachedFiles),
|
|
149
|
+
history: normalizeHistory(Array.isArray(input.history) ? input.history : []),
|
|
150
|
+
}
|
|
151
|
+
const stable = stableStringify(payload)
|
|
152
|
+
return crypto.createHash('sha256').update(stable).digest('hex')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function getCachedLlmResponse(
|
|
156
|
+
input: LlmResponseCacheKeyInput,
|
|
157
|
+
config: LlmResponseCacheConfig,
|
|
158
|
+
now = Date.now(),
|
|
159
|
+
): LlmResponseCacheHit | null {
|
|
160
|
+
if (!config.enabled) return null
|
|
161
|
+
const key = buildLlmResponseCacheKey(input)
|
|
162
|
+
const found = responseCache.get(key)
|
|
163
|
+
if (!found) return null
|
|
164
|
+
if (now >= found.expiresAt) {
|
|
165
|
+
responseCache.delete(key)
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
const next = { ...found, hits: found.hits + 1 }
|
|
169
|
+
moveToMostRecent(key, next)
|
|
170
|
+
return {
|
|
171
|
+
key,
|
|
172
|
+
text: next.text,
|
|
173
|
+
provider: next.provider,
|
|
174
|
+
model: next.model,
|
|
175
|
+
createdAt: next.createdAt,
|
|
176
|
+
ageMs: Math.max(0, now - next.createdAt),
|
|
177
|
+
hits: next.hits,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function setCachedLlmResponse(
|
|
182
|
+
input: LlmResponseCacheKeyInput,
|
|
183
|
+
text: string,
|
|
184
|
+
config: LlmResponseCacheConfig,
|
|
185
|
+
now = Date.now(),
|
|
186
|
+
): void {
|
|
187
|
+
if (!config.enabled) return
|
|
188
|
+
const normalizedText = normalizeText(text)
|
|
189
|
+
if (!normalizedText) return
|
|
190
|
+
const key = buildLlmResponseCacheKey(input)
|
|
191
|
+
const existing = responseCache.get(key)
|
|
192
|
+
const createdAt = existing?.createdAt ?? now
|
|
193
|
+
const entry: LlmResponseCacheEntry = {
|
|
194
|
+
key,
|
|
195
|
+
text: normalizedText,
|
|
196
|
+
provider: normalizeText(input.provider).toLowerCase(),
|
|
197
|
+
model: normalizeText(input.model),
|
|
198
|
+
createdAt,
|
|
199
|
+
expiresAt: now + config.ttlMs,
|
|
200
|
+
hits: existing?.hits ?? 0,
|
|
201
|
+
}
|
|
202
|
+
moveToMostRecent(key, entry)
|
|
203
|
+
trimToCapacity(config.maxEntries)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getLlmResponseCacheStats(now = Date.now()): {
|
|
207
|
+
entries: number
|
|
208
|
+
expired: number
|
|
209
|
+
oldestAgeMs: number
|
|
210
|
+
} {
|
|
211
|
+
let expired = 0
|
|
212
|
+
let oldestCreatedAt = Number.POSITIVE_INFINITY
|
|
213
|
+
for (const entry of responseCache.values()) {
|
|
214
|
+
if (entry.expiresAt <= now) expired++
|
|
215
|
+
oldestCreatedAt = Math.min(oldestCreatedAt, entry.createdAt)
|
|
216
|
+
}
|
|
217
|
+
const oldestAgeMs = Number.isFinite(oldestCreatedAt) ? Math.max(0, now - oldestCreatedAt) : 0
|
|
218
|
+
return {
|
|
219
|
+
entries: responseCache.size,
|
|
220
|
+
expired,
|
|
221
|
+
oldestAgeMs,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function clearLlmResponseCache(): void {
|
|
226
|
+
responseCache.clear()
|
|
227
|
+
}
|
|
@@ -935,7 +935,7 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
|
|
|
935
935
|
const sessions = loadSessions()
|
|
936
936
|
const session = sessions[input.sessionId]
|
|
937
937
|
if (!session) return null
|
|
938
|
-
if (!
|
|
938
|
+
if (!isProtectedMainSession(session)) return handleAgentHeartbeatResult(session, input)
|
|
939
939
|
|
|
940
940
|
const now = Date.now()
|
|
941
941
|
const state = normalizeState(session.mainLoopState, now)
|
|
@@ -3,12 +3,15 @@ const MAIN_SESSION_NAME = '__main__'
|
|
|
3
3
|
export function isProtectedMainSession(session: any): boolean {
|
|
4
4
|
if (!session || typeof session !== 'object') return false
|
|
5
5
|
if (session.mainSession === true) return true
|
|
6
|
-
|
|
7
|
-
const name = typeof session.name === 'string' ? session.name.trim() : ''
|
|
8
|
-
if (name === MAIN_SESSION_NAME) return true
|
|
6
|
+
if (session.sessionType === 'orchestrated') return true
|
|
9
7
|
|
|
10
8
|
const id = typeof session.id === 'string' ? session.id.trim() : ''
|
|
11
9
|
if (id.startsWith('main-')) return true
|
|
10
|
+
if (id.startsWith('agent-thread-')) return true
|
|
11
|
+
|
|
12
|
+
const name = typeof session.name === 'string' ? session.name.trim() : ''
|
|
13
|
+
if (name === MAIN_SESSION_NAME) return true
|
|
14
|
+
if (name.startsWith('agent-thread:')) return true
|
|
12
15
|
|
|
13
16
|
return false
|
|
14
17
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import { runMcpConformanceCheck } from './mcp-conformance.ts'
|
|
4
|
+
|
|
5
|
+
test('runMcpConformanceCheck reports connect/list failure for unsupported transport', async () => {
|
|
6
|
+
const result = await runMcpConformanceCheck({
|
|
7
|
+
id: 'bad',
|
|
8
|
+
name: 'Bad MCP',
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
transport: 'invalid-transport' as any,
|
|
11
|
+
createdAt: Date.now(),
|
|
12
|
+
updatedAt: Date.now(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
assert.equal(result.ok, false)
|
|
16
|
+
assert.equal(result.toolsCount, 0)
|
|
17
|
+
assert.ok(result.issues.some((issue) => issue.code === 'connect_or_list_failed'))
|
|
18
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { McpServerConfig } from '@/types'
|
|
2
|
+
import { connectMcpServer, disconnectMcpServer } from './mcp-client'
|
|
3
|
+
|
|
4
|
+
export interface McpConformanceIssue {
|
|
5
|
+
level: 'error' | 'warning'
|
|
6
|
+
code: string
|
|
7
|
+
message: string
|
|
8
|
+
toolName?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface McpConformanceOptions {
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
smokeToolName?: string | null
|
|
14
|
+
smokeToolArgs?: Record<string, unknown> | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface McpConformanceResult {
|
|
18
|
+
ok: boolean
|
|
19
|
+
serverId: string
|
|
20
|
+
serverName: string
|
|
21
|
+
checkedAt: number
|
|
22
|
+
toolsCount: number
|
|
23
|
+
smokeToolName: string | null
|
|
24
|
+
issues: McpConformanceIssue[]
|
|
25
|
+
timings: {
|
|
26
|
+
connectMs: number
|
|
27
|
+
listToolsMs: number
|
|
28
|
+
smokeInvokeMs: number | null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 12_000
|
|
33
|
+
const MIN_TIMEOUT_MS = 1_000
|
|
34
|
+
const MAX_TIMEOUT_MS = 120_000
|
|
35
|
+
|
|
36
|
+
function normalizeTimeoutMs(value: unknown): number {
|
|
37
|
+
const parsed = typeof value === 'number'
|
|
38
|
+
? value
|
|
39
|
+
: typeof value === 'string'
|
|
40
|
+
? Number.parseInt(value, 10)
|
|
41
|
+
: Number.NaN
|
|
42
|
+
if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS
|
|
43
|
+
return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.trunc(parsed)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
47
|
+
return new Promise<T>((resolve, reject) => {
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
|
|
50
|
+
}, timeoutMs)
|
|
51
|
+
promise.then(
|
|
52
|
+
(value) => {
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
resolve(value)
|
|
55
|
+
},
|
|
56
|
+
(error) => {
|
|
57
|
+
clearTimeout(timer)
|
|
58
|
+
reject(error)
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizedRequired(schema: Record<string, unknown>): string[] {
|
|
69
|
+
const required = schema.required
|
|
70
|
+
if (!Array.isArray(required)) return []
|
|
71
|
+
return required.filter((entry): entry is string => typeof entry === 'string')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function findSmokeTool(tools: Array<Record<string, unknown>>, preferredName?: string | null): string | null {
|
|
75
|
+
const preferred = typeof preferredName === 'string' ? preferredName.trim() : ''
|
|
76
|
+
if (preferred) {
|
|
77
|
+
const found = tools.find((tool) => tool.name === preferred)
|
|
78
|
+
if (found) return preferred
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const noArg = tools.find((tool) => {
|
|
82
|
+
const schema = isRecord(tool.inputSchema) ? tool.inputSchema : {}
|
|
83
|
+
const required = normalizedRequired(schema)
|
|
84
|
+
return required.length === 0
|
|
85
|
+
})
|
|
86
|
+
if (noArg && typeof noArg.name === 'string' && noArg.name.trim()) return noArg.name
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateToolSchemas(tools: Array<Record<string, unknown>>, issues: McpConformanceIssue[]): void {
|
|
91
|
+
const seenNames = new Set<string>()
|
|
92
|
+
for (const tool of tools) {
|
|
93
|
+
const toolName = typeof tool.name === 'string' ? tool.name.trim() : ''
|
|
94
|
+
if (!toolName) {
|
|
95
|
+
issues.push({
|
|
96
|
+
level: 'error',
|
|
97
|
+
code: 'tool_name_missing',
|
|
98
|
+
message: 'Tool is missing a valid name.',
|
|
99
|
+
})
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (seenNames.has(toolName)) {
|
|
103
|
+
issues.push({
|
|
104
|
+
level: 'error',
|
|
105
|
+
code: 'tool_name_duplicate',
|
|
106
|
+
message: `Duplicate tool name "${toolName}" detected.`,
|
|
107
|
+
toolName,
|
|
108
|
+
})
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
seenNames.add(toolName)
|
|
112
|
+
|
|
113
|
+
const schema = isRecord(tool.inputSchema) ? tool.inputSchema : null
|
|
114
|
+
if (!schema) {
|
|
115
|
+
issues.push({
|
|
116
|
+
level: 'warning',
|
|
117
|
+
code: 'tool_schema_missing',
|
|
118
|
+
message: `Tool "${toolName}" is missing an input schema.`,
|
|
119
|
+
toolName,
|
|
120
|
+
})
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const schemaType = typeof schema.type === 'string' ? schema.type : 'object'
|
|
125
|
+
if (schemaType !== 'object') {
|
|
126
|
+
issues.push({
|
|
127
|
+
level: 'warning',
|
|
128
|
+
code: 'tool_schema_non_object',
|
|
129
|
+
message: `Tool "${toolName}" schema type is "${schemaType}" (expected "object").`,
|
|
130
|
+
toolName,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const properties = isRecord(schema.properties) ? schema.properties : {}
|
|
135
|
+
const required = normalizedRequired(schema)
|
|
136
|
+
for (const req of required) {
|
|
137
|
+
if (!Object.prototype.hasOwnProperty.call(properties, req)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
level: 'warning',
|
|
140
|
+
code: 'tool_schema_required_missing_property',
|
|
141
|
+
message: `Tool "${toolName}" marks "${req}" as required but it is not present in schema.properties.`,
|
|
142
|
+
toolName,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function runMcpConformanceCheck(
|
|
150
|
+
server: McpServerConfig,
|
|
151
|
+
options: McpConformanceOptions = {},
|
|
152
|
+
): Promise<McpConformanceResult> {
|
|
153
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs)
|
|
154
|
+
const issues: McpConformanceIssue[] = []
|
|
155
|
+
const checkedAt = Date.now()
|
|
156
|
+
const result: McpConformanceResult = {
|
|
157
|
+
ok: false,
|
|
158
|
+
serverId: server.id,
|
|
159
|
+
serverName: server.name,
|
|
160
|
+
checkedAt,
|
|
161
|
+
toolsCount: 0,
|
|
162
|
+
smokeToolName: null,
|
|
163
|
+
issues,
|
|
164
|
+
timings: {
|
|
165
|
+
connectMs: 0,
|
|
166
|
+
listToolsMs: 0,
|
|
167
|
+
smokeInvokeMs: null,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let client: unknown
|
|
172
|
+
let transport: unknown
|
|
173
|
+
const connectStart = Date.now()
|
|
174
|
+
try {
|
|
175
|
+
const conn = await withTimeout(connectMcpServer(server), timeoutMs, 'MCP connect')
|
|
176
|
+
client = conn.client
|
|
177
|
+
transport = conn.transport
|
|
178
|
+
result.timings.connectMs = Date.now() - connectStart
|
|
179
|
+
|
|
180
|
+
const mcpClient = client as { listTools: () => Promise<Record<string, unknown>>; callTool: (opts: Record<string, unknown>) => Promise<unknown> }
|
|
181
|
+
const listStart = Date.now()
|
|
182
|
+
const listResponse = await withTimeout(mcpClient.listTools(), timeoutMs, 'MCP listTools') as Record<string, unknown>
|
|
183
|
+
result.timings.listToolsMs = Date.now() - listStart
|
|
184
|
+
const tools = Array.isArray(listResponse?.tools) ? listResponse.tools as Array<Record<string, unknown>> : []
|
|
185
|
+
result.toolsCount = tools.length
|
|
186
|
+
|
|
187
|
+
validateToolSchemas(tools, issues)
|
|
188
|
+
|
|
189
|
+
const smokeToolName = findSmokeTool(tools, options.smokeToolName)
|
|
190
|
+
result.smokeToolName = smokeToolName
|
|
191
|
+
if (!smokeToolName) {
|
|
192
|
+
issues.push({
|
|
193
|
+
level: 'warning',
|
|
194
|
+
code: 'smoke_tool_missing',
|
|
195
|
+
message: 'No smoke-testable tool found (no no-arg tool and no explicit smokeToolName).',
|
|
196
|
+
})
|
|
197
|
+
} else {
|
|
198
|
+
const smokeArgs = options.smokeToolArgs && isRecord(options.smokeToolArgs)
|
|
199
|
+
? options.smokeToolArgs
|
|
200
|
+
: {}
|
|
201
|
+
const smokeStart = Date.now()
|
|
202
|
+
try {
|
|
203
|
+
await withTimeout(
|
|
204
|
+
mcpClient.callTool({ name: smokeToolName, arguments: smokeArgs }),
|
|
205
|
+
timeoutMs,
|
|
206
|
+
`MCP callTool(${smokeToolName})`,
|
|
207
|
+
)
|
|
208
|
+
} catch (err) {
|
|
209
|
+
issues.push({
|
|
210
|
+
level: 'error',
|
|
211
|
+
code: 'smoke_tool_failed',
|
|
212
|
+
message: err instanceof Error ? err.message : String(err),
|
|
213
|
+
toolName: smokeToolName,
|
|
214
|
+
})
|
|
215
|
+
} finally {
|
|
216
|
+
result.timings.smokeInvokeMs = Date.now() - smokeStart
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
issues.push({
|
|
221
|
+
level: 'error',
|
|
222
|
+
code: 'connect_or_list_failed',
|
|
223
|
+
message: err instanceof Error ? err.message : String(err),
|
|
224
|
+
})
|
|
225
|
+
} finally {
|
|
226
|
+
if (client && transport) {
|
|
227
|
+
await disconnectMcpServer(client, transport)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
result.ok = issues.every((issue) => issue.level !== 'error')
|
|
232
|
+
return result
|
|
233
|
+
}
|