@swarmclawai/swarmclaw 1.1.8 → 1.2.0
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 +17 -0
- package/next.config.ts +0 -1
- package/package.json +3 -3
- package/src/app/activity/loading.tsx +5 -0
- package/src/app/api/agents/[id]/thread/route.ts +2 -1
- package/src/app/api/logs/route.ts +32 -5
- package/src/app/home/loading.tsx +5 -0
- package/src/app/logs/loading.tsx +5 -0
- package/src/app/memory/loading.tsx +5 -0
- package/src/app/tasks/loading.tsx +5 -0
- package/src/components/agents/agent-list.tsx +7 -12
- package/src/components/chat/chat-area.tsx +3 -3
- package/src/components/chat/chat-list.tsx +13 -2
- package/src/components/layout/sidebar-rail.tsx +14 -6
- package/src/components/memory/memory-graph-view.tsx +120 -68
- package/src/components/org-chart/mini-chat-bubble.tsx +58 -16
- package/src/components/shared/command-palette.tsx +35 -20
- package/src/components/shared/notification-center.tsx +1 -1
- package/src/hooks/use-app-bootstrap.ts +2 -1
- package/src/instrumentation.ts +27 -0
- package/src/lib/providers/anthropic.ts +14 -8
- package/src/lib/providers/openai.ts +3 -3
- package/src/lib/server/agents/agent-thread-session.ts +20 -14
- package/src/lib/server/chat-execution/continuation-evaluator.ts +2 -2
- package/src/lib/server/chat-execution/message-classifier.ts +2 -1
- package/src/lib/server/chat-execution/stream-agent-chat.ts +4 -19
- package/src/lib/server/chat-execution/stream-continuation.ts +4 -3
- package/src/lib/server/llm-response-cache.ts +2 -1
- package/src/lib/server/playwright-proxy.mjs +25 -6
- package/src/lib/server/runtime/run-ledger.ts +4 -4
- package/src/lib/server/runtime/scheduler.ts +9 -6
- package/src/lib/server/session-tools/skill-runtime.ts +10 -1
- package/src/lib/server/storage-cache.ts +2 -0
- package/src/lib/server/storage.ts +30 -3
- package/src/lib/server/tasks/task-quality-gate.test.ts +1 -1
- package/src/lib/server/tasks/task-quality-gate.ts +1 -1
- package/src/stores/slices/data-slice.ts +11 -0
- package/src/stores/slices/session-slice.ts +3 -0
|
@@ -8,6 +8,7 @@ import { useWs } from '@/hooks/use-ws'
|
|
|
8
8
|
import { api } from '@/lib/app/api-client'
|
|
9
9
|
import { fetchMessages } from '@/lib/chat/chats'
|
|
10
10
|
import { streamChat } from '@/lib/chat/chat'
|
|
11
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
11
12
|
import type { Agent, Message, Session, SSEEvent } from '@/types'
|
|
12
13
|
import { INTERNAL_KEY_RE, stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
|
|
13
14
|
|
|
@@ -19,6 +20,9 @@ interface Props {
|
|
|
19
20
|
|
|
20
21
|
const BUBBLE_W = 320
|
|
21
22
|
|
|
23
|
+
/** Client-side cache: agentId → sessionId, avoids redundant POST on reopen */
|
|
24
|
+
const sessionCache = hmrSingleton('miniChatBubble_sessionCache', () => new Map<string, string>())
|
|
25
|
+
|
|
22
26
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
|
|
23
27
|
|
|
24
28
|
/** Filter status text that looks like raw IDs or data dumps */
|
|
@@ -39,30 +43,57 @@ export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) {
|
|
|
39
43
|
const [streamText, setStreamText] = useState('')
|
|
40
44
|
const [inputValue, setInputValue] = useState('')
|
|
41
45
|
const [loading, setLoading] = useState(true)
|
|
46
|
+
const [error, setError] = useState<string | null>(null)
|
|
42
47
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
43
48
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
49
|
+
const cancelledRef = useRef(false)
|
|
44
50
|
|
|
45
51
|
// Initialize: get or create thread session, load messages
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const init = useCallback(async () => {
|
|
53
|
+
setError(null)
|
|
54
|
+
setLoading(true)
|
|
55
|
+
try {
|
|
56
|
+
const cachedSid = sessionCache.get(agent.id)
|
|
57
|
+
if (cachedSid) {
|
|
58
|
+
// Try cached session first — skip POST entirely
|
|
59
|
+
try {
|
|
60
|
+
const msgs = await fetchMessages(cachedSid)
|
|
61
|
+
if (cancelledRef.current) return
|
|
62
|
+
setSessionId(cachedSid)
|
|
63
|
+
setMessages(msgs)
|
|
64
|
+
return
|
|
65
|
+
} catch {
|
|
66
|
+
// Cached session gone — clear and fall through to POST
|
|
67
|
+
sessionCache.delete(agent.id)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const session = await api<Session>('POST', `/agents/${agent.id}/thread`)
|
|
72
|
+
if (cancelledRef.current) return
|
|
73
|
+
setSessionId(session.id)
|
|
74
|
+
sessionCache.set(agent.id, session.id)
|
|
75
|
+
|
|
76
|
+
// Use messages from POST response if present, otherwise fetch
|
|
77
|
+
if (Array.isArray(session.messages) && session.messages.length > 0) {
|
|
78
|
+
setMessages(session.messages as Message[])
|
|
79
|
+
} else {
|
|
53
80
|
const msgs = await fetchMessages(session.id)
|
|
54
|
-
if (
|
|
81
|
+
if (cancelledRef.current) return
|
|
55
82
|
setMessages(msgs)
|
|
56
|
-
} catch {
|
|
57
|
-
// Agent may not be available
|
|
58
|
-
} finally {
|
|
59
|
-
if (!cancelled) setLoading(false)
|
|
60
83
|
}
|
|
84
|
+
} catch {
|
|
85
|
+
if (!cancelledRef.current) setError('Could not connect to agent')
|
|
86
|
+
} finally {
|
|
87
|
+
if (!cancelledRef.current) setLoading(false)
|
|
61
88
|
}
|
|
62
|
-
init()
|
|
63
|
-
return () => { cancelled = true }
|
|
64
89
|
}, [agent.id])
|
|
65
90
|
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
cancelledRef.current = false
|
|
93
|
+
init()
|
|
94
|
+
return () => { cancelledRef.current = true }
|
|
95
|
+
}, [init])
|
|
96
|
+
|
|
66
97
|
// Real-time message refresh via WebSocket (mirrors main ChatArea pattern)
|
|
67
98
|
const refreshMessages = useCallback(async () => {
|
|
68
99
|
if (!sessionId || streaming) return
|
|
@@ -188,7 +219,18 @@ export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) {
|
|
|
188
219
|
{loading && (
|
|
189
220
|
<div className="text-[11px] text-text-3/50 text-center py-8">Loading...</div>
|
|
190
221
|
)}
|
|
191
|
-
{!loading &&
|
|
222
|
+
{!loading && error && (
|
|
223
|
+
<div className="text-center py-8 space-y-2">
|
|
224
|
+
<div className="text-[11px] text-red-400/70">{error}</div>
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => { init() }}
|
|
227
|
+
className="text-[11px] text-accent-bright/70 hover:text-accent-bright cursor-pointer border-none bg-transparent underline"
|
|
228
|
+
>
|
|
229
|
+
Retry
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
{!loading && !error && visibleMessages.length === 0 && !streaming && (
|
|
192
234
|
<div className="text-[11px] text-text-3/40 text-center py-8">
|
|
193
235
|
Start a conversation with {agent.name}
|
|
194
236
|
</div>
|
|
@@ -228,7 +270,7 @@ export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) {
|
|
|
228
270
|
onChange={(e) => setInputValue(e.target.value)}
|
|
229
271
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
|
230
272
|
placeholder="Type a message..."
|
|
231
|
-
disabled={loading || !sessionId}
|
|
273
|
+
disabled={loading || !!error || !sessionId}
|
|
232
274
|
className="flex-1 text-[12px] bg-white/[0.04] border border-white/[0.06] rounded-[6px] px-2.5 py-1.5 text-text placeholder:text-text-3/30 outline-none focus:border-accent-bright/30 transition-colors disabled:opacity-40"
|
|
233
275
|
/>
|
|
234
276
|
{streaming ? (
|
|
@@ -17,6 +17,33 @@ interface CommandItem {
|
|
|
17
17
|
|
|
18
18
|
export function CommandPalette() {
|
|
19
19
|
const [open, setOpen] = useState(false)
|
|
20
|
+
|
|
21
|
+
// Register keyboard shortcut (always mounted)
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handler = (e: KeyboardEvent) => {
|
|
24
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
setOpen((v) => !v)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
window.addEventListener('keydown', handler)
|
|
30
|
+
return () => window.removeEventListener('keydown', handler)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
// Also listen for custom event
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const handler = () => setOpen(true)
|
|
36
|
+
window.addEventListener('swarmclaw:open-search', handler)
|
|
37
|
+
return () => window.removeEventListener('swarmclaw:open-search', handler)
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
if (!open) return null
|
|
41
|
+
|
|
42
|
+
return <CommandPaletteInner setOpen={setOpen} />
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Inner component — only mounted when palette is open, so store subscriptions are gated. */
|
|
46
|
+
function CommandPaletteInner({ setOpen }: { setOpen: (v: boolean) => void }) {
|
|
20
47
|
const [query, setQuery] = useState('')
|
|
21
48
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
22
49
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
@@ -39,31 +66,21 @@ export function CommandPalette() {
|
|
|
39
66
|
detail: { tabId, sectionId },
|
|
40
67
|
}))
|
|
41
68
|
}, 80)
|
|
42
|
-
}, [navigateTo])
|
|
69
|
+
}, [navigateTo, setOpen])
|
|
43
70
|
|
|
44
|
-
//
|
|
71
|
+
// Close on Escape
|
|
45
72
|
useEffect(() => {
|
|
46
73
|
const handler = (e: KeyboardEvent) => {
|
|
47
|
-
if (
|
|
48
|
-
e.preventDefault()
|
|
49
|
-
setOpen((v) => !v)
|
|
50
|
-
}
|
|
51
|
-
if (e.key === 'Escape' && open) {
|
|
52
|
-
setOpen(false)
|
|
53
|
-
}
|
|
74
|
+
if (e.key === 'Escape') setOpen(false)
|
|
54
75
|
}
|
|
55
76
|
window.addEventListener('keydown', handler)
|
|
56
77
|
return () => window.removeEventListener('keydown', handler)
|
|
57
|
-
}, [
|
|
78
|
+
}, [setOpen])
|
|
58
79
|
|
|
59
|
-
// Focus input when
|
|
80
|
+
// Focus input when mounted
|
|
60
81
|
useEffect(() => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
setSelectedIndex(0)
|
|
64
|
-
setTimeout(() => inputRef.current?.focus(), 50)
|
|
65
|
-
}
|
|
66
|
-
}, [open])
|
|
82
|
+
setTimeout(() => inputRef.current?.focus(), 50)
|
|
83
|
+
}, [])
|
|
67
84
|
|
|
68
85
|
const items = useMemo<CommandItem[]>(() => {
|
|
69
86
|
const result: CommandItem[] = []
|
|
@@ -177,7 +194,7 @@ export function CommandPalette() {
|
|
|
177
194
|
}
|
|
178
195
|
|
|
179
196
|
return result
|
|
180
|
-
}, [agents, currentUser, navigateTo, openSettingsSection, sessions, setCurrentAgent, setEditingTaskId, setTaskSheetOpen, tasks])
|
|
197
|
+
}, [agents, currentUser, navigateTo, openSettingsSection, sessions, setCurrentAgent, setEditingTaskId, setOpen, setTaskSheetOpen, tasks])
|
|
181
198
|
|
|
182
199
|
const filtered = useMemo(() => {
|
|
183
200
|
if (!query.trim()) return items.slice(0, 20)
|
|
@@ -214,8 +231,6 @@ export function CommandPalette() {
|
|
|
214
231
|
el?.scrollIntoView({ block: 'nearest' })
|
|
215
232
|
}, [selectedIndex])
|
|
216
233
|
|
|
217
|
-
if (!open) return null
|
|
218
|
-
|
|
219
234
|
const categoryLabel = { agent: 'Agents', chat: 'Chats', task: 'Tasks', nav: 'Navigation', setting: 'Settings' } as const
|
|
220
235
|
const categoryIcon = {
|
|
221
236
|
agent: (
|
|
@@ -53,8 +53,8 @@ export function NotificationCenter({
|
|
|
53
53
|
align?: 'left' | 'right'
|
|
54
54
|
direction?: 'up' | 'down'
|
|
55
55
|
}) {
|
|
56
|
-
const now = useNow()
|
|
57
56
|
const [open, setOpen] = useState(false)
|
|
57
|
+
const now = useNow({ enabled: open })
|
|
58
58
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
59
59
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
60
60
|
const [panelStyle, setPanelStyle] = useState<CSSProperties>({
|
|
@@ -8,6 +8,7 @@ import { isDevelopmentLikeRuntime } from '@/lib/runtime/runtime-env'
|
|
|
8
8
|
import { useWs } from '@/hooks/use-ws'
|
|
9
9
|
import { useMountedRef } from '@/hooks/use-mounted-ref'
|
|
10
10
|
import { resolveSetupDone } from '@/hooks/setup-done-detection'
|
|
11
|
+
import { setIfChanged } from '@/stores/set-if-changed'
|
|
11
12
|
import type { Agent } from '@/types'
|
|
12
13
|
|
|
13
14
|
const AUTH_CHECK_TIMEOUT_MS = isDevelopmentLikeRuntime() ? 20_000 : 8_000
|
|
@@ -155,7 +156,7 @@ export function useAppBootstrap() {
|
|
|
155
156
|
retries: 0,
|
|
156
157
|
})
|
|
157
158
|
if (cancelled) return
|
|
158
|
-
useAppStore.setState
|
|
159
|
+
setIfChanged(useAppStore.setState, 'agents', agents)
|
|
159
160
|
|
|
160
161
|
const { currentAgentId, appSettings } = useAppStore.getState()
|
|
161
162
|
const targetId = (currentAgentId && agents[currentAgentId])
|
package/src/instrumentation.ts
CHANGED
|
@@ -49,6 +49,33 @@ export async function register() {
|
|
|
49
49
|
if (!shutdownState.registered) {
|
|
50
50
|
process.on('SIGTERM', () => { void shutdown('SIGTERM') })
|
|
51
51
|
process.on('SIGINT', () => { void shutdown('SIGINT') })
|
|
52
|
+
|
|
53
|
+
// Gracefully handle EPIPE errors from child processes (e.g. Playwright MCP proxy)
|
|
54
|
+
// that occur during dev server restarts when stdio pipes break
|
|
55
|
+
process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
|
|
56
|
+
if (err.code === 'EPIPE') {
|
|
57
|
+
console.warn('[instrumentation] Ignoring EPIPE (expected during dev server restart)')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
console.error('[instrumentation] Uncaught exception:', err)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// LangGraph's streamEvents leaves dangling internal promises when the
|
|
65
|
+
// for-await loop exits early. Suppress expected LangGraph rejections;
|
|
66
|
+
// log all others so they're not silently dropped.
|
|
67
|
+
process.on('unhandledRejection', (err: unknown) => {
|
|
68
|
+
if (
|
|
69
|
+
err && typeof err === 'object'
|
|
70
|
+
&& ('pregelTaskId' in err
|
|
71
|
+
|| (err instanceof Error && (err.name === 'AbortError' || err.name === 'GraphRecursionError'))
|
|
72
|
+
|| (err as Record<string, unknown>).lc_error_code === 'GRAPH_RECURSION_LIMIT')
|
|
73
|
+
) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
console.error('[instrumentation] Unhandled rejection:', err)
|
|
77
|
+
})
|
|
78
|
+
|
|
52
79
|
shutdownState.registered = true
|
|
53
80
|
}
|
|
54
81
|
}
|
|
@@ -4,17 +4,17 @@ import type { StreamChatOptions } from './index'
|
|
|
4
4
|
import { PROVIDER_DEFAULTS, IMAGE_EXTS, TEXT_EXTS, ANTHROPIC_MAX_TOKENS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
|
|
5
5
|
import { resolveImagePath } from '@/lib/server/resolve-image'
|
|
6
6
|
|
|
7
|
-
function fileToContentBlocks(filePath: string): Array<Record<string, unknown
|
|
7
|
+
async function fileToContentBlocks(filePath: string): Promise<Array<Record<string, unknown>>> {
|
|
8
8
|
if (!filePath || !fs.existsSync(filePath)) return []
|
|
9
9
|
if (IMAGE_EXTS.test(filePath)) {
|
|
10
|
-
const data = fs.
|
|
10
|
+
const data = (await fs.promises.readFile(filePath)).toString('base64')
|
|
11
11
|
const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
|
|
12
12
|
const mediaType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
|
|
13
13
|
return [{ type: 'image', source: { type: 'base64', media_type: mediaType, data } }]
|
|
14
14
|
}
|
|
15
15
|
if (TEXT_EXTS.test(filePath) || filePath.endsWith('.pdf')) {
|
|
16
16
|
try {
|
|
17
|
-
const text = fs.
|
|
17
|
+
const text = await fs.promises.readFile(filePath, 'utf-8')
|
|
18
18
|
const name = filePath.split('/').pop() || 'file'
|
|
19
19
|
return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
|
|
20
20
|
} catch { return [] }
|
|
@@ -23,8 +23,8 @@ function fileToContentBlocks(filePath: string): Array<Record<string, unknown>> {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const messages = buildMessages(session, message, imagePath, loadHistory)
|
|
26
|
+
return new Promise(async (resolve, reject) => {
|
|
27
|
+
const messages = await buildMessages(session, message, imagePath, loadHistory)
|
|
28
28
|
const model = session.model || 'claude-sonnet-4-6'
|
|
29
29
|
let usageInput = 0
|
|
30
30
|
let usageOutput = 0
|
|
@@ -59,6 +59,7 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
59
59
|
hostname: PROVIDER_DEFAULTS.anthropic,
|
|
60
60
|
path: '/v1/messages',
|
|
61
61
|
method: 'POST',
|
|
62
|
+
timeout: 60_000,
|
|
62
63
|
headers: {
|
|
63
64
|
'x-api-key': apiKey || '',
|
|
64
65
|
'anthropic-version': '2023-06-01',
|
|
@@ -122,6 +123,11 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
122
123
|
apiReqRef = apiReq
|
|
123
124
|
active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
|
|
124
125
|
|
|
126
|
+
apiReq.on('timeout', () => {
|
|
127
|
+
console.error(`[${session.id}] anthropic request timed out after 60s`)
|
|
128
|
+
apiReq.destroy(new Error('Request timed out after 60s'))
|
|
129
|
+
})
|
|
130
|
+
|
|
125
131
|
apiReq.on('error', (e) => {
|
|
126
132
|
console.error(`[${session.id}] anthropic request error:`, e.message)
|
|
127
133
|
writeSSE(write, 'err', e.message)
|
|
@@ -133,7 +139,7 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
133
139
|
})
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
function buildMessages(session: Record<string, unknown> & { id: string }, message: string, imagePath: string | undefined, loadHistory: (id: string) => Record<string, unknown>[]) {
|
|
142
|
+
async function buildMessages(session: Record<string, unknown> & { id: string }, message: string, imagePath: string | undefined, loadHistory: (id: string) => Record<string, unknown>[]) {
|
|
137
143
|
const msgs: Array<{ role: string; content: unknown }> = []
|
|
138
144
|
|
|
139
145
|
if (loadHistory) {
|
|
@@ -141,7 +147,7 @@ function buildMessages(session: Record<string, unknown> & { id: string }, messag
|
|
|
141
147
|
for (const m of history) {
|
|
142
148
|
const histImagePath = resolveImagePath(m.imagePath as string | undefined, m.imageUrl as string | undefined)
|
|
143
149
|
if (m.role === 'user' && histImagePath) {
|
|
144
|
-
const blocks = fileToContentBlocks(histImagePath)
|
|
150
|
+
const blocks = await fileToContentBlocks(histImagePath)
|
|
145
151
|
msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: m.text }] })
|
|
146
152
|
} else {
|
|
147
153
|
msgs.push({ role: m.role as string, content: m.text })
|
|
@@ -150,7 +156,7 @@ function buildMessages(session: Record<string, unknown> & { id: string }, messag
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
if (imagePath) {
|
|
153
|
-
const blocks = fileToContentBlocks(imagePath)
|
|
159
|
+
const blocks = await fileToContentBlocks(imagePath)
|
|
154
160
|
msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: message }] })
|
|
155
161
|
} else {
|
|
156
162
|
msgs.push({ role: 'user', content: message })
|
|
@@ -7,7 +7,7 @@ async function fileToContentParts(filePath: string): Promise<Array<Record<string
|
|
|
7
7
|
if (!filePath || !fs.existsSync(filePath)) return []
|
|
8
8
|
const name = filePath.split('/').pop() || 'file'
|
|
9
9
|
if (IMAGE_EXTS.test(filePath)) {
|
|
10
|
-
const buf = fs.
|
|
10
|
+
const buf = await fs.promises.readFile(filePath)
|
|
11
11
|
if (buf.length === 0) return [{ type: 'text', text: `[Attached image: ${name} — file is empty]` }]
|
|
12
12
|
const data = buf.toString('base64')
|
|
13
13
|
const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
|
|
@@ -22,7 +22,7 @@ async function fileToContentParts(filePath: string): Promise<Array<Record<string
|
|
|
22
22
|
try {
|
|
23
23
|
// @ts-ignore — pdf-parse types
|
|
24
24
|
const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
|
|
25
|
-
const buf = fs.
|
|
25
|
+
const buf = await fs.promises.readFile(filePath)
|
|
26
26
|
const result = await pdfParse(buf)
|
|
27
27
|
const pdfText = (result.text || '').trim()
|
|
28
28
|
if (!pdfText) return [{ type: 'text', text: `[Attached PDF: ${name} — no extractable text]` }]
|
|
@@ -34,7 +34,7 @@ async function fileToContentParts(filePath: string): Promise<Array<Record<string
|
|
|
34
34
|
}
|
|
35
35
|
if (TEXT_EXTS.test(filePath)) {
|
|
36
36
|
try {
|
|
37
|
-
const text = fs.
|
|
37
|
+
const text = await fs.promises.readFile(filePath, 'utf-8')
|
|
38
38
|
return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
|
|
39
39
|
} catch { return [] }
|
|
40
40
|
}
|
|
@@ -3,7 +3,7 @@ import type { Agent, Session } from '@/types'
|
|
|
3
3
|
import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
|
|
4
4
|
import { isAgentDisabled } from '@/lib/server/agents/agent-availability'
|
|
5
5
|
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
6
|
-
import { loadAgents, loadSessions, upsertAgent, upsertStoredItem } from '@/lib/server/storage'
|
|
6
|
+
import { loadAgents, loadSession, loadSessions, upsertAgent, upsertStoredItem } from '@/lib/server/storage'
|
|
7
7
|
import { getEnabledCapabilitySelection } from '@/lib/capability-selection'
|
|
8
8
|
|
|
9
9
|
function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
|
|
@@ -107,27 +107,33 @@ function shouldHealAgentCredentialId(agent: Agent, session: Session): boolean {
|
|
|
107
107
|
return session.provider === agent.provider
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export function ensureAgentThreadSession(agentId: string, user = 'default'): Session | null {
|
|
111
|
-
const
|
|
112
|
-
const agent = agents[agentId] as Agent | undefined
|
|
110
|
+
export function ensureAgentThreadSession(agentId: string, user = 'default', preloadedAgent?: Agent): Session | null {
|
|
111
|
+
const agent = preloadedAgent ?? (loadAgents()[agentId] as Agent | undefined)
|
|
113
112
|
if (!agent) return null
|
|
114
113
|
|
|
115
|
-
const sessions = loadSessions()
|
|
116
114
|
const now = Date.now()
|
|
117
|
-
const disabled = isAgentDisabled(agent)
|
|
118
115
|
|
|
116
|
+
// Fast path: agent already has a threadSessionId — single-row lookup
|
|
119
117
|
const existingId = typeof agent.threadSessionId === 'string' ? agent.threadSessionId : ''
|
|
120
|
-
if (existingId
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
agent
|
|
125
|
-
|
|
118
|
+
if (existingId) {
|
|
119
|
+
const existing = loadSession(existingId)
|
|
120
|
+
if (existing) {
|
|
121
|
+
const session = buildThreadSession(agent, existingId, user, now, existing)
|
|
122
|
+
if (shouldHealAgentCredentialId(agent, session)) {
|
|
123
|
+
agent.credentialId = session.credentialId ?? null
|
|
124
|
+
agent.updatedAt = now
|
|
125
|
+
upsertAgent(agentId, agent)
|
|
126
|
+
}
|
|
127
|
+
upsertStoredItem('sessions', existingId, session)
|
|
128
|
+
return session
|
|
126
129
|
}
|
|
127
|
-
|
|
128
|
-
return session
|
|
130
|
+
// Session was deleted — fall through to legacy search / creation
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
// Legacy search: full table scan only when threadSessionId is missing or stale
|
|
134
|
+
const sessions = loadSessions()
|
|
135
|
+
const disabled = isAgentDisabled(agent)
|
|
136
|
+
|
|
131
137
|
const legacySession = Object.values(sessions).find((session) => (
|
|
132
138
|
(session.shortcutForAgentId === agentId || session.name === `agent-thread:${agentId}`)
|
|
133
139
|
&& session.user === user
|
|
@@ -339,10 +339,10 @@ function checkCoordinatorDelegation(ctx: ContinuationContext): ContinuationDecis
|
|
|
339
339
|
// Skip if already delegated
|
|
340
340
|
const delegationTools = ['spawn_subagent', 'manage_protocols']
|
|
341
341
|
if (delegationTools.some(t => ctx.state.usedToolNames.has(t))) return null
|
|
342
|
-
// Only nudge if coordinator made
|
|
342
|
+
// Only nudge if coordinator made 2+ direct substantial tool calls
|
|
343
343
|
const directTools = ['files', 'edit_file', 'shell', 'web']
|
|
344
344
|
const directCallCount = directTools.filter(t => ctx.state.usedToolNames.has(t)).length
|
|
345
|
-
if (directCallCount <
|
|
345
|
+
if (directCallCount < 2) return null
|
|
346
346
|
ctx.limits.increment('coordinator_delegation_nudge')
|
|
347
347
|
writeStatus(ctx, { coordinatorDelegationNudge: true })
|
|
348
348
|
return { type: 'coordinator_delegation_nudge', requiredToolReminderNames: [] }
|
|
@@ -13,6 +13,7 @@ import crypto from 'node:crypto'
|
|
|
13
13
|
import { HumanMessage } from '@langchain/core/messages'
|
|
14
14
|
import { z } from 'zod'
|
|
15
15
|
import { buildLLM } from '@/lib/server/build-llm'
|
|
16
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
16
17
|
import type { Message } from '@/types'
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
@@ -37,7 +38,7 @@ export type MessageClassification = z.infer<typeof MessageClassificationSchema>
|
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
|
|
39
40
|
const MAX_CACHE_SIZE = 200
|
|
40
|
-
const classificationCache = new Map<string, MessageClassification>()
|
|
41
|
+
const classificationCache = hmrSingleton('__swarmclaw_classification_cache__', () => new Map<string, MessageClassification>())
|
|
41
42
|
|
|
42
43
|
function cacheKey(message: string): string {
|
|
43
44
|
return crypto.createHash('sha256').update(message).digest('hex')
|
|
@@ -99,24 +99,8 @@ import {
|
|
|
99
99
|
type MessageClassification,
|
|
100
100
|
} from '@/lib/server/chat-execution/message-classifier'
|
|
101
101
|
|
|
102
|
-
// LangGraph
|
|
103
|
-
//
|
|
104
|
-
// These promises may later reject with GraphRecursionError or AbortError.
|
|
105
|
-
// Register a permanent handler to prevent process crashes from these expected
|
|
106
|
-
// background rejections. Only LangGraph-specific errors (identified by
|
|
107
|
-
// pregelTaskId or lc_error_code) are suppressed; all other rejections propagate
|
|
108
|
-
// normally.
|
|
109
|
-
process.on('unhandledRejection', (err: unknown) => {
|
|
110
|
-
if (
|
|
111
|
-
err && typeof err === 'object'
|
|
112
|
-
&& ('pregelTaskId' in err
|
|
113
|
-
|| (err instanceof Error && (err.name === 'AbortError' || err.name === 'GraphRecursionError'))
|
|
114
|
-
|| (err as Record<string, unknown>).lc_error_code === 'GRAPH_RECURSION_LIMIT')
|
|
115
|
-
) {
|
|
116
|
-
// Silently suppress — expected background rejection from LangGraph
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
})
|
|
102
|
+
// LangGraph unhandledRejection handler has been moved to src/instrumentation.ts
|
|
103
|
+
// to avoid re-registration on every HMR reload.
|
|
120
104
|
|
|
121
105
|
// Re-export continuation functions so existing consumers don't need to change imports
|
|
122
106
|
export {
|
|
@@ -912,7 +896,8 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
912
896
|
&& !abortController.signal.aborted)
|
|
913
897
|
|| (isTransientProviderError && !abortController.signal.aborted)
|
|
914
898
|
|
|
915
|
-
|
|
899
|
+
const logLevel = abortController.signal.aborted ? 'warn' : 'error'
|
|
900
|
+
console[logLevel](`[stream-agent-chat] Error in streamEvents iteration=${iteration}`, {
|
|
916
901
|
errName, errMsg, errStack,
|
|
917
902
|
statusCode, retryAfterMs: extractedRetryAfterMs,
|
|
918
903
|
isRecursionError, isContextOverflow, isTransientAbort,
|
|
@@ -820,9 +820,10 @@ export function buildContinuationPrompt(params: {
|
|
|
820
820
|
|
|
821
821
|
case 'coordinator_delegation_nudge':
|
|
822
822
|
return [
|
|
823
|
-
'You have specialist workers available but have been
|
|
824
|
-
'
|
|
825
|
-
'
|
|
823
|
+
'IMPORTANT: You have specialist workers available but you have been doing substantial work directly with tools.',
|
|
824
|
+
'You MUST delegate the remaining work via `spawn_subagent` to the appropriate specialist worker NOW.',
|
|
825
|
+
'As a coordinator, your job is to orchestrate — not to do the work yourself. Direct tool use is only for quick lookups and validation.',
|
|
826
|
+
'Review the workers listed in your system prompt and delegate immediately.',
|
|
826
827
|
].join('\n')
|
|
827
828
|
|
|
828
829
|
case 'loop_recovery': {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
2
3
|
import type { AppSettings, Message } from '@/types'
|
|
3
4
|
|
|
4
5
|
export interface LlmResponseCacheConfig {
|
|
@@ -48,7 +49,7 @@ const MAX_TTL_SEC = 7 * 24 * 3600
|
|
|
48
49
|
const MIN_ENTRIES = 1
|
|
49
50
|
const MAX_ENTRIES = 20_000
|
|
50
51
|
|
|
51
|
-
const responseCache = new Map<string, LlmResponseCacheEntry>()
|
|
52
|
+
const responseCache = hmrSingleton('__swarmclaw_llm_response_cache__', () => new Map<string, LlmResponseCacheEntry>())
|
|
52
53
|
|
|
53
54
|
function normalizeText(value: unknown): string {
|
|
54
55
|
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
|
|
@@ -39,9 +39,17 @@ const child = cliPath
|
|
|
39
39
|
env: sanitizePlaywrightEnv(process.env),
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// Graceful EPIPE handling — dev server restarts break stdio pipes
|
|
43
|
+
function safeWrite(stream, chunk) {
|
|
44
|
+
try { stream.write(chunk) } catch { /* EPIPE during restart, ignore */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.stdin.on('data', (chunk) => safeWrite(child.stdin, chunk))
|
|
48
|
+
process.stdin.on('end', () => { try { child.stdin.end() } catch { /* ignore */ } })
|
|
49
|
+
child.stdin.on('error', (err) => {
|
|
50
|
+
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') return
|
|
51
|
+
process.stderr.write(`Proxy child stdin error: ${err.message}\n`)
|
|
52
|
+
})
|
|
45
53
|
|
|
46
54
|
// Parse MCP Content-Length framed messages from child stdout, intercept screenshots
|
|
47
55
|
let buf = ''
|
|
@@ -84,10 +92,21 @@ child.stdout.on('data', (chunk) => {
|
|
|
84
92
|
output = body
|
|
85
93
|
}
|
|
86
94
|
const frame = `Content-Length: ${Buffer.byteLength(output)}\r\n\r\n${output}`
|
|
87
|
-
process.stdout
|
|
95
|
+
safeWrite(process.stdout, frame)
|
|
88
96
|
}
|
|
89
97
|
})
|
|
98
|
+
child.stdout.on('error', (err) => {
|
|
99
|
+
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') return
|
|
100
|
+
process.stderr.write(`Proxy child stdout error: ${err.message}\n`)
|
|
101
|
+
})
|
|
90
102
|
|
|
91
|
-
child.stderr.on('data', (chunk) => process.stderr
|
|
103
|
+
child.stderr.on('data', (chunk) => safeWrite(process.stderr, chunk))
|
|
104
|
+
child.stderr.on('error', () => { /* ignore stderr errors */ })
|
|
92
105
|
child.on('close', (code) => process.exit(code || 0))
|
|
93
|
-
child.on('error', (err) => { process.stderr
|
|
106
|
+
child.on('error', (err) => { safeWrite(process.stderr, `Proxy error: ${err.message}\n`); process.exit(1) })
|
|
107
|
+
|
|
108
|
+
// Handle parent stdout/stderr EPIPE (broken pipe when dev server restarts)
|
|
109
|
+
process.stdout.on('error', (err) => {
|
|
110
|
+
if (err.code === 'EPIPE') { child.kill(); process.exit(0) }
|
|
111
|
+
})
|
|
112
|
+
process.stderr.on('error', () => { /* ignore */ })
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
deleteStoredItem,
|
|
5
5
|
loadRuntimeRun,
|
|
6
6
|
loadRuntimeRunEvents,
|
|
7
|
+
loadRuntimeRunEventsByRunId,
|
|
7
8
|
loadRuntimeRuns,
|
|
8
9
|
patchRuntimeRun,
|
|
9
10
|
upsertRuntimeRun,
|
|
@@ -96,10 +97,9 @@ export function appendPersistedRunEvent(input: {
|
|
|
96
97
|
|
|
97
98
|
export function listPersistedRunEvents(runId: string, limit = 1000): RunEventRecord[] {
|
|
98
99
|
const safeLimit = Math.max(1, Math.min(5000, Math.trunc(limit)))
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.slice(-safeLimit)
|
|
100
|
+
// Query filtered at SQL level to avoid full-table scan
|
|
101
|
+
const events = loadRuntimeRunEventsByRunId(runId)
|
|
102
|
+
return events.slice(-safeLimit)
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export function loadRecoverableStaleRuns(): SessionRunRecord[] {
|
|
@@ -10,10 +10,13 @@ import { prepareScheduledTaskRun } from '@/lib/server/tasks/task-lifecycle'
|
|
|
10
10
|
import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-session'
|
|
11
11
|
import { ensureMissionForTask, noteScheduleMissionTriggered } from '@/lib/server/missions/mission-service'
|
|
12
12
|
import { hasActiveProtocolRunForSchedule, launchProtocolRunForSchedule } from '@/lib/server/protocols/protocol-service'
|
|
13
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
13
14
|
import type { Schedule } from '@/types'
|
|
14
15
|
|
|
15
16
|
const TICK_INTERVAL = 60_000 // 60 seconds
|
|
16
|
-
|
|
17
|
+
const schedulerState = hmrSingleton('__swarmclaw_scheduler_state__', () => ({
|
|
18
|
+
intervalId: null as ReturnType<typeof setInterval> | null,
|
|
19
|
+
}))
|
|
17
20
|
|
|
18
21
|
interface ScheduleTaskLike {
|
|
19
22
|
status?: string
|
|
@@ -41,19 +44,19 @@ function shouldLaunchScheduleProtocol(schedule: Schedule): boolean {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export function startScheduler() {
|
|
44
|
-
if (intervalId) return
|
|
47
|
+
if (schedulerState.intervalId) return
|
|
45
48
|
console.log('[scheduler] Starting scheduler engine (60s tick)')
|
|
46
49
|
|
|
47
50
|
// Compute initial nextRunAt for cron schedules missing it
|
|
48
51
|
computeNextRuns()
|
|
49
52
|
|
|
50
|
-
intervalId = setInterval(tick, TICK_INTERVAL)
|
|
53
|
+
schedulerState.intervalId = setInterval(tick, TICK_INTERVAL)
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
export function stopScheduler() {
|
|
54
|
-
if (intervalId) {
|
|
55
|
-
clearInterval(intervalId)
|
|
56
|
-
intervalId = null
|
|
57
|
+
if (schedulerState.intervalId) {
|
|
58
|
+
clearInterval(schedulerState.intervalId)
|
|
59
|
+
schedulerState.intervalId = null
|
|
57
60
|
console.log('[scheduler] Stopped scheduler engine')
|
|
58
61
|
}
|
|
59
62
|
}
|