@swarmclawai/swarmclaw 0.7.6 → 0.7.8
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 +19 -10
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +16 -0
- package/src/app/api/agents/route.ts +2 -0
- package/src/app/api/chats/[id]/route.ts +21 -1
- package/src/app/api/chats/route.ts +13 -1
- package/src/app/api/connectors/[id]/route.ts +20 -2
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
- package/src/app/api/external-agents/[id]/route.ts +38 -6
- package/src/app/api/external-agents/route.ts +17 -1
- package/src/app/api/gateways/[id]/health/route.ts +8 -0
- package/src/app/api/gateways/[id]/route.ts +53 -1
- package/src/app/api/gateways/route.ts +53 -0
- package/src/app/api/openclaw/deploy/route.ts +139 -0
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- package/src/app/api/secrets/[id]/route.ts +1 -0
- package/src/app/api/secrets/route.ts +2 -1
- package/src/app/api/settings/route.ts +2 -0
- package/src/cli/index.js +40 -0
- package/src/cli/index.test.js +68 -0
- package/src/cli/spec.js +60 -0
- package/src/components/agents/agent-sheet.tsx +281 -33
- package/src/components/auth/setup-wizard.tsx +75 -2
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-header.tsx +4 -0
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/gateways/gateway-sheet.tsx +140 -8
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- package/src/components/providers/provider-list.tsx +221 -17
- package/src/components/shared/settings/section-capability-policy.tsx +38 -0
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/tasks/approvals-panel.tsx +177 -18
- package/src/components/tasks/task-board.tsx +137 -23
- package/src/components/tasks/task-card.tsx +29 -0
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/lib/server/agent-runtime-config.ts +142 -7
- package/src/lib/server/agent-thread-session.ts +9 -1
- package/src/lib/server/capability-router.test.ts +22 -0
- package/src/lib/server/capability-router.ts +54 -18
- package/src/lib/server/chat-execution.ts +33 -3
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.ts +99 -74
- package/src/lib/server/daemon-state.ts +83 -46
- package/src/lib/server/elevenlabs.test.ts +59 -1
- package/src/lib/server/heartbeat-service.ts +5 -1
- package/src/lib/server/main-agent-loop.test.ts +260 -0
- package/src/lib/server/main-agent-loop.ts +559 -14
- package/src/lib/server/openclaw-deploy.test.ts +8 -0
- package/src/lib/server/openclaw-deploy.ts +679 -19
- package/src/lib/server/orchestrator-lg.ts +1 -0
- package/src/lib/server/orchestrator.ts +11 -0
- package/src/lib/server/plugins.ts +6 -1
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-followups.test.ts +147 -2
- package/src/lib/server/queue.ts +278 -8
- package/src/lib/server/session-run-manager.ts +31 -0
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.ts +26 -1
- package/src/lib/server/session-tools/context.ts +5 -0
- package/src/lib/server/session-tools/crud.ts +265 -76
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +38 -2
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.ts +14 -2
- package/src/lib/server/session-tools/platform-access.test.ts +58 -0
- package/src/lib/server/session-tools/platform.ts +60 -19
- package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
- package/src/lib/server/session-tools/web.ts +153 -6
- package/src/lib/server/stream-agent-chat.test.ts +27 -2
- package/src/lib/server/stream-agent-chat.ts +104 -30
- package/src/lib/server/tool-aliases.ts +2 -0
- package/src/lib/server/tool-capability-policy.test.ts +24 -0
- package/src/lib/server/tool-capability-policy.ts +29 -1
- package/src/lib/server/tool-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +269 -0
- package/src/lib/setup-defaults.ts +2 -2
- package/src/lib/tool-definitions.ts +2 -1
- package/src/lib/validation/schemas.ts +9 -0
- package/src/types/index.ts +104 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import type { GoalContract, MessageToolEvent } from '@/types'
|
|
1
|
+
import type { GoalContract, Message, MessageToolEvent, Session } from '@/types'
|
|
2
|
+
import { mergeGoalContracts, parseGoalContractFromText, parseMainLoopPlan, parseMainLoopReview } from './autonomy-contract'
|
|
3
|
+
import { enqueueSystemEvent } from './system-events'
|
|
4
|
+
import { loadSessions, loadSettings } from './storage'
|
|
2
5
|
|
|
3
6
|
const LEGACY_META_LINE_RE = /\[(?:MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META)\]\s*(\{[^\n]*\})?/i
|
|
7
|
+
const HEARTBEAT_META_RE = /\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i
|
|
8
|
+
const MAX_PENDING_EVENTS = 16
|
|
9
|
+
const MAX_TIMELINE_ITEMS = 40
|
|
10
|
+
const MAX_WORKING_MEMORY_NOTES = 12
|
|
11
|
+
const DEFAULT_FOLLOWUP_DELAY_MS = 1500
|
|
12
|
+
const DEFAULT_MAX_FOLLOWUP_CHAIN = 3
|
|
4
13
|
|
|
5
14
|
export interface MainLoopState {
|
|
6
15
|
goal: string | null
|
|
@@ -66,14 +75,422 @@ export interface HandleMainLoopRunResultInput {
|
|
|
66
75
|
estimatedCost?: number
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
type MainSessionLike = Partial<Session> & Record<string, unknown>
|
|
79
|
+
|
|
80
|
+
const globalKey = '__swarmclaw_main_loop_state__' as const
|
|
81
|
+
const globalScope = globalThis as typeof globalThis & { [globalKey]?: Map<string, MainLoopState> }
|
|
82
|
+
const stateMap = globalScope[globalKey] ?? (globalScope[globalKey] = new Map<string, MainLoopState>())
|
|
83
|
+
|
|
84
|
+
function now(): number {
|
|
85
|
+
return Date.now()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function asSession(session: unknown): MainSessionLike | null {
|
|
89
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) return null
|
|
90
|
+
return session as MainSessionLike
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanText(value: unknown, maxChars = 320): string | null {
|
|
94
|
+
if (typeof value !== 'string') return null
|
|
95
|
+
const normalized = value.replace(/\s+/g, ' ').trim()
|
|
96
|
+
return normalized ? normalized.slice(0, maxChars) : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cleanMultiline(value: unknown, maxChars = 1400): string | null {
|
|
100
|
+
if (typeof value !== 'string') return null
|
|
101
|
+
const normalized = value
|
|
102
|
+
.split('\n')
|
|
103
|
+
.map((line) => line.trim())
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join('\n')
|
|
106
|
+
.slice(0, maxChars)
|
|
107
|
+
.trim()
|
|
108
|
+
return normalized || null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeConfidence(value: unknown): number | null {
|
|
112
|
+
const raw = typeof value === 'number'
|
|
113
|
+
? value
|
|
114
|
+
: typeof value === 'string'
|
|
115
|
+
? Number.parseFloat(value)
|
|
116
|
+
: Number.NaN
|
|
117
|
+
if (!Number.isFinite(raw)) return null
|
|
118
|
+
return Math.max(0, Math.min(1, raw))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function defaultState(): MainLoopState {
|
|
122
|
+
return {
|
|
123
|
+
goal: null,
|
|
124
|
+
goalContract: null,
|
|
125
|
+
summary: null,
|
|
126
|
+
nextAction: null,
|
|
127
|
+
planSteps: [],
|
|
128
|
+
currentPlanStep: null,
|
|
129
|
+
reviewNote: null,
|
|
130
|
+
reviewConfidence: null,
|
|
131
|
+
missionTaskId: null,
|
|
132
|
+
momentumScore: 0,
|
|
133
|
+
paused: false,
|
|
134
|
+
status: 'idle',
|
|
135
|
+
autonomyMode: 'assist',
|
|
136
|
+
pendingEvents: [],
|
|
137
|
+
timeline: [],
|
|
138
|
+
missionTokens: 0,
|
|
139
|
+
missionCostUsd: 0,
|
|
140
|
+
followupChainCount: 0,
|
|
141
|
+
metaMissCount: 0,
|
|
142
|
+
workingMemoryNotes: [],
|
|
143
|
+
lastMemoryNoteAt: null,
|
|
144
|
+
lastPlannedAt: null,
|
|
145
|
+
lastReviewedAt: null,
|
|
146
|
+
lastTickAt: null,
|
|
147
|
+
updatedAt: now(),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeStatus(value: unknown, fallback: MainLoopState['status'] = 'idle'): MainLoopState['status'] {
|
|
152
|
+
return value === 'progress' || value === 'blocked' || value === 'ok' || value === 'idle'
|
|
153
|
+
? value
|
|
154
|
+
: fallback
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeAutonomyMode(value: unknown, fallback: MainLoopState['autonomyMode'] = 'assist'): MainLoopState['autonomyMode'] {
|
|
158
|
+
return value === 'autonomous' || value === 'assist' ? value : fallback
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function uniqueStrings(values: string[], maxItems: number): string[] {
|
|
162
|
+
const seen = new Set<string>()
|
|
163
|
+
const out: string[] = []
|
|
164
|
+
for (const value of values) {
|
|
165
|
+
const normalized = cleanText(value, 280)
|
|
166
|
+
if (!normalized) continue
|
|
167
|
+
const key = normalized.toLowerCase()
|
|
168
|
+
if (seen.has(key)) continue
|
|
169
|
+
seen.add(key)
|
|
170
|
+
out.push(normalized)
|
|
171
|
+
if (out.length >= maxItems) break
|
|
172
|
+
}
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizePendingEvents(value: unknown): MainLoopState['pendingEvents'] {
|
|
177
|
+
if (!Array.isArray(value)) return []
|
|
178
|
+
const out: MainLoopState['pendingEvents'] = []
|
|
179
|
+
for (const entry of value) {
|
|
180
|
+
if (!entry || typeof entry !== 'object') continue
|
|
181
|
+
const record = entry as Record<string, unknown>
|
|
182
|
+
const text = cleanText(record.text, 320)
|
|
183
|
+
if (!text) continue
|
|
184
|
+
out.push({
|
|
185
|
+
id: typeof record.id === 'string' && record.id.trim() ? record.id.trim() : `evt-${out.length + 1}`,
|
|
186
|
+
type: typeof record.type === 'string' && record.type.trim() ? record.type.trim() : 'event',
|
|
187
|
+
text,
|
|
188
|
+
createdAt: typeof record.createdAt === 'number' && Number.isFinite(record.createdAt)
|
|
189
|
+
? Math.trunc(record.createdAt)
|
|
190
|
+
: now(),
|
|
191
|
+
})
|
|
192
|
+
if (out.length >= MAX_PENDING_EVENTS) break
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeTimeline(value: unknown): MainLoopState['timeline'] {
|
|
198
|
+
if (!Array.isArray(value)) return []
|
|
199
|
+
const out: MainLoopState['timeline'] = []
|
|
200
|
+
for (const entry of value) {
|
|
201
|
+
if (!entry || typeof entry !== 'object') continue
|
|
202
|
+
const record = entry as Record<string, unknown>
|
|
203
|
+
const note = cleanText(record.note, 320)
|
|
204
|
+
if (!note) continue
|
|
205
|
+
const status = record.status === 'idle'
|
|
206
|
+
|| record.status === 'progress'
|
|
207
|
+
|| record.status === 'blocked'
|
|
208
|
+
|| record.status === 'ok'
|
|
209
|
+
|| record.status === 'reflection'
|
|
210
|
+
? record.status
|
|
211
|
+
: undefined
|
|
212
|
+
out.push({
|
|
213
|
+
id: typeof record.id === 'string' && record.id.trim() ? record.id.trim() : `tl-${out.length + 1}`,
|
|
214
|
+
at: typeof record.at === 'number' && Number.isFinite(record.at) ? Math.trunc(record.at) : now(),
|
|
215
|
+
source: typeof record.source === 'string' && record.source.trim() ? record.source.trim() : 'state',
|
|
216
|
+
note,
|
|
217
|
+
status,
|
|
218
|
+
})
|
|
219
|
+
if (out.length >= MAX_TIMELINE_ITEMS) break
|
|
220
|
+
}
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseHeartbeatMeta(text: string): { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } | null {
|
|
225
|
+
const match = (text || '').match(HEARTBEAT_META_RE)
|
|
226
|
+
if (!match) return null
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(match[1]) as Record<string, unknown>
|
|
229
|
+
const payload: { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } = {}
|
|
230
|
+
const goal = cleanText(parsed.goal, 400)
|
|
231
|
+
const summary = cleanText(parsed.summary, 500)
|
|
232
|
+
const nextAction = cleanText(parsed.next_action, 240)
|
|
233
|
+
if (goal) payload.goal = goal
|
|
234
|
+
if (summary) payload.summary = summary
|
|
235
|
+
if (nextAction) payload.nextAction = nextAction
|
|
236
|
+
if (parsed.status === 'idle' || parsed.status === 'progress' || parsed.status === 'blocked' || parsed.status === 'ok') {
|
|
237
|
+
payload.status = normalizeStatus(parsed.status, 'idle')
|
|
238
|
+
}
|
|
239
|
+
return Object.keys(payload).length > 0 ? payload : null
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function clampState(state: MainLoopState): MainLoopState {
|
|
246
|
+
state.planSteps = uniqueStrings(state.planSteps || [], 8)
|
|
247
|
+
state.workingMemoryNotes = uniqueStrings(state.workingMemoryNotes || [], MAX_WORKING_MEMORY_NOTES)
|
|
248
|
+
state.pendingEvents = normalizePendingEvents(state.pendingEvents).slice(-MAX_PENDING_EVENTS)
|
|
249
|
+
state.timeline = normalizeTimeline(state.timeline).slice(-MAX_TIMELINE_ITEMS)
|
|
250
|
+
state.goal = cleanText(state.goal, 500)
|
|
251
|
+
state.summary = cleanText(state.summary, 1000)
|
|
252
|
+
state.nextAction = cleanText(state.nextAction, 240)
|
|
253
|
+
state.currentPlanStep = cleanText(state.currentPlanStep, 240)
|
|
254
|
+
state.reviewNote = cleanText(state.reviewNote, 320)
|
|
255
|
+
state.reviewConfidence = normalizeConfidence(state.reviewConfidence)
|
|
256
|
+
state.momentumScore = Math.max(-10, Math.min(10, Math.trunc(state.momentumScore || 0)))
|
|
257
|
+
state.followupChainCount = Math.max(0, Math.min(10, Math.trunc(state.followupChainCount || 0)))
|
|
258
|
+
state.metaMissCount = Math.max(0, Math.min(100, Math.trunc(state.metaMissCount || 0)))
|
|
259
|
+
state.missionTokens = Math.max(0, Math.trunc(state.missionTokens || 0))
|
|
260
|
+
state.missionCostUsd = Math.max(0, Number.isFinite(state.missionCostUsd) ? Number(state.missionCostUsd) : 0)
|
|
261
|
+
state.updatedAt = typeof state.updatedAt === 'number' && Number.isFinite(state.updatedAt) ? Math.trunc(state.updatedAt) : now()
|
|
262
|
+
return state
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
|
|
266
|
+
const next = defaultState()
|
|
267
|
+
if (input) {
|
|
268
|
+
if (input.goalContract) next.goalContract = input.goalContract
|
|
269
|
+
if (typeof input.goal === 'string' || input.goal === null) next.goal = input.goal
|
|
270
|
+
if (typeof input.summary === 'string' || input.summary === null) next.summary = input.summary
|
|
271
|
+
if (typeof input.nextAction === 'string' || input.nextAction === null) next.nextAction = input.nextAction
|
|
272
|
+
if (Array.isArray(input.planSteps)) next.planSteps = [...input.planSteps]
|
|
273
|
+
if (typeof input.currentPlanStep === 'string' || input.currentPlanStep === null) next.currentPlanStep = input.currentPlanStep
|
|
274
|
+
if (typeof input.reviewNote === 'string' || input.reviewNote === null) next.reviewNote = input.reviewNote
|
|
275
|
+
if (typeof input.reviewConfidence === 'number' || typeof input.reviewConfidence === 'string' || input.reviewConfidence === null) {
|
|
276
|
+
next.reviewConfidence = normalizeConfidence(input.reviewConfidence)
|
|
277
|
+
}
|
|
278
|
+
if (typeof input.missionTaskId === 'string' || input.missionTaskId === null) next.missionTaskId = input.missionTaskId
|
|
279
|
+
if (typeof input.momentumScore === 'number') next.momentumScore = input.momentumScore
|
|
280
|
+
if (typeof input.paused === 'boolean') next.paused = input.paused
|
|
281
|
+
if (input.status) next.status = normalizeStatus(input.status, next.status)
|
|
282
|
+
if (input.autonomyMode) next.autonomyMode = normalizeAutonomyMode(input.autonomyMode, next.autonomyMode)
|
|
283
|
+
if (Array.isArray(input.pendingEvents)) next.pendingEvents = [...input.pendingEvents]
|
|
284
|
+
if (Array.isArray(input.timeline)) next.timeline = [...input.timeline]
|
|
285
|
+
if (typeof input.missionTokens === 'number') next.missionTokens = input.missionTokens
|
|
286
|
+
if (typeof input.missionCostUsd === 'number') next.missionCostUsd = input.missionCostUsd
|
|
287
|
+
if (typeof input.followupChainCount === 'number') next.followupChainCount = input.followupChainCount
|
|
288
|
+
if (typeof input.metaMissCount === 'number') next.metaMissCount = input.metaMissCount
|
|
289
|
+
if (Array.isArray(input.workingMemoryNotes)) next.workingMemoryNotes = [...input.workingMemoryNotes]
|
|
290
|
+
if (typeof input.lastMemoryNoteAt === 'number' || input.lastMemoryNoteAt === null) next.lastMemoryNoteAt = input.lastMemoryNoteAt ?? null
|
|
291
|
+
if (typeof input.lastPlannedAt === 'number' || input.lastPlannedAt === null) next.lastPlannedAt = input.lastPlannedAt ?? null
|
|
292
|
+
if (typeof input.lastReviewedAt === 'number' || input.lastReviewedAt === null) next.lastReviewedAt = input.lastReviewedAt ?? null
|
|
293
|
+
if (typeof input.lastTickAt === 'number' || input.lastTickAt === null) next.lastTickAt = input.lastTickAt ?? null
|
|
294
|
+
if (typeof input.updatedAt === 'number') next.updatedAt = input.updatedAt
|
|
295
|
+
}
|
|
296
|
+
return clampState(next)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function appendTimeline(state: MainLoopState, source: string, note: string, status?: MainLoopState['timeline'][number]['status']): void {
|
|
300
|
+
const cleaned = cleanText(note, 320)
|
|
301
|
+
if (!cleaned) return
|
|
302
|
+
const previous = state.timeline.at(-1)
|
|
303
|
+
if (previous && previous.source === source && previous.note === cleaned) return
|
|
304
|
+
state.timeline.push({
|
|
305
|
+
id: `tl-${now()}-${state.timeline.length + 1}`,
|
|
306
|
+
at: now(),
|
|
307
|
+
source,
|
|
308
|
+
note: cleaned,
|
|
309
|
+
status,
|
|
310
|
+
})
|
|
311
|
+
state.timeline = state.timeline.slice(-MAX_TIMELINE_ITEMS)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function appendWorkingMemory(state: MainLoopState, note: string): void {
|
|
315
|
+
const cleaned = cleanText(note, 240)
|
|
316
|
+
if (!cleaned) return
|
|
317
|
+
state.workingMemoryNotes = uniqueStrings([...(state.workingMemoryNotes || []), cleaned], MAX_WORKING_MEMORY_NOTES)
|
|
318
|
+
state.lastMemoryNoteAt = now()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function extractLatestGoal(messages: Message[]): { goal: string | null; goalContract: GoalContract | null } {
|
|
322
|
+
let goal: string | null = null
|
|
323
|
+
let goalContract: GoalContract | null = null
|
|
324
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
325
|
+
const message = messages[index]
|
|
326
|
+
if (message.role !== 'user') continue
|
|
327
|
+
const text = cleanMultiline(message.text, 900)
|
|
328
|
+
if (!text) continue
|
|
329
|
+
goal = text
|
|
330
|
+
goalContract = mergeGoalContracts(goalContract, parseGoalContractFromText(text))
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
return { goal, goalContract }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hydrateStateFromSession(sessionId: string): MainLoopState | null {
|
|
337
|
+
const sessions = loadSessions()
|
|
338
|
+
const session = sessions[sessionId]
|
|
339
|
+
if (!session || !isMainSession(session)) return null
|
|
340
|
+
|
|
341
|
+
const messages = Array.isArray(session.messages) ? session.messages : []
|
|
342
|
+
const hydrated = defaultState()
|
|
343
|
+
hydrated.autonomyMode = session.heartbeatEnabled === true ? 'autonomous' : 'assist'
|
|
344
|
+
hydrated.updatedAt = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : now()
|
|
345
|
+
|
|
346
|
+
const initial = extractLatestGoal(messages)
|
|
347
|
+
hydrated.goal = initial.goal
|
|
348
|
+
hydrated.goalContract = initial.goalContract
|
|
349
|
+
|
|
350
|
+
for (const message of messages) {
|
|
351
|
+
if (message.role !== 'assistant' || typeof message.text !== 'string') continue
|
|
352
|
+
const heartbeat = parseHeartbeatMeta(message.text)
|
|
353
|
+
if (heartbeat?.goal) hydrated.goal = heartbeat.goal
|
|
354
|
+
if (heartbeat?.summary) hydrated.summary = heartbeat.summary
|
|
355
|
+
if (heartbeat?.nextAction) hydrated.nextAction = heartbeat.nextAction
|
|
356
|
+
if (heartbeat?.status) hydrated.status = heartbeat.status
|
|
357
|
+
|
|
358
|
+
const plan = parseMainLoopPlan(message.text)
|
|
359
|
+
if (plan?.steps?.length) hydrated.planSteps = plan.steps
|
|
360
|
+
if (plan?.current_step) hydrated.currentPlanStep = plan.current_step
|
|
361
|
+
if (plan) hydrated.lastPlannedAt = typeof message.time === 'number' ? message.time : hydrated.lastPlannedAt
|
|
362
|
+
|
|
363
|
+
const review = parseMainLoopReview(message.text)
|
|
364
|
+
if (review?.note) hydrated.reviewNote = review.note
|
|
365
|
+
if (typeof review?.confidence === 'number') hydrated.reviewConfidence = review.confidence
|
|
366
|
+
if (review) hydrated.lastReviewedAt = typeof message.time === 'number' ? message.time : hydrated.lastReviewedAt
|
|
367
|
+
|
|
368
|
+
const stripped = stripMainLoopMetaForPersistence(message.text)
|
|
369
|
+
if (stripped && !/^HEARTBEAT_OK$/i.test(stripped) && !/^NO_MESSAGE$/i.test(stripped)) {
|
|
370
|
+
hydrated.summary = cleanText(stripped, 1000) || hydrated.summary
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(message.toolEvents) && message.toolEvents.length > 0) {
|
|
373
|
+
const toolNames = uniqueStrings(message.toolEvents.map((event: MessageToolEvent) => event.name || '').filter(Boolean), 4)
|
|
374
|
+
if (toolNames.length > 0) appendWorkingMemory(hydrated, `Recent tools: ${toolNames.join(', ')}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return normalizeState(hydrated)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function getOrCreateState(sessionId: string): MainLoopState | null {
|
|
382
|
+
const existing = stateMap.get(sessionId)
|
|
383
|
+
if (existing) return existing
|
|
384
|
+
const hydrated = hydrateStateFromSession(sessionId)
|
|
385
|
+
if (!hydrated) return null
|
|
386
|
+
stateMap.set(sessionId, hydrated)
|
|
387
|
+
return hydrated
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function summarizePendingEvents(events: MainLoopState['pendingEvents']): string {
|
|
391
|
+
if (!events.length) return ''
|
|
392
|
+
return events
|
|
393
|
+
.slice(-6)
|
|
394
|
+
.map((event) => `- [${new Date(event.createdAt).toISOString()}] (${event.type}) ${event.text}`)
|
|
395
|
+
.join('\n')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function summarizeTimeline(items: MainLoopState['timeline']): string {
|
|
399
|
+
if (!items.length) return ''
|
|
400
|
+
return items
|
|
401
|
+
.slice(-6)
|
|
402
|
+
.map((entry) => `- [${new Date(entry.at).toISOString()}] ${entry.source}: ${entry.note}`)
|
|
403
|
+
.join('\n')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function formatGoalContract(goalContract: GoalContract | null): string {
|
|
407
|
+
if (!goalContract) return ''
|
|
408
|
+
const lines = [`Objective: ${goalContract.objective}`]
|
|
409
|
+
if (goalContract.constraints?.length) lines.push(`Constraints: ${goalContract.constraints.join(' | ')}`)
|
|
410
|
+
if (typeof goalContract.budgetUsd === 'number') lines.push(`Budget: $${goalContract.budgetUsd}`)
|
|
411
|
+
if (typeof goalContract.deadlineAt === 'number') lines.push(`Deadline: ${new Date(goalContract.deadlineAt).toISOString()}`)
|
|
412
|
+
if (goalContract.successMetric) lines.push(`Success metric: ${goalContract.successMetric}`)
|
|
413
|
+
return lines.join('\n')
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function extractWaitSignal(text: string, toolEvents: MessageToolEvent[]): boolean {
|
|
417
|
+
const haystack = `${text}\n${toolEvents.map((event) => `${event.name} ${event.input || ''} ${event.output || ''}`).join('\n')}`
|
|
418
|
+
return /\b(wait for|waiting for|approval|human reply|mailbox|watch job|pending approval)\b/i.test(haystack)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function followupLimit(): number {
|
|
422
|
+
const settings = loadSettings()
|
|
423
|
+
const raw = settings.maxFollowupChain
|
|
424
|
+
const parsed = typeof raw === 'number'
|
|
425
|
+
? raw
|
|
426
|
+
: typeof raw === 'string'
|
|
427
|
+
? Number.parseInt(raw, 10)
|
|
428
|
+
: Number.NaN
|
|
429
|
+
if (!Number.isFinite(parsed)) return DEFAULT_MAX_FOLLOWUP_CHAIN
|
|
430
|
+
return Math.max(0, Math.min(12, Math.trunc(parsed)))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function eventStatusForType(type: string): MainLoopState['status'] {
|
|
434
|
+
if (/fail|error|approval/i.test(type)) return 'blocked'
|
|
435
|
+
if (/complete|done|ok|success/i.test(type)) return 'ok'
|
|
436
|
+
return 'progress'
|
|
437
|
+
}
|
|
438
|
+
|
|
69
439
|
export function isMainSession(session: unknown): boolean {
|
|
70
|
-
|
|
71
|
-
return false
|
|
440
|
+
const candidate = asSession(session)
|
|
441
|
+
if (!candidate) return false
|
|
442
|
+
if (typeof candidate.parentSessionId === 'string' && candidate.parentSessionId.trim()) return false
|
|
443
|
+
const sessionType = typeof (candidate as Record<string, unknown>).sessionType === 'string'
|
|
444
|
+
? (candidate as Record<string, unknown>).sessionType
|
|
445
|
+
: null
|
|
446
|
+
if (sessionType === 'orchestrated') return false
|
|
447
|
+
const hasAgent = typeof candidate.agentId === 'string' && candidate.agentId.trim().length > 0
|
|
448
|
+
if (!hasAgent) return false
|
|
449
|
+
const shortcutThread = typeof candidate.shortcutForAgentId === 'string' && candidate.shortcutForAgentId.trim().length > 0
|
|
450
|
+
const connectorScope = typeof candidate.connectorSessionScope === 'string' && candidate.connectorSessionScope === 'main'
|
|
451
|
+
const contextScope = candidate.connectorContext && typeof candidate.connectorContext === 'object'
|
|
452
|
+
? (candidate.connectorContext as Record<string, unknown>).scope === 'main'
|
|
453
|
+
: false
|
|
454
|
+
const heartbeatOptIn = candidate.heartbeatEnabled === true
|
|
455
|
+
return shortcutThread || connectorScope || contextScope || heartbeatOptIn
|
|
72
456
|
}
|
|
73
457
|
|
|
74
458
|
export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: string): string {
|
|
75
|
-
|
|
76
|
-
return fallbackPrompt
|
|
459
|
+
const candidate = asSession(session)
|
|
460
|
+
if (!candidate?.id) return fallbackPrompt
|
|
461
|
+
const state = getOrCreateState(String(candidate.id))
|
|
462
|
+
if (!state) return fallbackPrompt
|
|
463
|
+
|
|
464
|
+
const planLines = state.planSteps.length > 0
|
|
465
|
+
? state.planSteps.map((step, index) => `${index + 1}. ${step}`).join('\n')
|
|
466
|
+
: ''
|
|
467
|
+
|
|
468
|
+
return [
|
|
469
|
+
'MAIN_AGENT_HEARTBEAT_TICK',
|
|
470
|
+
`Time: ${new Date().toISOString()}`,
|
|
471
|
+
state.goal ? `Current goal:\n${state.goal}` : '',
|
|
472
|
+
formatGoalContract(state.goalContract),
|
|
473
|
+
`Autonomy mode: ${state.autonomyMode}`,
|
|
474
|
+
`Current status: ${state.status}`,
|
|
475
|
+
state.summary ? `Latest summary:\n${state.summary}` : '',
|
|
476
|
+
state.nextAction ? `Planned next action: ${state.nextAction}` : '',
|
|
477
|
+
state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
|
|
478
|
+
planLines ? `Plan:\n${planLines}` : '',
|
|
479
|
+
state.pendingEvents.length > 0 ? `Pending external events:\n${summarizePendingEvents(state.pendingEvents)}` : '',
|
|
480
|
+
state.timeline.length > 0 ? `Recent timeline:\n${summarizeTimeline(state.timeline)}` : '',
|
|
481
|
+
state.workingMemoryNotes.length > 0 ? `Working memory:\n- ${state.workingMemoryNotes.join('\n- ')}` : '',
|
|
482
|
+
fallbackPrompt ? `Base heartbeat instructions:\n${fallbackPrompt}` : '',
|
|
483
|
+
'',
|
|
484
|
+
'You are the durable main mission thread for this agent.',
|
|
485
|
+
'Use the goal, plan, and pending events above to decide the highest-value next step.',
|
|
486
|
+
'Prefer acting with tools over restating the plan. Do not repeat completed work.',
|
|
487
|
+
'If you revise the plan, emit exactly one line like:',
|
|
488
|
+
'[MAIN_LOOP_PLAN]{"steps":["step 1","step 2"],"current_step":"step 1"}',
|
|
489
|
+
'After acting, emit exactly one review line like:',
|
|
490
|
+
'[MAIN_LOOP_REVIEW]{"note":"what changed","confidence":0.72,"needs_replan":false}',
|
|
491
|
+
'If you are actively progressing, also emit [AGENT_HEARTBEAT_META] with goal/status/next_action.',
|
|
492
|
+
'Reply HEARTBEAT_OK only when nothing needs action right now.',
|
|
493
|
+
].filter(Boolean).join('\n')
|
|
77
494
|
}
|
|
78
495
|
|
|
79
496
|
export function stripMainLoopMetaForPersistence(text: string): string {
|
|
@@ -85,22 +502,150 @@ export function stripMainLoopMetaForPersistence(text: string): string {
|
|
|
85
502
|
}
|
|
86
503
|
|
|
87
504
|
export function getMainLoopStateForSession(sessionId: string): MainLoopState | null {
|
|
88
|
-
|
|
89
|
-
return null
|
|
505
|
+
const state = getOrCreateState(sessionId)
|
|
506
|
+
return state ? normalizeState(state) : null
|
|
90
507
|
}
|
|
91
508
|
|
|
92
509
|
export function setMainLoopStateForSession(sessionId: string, patch: Partial<MainLoopState>): MainLoopState | null {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
510
|
+
const current = getOrCreateState(sessionId)
|
|
511
|
+
if (!current) return null
|
|
512
|
+
const next = normalizeState({
|
|
513
|
+
...current,
|
|
514
|
+
...patch,
|
|
515
|
+
planSteps: patch.planSteps ?? current.planSteps,
|
|
516
|
+
pendingEvents: patch.pendingEvents ?? current.pendingEvents,
|
|
517
|
+
timeline: patch.timeline ?? current.timeline,
|
|
518
|
+
workingMemoryNotes: patch.workingMemoryNotes ?? current.workingMemoryNotes,
|
|
519
|
+
updatedAt: now(),
|
|
520
|
+
})
|
|
521
|
+
stateMap.set(sessionId, next)
|
|
522
|
+
return normalizeState(next)
|
|
96
523
|
}
|
|
97
524
|
|
|
98
525
|
export function pushMainLoopEventToMainSessions(input: PushMainLoopEventInput): number {
|
|
99
|
-
|
|
100
|
-
return 0
|
|
526
|
+
const text = cleanText(input.text, 320)
|
|
527
|
+
if (!text) return 0
|
|
528
|
+
const sessions = loadSessions()
|
|
529
|
+
const nowTs = now()
|
|
530
|
+
let count = 0
|
|
531
|
+
|
|
532
|
+
for (const session of Object.values(sessions)) {
|
|
533
|
+
if (!isMainSession(session)) continue
|
|
534
|
+
const state = getOrCreateState(session.id)
|
|
535
|
+
if (!state) continue
|
|
536
|
+
|
|
537
|
+
const eventText = input.user ? `${input.user}: ${text}` : text
|
|
538
|
+
const previous = state.pendingEvents.at(-1)
|
|
539
|
+
if (!previous || previous.type !== input.type || previous.text !== eventText) {
|
|
540
|
+
state.pendingEvents.push({
|
|
541
|
+
id: `evt-${nowTs}-${state.pendingEvents.length + 1}`,
|
|
542
|
+
type: input.type || 'event',
|
|
543
|
+
text: eventText,
|
|
544
|
+
createdAt: nowTs,
|
|
545
|
+
})
|
|
546
|
+
state.pendingEvents = state.pendingEvents.slice(-MAX_PENDING_EVENTS)
|
|
547
|
+
}
|
|
548
|
+
state.status = eventStatusForType(input.type || 'event')
|
|
549
|
+
appendTimeline(state, input.type || 'event', eventText, state.status)
|
|
550
|
+
state.updatedAt = nowTs
|
|
551
|
+
stateMap.set(session.id, clampState(state))
|
|
552
|
+
enqueueSystemEvent(session.id, `[Main loop] ${eventText}`, `main-loop:${input.type || 'event'}`)
|
|
553
|
+
count += 1
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return count
|
|
101
557
|
}
|
|
102
558
|
|
|
103
559
|
export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): MainLoopFollowupRequest | null {
|
|
104
|
-
|
|
105
|
-
return null
|
|
560
|
+
const state = getOrCreateState(input.sessionId)
|
|
561
|
+
if (!state) return null
|
|
562
|
+
|
|
563
|
+
const resultText = input.resultText || ''
|
|
564
|
+
const persistedText = stripMainLoopMetaForPersistence(resultText)
|
|
565
|
+
const toolEvents = Array.isArray(input.toolEvents) ? input.toolEvents : []
|
|
566
|
+
const toolNames = uniqueStrings(toolEvents.map((event) => event.name || '').filter(Boolean), 8)
|
|
567
|
+
const heartbeat = parseHeartbeatMeta(resultText)
|
|
568
|
+
const plan = parseMainLoopPlan(resultText)
|
|
569
|
+
const review = parseMainLoopReview(resultText)
|
|
570
|
+
const messageGoal = parseGoalContractFromText(input.message || '')
|
|
571
|
+
const nowTs = now()
|
|
572
|
+
|
|
573
|
+
state.goalContract = mergeGoalContracts(state.goalContract, messageGoal)
|
|
574
|
+
if (!state.goal) state.goal = cleanMultiline(input.message, 900)
|
|
575
|
+
if (heartbeat?.goal) state.goal = heartbeat.goal
|
|
576
|
+
if (heartbeat?.summary) state.summary = heartbeat.summary
|
|
577
|
+
if (heartbeat?.nextAction) state.nextAction = heartbeat.nextAction
|
|
578
|
+
if (heartbeat?.status) state.status = heartbeat.status
|
|
579
|
+
|
|
580
|
+
if (plan?.steps?.length) state.planSteps = plan.steps
|
|
581
|
+
if (plan?.current_step) state.currentPlanStep = plan.current_step
|
|
582
|
+
if (plan) state.lastPlannedAt = nowTs
|
|
583
|
+
|
|
584
|
+
if (review?.note) state.reviewNote = review.note
|
|
585
|
+
if (typeof review?.confidence === 'number') state.reviewConfidence = review.confidence
|
|
586
|
+
if (review) state.lastReviewedAt = nowTs
|
|
587
|
+
|
|
588
|
+
if (toolNames.length > 0) {
|
|
589
|
+
appendWorkingMemory(state, `Used tools: ${toolNames.join(', ')}`)
|
|
590
|
+
state.momentumScore = Math.min(10, state.momentumScore + 1)
|
|
591
|
+
} else if (persistedText && !/^HEARTBEAT_OK$/i.test(persistedText) && !/^NO_MESSAGE$/i.test(persistedText)) {
|
|
592
|
+
state.momentumScore = Math.min(10, state.momentumScore + 1)
|
|
593
|
+
} else {
|
|
594
|
+
state.momentumScore = Math.max(-10, state.momentumScore - 1)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (persistedText && !/^HEARTBEAT_OK$/i.test(persistedText) && !/^NO_MESSAGE$/i.test(persistedText)) {
|
|
598
|
+
state.summary = cleanText(persistedText, 1000) || state.summary
|
|
599
|
+
appendTimeline(state, input.source || 'run', persistedText, input.error ? 'blocked' : state.status)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (input.error) {
|
|
603
|
+
state.status = 'blocked'
|
|
604
|
+
appendTimeline(state, input.source || 'run', `Error: ${input.error}`, 'blocked')
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
state.lastTickAt = nowTs
|
|
608
|
+
state.updatedAt = nowTs
|
|
609
|
+
state.missionTokens += Math.max(0, Math.trunc((input.inputTokens || 0) + (input.outputTokens || 0)))
|
|
610
|
+
state.missionCostUsd += Math.max(0, Number(input.estimatedCost || 0))
|
|
611
|
+
state.metaMissCount = heartbeat || plan || review ? 0 : state.metaMissCount + 1
|
|
612
|
+
|
|
613
|
+
if (input.internal) {
|
|
614
|
+
state.pendingEvents = []
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const cleanedResult = persistedText.trim()
|
|
618
|
+
const waitingForExternal = extractWaitSignal(resultText, toolEvents)
|
|
619
|
+
const gotTerminalAck = /^HEARTBEAT_OK$/i.test(cleanedResult) || /^NO_MESSAGE$/i.test(cleanedResult)
|
|
620
|
+
const needsReplan = review?.needs_replan === true || ((review?.confidence ?? 1) < 0.45)
|
|
621
|
+
const limit = followupLimit()
|
|
622
|
+
|
|
623
|
+
let followup: MainLoopFollowupRequest | null = null
|
|
624
|
+
if (!input.internal || input.source === 'chat') {
|
|
625
|
+
state.followupChainCount = 0
|
|
626
|
+
} else if (input.error || waitingForExternal || gotTerminalAck) {
|
|
627
|
+
state.followupChainCount = 0
|
|
628
|
+
if (gotTerminalAck && state.status !== 'blocked') state.status = 'ok'
|
|
629
|
+
} else {
|
|
630
|
+
const shouldContinue = needsReplan || state.status === 'progress' || (!!state.nextAction && toolNames.length > 0)
|
|
631
|
+
if (shouldContinue && state.followupChainCount < limit) {
|
|
632
|
+
state.followupChainCount += 1
|
|
633
|
+
const message = needsReplan
|
|
634
|
+
? 'Replan from the latest outcome, then execute only the highest-value remaining step. Do not repeat completed work.'
|
|
635
|
+
: state.nextAction
|
|
636
|
+
? `Continue the objective. Resume from this next action: ${state.nextAction}`
|
|
637
|
+
: 'Continue the objective and finish the next highest-value remaining step.'
|
|
638
|
+
followup = {
|
|
639
|
+
message,
|
|
640
|
+
delayMs: DEFAULT_FOLLOWUP_DELAY_MS,
|
|
641
|
+
dedupeKey: `main-loop:${input.sessionId}:${state.followupChainCount}:${state.currentPlanStep || state.nextAction || 'continue'}`,
|
|
642
|
+
}
|
|
643
|
+
appendTimeline(state, 'followup', message, 'progress')
|
|
644
|
+
} else {
|
|
645
|
+
state.followupChainCount = 0
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
stateMap.set(input.sessionId, clampState(state))
|
|
650
|
+
return followup
|
|
106
651
|
}
|
|
@@ -18,12 +18,16 @@ test('docker smart deploy bundle uses official image and provider-specific metad
|
|
|
18
18
|
assert.equal(bundle.providerLabel, 'DigitalOcean')
|
|
19
19
|
assert.equal(bundle.endpoint, 'https://gateway.example.com/v1')
|
|
20
20
|
assert.equal(bundle.wsUrl, 'wss://gateway.example.com')
|
|
21
|
+
assert.equal(bundle.useCase, 'single-vps')
|
|
22
|
+
assert.equal(bundle.exposure, 'caddy')
|
|
21
23
|
assert.match(bundle.summary, /official OpenClaw Docker image/i)
|
|
22
24
|
assert.deepEqual(bundle.files.map((file) => file.name), [
|
|
23
25
|
'cloud-init.yaml',
|
|
24
26
|
'.env',
|
|
25
27
|
'docker-compose.yml',
|
|
26
28
|
'bootstrap.sh',
|
|
29
|
+
'docker-compose.proxy.yml',
|
|
30
|
+
'Caddyfile',
|
|
27
31
|
])
|
|
28
32
|
|
|
29
33
|
const envFile = bundle.files.find((file) => file.name === '.env')
|
|
@@ -36,6 +40,10 @@ test('docker smart deploy bundle uses official image and provider-specific metad
|
|
|
36
40
|
assert.match(cloudInit.content, /docker\.io/)
|
|
37
41
|
assert.match(cloudInit.content, /docker pull "\$\{OPENCLAW_IMAGE:-openclaw:latest\}"/)
|
|
38
42
|
assert.match(cloudInit.content, /\/opt\/openclaw\/docker-compose\.yml/)
|
|
43
|
+
|
|
44
|
+
const caddyfile = bundle.files.find((file) => file.name === 'Caddyfile')
|
|
45
|
+
assert.ok(caddyfile)
|
|
46
|
+
assert.match(caddyfile.content, /gateway\.example\.com/)
|
|
39
47
|
})
|
|
40
48
|
|
|
41
49
|
test('render bundle stays aligned with the official repo flow', () => {
|