@swarmclawai/swarmclaw 0.9.3 → 0.9.4

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 (50) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  5. package/src/app/api/clawhub/install/route.ts +2 -0
  6. package/src/app/api/skills/[id]/route.ts +4 -0
  7. package/src/app/api/skills/route.ts +4 -0
  8. package/src/components/agents/agent-sheet.tsx +5 -5
  9. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  10. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  11. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  12. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  13. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  14. package/src/lib/server/agents/orchestrator.ts +11 -7
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  16. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  17. package/src/lib/server/chat-execution/chat-execution.ts +74 -26
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +65 -30
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +69 -25
  20. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  21. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  22. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  23. package/src/lib/server/connectors/manager.test.ts +504 -73
  24. package/src/lib/server/connectors/manager.ts +40 -9
  25. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  26. package/src/lib/server/connectors/session-kind.ts +7 -0
  27. package/src/lib/server/connectors/session.test.ts +104 -0
  28. package/src/lib/server/connectors/session.ts +5 -2
  29. package/src/lib/server/identity-continuity.test.ts +4 -3
  30. package/src/lib/server/identity-continuity.ts +8 -4
  31. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  32. package/src/lib/server/session-reset-policy.test.ts +17 -3
  33. package/src/lib/server/session-reset-policy.ts +4 -2
  34. package/src/lib/server/session-tools/connector.ts +11 -10
  35. package/src/lib/server/session-tools/crud.ts +41 -7
  36. package/src/lib/server/session-tools/index.ts +2 -0
  37. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  38. package/src/lib/server/session-tools/memory.ts +12 -23
  39. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  40. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  41. package/src/lib/server/session-tools/skills.ts +575 -0
  42. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  43. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  44. package/src/lib/server/skills/skill-discovery.ts +4 -0
  45. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  46. package/src/lib/server/skills/skills-normalize.ts +93 -1
  47. package/src/lib/server/storage.ts +1 -1
  48. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  49. package/src/lib/server/tasks/task-followups.ts +88 -13
  50. package/src/types/index.ts +26 -2
@@ -9,8 +9,7 @@ import { loadSettings, loadAgents, loadSkills, appendUsage } from '@/lib/server/
9
9
  import { estimateCost, buildPluginDefinitionCosts } from '@/lib/server/cost'
10
10
  import { getPluginManager } from '@/lib/server/plugins'
11
11
  import { loadRuntimeSettings, getAgentLoopRecursionLimit } from '@/lib/server/runtime/runtime-settings'
12
- import { buildSkillPromptText } from '@/lib/server/skills/skill-prompt-budget'
13
- import { buildDiscoveredSkillPromptText, collectPluginMatchedDiscoveredSkills } from '@/lib/server/skills/discovered-skill-prompt'
12
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
14
13
 
15
14
  import { logExecution } from '@/lib/server/execution-log'
16
15
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
@@ -22,6 +21,7 @@ import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
22
21
  import { resolveActiveProjectContext } from '@/lib/server/project-context'
23
22
  import { resolveImagePath } from '@/lib/server/resolve-image'
24
23
  import { routeTaskIntent } from '@/lib/server/capability-router'
24
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
25
25
  import {
26
26
  getEnabledToolPlanningView,
27
27
  getFirstToolForCapability,
@@ -97,6 +97,30 @@ export {
97
97
  resolveContinuationAssistantText,
98
98
  }
99
99
 
100
+ const TOOL_SUMMARY_SHORT_RESPONSE_EXEMPT_TOOLS = new Set([
101
+ 'use_skill',
102
+ ])
103
+
104
+ export function shouldSkipToolSummaryForShortResponse(params: {
105
+ fullText: string
106
+ toolEvents: MessageToolEvent[]
107
+ isConnectorSession?: boolean
108
+ }): boolean {
109
+ if (params.isConnectorSession) return false
110
+ if (!params.fullText.trim()) return false
111
+ if (!Array.isArray(params.toolEvents) || params.toolEvents.length === 0) return false
112
+ const toolNames = Array.from(new Set(
113
+ params.toolEvents
114
+ .map((event) => canonicalizePluginId(event.name) || event.name)
115
+ .filter((name): name is string => typeof name === 'string' && name.trim().length > 0),
116
+ ))
117
+ if (toolNames.length === 0) return false
118
+ // Skill runtime tools load guidance into context rather than producing external
119
+ // evidence that needs a forced synthesis pass. A short exact answer after those
120
+ // calls can already be the correct completion.
121
+ return toolNames.every((toolName) => TOOL_SUMMARY_SHORT_RESPONSE_EXEMPT_TOOLS.has(toolName))
122
+ }
123
+
100
124
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
101
125
  interface StreamAgentChatOpts {
102
126
  session: Session
@@ -380,6 +404,21 @@ function buildAgenticExecutionPolicy(opts: {
380
404
  'Do not use `manage_tasks`, `manage_agents`, or `delegate` as a substitute for a direct memory write or recall step.',
381
405
  )
382
406
  }
407
+ if (hasTooling) {
408
+ parts.push(
409
+ '## Skill Runtime',
410
+ 'When the skill runtime section lists a fitting reusable workflow, use `use_skill` to select it before falling back to generic exploration.',
411
+ 'Prefer `use_skill` action `run` for executable skills and `use_skill` action `load` only when the skill is guidance-only.',
412
+ )
413
+ }
414
+ if (opts.enabledPlugins.some((toolId) => (canonicalizePluginId(toolId) || toolId) === 'manage_skills')) {
415
+ parts.push(
416
+ '## Skill Resolution',
417
+ 'When you are blocked on a missing capability, binary, or environment setup, call `manage_skills` before repeating generic exploration.',
418
+ 'Use `manage_skills` action `recommend_for_task` or `status` to find a fitting local skill. If a fitting skill needs installation, request the explicit install approval through `manage_skills` and stop retrying the same blocker.',
419
+ 'Do not silently install skills during autonomous runs. Installation is explicit and approval-gated.',
420
+ )
421
+ }
383
422
  if (opts.hasAttachmentContext) {
384
423
  parts.push(
385
424
  '## Attachments',
@@ -492,7 +531,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
492
531
  async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
493
532
  const startTs = Date.now()
494
533
  const { session, message, imagePath, imageUrl, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
495
- const isConnectorSession = !!session.connectorContext?.connectorId
534
+ const isConnectorSession = isDirectConnectorSession(session)
496
535
  const rawPlugins = Array.isArray(session.plugins) ? session.plugins : []
497
536
  const hasShellCapability = rawPlugins.some((toolId) => ['shell', 'execute_command'].includes(String(toolId)))
498
537
  const sessionPlugins = expandPluginIds([
@@ -582,27 +621,16 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
582
621
  if (continuityBlock) promptParts.push(continuityBlock)
583
622
  if (agent?.soul) promptParts.push(agent.soul)
584
623
  if (agent?.systemPrompt) promptParts.push(agent.systemPrompt)
585
- const allSkills = loadSkills()
586
- if (agent?.skillIds?.length) {
587
- const skillPromptText = buildSkillPromptText(allSkills, agent.skillIds)
588
- if (skillPromptText) promptParts.push(skillPromptText)
589
- }
590
-
591
- // Auto-discover workspace/bundled skills. If one matches an enabled plugin,
592
- // inject the full skill content so the agent can use that tool more precisely.
593
624
  try {
594
- const { discoverSkills } = await import('@/lib/server/skills/skill-discovery')
595
- const discovered = discoverSkills({ cwd: session.cwd })
596
- if (discovered.length > 0) {
597
- const { matched, remaining } = collectPluginMatchedDiscoveredSkills(discovered, sessionPlugins, allSkills)
598
- const discoveredPromptText = buildDiscoveredSkillPromptText(matched)
599
- if (discoveredPromptText) promptParts.push(discoveredPromptText)
600
-
601
- const discoveredBlock = remaining
602
- .map(s => `- **${s.name}**: ${(s.description || '').slice(0, 120)}`)
603
- .join('\n')
604
- if (discoveredBlock) promptParts.push(`## Available Skills\n${discoveredBlock}`)
605
- }
625
+ const allSkills = loadSkills()
626
+ const runtimeSkills = resolveRuntimeSkills({
627
+ cwd: session.cwd,
628
+ enabledPlugins: sessionPlugins,
629
+ agentSkillIds: agent?.skillIds || [],
630
+ storedSkills: allSkills,
631
+ selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
632
+ })
633
+ promptParts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
606
634
  } catch { /* non-critical */ }
607
635
  }
608
636
  }
@@ -1555,7 +1583,16 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1555
1583
  // However, if tools already produced results but the model has no/trivial text,
1556
1584
  // we attempt a tool_summary continuation instead of just erroring out.
1557
1585
  if (loopDetectionTriggered) {
1558
- const loopTextIsTrivial = !fullText.trim() || (fullText.trim().length < 150 && streamedToolEvents.length >= 2)
1586
+ const skipToolSummaryForShortResponse = shouldSkipToolSummaryForShortResponse({
1587
+ fullText,
1588
+ toolEvents: streamedToolEvents,
1589
+ isConnectorSession,
1590
+ })
1591
+ const loopTextIsTrivial = !fullText.trim() || (
1592
+ !skipToolSummaryForShortResponse
1593
+ && fullText.trim().length < 150
1594
+ && streamedToolEvents.length >= 2
1595
+ )
1559
1596
  if (loopTextIsTrivial && streamedToolEvents.length > 0 && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES) {
1560
1597
  // Override: let the tool_summary check below handle it instead of breaking
1561
1598
  loopDetectionTriggered = null
@@ -1715,8 +1752,14 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1715
1752
  // Triggers when: (a) text is empty, or (b) text is trivially short (< 150 chars)
1716
1753
  // and multiple tools ran — the agent likely emitted a "I'll do X" preamble but
1717
1754
  // never synthesized the tool outputs into a real response.
1755
+ const skipToolSummaryForShortResponse = shouldSkipToolSummaryForShortResponse({
1756
+ fullText,
1757
+ toolEvents: streamedToolEvents,
1758
+ isConnectorSession,
1759
+ })
1718
1760
  const textIsTrivial = !fullText.trim() || (
1719
- !isConnectorSession && fullText.trim().length < 150
1761
+ !skipToolSummaryForShortResponse
1762
+ && !isConnectorSession && fullText.trim().length < 150
1720
1763
  && (
1721
1764
  streamedToolEvents.length >= 2
1722
1765
  || likelyResearchSynthesisTask
@@ -1728,6 +1771,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1728
1771
  && hasToolCalls
1729
1772
  && textIsTrivial
1730
1773
  && streamedToolEvents.length > 0
1774
+ && !skipToolSummaryForShortResponse
1731
1775
  && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES
1732
1776
  ) {
1733
1777
  shouldContinue = 'tool_summary'
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
1
4
  import { describe, it } from 'node:test'
2
5
  import assert from 'node:assert/strict'
3
6
  import type { Agent, Chatroom } from '@/types'
@@ -9,6 +12,7 @@ import {
9
12
  resolveChatroomWorkspaceDir,
10
13
  resolveAgentApiEndpoint,
11
14
  resolveReplyTargetAgentId,
15
+ buildAgentSystemPromptForChatroom,
12
16
  } from '@/lib/server/chatrooms/chatroom-helpers'
13
17
 
14
18
  function makeAgents(): Record<string, Agent> {
@@ -163,4 +167,26 @@ describe('chatroom-helpers', () => {
163
167
  assert.equal(cwd, resolveChatroomWorkspaceDir('room-safe'))
164
168
  assert.match(cwd, /chatrooms[\/\\]room-safe$/)
165
169
  })
170
+
171
+ it('includes discoverable local skills in chatroom prompts even when none are pinned', () => {
172
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chatroom-skill-'))
173
+ try {
174
+ const skillDir = path.join(cwd, 'skills', 'chatroom-default-skill')
175
+ fs.mkdirSync(skillDir, { recursive: true })
176
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---
177
+ name: chatroom-default-skill
178
+ description: Local chatroom skill.
179
+ ---
180
+ # Chatroom Default Skill
181
+
182
+ Prefer this chatroom workflow when it fits.
183
+ `)
184
+
185
+ const prompt = buildAgentSystemPromptForChatroom(makeAgents().default, cwd)
186
+ assert.match(prompt, /discoverable by default/i)
187
+ assert.match(prompt, /chatroom-default-skill/i)
188
+ } finally {
189
+ fs.rmSync(cwd, { recursive: true, force: true })
190
+ }
191
+ })
166
192
  })
@@ -9,6 +9,7 @@ import { getProvider } from '@/lib/providers'
9
9
  import { normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
10
10
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
11
11
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
12
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
12
13
  import type { Chatroom, ChatroomMember, Agent, Session, Message, ChatroomMessage } from '@/types'
13
14
 
14
15
  /** Resolve API key from an agent's credentialId */
@@ -324,7 +325,7 @@ export function appendSyntheticSessionMessage(
324
325
  }
325
326
 
326
327
  /** Build agent's system prompt including skills and identity context */
327
- export function buildAgentSystemPromptForChatroom(agent: Agent): string {
328
+ export function buildAgentSystemPromptForChatroom(agent: Agent, cwd?: string | null): string {
328
329
  const settings = loadSettings()
329
330
  const parts: string[] = []
330
331
 
@@ -358,13 +359,15 @@ export function buildAgentSystemPromptForChatroom(agent: Agent): string {
358
359
  if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
359
360
 
360
361
  // 5. Skills (SwarmClaw Core)
361
- if (agent.skillIds?.length) {
362
- const allSkills = loadSkills()
363
- for (const skillId of agent.skillIds) {
364
- const skill = allSkills[skillId]
365
- if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
366
- }
367
- }
362
+ try {
363
+ const runtimeSkills = resolveRuntimeSkills({
364
+ cwd,
365
+ enabledPlugins: agent.plugins || agent.tools || [],
366
+ agentSkillIds: agent.skillIds || [],
367
+ storedSkills: loadSkills(),
368
+ })
369
+ parts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
370
+ } catch { /* non-critical */ }
368
371
 
369
372
  // 6. Thinking & Output Format (OpenClaw Style)
370
373
  const thinkingHint = [
@@ -0,0 +1,101 @@
1
+ import type { Agent, Session, MemoryEntry, Connector } from '@/types'
2
+ import { getMemoryDb } from '@/lib/server/memory/memory-db'
3
+ import { dedup } from '@/lib/shared-utils'
4
+ import { isReplyToLastOutbound, textMentionsAlias } from './policy'
5
+ import type { InboundMessage } from './types'
6
+
7
+ function toDigits(raw: string): string {
8
+ const stripped = raw.replace(/@.*$/, '').replace(/[^\d]/g, '')
9
+ if (stripped.startsWith('0') && stripped.length >= 10) return `44${stripped.slice(1)}`
10
+ return stripped
11
+ }
12
+
13
+ function collectSenderIds(
14
+ msg: InboundMessage,
15
+ session?: Partial<Session> | null,
16
+ ): string[] {
17
+ return dedup([
18
+ msg.senderId,
19
+ msg.senderIdAlt,
20
+ msg.channelId,
21
+ msg.channelIdAlt,
22
+ ...(Array.isArray(session?.connectorContext?.allKnownPeerIds) ? session.connectorContext.allKnownPeerIds : []),
23
+ ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0))
24
+ }
25
+
26
+ function memoryMatchesSender(entry: MemoryEntry, senderIds: string[], senderName: string): boolean {
27
+ const title = String(entry.title || '').toLowerCase()
28
+ const content = String(entry.content || '').toLowerCase()
29
+ const normalizedSenderName = senderName.trim().toLowerCase()
30
+
31
+ for (const rawId of senderIds) {
32
+ const lowered = rawId.toLowerCase()
33
+ if (lowered && (title.includes(lowered) || content.includes(lowered))) return true
34
+ }
35
+
36
+ const senderDigits = new Set(senderIds.map(toDigits).filter((value) => value.length >= 6))
37
+ const memoryDigits = [
38
+ ...(String(entry.content || '').match(/(?:\+?\d[\d\s\-().]{6,}\d)/g) || []).map(toDigits),
39
+ ...(Array.isArray((entry.metadata as Record<string, unknown> | undefined)?.identifiers)
40
+ ? ((entry.metadata as Record<string, unknown>).identifiers as unknown[])
41
+ .filter((value): value is string => typeof value === 'string')
42
+ .map(toDigits)
43
+ : []),
44
+ ].filter((value) => value.length >= 6)
45
+
46
+ for (const memoryDigit of memoryDigits) {
47
+ for (const senderDigit of senderDigits) {
48
+ if (senderDigit.endsWith(memoryDigit) || memoryDigit.endsWith(senderDigit)) return true
49
+ }
50
+ }
51
+
52
+ if (!normalizedSenderName) return false
53
+ return title.includes(normalizedSenderName) || content.includes(normalizedSenderName)
54
+ }
55
+
56
+ function memoryDefinesQuietBoundary(entry: MemoryEntry): boolean {
57
+ const text = `${entry.title || ''}\n${entry.content || ''}`.toLowerCase()
58
+ const boundaryRule = /\b(?:do not respond|do not reply|don't respond|don't reply|no replies|stay quiet|stay silent|remain quiet|be quiet)\b[\s\S]{0,140}\bunless\b/i
59
+ const directAddressRule = /\b(?:address(?:es|ed)?|mention(?:s|ed)?|refer(?:s|red)?|talk(?:ing)? to)\b[\s\S]{0,80}\bhal\b/i
60
+ const verifyRule = /\bverify whether\b[\s\S]{0,80}\b(?:wayde|hal)\b/i
61
+ return boundaryRule.test(text) && (directAddressRule.test(text) || verifyRule.test(text))
62
+ }
63
+
64
+ function buildDirectAddressAliases(agent: Partial<Agent> | null | undefined, connector: Partial<Connector> | null | undefined): string[] {
65
+ const agentName = typeof agent?.name === 'string' ? agent.name.trim() : ''
66
+ const connectorName = typeof connector?.name === 'string' ? connector.name.trim() : ''
67
+ const aliases = [agentName, connectorName]
68
+ const firstWord = agentName.split(/\s+/)[0] || ''
69
+ if (firstWord) aliases.push(firstWord)
70
+ if (agentName.toLowerCase().includes('hal')) aliases.push('Hal')
71
+ return dedup(aliases.filter(Boolean))
72
+ }
73
+
74
+ export function enforceSenderQuietBoundary(params: {
75
+ agent?: Partial<Agent> | null
76
+ connector?: Partial<Connector> | null
77
+ session?: Partial<Session> | null
78
+ msg: InboundMessage
79
+ }): { suppress: boolean; memoryTitle?: string } {
80
+ const { agent, connector, session, msg } = params
81
+ if (!agent?.id || msg.isGroup) return { suppress: false }
82
+
83
+ const senderIds = collectSenderIds(msg, session)
84
+ const senderName = typeof msg.senderName === 'string' ? msg.senderName : ''
85
+ if (senderIds.length === 0 && !senderName.trim()) return { suppress: false }
86
+
87
+ const memDb = getMemoryDb()
88
+ const memories = memDb.list(agent.id, 200).filter((entry) =>
89
+ entry.category?.startsWith('identity/')
90
+ && memoryMatchesSender(entry, senderIds, senderName),
91
+ )
92
+ const matchedBoundary = memories.find(memoryDefinesQuietBoundary)
93
+ if (!matchedBoundary) return { suppress: false }
94
+
95
+ const explicitlyAddressed = textMentionsAlias(msg.text || '', buildDirectAddressAliases(agent, connector))
96
+ || isReplyToLastOutbound(msg, session)
97
+
98
+ return explicitlyAddressed
99
+ ? { suppress: false }
100
+ : { suppress: true, memoryTitle: matchedBoundary.title }
101
+ }