@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.
Files changed (49) hide show
  1. package/README.md +8 -7
  2. package/package.json +2 -2
  3. package/src/app/api/notifications/route.ts +11 -12
  4. package/src/app/page.tsx +9 -0
  5. package/src/components/chat/chat-list.tsx +10 -9
  6. package/src/components/home/home-view.tsx +13 -2
  7. package/src/components/layout/app-layout.tsx +1 -0
  8. package/src/components/shared/command-palette.tsx +4 -1
  9. package/src/components/shared/notification-center.tsx +7 -1
  10. package/src/components/shared/search-dialog.tsx +10 -2
  11. package/src/lib/local-observability.test.ts +73 -0
  12. package/src/lib/local-observability.ts +47 -0
  13. package/src/lib/notification-utils.test.ts +72 -0
  14. package/src/lib/notification-utils.ts +68 -0
  15. package/src/lib/providers/openclaw.test.ts +21 -1
  16. package/src/lib/providers/openclaw.ts +22 -0
  17. package/src/lib/runtime-loop.ts +1 -1
  18. package/src/lib/server/agent-thread-session.test.ts +41 -0
  19. package/src/lib/server/agent-thread-session.ts +1 -0
  20. package/src/lib/server/chat-execution-advanced.test.ts +7 -0
  21. package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
  22. package/src/lib/server/chat-execution.ts +22 -5
  23. package/src/lib/server/create-notification.test.ts +94 -0
  24. package/src/lib/server/create-notification.ts +31 -25
  25. package/src/lib/server/daemon-state.test.ts +50 -0
  26. package/src/lib/server/daemon-state.ts +121 -38
  27. package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
  28. package/src/lib/server/eval/agent-regression.test.ts +13 -1
  29. package/src/lib/server/eval/agent-regression.ts +221 -1
  30. package/src/lib/server/memory-policy.test.ts +32 -0
  31. package/src/lib/server/memory-policy.ts +25 -0
  32. package/src/lib/server/plugins-advanced.test.ts +7 -0
  33. package/src/lib/server/runtime-settings.test.ts +2 -2
  34. package/src/lib/server/session-tools/crud.test.ts +136 -0
  35. package/src/lib/server/session-tools/crud.ts +44 -2
  36. package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
  37. package/src/lib/server/session-tools/delegate.ts +30 -0
  38. package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
  39. package/src/lib/server/session-tools/discovery.ts +7 -6
  40. package/src/lib/server/session-tools/memory.ts +156 -6
  41. package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
  42. package/src/lib/server/session-tools/subagent.ts +4 -4
  43. package/src/lib/server/storage.ts +14 -1
  44. package/src/lib/server/stream-agent-chat.test.ts +78 -1
  45. package/src/lib/server/stream-agent-chat.ts +225 -22
  46. package/src/lib/server/tool-aliases.ts +1 -1
  47. package/src/lib/server/tool-capability-policy.ts +1 -1
  48. package/src/stores/use-app-store.ts +26 -1
  49. 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, 60)
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, 60)
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 durable secret storage. Create/update calls accept either `data` as JSON or direct top-level fields like `name`, `service`, `value`, `scope`, `agentIds`, and `projectId`.'
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
- return JSON.stringify({
146
- alreadyGranted: true,
147
- pluginId,
148
- message: `You already have access to "${pluginId}". If it was just granted, it will be available on the next agent turn.`,
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 memory_tool only when recalling past conversations or when explicitly asked to remember. For info already in the current conversation, respond directly without tool calls.',
769
- 'When the user directly says to remember, store, or correct a fact, do one memory_tool store/update call immediately. Treat the newest direct user statement as authoritative.',
770
- 'memory_tool store/update now merges canonical memories and retires superseded variants. After a successful store/update, do not keep re-searching unless the user explicitly asked you to verify.',
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, 8000), { childSessionId: sid })
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, 8000),
216
+ response: result.text.slice(0, 32_000),
217
217
  })
218
- } catch (err: any) {
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