@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
@@ -11,6 +11,7 @@ import { buildChatModel } from '@/lib/server/build-llm'
11
11
  import { getCheckpointSaver } from '@/lib/server/langgraph-checkpoint'
12
12
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
13
13
  import { getPluginManager } from '@/lib/server/plugins'
14
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
14
15
  import { buildBoardTask } from '@/lib/server/tasks/task-lifecycle'
15
16
  import '@/lib/server/builtin-plugins'
16
17
  import { genId } from '@/lib/id'
@@ -401,6 +402,7 @@ export async function executeLangGraphOrchestrator(
401
402
  taskId?: string,
402
403
  ): Promise<string> {
403
404
  const allAgents = loadAgents()
405
+ const orchestrationSession = loadSessions()[sessionId]
404
406
 
405
407
  // Build available agents list
406
408
  const agentIds = orchestrator.subAgentIds || []
@@ -461,14 +463,16 @@ export async function executeLangGraphOrchestrator(
461
463
  promptParts.push(buildCurrentDateTimePromptContext())
462
464
  if (orchestrator.soul) promptParts.push(orchestrator.soul)
463
465
  if (orchestrator.systemPrompt) promptParts.push(orchestrator.systemPrompt)
464
- // Inject dynamic skills
465
- if (orchestrator.skillIds?.length) {
466
- const allSkills = loadSkills()
467
- for (const skillId of orchestrator.skillIds) {
468
- const skill = allSkills[skillId]
469
- if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
470
- }
471
- }
466
+ try {
467
+ const runtimeSkills = resolveRuntimeSkills({
468
+ cwd: orchestrationSession?.cwd || WORKSPACE_DIR,
469
+ enabledPlugins: Array.isArray(orchestrator.plugins) ? orchestrator.plugins : [],
470
+ agentSkillIds: orchestrator.skillIds || [],
471
+ storedSkills: loadSkills(),
472
+ selectedSkillId: orchestrationSession?.skillRuntimeState?.selectedSkillId || null,
473
+ })
474
+ promptParts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
475
+ } catch { /* non-critical */ }
472
476
  const basePrompt = promptParts.join('\n\n')
473
477
 
474
478
  const systemMessage = [
@@ -7,6 +7,7 @@ import { WORKSPACE_DIR } from '@/lib/server/data-dir'
7
7
  import { loadRuntimeSettings, getLegacyOrchestratorMaxTurns } from '@/lib/server/runtime/runtime-settings'
8
8
  import { getMemoryDb } from '@/lib/server/memory/memory-db'
9
9
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
10
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
10
11
  import { getProvider } from '@/lib/providers'
11
12
  import type { Agent } from '@/types'
12
13
 
@@ -125,13 +126,16 @@ async function executeOrchestratorLegacy(
125
126
  promptParts.push(buildCurrentDateTimePromptContext())
126
127
  if (orchestrator.soul) promptParts.push(orchestrator.soul)
127
128
  if (orchestrator.systemPrompt) promptParts.push(orchestrator.systemPrompt)
128
- if (orchestrator.skillIds?.length) {
129
- const allSkills = loadSkills()
130
- for (const skillId of orchestrator.skillIds) {
131
- const skill = allSkills[skillId]
132
- if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
133
- }
134
- }
129
+ try {
130
+ const runtimeSkills = resolveRuntimeSkills({
131
+ cwd: session.cwd,
132
+ enabledPlugins: Array.isArray(orchestrator.plugins) ? orchestrator.plugins : [],
133
+ agentSkillIds: orchestrator.skillIds || [],
134
+ storedSkills: loadSkills(),
135
+ selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
136
+ })
137
+ promptParts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
138
+ } catch { /* non-critical */ }
135
139
  const basePrompt = promptParts.join('\n\n')
136
140
 
137
141
  const systemPrompt = [
@@ -485,26 +485,27 @@ describe('buildToolDisciplineLines advanced', () => {
485
485
  assert.ok(lines.some((line) => line.includes('Enabled tools')))
486
486
  })
487
487
 
488
- it('includes schedule guidance when manage_schedules is enabled', () => {
488
+ it('includes direct platform guidance when manage_schedules is enabled without manage_platform', () => {
489
489
  const lines = buildToolDisciplineLines(['manage_schedules'])
490
- assert.ok(lines.some((line) => line.includes('reuse or update matching agent-created schedules')))
490
+ assert.ok(lines.some((line) => line.includes('Use direct platform tools exactly as named (`manage_schedules`)')))
491
+ assert.ok(lines.some((line) => line.includes('Do not substitute `manage_platform` unless it is explicitly enabled.')))
491
492
  })
492
493
 
493
- it('includes delegate local-first guidance when coding tools and delegate enabled', () => {
494
+ it('includes local files and shell guidance when coding tools and delegate enabled', () => {
494
495
  const lines = buildToolDisciplineLines(['delegate', 'shell', 'files'])
495
- assert.ok(lines.some((line) => line.includes('prefer using them directly for straightforward coding')))
496
+ assert.ok(lines.some((line) => line.includes('{"action":"read","filePath":"path/to/file.md"}')))
497
+ assert.ok(lines.some((line) => line.includes('For `shell`, use `{"action":"execute","command":"..."}`')))
496
498
  })
497
499
 
498
- it('tells research-capable agents to try another enabled acquisition path before manual fallback', () => {
500
+ it('tells research-capable agents to try another enabled acquisition path before giving up', () => {
499
501
  const lines = buildToolDisciplineLines(['web_search', 'web_fetch', 'http_request', 'shell'])
500
- assert.ok(lines.some((line) => line.includes('try one other enabled acquisition path') && line.includes('`shell`') && line.includes('`http_request`')))
502
+ assert.ok(lines.some((line) => line.includes('If one research path is blocked, try another') && line.includes('`shell`') && line.includes('`http_request`')))
501
503
  })
502
504
 
503
- it('adds direct drafting/file-save/swarm-id guidance when those tools are enabled', () => {
505
+ it('adds direct email and file action guidance when those tools are enabled', () => {
504
506
  const lines = buildToolDisciplineLines(['files', 'email', 'spawn_subagent'])
505
- assert.ok(lines.some((line) => line.includes('draft, outline, critique, or revise email copy')))
506
- assert.ok(lines.some((line) => line.includes('actual file-writing tool call')))
507
- assert.ok(lines.some((line) => line.includes('returned `swarmId`')))
507
+ assert.ok(lines.some((line) => line.includes('For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`')))
508
+ assert.ok(lines.some((line) => line.includes('{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}')))
508
509
  })
509
510
  })
510
511
 
@@ -84,6 +84,13 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
84
84
  })
85
85
 
86
86
  const session = ensureAgentThreadSession('molly')
87
+ const sessionsBefore = storage.loadSessions()
88
+ sessionsBefore[session.id].connectorContext = {
89
+ connectorId: 'conn-stale',
90
+ channelId: 'stale-channel',
91
+ senderId: 'stale-user',
92
+ }
93
+ storage.saveSessions(sessionsBefore)
87
94
  const agents = storage.loadAgents()
88
95
  agents.molly.provider = 'test-provider'
89
96
  agents.molly.model = 'unit'
@@ -93,7 +100,7 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
93
100
  agents.molly.updatedAt = now + 1
94
101
  storage.saveAgents(agents)
95
102
 
96
- const result = await executeSessionChatTurn({
103
+ await executeSessionChatTurn({
97
104
  sessionId: session.id,
98
105
  message: 'hello',
99
106
  runId: 'run-session-sync',
@@ -101,19 +108,125 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
101
108
 
102
109
  const persisted = storage.loadSession(session.id)
103
110
  console.log(JSON.stringify({
104
- text: result.text || null,
105
111
  provider: persisted?.provider || null,
106
112
  model: persisted?.model || null,
107
113
  plugins: persisted?.plugins || [],
108
114
  heartbeatEnabled: persisted?.heartbeatEnabled ?? null,
109
115
  heartbeatIntervalSec: persisted?.heartbeatIntervalSec ?? null,
116
+ connectorContext: persisted?.connectorContext || null,
110
117
  }))
111
118
  `)
112
119
 
113
- assert.equal(output.text, 'synced')
114
120
  assert.equal(output.provider, 'test-provider')
115
121
  assert.equal(output.model, 'unit')
116
122
  assert.deepEqual(output.plugins, [])
117
123
  assert.equal(output.heartbeatEnabled, true)
118
124
  assert.equal(output.heartbeatIntervalSec, 90)
125
+ assert.equal(output.connectorContext, null)
126
+ })
127
+
128
+ test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thread history and clears stale connector state', () => {
129
+ const output = runWithTempDataDir(`
130
+ const storageMod = await import('@/lib/server/storage')
131
+ const providersMod = await import('@/lib/providers/index')
132
+ const execMod = await import('@/lib/server/chat-execution/chat-execution')
133
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
134
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
135
+ || execMod.default?.executeSessionChatTurn
136
+ || execMod['module.exports']?.executeSessionChatTurn
137
+ const providers = providersMod.PROVIDERS
138
+ || providersMod.default?.PROVIDERS
139
+ || providersMod['module.exports']?.PROVIDERS
140
+
141
+ providers['test-provider'] = {
142
+ id: 'test-provider',
143
+ name: 'Test Provider',
144
+ models: ['unit'],
145
+ requiresApiKey: false,
146
+ requiresEndpoint: false,
147
+ handler: {
148
+ async streamChat(opts) {
149
+ opts.write('data: ' + JSON.stringify({ t: 'r', text: 'Sent the ferry status to WhatsApp.' }) + '\\n')
150
+ return ''
151
+ },
152
+ },
153
+ }
154
+
155
+ const now = Date.now()
156
+ storage.saveAgents({
157
+ hal: {
158
+ id: 'hal',
159
+ name: 'Hal2k',
160
+ description: 'Heartbeat hygiene test',
161
+ provider: 'test-provider',
162
+ model: 'unit',
163
+ credentialId: null,
164
+ apiEndpoint: null,
165
+ fallbackCredentialIds: [],
166
+ disabled: false,
167
+ heartbeatEnabled: true,
168
+ heartbeatIntervalSec: 60,
169
+ plugins: [],
170
+ threadSessionId: 'agent_thread',
171
+ createdAt: now,
172
+ updatedAt: now,
173
+ },
174
+ })
175
+ storage.saveSessions({
176
+ agent_thread: {
177
+ id: 'agent_thread',
178
+ name: 'Hal2k',
179
+ cwd: process.env.WORKSPACE_DIR,
180
+ user: 'default',
181
+ provider: 'test-provider',
182
+ model: 'unit',
183
+ claudeSessionId: null,
184
+ codexThreadId: null,
185
+ opencodeSessionId: null,
186
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
187
+ messages: [
188
+ { role: 'user', text: 'seed user message', time: now - 1000 },
189
+ ],
190
+ createdAt: now,
191
+ lastActiveAt: now,
192
+ sessionType: 'human',
193
+ agentId: 'hal',
194
+ shortcutForAgentId: 'hal',
195
+ plugins: [],
196
+ connectorContext: {
197
+ connectorId: 'conn-stale',
198
+ channelId: 'wrong-chat',
199
+ senderId: 'wrong-user',
200
+ },
201
+ },
202
+ })
203
+
204
+ await executeSessionChatTurn({
205
+ sessionId: 'agent_thread',
206
+ message: 'AGENT_HEARTBEAT_WAKE\\nInternal connector follow-up only',
207
+ internal: true,
208
+ source: 'heartbeat-wake',
209
+ heartbeatConfig: {
210
+ ackMaxChars: 300,
211
+ showOk: false,
212
+ showAlerts: true,
213
+ target: null,
214
+ deliveryMode: 'tool_only',
215
+ },
216
+ runId: 'run-heartbeat-tool-only',
217
+ })
218
+
219
+ const persisted = storage.loadSession('agent_thread')
220
+ console.log(JSON.stringify({
221
+ connectorContext: persisted?.connectorContext || null,
222
+ messageCount: persisted?.messages?.length || 0,
223
+ lastMessageText: persisted?.messages?.at(-1)?.text || null,
224
+ heartbeatKinds: (persisted?.messages || []).filter((entry) => entry.kind === 'heartbeat').length,
225
+ }))
226
+ `)
227
+
228
+ assert.equal(output.connectorContext, null)
229
+ assert.equal(output.messageCount, 1)
230
+ assert.equal(output.lastMessageText, 'seed user message')
231
+ assert.equal(output.heartbeatKinds, 0)
119
232
  })
@@ -29,6 +29,7 @@ import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agent
29
29
  import { resolveSessionToolPolicy } from '@/lib/server/tool-capability-policy'
30
30
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
31
31
  import { buildWorkspaceContext } from '@/lib/server/workspace-context'
32
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
32
33
  import { resolveImagePath } from '@/lib/server/resolve-image'
33
34
  import {
34
35
  applyContextClearBoundary,
@@ -67,6 +68,7 @@ import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolic
67
68
  import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat/chat-streaming-state'
68
69
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
69
70
  import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/agent-availability'
71
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
70
72
  import { errorMessage as toErrorMessage } from '@/lib/shared-utils'
71
73
  import { listUniversalToolAccessPluginIds } from '@/lib/server/universal-tool-access'
72
74
 
@@ -109,6 +111,24 @@ export function buildEnabledToolsAutonomyGuidance(): string[] {
109
111
  ]
110
112
  }
111
113
 
114
+ function resolveHeartbeatLastConnectorTarget(session: Session | null | undefined): {
115
+ connectorId?: string
116
+ channelId: string
117
+ } | null {
118
+ if (!isDirectConnectorSession(session)) return null
119
+ const connectorId = typeof session?.connectorContext?.connectorId === 'string'
120
+ ? session.connectorContext.connectorId.trim()
121
+ : ''
122
+ const channelId = typeof session?.connectorContext?.channelId === 'string'
123
+ ? session.connectorContext.channelId.trim()
124
+ : ''
125
+ if (!channelId) return null
126
+ return {
127
+ connectorId: connectorId || undefined,
128
+ channelId,
129
+ }
130
+ }
131
+
112
132
  interface SessionWithCredentials {
113
133
  credentialId?: string | null
114
134
  }
@@ -130,7 +150,14 @@ export interface ExecuteChatTurnInput {
130
150
  signal?: AbortSignal
131
151
  onEvent?: (event: SSEEvent) => void
132
152
  modelOverride?: string
133
- heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null; lightContext?: boolean }
153
+ heartbeatConfig?: {
154
+ ackMaxChars: number
155
+ showOk: boolean
156
+ showAlerts: boolean
157
+ target: string | null
158
+ lightContext?: boolean
159
+ deliveryMode?: 'default' | 'tool_only'
160
+ }
134
161
  replyToId?: string
135
162
  }
136
163
 
@@ -422,6 +449,10 @@ function syncSessionFromAgent(sessionId: string): void {
422
449
  session.openclawAgentId = desiredOpenClawAgentId
423
450
  changed = true
424
451
  }
452
+ if (session.connectorContext) {
453
+ session.connectorContext = undefined
454
+ changed = true
455
+ }
425
456
  }
426
457
 
427
458
  if (changed) {
@@ -496,13 +527,16 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
496
527
  if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
497
528
 
498
529
  // 5. Skills (SwarmClaw Core)
499
- if (agent.skillIds?.length) {
500
- const allSkills = loadSkills()
501
- for (const skillId of agent.skillIds) {
502
- const skill = allSkills[skillId]
503
- if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
504
- }
505
- }
530
+ try {
531
+ const runtimeSkills = resolveRuntimeSkills({
532
+ cwd: session.cwd,
533
+ enabledPlugins,
534
+ agentSkillIds: agent.skillIds || [],
535
+ storedSkills: loadSkills(),
536
+ selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
537
+ })
538
+ parts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
539
+ } catch { /* non-critical */ }
506
540
 
507
541
  // 5b. Workspace context files (HEARTBEAT.md, IDENTITY.md, AGENTS.md, etc.)
508
542
  try {
@@ -1228,6 +1262,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1228
1262
  const shouldPersistAssistant = !hiddenControlOnly
1229
1263
  && hasPersistableAssistantPayload(persistedText, thinkingText, persistedToolEvents)
1230
1264
  && heartbeatClassification !== 'suppress'
1265
+ && !(isHeartbeatRun && heartbeatConfig?.deliveryMode === 'tool_only' && !isDirectConnectorSession(session))
1231
1266
 
1232
1267
  const normalizeResumeId = (value: unknown): string | null =>
1233
1268
  typeof value === 'string' && value.trim() ? value.trim() : null
@@ -1236,6 +1271,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1236
1271
  const current = fresh[sessionId]
1237
1272
  if (current) {
1238
1273
  current.messages = Array.isArray(current.messages) ? current.messages : []
1274
+ if (!isDirectConnectorSession(current) && current.connectorContext) {
1275
+ current.connectorContext = undefined
1276
+ }
1239
1277
  const currentAgent = current.agentId ? loadAgents()[current.agentId] : null
1240
1278
  pruneStreamingAssistantArtifacts(current.messages, {
1241
1279
  minIndex: runMessageStartIndex,
@@ -1309,18 +1347,22 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1309
1347
  }
1310
1348
 
1311
1349
  // Target routing for non-suppressed heartbeat alerts
1312
- if (isHeartbeatRun && heartbeatConfig?.target && heartbeatConfig.target !== 'none' && heartbeatConfig.showAlerts !== false) {
1350
+ if (
1351
+ isHeartbeatRun
1352
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1353
+ && heartbeatConfig?.target
1354
+ && heartbeatConfig.target !== 'none'
1355
+ ) {
1313
1356
  try {
1314
1357
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1315
- const { listRunningConnectors, sendConnectorMessage } = require('../connectors/manager')
1358
+ const { sendConnectorMessage } = require('../connectors/manager')
1316
1359
  let connectorId: string | undefined
1317
1360
  let channelId: string | undefined
1318
1361
  if (heartbeatConfig.target === 'last') {
1319
- const running = listRunningConnectors()
1320
- const first = running.find((c: { recentChannelId?: string }) => c.recentChannelId)
1321
- if (first) {
1322
- connectorId = first.id
1323
- channelId = first.recentChannelId
1362
+ const lastTarget = resolveHeartbeatLastConnectorTarget(current)
1363
+ if (lastTarget) {
1364
+ connectorId = lastTarget.connectorId
1365
+ channelId = lastTarget.channelId
1324
1366
  }
1325
1367
  } else if (heartbeatConfig.target.includes(':')) {
1326
1368
  const [cId, chId] = heartbeatConfig.target.split(':', 2)
@@ -1339,19 +1381,25 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1339
1381
 
1340
1382
  // Auto-discover connectors linked to this agent when no explicit target is set
1341
1383
  // Skip if a real inbound message was handled recently — the agent just responded to it
1342
- if (isHeartbeatRun && !heartbeatConfig?.target && heartbeatConfig?.showAlerts !== false && session.agentId) {
1343
- const recentInbound = session.connectorContext?.lastInboundAt
1344
- && (Date.now() - session.connectorContext.lastInboundAt) < 60_000
1345
- if (!recentInbound) {
1384
+ if (
1385
+ isHeartbeatRun
1386
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1387
+ && !heartbeatConfig?.target
1388
+ && isDirectConnectorSession(current)
1389
+ ) {
1390
+ const recentInbound = current.connectorContext?.lastInboundAt
1391
+ && (Date.now() - current.connectorContext.lastInboundAt) < 60_000
1392
+ const connectorId = typeof current.connectorContext?.connectorId === 'string'
1393
+ ? current.connectorContext.connectorId.trim()
1394
+ : ''
1395
+ const channelId = typeof current.connectorContext?.channelId === 'string'
1396
+ ? current.connectorContext.channelId.trim()
1397
+ : ''
1398
+ if (!recentInbound && channelId) {
1346
1399
  try {
1347
1400
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1348
- const { listRunningConnectors: listRunning, sendConnectorMessage: sendMsg } = require('../connectors/manager')
1349
- const agentConnectors = listRunning().filter((c: { agentId: string | null; recentChannelId: string | null; supportsSend: boolean }) =>
1350
- c.agentId === session.agentId && c.recentChannelId && c.supportsSend
1351
- )
1352
- for (const conn of agentConnectors) {
1353
- sendMsg({ connectorId: conn.id, channelId: conn.recentChannelId, text: persistedText }).catch(() => {})
1354
- }
1401
+ const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
1402
+ sendMsg({ connectorId: connectorId || undefined, channelId, text: persistedText }).catch(() => {})
1355
1403
  } catch {
1356
1404
  // Best effort — connector manager may not be loaded
1357
1405
  }
@@ -12,6 +12,7 @@ import {
12
12
  looksLikeOpenEndedDeliverableTask,
13
13
  resolveContinuationAssistantText,
14
14
  resolveFinalStreamResponseText,
15
+ shouldSkipToolSummaryForShortResponse,
15
16
  shouldForceAttachmentFollowthrough,
16
17
  shouldForceRecoverableToolErrorFollowthrough,
17
18
  shouldTerminateOnSuccessfulMemoryMutation,
@@ -52,72 +53,66 @@ describe('buildToolDisciplineLines', () => {
52
53
  assert.ok(lines.every((line) => !line.includes('Do not substitute `manage_platform`')))
53
54
  })
54
55
 
55
- it('includes concrete files-tool examples for revision work', () => {
56
+ it('includes concrete files-tool examples for file work', () => {
56
57
  const lines = buildToolDisciplineLines(['files'])
57
58
 
58
59
  assert.ok(lines.some((line) => line.includes('{"action":"read","filePath":"path/to/file.md"}')))
59
- assert.ok(lines.some((line) => line.includes('exactly N bullet points')))
60
- assert.ok(lines.some((line) => line.includes('Lower-priority logistics belong in FYI')))
60
+ assert.ok(lines.some((line) => line.includes('{"action":"list","dirPath":"."}')))
61
+ assert.ok(lines.some((line) => line.includes('{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}')))
61
62
  })
62
63
 
63
- it('adds schedule reuse and stop guidance when schedule tools are enabled', () => {
64
+ it('tells the agent to use direct schedule tools when manage_platform is absent', () => {
64
65
  const lines = buildToolDisciplineLines(['manage_schedules', 'schedule_wake'])
65
66
 
66
- assert.ok(lines.some((line) => line.includes('reuse or update matching agent-created schedules')))
67
- assert.ok(lines.some((line) => line.includes('pause or delete every matching schedule you created in this chat')))
68
- assert.ok(lines.some((line) => line.includes('prefer `schedule_wake` over creating a recurring schedule')))
67
+ assert.ok(lines.some((line) => line.includes('Use direct platform tools exactly as named (`manage_schedules`)')))
68
+ assert.ok(lines.some((line) => line.includes('Do not substitute `manage_platform` unless it is explicitly enabled.')))
69
69
  })
70
70
 
71
- it('warns browser tasks to use literal urls and the supported form schema', () => {
71
+ it('warns browser-capable sessions to use current supported tool inputs and sequencing', () => {
72
72
  const lines = buildToolDisciplineLines(['web_search', 'web_fetch', 'browser', 'manage_connectors', 'http_request', 'email', 'ask_human', 'manage_secrets'])
73
73
 
74
74
  assert.ok(lines.some((line) => line.includes('Do not invent placeholder URLs')))
75
75
  assert.ok(lines.some((line) => line.includes('A shorthand `form` object keyed by input id/name also works')))
76
- assert.ok(lines.some((line) => line.includes('prefer `fill_form` and `submit_form`')))
77
76
  assert.ok(lines.some((line) => line.includes('For current events, breaking news, or "latest" requests, start with `web_search`')))
78
77
  assert.ok(lines.some((line) => line.includes('Use `browser` when the user asks for screenshots')))
79
- assert.ok(lines.some((line) => line.includes('do not capture screenshots') && line.includes('`browser`')))
80
78
  assert.ok(lines.some((line) => line.includes('connector_message_tool') && line.includes('list_running')))
81
79
  assert.ok(lines.some((line) => line.includes('connector/channel setup is missing')))
82
- assert.ok(lines.some((line) => line.includes('capture the artifact first with `browser`') && line.includes('`connector_message_tool`')))
83
80
  assert.ok(lines.some((line) => line.includes('Keep JSON request bodies as raw JSON strings')))
84
- assert.ok(lines.some((line) => line.includes('try one other enabled acquisition path') && line.includes('`http_request`') && line.includes('`browser`')))
85
- assert.ok(lines.some((line) => line.includes('browser or web timeout is not final')))
81
+ assert.ok(lines.some((line) => line.includes('gather sources first, then capture')))
82
+ assert.ok(lines.some((line) => line.includes('If one research path is blocked, try another') && line.includes('`http_request`') && line.includes('`browser`')))
86
83
  assert.ok(lines.some((line) => line.includes('{"action":"send","to":"user@example.com","subject":"...","body":"..."}')))
87
84
  assert.ok(lines.some((line) => line.includes('do not guess or keep re-submitting blank forms')))
88
- assert.ok(lines.some((line) => line.includes('store it with `manage_secrets`') && line.includes('do not echo the raw value')))
89
- assert.ok(lines.some((line) => line.includes('Use `manage_secrets` only for sensitive credentials or tokens')))
85
+ assert.ok(lines.some((line) => line.includes('Store secrets (passwords, API keys, tokens) with `manage_secrets`')))
90
86
  })
91
87
 
92
88
  it('adds bounded execution guidance for wallet-connected external-service tasks', () => {
93
89
  const lines = buildToolDisciplineLines(['wallet', 'browser', 'http_request', 'manage_capabilities'])
94
90
 
95
- assert.ok(lines.some((line) => line.includes('inspect the available wallet first with `wallet_tool`')))
96
- assert.ok(lines.some((line) => line.includes('use a bounded loop') && line.includes('Do not keep browsing once the blocker is clear')))
97
- assert.ok(lines.some((line) => line.includes('do not shop across venues indefinitely')))
98
- assert.ok(lines.some((line) => line.includes('If a direct tool for the job is already enabled in this session, call that tool immediately')))
91
+ assert.ok(lines.some((line) => line.includes('inspect the wallet first with `wallet_tool`')))
92
+ assert.ok(lines.some((line) => line.includes('Use a bounded loop: verify, attempt one reversible step, then execute or state the blocker.')))
93
+ assert.ok(lines.some((line) => line.includes('stop venue-shopping') && line.includes('call_contract')))
99
94
  })
100
95
 
101
- it('tells agents to stay local when coding tools are already available', () => {
96
+ it('includes concrete local coding tool guidance when coding tools are already available', () => {
102
97
  const lines = buildToolDisciplineLines(['files', 'shell', 'delegate'])
103
98
 
104
- assert.ok(lines.some((line) => line.includes('prefer using them directly for straightforward coding and verification')))
99
+ assert.ok(lines.some((line) => line.includes('{"action":"read","filePath":"path/to/file.md"}')))
100
+ assert.ok(lines.some((line) => line.includes('For `shell`, use `{"action":"execute","command":"..."}`')))
105
101
  })
106
102
 
107
- it('adds explicit human-loop mailbox sequencing guidance when ask_human is enabled', () => {
103
+ it('adds explicit ask_human request and wait guidance when ask_human is enabled', () => {
108
104
  const lines = buildToolDisciplineLines(['browser', 'ask_human'])
109
105
 
110
- assert.ok(lines.some((line) => line.includes('request_input') && line.includes('wait_for_reply') && line.includes('list_mailbox')))
111
- assert.ok(lines.some((line) => line.includes('omit `envelopeId` to ack the newest unread human reply')))
112
- assert.ok(lines.some((line) => line.includes('Do not loop on `status` without a `watchJobId`')))
106
+ assert.ok(lines.some((line) => line.includes('request_input') && line.includes('wait_for_reply') && line.includes('correlationId')))
107
+ assert.ok(lines.some((line) => line.includes('do not guess or keep re-submitting blank forms')))
113
108
  })
114
109
 
115
- it('tells agents to draft email content directly and to use real file writes for named artifacts', () => {
110
+ it('tells agents how to send email and write files when those tools are enabled', () => {
116
111
  const lines = buildToolDisciplineLines(['files', 'email', 'spawn_subagent'])
117
112
 
118
- assert.ok(lines.some((line) => line.includes('draft, outline, critique, or revise email copy')))
119
- assert.ok(lines.some((line) => line.includes('make your first concrete step an actual file-writing tool call')))
120
- assert.ok(lines.some((line) => line.includes('capture the returned `swarmId`')))
113
+ assert.ok(lines.some((line) => line.includes('For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`')))
114
+ assert.ok(lines.some((line) => line.includes('If delivery depends on SMTP setup, check `{"action":"status"}` before claiming success.')))
115
+ assert.ok(lines.some((line) => line.includes('{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}')))
121
116
  })
122
117
 
123
118
  it('does not force capability-inferred tools — trusts the LLM to select tools', () => {
@@ -183,7 +178,7 @@ describe('buildToolDisciplineLines', () => {
183
178
  assert.ok(streamAgentChatSource.includes('If a task explicitly names an enabled tool, use that tool before declaring success.'))
184
179
  assert.ok(streamAgentChatSource.includes('collect required human input through the tool'))
185
180
  assert.ok(streamAgentChatSource.includes('## Attachments'))
186
- assert.ok(streamAgentChatSource.includes('Do not claim you cannot use images, attachments, or external tools when those capabilities are available in this session.'))
181
+ assert.ok(streamSources.includes('Do not claim that you cannot use images, attachments, or external tools when they are available in this session.'))
187
182
  assert.ok(streamSources.includes('You have not yet completed the required explicit tool step(s):'))
188
183
  assert.ok(streamSources.includes('attachment_followthrough'))
189
184
  assert.ok(streamSources.includes('unfinished_tool_followthrough'))
@@ -264,6 +259,46 @@ describe('isWalletSimulationResult', () => {
264
259
  })
265
260
  })
266
261
 
262
+ describe('shouldSkipToolSummaryForShortResponse', () => {
263
+ it('skips forced tool-summary continuation for short responses after pure use_skill calls', () => {
264
+ assert.equal(
265
+ shouldSkipToolSummaryForShortResponse({
266
+ fullText: 'HAL2K_RELEASE_LIVE_OK',
267
+ toolEvents: [
268
+ { name: 'use_skill', input: '{"action":"list"}', output: '{"ok":true}' },
269
+ { name: 'use_skill', input: '{"action":"load"}', output: '{"loaded":true}' },
270
+ ],
271
+ }),
272
+ true,
273
+ )
274
+ })
275
+
276
+ it('does not skip tool-summary continuation when substantive tools also ran', () => {
277
+ assert.equal(
278
+ shouldSkipToolSummaryForShortResponse({
279
+ fullText: 'Done.',
280
+ toolEvents: [
281
+ { name: 'use_skill', input: '{"action":"load"}', output: '{"loaded":true}' },
282
+ { name: 'web', input: '{"q":"latest"}', output: 'results' },
283
+ ],
284
+ }),
285
+ false,
286
+ )
287
+ })
288
+
289
+ it('does not skip tool-summary continuation for empty text', () => {
290
+ assert.equal(
291
+ shouldSkipToolSummaryForShortResponse({
292
+ fullText: '',
293
+ toolEvents: [
294
+ { name: 'use_skill', input: '{"action":"load"}', output: '{"loaded":true}' },
295
+ ],
296
+ }),
297
+ false,
298
+ )
299
+ })
300
+ })
301
+
267
302
  describe('looksLikeOpenEndedDeliverableTask', () => {
268
303
  it('detects open-ended deliverable prompts', () => {
269
304
  assert.equal(