@swarmclawai/swarmclaw 1.5.47 → 1.5.49
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 +15 -0
- package/package.json +1 -1
- package/skills/swarmclaw/SKILL.md +8 -0
- package/src/app/api/missions/[id]/control/route.ts +57 -0
- package/src/app/api/missions/[id]/events/route.ts +21 -0
- package/src/app/api/missions/[id]/reports/route.ts +33 -0
- package/src/app/api/missions/[id]/route.ts +82 -0
- package/src/app/api/missions/route.test.ts +170 -0
- package/src/app/api/missions/route.ts +58 -0
- package/src/app/missions/page.tsx +635 -0
- package/src/cli/index.js +15 -0
- package/src/cli/spec.js +14 -0
- package/src/components/layout/sidebar-rail.tsx +8 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +22 -0
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/view-constants.ts +10 -1
- package/src/lib/server/missions/mission-budget-hook.ts +38 -0
- package/src/lib/server/missions/mission-report-builder.test.ts +106 -0
- package/src/lib/server/missions/mission-report-builder.ts +158 -0
- package/src/lib/server/missions/mission-repository.test.ts +171 -0
- package/src/lib/server/missions/mission-repository.ts +137 -0
- package/src/lib/server/missions/mission-scheduler.ts +107 -0
- package/src/lib/server/missions/mission-service.test.ts +201 -0
- package/src/lib/server/missions/mission-service.ts +299 -0
- package/src/lib/server/runtime/heartbeat-service.ts +5 -0
- package/src/lib/server/runtime/session-run-manager/enqueue.ts +9 -0
- package/src/lib/server/storage-normalization.ts +145 -1
- package/src/lib/server/storage.ts +29 -0
- package/src/types/index.ts +1 -0
- package/src/types/mission.ts +115 -0
- package/src/types/session.ts +3 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus } from '@/types'
|
|
3
|
+
import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
|
|
4
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
5
|
+
import { log } from '@/lib/server/logger'
|
|
6
|
+
import {
|
|
7
|
+
appendMissionEvent,
|
|
8
|
+
appendMissionMilestone,
|
|
9
|
+
getMission,
|
|
10
|
+
listMissions,
|
|
11
|
+
patchMission,
|
|
12
|
+
upsertMission,
|
|
13
|
+
} from './mission-repository'
|
|
14
|
+
|
|
15
|
+
const TAG = 'mission-service'
|
|
16
|
+
|
|
17
|
+
interface MissionRuntimeCache {
|
|
18
|
+
sessionToMission: Map<string, string>
|
|
19
|
+
hydratedAt: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const runtime = hmrSingleton<MissionRuntimeCache>('mission_runtime_state', () => ({
|
|
23
|
+
sessionToMission: new Map(),
|
|
24
|
+
hydratedAt: 0,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
function hydrateSessionMap(force = false): void {
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
if (!force && now - runtime.hydratedAt < 30_000) return
|
|
30
|
+
const missions = listMissions()
|
|
31
|
+
const next = new Map<string, string>()
|
|
32
|
+
for (const m of missions) {
|
|
33
|
+
if (m.status === 'running' || m.status === 'paused') {
|
|
34
|
+
if (m.rootSessionId) next.set(m.rootSessionId, m.id)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
runtime.sessionToMission = next
|
|
38
|
+
runtime.hydratedAt = now
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getMissionIdForSession(sessionId: string): string | null {
|
|
42
|
+
if (!sessionId) return null
|
|
43
|
+
hydrateSessionMap(false)
|
|
44
|
+
return runtime.sessionToMission.get(sessionId) ?? null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function trackSessionMissionMapping(sessionId: string, missionId: string | null): void {
|
|
48
|
+
if (!sessionId) return
|
|
49
|
+
if (missionId) runtime.sessionToMission.set(sessionId, missionId)
|
|
50
|
+
else runtime.sessionToMission.delete(sessionId)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CreateMissionInput {
|
|
54
|
+
title: string
|
|
55
|
+
goal: string
|
|
56
|
+
successCriteria?: string[]
|
|
57
|
+
rootSessionId: string
|
|
58
|
+
agentIds?: string[]
|
|
59
|
+
budget?: Partial<MissionBudget>
|
|
60
|
+
reportSchedule?: MissionReportSchedule | null
|
|
61
|
+
reportConnectorIds?: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function newMissionId(): string {
|
|
65
|
+
return `mi_${crypto.randomBytes(8).toString('hex')}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sanitizeBudget(input: Partial<MissionBudget> = {}): MissionBudget {
|
|
69
|
+
const pick = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
70
|
+
const warn = Array.isArray(input.warnAtFractions) && input.warnAtFractions.length
|
|
71
|
+
? input.warnAtFractions.filter((f) => typeof f === 'number' && f > 0 && f < 1)
|
|
72
|
+
: DEFAULT_MISSION_WARN_FRACTIONS
|
|
73
|
+
return {
|
|
74
|
+
maxUsd: pick(input.maxUsd),
|
|
75
|
+
maxTokens: pick(input.maxTokens),
|
|
76
|
+
maxToolCalls: pick(input.maxToolCalls),
|
|
77
|
+
maxWallclockSec: pick(input.maxWallclockSec),
|
|
78
|
+
maxTurns: pick(input.maxTurns),
|
|
79
|
+
warnAtFractions: warn.length ? warn : DEFAULT_MISSION_WARN_FRACTIONS,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createMission(input: CreateMissionInput): Mission {
|
|
84
|
+
const now = Date.now()
|
|
85
|
+
const mission: Mission = {
|
|
86
|
+
id: newMissionId(),
|
|
87
|
+
title: input.title.trim(),
|
|
88
|
+
goal: input.goal.trim(),
|
|
89
|
+
successCriteria: (input.successCriteria ?? []).map((s) => s.trim()).filter(Boolean),
|
|
90
|
+
rootSessionId: input.rootSessionId,
|
|
91
|
+
agentIds: input.agentIds ?? [],
|
|
92
|
+
status: 'draft',
|
|
93
|
+
budget: sanitizeBudget(input.budget),
|
|
94
|
+
usage: {
|
|
95
|
+
usdSpent: 0,
|
|
96
|
+
tokensUsed: 0,
|
|
97
|
+
toolCallsUsed: 0,
|
|
98
|
+
turnsRun: 0,
|
|
99
|
+
wallclockMsElapsed: 0,
|
|
100
|
+
startedAt: null,
|
|
101
|
+
lastUpdatedAt: now,
|
|
102
|
+
warnFractionsHit: [],
|
|
103
|
+
},
|
|
104
|
+
milestones: [],
|
|
105
|
+
reportSchedule: input.reportSchedule ?? null,
|
|
106
|
+
reportConnectorIds: input.reportConnectorIds ?? [],
|
|
107
|
+
createdAt: now,
|
|
108
|
+
updatedAt: now,
|
|
109
|
+
}
|
|
110
|
+
upsertMission(mission)
|
|
111
|
+
log.info(TAG, `Created mission ${mission.id} (goal: ${mission.goal.slice(0, 80)})`)
|
|
112
|
+
return mission
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyStatusTransition(
|
|
116
|
+
id: string,
|
|
117
|
+
next: MissionStatus,
|
|
118
|
+
opts: { reason?: string; milestoneSummary?: string; endReason?: string } = {},
|
|
119
|
+
): Mission | null {
|
|
120
|
+
const updated = patchMission(id, (current) => {
|
|
121
|
+
if (!current) return null
|
|
122
|
+
const patch: Mission = { ...current, status: next }
|
|
123
|
+
if (next === 'running' && !current.usage.startedAt) {
|
|
124
|
+
patch.usage = { ...current.usage, startedAt: Date.now(), lastUpdatedAt: Date.now() }
|
|
125
|
+
patch.startedAt = patch.startedAt ?? Date.now()
|
|
126
|
+
}
|
|
127
|
+
if (next === 'completed' || next === 'failed' || next === 'cancelled' || next === 'budget_exhausted') {
|
|
128
|
+
patch.endedAt = Date.now()
|
|
129
|
+
patch.endReason = opts.endReason ?? opts.reason ?? null
|
|
130
|
+
}
|
|
131
|
+
return patch
|
|
132
|
+
})
|
|
133
|
+
if (!updated) return null
|
|
134
|
+
if (opts.milestoneSummary) {
|
|
135
|
+
appendMissionMilestone(id, {
|
|
136
|
+
kind: mapStatusToMilestoneKind(next),
|
|
137
|
+
summary: opts.milestoneSummary,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
trackSessionMissionMapping(
|
|
141
|
+
updated.rootSessionId,
|
|
142
|
+
next === 'running' || next === 'paused' ? id : null,
|
|
143
|
+
)
|
|
144
|
+
return getMission(id)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mapStatusToMilestoneKind(
|
|
148
|
+
status: MissionStatus,
|
|
149
|
+
): 'started' | 'paused' | 'resumed' | 'completed' | 'failed' | 'cancelled' | 'budget_hit' {
|
|
150
|
+
switch (status) {
|
|
151
|
+
case 'running': return 'started'
|
|
152
|
+
case 'paused': return 'paused'
|
|
153
|
+
case 'completed': return 'completed'
|
|
154
|
+
case 'failed': return 'failed'
|
|
155
|
+
case 'cancelled': return 'cancelled'
|
|
156
|
+
case 'budget_exhausted': return 'budget_hit'
|
|
157
|
+
default: return 'started'
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function startMission(id: string): Mission | null {
|
|
162
|
+
const current = getMission(id)
|
|
163
|
+
if (!current) return null
|
|
164
|
+
if (current.status === 'running') return current
|
|
165
|
+
const next = current.status === 'paused' ? 'running' : 'running'
|
|
166
|
+
const summary = current.status === 'paused' ? 'Mission resumed' : 'Mission started'
|
|
167
|
+
const updated = applyStatusTransition(id, next, { milestoneSummary: summary })
|
|
168
|
+
if (updated && current.status === 'paused') {
|
|
169
|
+
appendMissionMilestone(id, { kind: 'resumed', summary: 'Mission resumed' })
|
|
170
|
+
}
|
|
171
|
+
return updated
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function pauseMission(id: string, reason?: string): Mission | null {
|
|
175
|
+
const current = getMission(id)
|
|
176
|
+
if (!current || current.status !== 'running') return current
|
|
177
|
+
return applyStatusTransition(id, 'paused', {
|
|
178
|
+
reason,
|
|
179
|
+
milestoneSummary: reason ?? 'Mission paused',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function cancelMission(id: string, reason?: string): Mission | null {
|
|
184
|
+
const current = getMission(id)
|
|
185
|
+
if (!current) return null
|
|
186
|
+
if (current.status === 'completed' || current.status === 'cancelled') return current
|
|
187
|
+
return applyStatusTransition(id, 'cancelled', {
|
|
188
|
+
endReason: reason,
|
|
189
|
+
milestoneSummary: reason ?? 'Mission cancelled',
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function completeMission(id: string, summary?: string): Mission | null {
|
|
194
|
+
const current = getMission(id)
|
|
195
|
+
if (!current) return null
|
|
196
|
+
if (current.status === 'completed') return current
|
|
197
|
+
return applyStatusTransition(id, 'completed', {
|
|
198
|
+
endReason: summary,
|
|
199
|
+
milestoneSummary: summary ?? 'Mission complete',
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function failMission(id: string, reason: string): Mission | null {
|
|
204
|
+
const current = getMission(id)
|
|
205
|
+
if (!current) return null
|
|
206
|
+
if (current.status === 'failed') return current
|
|
207
|
+
return applyStatusTransition(id, 'failed', {
|
|
208
|
+
endReason: reason,
|
|
209
|
+
milestoneSummary: reason,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function markBudgetExhausted(id: string, reason: string): Mission | null {
|
|
214
|
+
const current = getMission(id)
|
|
215
|
+
if (!current) return null
|
|
216
|
+
if (current.status === 'budget_exhausted' || current.status === 'completed') return current
|
|
217
|
+
appendMissionEvent(id, 'budget_exhausted', { reason })
|
|
218
|
+
return applyStatusTransition(id, 'budget_exhausted', {
|
|
219
|
+
endReason: reason,
|
|
220
|
+
milestoneSummary: reason,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface BudgetVerdict {
|
|
225
|
+
allow: boolean
|
|
226
|
+
reason?: string
|
|
227
|
+
hitCap?: 'usd' | 'tokens' | 'toolCalls' | 'wallclock' | 'turns'
|
|
228
|
+
warningFraction?: number
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function evaluateMissionBudget(mission: Mission, at: number = Date.now()): BudgetVerdict {
|
|
232
|
+
const { budget, usage } = mission
|
|
233
|
+
if (budget.maxUsd != null && usage.usdSpent >= budget.maxUsd) {
|
|
234
|
+
return { allow: false, hitCap: 'usd', reason: `USD budget exhausted (${usage.usdSpent.toFixed(4)} >= ${budget.maxUsd})` }
|
|
235
|
+
}
|
|
236
|
+
if (budget.maxTokens != null && usage.tokensUsed >= budget.maxTokens) {
|
|
237
|
+
return { allow: false, hitCap: 'tokens', reason: `Token budget exhausted (${usage.tokensUsed} >= ${budget.maxTokens})` }
|
|
238
|
+
}
|
|
239
|
+
if (budget.maxToolCalls != null && usage.toolCallsUsed >= budget.maxToolCalls) {
|
|
240
|
+
return { allow: false, hitCap: 'toolCalls', reason: `Tool-call budget exhausted (${usage.toolCallsUsed} >= ${budget.maxToolCalls})` }
|
|
241
|
+
}
|
|
242
|
+
if (budget.maxTurns != null && usage.turnsRun >= budget.maxTurns) {
|
|
243
|
+
return { allow: false, hitCap: 'turns', reason: `Max turns reached (${usage.turnsRun} >= ${budget.maxTurns})` }
|
|
244
|
+
}
|
|
245
|
+
if (budget.maxWallclockSec != null && usage.startedAt != null) {
|
|
246
|
+
const elapsedSec = (at - usage.startedAt) / 1000
|
|
247
|
+
if (elapsedSec >= budget.maxWallclockSec) {
|
|
248
|
+
return { allow: false, hitCap: 'wallclock', reason: `Wallclock budget exhausted (${Math.round(elapsedSec)}s >= ${budget.maxWallclockSec}s)` }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Warn thresholds, highest unfired fraction first
|
|
252
|
+
const unfired = (budget.warnAtFractions ?? [])
|
|
253
|
+
.filter((f) => !usage.warnFractionsHit.includes(f))
|
|
254
|
+
.sort((a, b) => b - a)
|
|
255
|
+
for (const fraction of unfired) {
|
|
256
|
+
const tripped = (
|
|
257
|
+
(budget.maxUsd != null && usage.usdSpent >= budget.maxUsd * fraction)
|
|
258
|
+
|| (budget.maxTokens != null && usage.tokensUsed >= budget.maxTokens * fraction)
|
|
259
|
+
|| (budget.maxTurns != null && usage.turnsRun >= budget.maxTurns * fraction)
|
|
260
|
+
|| (
|
|
261
|
+
budget.maxWallclockSec != null && usage.startedAt != null
|
|
262
|
+
&& (at - usage.startedAt) / 1000 >= budget.maxWallclockSec * fraction
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if (tripped) return { allow: true, warningFraction: fraction }
|
|
266
|
+
}
|
|
267
|
+
return { allow: true }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function recordTurnUsage(
|
|
271
|
+
missionId: string,
|
|
272
|
+
delta: { usdDelta?: number; tokensDelta?: number; toolCallsDelta?: number; turnsDelta?: number },
|
|
273
|
+
): Mission | null {
|
|
274
|
+
let firedWarn: number | null = null
|
|
275
|
+
const result = patchMission(missionId, (current) => {
|
|
276
|
+
if (!current) return null
|
|
277
|
+
const now = Date.now()
|
|
278
|
+
const nextUsage = { ...current.usage }
|
|
279
|
+
if (typeof delta.usdDelta === 'number' && Number.isFinite(delta.usdDelta)) nextUsage.usdSpent += delta.usdDelta
|
|
280
|
+
if (typeof delta.tokensDelta === 'number' && Number.isFinite(delta.tokensDelta)) nextUsage.tokensUsed += delta.tokensDelta
|
|
281
|
+
if (typeof delta.toolCallsDelta === 'number' && Number.isFinite(delta.toolCallsDelta)) nextUsage.toolCallsUsed += delta.toolCallsDelta
|
|
282
|
+
if (typeof delta.turnsDelta === 'number' && Number.isFinite(delta.turnsDelta)) nextUsage.turnsRun += delta.turnsDelta
|
|
283
|
+
if (nextUsage.startedAt) nextUsage.wallclockMsElapsed = now - nextUsage.startedAt
|
|
284
|
+
nextUsage.lastUpdatedAt = now
|
|
285
|
+
const verdict = evaluateMissionBudget({ ...current, usage: nextUsage }, now)
|
|
286
|
+
if (verdict.warningFraction != null) {
|
|
287
|
+
firedWarn = verdict.warningFraction
|
|
288
|
+
nextUsage.warnFractionsHit = [...nextUsage.warnFractionsHit, verdict.warningFraction]
|
|
289
|
+
}
|
|
290
|
+
return { ...current, usage: nextUsage }
|
|
291
|
+
})
|
|
292
|
+
if (result && firedWarn != null) {
|
|
293
|
+
appendMissionMilestone(missionId, {
|
|
294
|
+
kind: 'budget_warn',
|
|
295
|
+
summary: `Budget ${Math.round(firedWarn * 100)}% reached`,
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
@@ -31,6 +31,7 @@ import { logExecution } from '@/lib/server/execution-log'
|
|
|
31
31
|
import { createNotification } from '@/lib/server/create-notification'
|
|
32
32
|
import { WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
33
33
|
import { buildSwarmFeedHeartbeatGuidance } from '@/lib/server/swarmfeed-runtime'
|
|
34
|
+
import { runMissionScheduler } from '@/lib/server/missions/mission-scheduler'
|
|
34
35
|
|
|
35
36
|
const HEARTBEAT_TICK_MS = 60_000
|
|
36
37
|
const MAX_CONCURRENT_HEARTBEATS = 1
|
|
@@ -576,6 +577,10 @@ export async function tickHeartbeats() {
|
|
|
576
577
|
const globalOngoing = shouldRunHeartbeats(settings)
|
|
577
578
|
|
|
578
579
|
const now = Date.now()
|
|
580
|
+
// Mission scheduler runs every tick, independent of heartbeat active window,
|
|
581
|
+
// so wallclock budgets and periodic reports still fire overnight.
|
|
582
|
+
try { runMissionScheduler() } catch (error) { log.warn('heartbeat', 'mission scheduler tick failed', error) }
|
|
583
|
+
|
|
579
584
|
const nowDate = new Date(now)
|
|
580
585
|
if (!inActiveWindow(nowDate, settings.heartbeatActiveStart, settings.heartbeatActiveEnd, settings.heartbeatTimezone)) {
|
|
581
586
|
return
|
|
@@ -8,6 +8,7 @@ import { getEnabledToolIds } from '@/lib/capability-selection'
|
|
|
8
8
|
import { isAllEstopEngaged, isAutonomyEstopEngaged } from '@/lib/server/runtime/estop'
|
|
9
9
|
import { isRestartRecoverableSource } from '@/lib/server/runtime/run-ledger'
|
|
10
10
|
import { getActiveSessionProcess } from '@/lib/server/runtime/runtime-state'
|
|
11
|
+
import { checkMissionBudgetForSession } from '@/lib/server/missions/mission-budget-hook'
|
|
11
12
|
|
|
12
13
|
import { cancelPendingForSession } from './cancellation'
|
|
13
14
|
import {
|
|
@@ -49,6 +50,7 @@ const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli'
|
|
|
49
50
|
type SessionToolConfig = {
|
|
50
51
|
tools?: string[] | null
|
|
51
52
|
extensions?: string[] | null
|
|
53
|
+
missionId?: string | null
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
function computeEffectiveRunTimeoutMs(
|
|
@@ -114,6 +116,13 @@ export function enqueueSessionRun(
|
|
|
114
116
|
if (isAutonomyEstopEngaged() && isAutonomyManagedEnqueue(source, internal)) {
|
|
115
117
|
throw new Error(`Autonomy estop is engaged. New ${source} runs are paused.`)
|
|
116
118
|
}
|
|
119
|
+
if (isAutonomyManagedEnqueue(source, internal)) {
|
|
120
|
+
const sessionForMission = getSession(input.sessionId) as SessionToolConfig | null
|
|
121
|
+
const missionVerdict = checkMissionBudgetForSession(sessionForMission?.missionId ?? null)
|
|
122
|
+
if (!missionVerdict.allow) {
|
|
123
|
+
throw new Error(`Mission halted: ${missionVerdict.reason ?? 'budget exhausted'}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
117
126
|
const executionKey = typeof input.executionGroupKey === 'string' && input.executionGroupKey.trim()
|
|
118
127
|
? input.executionGroupKey.trim()
|
|
119
128
|
: executionKeyForSession(input.sessionId)
|
|
@@ -355,6 +355,135 @@ function normalizeStoredMissionEventRecord(value: unknown): unknown {
|
|
|
355
355
|
return event
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
// --- Agent Mission normalizers (autonomous goal-driven runs, v1.5.49+) ---
|
|
359
|
+
|
|
360
|
+
const VALID_AGENT_MISSION_STATUSES = new Set([
|
|
361
|
+
'draft',
|
|
362
|
+
'running',
|
|
363
|
+
'paused',
|
|
364
|
+
'completed',
|
|
365
|
+
'failed',
|
|
366
|
+
'cancelled',
|
|
367
|
+
'budget_exhausted',
|
|
368
|
+
])
|
|
369
|
+
|
|
370
|
+
const VALID_AGENT_MISSION_REPORT_FORMATS = new Set(['markdown', 'slack', 'discord', 'email', 'audio'])
|
|
371
|
+
|
|
372
|
+
function normalizeFiniteNumber(value: unknown): number | null {
|
|
373
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
|
374
|
+
return value
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function normalizeNonNegativeNumber(value: unknown, fallback: number): number {
|
|
378
|
+
const n = normalizeFiniteNumber(value)
|
|
379
|
+
if (n == null || n < 0) return fallback
|
|
380
|
+
return n
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function normalizeStoredAgentMissionRecord(value: unknown): unknown {
|
|
384
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
385
|
+
const mission = value as StoredObject
|
|
386
|
+
|
|
387
|
+
const status = typeof mission.status === 'string' ? mission.status.trim().toLowerCase() : ''
|
|
388
|
+
mission.status = VALID_AGENT_MISSION_STATUSES.has(status) ? status : 'draft'
|
|
389
|
+
|
|
390
|
+
mission.successCriteria = normalizeStoredStringArray(mission.successCriteria, 64)
|
|
391
|
+
mission.agentIds = normalizeStoredStringArray(mission.agentIds, 32)
|
|
392
|
+
mission.reportConnectorIds = normalizeStoredStringArray(mission.reportConnectorIds, 16)
|
|
393
|
+
|
|
394
|
+
const budget = mission.budget && typeof mission.budget === 'object' && !Array.isArray(mission.budget)
|
|
395
|
+
? mission.budget as StoredObject
|
|
396
|
+
: {}
|
|
397
|
+
budget.maxUsd = normalizeFiniteNumber(budget.maxUsd)
|
|
398
|
+
budget.maxTokens = normalizeFiniteNumber(budget.maxTokens)
|
|
399
|
+
budget.maxToolCalls = normalizeFiniteNumber(budget.maxToolCalls)
|
|
400
|
+
budget.maxWallclockSec = normalizeFiniteNumber(budget.maxWallclockSec)
|
|
401
|
+
budget.maxTurns = normalizeFiniteNumber(budget.maxTurns)
|
|
402
|
+
if (!Array.isArray(budget.warnAtFractions)) {
|
|
403
|
+
budget.warnAtFractions = [0.5, 0.8, 0.95]
|
|
404
|
+
} else {
|
|
405
|
+
budget.warnAtFractions = (budget.warnAtFractions as unknown[])
|
|
406
|
+
.map((entry) => normalizeFiniteNumber(entry))
|
|
407
|
+
.filter((entry): entry is number => entry != null && entry > 0 && entry < 1)
|
|
408
|
+
if ((budget.warnAtFractions as number[]).length === 0) {
|
|
409
|
+
budget.warnAtFractions = [0.5, 0.8, 0.95]
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
mission.budget = budget
|
|
413
|
+
|
|
414
|
+
const usage = mission.usage && typeof mission.usage === 'object' && !Array.isArray(mission.usage)
|
|
415
|
+
? mission.usage as StoredObject
|
|
416
|
+
: {}
|
|
417
|
+
usage.usdSpent = normalizeNonNegativeNumber(usage.usdSpent, 0)
|
|
418
|
+
usage.tokensUsed = normalizeNonNegativeNumber(usage.tokensUsed, 0)
|
|
419
|
+
usage.toolCallsUsed = normalizeNonNegativeNumber(usage.toolCallsUsed, 0)
|
|
420
|
+
usage.turnsRun = normalizeNonNegativeNumber(usage.turnsRun, 0)
|
|
421
|
+
usage.wallclockMsElapsed = normalizeNonNegativeNumber(usage.wallclockMsElapsed, 0)
|
|
422
|
+
usage.startedAt = normalizeFiniteNumber(usage.startedAt)
|
|
423
|
+
usage.lastUpdatedAt = normalizeNonNegativeNumber(usage.lastUpdatedAt, 0)
|
|
424
|
+
if (!Array.isArray(usage.warnFractionsHit)) {
|
|
425
|
+
usage.warnFractionsHit = []
|
|
426
|
+
} else {
|
|
427
|
+
usage.warnFractionsHit = (usage.warnFractionsHit as unknown[])
|
|
428
|
+
.map((entry) => normalizeFiniteNumber(entry))
|
|
429
|
+
.filter((entry): entry is number => entry != null)
|
|
430
|
+
}
|
|
431
|
+
mission.usage = usage
|
|
432
|
+
|
|
433
|
+
if (!Array.isArray(mission.milestones)) mission.milestones = []
|
|
434
|
+
// Cap the stored tail so missions don't balloon
|
|
435
|
+
if ((mission.milestones as unknown[]).length > 200) {
|
|
436
|
+
mission.milestones = (mission.milestones as unknown[]).slice(-200)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const reportSchedule = mission.reportSchedule
|
|
440
|
+
&& typeof mission.reportSchedule === 'object'
|
|
441
|
+
&& !Array.isArray(mission.reportSchedule)
|
|
442
|
+
? mission.reportSchedule as StoredObject
|
|
443
|
+
: null
|
|
444
|
+
if (reportSchedule) {
|
|
445
|
+
const format = typeof reportSchedule.format === 'string' ? reportSchedule.format.trim().toLowerCase() : ''
|
|
446
|
+
reportSchedule.format = VALID_AGENT_MISSION_REPORT_FORMATS.has(format) ? format : 'markdown'
|
|
447
|
+
reportSchedule.intervalSec = normalizeNonNegativeNumber(reportSchedule.intervalSec, 3600)
|
|
448
|
+
reportSchedule.enabled = reportSchedule.enabled !== false
|
|
449
|
+
reportSchedule.lastReportAt = normalizeFiniteNumber(reportSchedule.lastReportAt)
|
|
450
|
+
mission.reportSchedule = reportSchedule
|
|
451
|
+
} else if (mission.reportSchedule !== undefined) {
|
|
452
|
+
mission.reportSchedule = null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (typeof mission.createdAt !== 'number') mission.createdAt = Date.now()
|
|
456
|
+
if (typeof mission.updatedAt !== 'number') mission.updatedAt = mission.createdAt as number
|
|
457
|
+
if (mission.startedAt === undefined) mission.startedAt = null
|
|
458
|
+
if (mission.endedAt === undefined) mission.endedAt = null
|
|
459
|
+
if (mission.endReason === undefined) mission.endReason = null
|
|
460
|
+
|
|
461
|
+
return mission
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function normalizeStoredMissionReportRecord(value: unknown): unknown {
|
|
465
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
466
|
+
const report = value as StoredObject
|
|
467
|
+
const format = typeof report.format === 'string' ? report.format.trim().toLowerCase() : ''
|
|
468
|
+
report.format = VALID_AGENT_MISSION_REPORT_FORMATS.has(format) ? format : 'markdown'
|
|
469
|
+
if (!Array.isArray(report.highlights)) report.highlights = []
|
|
470
|
+
if (!Array.isArray(report.deliveredTo)) report.deliveredTo = []
|
|
471
|
+
if (typeof report.body !== 'string') report.body = ''
|
|
472
|
+
if (typeof report.title !== 'string') report.title = 'Mission report'
|
|
473
|
+
return report
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function normalizeStoredAgentMissionEventRecord(value: unknown): unknown {
|
|
477
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
478
|
+
const event = value as StoredObject
|
|
479
|
+
if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) {
|
|
480
|
+
event.payload = {}
|
|
481
|
+
}
|
|
482
|
+
if (typeof event.kind !== 'string' || !event.kind.trim()) event.kind = 'unknown'
|
|
483
|
+
if (typeof event.at !== 'number' || !Number.isFinite(event.at)) event.at = Date.now()
|
|
484
|
+
return event
|
|
485
|
+
}
|
|
486
|
+
|
|
358
487
|
// --- Delegation job normalizer ---
|
|
359
488
|
|
|
360
489
|
function normalizeStoredDelegationJobRecord(value: unknown): unknown {
|
|
@@ -428,7 +557,7 @@ export function normalizeStoredRecord(
|
|
|
428
557
|
value: unknown,
|
|
429
558
|
loadItem: CollectionItemLoader,
|
|
430
559
|
): NormalizationResult {
|
|
431
|
-
// Tables with no normalization
|
|
560
|
+
// Tables with no normalization, early exit.
|
|
432
561
|
if (
|
|
433
562
|
table !== 'agents' && table !== 'tasks' && table !== 'missions'
|
|
434
563
|
&& table !== 'mission_events' && table !== 'delegation_jobs'
|
|
@@ -436,6 +565,9 @@ export function normalizeStoredRecord(
|
|
|
436
565
|
&& table !== 'provider_configs'
|
|
437
566
|
&& table !== 'runtime_runs' && table !== 'runtime_run_events'
|
|
438
567
|
&& table !== 'wallets'
|
|
568
|
+
&& table !== 'agent_missions'
|
|
569
|
+
&& table !== 'mission_reports'
|
|
570
|
+
&& table !== 'agent_mission_events'
|
|
439
571
|
) {
|
|
440
572
|
return { value, changed: false }
|
|
441
573
|
}
|
|
@@ -590,6 +722,18 @@ function normalizeStoredRecordInner(
|
|
|
590
722
|
return normalizeStoredMissionEventRecord(value)
|
|
591
723
|
}
|
|
592
724
|
|
|
725
|
+
if (table === 'agent_missions') {
|
|
726
|
+
return normalizeStoredAgentMissionRecord(value)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (table === 'mission_reports') {
|
|
730
|
+
return normalizeStoredMissionReportRecord(value)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (table === 'agent_mission_events') {
|
|
734
|
+
return normalizeStoredAgentMissionEventRecord(value)
|
|
735
|
+
}
|
|
736
|
+
|
|
593
737
|
if (table === 'delegation_jobs') {
|
|
594
738
|
return normalizeStoredDelegationJobRecord(value)
|
|
595
739
|
}
|
|
@@ -26,6 +26,9 @@ import type {
|
|
|
26
26
|
KnowledgeSource,
|
|
27
27
|
LearnedSkill,
|
|
28
28
|
Message,
|
|
29
|
+
Mission,
|
|
30
|
+
MissionEvent,
|
|
31
|
+
MissionReport,
|
|
29
32
|
ProtocolTemplate,
|
|
30
33
|
ProtocolRun,
|
|
31
34
|
ProtocolRunEvent,
|
|
@@ -176,6 +179,9 @@ const COLLECTIONS = [
|
|
|
176
179
|
'wallets',
|
|
177
180
|
'wallet_transactions',
|
|
178
181
|
'goals',
|
|
182
|
+
'agent_missions',
|
|
183
|
+
'mission_reports',
|
|
184
|
+
'agent_mission_events',
|
|
179
185
|
] as const
|
|
180
186
|
|
|
181
187
|
export type StorageCollection = (typeof COLLECTIONS)[number]
|
|
@@ -1686,6 +1692,29 @@ export const loadGoal = goalsStore.loadItem
|
|
|
1686
1692
|
export const upsertGoal = goalsStore.upsert
|
|
1687
1693
|
export const deleteGoalItem = goalsStore.deleteItem
|
|
1688
1694
|
|
|
1695
|
+
// --- Agent Missions (autonomous goal-driven runs) ---
|
|
1696
|
+
const agentMissionsStore = createCollectionStore<Mission>('agent_missions', { ttlMs: 5_000 })
|
|
1697
|
+
export const loadAgentMissions = agentMissionsStore.load
|
|
1698
|
+
export const saveAgentMissions = agentMissionsStore.save
|
|
1699
|
+
export const loadAgentMission = agentMissionsStore.loadItem
|
|
1700
|
+
export const upsertAgentMission = agentMissionsStore.upsert
|
|
1701
|
+
export const patchAgentMission = agentMissionsStore.patch
|
|
1702
|
+
export const deleteAgentMission = agentMissionsStore.deleteItem
|
|
1703
|
+
|
|
1704
|
+
const missionReportsStore = createCollectionStore<MissionReport>('mission_reports')
|
|
1705
|
+
export const loadMissionReports = missionReportsStore.load
|
|
1706
|
+
export const saveMissionReports = missionReportsStore.save
|
|
1707
|
+
export const loadMissionReport = missionReportsStore.loadItem
|
|
1708
|
+
export const upsertMissionReport = missionReportsStore.upsert
|
|
1709
|
+
export const deleteMissionReport = missionReportsStore.deleteItem
|
|
1710
|
+
|
|
1711
|
+
const agentMissionEventsStore = createCollectionStore<MissionEvent>('agent_mission_events')
|
|
1712
|
+
export const loadAgentMissionEvents = agentMissionEventsStore.load
|
|
1713
|
+
export const saveAgentMissionEvents = agentMissionEventsStore.save
|
|
1714
|
+
export const loadAgentMissionEvent = agentMissionEventsStore.loadItem
|
|
1715
|
+
export const upsertAgentMissionEvent = agentMissionEventsStore.upsert
|
|
1716
|
+
export const deleteAgentMissionEvent = agentMissionEventsStore.deleteItem
|
|
1717
|
+
|
|
1689
1718
|
function legacyMissionStatusToWorkingStatus(value: unknown): 'idle' | 'progress' | 'blocked' | 'completed' {
|
|
1690
1719
|
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
|
|
1691
1720
|
if (normalized === 'achieved' || normalized === 'completed' || normalized === 'ok') return 'completed'
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type MissionStatus =
|
|
2
|
+
| 'draft'
|
|
3
|
+
| 'running'
|
|
4
|
+
| 'paused'
|
|
5
|
+
| 'completed'
|
|
6
|
+
| 'failed'
|
|
7
|
+
| 'cancelled'
|
|
8
|
+
| 'budget_exhausted'
|
|
9
|
+
|
|
10
|
+
export type MissionReportFormat = 'markdown' | 'slack' | 'discord' | 'email' | 'audio'
|
|
11
|
+
|
|
12
|
+
export type MissionMilestoneKind =
|
|
13
|
+
| 'started'
|
|
14
|
+
| 'budget_warn'
|
|
15
|
+
| 'budget_hit'
|
|
16
|
+
| 'check_in'
|
|
17
|
+
| 'subgoal_done'
|
|
18
|
+
| 'report_sent'
|
|
19
|
+
| 'paused'
|
|
20
|
+
| 'resumed'
|
|
21
|
+
| 'completed'
|
|
22
|
+
| 'failed'
|
|
23
|
+
| 'cancelled'
|
|
24
|
+
|
|
25
|
+
export interface MissionBudget {
|
|
26
|
+
maxUsd?: number | null
|
|
27
|
+
maxTokens?: number | null
|
|
28
|
+
maxToolCalls?: number | null
|
|
29
|
+
maxWallclockSec?: number | null
|
|
30
|
+
maxTurns?: number | null
|
|
31
|
+
warnAtFractions?: number[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MissionUsage {
|
|
35
|
+
usdSpent: number
|
|
36
|
+
tokensUsed: number
|
|
37
|
+
toolCallsUsed: number
|
|
38
|
+
turnsRun: number
|
|
39
|
+
wallclockMsElapsed: number
|
|
40
|
+
startedAt: number | null
|
|
41
|
+
lastUpdatedAt: number
|
|
42
|
+
warnFractionsHit: number[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MissionMilestone {
|
|
46
|
+
id: string
|
|
47
|
+
at: number
|
|
48
|
+
kind: MissionMilestoneKind
|
|
49
|
+
summary: string
|
|
50
|
+
evidence?: string[]
|
|
51
|
+
sessionId?: string | null
|
|
52
|
+
runId?: string | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MissionReportSchedule {
|
|
56
|
+
intervalSec: number
|
|
57
|
+
format: MissionReportFormat
|
|
58
|
+
enabled: boolean
|
|
59
|
+
lastReportAt?: number | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MissionReportDelivery {
|
|
63
|
+
connectorId?: string | null
|
|
64
|
+
channelId?: string | null
|
|
65
|
+
deliveredAt: number
|
|
66
|
+
status: 'ok' | 'error'
|
|
67
|
+
error?: string | null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MissionReport {
|
|
71
|
+
id: string
|
|
72
|
+
missionId: string
|
|
73
|
+
generatedAt: number
|
|
74
|
+
format: MissionReportFormat
|
|
75
|
+
fromAt: number
|
|
76
|
+
toAt: number
|
|
77
|
+
title: string
|
|
78
|
+
body: string
|
|
79
|
+
audioUrl?: string | null
|
|
80
|
+
deliveredTo: MissionReportDelivery[]
|
|
81
|
+
highlights: Array<{ kind: string; summary: string; evidenceRunId?: string | null }>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface MissionEvent {
|
|
85
|
+
id: string
|
|
86
|
+
missionId: string
|
|
87
|
+
at: number
|
|
88
|
+
kind: string
|
|
89
|
+
payload: Record<string, unknown>
|
|
90
|
+
sessionId?: string | null
|
|
91
|
+
runId?: string | null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Mission {
|
|
95
|
+
id: string
|
|
96
|
+
title: string
|
|
97
|
+
goal: string
|
|
98
|
+
successCriteria: string[]
|
|
99
|
+
rootSessionId: string
|
|
100
|
+
agentIds: string[]
|
|
101
|
+
status: MissionStatus
|
|
102
|
+
budget: MissionBudget
|
|
103
|
+
usage: MissionUsage
|
|
104
|
+
milestones: MissionMilestone[]
|
|
105
|
+
reportSchedule?: MissionReportSchedule | null
|
|
106
|
+
reportConnectorIds: string[]
|
|
107
|
+
createdAt: number
|
|
108
|
+
updatedAt: number
|
|
109
|
+
startedAt?: number | null
|
|
110
|
+
endedAt?: number | null
|
|
111
|
+
endReason?: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const DEFAULT_MISSION_WARN_FRACTIONS = [0.5, 0.8, 0.95]
|
|
115
|
+
export const MISSION_MILESTONE_TAIL_CAP = 200
|