@swarmclawai/swarmclaw 1.9.1 → 1.9.3
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 +21 -1
- package/electron-dist/main.js +218 -0
- package/package.json +3 -3
- package/scripts/ensure-sandbox-browser-image.mjs +12 -2
- package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
- package/src/app/api/extensions/managed-resources/route.ts +116 -0
- package/src/app/api/knowledge/hygiene/route.ts +19 -1
- package/src/app/api/portability/export/route.test.ts +17 -0
- package/src/app/api/portability/export/route.ts +11 -2
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/lib/server/agents/delegation-advisory.test.ts +1 -0
- package/src/lib/server/agents/delegation-advisory.ts +10 -0
- package/src/lib/server/chat-execution/iteration-event-handler.ts +24 -8
- package/src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts +117 -0
- package/src/lib/server/chat-execution/reasoning-tag-scrubber.ts +219 -0
- package/src/lib/server/extension-managed-resources.test.ts +159 -0
- package/src/lib/server/extension-managed-resources.ts +905 -0
- package/src/lib/server/extensions.ts +113 -2
- package/src/lib/server/knowledge-sources.test.ts +45 -0
- package/src/lib/server/knowledge-sources.ts +33 -0
- package/src/lib/server/portability/export.ts +10 -0
- package/src/lib/server/session-tools/crud.ts +25 -2
- package/src/lib/server/session-tools/extension-creator.ts +50 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +7 -2
- package/src/lib/server/tasks/task-route-service.ts +18 -1
- package/src/lib/server/tasks/task-service.test.ts +60 -2
- package/src/lib/server/tasks/task-service.ts +35 -0
- package/src/lib/validation/schemas.ts +2 -0
- package/src/types/agent.ts +2 -0
- package/src/types/app-settings.ts +8 -0
- package/src/types/extension.ts +132 -0
- package/src/types/misc.ts +1 -1
- package/src/types/schedule.ts +3 -0
- package/src/views/settings/extension-manager.tsx +157 -1
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ExtensionUIDefinition,
|
|
12
12
|
ExtensionProviderDefinition,
|
|
13
13
|
ExtensionConnectorDefinition,
|
|
14
|
+
ExtensionManagedResources,
|
|
14
15
|
Session,
|
|
15
16
|
ExtensionPackageManager,
|
|
16
17
|
ExtensionDependencyInstallStatus,
|
|
@@ -467,11 +468,60 @@ function coerceTools(rawTools: unknown): ExtensionToolDef[] {
|
|
|
467
468
|
return []
|
|
468
469
|
}
|
|
469
470
|
|
|
471
|
+
function coerceManagedResources(raw: Record<string, unknown>): ExtensionManagedResources | undefined {
|
|
472
|
+
const explicit = isRecord(raw.managedResources)
|
|
473
|
+
? raw.managedResources as Record<string, unknown>
|
|
474
|
+
: {}
|
|
475
|
+
const agents = Array.isArray(explicit.agents)
|
|
476
|
+
? explicit.agents
|
|
477
|
+
: Array.isArray(raw.agents)
|
|
478
|
+
? raw.agents
|
|
479
|
+
: undefined
|
|
480
|
+
const schedules = Array.isArray(explicit.schedules)
|
|
481
|
+
? explicit.schedules
|
|
482
|
+
: Array.isArray(raw.schedules)
|
|
483
|
+
? raw.schedules
|
|
484
|
+
: undefined
|
|
485
|
+
const routines = Array.isArray(explicit.routines)
|
|
486
|
+
? explicit.routines
|
|
487
|
+
: Array.isArray(raw.routines)
|
|
488
|
+
? raw.routines
|
|
489
|
+
: undefined
|
|
490
|
+
const localFolders = Array.isArray(explicit.localFolders)
|
|
491
|
+
? explicit.localFolders
|
|
492
|
+
: Array.isArray(raw.localFolders)
|
|
493
|
+
? raw.localFolders
|
|
494
|
+
: undefined
|
|
495
|
+
const gatewayPlatforms = Array.isArray(explicit.gatewayPlatforms)
|
|
496
|
+
? explicit.gatewayPlatforms
|
|
497
|
+
: Array.isArray(raw.gatewayPlatforms)
|
|
498
|
+
? raw.gatewayPlatforms
|
|
499
|
+
: undefined
|
|
500
|
+
const setupChecks = Array.isArray(explicit.setupChecks)
|
|
501
|
+
? explicit.setupChecks
|
|
502
|
+
: Array.isArray(raw.setupChecks)
|
|
503
|
+
? raw.setupChecks
|
|
504
|
+
: undefined
|
|
505
|
+
|
|
506
|
+
const managedResources: ExtensionManagedResources = {
|
|
507
|
+
agents: agents as ExtensionManagedResources['agents'],
|
|
508
|
+
schedules: schedules as ExtensionManagedResources['schedules'],
|
|
509
|
+
routines: routines as ExtensionManagedResources['routines'],
|
|
510
|
+
localFolders: localFolders as ExtensionManagedResources['localFolders'],
|
|
511
|
+
gatewayPlatforms: gatewayPlatforms as ExtensionManagedResources['gatewayPlatforms'],
|
|
512
|
+
setupChecks: setupChecks as ExtensionManagedResources['setupChecks'],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)
|
|
516
|
+
? managedResources
|
|
517
|
+
: undefined
|
|
518
|
+
}
|
|
519
|
+
|
|
470
520
|
function normalizeExtension(mod: unknown): Extension | null {
|
|
471
521
|
const modObj = mod as Record<string, unknown>
|
|
472
522
|
const raw: Record<string, unknown> = (modObj?.default as Record<string, unknown>) || modObj
|
|
473
523
|
|
|
474
|
-
if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors)) {
|
|
524
|
+
if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors || raw.managedResources || raw.agents || raw.schedules || raw.routines || raw.localFolders || raw.gatewayPlatforms || raw.setupChecks)) {
|
|
475
525
|
const hooks = isRecord(raw.hooks) ? (raw.hooks as ExtensionHooks) : {}
|
|
476
526
|
return {
|
|
477
527
|
name: raw.name as string,
|
|
@@ -484,6 +534,7 @@ function normalizeExtension(mod: unknown): Extension | null {
|
|
|
484
534
|
ui: isRecord(raw.ui) ? (raw.ui as ExtensionUIDefinition) : undefined,
|
|
485
535
|
providers: Array.isArray(raw.providers) ? (raw.providers as ExtensionProviderDefinition[]) : undefined,
|
|
486
536
|
connectors: Array.isArray(raw.connectors) ? (raw.connectors as ExtensionConnectorDefinition[]) : undefined,
|
|
537
|
+
managedResources: coerceManagedResources(raw),
|
|
487
538
|
} as Extension
|
|
488
539
|
}
|
|
489
540
|
|
|
@@ -639,6 +690,7 @@ interface LoadedExtension {
|
|
|
639
690
|
ui?: ExtensionUIDefinition
|
|
640
691
|
providers?: ExtensionProviderDefinition[]
|
|
641
692
|
connectors?: ExtensionConnectorDefinition[]
|
|
693
|
+
managedResources?: ExtensionManagedResources
|
|
642
694
|
isBuiltin?: boolean
|
|
643
695
|
}
|
|
644
696
|
|
|
@@ -1017,6 +1069,7 @@ class ExtensionManager {
|
|
|
1017
1069
|
ui: p.ui,
|
|
1018
1070
|
providers: p.providers,
|
|
1019
1071
|
connectors: p.connectors,
|
|
1072
|
+
managedResources: p.managedResources || coerceManagedResources(p as unknown as Record<string, unknown>),
|
|
1020
1073
|
isBuiltin: true
|
|
1021
1074
|
})
|
|
1022
1075
|
this.markExtensionSuccess(id)
|
|
@@ -1064,6 +1117,7 @@ class ExtensionManager {
|
|
|
1064
1117
|
ui: ext.ui,
|
|
1065
1118
|
providers: ext.providers,
|
|
1066
1119
|
connectors: ext.connectors,
|
|
1120
|
+
managedResources: ext.managedResources,
|
|
1067
1121
|
})
|
|
1068
1122
|
this.markExtensionSuccess(file)
|
|
1069
1123
|
} catch (err: unknown) {
|
|
@@ -1145,6 +1199,55 @@ class ExtensionManager {
|
|
|
1145
1199
|
return allUI
|
|
1146
1200
|
}
|
|
1147
1201
|
|
|
1202
|
+
getManagedResourceExtensions(): Array<{
|
|
1203
|
+
extensionId: string
|
|
1204
|
+
extensionName: string
|
|
1205
|
+
enabled: boolean
|
|
1206
|
+
isBuiltin: boolean
|
|
1207
|
+
source?: ExtensionMeta['source']
|
|
1208
|
+
managedResources: ExtensionManagedResources
|
|
1209
|
+
}> {
|
|
1210
|
+
this.load()
|
|
1211
|
+
const result: Array<{
|
|
1212
|
+
extensionId: string
|
|
1213
|
+
extensionName: string
|
|
1214
|
+
enabled: boolean
|
|
1215
|
+
isBuiltin: boolean
|
|
1216
|
+
source?: ExtensionMeta['source']
|
|
1217
|
+
managedResources: ExtensionManagedResources
|
|
1218
|
+
}> = []
|
|
1219
|
+
|
|
1220
|
+
for (const [id, entry] of this.extensions.entries()) {
|
|
1221
|
+
const managedResources = entry.managedResources
|
|
1222
|
+
if (!managedResources) continue
|
|
1223
|
+
if (!Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)) continue
|
|
1224
|
+
result.push({
|
|
1225
|
+
extensionId: id,
|
|
1226
|
+
extensionName: entry.meta.name,
|
|
1227
|
+
enabled: entry.meta.enabled,
|
|
1228
|
+
isBuiltin: entry.isBuiltin === true,
|
|
1229
|
+
source: entry.meta.source,
|
|
1230
|
+
managedResources,
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return result
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
getManagedResources(extensionId: string): ExtensionManagedResources | null {
|
|
1238
|
+
this.load()
|
|
1239
|
+
const candidateIds = expandExtensionIds([extensionId])
|
|
1240
|
+
for (const id of candidateIds) {
|
|
1241
|
+
const loaded = this.extensions.get(id)
|
|
1242
|
+
if (loaded?.managedResources) return loaded.managedResources
|
|
1243
|
+
const builtin = this.builtins.get(id)
|
|
1244
|
+
if (builtin) {
|
|
1245
|
+
return builtin.managedResources || coerceManagedResources(builtin as unknown as Record<string, unknown>) || null
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return null
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1148
1251
|
listExtensionIds(): string[] {
|
|
1149
1252
|
this.load()
|
|
1150
1253
|
return Array.from(this.extensions.keys())
|
|
@@ -1842,11 +1945,14 @@ class ExtensionManager {
|
|
|
1842
1945
|
const failures = this.readFailureState()
|
|
1843
1946
|
const metas: ExtensionMeta[] = []
|
|
1844
1947
|
|
|
1845
|
-
const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields'> => {
|
|
1948
|
+
const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields' | 'managedAgentCount' | 'managedScheduleCount' | 'localFolderCount' | 'gatewayPlatformCount' | 'setupCheckCount'> => {
|
|
1846
1949
|
const tools = loaded?.tools || fallback?.tools || []
|
|
1847
1950
|
const hooks = loaded?.hooks || fallback?.hooks || {}
|
|
1848
1951
|
const providers = loaded?.providers || fallback?.providers || []
|
|
1849
1952
|
const connectors = loaded?.connectors || fallback?.connectors || []
|
|
1953
|
+
const managedResources = loaded?.managedResources
|
|
1954
|
+
|| fallback?.managedResources
|
|
1955
|
+
|| (fallback ? coerceManagedResources(fallback as unknown as Record<string, unknown>) : undefined)
|
|
1850
1956
|
const hasUi = !!(loaded?.ui || fallback?.ui)
|
|
1851
1957
|
const settingsFields = loaded?.ui?.settingsFields || fallback?.ui?.settingsFields
|
|
1852
1958
|
return {
|
|
@@ -1855,6 +1961,11 @@ class ExtensionManager {
|
|
|
1855
1961
|
hasUI: hasUi,
|
|
1856
1962
|
providerCount: Array.isArray(providers) ? providers.length : 0,
|
|
1857
1963
|
connectorCount: Array.isArray(connectors) ? connectors.length : 0,
|
|
1964
|
+
managedAgentCount: managedResources?.agents?.length || 0,
|
|
1965
|
+
managedScheduleCount: (managedResources?.schedules?.length || 0) + (managedResources?.routines?.length || 0),
|
|
1966
|
+
localFolderCount: managedResources?.localFolders?.length || 0,
|
|
1967
|
+
gatewayPlatformCount: managedResources?.gatewayPlatforms?.length || 0,
|
|
1968
|
+
setupCheckCount: managedResources?.setupChecks?.length || 0,
|
|
1858
1969
|
settingsFields: settingsFields?.length ? settingsFields : undefined,
|
|
1859
1970
|
}
|
|
1860
1971
|
}
|
|
@@ -259,3 +259,48 @@ test('runKnowledgeHygieneMaintenance keeps same-content sources separate when vi
|
|
|
259
259
|
assert.equal(output.agent1Hits, 2)
|
|
260
260
|
assert.equal(output.agent2Hits, 1)
|
|
261
261
|
})
|
|
262
|
+
|
|
263
|
+
test('pruneArchivedKnowledgeSources deletes old archived sources and records prune actions', () => {
|
|
264
|
+
const output = runWithTempDataDir<{
|
|
265
|
+
pruned: number
|
|
266
|
+
sourceStillExists: boolean
|
|
267
|
+
hitCount: number
|
|
268
|
+
actionKind: string | null
|
|
269
|
+
}>(`
|
|
270
|
+
const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
|
|
271
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
272
|
+
const knowledge = knowledgeMod.default || knowledgeMod
|
|
273
|
+
const storage = storageMod.default || storageMod
|
|
274
|
+
|
|
275
|
+
const source = await knowledge.createKnowledgeSource({
|
|
276
|
+
kind: 'manual',
|
|
277
|
+
title: 'Old Archived Notes',
|
|
278
|
+
content: 'prune candidate payload',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await knowledge.archiveKnowledgeSource(source.source.id, { reason: 'obsolete' })
|
|
282
|
+
const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000
|
|
283
|
+
storage.patchKnowledgeSource(source.source.id, (current) => current ? {
|
|
284
|
+
...current,
|
|
285
|
+
archivedAt: oldTimestamp,
|
|
286
|
+
maintenanceUpdatedAt: oldTimestamp,
|
|
287
|
+
updatedAt: oldTimestamp,
|
|
288
|
+
} : null)
|
|
289
|
+
|
|
290
|
+
const result = await knowledge.pruneArchivedKnowledgeSources({ olderThanDays: 30 })
|
|
291
|
+
const summary = await knowledge.getKnowledgeHygieneSummary()
|
|
292
|
+
const hits = await knowledge.searchKnowledgeHits({ query: 'candidate', includeArchived: true })
|
|
293
|
+
|
|
294
|
+
console.log(JSON.stringify({
|
|
295
|
+
pruned: result.pruned,
|
|
296
|
+
sourceStillExists: Boolean(storage.loadKnowledgeSource(source.source.id)),
|
|
297
|
+
hitCount: hits.length,
|
|
298
|
+
actionKind: summary.recentActions.find((action) => action.sourceId === source.source.id)?.kind || null,
|
|
299
|
+
}))
|
|
300
|
+
`, { prefix: 'swarmclaw-knowledge-prune-' })
|
|
301
|
+
|
|
302
|
+
assert.equal(output.pruned, 1)
|
|
303
|
+
assert.equal(output.sourceStillExists, false)
|
|
304
|
+
assert.equal(output.hitCount, 0)
|
|
305
|
+
assert.equal(output.actionKind, 'prune')
|
|
306
|
+
})
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { onNextIdleWindow } from '@/lib/server/runtime/idle-window'
|
|
32
32
|
|
|
33
33
|
const KNOWLEDGE_STALE_AFTER_MS = 1000 * 60 * 60 * 24 * 14
|
|
34
|
+
const DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS = 30
|
|
34
35
|
const CHUNK_TARGET_CHARS = 2200
|
|
35
36
|
const CHUNK_OVERLAP_CHARS = 320
|
|
36
37
|
const MAX_KNOWLEDGE_SCAN = 10_000
|
|
@@ -1049,6 +1050,38 @@ export async function supersedeKnowledgeSource(
|
|
|
1049
1050
|
return getKnowledgeSourceDetail(updated.id)
|
|
1050
1051
|
}
|
|
1051
1052
|
|
|
1053
|
+
export async function pruneArchivedKnowledgeSources(input?: {
|
|
1054
|
+
olderThanDays?: number | null
|
|
1055
|
+
now?: number
|
|
1056
|
+
}): Promise<{ pruned: number; sourceIds: string[] }> {
|
|
1057
|
+
await ensureLegacyKnowledgeBackfill()
|
|
1058
|
+
const now = typeof input?.now === 'number' && Number.isFinite(input.now) ? input.now : Date.now()
|
|
1059
|
+
const olderThanDays = typeof input?.olderThanDays === 'number' && Number.isFinite(input.olderThanDays)
|
|
1060
|
+
? Math.max(1, Math.trunc(input.olderThanDays))
|
|
1061
|
+
: DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS
|
|
1062
|
+
const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
|
|
1063
|
+
const sourceIds: string[] = []
|
|
1064
|
+
|
|
1065
|
+
for (const source of listStoredSources()) {
|
|
1066
|
+
if (!sourceIsArchived(source) && !sourceIsSuperseded(source)) continue
|
|
1067
|
+
const lifecycleAt = source.archivedAt || source.maintenanceUpdatedAt || source.updatedAt
|
|
1068
|
+
if (lifecycleAt > cutoff) continue
|
|
1069
|
+
const title = source.title
|
|
1070
|
+
const removed = await deleteKnowledgeSource(source.id)
|
|
1071
|
+
if (!removed) continue
|
|
1072
|
+
sourceIds.push(source.id)
|
|
1073
|
+
recordMaintenanceAction({
|
|
1074
|
+
kind: 'prune',
|
|
1075
|
+
sourceId: source.id,
|
|
1076
|
+
relatedSourceId: source.duplicateOfSourceId || source.supersededBySourceId || null,
|
|
1077
|
+
summary: `Pruned ${title}`,
|
|
1078
|
+
createdAt: now,
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return { pruned: sourceIds.length, sourceIds }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1052
1085
|
function sameSourceOrigin(left: KnowledgeSource, right: KnowledgeSource): boolean {
|
|
1053
1086
|
if (left.id === right.id) return false
|
|
1054
1087
|
if (left.sourceUrl && right.sourceUrl) return left.sourceUrl === right.sourceUrl
|
|
@@ -34,6 +34,16 @@ export interface PortableManifest {
|
|
|
34
34
|
extensions?: PortableExtensionRef[]
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export function buildPortableExportFilename(manifest: Pick<PortableManifest, 'exportedAt'> = { exportedAt: new Date().toISOString() }): string {
|
|
38
|
+
const safeStamp = manifest.exportedAt
|
|
39
|
+
.replaceAll(':', '')
|
|
40
|
+
.replaceAll('.', '')
|
|
41
|
+
.replaceAll('-', '')
|
|
42
|
+
.replace('T', '-')
|
|
43
|
+
.replace('Z', 'Z')
|
|
44
|
+
return `swarmclaw-export-${safeStamp}.json`
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export type PortableAgent = Omit<Agent,
|
|
38
48
|
| 'id' | 'credentialId' | 'fallbackCredentialIds' | 'apiEndpoint'
|
|
39
49
|
| 'threadSessionId' | 'lastUsedAt' | 'totalCost' | 'trashedAt'
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
buildDelegationTaskProfile,
|
|
44
44
|
formatDelegationRationale,
|
|
45
45
|
resolveDelegationAdvisory,
|
|
46
|
+
type DelegationWorkType,
|
|
46
47
|
} from '@/lib/server/agents/delegation-advisory'
|
|
47
48
|
import type { ToolBuildContext } from './context'
|
|
48
49
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
@@ -102,6 +103,17 @@ function buildTaskDelegationText(parsed: Record<string, unknown>): string {
|
|
|
102
103
|
return [title, description].filter(Boolean).join('\n\n').trim()
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
function normalizeDelegationWorkType(value: unknown): DelegationWorkType | null {
|
|
107
|
+
return value === 'coding'
|
|
108
|
+
|| value === 'research'
|
|
109
|
+
|| value === 'writing'
|
|
110
|
+
|| value === 'review'
|
|
111
|
+
|| value === 'operations'
|
|
112
|
+
|| value === 'general'
|
|
113
|
+
? value
|
|
114
|
+
: null
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
async function resolveManagedTaskDelegation(params: {
|
|
106
118
|
parsed: Record<string, unknown>
|
|
107
119
|
agents: ReturnType<typeof loadAgents>
|
|
@@ -122,9 +134,11 @@ async function resolveManagedTaskDelegation(params: {
|
|
|
122
134
|
return { assignedAgentId: params.assignedAgentId, advisory: null }
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
const decisionStartedAt = Date.now()
|
|
125
138
|
const explicitCapabilities = normalizeStringList(params.parsed.requiredCapabilities)
|
|
139
|
+
const explicitWorkType = normalizeDelegationWorkType(params.parsed.workType)
|
|
126
140
|
const classificationText = buildTaskDelegationText(params.parsed)
|
|
127
|
-
const classification = (!explicitCapabilities.length && classificationText && params.ctx?.sessionId)
|
|
141
|
+
const classification = (!explicitCapabilities.length && !explicitWorkType && classificationText && params.ctx?.sessionId)
|
|
128
142
|
? await classifyMessage({
|
|
129
143
|
sessionId: params.ctx.sessionId,
|
|
130
144
|
agentId: currentAgentId,
|
|
@@ -134,6 +148,7 @@ async function resolveManagedTaskDelegation(params: {
|
|
|
134
148
|
|
|
135
149
|
const profile = buildDelegationTaskProfile({
|
|
136
150
|
classification,
|
|
151
|
+
workType: explicitWorkType,
|
|
137
152
|
requiredCapabilities: explicitCapabilities,
|
|
138
153
|
})
|
|
139
154
|
if (!profile.substantial) {
|
|
@@ -167,10 +182,18 @@ async function resolveManagedTaskDelegation(params: {
|
|
|
167
182
|
advisory: {
|
|
168
183
|
recommendedAgentId: recommended.agentId,
|
|
169
184
|
recommendedAgentName: recommended.agentName,
|
|
185
|
+
routeKey: recommended.routeKey,
|
|
170
186
|
rationale: formatDelegationRationale(recommended),
|
|
171
187
|
workType: profile.workType,
|
|
172
188
|
requiredCapabilities: profile.requiredCapabilities,
|
|
173
189
|
autoAssigned,
|
|
190
|
+
routingMode: explicitCapabilities.length || explicitWorkType
|
|
191
|
+
? 'deterministic'
|
|
192
|
+
: classification
|
|
193
|
+
? 'classified'
|
|
194
|
+
: 'default',
|
|
195
|
+
decisionLatencyMs: Date.now() - decisionStartedAt,
|
|
196
|
+
score: Number(recommended.score.toFixed(3)),
|
|
174
197
|
},
|
|
175
198
|
}
|
|
176
199
|
}
|
|
@@ -465,7 +488,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
465
488
|
}
|
|
466
489
|
description += '\n\nCreate/update calls accept either `data` as a JSON string or direct top-level fields like `title`, `description`, `status`, `agentId`, and `projectId`.'
|
|
467
490
|
if (canAssignOtherAgents) {
|
|
468
|
-
description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities` or the classified work type. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
|
|
491
|
+
description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities`, explicit `workType`, or the classified work type. Use `workType:"coding"|"research"|"writing"|"review"|"operations"|"general"` for deterministic routing without a classifier call. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
|
|
469
492
|
}
|
|
470
493
|
description += '\n\nFor follow-up work, set `continueFromTaskId` (or `followUpToTaskId`) to a prior task ID. The new task will inherit the predecessor\'s project/agent/session context, block on that task by default, and reuse its execution session when possible.'
|
|
471
494
|
if (ctx?.projectId) {
|
|
@@ -138,6 +138,54 @@ module.exports = {
|
|
|
138
138
|
}
|
|
139
139
|
],
|
|
140
140
|
|
|
141
|
+
// --- Managed Resources (Paperclip-compatible) ---
|
|
142
|
+
managedResources: {
|
|
143
|
+
agents: [
|
|
144
|
+
{
|
|
145
|
+
agentKey: 'researcher',
|
|
146
|
+
displayName: 'Managed Researcher',
|
|
147
|
+
description: 'Reusable agent this extension can provision.',
|
|
148
|
+
systemPrompt: 'Research carefully and cite durable evidence.',
|
|
149
|
+
provider: 'openai',
|
|
150
|
+
model: 'gpt-4o-mini',
|
|
151
|
+
capabilities: ['research', 'analysis'],
|
|
152
|
+
extensions: ['web', 'memory']
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
schedules: [
|
|
156
|
+
{
|
|
157
|
+
scheduleKey: 'daily-digest',
|
|
158
|
+
displayName: 'Daily Digest',
|
|
159
|
+
agentRef: { resourceKind: 'agent', resourceKey: 'researcher' },
|
|
160
|
+
taskPrompt: 'Prepare the daily digest.',
|
|
161
|
+
scheduleType: 'cron',
|
|
162
|
+
cron: '0 9 * * *',
|
|
163
|
+
timezone: 'UTC',
|
|
164
|
+
status: 'paused'
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
localFolders: [
|
|
168
|
+
{
|
|
169
|
+
folderKey: 'workspace',
|
|
170
|
+
displayName: 'Workspace Folder',
|
|
171
|
+
access: 'readWrite',
|
|
172
|
+
requiredDirectories: ['inputs', 'outputs']
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
gatewayPlatforms: [
|
|
176
|
+
{
|
|
177
|
+
platformKey: 'openai-compatible-api',
|
|
178
|
+
displayName: 'OpenAI-compatible API',
|
|
179
|
+
transport: 'http',
|
|
180
|
+
endpoint: 'http://127.0.0.1:8642/v1',
|
|
181
|
+
authMode: 'bearer'
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
setupChecks: [
|
|
185
|
+
{ checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY', required: true }
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
|
|
141
189
|
// --- Real OpenClaw Format (register API) ---
|
|
142
190
|
register(api) {
|
|
143
191
|
api.registerHook('agent:start', (ctx) => {
|
|
@@ -162,6 +210,8 @@ Key rules:
|
|
|
162
210
|
- If your extension needs npm/pnpm/yarn/bun packages, include a packageJson object during scaffold or call install_dependencies later.
|
|
163
211
|
- Dependency installs are run by the extension manager inside a per-extension workspace using the selected package manager with scripts disabled.
|
|
164
212
|
- Extension settings are declared through ui.settingsFields and stored per extension ID
|
|
213
|
+
- Managed resources let an extension declare provisionable agents, schedules/routines, trusted local folders, gateway platforms, and setup checks. Operators reconcile them through Extensions > Managed Resources or /api/extensions/managed-resources.
|
|
214
|
+
- Paperclip-compatible top-level agents, routines, and localFolders are also accepted; SwarmClaw reconciles routines as schedules when they include schedule timing.
|
|
165
215
|
- Keep extensions focused: one clear purpose per extension
|
|
166
216
|
`
|
|
167
217
|
}
|
|
@@ -172,7 +172,7 @@ describe('manage_tasks tool', () => {
|
|
|
172
172
|
action: 'create',
|
|
173
173
|
title: 'Implement API integration',
|
|
174
174
|
description: 'Build the API client and fix the failing tests.',
|
|
175
|
-
|
|
175
|
+
workType: 'coding',
|
|
176
176
|
status: 'backlog',
|
|
177
177
|
})
|
|
178
178
|
|
|
@@ -185,7 +185,11 @@ describe('manage_tasks tool', () => {
|
|
|
185
185
|
assert.equal(output.stored.agentId, 'builder')
|
|
186
186
|
assert.equal(output.response.delegationAdvisory.autoAssigned, true)
|
|
187
187
|
assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
|
|
188
|
-
assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding'])
|
|
188
|
+
assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding', 'implementation', 'debugging'])
|
|
189
|
+
assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
|
|
190
|
+
assert.match(output.response.delegationAdvisory.routeKey, /^coding:coding,debugging,implementation:builder$/)
|
|
191
|
+
assert.equal(typeof output.response.delegationAdvisory.decisionLatencyMs, 'number')
|
|
192
|
+
assert.equal(output.stored.workflowStateId, 'in_progress')
|
|
189
193
|
})
|
|
190
194
|
|
|
191
195
|
it('keeps an explicit assignee but returns delegation advisory when another teammate is a better fit', () => {
|
|
@@ -262,5 +266,6 @@ describe('manage_tasks tool', () => {
|
|
|
262
266
|
assert.equal(output.stored.agentId, 'researcher')
|
|
263
267
|
assert.equal(output.response.delegationAdvisory.autoAssigned, false)
|
|
264
268
|
assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
|
|
269
|
+
assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
|
|
265
270
|
})
|
|
266
271
|
})
|
|
@@ -28,7 +28,11 @@ import {
|
|
|
28
28
|
type PrepareTaskExecutionWorkspaceOptions,
|
|
29
29
|
} from '@/lib/server/tasks/task-execution-workspace'
|
|
30
30
|
import { resolveTaskAgentFromDescription } from '@/lib/server/tasks/task-mention'
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
applyTaskPatch,
|
|
33
|
+
prepareTaskCreation,
|
|
34
|
+
resolveAssignmentWorkflowStateTransition,
|
|
35
|
+
} from '@/lib/server/tasks/task-service'
|
|
32
36
|
import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
|
|
33
37
|
import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
|
|
34
38
|
import { notify } from '@/lib/server/ws-hub'
|
|
@@ -427,12 +431,25 @@ export function bulkUpdateTasksFromRoute(body: Record<string, unknown>): Service
|
|
|
427
431
|
}
|
|
428
432
|
}
|
|
429
433
|
if ('agentId' in body) {
|
|
434
|
+
const previousAgentId = tasks[id].agentId
|
|
435
|
+
const previousWorkflowStateId = tasks[id].workflowStateId || null
|
|
430
436
|
tasks[id].agentId = body.agentId === null ? '' : String(body.agentId)
|
|
437
|
+
const workflowTransition = resolveAssignmentWorkflowStateTransition({
|
|
438
|
+
previousAgentId,
|
|
439
|
+
nextAgentId: tasks[id].agentId,
|
|
440
|
+
previousWorkflowStateId,
|
|
441
|
+
explicitWorkflowState: Object.prototype.hasOwnProperty.call(body, 'workflowStateId'),
|
|
442
|
+
})
|
|
443
|
+
if (workflowTransition) tasks[id].workflowStateId = workflowTransition
|
|
431
444
|
}
|
|
432
445
|
if ('projectId' in body) {
|
|
433
446
|
if (body.projectId === null) delete tasks[id].projectId
|
|
434
447
|
else tasks[id].projectId = String(body.projectId)
|
|
435
448
|
}
|
|
449
|
+
if ('workflowStateId' in body) {
|
|
450
|
+
if (body.workflowStateId === null) delete tasks[id].workflowStateId
|
|
451
|
+
else tasks[id].workflowStateId = String(body.workflowStateId)
|
|
452
|
+
}
|
|
436
453
|
tasks[id].updatedAt = Date.now()
|
|
437
454
|
updated += 1
|
|
438
455
|
results.push(id)
|
|
@@ -3,8 +3,11 @@ import { describe, it } from 'node:test'
|
|
|
3
3
|
|
|
4
4
|
import { computeTaskFingerprint } from '@/lib/task-dedupe'
|
|
5
5
|
import type { BoardTask } from '@/types'
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
applyTaskPatch,
|
|
8
|
+
prepareTaskCreation,
|
|
9
|
+
resolveAssignmentWorkflowStateTransition,
|
|
10
|
+
} from '@/lib/server/tasks/task-service'
|
|
8
11
|
|
|
9
12
|
function makeTask(overrides: Partial<BoardTask> = {}): BoardTask {
|
|
10
13
|
return {
|
|
@@ -106,3 +109,58 @@ describe('task service helpers', () => {
|
|
|
106
109
|
assert.equal(task.status, 'queued')
|
|
107
110
|
})
|
|
108
111
|
})
|
|
112
|
+
|
|
113
|
+
describe('task-service assignment workflow transitions', () => {
|
|
114
|
+
it('moves newly assigned backlog workflow tasks to in_progress without queueing runtime work', () => {
|
|
115
|
+
const task = makeTask({ agentId: '', workflowStateId: 'backlog' })
|
|
116
|
+
applyTaskPatch({
|
|
117
|
+
task,
|
|
118
|
+
patch: { agentId: 'agent-builder' },
|
|
119
|
+
now: 100,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
assert.equal(task.agentId, 'agent-builder')
|
|
123
|
+
assert.equal(task.status, 'backlog')
|
|
124
|
+
assert.equal(task.workflowStateId, 'in_progress')
|
|
125
|
+
assert.equal(task.updatedAt, 100)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('preserves explicit workflow state patches', () => {
|
|
129
|
+
const task = makeTask({ workflowStateId: 'todo' })
|
|
130
|
+
applyTaskPatch({
|
|
131
|
+
task,
|
|
132
|
+
patch: { agentId: 'agent-builder', workflowStateId: 'needs_review' },
|
|
133
|
+
now: 100,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
assert.equal(task.workflowStateId, 'needs_review')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('seeds assigned task creation into the in_progress workflow lane', () => {
|
|
140
|
+
const prepared = prepareTaskCreation({
|
|
141
|
+
input: {
|
|
142
|
+
title: 'Build the client',
|
|
143
|
+
description: '',
|
|
144
|
+
agentId: 'builder',
|
|
145
|
+
},
|
|
146
|
+
tasks: {},
|
|
147
|
+
now: 200,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
assert.equal(prepared.ok, true)
|
|
151
|
+
if (prepared.ok) {
|
|
152
|
+
assert.equal(prepared.task.status, 'backlog')
|
|
153
|
+
assert.equal(prepared.task.workflowStateId, 'in_progress')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('leaves already-started workflow states alone', () => {
|
|
158
|
+
const next = resolveAssignmentWorkflowStateTransition({
|
|
159
|
+
previousAgentId: '',
|
|
160
|
+
nextAgentId: 'agent-builder',
|
|
161
|
+
previousWorkflowStateId: 'needs_review',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
assert.equal(next, null)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -20,6 +20,28 @@ const TASK_STATUS_VALUES = new Set([
|
|
|
20
20
|
'archived',
|
|
21
21
|
])
|
|
22
22
|
|
|
23
|
+
const ASSIGNMENT_START_WORKFLOW_STATES = new Set(['triage', 'backlog', 'todo'])
|
|
24
|
+
|
|
25
|
+
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
26
|
+
return Object.prototype.hasOwnProperty.call(record, key)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveAssignmentWorkflowStateTransition(params: {
|
|
30
|
+
previousAgentId?: string | null
|
|
31
|
+
nextAgentId?: string | null
|
|
32
|
+
previousWorkflowStateId?: string | null
|
|
33
|
+
explicitWorkflowState?: boolean
|
|
34
|
+
}): string | null {
|
|
35
|
+
if (params.explicitWorkflowState) return null
|
|
36
|
+
const previousAgentId = typeof params.previousAgentId === 'string' ? params.previousAgentId.trim() : ''
|
|
37
|
+
const nextAgentId = typeof params.nextAgentId === 'string' ? params.nextAgentId.trim() : ''
|
|
38
|
+
if (!nextAgentId || nextAgentId === previousAgentId) return null
|
|
39
|
+
const currentWorkflow = typeof params.previousWorkflowStateId === 'string' && params.previousWorkflowStateId.trim()
|
|
40
|
+
? params.previousWorkflowStateId.trim()
|
|
41
|
+
: 'backlog'
|
|
42
|
+
return ASSIGNMENT_START_WORKFLOW_STATES.has(currentWorkflow) ? 'in_progress' : null
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
export function deriveTaskTitle(input: { title?: unknown; description?: unknown }): string {
|
|
24
46
|
const explicit = typeof input.title === 'string' ? input.title.replace(/\s+/g, ' ').trim() : ''
|
|
25
47
|
if (explicit && !/^untitled task$/i.test(explicit)) return explicit.slice(0, 120)
|
|
@@ -157,6 +179,7 @@ export type PrepareTaskCreationResult =
|
|
|
157
179
|
|
|
158
180
|
export function prepareTaskCreation(options: PrepareTaskCreationOptions): PrepareTaskCreationResult {
|
|
159
181
|
const seed = options.seed ? { ...options.seed } : {}
|
|
182
|
+
delete seed.workType
|
|
160
183
|
const explicitTitle = typeof options.input.title === 'string' ? options.input.title.trim() : ''
|
|
161
184
|
const derivedTitle = deriveTaskTitle(options.input)
|
|
162
185
|
const nextTitle = options.deriveTitleFromDescription
|
|
@@ -194,6 +217,9 @@ export function prepareTaskCreation(options: PrepareTaskCreationOptions): Prepar
|
|
|
194
217
|
qualityGate,
|
|
195
218
|
},
|
|
196
219
|
})
|
|
220
|
+
if (!task.workflowStateId && task.agentId) {
|
|
221
|
+
task.workflowStateId = 'in_progress'
|
|
222
|
+
}
|
|
197
223
|
task.fingerprint = computeTaskFingerprint(task.title || 'Untitled Task', task.agentId || '')
|
|
198
224
|
|
|
199
225
|
const duplicate = task.fingerprint
|
|
@@ -227,6 +253,8 @@ export interface ApplyTaskPatchOptions {
|
|
|
227
253
|
|
|
228
254
|
export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
|
|
229
255
|
const nextPatch = { ...options.patch }
|
|
256
|
+
const previousAgentId = options.task.agentId
|
|
257
|
+
const previousWorkflowStateId = options.task.workflowStateId || null
|
|
230
258
|
if (Object.prototype.hasOwnProperty.call(nextPatch, 'status')) {
|
|
231
259
|
const normalized = normalizeTaskStatusInput(nextPatch.status, options.task.status)
|
|
232
260
|
if (normalized) nextPatch.status = normalized
|
|
@@ -240,6 +268,13 @@ export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
|
|
|
240
268
|
|
|
241
269
|
Object.assign(options.task, nextPatch, { updatedAt: options.now })
|
|
242
270
|
if (options.clearProjectIdWhenNull && nextPatch.projectId === null) delete options.task.projectId
|
|
271
|
+
const workflowTransition = resolveAssignmentWorkflowStateTransition({
|
|
272
|
+
previousAgentId,
|
|
273
|
+
nextAgentId: options.task.agentId,
|
|
274
|
+
previousWorkflowStateId,
|
|
275
|
+
explicitWorkflowState: hasOwn(nextPatch, 'workflowStateId'),
|
|
276
|
+
})
|
|
277
|
+
if (workflowTransition) options.task.workflowStateId = workflowTransition
|
|
243
278
|
|
|
244
279
|
if (options.task.status === 'completed') {
|
|
245
280
|
const { validation } = refreshTaskCompletionValidation(options.task, options.settings)
|
|
@@ -200,6 +200,8 @@ export const TaskCreateSchema = z.object({
|
|
|
200
200
|
retryBackoffSec: z.number().optional(),
|
|
201
201
|
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
|
202
202
|
dueAt: z.number().nullable().optional(),
|
|
203
|
+
workflowStateId: z.string().nullable().optional(),
|
|
204
|
+
requiredCapabilities: z.array(z.string()).optional(),
|
|
203
205
|
provisionWorkspace: z.boolean().optional(),
|
|
204
206
|
previewLinks: z.array(z.object({
|
|
205
207
|
id: z.string().optional(),
|
package/src/types/agent.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ProviderId, ProviderType, OllamaMode } from './provider'
|
|
|
2
2
|
import type { SessionResetMode, IdentityContinuityState } from './session'
|
|
3
3
|
import type { SkillAllowlistMode } from './skill'
|
|
4
4
|
import type { DreamConfig } from './dream'
|
|
5
|
+
import type { ExtensionManagedResourceMarker } from './extension'
|
|
5
6
|
|
|
6
7
|
// --- SwarmFeed Heartbeat ---
|
|
7
8
|
|
|
@@ -237,6 +238,7 @@ export interface Agent {
|
|
|
237
238
|
swarmfeedLastAutoPostAt?: number | null
|
|
238
239
|
origin?: 'swarmdock' | 'swarmfeed' | 'swarmclaw' | 'external'
|
|
239
240
|
swarmfeedHeartbeat?: SwarmFeedHeartbeatConfig | null
|
|
241
|
+
managedByExtension?: ExtensionManagedResourceMarker | null
|
|
240
242
|
|
|
241
243
|
// SwarmDock (marketplace integration)
|
|
242
244
|
swarmdockEnabled?: boolean
|