@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 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.40",
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
- registerNativeCapability('files', FilesExtension)
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
- if (!bctx.hasExtension('files')) return []
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