@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
@@ -64,7 +64,7 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
64
64
  connectorIdleTimeoutSec: existing?.connectorIdleTimeoutSec || null,
65
65
  connectorMaxAgeSec: existing?.connectorMaxAgeSec || null,
66
66
  mailbox: existing?.mailbox || null,
67
- connectorContext: existing?.connectorContext || undefined,
67
+ connectorContext: undefined,
68
68
  lastAutoMemoryAt: existing?.lastAutoMemoryAt || null,
69
69
  lastHeartbeatText: existing?.lastHeartbeatText || null,
70
70
  lastHeartbeatSentAt: existing?.lastHeartbeatSentAt || null,
@@ -195,6 +195,83 @@ describe('main-agent-loop advanced', () => {
195
195
  assert.equal(output.followupOk, null, 'no followup on terminal ack')
196
196
  })
197
197
 
198
+ it('persists and upgrades a skill blocker across recommend/install steps', () => {
199
+ const output = runWithTempDataDir(`
200
+ ${sessionSetupScript()}
201
+
202
+ mainLoop.handleMainLoopRunResult({
203
+ sessionId: 'main',
204
+ message: 'Continue the Google Workspace automation.',
205
+ internal: true,
206
+ source: 'heartbeat',
207
+ resultText: 'Blocked: missing capability for Google Workspace CLI in this environment.',
208
+ })
209
+ const state1 = mainLoop.getMainLoopStateForSession('main')
210
+
211
+ mainLoop.handleMainLoopRunResult({
212
+ sessionId: 'main',
213
+ message: 'Continue the Google Workspace automation.',
214
+ internal: true,
215
+ source: 'heartbeat',
216
+ resultText: 'Checked local skills.',
217
+ toolEvents: [{
218
+ name: 'manage_skills',
219
+ input: JSON.stringify({ action: 'recommend_for_task', task: 'Google Workspace automation' }),
220
+ output: JSON.stringify({ local: [{ name: 'google-workspace', status: 'needs_install' }] }),
221
+ }],
222
+ })
223
+ const state2 = mainLoop.getMainLoopStateForSession('main')
224
+
225
+ mainLoop.handleMainLoopRunResult({
226
+ sessionId: 'main',
227
+ message: 'Continue the Google Workspace automation.',
228
+ internal: true,
229
+ source: 'heartbeat',
230
+ resultText: 'Install approval requested.',
231
+ toolEvents: [{
232
+ name: 'manage_skills',
233
+ input: JSON.stringify({ action: 'install', name: 'google-workspace' }),
234
+ output: JSON.stringify({
235
+ requiresApproval: true,
236
+ approval: { id: 'appr-123' },
237
+ skill: { name: 'google-workspace' },
238
+ }),
239
+ }],
240
+ })
241
+ const state3 = mainLoop.getMainLoopStateForSession('main')
242
+
243
+ const heartbeatPrompt = mainLoop.buildMainLoopHeartbeatPrompt({
244
+ id: 'main',
245
+ shortcutForAgentId: 'agent-a',
246
+ agentId: 'agent-a',
247
+ heartbeatEnabled: true,
248
+ messages: [{ role: 'user', text: 'Deploy the system.', time: 1 }],
249
+ }, 'Base prompt')
250
+
251
+ console.log(JSON.stringify({
252
+ firstStatus: state1?.skillBlocker?.status ?? null,
253
+ firstSummary: state1?.skillBlocker?.summary ?? null,
254
+ secondStatus: state2?.skillBlocker?.status ?? null,
255
+ secondCandidates: state2?.skillBlocker?.candidateSkills ?? [],
256
+ secondAttempts: state2?.skillBlocker?.attempts ?? -1,
257
+ thirdStatus: state3?.skillBlocker?.status ?? null,
258
+ thirdApprovalId: state3?.skillBlocker?.approvalId ?? null,
259
+ promptHasSkillBlocker: heartbeatPrompt.includes('Active skill blocker:'),
260
+ promptHasApproval: heartbeatPrompt.includes('Pending approval: appr-123'),
261
+ }))
262
+ `)
263
+
264
+ assert.equal(output.firstStatus, 'new')
265
+ assert.match(String(output.firstSummary), /missing capability/i)
266
+ assert.equal(output.secondStatus, 'recommended')
267
+ assert.deepEqual(output.secondCandidates, ['google-workspace'])
268
+ assert.equal(output.secondAttempts, 1)
269
+ assert.equal(output.thirdStatus, 'approval_requested')
270
+ assert.equal(output.thirdApprovalId, 'appr-123')
271
+ assert.equal(output.promptHasSkillBlocker, true)
272
+ assert.equal(output.promptHasApproval, true)
273
+ })
274
+
198
275
  it('resets metadata miss count when structured metadata returns and keeps terminal acks at zero', () => {
199
276
  const meta = heartbeatMetaLine('progress', 'deploy', 'continue')
200
277
  const output = runWithTempDataDir(`
@@ -44,6 +44,15 @@ export interface MainLoopState {
44
44
  followupChainCount: number
45
45
  metaMissCount: number
46
46
  workingMemoryNotes: string[]
47
+ skillBlocker: {
48
+ summary: string
49
+ query: string | null
50
+ status: 'new' | 'searched' | 'recommended' | 'approval_requested' | 'installed'
51
+ attempts: number
52
+ candidateSkills: string[]
53
+ approvalId: string | null
54
+ updatedAt: number
55
+ } | null
47
56
  lastMemoryNoteAt: number | null
48
57
  lastPlannedAt: number | null
49
58
  lastReviewedAt: number | null
@@ -139,6 +148,7 @@ function defaultState(): MainLoopState {
139
148
  followupChainCount: 0,
140
149
  metaMissCount: 0,
141
150
  workingMemoryNotes: [],
151
+ skillBlocker: null,
142
152
  lastMemoryNoteAt: null,
143
153
  lastPlannedAt: null,
144
154
  lastReviewedAt: null,
@@ -220,6 +230,42 @@ function normalizeTimeline(value: unknown): MainLoopState['timeline'] {
220
230
  return out
221
231
  }
222
232
 
233
+ function normalizeSkillBlocker(value: unknown): MainLoopState['skillBlocker'] {
234
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
235
+ const record = value as Record<string, unknown>
236
+ const summary = cleanText(record.summary, 240)
237
+ if (!summary) return null
238
+ const status = record.status === 'new'
239
+ || record.status === 'searched'
240
+ || record.status === 'recommended'
241
+ || record.status === 'approval_requested'
242
+ || record.status === 'installed'
243
+ ? record.status
244
+ : 'new'
245
+ const query = cleanText(record.query, 240)
246
+ const candidateSkills = Array.isArray(record.candidateSkills)
247
+ ? uniqueStrings(record.candidateSkills.filter((entry): entry is string => typeof entry === 'string'), 6)
248
+ : []
249
+ const approvalId = typeof record.approvalId === 'string' && record.approvalId.trim()
250
+ ? record.approvalId.trim()
251
+ : null
252
+ const updatedAt = typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt)
253
+ ? Math.trunc(record.updatedAt)
254
+ : now()
255
+ const attempts = typeof record.attempts === 'number' && Number.isFinite(record.attempts)
256
+ ? Math.max(0, Math.min(6, Math.trunc(record.attempts)))
257
+ : 0
258
+ return {
259
+ summary,
260
+ query,
261
+ status,
262
+ attempts,
263
+ candidateSkills,
264
+ approvalId,
265
+ updatedAt,
266
+ }
267
+ }
268
+
223
269
  function parseHeartbeatMeta(text: string): { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } | null {
224
270
  const match = (text || '').match(HEARTBEAT_META_RE)
225
271
  if (!match) return null
@@ -257,6 +303,7 @@ function clampState(state: MainLoopState): MainLoopState {
257
303
  state.metaMissCount = Math.max(0, Math.min(100, Math.trunc(state.metaMissCount || 0)))
258
304
  state.missionTokens = Math.max(0, Math.trunc(state.missionTokens || 0))
259
305
  state.missionCostUsd = Math.max(0, Number.isFinite(state.missionCostUsd) ? Number(state.missionCostUsd) : 0)
306
+ state.skillBlocker = normalizeSkillBlocker(state.skillBlocker)
260
307
  state.updatedAt = typeof state.updatedAt === 'number' && Number.isFinite(state.updatedAt) ? Math.trunc(state.updatedAt) : now()
261
308
  return state
262
309
  }
@@ -286,6 +333,7 @@ function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
286
333
  if (typeof input.followupChainCount === 'number') next.followupChainCount = input.followupChainCount
287
334
  if (typeof input.metaMissCount === 'number') next.metaMissCount = input.metaMissCount
288
335
  if (Array.isArray(input.workingMemoryNotes)) next.workingMemoryNotes = [...input.workingMemoryNotes]
336
+ if (input.skillBlocker === null || typeof input.skillBlocker === 'object') next.skillBlocker = input.skillBlocker
289
337
  if (typeof input.lastMemoryNoteAt === 'number' || input.lastMemoryNoteAt === null) next.lastMemoryNoteAt = input.lastMemoryNoteAt ?? null
290
338
  if (typeof input.lastPlannedAt === 'number' || input.lastPlannedAt === null) next.lastPlannedAt = input.lastPlannedAt ?? null
291
339
  if (typeof input.lastReviewedAt === 'number' || input.lastReviewedAt === null) next.lastReviewedAt = input.lastReviewedAt ?? null
@@ -404,6 +452,198 @@ function formatGoalContract(goalContract: GoalContract | null): string {
404
452
  return lines.join('\n')
405
453
  }
406
454
 
455
+ function parseJsonRecord(value: string | undefined): Record<string, unknown> | null {
456
+ if (typeof value !== 'string' || !value.trim()) return null
457
+ try {
458
+ const parsed = JSON.parse(value)
459
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
460
+ return parsed as Record<string, unknown>
461
+ }
462
+ } catch {
463
+ // ignore non-JSON outputs
464
+ }
465
+ return null
466
+ }
467
+
468
+ function summarizeSelectedSkillRuntime(session: MainSessionLike | null): string {
469
+ const runtimeState = session?.skillRuntimeState
470
+ if (!runtimeState || typeof runtimeState !== 'object') return ''
471
+ const state = runtimeState as Record<string, unknown>
472
+ const selectedSkillName = cleanText(state.selectedSkillName, 160)
473
+ if (!selectedSkillName) return ''
474
+ const lines = [`Selected skill: ${selectedSkillName}`]
475
+ const lastAction = typeof state.lastAction === 'string' ? state.lastAction.trim() : ''
476
+ const lastRunToolName = cleanText(state.lastRunToolName, 120)
477
+ if (lastAction) lines.push(`Last skill action: ${lastAction}`)
478
+ if (lastRunToolName) lines.push(`Last dispatched tool: ${lastRunToolName}`)
479
+ return lines.join('\n')
480
+ }
481
+
482
+ function summarizeUseSkillToolEvent(toolEvents: MessageToolEvent[]): string | null {
483
+ const event = [...toolEvents].reverse().find((entry) => entry.name === 'use_skill')
484
+ if (!event?.output) return null
485
+ const output = parseJsonRecord(event.output)
486
+ if (!output) return null
487
+ const skill = output.skill && typeof output.skill === 'object'
488
+ ? output.skill as Record<string, unknown>
489
+ : null
490
+ const skillName = typeof skill?.name === 'string' && skill.name.trim()
491
+ ? skill.name.trim()
492
+ : typeof output.selectedSkillName === 'string' && output.selectedSkillName.trim()
493
+ ? output.selectedSkillName.trim()
494
+ : ''
495
+ if (!skillName) return null
496
+ if (output.executed === true) {
497
+ const toolName = typeof output.dispatchedTool === 'string' ? output.dispatchedTool.trim() : ''
498
+ return toolName ? `Skill run: ${skillName} via ${toolName}` : `Skill run: ${skillName}`
499
+ }
500
+ if (output.loaded === true) return `Loaded skill guidance: ${skillName}`
501
+ if (output.selected === true) return `Selected skill: ${skillName}`
502
+ return `Skill context: ${skillName}`
503
+ }
504
+
505
+ function firstMatchingLine(text: string, pattern: RegExp): string | null {
506
+ for (const line of (text || '').split('\n')) {
507
+ const trimmed = line.trim()
508
+ if (trimmed && pattern.test(trimmed)) return trimmed
509
+ }
510
+ return null
511
+ }
512
+
513
+ function deriveSkillBlockerFromToolEvents(params: {
514
+ toolEvents: MessageToolEvent[]
515
+ current: MainLoopState['skillBlocker']
516
+ query: string | null
517
+ }): MainLoopState['skillBlocker'] {
518
+ const event = [...params.toolEvents].reverse().find((entry) => entry.name === 'manage_skills')
519
+ if (!event) return params.current
520
+ const input = parseJsonRecord(event.input)
521
+ const output = parseJsonRecord(event.output)
522
+ const action = typeof input?.action === 'string' ? input.action.trim().toLowerCase() : ''
523
+ const nowTs = now()
524
+
525
+ const candidateNames = (() => {
526
+ const local = Array.isArray(output?.local)
527
+ ? output?.local
528
+ : Array.isArray(output)
529
+ ? output
530
+ : []
531
+ return uniqueStrings(local.flatMap((entry) => {
532
+ if (!entry || typeof entry !== 'object') return []
533
+ const record = entry as Record<string, unknown>
534
+ const nestedSkill = record.skill && typeof record.skill === 'object' ? record.skill as Record<string, unknown> : null
535
+ const name = typeof record.skillName === 'string'
536
+ ? record.skillName
537
+ : typeof record.name === 'string'
538
+ ? record.name
539
+ : typeof nestedSkill?.name === 'string'
540
+ ? nestedSkill.name
541
+ : ''
542
+ return name ? [name] : []
543
+ }), 4)
544
+ })()
545
+
546
+ const installSkillName = (() => {
547
+ if (typeof output?.skillName === 'string' && output.skillName.trim()) return output.skillName.trim()
548
+ if (output?.skill && typeof output.skill === 'object') {
549
+ const nested = output.skill as Record<string, unknown>
550
+ if (typeof nested.name === 'string' && nested.name.trim()) return nested.name.trim()
551
+ }
552
+ if (typeof input?.name === 'string' && input.name.trim()) return input.name.trim()
553
+ return candidateNames[0] || null
554
+ })()
555
+
556
+ if (action === 'install') {
557
+ if (output?.ok === true && output.installed === true) {
558
+ return normalizeSkillBlocker({
559
+ summary: installSkillName
560
+ ? `Installed skill "${installSkillName}". Use it on the next step instead of re-discovering skills.`
561
+ : 'Installed a skill for this blocker. Use it before re-running discovery.',
562
+ query: params.query,
563
+ status: 'installed',
564
+ attempts: (params.current?.attempts || 0) + 1,
565
+ candidateSkills: installSkillName ? [installSkillName] : candidateNames,
566
+ approvalId: null,
567
+ updatedAt: nowTs,
568
+ })
569
+ }
570
+ const approval = output?.approval && typeof output.approval === 'object'
571
+ ? output.approval as Record<string, unknown>
572
+ : null
573
+ const approvalId = typeof approval?.id === 'string' ? approval.id.trim() : ''
574
+ if (output?.requiresApproval === true || approvalId) {
575
+ return normalizeSkillBlocker({
576
+ summary: installSkillName
577
+ ? `Install approval is pending for skill "${installSkillName}". Wait for the approval instead of retrying discovery.`
578
+ : 'A skill install approval is pending. Wait for the approval instead of retrying discovery.',
579
+ query: params.query,
580
+ status: 'approval_requested',
581
+ attempts: (params.current?.attempts || 0) + 1,
582
+ candidateSkills: installSkillName ? [installSkillName] : candidateNames,
583
+ approvalId: approvalId || params.current?.approvalId || null,
584
+ updatedAt: nowTs,
585
+ })
586
+ }
587
+ }
588
+
589
+ if (action === 'recommend_for_task' || action === 'status' || action === 'search_available') {
590
+ return normalizeSkillBlocker({
591
+ summary: candidateNames.length > 0
592
+ ? `Skill candidates found: ${candidateNames.join(', ')}. Use one of them or request install approval once if needed.`
593
+ : 'Checked local skills for this blocker. Avoid repeating the same discovery loop without a materially different query.',
594
+ query: params.query,
595
+ status: candidateNames.length > 0 ? 'recommended' : 'searched',
596
+ attempts: (params.current?.attempts || 0) + 1,
597
+ candidateSkills: candidateNames,
598
+ approvalId: params.current?.approvalId || null,
599
+ updatedAt: nowTs,
600
+ })
601
+ }
602
+
603
+ return params.current
604
+ }
605
+
606
+ function deriveSkillBlockerFromText(params: {
607
+ text: string
608
+ current: MainLoopState['skillBlocker']
609
+ query: string | null
610
+ }): MainLoopState['skillBlocker'] {
611
+ const blockerLine = firstMatchingLine(
612
+ params.text,
613
+ /\b(missing capability|missing (?:binary|binaries|env|tool|command)|not installed|install required|requires .* cli|requires .* binary)\b/i,
614
+ )
615
+ if (!blockerLine) return params.current
616
+ return normalizeSkillBlocker({
617
+ summary: blockerLine,
618
+ query: params.query,
619
+ status: params.current?.status === 'approval_requested' ? 'approval_requested' : 'new',
620
+ attempts: params.current?.attempts || 0,
621
+ candidateSkills: params.current?.candidateSkills || [],
622
+ approvalId: params.current?.approvalId || null,
623
+ updatedAt: now(),
624
+ })
625
+ }
626
+
627
+ function summarizeSkillBlocker(blocker: MainLoopState['skillBlocker']): string {
628
+ if (!blocker) return ''
629
+ const lines = [
630
+ `Summary: ${blocker.summary}`,
631
+ blocker.query ? `Current query: ${blocker.query}` : '',
632
+ blocker.candidateSkills.length > 0 ? `Candidate skills: ${blocker.candidateSkills.join(', ')}` : '',
633
+ blocker.approvalId ? `Pending approval: ${blocker.approvalId}` : '',
634
+ blocker.status === 'new'
635
+ ? 'Next action: use manage_skills once this turn to recommend or inspect a fitting skill for the blocker.'
636
+ : blocker.status === 'searched'
637
+ ? 'Next action: do not repeat the same discovery blindly. Either adjust the query materially or proceed with the explicit blocker.'
638
+ : blocker.status === 'recommended'
639
+ ? 'Next action: use one recommended skill now, or request one explicit install approval if the best fit is not yet installed.'
640
+ : blocker.status === 'approval_requested'
641
+ ? 'Next action: wait for the pending approval instead of repeating discovery or install requests.'
642
+ : 'Next action: use the installed skill before re-running generic exploration.',
643
+ ]
644
+ return lines.filter(Boolean).join('\n')
645
+ }
646
+
407
647
  function extractWaitSignal(text: string, toolEvents: MessageToolEvent[]): boolean {
408
648
  const haystack = `${text}\n${toolEvents.map((event) => `${event.name} ${event.input || ''} ${event.output || ''}`).join('\n')}`
409
649
  return /\b(wait for|waiting for|approval|human reply|mailbox|watch job|pending approval)\b/i.test(haystack)
@@ -473,6 +713,8 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
473
713
  state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
474
714
  planLines ? `Plan:\n${planLines}` : '',
475
715
  state.pendingEvents.length > 0 ? `Pending external events:\n${summarizePendingEvents(state.pendingEvents)}` : '',
716
+ state.skillBlocker ? `Active skill blocker:\n${summarizeSkillBlocker(state.skillBlocker)}` : '',
717
+ summarizeSelectedSkillRuntime(candidate),
476
718
  boundedSummary ? `Latest summary:\n${boundedSummary}` : '',
477
719
  boundedFallbackPrompt ? `Base heartbeat instructions:\n${boundedFallbackPrompt}` : '',
478
720
  '',
@@ -627,7 +869,24 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
627
869
  const cleanedResult = persistedText.trim()
628
870
  const waitingForExternal = extractWaitSignal(resultText, toolEvents)
629
871
  const gotTerminalAck = /^HEARTBEAT_OK$/i.test(cleanedResult) || /^NO_MESSAGE$/i.test(cleanedResult)
872
+ const selectedSkillNote = summarizeUseSkillToolEvent(toolEvents)
873
+ if (selectedSkillNote) appendWorkingMemory(state, selectedSkillNote)
630
874
  state.metaMissCount = heartbeat || plan || review || gotTerminalAck ? 0 : state.metaMissCount + 1
875
+ const skillQuery = cleanText(state.nextAction || input.message || state.goal, 240)
876
+ let skillBlocker = deriveSkillBlockerFromToolEvents({
877
+ toolEvents,
878
+ current: state.skillBlocker,
879
+ query: skillQuery,
880
+ })
881
+ skillBlocker = deriveSkillBlockerFromText({
882
+ text: `${resultText}\n${toolEvents.map((event) => event.output || '').join('\n')}`,
883
+ current: skillBlocker,
884
+ query: skillQuery,
885
+ })
886
+ if ((gotTerminalAck && state.status !== 'blocked') || (state.status === 'ok' && !waitingForExternal && !input.error)) {
887
+ skillBlocker = null
888
+ }
889
+ state.skillBlocker = skillBlocker
631
890
 
632
891
  if (input.internal) {
633
892
  state.pendingEvents = []
@@ -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
  })