@swarmclawai/swarmclaw 0.9.2 → 0.9.4
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 -10
- package/bundled-skills/google-workspace/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/app/agents/page.tsx +2 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
- package/src/app/api/clawhub/install/route.ts +2 -0
- package/src/app/api/skills/[id]/route.ts +4 -0
- package/src/app/api/skills/route.ts +4 -0
- package/src/app/globals.css +28 -0
- package/src/app/home/page.tsx +11 -0
- package/src/app/settings/page.tsx +12 -5
- package/src/components/agents/agent-sheet.tsx +5 -5
- package/src/components/connectors/connector-list.tsx +2 -5
- package/src/components/logs/log-list.tsx +2 -5
- package/src/components/providers/provider-list.tsx +2 -5
- package/src/components/runs/run-list.tsx +2 -6
- package/src/components/schedules/schedule-list.tsx +7 -1
- package/src/components/ui/full-screen-loader.tsx +0 -29
- package/src/components/ui/page-loader.tsx +69 -0
- package/src/lib/runtime/runtime-loop.ts +21 -1
- package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
- package/src/lib/server/agents/agent-thread-session.ts +1 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
- package/src/lib/server/agents/main-agent-loop.ts +259 -0
- package/src/lib/server/agents/orchestrator-lg.ts +12 -8
- package/src/lib/server/agents/orchestrator.ts +11 -7
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
- package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
- package/src/lib/server/chat-execution/chat-execution.ts +116 -29
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
- package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
- package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
- package/src/lib/server/connectors/contact-boundaries.ts +101 -0
- package/src/lib/server/connectors/manager.test.ts +504 -73
- package/src/lib/server/connectors/manager.ts +41 -10
- package/src/lib/server/connectors/session-consolidation.ts +2 -0
- package/src/lib/server/connectors/session-kind.ts +7 -0
- package/src/lib/server/connectors/session.test.ts +104 -0
- package/src/lib/server/connectors/session.ts +5 -2
- package/src/lib/server/identity-continuity.test.ts +4 -3
- package/src/lib/server/identity-continuity.ts +8 -4
- package/src/lib/server/memory/memory-policy.test.ts +5 -15
- package/src/lib/server/memory/memory-policy.ts +11 -41
- package/src/lib/server/memory/session-archive-memory.ts +2 -1
- package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
- package/src/lib/server/runtime/heartbeat-service.ts +5 -1
- package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
- package/src/lib/server/runtime/runtime-settings.ts +4 -0
- package/src/lib/server/runtime/session-run-manager.ts +2 -0
- package/src/lib/server/session-reset-policy.test.ts +17 -3
- package/src/lib/server/session-reset-policy.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +11 -10
- package/src/lib/server/session-tools/crud.ts +41 -7
- package/src/lib/server/session-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
- package/src/lib/server/session-tools/memory.ts +209 -48
- package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
- package/src/lib/server/session-tools/skill-runtime.ts +382 -0
- package/src/lib/server/session-tools/skills.ts +575 -0
- package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
- package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
- package/src/lib/server/skills/skill-discovery.ts +4 -0
- package/src/lib/server/skills/skills-normalize.test.ts +28 -0
- package/src/lib/server/skills/skills-normalize.ts +93 -1
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-followups.test.ts +124 -0
- package/src/lib/server/tasks/task-followups.ts +88 -13
- package/src/types/index.ts +30 -2
- package/src/views/settings/section-runtime-loop.tsx +38 -0
|
@@ -28,8 +28,16 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|
|
28
28
|
|
|
29
29
|
test('inferSessionResetType distinguishes direct, group, and thread sessions', () => {
|
|
30
30
|
assert.equal(inferSessionResetType(makeSession()), 'direct')
|
|
31
|
-
assert.equal(inferSessionResetType(makeSession({
|
|
32
|
-
|
|
31
|
+
assert.equal(inferSessionResetType(makeSession({
|
|
32
|
+
name: 'connector:group',
|
|
33
|
+
user: 'connector',
|
|
34
|
+
connectorContext: { isGroup: true },
|
|
35
|
+
})), 'group')
|
|
36
|
+
assert.equal(inferSessionResetType(makeSession({
|
|
37
|
+
name: 'connector:thread',
|
|
38
|
+
user: 'connector',
|
|
39
|
+
connectorContext: { threadId: 'thread-1' },
|
|
40
|
+
})), 'thread')
|
|
33
41
|
})
|
|
34
42
|
|
|
35
43
|
test('resolveSessionResetPolicy falls back to type defaults', () => {
|
|
@@ -37,7 +45,13 @@ test('resolveSessionResetPolicy falls back to type defaults', () => {
|
|
|
37
45
|
assert.equal(direct.mode, 'idle')
|
|
38
46
|
assert.equal(direct.idleTimeoutSec, 12 * 60 * 60)
|
|
39
47
|
|
|
40
|
-
const thread = resolveSessionResetPolicy({
|
|
48
|
+
const thread = resolveSessionResetPolicy({
|
|
49
|
+
session: makeSession({
|
|
50
|
+
name: 'connector:thread',
|
|
51
|
+
user: 'connector',
|
|
52
|
+
connectorContext: { threadId: 'thread-1' },
|
|
53
|
+
}),
|
|
54
|
+
})
|
|
41
55
|
assert.equal(thread.mode, 'idle')
|
|
42
56
|
assert.equal(thread.idleTimeoutSec, 4 * 60 * 60)
|
|
43
57
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Agent, AppSettings, Session, SessionResetMode, SessionResetType } from '@/types'
|
|
2
|
+
import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
|
|
2
3
|
|
|
3
4
|
export interface ResolvedSessionResetPolicy {
|
|
4
5
|
type: SessionResetType
|
|
@@ -140,9 +141,10 @@ export function inferSessionResetType(
|
|
|
140
141
|
opts?: { isGroup?: boolean | null; threadId?: string | null },
|
|
141
142
|
): SessionResetType {
|
|
142
143
|
if ((session?.sessionType as string | undefined) === 'orchestrated') return 'main'
|
|
143
|
-
const
|
|
144
|
+
const connectorContext = isDirectConnectorSession(session) ? session?.connectorContext : null
|
|
145
|
+
const threadId = opts?.threadId ?? connectorContext?.threadId ?? null
|
|
144
146
|
if (threadId) return 'thread'
|
|
145
|
-
const isGroup = opts?.isGroup ??
|
|
147
|
+
const isGroup = opts?.isGroup ?? connectorContext?.isGroup ?? false
|
|
146
148
|
return isGroup ? 'group' : 'direct'
|
|
147
149
|
}
|
|
148
150
|
|
|
@@ -13,6 +13,7 @@ import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
|
13
13
|
import { safeJsonParseObject } from '../json-utils'
|
|
14
14
|
import { tryResolvePathWithinBaseDir } from '../path-utils'
|
|
15
15
|
import { dedup, errorMessage } from '@/lib/shared-utils'
|
|
16
|
+
import { isDirectConnectorSession } from '../connectors/session-kind'
|
|
16
17
|
|
|
17
18
|
const CONNECTOR_ACTION_DEDUPE_TTL_MS = 30_000
|
|
18
19
|
const CONNECTOR_TURN_SEND_TTL_MS = 180_000
|
|
@@ -341,11 +342,14 @@ function trimToString(value: unknown): string {
|
|
|
341
342
|
|
|
342
343
|
function resolveSessionConnectorTargets(
|
|
343
344
|
session: {
|
|
345
|
+
user?: string
|
|
346
|
+
name?: string
|
|
344
347
|
connectorContext?: Record<string, unknown>
|
|
345
348
|
messages?: Array<Record<string, unknown>>
|
|
346
349
|
} | null | undefined,
|
|
347
350
|
connectorId: string,
|
|
348
351
|
): Array<{ channelId: string; senderId?: string; senderName?: string }> {
|
|
352
|
+
if (!isDirectConnectorSession(session)) return []
|
|
349
353
|
const targets: Array<{ channelId: string; senderId?: string; senderName?: string }> = []
|
|
350
354
|
const seen = new Set<string>()
|
|
351
355
|
const pushTarget = (target: { channelId: string; senderId?: string; senderName?: string } | null) => {
|
|
@@ -368,6 +372,7 @@ function resolveSessionConnectorTargets(
|
|
|
368
372
|
|
|
369
373
|
const messages = Array.isArray(session?.messages) ? session.messages : []
|
|
370
374
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
375
|
+
if (messages[i]?.historyExcluded === true) continue
|
|
371
376
|
const source = messages[i]?.source as Record<string, unknown> | undefined
|
|
372
377
|
if (!source || trimToString(source.connectorId) !== connectorId) continue
|
|
373
378
|
const channelId = trimToString(source.channelId)
|
|
@@ -386,8 +391,9 @@ function pickChannelTarget(params: {
|
|
|
386
391
|
connector: { config?: Record<string, string> }
|
|
387
392
|
connectorId: string
|
|
388
393
|
to?: string
|
|
389
|
-
recentChannelId: string | null
|
|
390
394
|
currentSession?: {
|
|
395
|
+
user?: string
|
|
396
|
+
name?: string
|
|
391
397
|
connectorContext?: Record<string, unknown>
|
|
392
398
|
messages?: Array<Record<string, unknown>>
|
|
393
399
|
} | null
|
|
@@ -421,9 +427,6 @@ function pickChannelTarget(params: {
|
|
|
421
427
|
const outbound = connector.config?.outboundTarget?.trim()
|
|
422
428
|
if (outbound) channelId = outbound
|
|
423
429
|
}
|
|
424
|
-
if (!channelId && params.recentChannelId) {
|
|
425
|
-
channelId = params.recentChannelId
|
|
426
|
-
}
|
|
427
430
|
if (!channelId) {
|
|
428
431
|
const allowed = parseCsv(connector.config?.allowedJids)
|
|
429
432
|
if (allowed.length) channelId = allowed[0]
|
|
@@ -562,7 +565,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
562
565
|
const {
|
|
563
566
|
listRunningConnectors,
|
|
564
567
|
sendConnectorMessage,
|
|
565
|
-
getConnectorRecentChannelId,
|
|
566
568
|
scheduleConnectorFollowUp,
|
|
567
569
|
performConnectorMessageAction,
|
|
568
570
|
} = await import('../connectors/manager')
|
|
@@ -646,6 +648,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
646
648
|
|
|
647
649
|
const currentSession = bctx.resolveCurrentSession?.()
|
|
648
650
|
const sessionId = bctx.ctx?.sessionId || currentSession?.id || undefined
|
|
651
|
+
const connectorScopedSessionId = isDirectConnectorSession(currentSession) ? sessionId : undefined
|
|
649
652
|
|
|
650
653
|
if (actionName === 'send' || actionName === 'send_voice_note' || actionName === 'schedule_followup') {
|
|
651
654
|
const settings = loadSettings()
|
|
@@ -662,7 +665,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
662
665
|
connector,
|
|
663
666
|
connectorId: selected.id,
|
|
664
667
|
to,
|
|
665
|
-
recentChannelId: getConnectorRecentChannelId(selected.id),
|
|
666
668
|
currentSession,
|
|
667
669
|
})
|
|
668
670
|
if (target.error) return target.error
|
|
@@ -728,7 +730,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
728
730
|
const sent = await sendConnectorMessage({
|
|
729
731
|
connectorId: selected.id, channelId, text: '', mediaPath: voicePath, mimeType: outboundMimeType,
|
|
730
732
|
fileName: fileName?.trim() || 'voicenote.mp3', caption: caption?.trim() || undefined, ptt: ptt ?? true,
|
|
731
|
-
sessionId,
|
|
733
|
+
sessionId: connectorScopedSessionId,
|
|
732
734
|
replyToMessageId: replyToMessageId?.trim() || undefined,
|
|
733
735
|
threadId: threadId?.trim() || undefined,
|
|
734
736
|
})
|
|
@@ -768,7 +770,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
768
770
|
connectorId: selected.id,
|
|
769
771
|
channelId,
|
|
770
772
|
text: followupText,
|
|
771
|
-
sessionId,
|
|
773
|
+
sessionId: connectorScopedSessionId,
|
|
772
774
|
delaySec: followupDelay,
|
|
773
775
|
dedupeKey: dedupeKey?.trim() || undefined,
|
|
774
776
|
imageUrl: media.imageUrl,
|
|
@@ -793,7 +795,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
793
795
|
|
|
794
796
|
const sent = await sendConnectorMessage({
|
|
795
797
|
connectorId: selected.id, channelId, text: message?.trim() || '',
|
|
796
|
-
sessionId,
|
|
798
|
+
sessionId: connectorScopedSessionId,
|
|
797
799
|
imageUrl: media.imageUrl, fileUrl: media.fileUrl, mediaPath: media.mediaPath,
|
|
798
800
|
mimeType: mimeType?.trim() || undefined, fileName: fileName?.trim() || undefined,
|
|
799
801
|
caption: caption?.trim() || undefined,
|
|
@@ -815,7 +817,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
|
|
|
815
817
|
connector: resolved.connector,
|
|
816
818
|
connectorId: selected.id,
|
|
817
819
|
to,
|
|
818
|
-
recentChannelId: getConnectorRecentChannelId(selected.id),
|
|
819
820
|
currentSession,
|
|
820
821
|
})
|
|
821
822
|
if (target.error) return target.error
|
|
@@ -46,6 +46,8 @@ import { safePath, findBinaryOnPath } from './context'
|
|
|
46
46
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
47
47
|
import type { BoardTask } from '@/types'
|
|
48
48
|
import { dedup } from '@/lib/shared-utils'
|
|
49
|
+
import { isDirectConnectorSession } from '../connectors/session-kind'
|
|
50
|
+
import { buildManageSkillsDescription, executeManageSkillsAction } from './skills'
|
|
49
51
|
|
|
50
52
|
// ---------------------------------------------------------------------------
|
|
51
53
|
// Document helpers
|
|
@@ -258,8 +260,10 @@ function deriveScheduleFollowupTarget(sessionId: string | null | undefined): {
|
|
|
258
260
|
}
|
|
259
261
|
}
|
|
260
262
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
+
if (isDirectConnectorSession(session as { user?: string; name?: string })) {
|
|
264
|
+
const contextTarget = pickSourceFields(session.connectorContext || undefined)
|
|
265
|
+
if (contextTarget.followupConnectorId && contextTarget.followupChannelId) return contextTarget
|
|
266
|
+
}
|
|
263
267
|
|
|
264
268
|
const messages = Array.isArray(session.messages) ? session.messages : []
|
|
265
269
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
@@ -447,12 +451,17 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
447
451
|
if (ctx?.projectId) {
|
|
448
452
|
description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to link the secret to this active project.`
|
|
449
453
|
}
|
|
454
|
+
} else if (toolKey === 'manage_skills') {
|
|
455
|
+
description = buildManageSkillsDescription()
|
|
450
456
|
}
|
|
451
457
|
|
|
452
458
|
tools.push(
|
|
453
459
|
tool(
|
|
454
460
|
async (rawArgs) => {
|
|
455
461
|
const normalized = normalizeToolInputArgs((rawArgs ?? {}) as Record<string, unknown>)
|
|
462
|
+
if (toolKey === 'manage_skills') {
|
|
463
|
+
return executeManageSkillsAction(normalized, bctx)
|
|
464
|
+
}
|
|
456
465
|
const action = normalized.action as string | undefined
|
|
457
466
|
const id = normalized.id as string | undefined
|
|
458
467
|
const data = normalized.data as string | undefined
|
|
@@ -881,11 +890,36 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
881
890
|
{
|
|
882
891
|
name: toolKey,
|
|
883
892
|
description,
|
|
884
|
-
schema:
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
893
|
+
schema: toolKey === 'manage_skills'
|
|
894
|
+
? z.object({
|
|
895
|
+
action: z.enum([
|
|
896
|
+
'list',
|
|
897
|
+
'get',
|
|
898
|
+
'create',
|
|
899
|
+
'update',
|
|
900
|
+
'delete',
|
|
901
|
+
'status',
|
|
902
|
+
'search_available',
|
|
903
|
+
'recommend_for_task',
|
|
904
|
+
'attach',
|
|
905
|
+
'install',
|
|
906
|
+
]).describe('The manage_skills action to perform'),
|
|
907
|
+
id: z.string().optional().describe('Stored skill ID or runtime skill selector'),
|
|
908
|
+
skillId: z.string().optional().describe('Alternate skill selector'),
|
|
909
|
+
name: z.string().optional().describe('Skill name or marketplace name'),
|
|
910
|
+
query: z.string().optional().describe('Search query or task description'),
|
|
911
|
+
task: z.string().optional().describe('Task description for skill recommendation'),
|
|
912
|
+
url: z.string().optional().describe('Remote skill URL for install'),
|
|
913
|
+
approvalId: z.string().optional().describe('Approved install request id'),
|
|
914
|
+
attach: z.boolean().optional().describe('Attach the skill to the current agent after install'),
|
|
915
|
+
agentId: z.string().optional().describe('Target agent id for attach'),
|
|
916
|
+
data: z.string().optional().describe('JSON string of fields for create/update'),
|
|
917
|
+
}).passthrough()
|
|
918
|
+
: z.object({
|
|
919
|
+
action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('The CRUD action to perform'),
|
|
920
|
+
id: z.string().optional().describe('Resource ID (required for get, update, delete)'),
|
|
921
|
+
data: z.string().optional().describe('JSON string of fields for create/update'),
|
|
922
|
+
}).passthrough(),
|
|
889
923
|
},
|
|
890
924
|
),
|
|
891
925
|
)
|
|
@@ -931,13 +931,13 @@ const DelegatePlugin: Plugin = {
|
|
|
931
931
|
name: 'Core Delegate',
|
|
932
932
|
description: 'Delegate complex multi-file tasks to specialized CLI backends or other agents.',
|
|
933
933
|
hooks: {
|
|
934
|
-
getCapabilityDescription: () => 'I can hand off
|
|
935
|
-
getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate
|
|
934
|
+
getCapabilityDescription: () => 'I can hand off coding work to Claude Code, Codex, OpenCode, or Gemini CLI (`delegate`) for file creation, refactoring, debugging, code generation, and multi-file edits. Resume IDs may come back via `[delegate_meta]`.',
|
|
935
|
+
getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate for code tasks: writing/creating files, refactors, debugging, generation, test suites, data exports to files.'],
|
|
936
936
|
} as PluginHooks,
|
|
937
937
|
tools: [
|
|
938
938
|
{
|
|
939
939
|
name: 'delegate',
|
|
940
|
-
description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini). Supports background jobs with action=status|list|wait|cancel.',
|
|
940
|
+
description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini) for code tasks: writing files, refactoring, debugging, code generation, and multi-file edits. Supports background jobs with action=status|list|wait|cancel.',
|
|
941
941
|
parameters: {
|
|
942
942
|
type: 'object',
|
|
943
943
|
properties: {
|
|
@@ -45,6 +45,7 @@ import { buildExtractTools } from './extract'
|
|
|
45
45
|
import { buildTableTools } from './table'
|
|
46
46
|
import { buildCrawlTools } from './crawl'
|
|
47
47
|
import { buildGoogleWorkspaceTools } from './google-workspace'
|
|
48
|
+
import { buildSkillRuntimeTools } from './skill-runtime'
|
|
48
49
|
import './connector'
|
|
49
50
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
50
51
|
import { enforceFileAccessPolicy } from './file-access-policy'
|
|
@@ -172,6 +173,7 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
|
|
|
172
173
|
['calendar', buildCalendarTools],
|
|
173
174
|
['replicate', buildReplicateTools],
|
|
174
175
|
['google_workspace', buildGoogleWorkspaceTools],
|
|
176
|
+
['use_skill', buildSkillRuntimeTools],
|
|
175
177
|
['mailbox', buildMailboxTools],
|
|
176
178
|
['ask_human', buildHumanLoopTools],
|
|
177
179
|
['document', buildDocumentTools],
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { after, before, describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import type { Agent, Skill } from '@/types'
|
|
7
|
+
|
|
8
|
+
const originalEnv = {
|
|
9
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
10
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
11
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let tempDir = ''
|
|
15
|
+
let workspaceDir = ''
|
|
16
|
+
let buildCrudTools: Awaited<typeof import('./crud')>['buildCrudTools']
|
|
17
|
+
let loadSkills: Awaited<typeof import('../storage')>['loadSkills']
|
|
18
|
+
let loadAgent: Awaited<typeof import('../storage')>['loadAgent']
|
|
19
|
+
let saveAgents: Awaited<typeof import('../storage')>['saveAgents']
|
|
20
|
+
let saveSessions: Awaited<typeof import('../storage')>['saveSessions']
|
|
21
|
+
let upsertApproval: Awaited<typeof import('../storage')>['upsertApproval']
|
|
22
|
+
|
|
23
|
+
function buildManageSkillsTool() {
|
|
24
|
+
const tools = buildCrudTools({
|
|
25
|
+
cwd: workspaceDir,
|
|
26
|
+
ctx: { sessionId: 'skill-session', agentId: 'agent-skill-test', platformAssignScope: 'self' },
|
|
27
|
+
hasPlugin: (name) => name === 'manage_skills',
|
|
28
|
+
hasTool: (name) => name === 'manage_skills',
|
|
29
|
+
cleanupFns: [],
|
|
30
|
+
commandTimeoutMs: 1_000,
|
|
31
|
+
claudeTimeoutMs: 1_000,
|
|
32
|
+
cliProcessTimeoutMs: 1_000,
|
|
33
|
+
persistDelegateResumeId: () => {},
|
|
34
|
+
readStoredDelegateResumeId: () => null,
|
|
35
|
+
resolveCurrentSession: () => null,
|
|
36
|
+
activePlugins: ['manage_skills', 'google_workspace'],
|
|
37
|
+
})
|
|
38
|
+
const tool = tools.find((entry) => entry.name === 'manage_skills')
|
|
39
|
+
assert.ok(tool, 'expected manage_skills tool')
|
|
40
|
+
return tool!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
before(async () => {
|
|
44
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-manage-skills-'))
|
|
45
|
+
workspaceDir = path.join(tempDir, 'workspace')
|
|
46
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
47
|
+
process.env.WORKSPACE_DIR = workspaceDir
|
|
48
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
49
|
+
fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
|
|
50
|
+
fs.mkdirSync(workspaceDir, { recursive: true })
|
|
51
|
+
|
|
52
|
+
const crudMod = await import('./crud')
|
|
53
|
+
buildCrudTools = crudMod.buildCrudTools
|
|
54
|
+
|
|
55
|
+
const storageMod = await import('../storage')
|
|
56
|
+
loadSkills = storageMod.loadSkills
|
|
57
|
+
loadAgent = storageMod.loadAgent
|
|
58
|
+
saveAgents = storageMod.saveAgents
|
|
59
|
+
saveSessions = storageMod.saveSessions
|
|
60
|
+
upsertApproval = storageMod.upsertApproval
|
|
61
|
+
|
|
62
|
+
saveAgents({
|
|
63
|
+
'agent-skill-test': {
|
|
64
|
+
id: 'agent-skill-test',
|
|
65
|
+
name: 'Skill Tester',
|
|
66
|
+
provider: 'openai',
|
|
67
|
+
model: 'gpt-test',
|
|
68
|
+
plugins: ['manage_skills'],
|
|
69
|
+
tools: ['manage_skills'],
|
|
70
|
+
skillIds: [],
|
|
71
|
+
platformAssignScope: 'self',
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
updatedAt: Date.now(),
|
|
74
|
+
} satisfies Agent,
|
|
75
|
+
})
|
|
76
|
+
saveSessions({
|
|
77
|
+
'skill-session': {
|
|
78
|
+
id: 'skill-session',
|
|
79
|
+
name: 'Skill Session',
|
|
80
|
+
cwd: workspaceDir,
|
|
81
|
+
user: 'tester',
|
|
82
|
+
provider: 'openai',
|
|
83
|
+
model: 'gpt-test',
|
|
84
|
+
messages: [],
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
lastActiveAt: Date.now(),
|
|
87
|
+
sessionType: 'human',
|
|
88
|
+
agentId: 'agent-skill-test',
|
|
89
|
+
plugins: ['manage_skills'],
|
|
90
|
+
heartbeatEnabled: false,
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
after(() => {
|
|
96
|
+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
|
|
97
|
+
else process.env.DATA_DIR = originalEnv.DATA_DIR
|
|
98
|
+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
|
|
99
|
+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
|
|
100
|
+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
|
|
101
|
+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
|
|
102
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('manage_skills runtime actions', () => {
|
|
106
|
+
it('status reports resolved local skills with eligibility and metadata', async () => {
|
|
107
|
+
const manageSkills = buildManageSkillsTool()
|
|
108
|
+
const created = await manageSkills.invoke({
|
|
109
|
+
action: 'create',
|
|
110
|
+
name: 'workspace-helper',
|
|
111
|
+
description: 'Automate workspace docs.',
|
|
112
|
+
content: '# Workspace Helper\nUse the workspace workflow.',
|
|
113
|
+
toolNames: ['google_workspace'],
|
|
114
|
+
capabilities: ['docs', 'workspace'],
|
|
115
|
+
})
|
|
116
|
+
const createdSkill = JSON.parse(String(created)) as Skill
|
|
117
|
+
|
|
118
|
+
const raw = await manageSkills.invoke({ action: 'status', query: 'workspace docs' })
|
|
119
|
+
const result = JSON.parse(String(raw)) as Array<Record<string, unknown>>
|
|
120
|
+
|
|
121
|
+
const statusEntry = result.find((entry) => entry.storageId === createdSkill.id)
|
|
122
|
+
assert.ok(statusEntry)
|
|
123
|
+
assert.equal(statusEntry?.eligible, true)
|
|
124
|
+
assert.deepEqual(statusEntry?.toolNames, ['google_workspace'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('attach materializes a discovered project skill and binds it to the current agent', async () => {
|
|
128
|
+
const localSkillDir = path.join(workspaceDir, 'skills', 'project-helper')
|
|
129
|
+
fs.mkdirSync(localSkillDir, { recursive: true })
|
|
130
|
+
fs.writeFileSync(path.join(localSkillDir, 'SKILL.md'), `---
|
|
131
|
+
name: project-helper
|
|
132
|
+
description: Project-local helper.
|
|
133
|
+
metadata:
|
|
134
|
+
openclaw:
|
|
135
|
+
toolNames: [google_workspace]
|
|
136
|
+
---
|
|
137
|
+
# Project Helper
|
|
138
|
+
|
|
139
|
+
Use the project helper.
|
|
140
|
+
`)
|
|
141
|
+
|
|
142
|
+
const manageSkills = buildManageSkillsTool()
|
|
143
|
+
const raw = await manageSkills.invoke({
|
|
144
|
+
action: 'attach',
|
|
145
|
+
name: 'project-helper',
|
|
146
|
+
})
|
|
147
|
+
const result = JSON.parse(String(raw)) as Record<string, unknown>
|
|
148
|
+
const skillId = String(result.skillId || '')
|
|
149
|
+
const agent = loadAgent('agent-skill-test') as Agent
|
|
150
|
+
|
|
151
|
+
assert.ok(skillId)
|
|
152
|
+
assert.ok(loadSkills()[skillId], 'discovered skill copied into managed storage')
|
|
153
|
+
assert.ok(agent.skillIds?.includes(skillId), 'skill attached to current agent')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('install is approval-gated and can install a remote skill after approval', async () => {
|
|
157
|
+
const manageSkills = buildManageSkillsTool()
|
|
158
|
+
const firstRaw = await manageSkills.invoke({
|
|
159
|
+
action: 'install',
|
|
160
|
+
name: 'remote-helper',
|
|
161
|
+
url: 'https://clawhub.ai/skills/remote-helper',
|
|
162
|
+
content: '# Remote Helper\nUse the remote helper.',
|
|
163
|
+
attach: true,
|
|
164
|
+
})
|
|
165
|
+
const first = JSON.parse(String(firstRaw)) as Record<string, unknown>
|
|
166
|
+
const approval = first.approval as { id: string }
|
|
167
|
+
|
|
168
|
+
assert.equal(first.requiresApproval, true)
|
|
169
|
+
assert.ok(approval?.id)
|
|
170
|
+
|
|
171
|
+
upsertApproval(approval.id, {
|
|
172
|
+
...(first.approval as Record<string, unknown>),
|
|
173
|
+
status: 'approved',
|
|
174
|
+
updatedAt: Date.now(),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const secondRaw = await manageSkills.invoke({
|
|
178
|
+
action: 'install',
|
|
179
|
+
name: 'remote-helper',
|
|
180
|
+
url: 'https://clawhub.ai/skills/remote-helper',
|
|
181
|
+
content: '# Remote Helper\nUse the remote helper.',
|
|
182
|
+
attach: true,
|
|
183
|
+
approvalId: approval.id,
|
|
184
|
+
})
|
|
185
|
+
const second = JSON.parse(String(secondRaw)) as Record<string, unknown>
|
|
186
|
+
const installedSkill = second.skill as Skill
|
|
187
|
+
const agent = loadAgent('agent-skill-test') as Agent
|
|
188
|
+
|
|
189
|
+
assert.equal(second.ok, true)
|
|
190
|
+
assert.ok(installedSkill?.id)
|
|
191
|
+
assert.ok(loadSkills()[installedSkill.id])
|
|
192
|
+
assert.ok(agent.skillIds?.includes(installedSkill.id), 'approved install can attach to the agent')
|
|
193
|
+
})
|
|
194
|
+
})
|