@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.
- package/README.md +12 -10
- package/bundled-skills/google-workspace/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/app/agents/page.tsx +2 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
- package/src/app/api/clawhub/install/route.ts +2 -0
- package/src/app/api/skills/[id]/route.ts +4 -0
- package/src/app/api/skills/route.ts +4 -0
- package/src/app/globals.css +28 -0
- package/src/app/home/page.tsx +11 -0
- package/src/app/settings/page.tsx +12 -5
- package/src/components/agents/agent-sheet.tsx +5 -5
- package/src/components/connectors/connector-list.tsx +2 -5
- package/src/components/logs/log-list.tsx +2 -5
- package/src/components/providers/provider-list.tsx +2 -5
- package/src/components/runs/run-list.tsx +2 -6
- package/src/components/schedules/schedule-list.tsx +7 -1
- package/src/components/ui/full-screen-loader.tsx +0 -29
- package/src/components/ui/page-loader.tsx +69 -0
- package/src/lib/runtime/runtime-loop.ts +21 -1
- package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
- package/src/lib/server/agents/agent-thread-session.ts +1 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
- package/src/lib/server/agents/main-agent-loop.ts +259 -0
- package/src/lib/server/agents/orchestrator-lg.ts +12 -8
- package/src/lib/server/agents/orchestrator.ts +11 -7
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
- package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
- package/src/lib/server/chat-execution/chat-execution.ts +116 -29
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
- package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
- package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
- package/src/lib/server/connectors/contact-boundaries.ts +101 -0
- package/src/lib/server/connectors/manager.test.ts +504 -73
- package/src/lib/server/connectors/manager.ts +41 -10
- package/src/lib/server/connectors/session-consolidation.ts +2 -0
- package/src/lib/server/connectors/session-kind.ts +7 -0
- package/src/lib/server/connectors/session.test.ts +104 -0
- package/src/lib/server/connectors/session.ts +5 -2
- package/src/lib/server/identity-continuity.test.ts +4 -3
- package/src/lib/server/identity-continuity.ts +8 -4
- package/src/lib/server/memory/memory-policy.test.ts +5 -15
- package/src/lib/server/memory/memory-policy.ts +11 -41
- package/src/lib/server/memory/session-archive-memory.ts +2 -1
- package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
- package/src/lib/server/runtime/heartbeat-service.ts +5 -1
- package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
- package/src/lib/server/runtime/runtime-settings.ts +4 -0
- package/src/lib/server/runtime/session-run-manager.ts +2 -0
- package/src/lib/server/session-reset-policy.test.ts +17 -3
- package/src/lib/server/session-reset-policy.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +11 -10
- package/src/lib/server/session-tools/crud.ts +41 -7
- package/src/lib/server/session-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
- package/src/lib/server/session-tools/memory.ts +209 -48
- package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
- package/src/lib/server/session-tools/skill-runtime.ts +382 -0
- package/src/lib/server/session-tools/skills.ts +575 -0
- package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
- package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
- package/src/lib/server/skills/skill-discovery.ts +4 -0
- package/src/lib/server/skills/skills-normalize.test.ts +28 -0
- package/src/lib/server/skills/skills-normalize.ts +93 -1
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-followups.test.ts +124 -0
- package/src/lib/server/tasks/task-followups.ts +88 -13
- package/src/types/index.ts +30 -2
- 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:
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
506
|
-
assert.ok(lines.some((line) => line.includes('
|
|
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
|
-
|
|
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
|
})
|