@swarmclawai/swarmclaw 0.8.0 → 0.8.2
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 +8 -7
- package/package.json +2 -2
- package/src/app/api/notifications/route.ts +11 -12
- package/src/app/page.tsx +9 -0
- package/src/components/chat/chat-list.tsx +10 -9
- package/src/components/home/home-view.tsx +13 -2
- package/src/components/layout/app-layout.tsx +1 -0
- package/src/components/shared/command-palette.tsx +4 -1
- package/src/components/shared/notification-center.tsx +7 -1
- package/src/components/shared/search-dialog.tsx +10 -2
- package/src/lib/local-observability.test.ts +73 -0
- package/src/lib/local-observability.ts +47 -0
- package/src/lib/notification-utils.test.ts +72 -0
- package/src/lib/notification-utils.ts +68 -0
- package/src/lib/providers/openclaw.test.ts +21 -1
- package/src/lib/providers/openclaw.ts +22 -0
- package/src/lib/runtime-loop.ts +1 -1
- package/src/lib/server/agent-thread-session.test.ts +41 -0
- package/src/lib/server/agent-thread-session.ts +1 -0
- package/src/lib/server/chat-execution-advanced.test.ts +7 -0
- package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
- package/src/lib/server/chat-execution.ts +22 -5
- package/src/lib/server/create-notification.test.ts +94 -0
- package/src/lib/server/create-notification.ts +31 -25
- package/src/lib/server/daemon-state.test.ts +50 -0
- package/src/lib/server/daemon-state.ts +121 -38
- package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
- package/src/lib/server/eval/agent-regression.test.ts +13 -1
- package/src/lib/server/eval/agent-regression.ts +221 -1
- package/src/lib/server/memory-policy.test.ts +32 -0
- package/src/lib/server/memory-policy.ts +25 -0
- package/src/lib/server/plugins-advanced.test.ts +7 -0
- package/src/lib/server/runtime-settings.test.ts +2 -2
- package/src/lib/server/session-tools/crud.test.ts +136 -0
- package/src/lib/server/session-tools/crud.ts +44 -2
- package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
- package/src/lib/server/session-tools/delegate.ts +30 -0
- package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
- package/src/lib/server/session-tools/discovery.ts +7 -6
- package/src/lib/server/session-tools/memory.ts +156 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
- package/src/lib/server/session-tools/subagent.ts +4 -4
- package/src/lib/server/storage.ts +14 -1
- package/src/lib/server/stream-agent-chat.test.ts +78 -1
- package/src/lib/server/stream-agent-chat.ts +225 -22
- package/src/lib/server/tool-aliases.ts +1 -1
- package/src/lib/server/tool-capability-policy.ts +1 -1
- package/src/stores/use-app-store.ts +26 -1
- package/src/types/index.ts +4 -0
|
@@ -46,7 +46,7 @@ describe('runtime settings defaults', () => {
|
|
|
46
46
|
`)
|
|
47
47
|
|
|
48
48
|
assert.equal(output.settings.loopMode, 'bounded')
|
|
49
|
-
assert.equal(output.settings.agentLoopRecursionLimit,
|
|
49
|
+
assert.equal(output.settings.agentLoopRecursionLimit, 120)
|
|
50
50
|
assert.equal(output.settings.orchestratorLoopRecursionLimit, 80)
|
|
51
51
|
assert.equal(output.settings.legacyOrchestratorMaxTurns, 16)
|
|
52
52
|
assert.equal(output.settings.ongoingLoopMaxIterations, 250)
|
|
@@ -61,7 +61,7 @@ describe('runtime settings defaults', () => {
|
|
|
61
61
|
assert.equal(output.settings.heartbeatShowAlerts, true)
|
|
62
62
|
assert.equal(output.settings.heartbeatTarget, null)
|
|
63
63
|
assert.equal(output.settings.heartbeatPrompt, null)
|
|
64
|
-
assert.equal(output.runtime.agentLoopRecursionLimit,
|
|
64
|
+
assert.equal(output.runtime.agentLoopRecursionLimit, 120)
|
|
65
65
|
assert.equal(output.runtime.orchestratorLoopRecursionLimit, 80)
|
|
66
66
|
assert.equal(output.runtime.legacyOrchestratorMaxTurns, 16)
|
|
67
67
|
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { after, before, describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import type { Agent } from '@/types'
|
|
7
|
+
|
|
8
|
+
const originalEnv = {
|
|
9
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
10
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
11
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let tempDir = ''
|
|
15
|
+
let buildCrudTools: Awaited<typeof import('./crud')>['buildCrudTools']
|
|
16
|
+
let loadAgents: Awaited<typeof import('../storage')>['loadAgents']
|
|
17
|
+
let saveAgents: Awaited<typeof import('../storage')>['saveAgents']
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-crud-test-'))
|
|
21
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
22
|
+
process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
|
|
23
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
24
|
+
fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
|
|
25
|
+
fs.mkdirSync(process.env.WORKSPACE_DIR, { recursive: true })
|
|
26
|
+
|
|
27
|
+
const crudMod = await import('./crud')
|
|
28
|
+
buildCrudTools = crudMod.buildCrudTools
|
|
29
|
+
|
|
30
|
+
const storageMod = await import('../storage')
|
|
31
|
+
loadAgents = storageMod.loadAgents
|
|
32
|
+
saveAgents = storageMod.saveAgents
|
|
33
|
+
|
|
34
|
+
const agents = loadAgents({ includeTrashed: true }) as Record<string, Agent>
|
|
35
|
+
agents['agent-soul-test'] = {
|
|
36
|
+
id: 'agent-soul-test',
|
|
37
|
+
name: 'Soul Test Agent',
|
|
38
|
+
description: 'Agent used for CRUD soul validation tests',
|
|
39
|
+
systemPrompt: '',
|
|
40
|
+
provider: 'ollama',
|
|
41
|
+
model: 'glm-5:cloud',
|
|
42
|
+
plugins: ['manage_agents'],
|
|
43
|
+
tools: ['manage_agents'],
|
|
44
|
+
platformAssignScope: 'self',
|
|
45
|
+
createdAt: Date.now(),
|
|
46
|
+
updatedAt: Date.now(),
|
|
47
|
+
}
|
|
48
|
+
saveAgents(agents)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
after(() => {
|
|
52
|
+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
|
|
53
|
+
else process.env.DATA_DIR = originalEnv.DATA_DIR
|
|
54
|
+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
|
|
55
|
+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
|
|
56
|
+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
|
|
57
|
+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
|
|
58
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('manage_agents soul validation', () => {
|
|
62
|
+
it('rejects non-string soul payloads so preferences do not leak into agent config', async () => {
|
|
63
|
+
const tools = buildCrudTools({
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
ctx: { agentId: 'agent-soul-test', platformAssignScope: 'self' },
|
|
66
|
+
hasPlugin: (name) => name === 'manage_agents',
|
|
67
|
+
hasTool: (name) => name === 'manage_agents',
|
|
68
|
+
cleanupFns: [],
|
|
69
|
+
commandTimeoutMs: 1_000,
|
|
70
|
+
claudeTimeoutMs: 1_000,
|
|
71
|
+
cliProcessTimeoutMs: 1_000,
|
|
72
|
+
persistDelegateResumeId: () => {},
|
|
73
|
+
readStoredDelegateResumeId: () => null,
|
|
74
|
+
resolveCurrentSession: () => null,
|
|
75
|
+
activePlugins: ['manage_agents'],
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const manageAgents = tools.find((tool) => tool.name === 'manage_agents')
|
|
79
|
+
assert.ok(manageAgents, 'expected manage_agents tool')
|
|
80
|
+
|
|
81
|
+
const raw = await manageAgents!.invoke({
|
|
82
|
+
action: 'update',
|
|
83
|
+
id: 'agent-soul-test',
|
|
84
|
+
soul: {
|
|
85
|
+
preferences: {
|
|
86
|
+
programmingLanguage: 'Rust',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
assert.match(
|
|
92
|
+
String(raw),
|
|
93
|
+
/manage_agents data\.soul must be a plain instruction string/i,
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('deduplicates repeated manage_agents create calls in the same session', async () => {
|
|
98
|
+
const tools = buildCrudTools({
|
|
99
|
+
cwd: process.cwd(),
|
|
100
|
+
ctx: { sessionId: 'agent-dedupe-session', agentId: 'agent-soul-test', platformAssignScope: 'all' },
|
|
101
|
+
hasPlugin: (name) => name === 'manage_agents',
|
|
102
|
+
hasTool: (name) => name === 'manage_agents',
|
|
103
|
+
cleanupFns: [],
|
|
104
|
+
commandTimeoutMs: 1_000,
|
|
105
|
+
claudeTimeoutMs: 1_000,
|
|
106
|
+
cliProcessTimeoutMs: 1_000,
|
|
107
|
+
persistDelegateResumeId: () => {},
|
|
108
|
+
readStoredDelegateResumeId: () => null,
|
|
109
|
+
resolveCurrentSession: () => null,
|
|
110
|
+
activePlugins: ['manage_agents'],
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const manageAgents = tools.find((tool) => tool.name === 'manage_agents')
|
|
114
|
+
assert.ok(manageAgents, 'expected manage_agents tool')
|
|
115
|
+
|
|
116
|
+
const firstRaw = await manageAgents!.invoke({
|
|
117
|
+
action: 'create',
|
|
118
|
+
name: 'Session Dedupe Worker',
|
|
119
|
+
soul: 'Coordinates a worker lane and never stores user memory.',
|
|
120
|
+
})
|
|
121
|
+
const secondRaw = await manageAgents!.invoke({
|
|
122
|
+
action: 'create',
|
|
123
|
+
name: 'Session Dedupe Worker',
|
|
124
|
+
soul: 'Coordinates a worker lane and never stores user memory.',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const first = JSON.parse(String(firstRaw)) as Record<string, unknown>
|
|
128
|
+
const second = JSON.parse(String(secondRaw)) as Record<string, unknown>
|
|
129
|
+
const created = Object.values(loadAgents({ includeTrashed: true }) as Record<string, Agent & { createdInSessionId?: string }>)
|
|
130
|
+
.filter((agent) => agent.createdInSessionId === 'agent-dedupe-session')
|
|
131
|
+
|
|
132
|
+
assert.equal(created.length, 1)
|
|
133
|
+
assert.equal(second.id, first.id)
|
|
134
|
+
assert.equal(second.deduplicated, true)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -118,6 +118,34 @@ function deriveTaskTitle(input: { title?: unknown; description?: unknown }): str
|
|
|
118
118
|
return compact.slice(0, 120)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
function validateAgentSoulPayload(value: unknown): string | null {
|
|
122
|
+
if (value === undefined) return null
|
|
123
|
+
if (typeof value === 'string') return null
|
|
124
|
+
return 'Error: manage_agents data.soul must be a plain instruction string. Use memory tools for user preferences, durable facts, and long-term memory instead.'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findDuplicateManagedAgent(
|
|
128
|
+
all: Record<string, unknown>,
|
|
129
|
+
parsed: Record<string, unknown>,
|
|
130
|
+
ctx?: ToolBuildContext['ctx'],
|
|
131
|
+
): Record<string, unknown> | null {
|
|
132
|
+
const requestedId = typeof parsed.id === 'string' ? parsed.id.trim() : ''
|
|
133
|
+
const requestedName = typeof parsed.name === 'string' ? parsed.name.trim().toLowerCase() : ''
|
|
134
|
+
if (!requestedId && !requestedName) return null
|
|
135
|
+
|
|
136
|
+
for (const candidate of Object.values(all)) {
|
|
137
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue
|
|
138
|
+
const record = candidate as Record<string, unknown>
|
|
139
|
+
if (requestedId && String(record.id || '').trim() === requestedId) return record
|
|
140
|
+
if (!requestedName) continue
|
|
141
|
+
const sameName = String(record.name || '').trim().toLowerCase() === requestedName
|
|
142
|
+
const sameSession = ctx?.sessionId && record.createdInSessionId === ctx.sessionId
|
|
143
|
+
if (sameName && sameSession) return record
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
const VALID_CONNECTOR_PLATFORMS = new Set([
|
|
122
150
|
'discord',
|
|
123
151
|
'telegram',
|
|
@@ -562,7 +590,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
562
590
|
description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). For get/update/delete, you may omit "id" to target this active project.`
|
|
563
591
|
}
|
|
564
592
|
} else if (toolKey === 'manage_agents') {
|
|
565
|
-
description += `\n\nAgents may self-edit their own soul. To update your soul, use action="update", id="${ctx?.agentId || 'your-agent-id'}", and include data with the "soul" field. Set "platformAssignScope":"all" to let an agent delegate work across the fleet; use "self" for solo execution.`
|
|
593
|
+
description += `\n\nAgents may self-edit their own soul only when explicitly changing persona or operating instructions. Do not use manage_agents to store user preferences, durable facts, or normal memory; use the memory tools for that. To update your soul, use action="update", id="${ctx?.agentId || 'your-agent-id'}", and include data with the "soul" field. Set "platformAssignScope":"all" to let an agent delegate work across the fleet; use "self" for solo execution.`
|
|
566
594
|
} else if (toolKey === 'manage_schedules') {
|
|
567
595
|
if (assignScope === 'self') {
|
|
568
596
|
description += `\n\nOmit "agentId" to assign a schedule to yourself ("${ctx?.agentId || 'unknown'}"), or set it explicitly to yourself. You can only assign schedules to yourself. Schedule types: interval (set intervalMs), cron (set cron), once (set runAt). Provide either taskPrompt, command, or action+path. Before create, call list/get to avoid duplicate schedules. Reuse or update an existing schedule you already created in this chat instead of making a near-duplicate. If an equivalent active/paused schedule already exists, create returns that existing schedule (deduplicated=true). For one-off reminders, prefer "once"; agent-created one-off schedules are cleaned up automatically after they finish. When the user says stop/pause/cancel a reminder, pause or delete every matching schedule you created in this chat, not just one row.`
|
|
@@ -575,7 +603,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
575
603
|
} else if (toolKey === 'manage_webhooks') {
|
|
576
604
|
description += '\n\nUse `source`, `events`, `agentId`, and `secret` when creating webhooks. Inbound calls should POST to `/api/webhooks/{id}` with header `x-webhook-secret` when a secret is configured.'
|
|
577
605
|
} else if (toolKey === 'manage_secrets') {
|
|
578
|
-
description += '\n\nUse this for credential bootstrapping and
|
|
606
|
+
description += '\n\nUse this only for credential bootstrapping and sensitive secret storage such as API keys, passwords, tokens, recovery codes, and webhook secrets. Do not use it for normal memory, user preferences, durable facts, or project notes. Create/update calls accept either `data` as JSON or direct top-level fields like `name`, `service`, `value`, `scope`, `agentIds`, and `projectId`.'
|
|
579
607
|
if (ctx?.projectId) {
|
|
580
608
|
description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to link the secret to this active project.`
|
|
581
609
|
}
|
|
@@ -757,6 +785,16 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
757
785
|
: null
|
|
758
786
|
}
|
|
759
787
|
}
|
|
788
|
+
if (toolKey === 'manage_agents' && Object.prototype.hasOwnProperty.call(parsed, 'soul')) {
|
|
789
|
+
const soulError = validateAgentSoulPayload((parsed as Record<string, unknown>).soul)
|
|
790
|
+
if (soulError) return soulError
|
|
791
|
+
}
|
|
792
|
+
if (toolKey === 'manage_agents') {
|
|
793
|
+
const duplicateAgent = findDuplicateManagedAgent(all as Record<string, unknown>, parsed as Record<string, unknown>, ctx)
|
|
794
|
+
if (duplicateAgent) {
|
|
795
|
+
return JSON.stringify({ ...duplicateAgent, deduplicated: true })
|
|
796
|
+
}
|
|
797
|
+
}
|
|
760
798
|
// Task dedup
|
|
761
799
|
if (toolKey === 'manage_tasks') {
|
|
762
800
|
const fp = computeTaskFingerprint(parsed.title || 'Untitled Task', parsed.agentId || ctx?.agentId || '')
|
|
@@ -851,6 +889,10 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
851
889
|
? sanitizeConnectorCrudPayload(buildCrudPayload(normalized, action, data), { forUpdate: true })
|
|
852
890
|
: buildCrudPayload(normalized, action, data)
|
|
853
891
|
const parsedRecord = parsed as Record<string, unknown>
|
|
892
|
+
if (toolKey === 'manage_agents' && Object.prototype.hasOwnProperty.call(parsedRecord, 'soul')) {
|
|
893
|
+
const soulError = validateAgentSoulPayload(parsedRecord.soul)
|
|
894
|
+
if (soulError) return soulError
|
|
895
|
+
}
|
|
854
896
|
if (toolKey === 'manage_tasks') {
|
|
855
897
|
const continuationError = applyTaskContinuationDefaults(parsedRecord, all as Record<string, BoardTask>, parsedRecord)
|
|
856
898
|
if (continuationError) return continuationError
|
|
@@ -131,6 +131,42 @@ describe('delegate fallback', () => {
|
|
|
131
131
|
assert.match(String(output.response || ''), /codex fallback ok/i)
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
it('rejects delegating a locally available tool call', () => {
|
|
135
|
+
const output = runWithFakeDelegates(`
|
|
136
|
+
const mod = await import('./src/lib/server/session-tools/delegate.ts')
|
|
137
|
+
const { buildDelegateTools } = mod.default || mod['module.exports'] || mod
|
|
138
|
+
|
|
139
|
+
const tools = buildDelegateTools({
|
|
140
|
+
cwd: process.cwd(),
|
|
141
|
+
ctx: { sessionId: 'session-test', agentId: 'agent-test', platformAssignScope: 'self' },
|
|
142
|
+
hasPlugin: (name) => name === 'delegate' || name === 'memory' || name === 'memory_store',
|
|
143
|
+
hasTool: (name) => name === 'delegate' || name === 'memory' || name === 'memory_store',
|
|
144
|
+
cleanupFns: [],
|
|
145
|
+
commandTimeoutMs: 5000,
|
|
146
|
+
claudeTimeoutMs: 5000,
|
|
147
|
+
cliProcessTimeoutMs: 5000,
|
|
148
|
+
persistDelegateResumeId: () => {},
|
|
149
|
+
readStoredDelegateResumeId: () => null,
|
|
150
|
+
resolveCurrentSession: () => null,
|
|
151
|
+
activePlugins: ['delegate', 'memory'],
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const delegateTool = tools.find((tool) => tool.name === 'delegate')
|
|
155
|
+
const raw = await delegateTool.invoke({
|
|
156
|
+
input: JSON.stringify({
|
|
157
|
+
tool: 'memory_store',
|
|
158
|
+
args: {
|
|
159
|
+
title: 'User programming preferences',
|
|
160
|
+
value: 'Favorite language: Rust',
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
})
|
|
164
|
+
console.log(JSON.stringify({ raw }))
|
|
165
|
+
`)
|
|
166
|
+
|
|
167
|
+
assert.match(String(output.raw || ''), /Call `memory` directly|Call `memory_store` directly|already available in this session/i)
|
|
168
|
+
})
|
|
169
|
+
|
|
134
170
|
it('synthesizes a delegated task from write-style payloads', () => {
|
|
135
171
|
const output = runWithFakeDelegates(`
|
|
136
172
|
const mod = await import('./src/lib/server/session-tools/delegate.ts')
|
|
@@ -6,6 +6,7 @@ import { truncate, findBinaryOnPath, MAX_OUTPUT } from './context'
|
|
|
6
6
|
import type { Plugin, PluginHooks } from '@/types'
|
|
7
7
|
import { getPluginManager } from '../plugins'
|
|
8
8
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
9
|
+
import { canonicalizePluginId } from '../tool-aliases'
|
|
9
10
|
import {
|
|
10
11
|
appendDelegationCheckpoint,
|
|
11
12
|
cancelDelegationJob,
|
|
@@ -277,6 +278,30 @@ function normalizeDelegateArgs(rawArgs: Record<string, unknown>): Record<string,
|
|
|
277
278
|
return normalized
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
function resolveDirectLocalToolDelegationTarget(
|
|
282
|
+
normalized: Record<string, unknown>,
|
|
283
|
+
bctx: DelegateContext,
|
|
284
|
+
): string | null {
|
|
285
|
+
const requestedTool = [
|
|
286
|
+
normalized.tool,
|
|
287
|
+
normalized.tool_name,
|
|
288
|
+
normalized.toolName,
|
|
289
|
+
normalized.tool_id,
|
|
290
|
+
normalized.toolId,
|
|
291
|
+
].find((value) => typeof value === 'string' && value.trim()) as string | undefined
|
|
292
|
+
const trimmed = typeof requestedTool === 'string' ? requestedTool.trim() : ''
|
|
293
|
+
if (!trimmed) return null
|
|
294
|
+
if (coerceDelegateBackend(trimmed)) return null
|
|
295
|
+
|
|
296
|
+
const canonical = canonicalizePluginId(trimmed) || trimmed.toLowerCase()
|
|
297
|
+
if (canonical === 'delegate') return null
|
|
298
|
+
const hasLocalTool = bctx.hasPlugin?.(trimmed)
|
|
299
|
+
|| bctx.hasPlugin?.(canonical)
|
|
300
|
+
|| bctx.hasTool?.(trimmed)
|
|
301
|
+
|| bctx.hasTool?.(canonical)
|
|
302
|
+
return hasLocalTool ? canonical : null
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
function resolveDelegateSessionId(bctx: DelegateContext): string | null {
|
|
281
306
|
const nested = typeof bctx.ctx?.sessionId === 'string' ? bctx.ctx.sessionId.trim() : ''
|
|
282
307
|
if (nested) return nested
|
|
@@ -459,6 +484,7 @@ async function waitForDelegateJob(jobId: string, timeoutSec = 30): Promise<strin
|
|
|
459
484
|
async function executeDelegateAction(args: Record<string, unknown>, bctx: DelegateContext) {
|
|
460
485
|
const normalized = normalizeDelegateArgs(args)
|
|
461
486
|
const action = String(normalized.action || '').trim().toLowerCase()
|
|
487
|
+
const directLocalToolTarget = resolveDirectLocalToolDelegationTarget(normalized, bctx)
|
|
462
488
|
const task = normalized.task as string
|
|
463
489
|
const requestedBackend = ((normalized.backend as string) || 'claude') as DelegateBackend
|
|
464
490
|
const jobId = typeof normalized.jobId === 'string' ? normalized.jobId.trim() : ''
|
|
@@ -488,6 +514,10 @@ async function executeDelegateAction(args: Record<string, unknown>, bctx: Delega
|
|
|
488
514
|
return waitForDelegateJob(jobId, timeoutSec)
|
|
489
515
|
}
|
|
490
516
|
|
|
517
|
+
if (directLocalToolTarget) {
|
|
518
|
+
return `Error: \`${directLocalToolTarget}\` is already available in this session. Call \`${directLocalToolTarget}\` directly instead of wrapping it inside \`delegate\`.`
|
|
519
|
+
}
|
|
520
|
+
|
|
491
521
|
if (!task) return 'Error: task is required.'
|
|
492
522
|
|
|
493
523
|
const job = createDelegationJob({
|
|
@@ -128,6 +128,46 @@ describe('discovery approval flows', () => {
|
|
|
128
128
|
assert.equal(output.plugins.includes('shell'), true)
|
|
129
129
|
})
|
|
130
130
|
|
|
131
|
+
it('manage_capabilities request_access tells the agent to call already-available alias tools directly', () => {
|
|
132
|
+
const output = runWithTempDataDir(`
|
|
133
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
134
|
+
const toolsMod = await import('./src/lib/server/session-tools/index.ts')
|
|
135
|
+
const storage = storageMod.default || storageMod
|
|
136
|
+
const toolsApi = toolsMod.default || toolsMod
|
|
137
|
+
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
storage.saveSessions({
|
|
140
|
+
session_memory: {
|
|
141
|
+
id: 'session_memory',
|
|
142
|
+
name: 'Memory Alias Test',
|
|
143
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
144
|
+
user: 'tester',
|
|
145
|
+
provider: 'openai',
|
|
146
|
+
model: 'gpt-test',
|
|
147
|
+
claudeSessionId: null,
|
|
148
|
+
messages: [],
|
|
149
|
+
createdAt: now,
|
|
150
|
+
lastActiveAt: now,
|
|
151
|
+
sessionType: 'human',
|
|
152
|
+
agentId: 'default',
|
|
153
|
+
plugins: ['memory'],
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const built = await toolsApi.buildSessionTools(process.env.WORKSPACE_DIR, ['memory'], {
|
|
158
|
+
sessionId: 'session_memory',
|
|
159
|
+
agentId: 'default',
|
|
160
|
+
platformAssignScope: 'self',
|
|
161
|
+
})
|
|
162
|
+
const tool = built.tools.find((entry) => entry.name === 'manage_capabilities')
|
|
163
|
+
const raw = await tool.invoke({ action: 'request_access', query: 'memory_store', reason: 'Need to remember a user preference.' })
|
|
164
|
+
console.log(JSON.stringify({ raw }))
|
|
165
|
+
`)
|
|
166
|
+
|
|
167
|
+
assert.match(String(output.raw), /"alreadyAvailable":true/)
|
|
168
|
+
assert.match(String(output.raw), /memory_store\\\" directly now/i)
|
|
169
|
+
})
|
|
170
|
+
|
|
131
171
|
it('granting manage_schedules does not surface the manage_platform umbrella tool', () => {
|
|
132
172
|
const output = runWithTempDataDir(`
|
|
133
173
|
const storageMod = await import('./src/lib/server/storage.ts')
|
|
@@ -142,12 +142,13 @@ async function executeDiscoveryAction(args: Record<string, unknown>, bctx?: Tool
|
|
|
142
142
|
const currentSession = allSessions[bctx.ctx.sessionId]
|
|
143
143
|
const grantedTools = currentSession?.plugins || currentSession?.tools || []
|
|
144
144
|
if (currentSession && pluginIdMatches(grantedTools, pluginId)) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
return JSON.stringify({
|
|
146
|
+
alreadyGranted: true,
|
|
147
|
+
alreadyAvailable: true,
|
|
148
|
+
pluginId,
|
|
149
|
+
message: `You already have access to "${pluginId}" in this session. Call "${pluginId}" directly now instead of using manage_capabilities again.`,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
151
152
|
}
|
|
152
153
|
const { requestApprovalMaybeAutoApprove } = await import('../approvals')
|
|
153
154
|
const approval = await requestApprovalMaybeAutoApprove({
|
|
@@ -35,6 +35,7 @@ type MemoryActionContext = Partial<Session> & {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
type MemorySearchSource = 'durable' | 'working' | 'archive' | 'all'
|
|
38
|
+
type NarrowMemoryAction = 'search' | 'get' | 'store' | 'update'
|
|
38
39
|
type CanonicalMemoryCandidate = {
|
|
39
40
|
entry: MemoryEntry
|
|
40
41
|
score: number
|
|
@@ -150,6 +151,24 @@ function normalizeMemoryText(value: unknown): string {
|
|
|
150
151
|
.trim()
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
function buildNamedMemoryActionArgs(
|
|
155
|
+
action: NarrowMemoryAction,
|
|
156
|
+
args: Record<string, unknown>,
|
|
157
|
+
): Record<string, unknown> {
|
|
158
|
+
return { ...args, action }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function executeNamedMemoryAction(
|
|
162
|
+
action: NarrowMemoryAction,
|
|
163
|
+
args: Record<string, unknown>,
|
|
164
|
+
context: { session?: MemoryActionContext | null } | null | undefined,
|
|
165
|
+
) {
|
|
166
|
+
return executeMemoryAction(
|
|
167
|
+
buildNamedMemoryActionArgs(action, normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)),
|
|
168
|
+
context?.session,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
153
172
|
function stripGeneratedMemoryPrefix(value: string): string {
|
|
154
173
|
return value.replace(/^\[(?:auto|auto-consolidated)[^\]]*\]\s*/i, '').trim()
|
|
155
174
|
}
|
|
@@ -763,11 +782,15 @@ const MemoryPlugin: Plugin = {
|
|
|
763
782
|
ctx.session.lastAutoMemoryAt = now
|
|
764
783
|
} catch { /* auto-memory is best-effort */ }
|
|
765
784
|
},
|
|
766
|
-
getCapabilityDescription: () => 'I have long-term memory (`memory_tool`) — I can remember things across conversations and recall them when needed.',
|
|
785
|
+
getCapabilityDescription: () => 'I have long-term memory (`memory_search`, `memory_get`, `memory_store`, `memory_update`, `memory_tool`) — I can remember things across conversations and recall them when needed.',
|
|
767
786
|
getOperatingGuidance: () => [
|
|
768
|
-
'Memory: use
|
|
769
|
-
'
|
|
770
|
-
'
|
|
787
|
+
'Memory: use narrow memory tools first. For past-conversation recall, prefer `memory_search` then `memory_get`. For direct writes or corrections, prefer `memory_store` or `memory_update`. Keep `memory_tool` for list/delete/link/doctor or when you truly need the generic surface.',
|
|
788
|
+
'For info already in the current conversation, respond directly without calling any memory tool.',
|
|
789
|
+
'For questions about prior work, decisions, dates, people, preferences, or todos from earlier conversations: start with one durable `memory_search`, then use `memory_get` only if you need a more targeted read. Only use archive/session history when the user explicitly needs transcript-level detail or the durable search is insufficient.',
|
|
790
|
+
'When the user directly says to remember, store, or correct a fact, do one `memory_store` or `memory_update` call immediately. Treat the newest direct user statement as authoritative.',
|
|
791
|
+
'When one user message contains multiple related facts to remember, prefer one canonical `memory_store` write that captures the full set instead of many near-duplicate store calls.',
|
|
792
|
+
'If someone says "remember this", write it down; do not rely on RAM alone.',
|
|
793
|
+
'Memory writes merge canonical memories and retire superseded variants. After a successful store/update, do not keep re-searching unless the user explicitly asked you to verify.',
|
|
771
794
|
'By default, memory searches focus on durable memories. Only include archives or working execution notes when you explicitly need transcript or run-history context.',
|
|
772
795
|
'For open goals, form a hypothesis and execute — do not keep re-asking broad questions.',
|
|
773
796
|
],
|
|
@@ -794,7 +817,102 @@ const MemoryPlugin: Plugin = {
|
|
|
794
817
|
},
|
|
795
818
|
execute: async (args, context) => {
|
|
796
819
|
return executeMemoryAction(args, context.session)
|
|
797
|
-
}
|
|
820
|
+
},
|
|
821
|
+
planning: {
|
|
822
|
+
capabilities: ['memory.search', 'memory.write'],
|
|
823
|
+
disciplineGuidance: [
|
|
824
|
+
'Use `memory_tool` for broad memory administration such as list, delete, link, unlink, or doctor. Prefer the narrow memory tools for routine search/get/store/update work.',
|
|
825
|
+
],
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
name: 'memory_search',
|
|
830
|
+
description: 'Search durable long-term memory for prior work, decisions, dates, people, preferences, or todos from earlier conversations. Prefer this before broader history tools.',
|
|
831
|
+
parameters: {
|
|
832
|
+
type: 'object',
|
|
833
|
+
properties: {
|
|
834
|
+
query: { type: 'string' },
|
|
835
|
+
scope: { type: 'string', enum: ['auto', 'all', 'global', 'shared', 'agent', 'session', 'project'] },
|
|
836
|
+
sources: { type: 'array', items: { type: 'string', enum: ['durable', 'working', 'archive', 'all'] } },
|
|
837
|
+
rerank: { type: 'string', enum: ['balanced', 'semantic', 'lexical'] },
|
|
838
|
+
},
|
|
839
|
+
required: ['query'],
|
|
840
|
+
},
|
|
841
|
+
planning: {
|
|
842
|
+
capabilities: ['memory.search'],
|
|
843
|
+
disciplineGuidance: [
|
|
844
|
+
'For earlier-conversation recall, start with `memory_search` instead of browsing archive/session history. Keep searches durable-first unless transcript or run-history detail is explicitly needed.',
|
|
845
|
+
],
|
|
846
|
+
},
|
|
847
|
+
execute: async (args, context) => executeNamedMemoryAction('search', args, context),
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
name: 'memory_get',
|
|
851
|
+
description: 'Read a specific memory entry by id or key after search, keeping context focused.',
|
|
852
|
+
parameters: {
|
|
853
|
+
type: 'object',
|
|
854
|
+
properties: {
|
|
855
|
+
id: { type: 'string' },
|
|
856
|
+
key: { type: 'string' },
|
|
857
|
+
scope: { type: 'string', enum: ['auto', 'all', 'global', 'shared', 'agent', 'session', 'project'] },
|
|
858
|
+
},
|
|
859
|
+
required: [],
|
|
860
|
+
},
|
|
861
|
+
planning: {
|
|
862
|
+
capabilities: ['memory.search'],
|
|
863
|
+
disciplineGuidance: [
|
|
864
|
+
'Use `memory_get` after `memory_search` when you need one targeted memory entry. Do not dump the whole memory list when a single entry is enough.',
|
|
865
|
+
],
|
|
866
|
+
},
|
|
867
|
+
execute: async (args, context) => executeNamedMemoryAction('get', args, context),
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
name: 'memory_store',
|
|
871
|
+
description: 'Store a durable fact, preference, decision, or correction from the user. Use this immediately when the user says to remember something. If several related facts arrive in one request, prefer one canonical write over many near-duplicate calls.',
|
|
872
|
+
parameters: {
|
|
873
|
+
type: 'object',
|
|
874
|
+
properties: {
|
|
875
|
+
title: { type: 'string' },
|
|
876
|
+
value: { type: 'string' },
|
|
877
|
+
category: { type: 'string' },
|
|
878
|
+
key: { type: 'string' },
|
|
879
|
+
scope: { type: 'string', enum: ['auto', 'all', 'global', 'shared', 'agent', 'session', 'project'] },
|
|
880
|
+
sharedWith: { type: 'array', items: { type: 'string' } },
|
|
881
|
+
},
|
|
882
|
+
required: [],
|
|
883
|
+
},
|
|
884
|
+
planning: {
|
|
885
|
+
capabilities: ['memory.write'],
|
|
886
|
+
disciplineGuidance: [
|
|
887
|
+
'When the user says to remember or store a fact, call `memory_store` immediately. Do not delegate or use platform-management tools first.',
|
|
888
|
+
'If the user bundled multiple related facts into one remember request, store them together in one canonical write unless they asked for separate memories.',
|
|
889
|
+
],
|
|
890
|
+
},
|
|
891
|
+
execute: async (args, context) => executeNamedMemoryAction('store', args, context),
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
name: 'memory_update',
|
|
895
|
+
description: 'Update or correct an existing durable memory when new information supersedes the old value.',
|
|
896
|
+
parameters: {
|
|
897
|
+
type: 'object',
|
|
898
|
+
properties: {
|
|
899
|
+
id: { type: 'string' },
|
|
900
|
+
key: { type: 'string' },
|
|
901
|
+
title: { type: 'string' },
|
|
902
|
+
value: { type: 'string' },
|
|
903
|
+
category: { type: 'string' },
|
|
904
|
+
query: { type: 'string' },
|
|
905
|
+
scope: { type: 'string', enum: ['auto', 'all', 'global', 'shared', 'agent', 'session', 'project'] },
|
|
906
|
+
},
|
|
907
|
+
required: [],
|
|
908
|
+
},
|
|
909
|
+
planning: {
|
|
910
|
+
capabilities: ['memory.write'],
|
|
911
|
+
disciplineGuidance: [
|
|
912
|
+
'When the user corrects or revises remembered information, prefer `memory_update` so the canonical durable memory is updated instead of creating noisy duplicates.',
|
|
913
|
+
],
|
|
914
|
+
},
|
|
915
|
+
execute: async (args, context) => executeNamedMemoryAction('update', args, context),
|
|
798
916
|
}
|
|
799
917
|
]
|
|
800
918
|
}
|
|
@@ -813,6 +931,38 @@ export function buildMemoryTools(bctx: ToolBuildContext) {
|
|
|
813
931
|
description: MemoryPlugin.tools![0].description,
|
|
814
932
|
schema: z.object({}).passthrough()
|
|
815
933
|
}
|
|
816
|
-
)
|
|
934
|
+
),
|
|
935
|
+
tool(
|
|
936
|
+
async (args) => executeNamedMemoryAction('search', (args ?? {}) as Record<string, unknown>, { session: bctx.ctx }),
|
|
937
|
+
{
|
|
938
|
+
name: 'memory_search',
|
|
939
|
+
description: MemoryPlugin.tools![1].description,
|
|
940
|
+
schema: z.object({}).passthrough(),
|
|
941
|
+
},
|
|
942
|
+
),
|
|
943
|
+
tool(
|
|
944
|
+
async (args) => executeNamedMemoryAction('get', (args ?? {}) as Record<string, unknown>, { session: bctx.ctx }),
|
|
945
|
+
{
|
|
946
|
+
name: 'memory_get',
|
|
947
|
+
description: MemoryPlugin.tools![2].description,
|
|
948
|
+
schema: z.object({}).passthrough(),
|
|
949
|
+
},
|
|
950
|
+
),
|
|
951
|
+
tool(
|
|
952
|
+
async (args) => executeNamedMemoryAction('store', (args ?? {}) as Record<string, unknown>, { session: bctx.ctx }),
|
|
953
|
+
{
|
|
954
|
+
name: 'memory_store',
|
|
955
|
+
description: MemoryPlugin.tools![3].description,
|
|
956
|
+
schema: z.object({}).passthrough(),
|
|
957
|
+
},
|
|
958
|
+
),
|
|
959
|
+
tool(
|
|
960
|
+
async (args) => executeNamedMemoryAction('update', (args ?? {}) as Record<string, unknown>, { session: bctx.ctx }),
|
|
961
|
+
{
|
|
962
|
+
name: 'memory_update',
|
|
963
|
+
description: MemoryPlugin.tools![4].description,
|
|
964
|
+
schema: z.object({}).passthrough(),
|
|
965
|
+
},
|
|
966
|
+
),
|
|
817
967
|
]
|
|
818
968
|
}
|
|
@@ -138,6 +138,18 @@ describe('memory tool knowledge actions (source verification)', () => {
|
|
|
138
138
|
assert.equal(enumBody.includes("'knowledge_store'"), false)
|
|
139
139
|
assert.equal(enumBody.includes("'knowledge_search'"), false)
|
|
140
140
|
})
|
|
141
|
+
|
|
142
|
+
it('declares the narrow OpenClaw-style memory tool names', async () => {
|
|
143
|
+
const fs = await import('fs')
|
|
144
|
+
const src = fs.readFileSync(
|
|
145
|
+
new URL('./memory.ts', import.meta.url).pathname,
|
|
146
|
+
'utf-8',
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
for (const toolName of ['memory_search', 'memory_get', 'memory_store', 'memory_update']) {
|
|
150
|
+
assert.ok(src.includes(`name: '${toolName}'`), `memory.ts should declare ${toolName}`)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
141
153
|
})
|
|
142
154
|
|
|
143
155
|
// ---------------------------------------------------------------------------
|
|
@@ -120,7 +120,7 @@ async function startSubagentJob(jobId: string, args: {
|
|
|
120
120
|
const latest = getDelegationJob(jobId)
|
|
121
121
|
if (latest?.status === 'cancelled') return
|
|
122
122
|
appendDelegationCheckpoint(jobId, 'Child session completed', 'completed')
|
|
123
|
-
completeDelegationJob(jobId, result.text.slice(0,
|
|
123
|
+
completeDelegationJob(jobId, result.text.slice(0, 32_000), { childSessionId: sid })
|
|
124
124
|
})
|
|
125
125
|
.catch((err: unknown) => {
|
|
126
126
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -213,10 +213,10 @@ async function executeSubagentAction(args: any, context: { sessionId?: string; c
|
|
|
213
213
|
agentId,
|
|
214
214
|
agentName: started.agent.name,
|
|
215
215
|
sessionId: started.sid,
|
|
216
|
-
response: result.text.slice(0,
|
|
216
|
+
response: result.text.slice(0, 32_000),
|
|
217
217
|
})
|
|
218
|
-
} catch (err:
|
|
219
|
-
return `Error: ${err.message}`
|
|
218
|
+
} catch (err: unknown) {
|
|
219
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|