@swarmclawai/swarmclaw 0.9.1 → 0.9.3
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/package.json +1 -1
- package/src/app/agents/page.tsx +2 -1
- 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/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/chat-execution/chat-execution-utils.test.ts +59 -2
- package/src/lib/server/chat-execution/chat-execution-utils.ts +25 -1
- package/src/lib/server/chat-execution/chat-execution.ts +43 -4
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +2 -46
- package/src/lib/server/chat-execution/stream-agent-chat.ts +51 -86
- package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
- package/src/lib/server/connectors/manager.ts +1 -1
- 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/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-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/memory.ts +220 -48
- package/src/types/index.ts +4 -0
- package/src/views/settings/section-runtime-loop.tsx +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
package/src/app/agents/page.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
6
6
|
import { useMediaQuery } from '@/hooks/use-media-query'
|
|
7
7
|
import { getViewPath } from '@/lib/app/navigation'
|
|
8
8
|
import { AgentChatList } from '@/components/agents/agent-chat-list'
|
|
9
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
9
10
|
|
|
10
11
|
export default function AgentsPage() {
|
|
11
12
|
const isDesktop = useMediaQuery('(min-width: 768px)')
|
|
@@ -30,5 +31,5 @@ export default function AgentsPage() {
|
|
|
30
31
|
if (!isDesktop) return <AgentChatList />
|
|
31
32
|
|
|
32
33
|
// Brief flash while redirecting, or no agents exist yet
|
|
33
|
-
return
|
|
34
|
+
return <PageLoader />
|
|
34
35
|
}
|
package/src/app/globals.css
CHANGED
|
@@ -287,6 +287,34 @@ textarea:hover::-webkit-scrollbar { width: 6px; }
|
|
|
287
287
|
to { opacity: 1; transform: translateY(0); }
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/* ===== SwarmClaw Loader Keyframes ===== */
|
|
291
|
+
@keyframes sc-orbit {
|
|
292
|
+
from { transform: rotate(0deg); }
|
|
293
|
+
to { transform: rotate(360deg); }
|
|
294
|
+
}
|
|
295
|
+
@keyframes sc-ring {
|
|
296
|
+
from { transform: rotate(0deg) scale(1); }
|
|
297
|
+
50% { transform: rotate(180deg) scale(1.02); }
|
|
298
|
+
to { transform: rotate(360deg) scale(1); }
|
|
299
|
+
}
|
|
300
|
+
@keyframes sc-breathe {
|
|
301
|
+
0%, 100% { transform: scale(1); opacity: 0.9; }
|
|
302
|
+
50% { transform: scale(1.06); opacity: 1; }
|
|
303
|
+
}
|
|
304
|
+
@keyframes sc-glow {
|
|
305
|
+
0%, 100% { opacity: 0.5; transform: scale(0.9); }
|
|
306
|
+
50% { opacity: 1; transform: scale(1.1); }
|
|
307
|
+
}
|
|
308
|
+
@keyframes sc-text-fade {
|
|
309
|
+
0% { opacity: 0.6; }
|
|
310
|
+
100% { opacity: 1; }
|
|
311
|
+
}
|
|
312
|
+
@keyframes sc-progress {
|
|
313
|
+
0% { width: 0; margin-left: 0; }
|
|
314
|
+
50% { width: 70%; margin-left: 15%; }
|
|
315
|
+
100% { width: 0; margin-left: 100%; }
|
|
316
|
+
}
|
|
317
|
+
|
|
290
318
|
/* Heartbeat float animation */
|
|
291
319
|
@keyframes heartbeat-float {
|
|
292
320
|
0% { opacity: 1; transform: translateY(0) scale(1); }
|
package/src/app/home/page.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { timeAgo, timeUntil } from '@/lib/time-format'
|
|
|
16
16
|
import type { Agent, Session, ActivityEntry, BoardTask, AppNotification } from '@/types'
|
|
17
17
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
18
18
|
import { MainContent } from '@/components/layout/main-content'
|
|
19
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
19
20
|
import { SectionHeader } from '@/components/ui/section-header'
|
|
20
21
|
import { StatCard } from '@/components/ui/stat-card'
|
|
21
22
|
|
|
@@ -77,6 +78,7 @@ export default function HomePage() {
|
|
|
77
78
|
const [todayCost, setTodayCost] = useState(0)
|
|
78
79
|
const [costTrend, setCostTrend] = useState<{ cost: number; bucket: string }[]>([])
|
|
79
80
|
const [localhostBrowser, setLocalhostBrowser] = useState(false)
|
|
81
|
+
const [pageReady, setPageReady] = useState(false)
|
|
80
82
|
const mountedRef = useMountedRef()
|
|
81
83
|
|
|
82
84
|
useEffect(() => {
|
|
@@ -155,6 +157,7 @@ export default function HomePage() {
|
|
|
155
157
|
setTodayCost(todayPt?.cost || 0)
|
|
156
158
|
})
|
|
157
159
|
.catch(() => {})
|
|
160
|
+
.finally(() => { if (!cancelled && mountedRef.current) setPageReady(true) })
|
|
158
161
|
return () => {
|
|
159
162
|
cancelled = true
|
|
160
163
|
window.clearTimeout(connectorTimer)
|
|
@@ -188,6 +191,14 @@ export default function HomePage() {
|
|
|
188
191
|
}
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
if (!pageReady) {
|
|
195
|
+
return (
|
|
196
|
+
<MainContent>
|
|
197
|
+
<PageLoader label="Loading dashboard..." />
|
|
198
|
+
</MainContent>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
191
202
|
return (
|
|
192
203
|
<MainContent>
|
|
193
204
|
<div className="flex-1 overflow-y-auto">
|
|
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
|
|
4
4
|
import type { ReactNode } from 'react'
|
|
5
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
6
6
|
import { MainContent } from '@/components/layout/main-content'
|
|
7
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
7
8
|
import { inputClass } from '@/views/settings/utils'
|
|
8
9
|
import { UserPreferencesSection } from '@/views/settings/section-user-preferences'
|
|
9
10
|
import { ThemeSection } from '@/views/settings/section-theme'
|
|
@@ -84,6 +85,7 @@ export default function SettingsRoute() {
|
|
|
84
85
|
const loadSecrets = useAppStore((s) => s.loadSecrets)
|
|
85
86
|
const loadAgents = useAppStore((s) => s.loadAgents)
|
|
86
87
|
const credentials = useAppStore((s) => s.credentials)
|
|
88
|
+
const [pageReady, setPageReady] = useState(false)
|
|
87
89
|
const [activeTab, setActiveTabRaw] = useState('general')
|
|
88
90
|
const contentRef = useRef<HTMLDivElement>(null)
|
|
89
91
|
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
|
@@ -98,11 +100,8 @@ export default function SettingsRoute() {
|
|
|
98
100
|
}, [])
|
|
99
101
|
|
|
100
102
|
useEffect(() => {
|
|
101
|
-
loadProviders()
|
|
102
|
-
|
|
103
|
-
loadSettings()
|
|
104
|
-
loadSecrets()
|
|
105
|
-
loadAgents()
|
|
103
|
+
Promise.all([loadProviders(), loadCredentials(), loadSettings(), loadSecrets(), loadAgents()])
|
|
104
|
+
.finally(() => setPageReady(true))
|
|
106
105
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
106
|
}, [])
|
|
108
107
|
|
|
@@ -298,6 +297,14 @@ export default function SettingsRoute() {
|
|
|
298
297
|
|
|
299
298
|
const visibleSections = sectionsByTab.get(activeTab) || []
|
|
300
299
|
|
|
300
|
+
if (!pageReady) {
|
|
301
|
+
return (
|
|
302
|
+
<MainContent>
|
|
303
|
+
<PageLoader label="Loading settings..." />
|
|
304
|
+
</MainContent>
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
301
308
|
return (
|
|
302
309
|
<MainContent>
|
|
303
310
|
<div className="flex-1 flex h-full min-w-0">
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveConnectorPlatformMeta,
|
|
15
15
|
} from '@/components/shared/connector-platform-icon'
|
|
16
16
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
17
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
17
18
|
import { StatusDot } from '@/components/ui/status-dot'
|
|
18
19
|
|
|
19
20
|
function relativeTime(ts: number): string {
|
|
@@ -158,11 +159,7 @@ export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
if (!loaded) {
|
|
161
|
-
return
|
|
162
|
-
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 text-center">
|
|
163
|
-
<p className="text-[13px] text-text-3">Loading connectors...</p>
|
|
164
|
-
</div>
|
|
165
|
-
)
|
|
162
|
+
return <PageLoader label="Loading connectors..." />
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
if (!list.length) {
|
|
@@ -5,6 +5,7 @@ import { api } from '@/lib/app/api-client'
|
|
|
5
5
|
import { useWs } from '@/hooks/use-ws'
|
|
6
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
7
7
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
8
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
8
9
|
import { safeStorageGetJson, safeStorageSet } from '@/lib/app/safe-storage'
|
|
9
10
|
|
|
10
11
|
interface LogEntry {
|
|
@@ -135,11 +136,7 @@ export function LogList() {
|
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
if (loading) {
|
|
138
|
-
return
|
|
139
|
-
<div className="flex-1 flex items-center justify-center text-text-3 text-[13px]">
|
|
140
|
-
Loading logs...
|
|
141
|
-
</div>
|
|
142
|
-
)
|
|
139
|
+
return <PageLoader label="Loading logs..." />
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
const agentList = Object.values(agents)
|
|
@@ -8,6 +8,7 @@ import { useWs } from '@/hooks/use-ws'
|
|
|
8
8
|
import { api } from '@/lib/app/api-client'
|
|
9
9
|
import type { Credential, GatewayProfile } from '@/types'
|
|
10
10
|
import { dedup } from '@/lib/shared-utils'
|
|
11
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
11
12
|
import { StatusDot } from '@/components/ui/status-dot'
|
|
12
13
|
|
|
13
14
|
interface OpenClawDeployDraft {
|
|
@@ -254,11 +255,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
254
255
|
}, {})
|
|
255
256
|
|
|
256
257
|
if (!loaded) {
|
|
257
|
-
return
|
|
258
|
-
<div className={`flex-1 flex items-center justify-center ${inSidebar ? 'px-3 pb-4' : 'px-5'}`}>
|
|
259
|
-
<p className="text-[13px] text-text-3">Loading providers...</p>
|
|
260
|
-
</div>
|
|
261
|
-
)
|
|
258
|
+
return <PageLoader label="Loading providers..." />
|
|
262
259
|
}
|
|
263
260
|
|
|
264
261
|
return (
|
|
@@ -6,6 +6,7 @@ import { useNow } from '@/hooks/use-now'
|
|
|
6
6
|
import { useWs } from '@/hooks/use-ws'
|
|
7
7
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
8
8
|
import type { SessionRunRecord, SessionRunStatus } from '@/types'
|
|
9
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
9
10
|
import { formatElapsed } from '@/lib/format-display'
|
|
10
11
|
|
|
11
12
|
const STATUS_COLORS: Record<SessionRunStatus, { bg: string; text: string }> = {
|
|
@@ -55,12 +56,7 @@ export function RunList() {
|
|
|
55
56
|
const filtered = statusFilter ? runs.filter((r) => r.status === statusFilter) : runs
|
|
56
57
|
|
|
57
58
|
if (loading) {
|
|
58
|
-
return
|
|
59
|
-
<div className="flex-1 flex items-center justify-center text-text-3 text-[13px]">
|
|
60
|
-
<span className="w-4 h-4 rounded-full border-2 border-text-3/20 border-t-text-3/60 animate-spin mr-2" />
|
|
61
|
-
Loading runs...
|
|
62
|
-
</div>
|
|
63
|
-
)
|
|
59
|
+
return <PageLoader label="Loading runs..." />
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
return (
|
|
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
5
5
|
import { ScheduleCard } from './schedule-card'
|
|
6
6
|
import { SCHEDULE_TEMPLATES, FEATURED_TEMPLATE_IDS } from '@/lib/schedules/schedule-templates'
|
|
7
7
|
import { Newspaper, HeartPulse, PenLine, FileText } from 'lucide-react'
|
|
8
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
8
9
|
import { SearchInput } from '@/components/ui/search-input'
|
|
9
10
|
import { Button } from '@/components/ui/button'
|
|
10
11
|
|
|
@@ -27,8 +28,9 @@ export function ScheduleList({ inSidebar }: Props) {
|
|
|
27
28
|
const setTemplatePrefill = useAppStore((s) => s.setScheduleTemplatePrefill)
|
|
28
29
|
const activeProjectFilter = useAppStore((s) => s.activeProjectFilter)
|
|
29
30
|
const [search, setSearch] = useState('')
|
|
31
|
+
const [loaded, setLoaded] = useState(false)
|
|
30
32
|
|
|
31
|
-
useEffect(() => { loadSchedules() }, [])
|
|
33
|
+
useEffect(() => { loadSchedules().finally(() => setLoaded(true)) }, [])
|
|
32
34
|
|
|
33
35
|
const filtered = useMemo(() => {
|
|
34
36
|
return Object.values(schedules)
|
|
@@ -40,6 +42,10 @@ export function ScheduleList({ inSidebar }: Props) {
|
|
|
40
42
|
.sort((a, b) => b.createdAt - a.createdAt)
|
|
41
43
|
}, [schedules, search, activeProjectFilter])
|
|
42
44
|
|
|
45
|
+
if (!loaded) {
|
|
46
|
+
return <PageLoader label="Loading schedules..." />
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
if (!filtered.length && !search) {
|
|
44
50
|
return (
|
|
45
51
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center">
|
|
@@ -130,35 +130,6 @@ export function FullScreenLoader(props: {
|
|
|
130
130
|
</div>
|
|
131
131
|
) : null}
|
|
132
132
|
|
|
133
|
-
{/* Loading animation keyframes */}
|
|
134
|
-
<style>{`
|
|
135
|
-
@keyframes sc-orbit {
|
|
136
|
-
from { transform: rotate(0deg); }
|
|
137
|
-
to { transform: rotate(360deg); }
|
|
138
|
-
}
|
|
139
|
-
@keyframes sc-ring {
|
|
140
|
-
from { transform: rotate(0deg) scale(1); }
|
|
141
|
-
50% { transform: rotate(180deg) scale(1.02); }
|
|
142
|
-
to { transform: rotate(360deg) scale(1); }
|
|
143
|
-
}
|
|
144
|
-
@keyframes sc-breathe {
|
|
145
|
-
0%, 100% { transform: scale(1); opacity: 0.9; }
|
|
146
|
-
50% { transform: scale(1.06); opacity: 1; }
|
|
147
|
-
}
|
|
148
|
-
@keyframes sc-glow {
|
|
149
|
-
0%, 100% { opacity: 0.5; transform: scale(0.9); }
|
|
150
|
-
50% { opacity: 1; transform: scale(1.1); }
|
|
151
|
-
}
|
|
152
|
-
@keyframes sc-text-fade {
|
|
153
|
-
0% { opacity: 0.6; }
|
|
154
|
-
100% { opacity: 1; }
|
|
155
|
-
}
|
|
156
|
-
@keyframes sc-progress {
|
|
157
|
-
0% { width: 0; margin-left: 0; }
|
|
158
|
-
50% { width: 70%; margin-left: 15%; }
|
|
159
|
-
100% { width: 0; margin-left: 100%; }
|
|
160
|
-
}
|
|
161
|
-
`}</style>
|
|
162
133
|
</div>
|
|
163
134
|
)
|
|
164
135
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight page-level loader — smaller sibling of FullScreenLoader.
|
|
3
|
+
* 3 orbiting dots, subtle glow ring, optional label.
|
|
4
|
+
* 150ms CSS animation-delay prevents flicker on fast loads.
|
|
5
|
+
*/
|
|
6
|
+
export function PageLoader({ label }: { label?: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className="flex-1 flex flex-col items-center justify-center select-none"
|
|
10
|
+
style={{
|
|
11
|
+
opacity: 0,
|
|
12
|
+
animation: 'fade-up 0.4s var(--ease-spring) 0.15s both',
|
|
13
|
+
}}
|
|
14
|
+
>
|
|
15
|
+
{/* Orbital ring */}
|
|
16
|
+
<div className="relative w-[64px] h-[64px] mb-5">
|
|
17
|
+
{/* Glow pulse */}
|
|
18
|
+
<div
|
|
19
|
+
className="absolute inset-[-12px] rounded-full"
|
|
20
|
+
style={{
|
|
21
|
+
background: 'radial-gradient(circle, rgba(99,102,241,0.06) 0%, transparent 70%)',
|
|
22
|
+
animation: 'sc-glow 2.5s ease-in-out infinite',
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
{/* Ring */}
|
|
27
|
+
<div
|
|
28
|
+
className="absolute inset-0 rounded-full border border-white/[0.05]"
|
|
29
|
+
style={{ animation: 'sc-ring 3s linear infinite' }}
|
|
30
|
+
/>
|
|
31
|
+
|
|
32
|
+
{/* 3 orbiting dots */}
|
|
33
|
+
{[0, 1, 2].map((i) => (
|
|
34
|
+
<div
|
|
35
|
+
key={i}
|
|
36
|
+
className="absolute inset-0"
|
|
37
|
+
style={{
|
|
38
|
+
animation: 'sc-orbit 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite',
|
|
39
|
+
animationDelay: `${i * -0.8}s`,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<div
|
|
43
|
+
className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
|
44
|
+
style={{
|
|
45
|
+
width: i === 0 ? 6 : 5,
|
|
46
|
+
height: i === 0 ? 6 : 5,
|
|
47
|
+
background: i === 0 ? '#818CF8' : `rgba(129, 140, 248, ${0.6 - i * 0.15})`,
|
|
48
|
+
boxShadow: i === 0 ? '0 0 10px rgba(99,102,241,0.4)' : 'none',
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Shimmer bar */}
|
|
56
|
+
<div className="w-[60px] h-[2px] rounded-full bg-white/[0.05] overflow-hidden">
|
|
57
|
+
<div
|
|
58
|
+
className="h-full rounded-full bg-accent-bright/50"
|
|
59
|
+
style={{ animation: 'sc-progress 1.5s ease-in-out infinite' }}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Optional label */}
|
|
64
|
+
{label && (
|
|
65
|
+
<p className="mt-3 text-[12px] text-text-3/60">{label}</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -21,6 +21,10 @@ export const CLAUDE_CODE_TIMEOUT_SEC_MIN = 5
|
|
|
21
21
|
export const CLAUDE_CODE_TIMEOUT_SEC_MAX = 7200
|
|
22
22
|
export const CLI_PROCESS_TIMEOUT_SEC_MIN = 10
|
|
23
23
|
export const CLI_PROCESS_TIMEOUT_SEC_MAX = 7200
|
|
24
|
+
export const STREAM_IDLE_STALL_SEC_MIN = 30
|
|
25
|
+
export const STREAM_IDLE_STALL_SEC_MAX = 600
|
|
26
|
+
export const REQUIRED_TOOL_KICKOFF_SEC_MIN = 10
|
|
27
|
+
export const REQUIRED_TOOL_KICKOFF_SEC_MAX = 120
|
|
24
28
|
|
|
25
29
|
export const DEFAULT_AGENT_LOOP_RECURSION_LIMIT = 300
|
|
26
30
|
export const DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT = 80
|
|
@@ -30,9 +34,11 @@ export const DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES = 60
|
|
|
30
34
|
export const DEFAULT_DELEGATION_MAX_DEPTH = 3
|
|
31
35
|
|
|
32
36
|
// Tool/process timeouts
|
|
33
|
-
export const DEFAULT_SHELL_COMMAND_TIMEOUT_SEC =
|
|
37
|
+
export const DEFAULT_SHELL_COMMAND_TIMEOUT_SEC = 120
|
|
34
38
|
export const DEFAULT_CLAUDE_CODE_TIMEOUT_SEC = 1800
|
|
35
39
|
export const DEFAULT_CLI_PROCESS_TIMEOUT_SEC = 1800
|
|
40
|
+
export const DEFAULT_STREAM_IDLE_STALL_SEC = 180
|
|
41
|
+
export const DEFAULT_REQUIRED_TOOL_KICKOFF_SEC = 45
|
|
36
42
|
|
|
37
43
|
function parseIntSetting(value: unknown, fallback: number, min: number, max: number): number {
|
|
38
44
|
const parsed = typeof value === 'number'
|
|
@@ -55,6 +61,8 @@ export interface NormalizedRuntimeSettingFields {
|
|
|
55
61
|
shellCommandTimeoutSec: number
|
|
56
62
|
claudeCodeTimeoutSec: number
|
|
57
63
|
cliProcessTimeoutSec: number
|
|
64
|
+
streamIdleStallSec: number
|
|
65
|
+
requiredToolKickoffSec: number
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export function normalizeRuntimeSettingFields(settings: Record<string, unknown>): NormalizedRuntimeSettingFields {
|
|
@@ -114,5 +122,17 @@ export function normalizeRuntimeSettingFields(settings: Record<string, unknown>)
|
|
|
114
122
|
CLI_PROCESS_TIMEOUT_SEC_MIN,
|
|
115
123
|
CLI_PROCESS_TIMEOUT_SEC_MAX,
|
|
116
124
|
),
|
|
125
|
+
streamIdleStallSec: parseIntSetting(
|
|
126
|
+
settings.streamIdleStallSec,
|
|
127
|
+
DEFAULT_STREAM_IDLE_STALL_SEC,
|
|
128
|
+
STREAM_IDLE_STALL_SEC_MIN,
|
|
129
|
+
STREAM_IDLE_STALL_SEC_MAX,
|
|
130
|
+
),
|
|
131
|
+
requiredToolKickoffSec: parseIntSetting(
|
|
132
|
+
settings.requiredToolKickoffSec,
|
|
133
|
+
DEFAULT_REQUIRED_TOOL_KICKOFF_SEC,
|
|
134
|
+
REQUIRED_TOOL_KICKOFF_SEC_MIN,
|
|
135
|
+
REQUIRED_TOOL_KICKOFF_SEC_MAX,
|
|
136
|
+
),
|
|
117
137
|
}
|
|
118
138
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getToolEventsSnapshotKey,
|
|
11
11
|
hasPersistableAssistantPayload,
|
|
12
12
|
parseUsdLimit,
|
|
13
|
+
pruneOldHeartbeatMessages,
|
|
13
14
|
shouldAutoRouteHeartbeatAlerts,
|
|
14
15
|
shouldPersistInboundUserMessage,
|
|
15
16
|
shouldReplaceRecentAssistantMessage,
|
|
@@ -82,11 +83,12 @@ describe('shouldPersistInboundUserMessage', () => {
|
|
|
82
83
|
assert.equal(shouldPersistInboundUserMessage(false, 'connector'), true)
|
|
83
84
|
})
|
|
84
85
|
|
|
85
|
-
it('returns true for internal eval messages', () => {
|
|
86
|
+
it('returns true for internal eval and subagent messages', () => {
|
|
86
87
|
assert.equal(shouldPersistInboundUserMessage(true, 'eval'), true)
|
|
88
|
+
assert.equal(shouldPersistInboundUserMessage(true, 'subagent'), true)
|
|
87
89
|
})
|
|
88
90
|
|
|
89
|
-
it('returns false for internal
|
|
91
|
+
it('returns false for other internal messages', () => {
|
|
90
92
|
assert.equal(shouldPersistInboundUserMessage(true, 'heartbeat'), false)
|
|
91
93
|
assert.equal(shouldPersistInboundUserMessage(true, 'chat'), false)
|
|
92
94
|
assert.equal(shouldPersistInboundUserMessage(true, 'daemon'), false)
|
|
@@ -477,3 +479,58 @@ describe('estimateConversationTone', () => {
|
|
|
477
479
|
assert.equal(estimateConversationTone(''), 'neutral')
|
|
478
480
|
})
|
|
479
481
|
})
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// pruneOldHeartbeatMessages
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
describe('pruneOldHeartbeatMessages', () => {
|
|
487
|
+
it('removes old heartbeat messages keeping only the most recent 2', () => {
|
|
488
|
+
const messages: Message[] = [
|
|
489
|
+
{ role: 'user', text: 'hi', time: 1 },
|
|
490
|
+
{ role: 'assistant', text: 'alert 1', time: 2, kind: 'heartbeat' },
|
|
491
|
+
{ role: 'assistant', text: 'alert 2', time: 3, kind: 'heartbeat' },
|
|
492
|
+
{ role: 'assistant', text: 'real reply', time: 4, kind: 'chat' },
|
|
493
|
+
{ role: 'assistant', text: 'alert 3', time: 5, kind: 'heartbeat' },
|
|
494
|
+
{ role: 'assistant', text: 'alert 4', time: 6, kind: 'heartbeat' },
|
|
495
|
+
]
|
|
496
|
+
const removed = pruneOldHeartbeatMessages(messages)
|
|
497
|
+
assert.equal(removed, 2)
|
|
498
|
+
assert.equal(messages.length, 4)
|
|
499
|
+
// Only the last 2 heartbeat messages remain
|
|
500
|
+
const heartbeats = messages.filter((m) => m.kind === 'heartbeat')
|
|
501
|
+
assert.equal(heartbeats.length, 2)
|
|
502
|
+
assert.equal(heartbeats[0].text, 'alert 3')
|
|
503
|
+
assert.equal(heartbeats[1].text, 'alert 4')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('does not remove anything when count is at or below maxKeep', () => {
|
|
507
|
+
const messages: Message[] = [
|
|
508
|
+
{ role: 'assistant', text: 'alert 1', time: 1, kind: 'heartbeat' },
|
|
509
|
+
{ role: 'user', text: 'hi', time: 2 },
|
|
510
|
+
{ role: 'assistant', text: 'alert 2', time: 3, kind: 'heartbeat' },
|
|
511
|
+
]
|
|
512
|
+
assert.equal(pruneOldHeartbeatMessages(messages), 0)
|
|
513
|
+
assert.equal(messages.length, 3)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('respects custom maxKeep value', () => {
|
|
517
|
+
const messages: Message[] = [
|
|
518
|
+
{ role: 'assistant', text: 'hb1', time: 1, kind: 'heartbeat' },
|
|
519
|
+
{ role: 'assistant', text: 'hb2', time: 2, kind: 'heartbeat' },
|
|
520
|
+
{ role: 'assistant', text: 'hb3', time: 3, kind: 'heartbeat' },
|
|
521
|
+
]
|
|
522
|
+
assert.equal(pruneOldHeartbeatMessages(messages, 1), 2)
|
|
523
|
+
assert.equal(messages.length, 1)
|
|
524
|
+
assert.equal(messages[0].text, 'hb3')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('does not touch non-heartbeat messages', () => {
|
|
528
|
+
const messages: Message[] = [
|
|
529
|
+
{ role: 'user', text: 'a', time: 1 },
|
|
530
|
+
{ role: 'assistant', text: 'b', time: 2, kind: 'chat' },
|
|
531
|
+
{ role: 'assistant', text: 'c', time: 3 },
|
|
532
|
+
]
|
|
533
|
+
assert.equal(pruneOldHeartbeatMessages(messages), 0)
|
|
534
|
+
assert.equal(messages.length, 3)
|
|
535
|
+
})
|
|
536
|
+
})
|
|
@@ -40,7 +40,7 @@ export function shouldAutoRouteHeartbeatAlerts(config?: {
|
|
|
40
40
|
|
|
41
41
|
export function shouldPersistInboundUserMessage(internal: boolean, source: string): boolean {
|
|
42
42
|
if (!internal) return true
|
|
43
|
-
return source === 'eval'
|
|
43
|
+
return source === 'eval' || source === 'subagent'
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function escapeRegExp(value: string): string {
|
|
@@ -522,6 +522,30 @@ export function classifyHeartbeatResponse(text: string, ackMaxChars: number, had
|
|
|
522
522
|
return stripped.length < cleaned.length ? 'strip' : 'keep'
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Prune old heartbeat messages from the transcript to prevent context bloat.
|
|
527
|
+
* Keeps only the most recent `maxKeep` heartbeat assistant messages.
|
|
528
|
+
* Returns the number of messages removed.
|
|
529
|
+
*/
|
|
530
|
+
export function pruneOldHeartbeatMessages(messages: Message[], maxKeep = 2): number {
|
|
531
|
+
const heartbeatIndices: number[] = []
|
|
532
|
+
for (let i = 0; i < messages.length; i++) {
|
|
533
|
+
if (messages[i].role === 'assistant' && messages[i].kind === 'heartbeat') {
|
|
534
|
+
heartbeatIndices.push(i)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (heartbeatIndices.length <= maxKeep) return 0
|
|
538
|
+
const toRemove = new Set(heartbeatIndices.slice(0, heartbeatIndices.length - maxKeep))
|
|
539
|
+
let removed = 0
|
|
540
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
541
|
+
if (toRemove.has(i)) {
|
|
542
|
+
messages.splice(i, 1)
|
|
543
|
+
removed++
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return removed
|
|
547
|
+
}
|
|
548
|
+
|
|
525
549
|
export function estimateConversationTone(text: string): string {
|
|
526
550
|
const t = text || ''
|
|
527
551
|
if (/```/.test(t) || /\b(function|const|let|var|import|export|class|interface|async|await|return)\b/.test(t)) return 'technical'
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
getTodaySpendUsd,
|
|
49
49
|
classifyHeartbeatResponse,
|
|
50
50
|
estimateConversationTone,
|
|
51
|
+
pruneOldHeartbeatMessages,
|
|
51
52
|
} from '@/lib/server/chat-execution/chat-execution-utils'
|
|
52
53
|
import { runPostLlmToolRouting } from '@/lib/server/chat-execution/chat-turn-tool-routing'
|
|
53
54
|
import {
|
|
@@ -129,7 +130,7 @@ export interface ExecuteChatTurnInput {
|
|
|
129
130
|
signal?: AbortSignal
|
|
130
131
|
onEvent?: (event: SSEEvent) => void
|
|
131
132
|
modelOverride?: string
|
|
132
|
-
heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null }
|
|
133
|
+
heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null; lightContext?: boolean }
|
|
133
134
|
replyToId?: string
|
|
134
135
|
}
|
|
135
136
|
|
|
@@ -429,6 +430,29 @@ function syncSessionFromAgent(sessionId: string): void {
|
|
|
429
430
|
}
|
|
430
431
|
}
|
|
431
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Build a minimal system prompt for lightweight heartbeat context.
|
|
435
|
+
* Strips conversation history, skills, tool discipline, and workspace context.
|
|
436
|
+
* Keeps identity, datetime, and heartbeat guidance for correct routing.
|
|
437
|
+
*/
|
|
438
|
+
function buildLightHeartbeatSystemPrompt(session: Session): string | undefined {
|
|
439
|
+
if (!session.agentId) return undefined
|
|
440
|
+
const agents = loadAgents()
|
|
441
|
+
const agent = agents[session.agentId]
|
|
442
|
+
if (!agent) return undefined
|
|
443
|
+
|
|
444
|
+
const parts: string[] = []
|
|
445
|
+
parts.push(`## Identity\nName: ${agent.name}`)
|
|
446
|
+
if (agent.description) parts.push(`Description: ${agent.description}`)
|
|
447
|
+
parts.push(buildCurrentDateTimePromptContext())
|
|
448
|
+
if (agent.soul) parts.push(`## Soul\n${agent.soul.slice(0, 300)}`)
|
|
449
|
+
parts.push([
|
|
450
|
+
'## Heartbeats',
|
|
451
|
+
'You run on an autonomous heartbeat. If you receive a heartbeat poll and nothing needs attention, reply exactly: HEARTBEAT_OK',
|
|
452
|
+
].join('\n'))
|
|
453
|
+
return parts.join('\n\n')
|
|
454
|
+
}
|
|
455
|
+
|
|
432
456
|
function buildAgentSystemPrompt(session: Session): string | undefined {
|
|
433
457
|
if (!session.agentId) return undefined
|
|
434
458
|
const agents = loadAgents()
|
|
@@ -601,6 +625,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
601
625
|
)
|
|
602
626
|
const isHeartbeatRun = isInternalHeartbeatRun(internal, source)
|
|
603
627
|
const isAutonomousInternalRun = internal && source !== 'chat'
|
|
628
|
+
const heartbeatLightContext = isHeartbeatRun && !!input.heartbeatConfig?.lightContext
|
|
604
629
|
const isAutoRunNoHistory = isHeartbeatRun
|
|
605
630
|
const heartbeatStatusOnly = false
|
|
606
631
|
if (shouldApplySessionFreshnessReset(source)) {
|
|
@@ -803,7 +828,10 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
803
828
|
// including identity, soul, skills, tool discipline, and execution policy.
|
|
804
829
|
// Only build the standalone system prompt for the direct-provider (no LangGraph) path
|
|
805
830
|
// to avoid duplicating tool discipline, operating guidance, and capability sections.
|
|
806
|
-
|
|
831
|
+
// lightContext mode uses a minimal prompt for both paths to reduce token cost.
|
|
832
|
+
const systemPrompt = heartbeatLightContext
|
|
833
|
+
? buildLightHeartbeatSystemPrompt(session)
|
|
834
|
+
: (hasPlugins ? undefined : buildAgentSystemPrompt(session))
|
|
807
835
|
const toolEvents: MessageToolEvent[] = []
|
|
808
836
|
const streamErrors: string[] = []
|
|
809
837
|
const accumulatedUsage = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }
|
|
@@ -967,8 +995,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
967
995
|
// Heartbeat runs get a small tail of recent messages so the agent can see
|
|
968
996
|
// prior findings and avoid repeating the same searches. Full history is
|
|
969
997
|
// skipped to avoid blowing the context window on long-lived sessions.
|
|
998
|
+
// lightContext mode skips history entirely for maximum token savings.
|
|
970
999
|
const heartbeatHistory = isAutoRunNoHistory
|
|
971
|
-
? getSessionMessages(sessionId).slice(-6)
|
|
1000
|
+
? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
|
|
972
1001
|
: undefined
|
|
973
1002
|
|
|
974
1003
|
console.log(`[chat-execution] provider=${providerType}, hasPlugins=${hasPlugins}, localOpenClawNative=${useLocalOpenClawNativeRuntime}, imagePath=${resolvedImagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, plugins=${enabledSessionPlugins.length}`)
|
|
@@ -988,7 +1017,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
988
1017
|
fullResponse = result.finalResponse || result.fullText
|
|
989
1018
|
} else {
|
|
990
1019
|
const directHistorySnapshot = isAutoRunNoHistory
|
|
991
|
-
? getSessionMessages(sessionId).slice(-6)
|
|
1020
|
+
? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
|
|
992
1021
|
: applyContextClearBoundary(getSessionMessages(sessionId))
|
|
993
1022
|
responseCacheInput = {
|
|
994
1023
|
provider: providerType,
|
|
@@ -1186,6 +1215,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
1186
1215
|
&& (Date.now() - prevSentAt) < 24 * 60 * 60 * 1000
|
|
1187
1216
|
if (isDuplicate) {
|
|
1188
1217
|
heartbeatClassification = 'suppress'
|
|
1218
|
+
log.info('heartbeat', `Duplicate heartbeat suppressed for session ${sessionId} (same text within 24h)`)
|
|
1189
1219
|
}
|
|
1190
1220
|
}
|
|
1191
1221
|
}
|
|
@@ -1332,6 +1362,15 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
1332
1362
|
pruneSuppressedHeartbeatStreamMessage(current.messages)
|
|
1333
1363
|
}
|
|
1334
1364
|
|
|
1365
|
+
// P1: Prune old heartbeat messages to prevent context bloat.
|
|
1366
|
+
// Long-running agents accumulate ~48 no-op messages/day; keep only the most recent 2.
|
|
1367
|
+
if (isHeartbeatRun) {
|
|
1368
|
+
const pruned = pruneOldHeartbeatMessages(current.messages)
|
|
1369
|
+
if (pruned > 0) {
|
|
1370
|
+
log.info('heartbeat', `Pruned ${pruned} old heartbeat message(s) from session ${sessionId}`)
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1335
1374
|
// Fire afterChatTurn hook for all enabled plugins (memory auto-save, logging, etc.)
|
|
1336
1375
|
try {
|
|
1337
1376
|
await getPluginManager().runHook('afterChatTurn', {
|