@swarmclawai/swarmclaw 1.5.40 → 1.5.41
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 +4 -6
- package/package.json +1 -1
- package/src/lib/server/session-tools/build-session-tools-dedup.test.ts +127 -0
- package/src/lib/server/session-tools/files-tool.test.ts +56 -0
- package/src/lib/server/session-tools/files-tool.ts +10 -2
- package/src/lib/server/session-tools/index.ts +20 -3
package/README.md
CHANGED
|
@@ -389,6 +389,10 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
389
389
|
|
|
390
390
|
## Releases
|
|
391
391
|
|
|
392
|
+
### v1.5.41 Highlights
|
|
393
|
+
|
|
394
|
+
- **Moonshot / Kimi compatibility — duplicate `files` tool name fixed**: any agent with the default `files` extension was sending two tools both literally named `files` to the LLM. Most providers tolerated the duplicate; Moonshot's strict tool-schema validation rejected it with `MoonshotException - function name files is duplicated` ([#39](https://github.com/swarmclawai/swarmclaw/issues/39), reported by [@SteamedFish](https://github.com/SteamedFish)). Three fixes: the v2 file builder is now correctly gated on `files_v2` (not `files`), it registers under the matching capability key, and the session-tools assembler now shares a single dedup Set across native, CRUD, and extension phases so any future name collision is rejected with a clear warning instead of a silent double-register.
|
|
395
|
+
|
|
392
396
|
### v1.5.40 Highlights
|
|
393
397
|
|
|
394
398
|
- **Current-thread recall routing**: the message classifier now emits four explicit flags (`isCurrentThreadRecall`, `isGreeting`, `isAcknowledgement`, `isMemoryWriteIntent`) so the chat router stops treating in-thread pronouns ("your last reply", "both answers", "what I just said") as durable-memory queries. Previously small OSS models (`devstral-small-2:24b` and similar) would run `memory_search` for these, come back empty, and truthfully report "no memories found" even when the answer was three messages up.
|
|
@@ -426,12 +430,6 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
426
430
|
- Pins `outputFileTracingRoot` in `next.config.ts` to the project root so the Next.js build no longer walks `C:\Users\<user>\Application Data` (a legacy NTFS junction that throws EPERM on Windows runners).
|
|
427
431
|
- Pins Python 3.11 in the desktop-release workflow so `node-gyp` rebuilds of native modules (`node-liblzma`, etc.) succeed on Python 3.12+ runners where `distutils` was removed from the stdlib.
|
|
428
432
|
|
|
429
|
-
### v1.5.36 Highlights
|
|
430
|
-
|
|
431
|
-
- **Desktop app (Electron)**: SwarmClaw now ships as a native desktop app for macOS (Apple Silicon + Intel), Windows, and Linux (AppImage + .deb). Download from [swarmclaw.ai/downloads](https://swarmclaw.ai/downloads). The app wraps the existing standalone server inside an Electron shell, stores data in the OS app-data directory, and auto-updates via GitHub Releases (notify-only on unsigned macOS builds).
|
|
432
|
-
- **Desktop release CI**: new `desktop-release.yml` workflow builds and publishes installers for all three platforms to GitHub Releases on every version tag.
|
|
433
|
-
- **UI cleanup**: removed sibling-product navigation links from the in-app sidebar rail and login gate so the open-source app focuses on SwarmClaw itself. Those links remain in the project README and on swarmclaw.ai.
|
|
434
|
-
|
|
435
433
|
Older releases: https://swarmclaw.ai/docs/release-notes
|
|
436
434
|
|
|
437
435
|
- GitHub releases: https://github.com/swarmclawai/swarmclaw/releases
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.41",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
// Issue #39 (Moonshot/Kimi rejecting duplicate tool names) showed that the
|
|
5
|
+
// Phase 1 native-tool loop in `session-tools/index.ts` was pushing tools
|
|
6
|
+
// without checking for duplicate names. Phase 2 already had a dedup Set; the
|
|
7
|
+
// fix lifts that Set above Phase 1 so all phases share it.
|
|
8
|
+
//
|
|
9
|
+
// This test mirrors the dedup algorithm in pure form so it can be verified
|
|
10
|
+
// without booting the full session-tools graph (which OOMs in test workers
|
|
11
|
+
// when run alongside the dev server).
|
|
12
|
+
|
|
13
|
+
type FakeTool = { name: string }
|
|
14
|
+
type Builder = () => FakeTool[]
|
|
15
|
+
|
|
16
|
+
interface DedupWarn {
|
|
17
|
+
toolName: string
|
|
18
|
+
source: 'native' | 'crud' | 'extension'
|
|
19
|
+
extensionId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dedupAssemble(
|
|
23
|
+
nativeBuilders: ReadonlyArray<readonly [string, Builder]>,
|
|
24
|
+
crudBuilder: Builder,
|
|
25
|
+
extensionTools: ReadonlyArray<{ extensionId: string; tool: FakeTool }>,
|
|
26
|
+
): { tools: FakeTool[]; warnings: DedupWarn[] } {
|
|
27
|
+
const tools: FakeTool[] = []
|
|
28
|
+
const warnings: DedupWarn[] = []
|
|
29
|
+
const existingNames = new Set<string>()
|
|
30
|
+
|
|
31
|
+
for (const [extensionId, builder] of nativeBuilders) {
|
|
32
|
+
for (const t of builder()) {
|
|
33
|
+
if (existingNames.has(t.name)) {
|
|
34
|
+
warnings.push({ toolName: t.name, source: 'native', extensionId })
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
existingNames.add(t.name)
|
|
38
|
+
tools.push(t)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const t of crudBuilder()) {
|
|
43
|
+
if (existingNames.has(t.name)) {
|
|
44
|
+
warnings.push({ toolName: t.name, source: 'crud' })
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
existingNames.add(t.name)
|
|
48
|
+
tools.push(t)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const entry of extensionTools) {
|
|
52
|
+
if (existingNames.has(entry.tool.name)) {
|
|
53
|
+
warnings.push({ toolName: entry.tool.name, source: 'extension', extensionId: entry.extensionId })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
existingNames.add(entry.tool.name)
|
|
57
|
+
tools.push(entry.tool)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { tools, warnings }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('session-tools assembler dedup (issue #39 regression)', () => {
|
|
64
|
+
it('emits a single `files` tool when two native builders both produce one (the original issue #39 scenario)', () => {
|
|
65
|
+
const result = dedupAssemble(
|
|
66
|
+
[
|
|
67
|
+
['files', () => [{ name: 'files' }]],
|
|
68
|
+
['files_v2', () => [{ name: 'files' }]],
|
|
69
|
+
],
|
|
70
|
+
() => [],
|
|
71
|
+
[],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const fileTools = result.tools.filter((t) => t.name === 'files')
|
|
75
|
+
assert.equal(fileTools.length, 1, 'must emit exactly one tool named "files"')
|
|
76
|
+
assert.equal(result.warnings.length, 1)
|
|
77
|
+
assert.equal(result.warnings[0].toolName, 'files')
|
|
78
|
+
assert.equal(result.warnings[0].source, 'native')
|
|
79
|
+
assert.equal(result.warnings[0].extensionId, 'files_v2')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('first builder wins when names collide', () => {
|
|
83
|
+
const t1 = { name: 'shared' }
|
|
84
|
+
const t2 = { name: 'shared' }
|
|
85
|
+
const result = dedupAssemble(
|
|
86
|
+
[
|
|
87
|
+
['ext-a', () => [t1]],
|
|
88
|
+
['ext-b', () => [t2]],
|
|
89
|
+
],
|
|
90
|
+
() => [],
|
|
91
|
+
[],
|
|
92
|
+
)
|
|
93
|
+
assert.equal(result.tools.length, 1)
|
|
94
|
+
assert.strictEqual(result.tools[0], t1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('CRUD tools cannot collide with native tools', () => {
|
|
98
|
+
const result = dedupAssemble(
|
|
99
|
+
[['ext-a', () => [{ name: 'crud_op' }]]],
|
|
100
|
+
() => [{ name: 'crud_op' }],
|
|
101
|
+
[],
|
|
102
|
+
)
|
|
103
|
+
assert.equal(result.tools.length, 1)
|
|
104
|
+
assert.equal(result.warnings[0].source, 'crud')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('extension tools dedup against the same shared Set', () => {
|
|
108
|
+
const result = dedupAssemble(
|
|
109
|
+
[['ext-a', () => [{ name: 'foo' }]]],
|
|
110
|
+
() => [],
|
|
111
|
+
[{ extensionId: 'ext-b', tool: { name: 'foo' } }],
|
|
112
|
+
)
|
|
113
|
+
assert.equal(result.tools.length, 1)
|
|
114
|
+
assert.equal(result.warnings[0].source, 'extension')
|
|
115
|
+
assert.equal(result.warnings[0].extensionId, 'ext-b')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('lets distinct names through unchanged', () => {
|
|
119
|
+
const result = dedupAssemble(
|
|
120
|
+
[['ext-a', () => [{ name: 'a' }, { name: 'b' }]]],
|
|
121
|
+
() => [{ name: 'c' }],
|
|
122
|
+
[{ extensionId: 'ext-b', tool: { name: 'd' } }],
|
|
123
|
+
)
|
|
124
|
+
assert.deepEqual(result.tools.map((t) => t.name), ['a', 'b', 'c', 'd'])
|
|
125
|
+
assert.equal(result.warnings.length, 0)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { buildFilesTools } from '@/lib/server/session-tools/files-tool'
|
|
4
|
+
import type { ToolBuildContext } from '@/lib/server/session-tools/context'
|
|
5
|
+
|
|
6
|
+
function makeBctx(enabled: Set<string>): ToolBuildContext {
|
|
7
|
+
return {
|
|
8
|
+
cwd: '/tmp',
|
|
9
|
+
ctx: undefined,
|
|
10
|
+
hasExtension: (name) => enabled.has(name),
|
|
11
|
+
hasTool: (name) => enabled.has(name),
|
|
12
|
+
cleanupFns: [],
|
|
13
|
+
commandTimeoutMs: 0,
|
|
14
|
+
claudeTimeoutMs: 0,
|
|
15
|
+
cliProcessTimeoutMs: 0,
|
|
16
|
+
persistDelegateResumeId: () => {},
|
|
17
|
+
readStoredDelegateResumeId: () => null,
|
|
18
|
+
resolveCurrentSession: () => null,
|
|
19
|
+
activeExtensions: Array.from(enabled),
|
|
20
|
+
filesystemScope: 'workspace',
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('buildFilesTools (issue #39)', () => {
|
|
25
|
+
it('returns no tools when only the legacy `files` extension is enabled', () => {
|
|
26
|
+
// Pre-fix this returned a tool named "files", on top of the v1 builder
|
|
27
|
+
// which already produced a tool with the same name. Moonshot/Kimi rejected
|
|
28
|
+
// the duplicate with `function name files is duplicated`.
|
|
29
|
+
const bctx = makeBctx(new Set(['files']))
|
|
30
|
+
const out = buildFilesTools(bctx)
|
|
31
|
+
assert.equal(out.length, 0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns no tools when no relevant extension is enabled', () => {
|
|
35
|
+
const bctx = makeBctx(new Set(['shell', 'web']))
|
|
36
|
+
const out = buildFilesTools(bctx)
|
|
37
|
+
assert.equal(out.length, 0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns one `files` tool when the v2 extension is explicitly enabled', () => {
|
|
41
|
+
const bctx = makeBctx(new Set(['files_v2']))
|
|
42
|
+
const out = buildFilesTools(bctx)
|
|
43
|
+
assert.equal(out.length, 1)
|
|
44
|
+
assert.equal(out[0].name, 'files')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns one `files` tool when both `files` and `files_v2` are enabled', () => {
|
|
48
|
+
// Defensive: even with both enabled, this builder emits exactly one tool.
|
|
49
|
+
// (The duplicate-with-v1 protection lives in the session-tools assembler
|
|
50
|
+
// dedup loop, covered by build-session-tools-dedup.test.ts.)
|
|
51
|
+
const bctx = makeBctx(new Set(['files', 'files_v2']))
|
|
52
|
+
const out = buildFilesTools(bctx)
|
|
53
|
+
assert.equal(out.length, 1)
|
|
54
|
+
assert.equal(out[0].name, 'files')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -608,14 +608,22 @@ const FilesExtension: Extension = {
|
|
|
608
608
|
],
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
-
|
|
611
|
+
// Registered under 'files_v2' to avoid colliding with the v1 FileExtension
|
|
612
|
+
// in `file.ts`, which also registers under the literal key 'files'. The
|
|
613
|
+
// builder below is wired into `session-tools/index.ts` via the same key.
|
|
614
|
+
registerNativeCapability('files_v2', FilesExtension)
|
|
612
615
|
|
|
613
616
|
// ---------------------------------------------------------------------------
|
|
614
617
|
// Tool builder (called from session-tools/index.ts)
|
|
615
618
|
// ---------------------------------------------------------------------------
|
|
616
619
|
|
|
617
620
|
export function buildFilesTools(bctx: ToolBuildContext) {
|
|
618
|
-
|
|
621
|
+
// Gate on 'files_v2' (not 'files'). Previously this checked 'files', which
|
|
622
|
+
// meant that enabling the v1 `files` extension activated BOTH builders and
|
|
623
|
+
// registered two tools literally named "files". Most providers tolerate
|
|
624
|
+
// duplicate tool names; Moonshot/Kimi rejects them with `function name
|
|
625
|
+
// files is duplicated`. Reported as issue #39.
|
|
626
|
+
if (!bctx.hasExtension('files_v2')) return []
|
|
619
627
|
|
|
620
628
|
return [
|
|
621
629
|
tool(
|
|
@@ -221,24 +221,41 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
|
|
|
221
221
|
['swarmdock', buildSwarmDockTools],
|
|
222
222
|
]
|
|
223
223
|
|
|
224
|
+
// Track tool names across all phases so duplicates are rejected
|
|
225
|
+
// consistently. Issue #39: Moonshot rejects duplicate tool names that
|
|
226
|
+
// most providers silently tolerate, so guarding only Phase 2 (as the
|
|
227
|
+
// pre-fix code did) was not enough.
|
|
228
|
+
const existingNames = new Set<string>()
|
|
224
229
|
for (const [extensionId, builder] of nativeBuilders) {
|
|
225
230
|
const builtTools = builder(bctx)
|
|
226
231
|
for (const t of builtTools) {
|
|
232
|
+
if (existingNames.has(t.name)) {
|
|
233
|
+
log.warn('session-tools', 'Skipping native tool due to duplicate name', {
|
|
234
|
+
toolName: t.name,
|
|
235
|
+
extensionId,
|
|
236
|
+
})
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
existingNames.add(t.name)
|
|
227
240
|
toolToExtensionMap[t.name] = extensionId
|
|
241
|
+
tools.push(t)
|
|
228
242
|
}
|
|
229
|
-
tools.push(...builtTools)
|
|
230
243
|
}
|
|
231
244
|
|
|
232
245
|
const crudTools = buildCrudTools(bctx)
|
|
233
246
|
for (const toolEntry of crudTools) {
|
|
247
|
+
if (existingNames.has(toolEntry.name)) {
|
|
248
|
+
log.warn('session-tools', 'Skipping CRUD tool due to duplicate name', { toolName: toolEntry.name })
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
existingNames.add(toolEntry.name)
|
|
234
252
|
toolToExtensionMap[toolEntry.name] = toolEntry.name
|
|
253
|
+
tools.push(toolEntry)
|
|
235
254
|
}
|
|
236
|
-
tools.push(...crudTools)
|
|
237
255
|
|
|
238
256
|
// 2. Build Extension Tools (Built-in + External)
|
|
239
257
|
try {
|
|
240
258
|
const extensionTools = extensionManager.getTools(activeExtensions)
|
|
241
|
-
const existingNames = new Set(tools.map((t) => t.name))
|
|
242
259
|
|
|
243
260
|
for (const entry of extensionTools) {
|
|
244
261
|
const pt = entry.tool
|