@swarmclawai/swarmclaw 0.9.2 → 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 (75) 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/agents/page.tsx +2 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  6. package/src/app/api/clawhub/install/route.ts +2 -0
  7. package/src/app/api/skills/[id]/route.ts +4 -0
  8. package/src/app/api/skills/route.ts +4 -0
  9. package/src/app/globals.css +28 -0
  10. package/src/app/home/page.tsx +11 -0
  11. package/src/app/settings/page.tsx +12 -5
  12. package/src/components/agents/agent-sheet.tsx +5 -5
  13. package/src/components/connectors/connector-list.tsx +2 -5
  14. package/src/components/logs/log-list.tsx +2 -5
  15. package/src/components/providers/provider-list.tsx +2 -5
  16. package/src/components/runs/run-list.tsx +2 -6
  17. package/src/components/schedules/schedule-list.tsx +7 -1
  18. package/src/components/ui/full-screen-loader.tsx +0 -29
  19. package/src/components/ui/page-loader.tsx +69 -0
  20. package/src/lib/runtime/runtime-loop.ts +21 -1
  21. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  22. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  23. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  24. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  25. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  26. package/src/lib/server/agents/orchestrator.ts +11 -7
  27. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  28. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  29. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
  30. package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
  31. package/src/lib/server/chat-execution/chat-execution.ts +116 -29
  32. package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
  33. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
  34. package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
  35. package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
  36. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  37. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  38. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  39. package/src/lib/server/connectors/manager.test.ts +504 -73
  40. package/src/lib/server/connectors/manager.ts +41 -10
  41. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  42. package/src/lib/server/connectors/session-kind.ts +7 -0
  43. package/src/lib/server/connectors/session.test.ts +104 -0
  44. package/src/lib/server/connectors/session.ts +5 -2
  45. package/src/lib/server/identity-continuity.test.ts +4 -3
  46. package/src/lib/server/identity-continuity.ts +8 -4
  47. package/src/lib/server/memory/memory-policy.test.ts +5 -15
  48. package/src/lib/server/memory/memory-policy.ts +11 -41
  49. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  50. package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
  51. package/src/lib/server/runtime/heartbeat-service.ts +5 -1
  52. package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
  53. package/src/lib/server/runtime/runtime-settings.ts +4 -0
  54. package/src/lib/server/runtime/session-run-manager.ts +2 -0
  55. package/src/lib/server/session-reset-policy.test.ts +17 -3
  56. package/src/lib/server/session-reset-policy.ts +4 -2
  57. package/src/lib/server/session-tools/connector.ts +11 -10
  58. package/src/lib/server/session-tools/crud.ts +41 -7
  59. package/src/lib/server/session-tools/delegate.ts +3 -3
  60. package/src/lib/server/session-tools/index.ts +2 -0
  61. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  62. package/src/lib/server/session-tools/memory.ts +209 -48
  63. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  64. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  65. package/src/lib/server/session-tools/skills.ts +575 -0
  66. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  67. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  68. package/src/lib/server/skills/skill-discovery.ts +4 -0
  69. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  70. package/src/lib/server/skills/skills-normalize.ts +93 -1
  71. package/src/lib/server/storage.ts +1 -1
  72. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  73. package/src/lib/server/tasks/task-followups.ts +88 -13
  74. package/src/types/index.ts +30 -2
  75. package/src/views/settings/section-runtime-loop.tsx +38 -0
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import { HumanMessage, AIMessage } from '@langchain/core/messages'
3
3
  import { createReactAgent } from '@langchain/langgraph/prebuilt'
4
+ import { MemorySaver } from '@langchain/langgraph'
4
5
  import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/runtime/heartbeat-defaults'
5
6
  import { buildSessionTools } from '@/lib/server/session-tools'
6
7
  import { buildChatModel } from '@/lib/server/build-llm'
@@ -8,8 +9,7 @@ import { loadSettings, loadAgents, loadSkills, appendUsage } from '@/lib/server/
8
9
  import { estimateCost, buildPluginDefinitionCosts } from '@/lib/server/cost'
9
10
  import { getPluginManager } from '@/lib/server/plugins'
10
11
  import { loadRuntimeSettings, getAgentLoopRecursionLimit } from '@/lib/server/runtime/runtime-settings'
11
- import { buildSkillPromptText } from '@/lib/server/skills/skill-prompt-budget'
12
- import { buildDiscoveredSkillPromptText, collectPluginMatchedDiscoveredSkills } from '@/lib/server/skills/discovered-skill-prompt'
12
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
13
13
 
14
14
  import { logExecution } from '@/lib/server/execution-log'
15
15
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
@@ -21,6 +21,7 @@ import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
21
21
  import { resolveActiveProjectContext } from '@/lib/server/project-context'
22
22
  import { resolveImagePath } from '@/lib/server/resolve-image'
23
23
  import { routeTaskIntent } from '@/lib/server/capability-router'
24
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
24
25
  import {
25
26
  getEnabledToolPlanningView,
26
27
  getFirstToolForCapability,
@@ -51,31 +52,43 @@ import {
51
52
  import type { ContinuationType } from '@/lib/server/chat-execution/stream-continuation'
52
53
  import { dedup, errorMessage, sleep } from '@/lib/shared-utils'
53
54
  import { perf } from '@/lib/server/runtime/perf'
54
- import { getCheckpointSaver } from '@/lib/server/langgraph-checkpoint'
55
55
  import {
56
56
  compactThreadRecallText,
57
57
  getExplicitRequiredToolNames,
58
58
  getWalletApprovalBoundaryAction,
59
- isNarrowDirectMemoryWriteTurn,
60
59
  isWalletSimulationResult,
61
60
  resolveToolAction,
62
- shouldAllowToolForDirectMemoryWrite,
63
- shouldAllowToolForCurrentThreadRecall,
64
61
  shouldForceExternalServiceSummary,
65
62
  shouldTerminateOnSuccessfulMemoryMutation,
66
63
  updateStreamedToolEvents,
67
64
  } from '@/lib/server/chat-execution/chat-streaming-utils'
68
65
  import { LangGraphToolEventTracker } from '@/lib/server/chat-execution/tool-event-tracker'
69
66
 
67
+ // LangGraph's streamEvents leaves dangling internal promises when the for-await
68
+ // loop exits early (break on tool loop detection, execution boundary, etc.).
69
+ // These promises may later reject with GraphRecursionError or AbortError.
70
+ // Register a permanent handler to prevent process crashes from these expected
71
+ // background rejections. Only LangGraph-specific errors (identified by
72
+ // pregelTaskId or lc_error_code) are suppressed; all other rejections propagate
73
+ // normally.
74
+ process.on('unhandledRejection', (err: unknown) => {
75
+ if (
76
+ err && typeof err === 'object'
77
+ && ('pregelTaskId' in err
78
+ || (err instanceof Error && (err.name === 'AbortError' || err.name === 'GraphRecursionError'))
79
+ || (err as Record<string, unknown>).lc_error_code === 'GRAPH_RECURSION_LIMIT')
80
+ ) {
81
+ // Silently suppress — expected background rejection from LangGraph
82
+ return
83
+ }
84
+ })
85
+
70
86
  // Re-export continuation functions so existing consumers don't need to change imports
71
87
  export {
72
88
  getExplicitRequiredToolNames,
73
- isNarrowDirectMemoryWriteTurn,
74
89
  isWalletSimulationResult,
75
90
  looksLikeOpenEndedDeliverableTask,
76
91
  shouldForceRecoverableToolErrorFollowthrough,
77
- shouldAllowToolForDirectMemoryWrite,
78
- shouldAllowToolForCurrentThreadRecall,
79
92
  shouldForceExternalExecutionFollowthrough,
80
93
  shouldForceDeliverableFollowthrough,
81
94
  shouldForceExternalServiceSummary,
@@ -84,6 +97,30 @@ export {
84
97
  resolveContinuationAssistantText,
85
98
  }
86
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
+
87
124
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
88
125
  interface StreamAgentChatOpts {
89
126
  session: Session
@@ -101,9 +138,6 @@ interface StreamAgentChatOpts {
101
138
 
102
139
  // LangGraph uses this internal configurable key to bypass subgraph lookup when
103
140
  // resolving state from a namespaced checkpoint. It is not exported publicly in
104
- // @langchain/langgraph 1.x, so keep the literal here instead of importing a
105
- // non-exported symbol that breaks Next compilation.
106
- const LANGGRAPH_CHECKPOINTER_CONFIG_KEY = '__pregel_checkpointer'
107
141
  const CONTEXT_WARNING_OVERHEAD_TOKENS = 192
108
142
 
109
143
  /** Extract HTTP status code and Retry-After from provider error objects (OpenAI SDK, etc.) */
@@ -341,7 +375,6 @@ function buildAgenticExecutionPolicy(opts: {
341
375
  const pluginLines = buildPluginCapabilityLines(opts.enabledPlugins, { platformAssignScope: opts.platformAssignScope })
342
376
  const toolDisciplineLines = buildToolDisciplineLines(opts.enabledPlugins)
343
377
  const hasMemoryTools = opts.enabledPlugins.some((toolId) => (canonicalizePluginId(toolId) || toolId) === 'memory')
344
- const directMemoryWriteOnlyTurn = Boolean(opts.userMessage && isNarrowDirectMemoryWriteTurn(opts.userMessage))
345
378
 
346
379
  const parts: string[] = []
347
380
 
@@ -371,10 +404,21 @@ function buildAgenticExecutionPolicy(opts: {
371
404
  'Do not use `manage_tasks`, `manage_agents`, or `delegate` as a substitute for a direct memory write or recall step.',
372
405
  )
373
406
  }
374
- if (hasMemoryTools && directMemoryWriteOnlyTurn) {
375
- parts.push(buildDirectMemoryWriteBlock())
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
+ )
376
421
  }
377
-
378
422
  if (opts.hasAttachmentContext) {
379
423
  parts.push(
380
424
  '## Attachments',
@@ -393,6 +437,7 @@ function buildAgenticExecutionPolicy(opts: {
393
437
  'Execute by default — only confirm on high-risk actions.',
394
438
  'If a tool errors, retry or explain the blocker. Never claim success without evidence.',
395
439
  'Keep responses concise. Bullet points over prose. After file operations, confirm the result briefly (path and status) without echoing the full file contents.',
440
+ 'Do not end every reply with a question. Only ask when a specific missing detail blocks progress. When a task is done, state the result and stop.',
396
441
  opts.responseStyle === 'concise'
397
442
  ? `IMPORTANT: Be extremely concise.${opts.responseMaxChars ? ` Keep responses under ${opts.responseMaxChars} characters.` : ' Target under 500 characters.'} Lead with the answer, skip preamble.`
398
443
  : opts.responseStyle === 'detailed'
@@ -458,16 +503,6 @@ function buildCurrentThreadRecallBlock(history: Message[]): string {
458
503
  return lines.join('\n')
459
504
  }
460
505
 
461
- function buildDirectMemoryWriteBlock(): string {
462
- return [
463
- '## Direct Memory Write',
464
- 'This turn is a direct request to remember, store, or correct a durable fact.',
465
- 'Call `memory_store` or `memory_update` immediately, then confirm the stored value succinctly.',
466
- 'If the user bundled several related facts into one remember request, store them together in one canonical memory write unless they explicitly asked for separate entries.',
467
- 'Do not inspect skills, browse the workspace, request capabilities, manage tasks, manage agents, or delegate before the direct memory write is complete.',
468
- ].join('\n')
469
- }
470
-
471
506
  export interface StreamAgentChatResult {
472
507
  /** All text accumulated across every LLM turn (for SSE / web UI history). */
473
508
  fullText: string
@@ -496,7 +531,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
496
531
  async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
497
532
  const startTs = Date.now()
498
533
  const { session, message, imagePath, imageUrl, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
499
- const isConnectorSession = !!session.connectorContext?.connectorId
534
+ const isConnectorSession = isDirectConnectorSession(session)
500
535
  const rawPlugins = Array.isArray(session.plugins) ? session.plugins : []
501
536
  const hasShellCapability = rawPlugins.some((toolId) => ['shell', 'execute_command'].includes(String(toolId)))
502
537
  const sessionPlugins = expandPluginIds([
@@ -543,8 +578,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
543
578
 
544
579
  const promptParts: string[] = []
545
580
  const hasProvidedSystemPrompt = typeof systemPrompt === 'string' && systemPrompt.trim().length > 0
546
- const directMemoryWriteOnlyTurn = isNarrowDirectMemoryWriteTurn(message)
547
- const currentThreadRecallRequest = !directMemoryWriteOnlyTurn && isCurrentThreadRecallRequest(message)
581
+ const currentThreadRecallRequest = isCurrentThreadRecallRequest(message)
548
582
  const hasAttachmentContext = Boolean(
549
583
  imagePath
550
584
  || attachedFiles?.length
@@ -587,27 +621,16 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
587
621
  if (continuityBlock) promptParts.push(continuityBlock)
588
622
  if (agent?.soul) promptParts.push(agent.soul)
589
623
  if (agent?.systemPrompt) promptParts.push(agent.systemPrompt)
590
- const allSkills = loadSkills()
591
- if (agent?.skillIds?.length) {
592
- const skillPromptText = buildSkillPromptText(allSkills, agent.skillIds)
593
- if (skillPromptText) promptParts.push(skillPromptText)
594
- }
595
-
596
- // Auto-discover workspace/bundled skills. If one matches an enabled plugin,
597
- // inject the full skill content so the agent can use that tool more precisely.
598
624
  try {
599
- const { discoverSkills } = await import('@/lib/server/skills/skill-discovery')
600
- const discovered = discoverSkills({ cwd: session.cwd })
601
- if (discovered.length > 0) {
602
- const { matched, remaining } = collectPluginMatchedDiscoveredSkills(discovered, sessionPlugins, allSkills)
603
- const discoveredPromptText = buildDiscoveredSkillPromptText(matched)
604
- if (discoveredPromptText) promptParts.push(discoveredPromptText)
605
-
606
- const discoveredBlock = remaining
607
- .map(s => `- **${s.name}**: ${(s.description || '').slice(0, 120)}`)
608
- .join('\n')
609
- if (discoveredBlock) promptParts.push(`## Available Skills\n${discoveredBlock}`)
610
- }
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))
611
634
  } catch { /* non-critical */ }
612
635
  }
613
636
  }
@@ -653,8 +676,8 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
653
676
  try {
654
677
  const pluginContextParts = await getPluginManager().collectAgentContext(session, sessionPlugins, message, history)
655
678
  promptParts.push(...pluginContextParts)
656
- } catch {
657
- // Plugin context injection is non-critical
679
+ } catch (err: unknown) {
680
+ console.error('[stream-agent-chat] Plugin context injection failed:', err instanceof Error ? err.message : String(err))
658
681
  }
659
682
 
660
683
  if (!hasProvidedSystemPrompt && activeProjectContext.projectId) {
@@ -757,7 +780,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
757
780
 
758
781
  // Proactive memory recall: inject relevant memories into context before LLM invocation
759
782
  // Skips heartbeat polls, very short messages, and thread-recall requests (which use chat history instead)
760
- if (session.agentId && !directMemoryWriteOnlyTurn && !currentThreadRecallRequest && message.length > 12) {
783
+ if (session.agentId && !currentThreadRecallRequest && message.length > 12) {
761
784
  try {
762
785
  const agents = loadAgents()
763
786
  const agentForMemory = agents[session.agentId]
@@ -794,23 +817,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
794
817
  memoryScopeMode: agentMemoryScopeMode,
795
818
  })
796
819
  endToolBuildPerf({ toolCount: tools.length })
797
- const toolsForTurn = currentThreadRecallRequest
798
- ? tools.filter((tool) => {
799
- const toolName = typeof (tool as { name?: unknown }).name === 'string'
800
- ? String((tool as { name?: unknown }).name)
801
- : ''
802
- return shouldAllowToolForCurrentThreadRecall(toolName)
803
- })
804
- : directMemoryWriteOnlyTurn
805
- ? tools.filter((tool) => {
806
- const toolName = typeof (tool as { name?: unknown }).name === 'string'
807
- ? String((tool as { name?: unknown }).name)
808
- : ''
809
- return shouldAllowToolForDirectMemoryWrite(toolName)
810
- })
811
- : tools
812
- const checkpointNamespace = `chat:${startTs}`
813
- const checkpointSaver = getCheckpointSaver()
814
820
  const recursionLimit = getAgentLoopRecursionLimit(runtime)
815
821
 
816
822
  // Build message history for context
@@ -997,11 +1003,17 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
997
1003
  // Warning failure is non-critical
998
1004
  }
999
1005
 
1006
+ // Use a fresh in-memory checkpointer instead of the SQLite one. We manage
1007
+ // conversation history externally via langchainMessages — each iteration
1008
+ // receives full history, so no cross-iteration checkpoint state is needed.
1009
+ // MemorySaver avoids the SQLite serde round-trip that dropped tool_call IDs
1010
+ // or ToolMessages, causing OpenAI to reject with "tool_calls must be
1011
+ // followed by tool messages" errors.
1000
1012
  const agent = createReactAgent({
1001
1013
  llm,
1002
- tools: toolsForTurn,
1014
+ tools,
1003
1015
  prompt,
1004
- checkpointer: checkpointSaver,
1016
+ checkpointer: new MemorySaver(),
1005
1017
  })
1006
1018
 
1007
1019
  const langchainMessages: Array<HumanMessage | AIMessage> = []
@@ -1018,7 +1030,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1018
1030
  const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
1019
1031
  langchainMessages.push(new HumanMessage({ content: currentContent }))
1020
1032
  let pendingGraphMessages = [...langchainMessages]
1021
- let currentCheckpointId: string | undefined
1022
1033
 
1023
1034
  let fullText = ''
1024
1035
  let lastSegment = ''
@@ -1073,7 +1084,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1073
1084
  MAX_TOOL_SUMMARY_RETRIES = 1
1074
1085
  MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS = 1
1075
1086
  }
1076
- const REQUIRED_TOOL_KICKOFF_TIMEOUT_MS = 20_000
1087
+ const REQUIRED_TOOL_KICKOFF_TIMEOUT_MS = runtime.requiredToolKickoffMs
1077
1088
  let autoContinueCount = 0
1078
1089
  let transientRetryCount = 0
1079
1090
  let pendingRetryAfterMs: number | null = null
@@ -1154,7 +1165,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1154
1165
  idleTimer = setTimeout(() => {
1155
1166
  idleTimedOut = true
1156
1167
  iterationController.abort()
1157
- }, 90_000)
1168
+ }, runtime.streamIdleStallMs)
1158
1169
  }
1159
1170
 
1160
1171
  const armRequiredToolKickoff = () => {
@@ -1175,23 +1186,21 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1175
1186
  const toolPerfEnds = new Map<string, (extra?: Record<string, unknown>) => number>()
1176
1187
  const iterationInputMessages = pendingGraphMessages
1177
1188
  let iterationSucceeded = false
1189
+ const eventStream = agent.streamEvents(
1190
+ { messages: iterationInputMessages },
1191
+ {
1192
+ version: 'v2',
1193
+ recursionLimit,
1194
+ signal: iterationController.signal,
1195
+ configurable: {
1196
+ thread_id: `${session.id}:${startTs}:${iteration}`,
1197
+ },
1198
+ },
1199
+ )
1178
1200
 
1179
1201
  try {
1180
1202
  armIdleWatchdog()
1181
1203
  armRequiredToolKickoff()
1182
- const eventStream = agent.streamEvents(
1183
- { messages: iterationInputMessages },
1184
- {
1185
- version: 'v2',
1186
- recursionLimit,
1187
- signal: iterationController.signal,
1188
- configurable: {
1189
- thread_id: session.id,
1190
- checkpoint_ns: checkpointNamespace,
1191
- ...(currentCheckpointId ? { checkpoint_id: currentCheckpointId } : {}),
1192
- },
1193
- },
1194
- )
1195
1204
 
1196
1205
  for await (const event of eventStream) {
1197
1206
  const kind = event.event
@@ -1434,7 +1443,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1434
1443
  } catch (innerErr: unknown) {
1435
1444
  const errName = innerErr instanceof Error ? innerErr.constructor.name : ''
1436
1445
  const errMsg = idleTimedOut
1437
- ? 'Model stream stalled without emitting text or tool results for 90 seconds.'
1446
+ ? `Model stream stalled without emitting text or tool results for ${Math.trunc(runtime.streamIdleStallMs / 1000)} seconds.`
1438
1447
  : requiredToolKickoffTimedOut
1439
1448
  ? `The turn did not start the required workspace tool step within ${Math.trunc(REQUIRED_TOOL_KICKOFF_TIMEOUT_MS / 1000)} seconds.`
1440
1449
  : errorMessage(innerErr)
@@ -1539,24 +1548,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1539
1548
  abortController.signal.removeEventListener('abort', onParentAbort)
1540
1549
  }
1541
1550
 
1542
- if (iterationSucceeded) {
1543
- try {
1544
- const state = await agent.getState({
1545
- configurable: {
1546
- thread_id: session.id,
1547
- checkpoint_ns: checkpointNamespace,
1548
- [LANGGRAPH_CHECKPOINTER_CONFIG_KEY]: checkpointSaver,
1549
- },
1550
- }) as { config?: { configurable?: { checkpoint_id?: unknown } } } | null
1551
- const latestCheckpointId = state?.config?.configurable?.checkpoint_id
1552
- if (typeof latestCheckpointId === 'string' && latestCheckpointId.trim()) {
1553
- currentCheckpointId = latestCheckpointId
1554
- }
1555
- } catch (checkpointErr) {
1556
- console.warn('[stream-agent-chat] Failed to refresh latest checkpoint state:', errorMessage(checkpointErr))
1557
- }
1558
- }
1559
-
1560
1551
  if (reachedExecutionBoundary) break
1561
1552
 
1562
1553
  if (!shouldContinue
@@ -1592,7 +1583,16 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1592
1583
  // However, if tools already produced results but the model has no/trivial text,
1593
1584
  // we attempt a tool_summary continuation instead of just erroring out.
1594
1585
  if (loopDetectionTriggered) {
1595
- 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
+ )
1596
1596
  if (loopTextIsTrivial && streamedToolEvents.length > 0 && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES) {
1597
1597
  // Override: let the tool_summary check below handle it instead of breaking
1598
1598
  loopDetectionTriggered = null
@@ -1752,8 +1752,14 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1752
1752
  // Triggers when: (a) text is empty, or (b) text is trivially short (< 150 chars)
1753
1753
  // and multiple tools ran — the agent likely emitted a "I'll do X" preamble but
1754
1754
  // never synthesized the tool outputs into a real response.
1755
+ const skipToolSummaryForShortResponse = shouldSkipToolSummaryForShortResponse({
1756
+ fullText,
1757
+ toolEvents: streamedToolEvents,
1758
+ isConnectorSession,
1759
+ })
1755
1760
  const textIsTrivial = !fullText.trim() || (
1756
- !isConnectorSession && fullText.trim().length < 150
1761
+ !skipToolSummaryForShortResponse
1762
+ && !isConnectorSession && fullText.trim().length < 150
1757
1763
  && (
1758
1764
  streamedToolEvents.length >= 2
1759
1765
  || likelyResearchSynthesisTask
@@ -1765,6 +1771,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1765
1771
  && hasToolCalls
1766
1772
  && textIsTrivial
1767
1773
  && streamedToolEvents.length > 0
1774
+ && !skipToolSummaryForShortResponse
1768
1775
  && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES
1769
1776
  ) {
1770
1777
  shouldContinue = 'tool_summary'
@@ -1808,7 +1815,9 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1808
1815
  const promptMessage = new HumanMessage({ content: continuationPrompt })
1809
1816
  langchainMessages.push(promptMessage)
1810
1817
  continuationMessages.push(promptMessage)
1811
- pendingGraphMessages = continuationMessages
1818
+ // Provide full conversation history since the agent has no checkpointer
1819
+ // and each iteration starts with only the messages we explicitly pass.
1820
+ pendingGraphMessages = [...langchainMessages]
1812
1821
  lastSegment = ''
1813
1822
  } else if (shouldContinue === 'transient') {
1814
1823
  // Exponential backoff before retrying transient errors; respect Retry-After if present
@@ -1896,7 +1905,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1896
1905
  const totalTokens = totalInputTokens + totalOutputTokens
1897
1906
  if (totalTokens > 0) {
1898
1907
  const cost = estimateCost(session.model, totalInputTokens, totalOutputTokens)
1899
- const pluginDefinitionCosts = buildPluginDefinitionCosts(toolsForTurn, toolToPluginMap)
1908
+ const pluginDefinitionCosts = buildPluginDefinitionCosts(tools, toolToPluginMap)
1900
1909
  const usageRecord: UsageRecord = {
1901
1910
  sessionId: session.id,
1902
1911
  messageIndex: history.length,
@@ -100,7 +100,7 @@ function looksLikeIncompleteDeliverableResponse(text: string): boolean {
100
100
  }
101
101
 
102
102
  const ARTIFACT_PATH_EXT_RE = /\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|ts|tsx|js|jsx|mjs|cjs|py|sql|sh)$/i
103
- const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export)\b[^.!?\n]{0,80}\b(?:to|as|at|in)\b[^.!?\n]{0,60}(\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|~\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|\.\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|[a-z0-9._/-]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)\b)/i
103
+ const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export|create|generate)\b[^.!?\n]{0,80}\b(?:to|as|at|in)\b[^.!?\n]{0,60}(\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|~\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|\.\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|[a-z0-9._/-]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)\b)/i
104
104
 
105
105
  function hasExplicitFileOutputRequest(text: string): boolean {
106
106
  const normalized = text.toLowerCase()
@@ -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
+ }