@swarmclawai/swarmclaw 1.9.32 → 1.9.34
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 +38 -0
- package/package.json +2 -2
- package/src/app/api/agents/agents-route.test.ts +36 -0
- package/src/app/api/extensions/builtins/route.ts +2 -1
- package/src/app/api/tasks/[id]/retry/route.ts +12 -0
- package/src/app/api/tasks/task-workspace-route.test.ts +62 -0
- package/src/cli/index.js +1 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-sheet.tsx +41 -2
- package/src/components/chat/chat-tool-toggles.tsx +29 -7
- package/src/lib/providers/openclaw.test.ts +8 -1
- package/src/lib/providers/openclaw.ts +4 -2
- package/src/lib/server/chat-execution/chat-execution-connector-delivery.ts +17 -1
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -2
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +24 -0
- package/src/lib/server/connectors/connector-inbound.ts +6 -6
- package/src/lib/server/connectors/connector-lifecycle.ts +17 -1
- package/src/lib/server/connectors/openclaw.test.ts +9 -2
- package/src/lib/server/connectors/openclaw.ts +1 -1
- package/src/lib/server/session-tools/credential-env.ts +4 -3
- package/src/lib/server/session-tools/crud.ts +5 -0
- package/src/lib/server/session-tools/discovery-approvals.test.ts +49 -0
- package/src/lib/server/session-tools/execute.test.ts +29 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +55 -0
- package/src/lib/server/storage-auth.test.ts +204 -0
- package/src/lib/server/storage-auth.ts +309 -16
- package/src/lib/server/tasks/task-route-service.ts +46 -0
- package/src/lib/server/tasks/task-service.test.ts +50 -0
- package/src/lib/server/tasks/task-service.ts +16 -3
- package/src/lib/server/universal-tool-access.test.ts +16 -0
- package/src/lib/server/universal-tool-access.ts +3 -1
- package/src/lib/validation/schemas.ts +12 -0
- package/src/types/agent.ts +1 -0
package/README.md
CHANGED
|
@@ -151,6 +151,25 @@ openclaw skills install swarmclaw
|
|
|
151
151
|
|
|
152
152
|
[Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
|
|
153
153
|
|
|
154
|
+
## v1.9.34 Highlights
|
|
155
|
+
|
|
156
|
+
Credential recovery and external extension access release for npm-global upgrades and scoped agent tool configuration.
|
|
157
|
+
|
|
158
|
+
- **Credential secret recovery.** Startup now checks prior npm-global build env files before accepting a fresh per-version `CREDENTIAL_SECRET`, and validates candidate secrets against existing encrypted credentials before persisting `DATA_DIR/credential-secret`.
|
|
159
|
+
- **Clear connector failures.** Connector startup now logs and surfaces credential decrypt failures directly instead of falling through to a misleading "No bot token configured" error.
|
|
160
|
+
- **External extension tools.** Scoped agents now keep explicitly attached external `*.js` and `*.mjs` extensions, and the agent/chat tool controls persist enabled external tools through the `extensions` field.
|
|
161
|
+
- **Regression coverage.** Added tests for previous-build credential recovery, non-decrypting secret replacement, scoped external extension access, and extension access persistence.
|
|
162
|
+
|
|
163
|
+
## v1.9.33 Highlights
|
|
164
|
+
|
|
165
|
+
Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
|
|
166
|
+
|
|
167
|
+
- **Credential durability.** Execute-tool credential injection now reads the persisted `encryptedKey` field, and `CREDENTIAL_SECRET` now resolves in a stable order: explicit environment value, `DATA_DIR/credential-secret`, legacy env files, then generated fallback.
|
|
168
|
+
- **Delegated task dispatch.** Agent-created tasks delegated to another agent auto-queue when no explicit status is supplied, and failed dead-lettered tasks can be requeued through `POST /api/tasks/:id/retry`.
|
|
169
|
+
- **Connector output hygiene.** Connector replies now reuse the internal metadata scrubber before delivery and persistence, while successful non-connector delivery tool output is no longer overwritten as an unconfirmed send.
|
|
170
|
+
- **Agent and gateway compatibility.** Agent updates preserve workspace filesystem settings, and OpenClaw gateway routes now use protocol version 4.
|
|
171
|
+
- **Regression coverage.** Added tests for credential env injection, secret precedence, delegated queueing, failed-task retry, connector sanitization, agent workspace settings, and OpenClaw gateway protocol exports.
|
|
172
|
+
|
|
154
173
|
## v1.9.32 Highlights
|
|
155
174
|
|
|
156
175
|
PR integration release for background model routing, reflection memory controls, and current ClawHub install guidance.
|
|
@@ -435,6 +454,25 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
435
454
|
|
|
436
455
|
## Releases
|
|
437
456
|
|
|
457
|
+
### v1.9.34 Highlights
|
|
458
|
+
|
|
459
|
+
Credential recovery and external extension access release for npm-global upgrades and scoped agent tool configuration.
|
|
460
|
+
|
|
461
|
+
- **Credential secret recovery.** Startup now checks prior npm-global build env files before accepting a fresh per-version `CREDENTIAL_SECRET`, and validates candidate secrets against existing encrypted credentials before persisting `DATA_DIR/credential-secret`.
|
|
462
|
+
- **Clear connector failures.** Connector startup now logs and surfaces credential decrypt failures directly instead of falling through to a misleading "No bot token configured" error.
|
|
463
|
+
- **External extension tools.** Scoped agents now keep explicitly attached external `*.js` and `*.mjs` extensions, and the agent/chat tool controls persist enabled external tools through the `extensions` field.
|
|
464
|
+
- **Regression coverage.** Added tests for previous-build credential recovery, non-decrypting secret replacement, scoped external extension access, and extension access persistence.
|
|
465
|
+
|
|
466
|
+
### v1.9.33 Highlights
|
|
467
|
+
|
|
468
|
+
Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
|
|
469
|
+
|
|
470
|
+
- **Credential durability.** Execute-tool credential injection now reads the persisted `encryptedKey` field, and `CREDENTIAL_SECRET` now resolves in a stable order: explicit environment value, `DATA_DIR/credential-secret`, legacy env files, then generated fallback.
|
|
471
|
+
- **Delegated task dispatch.** Agent-created tasks delegated to another agent auto-queue when no explicit status is supplied, and failed dead-lettered tasks can be requeued through `POST /api/tasks/:id/retry`.
|
|
472
|
+
- **Connector output hygiene.** Connector replies now reuse the internal metadata scrubber before delivery and persistence, while successful non-connector delivery tool output is no longer overwritten as an unconfirmed send.
|
|
473
|
+
- **Agent and gateway compatibility.** Agent updates preserve workspace filesystem settings, and OpenClaw gateway routes now use protocol version 4.
|
|
474
|
+
- **Regression coverage.** Added tests for credential env injection, secret precedence, delegated queueing, failed-task retry, connector sanitization, agent workspace settings, and OpenClaw gateway protocol exports.
|
|
475
|
+
|
|
438
476
|
### v1.9.32 Highlights
|
|
439
477
|
|
|
440
478
|
PR integration release for background model routing, reflection memory controls, and current ClawHub install guidance.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.34",
|
|
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",
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
"next": "16.2.4",
|
|
155
155
|
"next-themes": "^0.4.6",
|
|
156
156
|
"nodemailer": "^8.0.1",
|
|
157
|
-
"openclaw": "^2026.
|
|
157
|
+
"openclaw": "^2026.5.12",
|
|
158
158
|
"pdf-parse": "^2.4.5",
|
|
159
159
|
"qrcode": "^1.5.4",
|
|
160
160
|
"radix-ui": "^1.4.3",
|
|
@@ -206,6 +206,42 @@ test('PUT /api/agents/:id updates planning mode without clobbering other fields'
|
|
|
206
206
|
assert.equal(body.proactiveMemory, false)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
+
test('PUT /api/agents/:id persists workspace filesystem settings', async () => {
|
|
210
|
+
seedAgent('agent-workspace-settings', {
|
|
211
|
+
name: 'Workspace Agent',
|
|
212
|
+
workspace: null,
|
|
213
|
+
filesystemScope: null,
|
|
214
|
+
fileAccessPolicy: null,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const response = await putAgent(new Request('http://local/api/agents/agent-workspace-settings', {
|
|
218
|
+
method: 'PUT',
|
|
219
|
+
headers: { 'content-type': 'application/json' },
|
|
220
|
+
body: JSON.stringify({
|
|
221
|
+
workspace: '/tmp/swarmclaw-agent-workspace',
|
|
222
|
+
filesystemScope: 'workspace',
|
|
223
|
+
fileAccessPolicy: {
|
|
224
|
+
allowedPaths: ['/tmp/swarmclaw-agent-workspace'],
|
|
225
|
+
blockedPaths: ['/tmp/swarmclaw-agent-workspace/private'],
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
}), routeParams('agent-workspace-settings'))
|
|
229
|
+
|
|
230
|
+
assert.equal(response.status, 200)
|
|
231
|
+
const body = await response.json()
|
|
232
|
+
assert.equal(body.workspace, '/tmp/swarmclaw-agent-workspace')
|
|
233
|
+
assert.equal(body.filesystemScope, 'workspace')
|
|
234
|
+
assert.deepEqual(body.fileAccessPolicy, {
|
|
235
|
+
allowedPaths: ['/tmp/swarmclaw-agent-workspace'],
|
|
236
|
+
blockedPaths: ['/tmp/swarmclaw-agent-workspace/private'],
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const stored = loadAgents()['agent-workspace-settings']
|
|
240
|
+
assert.equal(stored.workspace, '/tmp/swarmclaw-agent-workspace')
|
|
241
|
+
assert.equal(stored.filesystemScope, 'workspace')
|
|
242
|
+
assert.deepEqual(stored.fileAccessPolicy, body.fileAccessPolicy)
|
|
243
|
+
})
|
|
244
|
+
|
|
209
245
|
test('PUT /api/agents/:id rejects non-string name', async () => {
|
|
210
246
|
seedAgent('agent-bad-name', { name: 'Good' })
|
|
211
247
|
|
|
@@ -20,7 +20,7 @@ export async function GET() {
|
|
|
20
20
|
|
|
21
21
|
// For external extensions that are enabled, also collect their concrete tool names
|
|
22
22
|
// so the UI can show those tools in the toggles
|
|
23
|
-
const externalTools: Array<{ extensionId: string; toolName: string; label: string; description: string }> = []
|
|
23
|
+
const externalTools: Array<{ extensionId: string; extensionName: string; toolName: string; label: string; description: string }> = []
|
|
24
24
|
for (const meta of all) {
|
|
25
25
|
if (meta.isBuiltin || !meta.enabled) continue
|
|
26
26
|
try {
|
|
@@ -28,6 +28,7 @@ export async function GET() {
|
|
|
28
28
|
for (const entry of tools) {
|
|
29
29
|
externalTools.push({
|
|
30
30
|
extensionId: entry.extensionId,
|
|
31
|
+
extensionName: meta.name || meta.filename,
|
|
31
32
|
toolName: entry.tool.name,
|
|
32
33
|
label: entry.tool.name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
33
34
|
description: entry.tool.description || '',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
3
|
+
import { retryTaskFromRoute } from '@/lib/server/tasks/task-route-service'
|
|
4
|
+
|
|
5
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
6
|
+
const { id } = await params
|
|
7
|
+
const result = retryTaskFromRoute(id)
|
|
8
|
+
if (!result.ok && result.status === 404) return notFound()
|
|
9
|
+
return result.ok
|
|
10
|
+
? NextResponse.json(result.payload)
|
|
11
|
+
: NextResponse.json(result.payload, { status: result.status })
|
|
12
|
+
}
|
|
@@ -19,6 +19,7 @@ let getTaskHandoff: typeof import('./[id]/handoff/route')['GET']
|
|
|
19
19
|
let postTaskHandoff: typeof import('./[id]/handoff/route')['POST']
|
|
20
20
|
let getTaskExecutionPolicy: typeof import('./[id]/execution-policy/route')['GET']
|
|
21
21
|
let postTaskExecutionPolicy: typeof import('./[id]/execution-policy/route')['POST']
|
|
22
|
+
let postTaskRetry: typeof import('./[id]/retry/route')['POST']
|
|
22
23
|
let getTaskHandoffs: typeof import('./handoffs/route')['GET']
|
|
23
24
|
let getTasks: typeof import('./route')['GET']
|
|
24
25
|
let storage: typeof import('@/lib/server/storage')
|
|
@@ -57,6 +58,7 @@ before(async () => {
|
|
|
57
58
|
const policyRoute = await import('./[id]/execution-policy/route')
|
|
58
59
|
getTaskExecutionPolicy = policyRoute.GET
|
|
59
60
|
postTaskExecutionPolicy = policyRoute.POST
|
|
61
|
+
postTaskRetry = (await import('./[id]/retry/route')).POST
|
|
60
62
|
getTaskHandoffs = (await import('./handoffs/route')).GET
|
|
61
63
|
getTasks = (await import('./route')).GET
|
|
62
64
|
})
|
|
@@ -255,6 +257,66 @@ test('GET /api/tasks/:id/execution-policy returns policy summary', async () => {
|
|
|
255
257
|
assert.equal(body.summary.status, 'waiting')
|
|
256
258
|
})
|
|
257
259
|
|
|
260
|
+
test('POST /api/tasks/:id/retry requeues a dead-lettered failed task', async () => {
|
|
261
|
+
seedTask('task-dead-letter-retry', {
|
|
262
|
+
title: 'Dead Letter Retry',
|
|
263
|
+
status: 'failed',
|
|
264
|
+
attempts: 3,
|
|
265
|
+
maxAttempts: 3,
|
|
266
|
+
retryScheduledAt: Date.now() + 60_000,
|
|
267
|
+
deadLetteredAt: Date.now(),
|
|
268
|
+
checkoutRunId: 'run-failed',
|
|
269
|
+
error: 'Dead-lettered after 3/3 attempts: timeout',
|
|
270
|
+
validation: { ok: false, reasons: ['No result'], checkedAt: Date.now() },
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const response = await postTaskRetry(
|
|
274
|
+
new Request('http://local/api/tasks/task-dead-letter-retry/retry', { method: 'POST' }),
|
|
275
|
+
routeParams('task-dead-letter-retry'),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
assert.equal(response.status, 200)
|
|
279
|
+
const body = await response.json() as BoardTask
|
|
280
|
+
assert.equal(body.status, 'queued')
|
|
281
|
+
assert.equal(body.attempts, 0)
|
|
282
|
+
assert.equal(body.retryScheduledAt, null)
|
|
283
|
+
assert.equal(body.deadLetteredAt, null)
|
|
284
|
+
assert.equal(body.checkoutRunId, null)
|
|
285
|
+
assert.equal(body.error, null)
|
|
286
|
+
assert.equal(body.validation, null)
|
|
287
|
+
assert.equal(storage.loadQueue().includes('task-dead-letter-retry'), true)
|
|
288
|
+
assert.equal(body.comments?.some((comment) => comment.text.includes('retry requested')), true)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('POST /api/tasks/:id/retry rejects tasks still blocked by dependencies', async () => {
|
|
292
|
+
seedTask('task-blocked-retry', {
|
|
293
|
+
title: 'Blocked Retry',
|
|
294
|
+
status: 'failed',
|
|
295
|
+
blockedBy: ['retry-dep'],
|
|
296
|
+
deadLetteredAt: Date.now(),
|
|
297
|
+
})
|
|
298
|
+
const tasks = storage.loadTasks()
|
|
299
|
+
tasks['retry-dep'] = {
|
|
300
|
+
id: 'retry-dep',
|
|
301
|
+
title: 'Retry Dependency',
|
|
302
|
+
description: '',
|
|
303
|
+
status: 'running',
|
|
304
|
+
agentId: 'agent-1',
|
|
305
|
+
createdAt: Date.now(),
|
|
306
|
+
updatedAt: Date.now(),
|
|
307
|
+
} as BoardTask
|
|
308
|
+
storage.saveTasks(tasks)
|
|
309
|
+
|
|
310
|
+
const response = await postTaskRetry(
|
|
311
|
+
new Request('http://local/api/tasks/task-blocked-retry/retry', { method: 'POST' }),
|
|
312
|
+
routeParams('task-blocked-retry'),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
assert.equal(response.status, 409)
|
|
316
|
+
assert.equal(storage.loadTasks()['task-blocked-retry']?.status, 'failed')
|
|
317
|
+
assert.equal(storage.loadQueue().includes('task-blocked-retry'), false)
|
|
318
|
+
})
|
|
319
|
+
|
|
258
320
|
test('POST /api/tasks/:id/handoff saves markdown and JSON snapshots into the workspace', async () => {
|
|
259
321
|
seedTask('task-handoff-save', {
|
|
260
322
|
title: 'Saved Handoff Task',
|
package/src/cli/index.js
CHANGED
|
@@ -761,6 +761,7 @@ const COMMAND_GROUPS = [
|
|
|
761
761
|
cmd('handoffs', 'GET', '/tasks/handoffs', 'List task handoff readiness packets'),
|
|
762
762
|
cmd('execution-policy', 'GET', '/tasks/:id/execution-policy', 'Get task execution policy state'),
|
|
763
763
|
cmd('execution-policy-decision', 'POST', '/tasks/:id/execution-policy', 'Approve, request changes, or reset a task policy stage', { expectsJsonBody: true }),
|
|
764
|
+
cmd('retry', 'POST', '/tasks/:id/retry', 'Retry a failed task and requeue it'),
|
|
764
765
|
cmd('create', 'POST', '/tasks', 'Create task', { expectsJsonBody: true }),
|
|
765
766
|
cmd('bulk', 'POST', '/tasks/bulk', 'Bulk update tasks (status/agent/project)', { expectsJsonBody: true }),
|
|
766
767
|
cmd('update', 'PUT', '/tasks/:id', 'Update task', { expectsJsonBody: true }),
|
package/src/cli/index.test.js
CHANGED
|
@@ -225,6 +225,36 @@ test('tasks execution-policy-decision posts policy decisions', async () => {
|
|
|
225
225
|
assert.equal(stderr.toString(), '')
|
|
226
226
|
})
|
|
227
227
|
|
|
228
|
+
test('tasks retry posts to the failed-task retry endpoint', async () => {
|
|
229
|
+
const stdout = makeWritable()
|
|
230
|
+
const stderr = makeWritable()
|
|
231
|
+
const calls = []
|
|
232
|
+
|
|
233
|
+
const fetchImpl = async (url, init) => {
|
|
234
|
+
calls.push({ url: String(url), init })
|
|
235
|
+
return jsonResponse({ id: 'task-1', status: 'queued' })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const exitCode = await runCli(
|
|
239
|
+
['tasks', 'retry', 'task-1', '--json'],
|
|
240
|
+
{
|
|
241
|
+
fetchImpl,
|
|
242
|
+
stdout,
|
|
243
|
+
stderr,
|
|
244
|
+
env: {},
|
|
245
|
+
cwd: process.cwd(),
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
assert.equal(exitCode, 0)
|
|
250
|
+
assert.equal(calls.length, 1)
|
|
251
|
+
assert.match(calls[0].url, /\/api\/tasks\/task-1\/retry$/)
|
|
252
|
+
assert.equal(calls[0].init.method, 'POST')
|
|
253
|
+
assert.equal(calls[0].init.body, undefined)
|
|
254
|
+
assert.match(stdout.toString(), /"queued"/)
|
|
255
|
+
assert.equal(stderr.toString(), '')
|
|
256
|
+
})
|
|
257
|
+
|
|
228
258
|
test('gateways drain command posts a lifecycle control action', async () => {
|
|
229
259
|
const stdout = makeWritable()
|
|
230
260
|
const stderr = makeWritable()
|
package/src/cli/spec.js
CHANGED
|
@@ -541,6 +541,7 @@ const COMMAND_GROUPS = {
|
|
|
541
541
|
handoffs: { description: 'List task handoff readiness packets', method: 'GET', path: '/tasks/handoffs' },
|
|
542
542
|
'execution-policy': { description: 'Get task execution policy state', method: 'GET', path: '/tasks/:id/execution-policy', params: ['id'] },
|
|
543
543
|
'execution-policy-decision': { description: 'Approve, request changes, or reset a task policy stage', method: 'POST', path: '/tasks/:id/execution-policy', params: ['id'] },
|
|
544
|
+
retry: { description: 'Retry a failed task and requeue it', method: 'POST', path: '/tasks/:id/retry', params: ['id'] },
|
|
544
545
|
create: { description: 'Create task', method: 'POST', path: '/tasks' },
|
|
545
546
|
bulk: { description: 'Bulk update tasks (status/agent/project)', method: 'POST', path: '/tasks/bulk' },
|
|
546
547
|
update: { description: 'Update task', method: 'PUT', path: '/tasks/:id', params: ['id'] },
|
|
@@ -57,6 +57,14 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
|
|
|
57
57
|
const CONNECTION_TEST_TIMEOUT_MS = 40_000
|
|
58
58
|
type AgentProviderId = string
|
|
59
59
|
|
|
60
|
+
interface ExtensionToolInfo {
|
|
61
|
+
extensionId: string
|
|
62
|
+
extensionName?: string
|
|
63
|
+
toolName: string
|
|
64
|
+
label: string
|
|
65
|
+
description: string
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
function SectionCard({
|
|
61
69
|
title,
|
|
62
70
|
description,
|
|
@@ -223,6 +231,7 @@ export function AgentSheet() {
|
|
|
223
231
|
const [toolAccessMode, setToolAccessMode] = useState<'universal' | 'scoped'>('scoped')
|
|
224
232
|
const [extensions, setExtensions] = useState<string[]>([])
|
|
225
233
|
const [enabledExtensionIds, setEnabledExtensionIds] = useState<Set<string> | null>(null)
|
|
234
|
+
const [externalTools, setExternalTools] = useState<ExtensionToolInfo[]>([])
|
|
226
235
|
const [skills, setSkills] = useState<string[]>([])
|
|
227
236
|
const [skillIds, setSkillIds] = useState<string[]>([])
|
|
228
237
|
const [mcpServerIds, setMcpServerIds] = useState<string[]>([])
|
|
@@ -423,8 +432,11 @@ export function AgentSheet() {
|
|
|
423
432
|
loadProjects()
|
|
424
433
|
loadClaudeSkills()
|
|
425
434
|
// Fetch enabled extension IDs so we can filter tool toggles
|
|
426
|
-
api<{ enabledExtensionIds: string[] }>('GET', '/extensions/builtins')
|
|
427
|
-
.then((res) => {
|
|
435
|
+
api<{ enabledExtensionIds: string[]; externalTools?: ExtensionToolInfo[] }>('GET', '/extensions/builtins')
|
|
436
|
+
.then((res) => {
|
|
437
|
+
if (res?.enabledExtensionIds) setEnabledExtensionIds(new Set(res.enabledExtensionIds))
|
|
438
|
+
if (Array.isArray(res?.externalTools)) setExternalTools(res.externalTools)
|
|
439
|
+
})
|
|
428
440
|
.catch(() => {})
|
|
429
441
|
setTestStatus('idle')
|
|
430
442
|
setTestMessage('')
|
|
@@ -2642,6 +2654,33 @@ export function AgentSheet() {
|
|
|
2642
2654
|
</div>
|
|
2643
2655
|
)}
|
|
2644
2656
|
|
|
2657
|
+
{!hasNativeCapabilities && externalTools.length > 0 && (
|
|
2658
|
+
<div className="mb-8">
|
|
2659
|
+
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Extension Tools</label>
|
|
2660
|
+
<p className="text-[12px] text-text-3/60 mb-3">Attach enabled external extension tools to this agent.</p>
|
|
2661
|
+
<div className="space-y-3">
|
|
2662
|
+
{externalTools.map((t) => {
|
|
2663
|
+
const attached = extensions.includes(t.extensionId)
|
|
2664
|
+
const description = t.extensionName
|
|
2665
|
+
? `${t.description || 'External extension tool'} (${t.extensionName})`
|
|
2666
|
+
: (t.description || 'External extension tool')
|
|
2667
|
+
return (
|
|
2668
|
+
<label key={`${t.extensionId}:${t.toolName}`} className="flex items-center gap-3 cursor-pointer">
|
|
2669
|
+
<div
|
|
2670
|
+
onClick={() => setExtensions((prev) => prev.includes(t.extensionId) ? prev.filter((x) => x !== t.extensionId) : [...prev, t.extensionId])}
|
|
2671
|
+
className={`w-11 h-6 rounded-full transition-all duration-200 relative shrink-0 ${attached ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.08] cursor-pointer'}`}
|
|
2672
|
+
>
|
|
2673
|
+
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${attached ? 'left-[22px]' : 'left-0.5'}`} />
|
|
2674
|
+
</div>
|
|
2675
|
+
<span className="font-display text-[14px] font-600 text-text-2">{t.label}</span>
|
|
2676
|
+
<span className="text-[12px] text-text-3">{description}</span>
|
|
2677
|
+
</label>
|
|
2678
|
+
)
|
|
2679
|
+
})}
|
|
2680
|
+
</div>
|
|
2681
|
+
</div>
|
|
2682
|
+
)}
|
|
2683
|
+
|
|
2645
2684
|
{/* Native capability provider note — not shown for OpenClaw (covered in connection status) */}
|
|
2646
2685
|
{hasNativeCapabilities && !openclawEnabled && (
|
|
2647
2686
|
<div className="mb-8 p-4 rounded-[14px] bg-white/[0.02] border border-white/[0.06]">
|
|
@@ -7,7 +7,7 @@ import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
|
|
|
7
7
|
import type { ToolDefinition } from '@/lib/tool-definitions'
|
|
8
8
|
import type { Session } from '@/types'
|
|
9
9
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
|
10
|
-
import { getEnabledToolIds, getEnabledExtensionIds } from '@/lib/capability-selection'
|
|
10
|
+
import { getEnabledToolIds, getEnabledExtensionIds, isExternalExtensionId } from '@/lib/capability-selection'
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
session: Session
|
|
@@ -15,6 +15,7 @@ interface Props {
|
|
|
15
15
|
|
|
16
16
|
interface ExtensionToolInfo {
|
|
17
17
|
extensionId: string
|
|
18
|
+
extensionName?: string
|
|
18
19
|
toolName: string
|
|
19
20
|
label: string
|
|
20
21
|
description: string
|
|
@@ -55,7 +56,20 @@ export function ChatToolToggles({ session }: Props) {
|
|
|
55
56
|
return () => document.removeEventListener('mousedown', handler)
|
|
56
57
|
}, [open])
|
|
57
58
|
|
|
58
|
-
const toggleTool = async (
|
|
59
|
+
const toggleTool = async (tool: ToolDefinition) => {
|
|
60
|
+
if (tool.extensionId && isExternalExtensionId(tool.extensionId)) {
|
|
61
|
+
const updatedExtensions = sessionExtensions.includes(tool.extensionId)
|
|
62
|
+
? sessionExtensions.filter((extensionId) => extensionId !== tool.extensionId)
|
|
63
|
+
: [...sessionExtensions, tool.extensionId]
|
|
64
|
+
await api('PUT', `/chats/${session.id}`, {
|
|
65
|
+
tools: sessionTools,
|
|
66
|
+
extensions: updatedExtensions,
|
|
67
|
+
})
|
|
68
|
+
await refreshSession(session.id)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const toolId = tool.id
|
|
59
73
|
const updated = sessionTools.includes(toolId)
|
|
60
74
|
? sessionTools.filter((t) => t !== toolId)
|
|
61
75
|
: [...sessionTools, toolId]
|
|
@@ -78,9 +92,9 @@ export function ChatToolToggles({ session }: Props) {
|
|
|
78
92
|
|
|
79
93
|
// Convert external extension tools into ToolDefinition-like items for display
|
|
80
94
|
const extensionToolDefs: ToolDefinition[] = externalTools.map((et) => ({
|
|
81
|
-
id: et.toolName
|
|
95
|
+
id: `${et.extensionId}:${et.toolName}`,
|
|
82
96
|
label: et.label,
|
|
83
|
-
description: et.description,
|
|
97
|
+
description: et.extensionName ? `${et.description || 'External extension tool'} (${et.extensionName})` : et.description,
|
|
84
98
|
extensionId: et.extensionId,
|
|
85
99
|
}))
|
|
86
100
|
|
|
@@ -92,7 +106,11 @@ export function ChatToolToggles({ session }: Props) {
|
|
|
92
106
|
|
|
93
107
|
const allVisibleTools = groups.flatMap((g) => g.tools)
|
|
94
108
|
const totalCount = allVisibleTools.length
|
|
95
|
-
const enabledCount =
|
|
109
|
+
const enabledCount = allVisibleTools.filter((tool) => (
|
|
110
|
+
tool.extensionId && isExternalExtensionId(tool.extensionId)
|
|
111
|
+
? sessionExtensions.includes(tool.extensionId)
|
|
112
|
+
: sessionTools.includes(tool.id)
|
|
113
|
+
)).length
|
|
96
114
|
|
|
97
115
|
return (
|
|
98
116
|
<div className="relative" ref={ref}>
|
|
@@ -120,13 +138,17 @@ export function ChatToolToggles({ session }: Props) {
|
|
|
120
138
|
<p className="text-[10px] font-600 text-text-3/60 uppercase tracking-wider mb-2">{group.label}</p>
|
|
121
139
|
{group.tools.map((tool) => {
|
|
122
140
|
const extDisabled = !isExtensionEnabled(tool)
|
|
123
|
-
const enabled = !extDisabled &&
|
|
141
|
+
const enabled = !extDisabled && (
|
|
142
|
+
tool.extensionId && isExternalExtensionId(tool.extensionId)
|
|
143
|
+
? sessionExtensions.includes(tool.extensionId)
|
|
144
|
+
: sessionTools.includes(tool.id)
|
|
145
|
+
)
|
|
124
146
|
return (
|
|
125
147
|
<Tooltip key={tool.id}>
|
|
126
148
|
<TooltipTrigger asChild>
|
|
127
149
|
<label className={`flex items-center gap-2.5 py-1.5 ${extDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}>
|
|
128
150
|
<div
|
|
129
|
-
onClick={() => !extDisabled && toggleTool(tool
|
|
151
|
+
onClick={() => !extDisabled && toggleTool(tool)}
|
|
130
152
|
className={`w-8 h-[18px] rounded-full transition-all duration-200 relative shrink-0
|
|
131
153
|
${extDisabled ? 'bg-white/[0.04] cursor-not-allowed' : enabled ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.12] cursor-pointer'}`}
|
|
132
154
|
>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { afterEach, test } from 'node:test'
|
|
3
3
|
|
|
4
|
-
import { buildOpenClawSessionKey, resolveGatewayAgentId } from './openclaw'
|
|
4
|
+
import { buildOpenClawConnectParams, buildOpenClawSessionKey, resolveGatewayAgentId } from './openclaw'
|
|
5
5
|
import { loadAgents, saveAgents } from '../server/storage'
|
|
6
6
|
import type { Agent } from '@/types'
|
|
7
7
|
|
|
@@ -72,3 +72,10 @@ test('buildOpenClawSessionKey honors explicit OpenClaw session keys when provide
|
|
|
72
72
|
|
|
73
73
|
assert.equal(sessionKey, 'agent:ops:benchmark:fixed-key')
|
|
74
74
|
})
|
|
75
|
+
|
|
76
|
+
test('buildOpenClawConnectParams advertises the current gateway protocol', () => {
|
|
77
|
+
const params = buildOpenClawConnectParams('test-token', 'test-nonce')
|
|
78
|
+
|
|
79
|
+
assert.equal(params.minProtocol, 4)
|
|
80
|
+
assert.equal(params.maxProtocol, 4)
|
|
81
|
+
})
|
|
@@ -113,6 +113,8 @@ export function getDeviceId(): string {
|
|
|
113
113
|
|
|
114
114
|
// --- Protocol helpers ---
|
|
115
115
|
|
|
116
|
+
export const OPENCLAW_GATEWAY_PROTOCOL_VERSION = 4
|
|
117
|
+
|
|
116
118
|
/**
|
|
117
119
|
* Build connect params for the OpenClaw gateway protocol.
|
|
118
120
|
*
|
|
@@ -132,8 +134,8 @@ export function buildOpenClawConnectParams(
|
|
|
132
134
|
const scopes = ['operator.admin']
|
|
133
135
|
|
|
134
136
|
const params: Record<string, unknown> = {
|
|
135
|
-
minProtocol:
|
|
136
|
-
maxProtocol:
|
|
137
|
+
minProtocol: OPENCLAW_GATEWAY_PROTOCOL_VERSION,
|
|
138
|
+
maxProtocol: OPENCLAW_GATEWAY_PROTOCOL_VERSION,
|
|
137
139
|
auth: token ? { token } : undefined,
|
|
138
140
|
client: {
|
|
139
141
|
id: clientId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MessageToolEvent } from '@/types'
|
|
2
2
|
import { dedupeConsecutiveToolEvents } from '@/lib/server/chat-execution/chat-execution-tool-events'
|
|
3
|
+
import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
|
|
3
4
|
|
|
4
5
|
function parseToolJsonObject(raw: string): Record<string, unknown> | null {
|
|
5
6
|
const trimmed = raw.trim()
|
|
@@ -65,12 +66,23 @@ export function looksLikePositiveConnectorDeliveryText(
|
|
|
65
66
|
|
|
66
67
|
export function reconcileConnectorDeliveryText(text: string, events: MessageToolEvent[]): string {
|
|
67
68
|
const trimmed = text.trim()
|
|
68
|
-
const
|
|
69
|
+
const dedupedEvents = dedupeConsecutiveToolEvents(events)
|
|
70
|
+
const connectorEvents = dedupedEvents.filter((event) => event.name === 'connector_message_tool')
|
|
69
71
|
if (!looksLikePositiveConnectorDeliveryText(trimmed, {
|
|
70
72
|
requireConnectorContext: connectorEvents.length === 0,
|
|
71
73
|
})) return text
|
|
72
74
|
if (connectorEvents.some((event) => connectorToolEventSucceeded(event))) return text
|
|
73
75
|
if (connectorEvents.length === 0) {
|
|
76
|
+
// No connector_message_tool event was recorded for this turn, but the
|
|
77
|
+
// agent may have legitimately delivered the message through another tool
|
|
78
|
+
// — typically `execute` running nodemailer / smtp / curl against an
|
|
79
|
+
// outbound HTTP endpoint, or a future connector that doesn't route
|
|
80
|
+
// through connector_message_tool. If *any* tool ran this turn, the agent
|
|
81
|
+
// did real work; trust them rather than overwriting their response with
|
|
82
|
+
// a false-negative. Only reconcile (with the overwrite below) when there
|
|
83
|
+
// is genuine evidence of a failed send — i.e., a connector_message_tool
|
|
84
|
+
// event exists but didn't succeed.
|
|
85
|
+
if (dedupedEvents.length > 0) return text
|
|
74
86
|
return `I couldn't confirm that the configured connector actually sent anything. No connector delivery tool call was recorded for this response.`
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -84,3 +96,7 @@ export function reconcileConnectorDeliveryText(text: string, events: MessageTool
|
|
|
84
96
|
|
|
85
97
|
return `I couldn't send that through the configured connector. ${failureSummary}`.trim()
|
|
86
98
|
}
|
|
99
|
+
|
|
100
|
+
export function sanitizeConnectorDeliveryText(text: string, events: MessageToolEvent[]): string {
|
|
101
|
+
return reconcileConnectorDeliveryText(stripAllInternalMetadata(text), events).trim()
|
|
102
|
+
}
|
|
@@ -12,7 +12,7 @@ import { stripMainLoopMetaForPersistence } from '@/lib/server/agents/main-agent-
|
|
|
12
12
|
import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
|
|
13
13
|
import { pruneStreamingAssistantArtifacts } from '@/lib/chat/chat-streaming-state'
|
|
14
14
|
import { pruneIncompleteToolEvents } from '@/lib/server/chat-execution/chat-streaming-utils'
|
|
15
|
-
import {
|
|
15
|
+
import { sanitizeConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
|
|
16
16
|
import {
|
|
17
17
|
classifyHeartbeatResponse,
|
|
18
18
|
estimateConversationTone,
|
|
@@ -347,7 +347,7 @@ export async function finalizeChatTurn(params: {
|
|
|
347
347
|
// Outbound transforms are non-critical.
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
|
-
finalText =
|
|
350
|
+
finalText = sanitizeConnectorDeliveryText(finalText, persistedToolEvents)
|
|
351
351
|
finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
|
|
352
352
|
finalText = applyExactOutputContract({
|
|
353
353
|
contract: await resolveExactOutputContractWithTimeout({
|
|
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import { describe, it } from 'node:test'
|
|
3
3
|
|
|
4
4
|
import { stripLeakedClassificationJson } from './post-stream-finalization'
|
|
5
|
+
import { sanitizeConnectorDeliveryText } from './chat-execution-connector-delivery'
|
|
5
6
|
|
|
6
7
|
// A fully-valid MessageClassification serialized by the model. Mirrors the
|
|
7
8
|
// real output we observed during a live delegation turn.
|
|
@@ -105,3 +106,26 @@ describe('stripLeakedClassificationJson', () => {
|
|
|
105
106
|
assert.equal(cleaned, input)
|
|
106
107
|
})
|
|
107
108
|
})
|
|
109
|
+
|
|
110
|
+
describe('sanitizeConnectorDeliveryText', () => {
|
|
111
|
+
it('strips internal metadata before connector delivery reconciliation', () => {
|
|
112
|
+
const input = [
|
|
113
|
+
'{ "isDeliverableTask": true, "confidence": 0.9 }',
|
|
114
|
+
'I sent the message via the endpoint. Message ID: abc123.',
|
|
115
|
+
].join('\n')
|
|
116
|
+
const result = sanitizeConnectorDeliveryText(input, [
|
|
117
|
+
{
|
|
118
|
+
name: 'execute',
|
|
119
|
+
input: '{"code":"curl -X POST https://example.invalid/send"}',
|
|
120
|
+
output: 'ok',
|
|
121
|
+
},
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
assert.equal(result, 'I sent the message via the endpoint. Message ID: abc123.')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('preserves benign user JSON in non-delivery text', () => {
|
|
128
|
+
const input = 'The payload example is { "port": 3000 }.'
|
|
129
|
+
assert.equal(sanitizeConnectorDeliveryText(input, []), input)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -92,7 +92,8 @@ import {
|
|
|
92
92
|
resolveSenderPreferencePolicy,
|
|
93
93
|
} from './contact-preferences'
|
|
94
94
|
import { prepareConnectorVoiceNotePayload } from './voice-note'
|
|
95
|
-
import {
|
|
95
|
+
import { sanitizeConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
|
|
96
|
+
import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
|
|
96
97
|
import { pruneIncompleteToolEvents, updateStreamedToolEvents } from '@/lib/server/chat-execution/chat-streaming-utils'
|
|
97
98
|
import { guardUntrustedText, getUntrustedContentGuardMode } from '@/lib/server/untrusted-content'
|
|
98
99
|
import {
|
|
@@ -432,7 +433,7 @@ export async function deliverQueuedConnectorRunResult(params: {
|
|
|
432
433
|
session,
|
|
433
434
|
toolEvents: params.result.toolEvents || [],
|
|
434
435
|
})
|
|
435
|
-
fullText =
|
|
436
|
+
fullText = sanitizeConnectorDeliveryText(stripHiddenControlTokens(fullText), params.result.toolEvents || [])
|
|
436
437
|
|
|
437
438
|
if (!fullText && !currentChannelDelivery) {
|
|
438
439
|
await maybeSendStatusReaction(params.connector, params.msg, 'silent')
|
|
@@ -729,7 +730,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
729
730
|
history,
|
|
730
731
|
})
|
|
731
732
|
|
|
732
|
-
const responseText = stripHiddenControlTokens(result.finalResponse || result.fullText)
|
|
733
|
+
const responseText = stripAllInternalMetadata(stripHiddenControlTokens(result.finalResponse || result.fullText))
|
|
733
734
|
if (responseText.trim() && !isNoMessage(responseText)) {
|
|
734
735
|
// Persist agent response to chatroom
|
|
735
736
|
const agentSource: MessageSource = {
|
|
@@ -1332,12 +1333,11 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1332
1333
|
}
|
|
1333
1334
|
|
|
1334
1335
|
const suppressHiddenResponse = shouldSuppressHiddenControlText(fullText)
|
|
1335
|
-
fullText = stripHiddenControlTokens(fullText)
|
|
1336
|
-
fullText = reconcileConnectorDeliveryText(fullText, settledConnectorToolEvents).trim()
|
|
1336
|
+
fullText = sanitizeConnectorDeliveryText(stripHiddenControlTokens(fullText), settledConnectorToolEvents)
|
|
1337
1337
|
|
|
1338
1338
|
// If the agent chose NO_MESSAGE, skip saving it to history — the user's message
|
|
1339
1339
|
// is already recorded, and saving the sentinel would pollute the LLM's context
|
|
1340
|
-
if (suppressHiddenResponse || isNoMessage(fullText)) {
|
|
1340
|
+
if (suppressHiddenResponse || isNoMessage(fullText) || !fullText.trim()) {
|
|
1341
1341
|
if (currentChannelDeliveryRef.current) {
|
|
1342
1342
|
persistConnectorDeliveryMarker({
|
|
1343
1343
|
session,
|