@swarmclawai/swarmclaw 1.5.33 → 1.5.35

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
@@ -39,6 +39,10 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
39
39
  <td align="center"><img src="doc/assets/logos/codex.svg" width="32" alt="Codex"><br><sub>Codex</sub></td>
40
40
  <td align="center"><img src="doc/assets/logos/gemini-cli.svg" width="32" alt="Gemini CLI"><br><sub>Gemini CLI</sub></td>
41
41
  <td align="center"><img src="doc/assets/logos/opencode.svg" width="32" alt="OpenCode"><br><sub>OpenCode</sub></td>
42
+ <td align="center"><img src="doc/assets/logos/copilot-cli.svg" width="32" alt="Copilot CLI"><br><sub>Copilot</sub></td>
43
+ <td align="center"><img src="doc/assets/logos/cursor-cli.svg" width="32" alt="Cursor Agent CLI"><br><sub>Cursor</sub></td>
44
+ <td align="center"><img src="doc/assets/logos/qwen-code-cli.svg" width="32" alt="Qwen Code CLI"><br><sub>Qwen Code</sub></td>
45
+ <td align="center"><img src="doc/assets/logos/goose.svg" width="32" alt="Goose"><br><sub>Goose</sub></td>
42
46
  <td align="center"><img src="doc/assets/logos/anthropic.svg" width="32" alt="Anthropic"><br><sub>Anthropic</sub></td>
43
47
  <td align="center"><img src="doc/assets/logos/openai.svg" width="32" alt="OpenAI"><br><sub>OpenAI</sub></td>
44
48
  <td align="center"><img src="public/provider-logos/openrouter.png" width="32" alt="OpenRouter"><br><sub>OpenRouter</sub></td>
@@ -61,7 +65,7 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
61
65
  - Node.js 22.6+ (`nvm use` will pick up the repo's `.nvmrc`, which matches CI)
62
66
  - npm 10+ or another supported package manager
63
67
  - Docker Desktop is recommended for sandbox browser execution
64
- - Optional provider CLIs if you want delegated CLI backends such as Claude Code, Codex, OpenCode, or Gemini
68
+ - Optional provider CLIs if you want delegated CLI backends such as Claude Code, Codex, OpenCode, Gemini, Copilot, Cursor Agent, Qwen Code, or Goose
65
69
 
66
70
  ## Quick Start
67
71
 
@@ -147,10 +151,10 @@ Full hosted deployment guides live at https://swarmclaw.ai/docs/deployment
147
151
 
148
152
  ## Core Capabilities
149
153
 
150
- - **Providers**: OpenClaw, OpenAI, OpenRouter, Anthropic, Ollama, Hermes Agent, Google, DeepSeek, Groq, Together, Mistral, xAI, Fireworks, Nebius, DeepInfra, plus compatible custom endpoints.
154
+ - **Providers**: 23 built-in — Claude Code CLI, Codex CLI, OpenCode CLI, Gemini CLI, Copilot CLI, Cursor Agent CLI, Qwen Code CLI, Goose, Anthropic, OpenAI, OpenRouter, Google Gemini, DeepSeek, Groq, Together, Mistral, xAI, Fireworks, Nebius, DeepInfra, Ollama, OpenClaw, and Hermes Agent, plus compatible custom endpoints.
151
155
  - **OpenRouter**: <img src="public/provider-logos/openrouter.png" alt="OpenRouter logo" width="20" height="20" /> Use OpenRouter as a first-class built-in provider with its standard OpenAI-compatible endpoint and routed model IDs such as `openai/gpt-4.1-mini`.
152
156
  - **Hermes Agent**: <img src="public/provider-logos/hermes-agent.png" alt="Hermes Agent logo" width="20" height="20" /> Connect Hermes through its OpenAI-compatible API server, locally or through a reachable remote `/v1` endpoint.
153
- - **Delegation**: built-in delegation to Claude Code, Codex CLI, OpenCode CLI, Gemini CLI, and native SwarmClaw subagents.
157
+ - **Delegation**: built-in delegation to Claude Code, Codex CLI, OpenCode CLI, Gemini CLI, Cursor Agent CLI, Qwen Code CLI, and native SwarmClaw subagents.
154
158
  - **Autonomy**: heartbeat loops, schedules, background jobs, task execution, supervisor recovery, and agent wakeups.
155
159
  - **Orchestration**: durable structured execution with branching, repeat loops, parallel branches, explicit joins, restart-safe run state, and contextual launch from chats, chatrooms, tasks, schedules, and API flows.
156
160
  - **Structured Sessions**: reusable bounded runs with templates, facilitators, participants, hidden live rooms, chatroom `/breakout`, durable transcripts, outputs, operator controls, and a visible protocols template gallery plus visual builder.
@@ -371,6 +375,20 @@ Operational docs: https://swarmclaw.ai/docs/observability
371
375
 
372
376
  ## Releases
373
377
 
378
+ ### v1.5.35 Highlights
379
+
380
+ - **Update safety: prevent DB corruption on Linux**: `npm run update:easy`, `swarmclaw update`, and the in-app update endpoint now stop the running server (or checkpoint the SQLite WAL) before rebuilding native modules, preventing the WAL journal corruption that forced some Linux users back to the setup wizard.
381
+ - **SQLite graceful shutdown**: the server now checkpoints and closes the database on SIGTERM/SIGINT, eliminating stale WAL state after any clean stop.
382
+ - **Doctor: detect dangling gateway credentials**: the setup doctor now flags gateway profiles that reference deleted or missing credentials, explaining the "gateway token missing" connection errors.
383
+ - **Gateway credential resolution logging**: when a gateway credential can't be resolved, the server now logs a clear warning identifying the missing credential ID.
384
+ - **Credential decryption error logging**: when a stored credential can't be decrypted (e.g. after `CREDENTIAL_SECRET` changes), the server now logs the credential ID and provider so users know which key to re-add.
385
+
386
+ ### v1.5.34 Highlights
387
+
388
+ - **Ollama Cloud auth fix**: SwarmClaw now normalizes `api.ollama.com` and `www.ollama.com` to `ollama.com` before making authenticated requests, avoiding the redirect that was dropping authorization headers and causing false provider-health/runtime failures.
389
+ - **Chat execution context hardening**: tool invocation now resolves names case-insensitively, oversized tool results are truncated before they are fed back into the model, and proactive grounding/heartbeat prompts stay smaller under pressure to reduce avoidable context blowouts.
390
+ - **API compatibility fixes**: OpenAI-compatible streaming now captures reasoning deltas from providers that emit them outside `delta.content`, and A2A endpoints are exempt from the main proxy access-key gate so they can rely on their own auth scheme.
391
+
374
392
  ### v1.5.33 Highlights
375
393
 
376
394
  - **CLI global flag compatibility**: legacy-routed commands now honor the documented `--access-key` and `--base-url` aliases even when they appear after the subcommand, so authenticated CLI automation works the same across binary entry points.
@@ -380,6 +398,13 @@ Operational docs: https://swarmclaw.ai/docs/observability
380
398
 
381
399
  - **Fix Docker first-run crash**: resolved `EISDIR: illegal operation on a directory, read` error when running `docker compose up` without a pre-existing `.env.local` file. Docker was creating a directory mount instead of a file, which crashed Next.js on startup. Replaced the file bind mount with `env_file` directive using `required: false`.
382
400
 
401
+ ### v1.5.4 Highlights
402
+
403
+ - **Cursor Agent CLI built-in provider**: Cursor Agent CLI is now a first-class worker provider with session continuity, headless execution, and delegation support.
404
+ - **Qwen Code CLI built-in provider**: Qwen Code CLI is now available as a built-in worker provider and delegation backend with structured headless execution support.
405
+ - **Goose built-in provider**: Goose is now supported as a runtime-managed worker provider, using its own local auth and provider configuration while preserving SwarmClaw session continuity.
406
+ - **CLI setup and health parity**: setup flows, provider checks, setup doctor, and provider-facing UI now recognize Cursor, Qwen Code, and Goose alongside the existing CLI-backed providers.
407
+
383
408
  ### v1.5.3 Highlights
384
409
 
385
410
  - **Copilot CLI v1.x compatibility**: the `copilot-cli` provider now handles the current event format (`assistant.message_delta`, `assistant.message`, updated `result` payload) while keeping backward compatibility with the legacy format. Also fixes `--resume` flag syntax. (Community contribution by [@borislavnnikolov](https://github.com/borislavnnikolov) -- PR #36)
package/bin/update-cmd.js CHANGED
@@ -98,6 +98,29 @@ function runRegistrySelfUpdate(
98
98
  return 0
99
99
  }
100
100
 
101
+ function stopRunningServer(logger = { log, logError }) {
102
+ // Stop the server gracefully before updating to prevent SQLite WAL corruption.
103
+ try {
104
+ const serverCmd = path.join(PKG_ROOT, 'bin', 'swarmclaw.js')
105
+ const status = execFileSync(process.execPath, [serverCmd, 'status'], {
106
+ encoding: 'utf-8',
107
+ cwd: PKG_ROOT,
108
+ timeout: 10_000,
109
+ }).trim()
110
+ if (status.toLowerCase().includes('running')) {
111
+ logger.log('Stopping running server before update...')
112
+ execFileSync(process.execPath, [serverCmd, 'stop'], {
113
+ encoding: 'utf-8',
114
+ cwd: PKG_ROOT,
115
+ timeout: 15_000,
116
+ })
117
+ logger.log('Server stopped.')
118
+ }
119
+ } catch {
120
+ // Server may not be running or status command unavailable — continue.
121
+ }
122
+ }
123
+
101
124
  function main(args = process.argv.slice(3)) {
102
125
  if (args.includes('-h') || args.includes('--help')) {
103
126
  console.log(`
@@ -112,9 +135,12 @@ If running from a registry install, update the global package with its owning pa
112
135
  try {
113
136
  run('git rev-parse --git-dir')
114
137
  } catch {
138
+ stopRunningServer()
115
139
  process.exit(runRegistrySelfUpdate())
116
140
  }
117
141
 
142
+ stopRunningServer()
143
+
118
144
  const beforeRef = run('git rev-parse HEAD')
119
145
  const beforeSha = run('git rev-parse --short HEAD')
120
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.33",
3
+ "version": "1.5.35",
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
  "license": "MIT",
6
6
  "publishConfig": {
@@ -74,7 +74,7 @@
74
74
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
75
75
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
76
76
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
77
- "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
77
+ "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
78
78
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
79
79
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
80
80
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
3
5
  import { spawnSync } from 'node:child_process'
4
6
 
5
7
  const args = new Set(process.argv.slice(2))
@@ -68,12 +70,33 @@ function getLatestStableTag() {
68
70
  return tags.find((tag) => RELEASE_TAG_RE.test(tag)) || null
69
71
  }
70
72
 
73
+ function stopRunningServer() {
74
+ // Try to stop the SwarmClaw server gracefully before updating.
75
+ // An unclean shutdown while npm rebuild replaces native modules
76
+ // can corrupt the SQLite WAL journal on Linux.
77
+ const cliPath = path.join(cwd, 'bin', 'swarmclaw.js')
78
+ if (!fs.existsSync(cliPath)) return
79
+
80
+ const status = run('node', [cliPath, 'status'])
81
+ if (!status.ok || !status.out.toLowerCase().includes('running')) return
82
+
83
+ log('Stopping running SwarmClaw server before update...')
84
+ const stop = run('node', [cliPath, 'stop'])
85
+ if (stop.ok) {
86
+ log('Server stopped.')
87
+ } else {
88
+ log('Could not stop the server automatically. Please stop it manually before updating.')
89
+ }
90
+ }
91
+
71
92
  function main() {
72
93
  const gitCheck = run('git', ['rev-parse', '--is-inside-work-tree'])
73
94
  if (!gitCheck.ok) {
74
95
  fail('This folder is not a git repository. Automatic updates require git.')
75
96
  }
76
97
 
98
+ stopRunningServer()
99
+
77
100
  const dirty = run('git', ['status', '--porcelain'])
78
101
  const isDirty = !!dirty.out
79
102
  if (isDirty && !allowDirty) {
@@ -12,7 +12,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
12
12
  const secret = secrets[id]
13
13
  if (!secret) return notFound()
14
14
  // Never expose the encrypted value
15
- const { encryptedValue, ...safe } = secret as Record<string, unknown>
15
+ const safe = { ...(secret as Record<string, unknown>) }
16
+ delete safe.encryptedValue
16
17
  return NextResponse.json(safe)
17
18
  }
18
19
 
@@ -36,6 +37,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
36
37
  return secret
37
38
  })
38
39
  if (!result) return notFound()
39
- const { encryptedValue, ...safe } = result as Record<string, unknown>
40
+ const safe = { ...(result as Record<string, unknown>) }
41
+ delete safe.encryptedValue
40
42
  return NextResponse.json(safe)
41
43
  }
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { NextResponse } from 'next/server'
5
5
  import { DATA_DIR } from '@/lib/server/data-dir'
6
- import { loadAgents, loadCredentials, loadSettings } from '@/lib/server/storage'
6
+ import { loadAgents, loadCredentials, loadSettings, loadCollection } from '@/lib/server/storage'
7
7
  import { dedup, errorMessage } from '@/lib/shared-utils'
8
8
  import { detectDocker } from '@/lib/server/sandbox/docker-detect'
9
9
 
@@ -160,6 +160,7 @@ export async function GET(req: Request) {
160
160
  }
161
161
 
162
162
  const credentials = Object.values(loadCredentials() || {})
163
+ const credentialIds = new Set(credentials.map((c) => (c as Record<string, unknown>).id as string).filter(Boolean))
163
164
  if (credentials.length > 0) {
164
165
  pushCheck(checks, 'credentials', 'Credentials', 'pass', `${credentials.length} credential(s) saved.`)
165
166
  } else {
@@ -167,6 +168,35 @@ export async function GET(req: Request) {
167
168
  actions.push('If using cloud providers, add an API key in the setup wizard or Settings → Providers.')
168
169
  }
169
170
 
171
+ // Check for undecryptable credentials (CREDENTIAL_SECRET may have changed)
172
+ if (credentials.length > 0) {
173
+ try {
174
+ const { resolveCredentialSecret } = await import('@/lib/server/credentials/credential-service')
175
+ let undecryptable = 0
176
+ for (const cred of credentials) {
177
+ const c = cred as Record<string, unknown>
178
+ if (c.encryptedKey && !resolveCredentialSecret(c.id as string)) undecryptable++
179
+ }
180
+ if (undecryptable > 0) {
181
+ pushCheck(checks, 'credential-decrypt', 'Credential decryption', 'fail',
182
+ `${undecryptable} of ${credentials.length} credential(s) cannot be decrypted. CREDENTIAL_SECRET may have changed since these keys were stored.`, true)
183
+ actions.push('Your CREDENTIAL_SECRET changed (possibly from a container restart). Re-add your API keys in Settings → Providers.')
184
+ }
185
+ } catch { /* best-effort */ }
186
+ }
187
+
188
+ // Check for dangling credential references in gateway profiles
189
+ try {
190
+ const gateways = Object.values(loadCollection('gateway_profiles') || {}) as Array<Record<string, unknown>>
191
+ const dangling = gateways.filter((gw) => gw.credentialId && !credentialIds.has(gw.credentialId as string))
192
+ if (dangling.length > 0) {
193
+ const names = dangling.map((gw) => gw.name || gw.id).join(', ')
194
+ pushCheck(checks, 'gateway-credentials', 'Gateway credential references', 'warn',
195
+ `${dangling.length} gateway profile(s) reference missing credentials: ${names}. This causes "gateway token missing" errors.`)
196
+ actions.push('Re-add the missing API key in Settings → Gateways, or update the gateway profile to use an existing credential.')
197
+ }
198
+ } catch { /* best-effort */ }
199
+
170
200
  const optionalBinaries: Array<{ id: string; label: string; command: string }> = [
171
201
  { id: 'claude-cli', label: 'Claude Code CLI', command: 'claude' },
172
202
  { id: 'codex-cli', label: 'OpenAI Codex CLI', command: 'codex' },
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { execSync } from 'child_process'
3
+ import { getDb } from '@/lib/server/storage'
3
4
 
4
5
  const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
5
6
 
@@ -7,6 +8,19 @@ function run(cmd: string): string {
7
8
  return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 }).trim()
8
9
  }
9
10
 
11
+ /**
12
+ * Checkpoint the SQLite WAL before operations that replace native modules
13
+ * (npm install rebuilds better-sqlite3). Without this, an unclean WAL state
14
+ * combined with a replaced native binary can corrupt the database on Linux.
15
+ */
16
+ function checkpointDatabase(): void {
17
+ try {
18
+ getDb().pragma('wal_checkpoint(TRUNCATE)')
19
+ } catch {
20
+ // Best-effort — the database may already be in a good state.
21
+ }
22
+ }
23
+
10
24
  function getLatestStableTag(): string | null {
11
25
  const tags = run(`git tag --list 'v*' --sort=-v:refname`)
12
26
  .split('\n')
@@ -67,6 +81,9 @@ export async function POST() {
67
81
  try {
68
82
  const diff = run(`git diff --name-only ${beforeSha}..HEAD`)
69
83
  if (diff.includes('package-lock.json') || diff.includes('package.json')) {
84
+ // Checkpoint WAL before npm install — the postinstall hook rebuilds
85
+ // better-sqlite3's native module, which can corrupt an open WAL journal.
86
+ checkpointDatabase()
70
87
  run('npm install --omit=dev')
71
88
  installedDeps = true
72
89
  }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { isOllamaCloudEndpoint, normalizeOllamaMode, resolveStoredOllamaMode } from './ollama-mode'
3
+ import { isOllamaCloudEndpoint, normalizeOllamaCloudEndpoint, normalizeOllamaMode, resolveStoredOllamaMode } from './ollama-mode'
4
4
 
5
5
  test('normalizeOllamaMode only accepts explicit local and cloud values', () => {
6
6
  assert.equal(normalizeOllamaMode('local'), 'local')
@@ -31,3 +31,20 @@ test('resolveStoredOllamaMode falls back to endpoint only for legacy records', (
31
31
  assert.equal(resolveStoredOllamaMode({ apiEndpoint: 'http://localhost:11434' }), 'local')
32
32
  assert.equal(resolveStoredOllamaMode({}), 'local')
33
33
  })
34
+
35
+ test('normalizeOllamaCloudEndpoint rewrites api.ollama.com to ollama.com', () => {
36
+ assert.equal(normalizeOllamaCloudEndpoint('https://api.ollama.com'), 'https://ollama.com')
37
+ assert.equal(normalizeOllamaCloudEndpoint('https://api.ollama.com/v1'), 'https://ollama.com/v1')
38
+ assert.equal(normalizeOllamaCloudEndpoint('http://api.ollama.com'), 'http://ollama.com')
39
+ assert.equal(normalizeOllamaCloudEndpoint('https://www.ollama.com'), 'https://ollama.com')
40
+ })
41
+
42
+ test('normalizeOllamaCloudEndpoint preserves correct ollama.com URLs', () => {
43
+ assert.equal(normalizeOllamaCloudEndpoint('https://ollama.com'), 'https://ollama.com')
44
+ assert.equal(normalizeOllamaCloudEndpoint('https://ollama.com/v1'), 'https://ollama.com/v1')
45
+ })
46
+
47
+ test('normalizeOllamaCloudEndpoint does not mangle non-Ollama endpoints', () => {
48
+ assert.equal(normalizeOllamaCloudEndpoint('http://localhost:11434'), 'http://localhost:11434')
49
+ assert.equal(normalizeOllamaCloudEndpoint('https://api.openai.com/v1'), 'https://api.openai.com/v1')
50
+ })
@@ -18,6 +18,14 @@ export function isOllamaCloudEndpoint(endpoint: string | null | undefined): bool
18
18
  return /^https?:\/\/(?:www\.|api\.)?ollama\.com(?:\/|$)/i.test(normalized)
19
19
  }
20
20
 
21
+ /**
22
+ * Normalize an Ollama Cloud endpoint to avoid the api.ollama.com -> ollama.com
23
+ * 301 redirect which drops the Authorization header.
24
+ */
25
+ export function normalizeOllamaCloudEndpoint(endpoint: string): string {
26
+ return endpoint.replace(/^(https?:\/\/)(?:www\.|api\.)?ollama\.com/i, '$1ollama.com')
27
+ }
28
+
21
29
  export function resolveStoredOllamaMode(input: {
22
30
  ollamaMode?: string | null
23
31
  apiEndpoint?: string | null
@@ -165,7 +165,11 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
165
165
  if (data === '[DONE]') continue
166
166
  try {
167
167
  const parsed = JSON.parse(data)
168
- const delta = parsed.choices?.[0]?.delta?.content
168
+ const choice = parsed.choices?.[0]?.delta
169
+ const delta = choice?.content
170
+ // Thinking/reasoning models (kimi-k2, etc.) put output in reasoning fields
171
+ || choice?.reasoning_content
172
+ || choice?.reasoning
169
173
  if (delta) {
170
174
  fullResponse += delta
171
175
  writeSSE(write, 'd', delta)
@@ -453,12 +453,13 @@ async function invokeSessionTool(
453
453
  })
454
454
 
455
455
  try {
456
- const directTool = tools.find((t) => t?.name === toolName) as StructuredToolInterface | undefined
456
+ const lowerName = toolName.toLowerCase()
457
+ const directTool = tools.find((t) => t?.name === toolName || t?.name?.toLowerCase() === lowerName) as StructuredToolInterface | undefined
457
458
  const availableToolNames = tools.map((candidate) => candidate?.name).filter(Boolean)
458
459
  const translated = directTool
459
- ? { toolName, args }
460
+ ? { toolName: directTool.name, args }
460
461
  : translateRequestedToolInvocation(toolName, args, ctx.message, availableToolNames)
461
- const selectedTool = directTool || tools.find((t) => t?.name === translated.toolName) as StructuredToolInterface | undefined
462
+ const selectedTool = directTool || tools.find((t) => t?.name === translated.toolName || t?.name?.toLowerCase() === translated.toolName.toLowerCase()) as StructuredToolInterface | undefined
462
463
  if (!selectedTool?.invoke) {
463
464
  const resolvedName = translated.toolName !== toolName ? translated.toolName : null
464
465
  const unavailableReason = resolvedName === 'delegate'
@@ -469,18 +470,19 @@ async function invokeSessionTool(
469
470
  return { invoked: false, responseOverride: null, unavailableReason }
470
471
  }
471
472
 
473
+ const resolvedToolName = selectedTool.name
472
474
  const toolCallId = genId()
473
- ctx.emit({ t: 'tool_call', toolName, toolInput: JSON.stringify(translated.args), toolCallId })
475
+ ctx.emit({ t: 'tool_call', toolName: resolvedToolName, toolInput: JSON.stringify(translated.args), toolCallId })
474
476
  const toolOutput = await selectedTool.invoke(translated.args)
475
477
  const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
476
- ctx.emit({ t: 'tool_result', toolName, toolOutput: outputText, toolCallId })
478
+ ctx.emit({ t: 'tool_result', toolName: resolvedToolName, toolOutput: outputText, toolCallId })
477
479
 
478
480
  const delegateResponse = (
479
- toolName === 'delegate'
480
- || toolName.startsWith('delegate_to_')
481
+ resolvedToolName === 'delegate'
482
+ || resolvedToolName.startsWith('delegate_to_')
481
483
  ) ? extractDelegateResponse(outputText) : null
482
484
 
483
- calledNames.add(toolName)
485
+ calledNames.add(resolvedToolName)
484
486
 
485
487
  if (delegateResponse) {
486
488
  return { invoked: true, responseOverride: delegateResponse, toolOutputText: outputText }
@@ -475,7 +475,7 @@ export async function buildProactiveMemorySection(
475
475
  )
476
476
  sections.push(`## Recalled Context\nRelevant memories from previous interactions:\n${recalledLines.join('\n')}`)
477
477
  if (knowledgeTrace?.hits.length) {
478
- const groundingLines = knowledgeTrace.hits.map((hit) =>
478
+ const groundingLines = knowledgeTrace.hits.slice(0, 10).map((hit) =>
479
479
  `- [${hit.chunkIndex + 1}/${hit.chunkCount}] ${hit.sourceTitle}: ${hit.snippet}`,
480
480
  )
481
481
  sections.push(`## Source Grounding\nSource-backed knowledge retrieved for this turn:\n${groundingLines.join('\n')}`)
@@ -488,7 +488,7 @@ export async function buildProactiveMemorySection(
488
488
  }
489
489
 
490
490
  if (knowledgeTrace?.hits.length) {
491
- const groundingLines = knowledgeTrace.hits.map((hit) =>
491
+ const groundingLines = knowledgeTrace.hits.slice(0, 10).map((hit) =>
492
492
  `- [${hit.chunkIndex + 1}/${hit.chunkCount}] ${hit.sourceTitle}: ${hit.snippet}`,
493
493
  )
494
494
  return {
@@ -695,7 +695,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
695
695
  (result.summaryAdded ? ' (LLM summary)' : ' (sliding window fallback)'),
696
696
  )
697
697
  }
698
- } catch { /* non-critical */ }
698
+ } catch (compactErr) { log.warn(TAG, `Auto-compaction failed for ${session.id}:`, compactErr) }
699
699
 
700
700
  // Truncate oversized assistant messages in history to prevent context blowout
701
701
  const HISTORY_MSG_MAX_CHARS = 8_000
@@ -787,7 +787,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
787
787
  if (warning) {
788
788
  prompt = joinPromptSegments(warning, prompt)
789
789
  }
790
- } catch { /* non-critical */ }
790
+ } catch (degradeErr) { log.warn(TAG, `Context degradation check failed for ${session.id}:`, degradeErr) }
791
791
 
792
792
  await runCapabilityHook(
793
793
  'llmInput',
@@ -9,6 +9,9 @@ import {
9
9
  loadCredentials,
10
10
  saveCredential,
11
11
  } from '@/lib/server/credentials/credential-repository'
12
+ import { log } from '@/lib/server/logger'
13
+
14
+ const TAG = 'credential-service'
12
15
 
13
16
  export type CredentialSummary = Pick<Credential, 'id' | 'provider' | 'name' | 'createdAt'>
14
17
 
@@ -55,7 +58,12 @@ export function resolveCredentialSecret(credentialId: string | null | undefined)
55
58
  if (!credential?.encryptedKey) return null
56
59
  try {
57
60
  return decryptKey(credential.encryptedKey)
58
- } catch {
61
+ } catch (err) {
62
+ log.warn(TAG, `Failed to decrypt credential "${id}" — CREDENTIAL_SECRET may have changed since this key was stored. Re-add the API key to fix.`, {
63
+ credentialId: id,
64
+ provider: credential.provider,
65
+ error: err instanceof Error ? err.message : String(err),
66
+ })
59
67
  return null
60
68
  }
61
69
  }
@@ -1,5 +1,5 @@
1
1
  import { stripOllamaCloudModelSuffix } from '@/lib/ollama-model'
2
- import { isOllamaCloudEndpoint, resolveStoredOllamaMode } from '@/lib/ollama-mode'
2
+ import { isOllamaCloudEndpoint, normalizeOllamaCloudEndpoint, resolveStoredOllamaMode } from '@/lib/ollama-mode'
3
3
  import { PROVIDER_DEFAULTS } from '@/lib/providers/provider-defaults'
4
4
 
5
5
  const OLLAMA_CLOUD_KEY_ENV_VARS = ['OLLAMA_API_KEY', 'OLLAMA_CLOUD_API_KEY'] as const
@@ -41,7 +41,7 @@ export function resolveOllamaRuntimeConfig(input: {
41
41
  const cloudApiKey = resolveOllamaCloudApiKey(explicitApiKey)
42
42
  const useCloud = ollamaMode === 'cloud'
43
43
  const endpoint = useCloud
44
- ? (isOllamaCloudEndpoint(explicitEndpoint) ? explicitEndpoint! : PROVIDER_DEFAULTS.ollamaCloud)
44
+ ? normalizeOllamaCloudEndpoint(isOllamaCloudEndpoint(explicitEndpoint) ? explicitEndpoint! : PROVIDER_DEFAULTS.ollamaCloud)
45
45
  : (explicitEndpoint && !isOllamaCloudEndpoint(explicitEndpoint) ? explicitEndpoint : PROVIDER_DEFAULTS.ollama)
46
46
 
47
47
  return {
@@ -81,7 +81,14 @@ function normalizeWsUrl(raw: string): string {
81
81
  }
82
82
 
83
83
  function resolveTokenForCredential(credentialId?: string | null): string | undefined {
84
- return resolveCredentialSecret(credentialId) || undefined
84
+ if (!credentialId) return undefined
85
+ const secret = resolveCredentialSecret(credentialId)
86
+ if (!secret) {
87
+ log.warn(TAG, `Credential "${credentialId}" is referenced but could not be resolved — gateway connection will lack a token`, {
88
+ credentialId,
89
+ })
90
+ }
91
+ return secret || undefined
85
92
  }
86
93
 
87
94
  export function resolveGatewayConfig(target?: {
@@ -305,8 +305,10 @@ export async function pingAnthropic(apiKey: string): Promise<{ ok: boolean; mess
305
305
  }
306
306
 
307
307
  export async function pingOllama(endpoint: string, apiKey?: string): Promise<{ ok: boolean; message: string }> {
308
- const normalizedEndpoint = (endpoint || 'http://localhost:11434').replace(/\/+$/, '')
308
+ let normalizedEndpoint = (endpoint || 'http://localhost:11434').replace(/\/+$/, '')
309
309
  const useCloud = isOllamaCloudEndpoint(normalizedEndpoint)
310
+ // Normalize api.ollama.com -> ollama.com to avoid 301 redirect that drops auth
311
+ if (useCloud) normalizedEndpoint = normalizedEndpoint.replace(/^(https?:\/\/)(?:www\.|api\.)?ollama\.com/i, '$1ollama.com')
310
312
  if (useCloud && !apiKey) {
311
313
  return { ok: false, message: 'Ollama Cloud requires an API key.' }
312
314
  }
@@ -319,10 +319,11 @@ export function buildAgentHeartbeatPrompt(
319
319
  agent: HeartbeatPromptAgent | null | undefined,
320
320
  fallbackPrompt: string,
321
321
  heartbeatFileContent: string,
322
- opts?: { approvals?: Record<string, ApprovalRequest>; chatrooms?: Record<string, Chatroom> },
322
+ opts?: { approvals?: Record<string, ApprovalRequest>; chatrooms?: Record<string, Chatroom>; lightContext?: boolean },
323
323
  ): string {
324
324
  if (!agent) return fallbackPrompt
325
325
 
326
+ const light = opts?.lightContext === true
326
327
  const sections: string[] = []
327
328
 
328
329
  // ── Phase 1: Identity context ──
@@ -331,11 +332,11 @@ export function buildAgentHeartbeatPrompt(
331
332
  const identityContext = buildIdentityContext(session, agent)
332
333
  const continuityContext = buildIdentityContinuityContext(session, agent)
333
334
  if (identityContext) sections.push(identityContext)
334
- if (continuityContext) sections.push(continuityContext)
335
+ if (!light && continuityContext) sections.push(continuityContext)
335
336
  const description = agent.description || ''
336
337
  const soul = agent.soul || ''
337
338
  if (description) sections.push(`Description: ${description}`)
338
- if (soul) sections.push(`Persona: ${soul.slice(0, 300)}`)
339
+ if (!light && soul) sections.push(`Persona: ${soul.slice(0, 300)}`)
339
340
 
340
341
  // ── Phase 2: Pending approvals ──
341
342
  const agentId = agent.id || session.agentId || ''
@@ -346,7 +347,7 @@ export function buildAgentHeartbeatPrompt(
346
347
  (a) => a.status === 'pending' && a.agentId === agentId,
347
348
  )
348
349
  if (pending.length > 0) {
349
- const approvalLines = pending.slice(0, 5).map(
350
+ const approvalLines = pending.slice(0, light ? 2 : 5).map(
350
351
  (a) => `- [${a.category}] ${a.title}${a.description ? `: ${a.description.slice(0, 100)}` : ''}`,
351
352
  )
352
353
  sections.push(`### Pending Approvals (${pending.length})\n${approvalLines.join('\n')}`)
@@ -366,7 +367,7 @@ export function buildAgentHeartbeatPrompt(
366
367
  const dynamicGoal = agent.heartbeatGoal || ''
367
368
  const dynamicNextAction = agent.heartbeatNextAction || ''
368
369
  const systemPrompt = agent.systemPrompt || ''
369
- const goalSummary = systemPrompt.slice(0, 500)
370
+ const goalSummary = systemPrompt.slice(0, light ? 200 : 500)
370
371
 
371
372
  if (dynamicGoal) {
372
373
  sections.push(`Current goal (self-set): ${dynamicGoal}`)
@@ -377,12 +378,13 @@ export function buildAgentHeartbeatPrompt(
377
378
 
378
379
  const strippedContent = stripBlockedItems(heartbeatFileContent)
379
380
  const effectiveFileContent = isHeartbeatContentEffectivelyEmpty(strippedContent) ? '' : strippedContent
380
- if (effectiveFileContent) sections.push(`\nHEARTBEAT.md contents:\n${effectiveFileContent.slice(0, 2000)}`)
381
+ if (effectiveFileContent) sections.push(`\nHEARTBEAT.md contents:\n${effectiveFileContent.slice(0, light ? 500 : 2000)}`)
381
382
 
383
+ const messageCount = light ? 2 : 5
382
384
  const recentMessages = (
383
385
  Array.isArray(session.messages)
384
- ? session.messages.slice(-5)
385
- : (session.id ? getRecentMessages(session.id, 5) : [])
386
+ ? session.messages.slice(-messageCount)
387
+ : (session.id ? getRecentMessages(session.id, messageCount) : [])
386
388
  ) as HeartbeatPromptMessage[]
387
389
  const recentContext = recentMessages
388
390
  .map((m) => {
@@ -395,8 +397,8 @@ export function buildAgentHeartbeatPrompt(
395
397
  .join('\n')
396
398
  if (recentContext) sections.push(`Recent conversation:\n${recentContext}`)
397
399
 
398
- // ── Phase 4b: Chatroom mentions since last heartbeat ──
399
- try {
400
+ // ── Phase 4b: Chatroom mentions since last heartbeat (skip in light mode) ──
401
+ if (!light) try {
400
402
  const chatrooms = Object.values(opts?.chatrooms ?? loadChatrooms()) as Chatroom[]
401
403
  const myChatrooms = chatrooms.filter((c) => !c.archivedAt && c.agentIds?.includes(agentId))
402
404
  if (myChatrooms.length > 0) {
@@ -718,6 +720,7 @@ export async function tickHeartbeats() {
718
720
  const baseHeartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent, {
719
721
  approvals: sharedApprovals,
720
722
  chatrooms: sharedChatrooms,
723
+ lightContext: cfg.lightContext,
721
724
  })
722
725
  let heartbeatMessage = isMainSession(session)
723
726
  ? buildMainLoopHeartbeatPrompt(session, baseHeartbeatMessage)
@@ -6,6 +6,8 @@ import { loadSettings, loadSession, loadAgent, loadMcpServers, patchAgent, patch
6
6
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
7
7
  import { log } from '../logger'
8
8
  import { resolveSessionToolPolicy } from '../tool-capability-policy'
9
+ import { truncateToolResultText, calculateMaxToolResultChars } from '../chat-execution/tool-result-guard'
10
+ import { getContextWindowSize } from '../context-manager'
9
11
  import { expandExtensionIds } from '../tool-aliases'
10
12
  import type { ToolContext, SessionToolsResult, ToolBuildContext, AbortSignalRef } from './context'
11
13
 
@@ -455,9 +457,17 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
455
457
  }
456
458
  const effectiveArgs = hookResult.input ?? guardedArgs
457
459
  const result = await candidate.invoke(effectiveArgs ?? {})
458
- const outputText = typeof result === 'string' ? result : JSON.stringify(result)
460
+ const rawOutput = typeof result === 'string' ? result : JSON.stringify(result)
461
+ // Truncate oversized tool outputs before LangGraph feeds them back to
462
+ // the LLM. Without this, a single large tool result (e.g. shell dump,
463
+ // large web fetch) can blow out the context window inside LangGraph's
464
+ // internal state, which the auto-compaction system cannot observe.
465
+ const currentSession = resolveCurrentSession()
466
+ const maxChars = calculateMaxToolResultChars(getContextWindowSize(currentSession?.provider || '', currentSession?.model || ''))
467
+ const outputText = truncateToolResultText(rawOutput, maxChars)
459
468
  setSpanAttributes(span, {
460
469
  'swarmclaw.tool.output_bytes': Buffer.byteLength(outputText, 'utf-8'),
470
+ ...(rawOutput.length !== outputText.length ? { 'swarmclaw.tool.truncated_from': rawOutput.length } : {}),
461
471
  })
462
472
  await runCapabilityHook(
463
473
  'afterToolExec',
@@ -94,6 +94,21 @@ if (!IS_BUILD_BOOTSTRAP) {
94
94
  }
95
95
  db.pragma('foreign_keys = ON')
96
96
 
97
+ // Graceful shutdown: checkpoint WAL and close the database to prevent
98
+ // corruption when the process is killed (e.g. during npm run update:easy).
99
+ if (!IS_BUILD_BOOTSTRAP) {
100
+ const shutdownDb = () => {
101
+ try {
102
+ db.pragma('wal_checkpoint(TRUNCATE)')
103
+ db.close()
104
+ } catch {
105
+ // Best-effort — process is exiting.
106
+ }
107
+ }
108
+ process.on('SIGTERM', shutdownDb)
109
+ process.on('SIGINT', shutdownDb)
110
+ }
111
+
97
112
  /** Run a function inside an immediate SQLite transaction for atomicity. */
98
113
  export function withTransaction<T>(fn: () => T): T {
99
114
  const wrapped = db.transaction(fn)
package/src/proxy.ts CHANGED
@@ -87,13 +87,19 @@ export function proxy(request: NextRequest) {
87
87
  })
88
88
  }
89
89
 
90
- // Only protect API routes (not auth or inbound webhooks)
90
+ // A2A endpoints use their own authentication (Authorization: Bearer / x-a2a-access-key)
91
+ const isA2ARoute = pathname === '/api/a2a'
92
+ || pathname.startsWith('/api/a2a/')
93
+ || pathname === '/api/.well-known/agent-card'
94
+
95
+ // Only protect API routes (not auth, inbound webhooks, or A2A)
91
96
  if (
92
97
  !pathname.startsWith('/api/')
93
98
  || pathname === '/api/auth'
94
99
  || pathname === '/api/healthz'
95
100
  || isWebhookTrigger
96
101
  || isConnectorWebhook
102
+ || isA2ARoute
97
103
  ) {
98
104
  return NextResponse.next()
99
105
  }