@swarmclawai/swarmclaw 1.5.68 → 1.5.69
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 +9 -0
- package/package.json +2 -1
- package/src/app/api/runs/[id]/events/route.ts +10 -4
- package/src/app/api/runs/[id]/route.ts +6 -2
- package/src/app/api/runs/route.test.ts +84 -0
- package/src/app/api/runs/route.ts +41 -2
- package/src/components/agents/inspector-panel.tsx +2 -1
- package/src/components/chat/chat-area.tsx +57 -12
- package/src/components/chat/chat-header.tsx +20 -1
- package/src/components/schedules/schedule-console.tsx +148 -30
- package/src/lib/chat/new-session.test.ts +114 -0
- package/src/lib/chat/new-session.ts +146 -0
- package/src/lib/server/daemon/controller.test.ts +78 -0
- package/src/lib/server/daemon/controller.ts +50 -7
- package/src/lib/server/protocols/protocol-agent-turn.test.ts +164 -0
- package/src/lib/server/protocols/protocol-agent-turn.ts +119 -16
- package/src/lib/server/provider-endpoint.ts +0 -3
- package/src/lib/server/runs/unified-run-records.ts +91 -0
- package/src/lib/server/schedules/schedule-normalization.ts +4 -1
- package/src/lib/server/schedules/schedule-service.test.ts +73 -0
- package/src/lib/server/schedules/schedule-service.ts +10 -3
- package/src/lib/server/test-utils/run-with-temp-data-dir.ts +3 -1
|
@@ -25,7 +25,12 @@ import type {
|
|
|
25
25
|
} from '@/lib/server/daemon/types'
|
|
26
26
|
import { DATA_DIR } from '@/lib/server/data-dir'
|
|
27
27
|
import { loadEstopState } from '@/lib/server/runtime/estop'
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
getDaemonHealthSummary,
|
|
30
|
+
getDaemonStatus,
|
|
31
|
+
startDaemon,
|
|
32
|
+
stopDaemon,
|
|
33
|
+
} from '@/lib/server/runtime/daemon-state/core'
|
|
29
34
|
import { daemonAutostartEnvEnabled } from '@/lib/server/runtime/daemon-policy'
|
|
30
35
|
import {
|
|
31
36
|
releaseRuntimeLock,
|
|
@@ -166,6 +171,15 @@ type DaemonSnapshotResponse = {
|
|
|
166
171
|
healthSummary: DaemonHealthSummaryPayload
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
function getInProcessDaemonSnapshot(): DaemonSnapshotResponse | null {
|
|
175
|
+
const status = getDaemonStatus()
|
|
176
|
+
if (!status.running) return null
|
|
177
|
+
return {
|
|
178
|
+
status,
|
|
179
|
+
healthSummary: getDaemonHealthSummary() as DaemonHealthSummaryPayload,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
async function requestDaemon<T>(
|
|
170
184
|
metadata: DaemonAdminMetadata,
|
|
171
185
|
routePath: string,
|
|
@@ -352,18 +366,19 @@ export async function ensureDaemonProcessRunning(
|
|
|
352
366
|
source: string,
|
|
353
367
|
opts?: { manualStart?: boolean },
|
|
354
368
|
): Promise<boolean> {
|
|
355
|
-
// In dev mode, the daemon may already be running in-process (same Next.js server)
|
|
356
|
-
// without a daemon-admin.json file. Check in-process state first to avoid spawning
|
|
357
|
-
// a subprocess that fails to acquire the already-held lease.
|
|
358
|
-
const inProcessStatus = getDaemonStatus()
|
|
359
|
-
if (inProcessStatus.running) return false
|
|
360
|
-
|
|
361
369
|
const manualStart = opts?.manualStart === true
|
|
362
370
|
const record = loadDaemonStatusRecord()
|
|
363
371
|
if (loadEstopState().level !== 'none') return false
|
|
364
372
|
if (!manualStart && !daemonAutostartEnvEnabled()) return false
|
|
365
373
|
if (!manualStart && record.manualStopRequested) return false
|
|
366
374
|
|
|
375
|
+
const inProcessSnapshot = getInProcessDaemonSnapshot()
|
|
376
|
+
if (inProcessSnapshot) return false
|
|
377
|
+
|
|
378
|
+
const startedInProcess = startDaemon({ source, manualStart })
|
|
379
|
+
if (startedInProcess) return true
|
|
380
|
+
if (getInProcessDaemonSnapshot()) return false
|
|
381
|
+
|
|
367
382
|
const live = await getLiveDaemonSnapshot()
|
|
368
383
|
if (live?.status.running) return false
|
|
369
384
|
|
|
@@ -448,6 +463,28 @@ export async function stopDaemonProcess(opts?: {
|
|
|
448
463
|
const source = opts?.source || 'unknown'
|
|
449
464
|
const manualStop = opts?.manualStop === true
|
|
450
465
|
const metadata = readDaemonAdminMetadata()
|
|
466
|
+
const inProcessSnapshot = getInProcessDaemonSnapshot()
|
|
467
|
+
|
|
468
|
+
if (inProcessSnapshot && (!metadata || metadata.pid === process.pid || !isProcessRunning(metadata.pid))) {
|
|
469
|
+
await stopDaemon({ source, manualStop })
|
|
470
|
+
clearDaemonAdminMetadata()
|
|
471
|
+
patchDaemonStatusRecord((current) => ({
|
|
472
|
+
...current,
|
|
473
|
+
pid: null,
|
|
474
|
+
adminPort: null,
|
|
475
|
+
desiredState: 'stopped',
|
|
476
|
+
manualStopRequested: manualStop ? true : current.manualStopRequested,
|
|
477
|
+
stoppedAt: now(),
|
|
478
|
+
updatedAt: now(),
|
|
479
|
+
lastStopSource: source,
|
|
480
|
+
lastStatus: {
|
|
481
|
+
...getDaemonStatus(),
|
|
482
|
+
manualStopRequested: manualStop ? true : current.manualStopRequested,
|
|
483
|
+
},
|
|
484
|
+
lastHealthSummary: getDaemonHealthSummary() as DaemonHealthSummaryPayload,
|
|
485
|
+
}))
|
|
486
|
+
return true
|
|
487
|
+
}
|
|
451
488
|
|
|
452
489
|
if (!metadata || !isProcessRunning(metadata.pid)) {
|
|
453
490
|
clearDaemonAdminMetadata()
|
|
@@ -510,12 +547,16 @@ export async function stopDaemonProcess(opts?: {
|
|
|
510
547
|
}
|
|
511
548
|
|
|
512
549
|
export async function getDaemonStatusSnapshot(): Promise<DaemonStatusPayload> {
|
|
550
|
+
const inProcessSnapshot = getInProcessDaemonSnapshot()
|
|
551
|
+
if (inProcessSnapshot) return inProcessSnapshot.status
|
|
513
552
|
const live = await getLiveDaemonSnapshot()
|
|
514
553
|
if (live) return live.status
|
|
515
554
|
return buildFallbackStatus()
|
|
516
555
|
}
|
|
517
556
|
|
|
518
557
|
export async function getDaemonHealthSummarySnapshot(): Promise<DaemonHealthSummaryPayload> {
|
|
558
|
+
const inProcessSnapshot = getInProcessDaemonSnapshot()
|
|
559
|
+
if (inProcessSnapshot) return inProcessSnapshot.healthSummary
|
|
519
560
|
const live = await getLiveDaemonSnapshot()
|
|
520
561
|
if (live) return live.healthSummary
|
|
521
562
|
return buildFallbackHealthSummary()
|
|
@@ -523,6 +564,8 @@ export async function getDaemonHealthSummarySnapshot(): Promise<DaemonHealthSumm
|
|
|
523
564
|
|
|
524
565
|
export async function runDaemonHealthCheckViaAdmin(source: string): Promise<DaemonSnapshotResponse> {
|
|
525
566
|
await ensureDaemonProcessRunning(source, { manualStart: true })
|
|
567
|
+
const inProcessSnapshot = getInProcessDaemonSnapshot()
|
|
568
|
+
if (inProcessSnapshot) return inProcessSnapshot
|
|
526
569
|
const metadata = readDaemonAdminMetadata()
|
|
527
570
|
if (!metadata) {
|
|
528
571
|
return {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
4
|
+
|
|
5
|
+
test('defaultExecuteAgentTurn surfaces execution-log errors instead of returning a blank structured response', () => {
|
|
6
|
+
const output = runWithTempDataDir<{ error: string | null }>(`
|
|
7
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
8
|
+
const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
|
|
9
|
+
const protocolTurnMod = await import('./src/lib/server/protocols/protocol-agent-turn')
|
|
10
|
+
const streamMod = await import('./src/lib/server/chat-execution/stream-agent-chat')
|
|
11
|
+
const executionLogMod = await import('./src/lib/server/execution-log')
|
|
12
|
+
|
|
13
|
+
const storage = storageMod.default || storageMod
|
|
14
|
+
const protocols = protocolsMod.default || protocolsMod
|
|
15
|
+
const { defaultExecuteAgentTurn } = protocolTurnMod.default || protocolTurnMod
|
|
16
|
+
const { setStreamAgentChatForTest } = streamMod.default || streamMod
|
|
17
|
+
const { logExecution } = executionLogMod.default || executionLogMod
|
|
18
|
+
|
|
19
|
+
storage.upsertStoredItem('agents', 'agentA', {
|
|
20
|
+
id: 'agentA',
|
|
21
|
+
name: 'Agent A',
|
|
22
|
+
provider: 'openai',
|
|
23
|
+
model: 'gpt-4.1',
|
|
24
|
+
systemPrompt: 'test',
|
|
25
|
+
createdAt: 1,
|
|
26
|
+
updatedAt: 1,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const run = protocols.createProtocolRun({
|
|
30
|
+
title: 'Credential failure run',
|
|
31
|
+
templateId: 'single_agent_structured_run',
|
|
32
|
+
participantAgentIds: ['agentA'],
|
|
33
|
+
facilitatorAgentId: 'agentA',
|
|
34
|
+
autoStart: false,
|
|
35
|
+
}, { now: () => 1000 })
|
|
36
|
+
|
|
37
|
+
setStreamAgentChatForTest(async (opts) => {
|
|
38
|
+
logExecution(opts.session.id, 'error', 'Missing credentials. Please pass an apiKey.', {
|
|
39
|
+
agentId: opts.session.agentId,
|
|
40
|
+
})
|
|
41
|
+
return { fullText: '', finalResponse: '', toolEvents: [] }
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await defaultExecuteAgentTurn({
|
|
46
|
+
run,
|
|
47
|
+
phase: { id: 'respond', label: 'Respond', kind: 'round_robin' },
|
|
48
|
+
agentId: 'agentA',
|
|
49
|
+
prompt: 'Say something useful.',
|
|
50
|
+
})
|
|
51
|
+
console.log(JSON.stringify({ error: null }))
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
54
|
+
} finally {
|
|
55
|
+
setStreamAgentChatForTest(null)
|
|
56
|
+
}
|
|
57
|
+
`, { prefix: 'swarmclaw-protocol-agent-turn-error-' })
|
|
58
|
+
|
|
59
|
+
assert.match(String(output.error || ''), /missing credentials/i)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('defaultExecuteAgentTurn rejects blank structured responses even without a logged error', () => {
|
|
63
|
+
const output = runWithTempDataDir<{ error: string | null }>(`
|
|
64
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
65
|
+
const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
|
|
66
|
+
const protocolTurnMod = await import('./src/lib/server/protocols/protocol-agent-turn')
|
|
67
|
+
const streamMod = await import('./src/lib/server/chat-execution/stream-agent-chat')
|
|
68
|
+
|
|
69
|
+
const storage = storageMod.default || storageMod
|
|
70
|
+
const protocols = protocolsMod.default || protocolsMod
|
|
71
|
+
const { defaultExecuteAgentTurn } = protocolTurnMod.default || protocolTurnMod
|
|
72
|
+
const { setStreamAgentChatForTest } = streamMod.default || streamMod
|
|
73
|
+
|
|
74
|
+
storage.upsertStoredItem('agents', 'agentA', {
|
|
75
|
+
id: 'agentA',
|
|
76
|
+
name: 'Agent A',
|
|
77
|
+
provider: 'openai',
|
|
78
|
+
model: 'gpt-4.1',
|
|
79
|
+
systemPrompt: 'test',
|
|
80
|
+
createdAt: 1,
|
|
81
|
+
updatedAt: 1,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const run = protocols.createProtocolRun({
|
|
85
|
+
title: 'Blank response run',
|
|
86
|
+
templateId: 'single_agent_structured_run',
|
|
87
|
+
participantAgentIds: ['agentA'],
|
|
88
|
+
facilitatorAgentId: 'agentA',
|
|
89
|
+
autoStart: false,
|
|
90
|
+
}, { now: () => 1000 })
|
|
91
|
+
|
|
92
|
+
setStreamAgentChatForTest(async () => ({ fullText: '', finalResponse: '', toolEvents: [] }))
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await defaultExecuteAgentTurn({
|
|
96
|
+
run,
|
|
97
|
+
phase: { id: 'summarize', label: 'Summarize', kind: 'summarize' },
|
|
98
|
+
agentId: 'agentA',
|
|
99
|
+
prompt: 'Summarize.',
|
|
100
|
+
})
|
|
101
|
+
console.log(JSON.stringify({ error: null }))
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
104
|
+
} finally {
|
|
105
|
+
setStreamAgentChatForTest(null)
|
|
106
|
+
}
|
|
107
|
+
`, { prefix: 'swarmclaw-protocol-agent-turn-blank-' })
|
|
108
|
+
|
|
109
|
+
assert.match(String(output.error || ''), /no visible output/i)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('defaultExecuteAgentTurn uses direct provider runtime for CLI providers', () => {
|
|
113
|
+
const output = runWithTempDataDir<{ text: string | null }>(`
|
|
114
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
115
|
+
const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
|
|
116
|
+
const protocolTurnMod = await import('./src/lib/server/protocols/protocol-agent-turn')
|
|
117
|
+
const providersMod = await import('./src/lib/providers/index')
|
|
118
|
+
|
|
119
|
+
const storage = storageMod.default || storageMod
|
|
120
|
+
const protocols = protocolsMod.default || protocolsMod
|
|
121
|
+
const { defaultExecuteAgentTurn } = protocolTurnMod.default || protocolTurnMod
|
|
122
|
+
const providers = providersMod.default || providersMod
|
|
123
|
+
|
|
124
|
+
storage.upsertStoredItem('agents', 'agentA', {
|
|
125
|
+
id: 'agentA',
|
|
126
|
+
name: 'Agent A',
|
|
127
|
+
provider: 'copilot-cli',
|
|
128
|
+
model: 'gpt-5.4',
|
|
129
|
+
systemPrompt: 'test',
|
|
130
|
+
createdAt: 1,
|
|
131
|
+
updatedAt: 1,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const originalHandler = providers.PROVIDERS['copilot-cli'].handler
|
|
135
|
+
providers.PROVIDERS['copilot-cli'].handler = {
|
|
136
|
+
streamChat: async (opts) => {
|
|
137
|
+
opts.write('data: ' + JSON.stringify({ t: 'd', text: 'Copilot CLI structured response.' }) + '\\n\\n')
|
|
138
|
+
return 'Copilot CLI structured response.'
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const run = protocols.createProtocolRun({
|
|
143
|
+
title: 'CLI structured run',
|
|
144
|
+
templateId: 'single_agent_structured_run',
|
|
145
|
+
participantAgentIds: ['agentA'],
|
|
146
|
+
facilitatorAgentId: 'agentA',
|
|
147
|
+
autoStart: false,
|
|
148
|
+
}, { now: () => 1000 })
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const result = await defaultExecuteAgentTurn({
|
|
152
|
+
run,
|
|
153
|
+
phase: { id: 'respond', label: 'Respond', kind: 'round_robin' },
|
|
154
|
+
agentId: 'agentA',
|
|
155
|
+
prompt: 'Say something useful.',
|
|
156
|
+
})
|
|
157
|
+
console.log(JSON.stringify({ text: result.text }))
|
|
158
|
+
} finally {
|
|
159
|
+
providers.PROVIDERS['copilot-cli'].handler = originalHandler
|
|
160
|
+
}
|
|
161
|
+
`, { prefix: 'swarmclaw-protocol-agent-turn-cli-' })
|
|
162
|
+
|
|
163
|
+
assert.equal(output.text, 'Copilot CLI structured response.')
|
|
164
|
+
})
|
|
@@ -6,6 +6,8 @@ import { HumanMessage } from '@langchain/core/messages'
|
|
|
6
6
|
import { z } from 'zod'
|
|
7
7
|
import { log } from '@/lib/server/logger'
|
|
8
8
|
import { genId } from '@/lib/id'
|
|
9
|
+
import { getProvider } from '@/lib/providers'
|
|
10
|
+
import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
9
11
|
|
|
10
12
|
const TAG = 'protocol-agent-turn'
|
|
11
13
|
import type {
|
|
@@ -49,6 +51,7 @@ import type { ProtocolAgentTurnResult, ProtocolRunDeps } from '@/lib/server/prot
|
|
|
49
51
|
import { normalizeProtocolRun } from '@/lib/server/protocols/protocol-normalization'
|
|
50
52
|
import { persistChatroomInteractionMemory } from '@/lib/server/chatrooms/chatroom-memory-bridge'
|
|
51
53
|
import { selectKnowledgeCitations } from '@/lib/server/knowledge-sources'
|
|
54
|
+
import { queryLogs } from '@/lib/server/execution-log'
|
|
52
55
|
|
|
53
56
|
// ---- Zod schema ----
|
|
54
57
|
|
|
@@ -248,24 +251,26 @@ export async function defaultExecuteAgentTurn(params: {
|
|
|
248
251
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
249
252
|
}
|
|
250
253
|
try {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
254
|
+
const turnStartedAt = Date.now()
|
|
255
|
+
const history = buildHistoryForAgent(chatroom, agent.id)
|
|
256
|
+
const result = await executeStructuredSessionTurnWithTimeout({
|
|
257
|
+
session: syntheticSession,
|
|
258
|
+
message: params.prompt,
|
|
259
|
+
apiKey,
|
|
260
|
+
systemPrompt: fullSystemPrompt,
|
|
261
|
+
history,
|
|
262
|
+
agentId: params.agentId,
|
|
263
|
+
})
|
|
264
|
+
const rawText = result.text || ''
|
|
265
|
+
const text = resolveStructuredSessionTurnText({
|
|
266
|
+
rawText,
|
|
267
|
+
sessionId: syntheticSession.id,
|
|
268
|
+
turnStartedAt,
|
|
269
|
+
agentId: params.agentId,
|
|
270
|
+
})
|
|
266
271
|
const grounding = selectKnowledgeCitations({
|
|
267
272
|
responseText: text,
|
|
268
|
-
retrievalTrace: result.
|
|
273
|
+
retrievalTrace: result.retrievalTrace || null,
|
|
269
274
|
})
|
|
270
275
|
if (text.trim() && !shouldSuppressHiddenControlText(rawText)) {
|
|
271
276
|
appendSyntheticSessionMessage(syntheticSession.id, 'assistant', text)
|
|
@@ -297,6 +302,104 @@ export async function defaultExecuteAgentTurn(params: {
|
|
|
297
302
|
throw lastError
|
|
298
303
|
}
|
|
299
304
|
|
|
305
|
+
async function executeStructuredSessionTurn(params: {
|
|
306
|
+
session: ReturnType<typeof ensureSyntheticSession>
|
|
307
|
+
message: string
|
|
308
|
+
apiKey: string | null
|
|
309
|
+
systemPrompt: string
|
|
310
|
+
history: ReturnType<typeof buildHistoryForAgent>
|
|
311
|
+
}): Promise<ProtocolAgentTurnResult> {
|
|
312
|
+
if (NON_LANGGRAPH_PROVIDER_IDS.has(params.session.provider)) {
|
|
313
|
+
const provider = getProvider(params.session.provider)
|
|
314
|
+
if (!provider) throw new Error(`Unknown provider: ${params.session.provider}`)
|
|
315
|
+
let streamedText = ''
|
|
316
|
+
const rawResponseText = await provider.handler.streamChat({
|
|
317
|
+
session: params.session,
|
|
318
|
+
message: params.message,
|
|
319
|
+
apiKey: params.apiKey,
|
|
320
|
+
systemPrompt: params.systemPrompt,
|
|
321
|
+
write: (raw: string) => {
|
|
322
|
+
for (const line of raw.split('\n')) {
|
|
323
|
+
if (!line.startsWith('data: ')) continue
|
|
324
|
+
try {
|
|
325
|
+
const parsed = JSON.parse(line.slice(6).trim()) as { t?: string; text?: string }
|
|
326
|
+
if ((parsed.t === 'd' || parsed.t === 'r') && typeof parsed.text === 'string') {
|
|
327
|
+
streamedText += parsed.text
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Ignore malformed provider event payloads here and fall back to the final response text.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
active: new Map<string, unknown>(),
|
|
335
|
+
loadHistory: () => params.history,
|
|
336
|
+
})
|
|
337
|
+
return {
|
|
338
|
+
text: rawResponseText || streamedText,
|
|
339
|
+
toolEvents: [],
|
|
340
|
+
retrievalTrace: null,
|
|
341
|
+
citations: [],
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const streamed = await streamAgentChat({
|
|
346
|
+
session: params.session,
|
|
347
|
+
message: params.message,
|
|
348
|
+
apiKey: params.apiKey,
|
|
349
|
+
systemPrompt: params.systemPrompt,
|
|
350
|
+
write: () => {},
|
|
351
|
+
history: params.history,
|
|
352
|
+
})
|
|
353
|
+
return {
|
|
354
|
+
text: streamed.finalResponse || streamed.fullText || '',
|
|
355
|
+
toolEvents: streamed.toolEvents || [],
|
|
356
|
+
retrievalTrace: streamed.knowledgeRetrievalTrace || null,
|
|
357
|
+
citations: [],
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function executeStructuredSessionTurnWithTimeout(params: {
|
|
362
|
+
session: ReturnType<typeof ensureSyntheticSession>
|
|
363
|
+
message: string
|
|
364
|
+
apiKey: string | null
|
|
365
|
+
systemPrompt: string
|
|
366
|
+
history: ReturnType<typeof buildHistoryForAgent>
|
|
367
|
+
agentId: string
|
|
368
|
+
}): Promise<ProtocolAgentTurnResult> {
|
|
369
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
370
|
+
try {
|
|
371
|
+
return await Promise.race([
|
|
372
|
+
executeStructuredSessionTurn(params),
|
|
373
|
+
new Promise<never>((_, reject) => {
|
|
374
|
+
timeoutId = setTimeout(
|
|
375
|
+
() => reject(new Error(`Agent turn timed out after ${AGENT_TURN_TIMEOUT_MS / 1000}s (agent: ${params.agentId})`)),
|
|
376
|
+
AGENT_TURN_TIMEOUT_MS,
|
|
377
|
+
)
|
|
378
|
+
}),
|
|
379
|
+
])
|
|
380
|
+
} finally {
|
|
381
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function resolveStructuredSessionTurnText(params: {
|
|
386
|
+
rawText: string
|
|
387
|
+
sessionId: string
|
|
388
|
+
turnStartedAt: number
|
|
389
|
+
agentId: string
|
|
390
|
+
}): string {
|
|
391
|
+
const text = stripHiddenControlTokens(params.rawText)
|
|
392
|
+
if (text.trim()) return text
|
|
393
|
+
const recentError = queryLogs({
|
|
394
|
+
sessionId: params.sessionId,
|
|
395
|
+
category: 'error',
|
|
396
|
+
since: params.turnStartedAt,
|
|
397
|
+
limit: 1,
|
|
398
|
+
})[0]
|
|
399
|
+
if (recentError?.summary) throw new Error(recentError.summary)
|
|
400
|
+
throw new Error(`Structured session turn produced no visible output for agent ${params.agentId}.`)
|
|
401
|
+
}
|
|
402
|
+
|
|
300
403
|
export function extractFirstJsonObject(text: string): string | null {
|
|
301
404
|
const source = String(text || '')
|
|
302
405
|
let start = -1
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ProtocolRun, ProtocolRunEvent, RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
|
|
2
|
+
|
|
3
|
+
function mapProtocolStatus(status: ProtocolRun['status']): SessionRunStatus {
|
|
4
|
+
switch (status) {
|
|
5
|
+
case 'draft':
|
|
6
|
+
return 'queued'
|
|
7
|
+
case 'running':
|
|
8
|
+
case 'waiting':
|
|
9
|
+
case 'paused':
|
|
10
|
+
return 'running'
|
|
11
|
+
case 'completed':
|
|
12
|
+
return 'completed'
|
|
13
|
+
case 'failed':
|
|
14
|
+
return 'failed'
|
|
15
|
+
case 'cancelled':
|
|
16
|
+
case 'archived':
|
|
17
|
+
return 'cancelled'
|
|
18
|
+
default:
|
|
19
|
+
return 'queued'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildProtocolMessagePreview(run: ProtocolRun): string {
|
|
24
|
+
return (
|
|
25
|
+
run.title
|
|
26
|
+
|| run.config?.goal
|
|
27
|
+
|| run.config?.kickoffMessage
|
|
28
|
+
|| run.templateName
|
|
29
|
+
|| 'Structured session run'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildProtocolResultPreview(run: ProtocolRun): string {
|
|
34
|
+
const summary = typeof run.summary === 'string' ? run.summary.trim() : ''
|
|
35
|
+
if (summary) return summary
|
|
36
|
+
const latestArtifact = Array.isArray(run.artifacts)
|
|
37
|
+
? [...run.artifacts].sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))[0]
|
|
38
|
+
: null
|
|
39
|
+
const artifactContent = typeof latestArtifact?.content === 'string' ? latestArtifact.content.trim() : ''
|
|
40
|
+
if (artifactContent) return artifactContent
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function protocolRunToSessionRunRecord(run: ProtocolRun): SessionRunRecord {
|
|
45
|
+
return {
|
|
46
|
+
id: run.id,
|
|
47
|
+
sessionId: run.sessionId || run.transcriptChatroomId || `protocol-run:${run.id}`,
|
|
48
|
+
kind: 'protocol_step',
|
|
49
|
+
ownerType: 'protocol_run',
|
|
50
|
+
ownerId: run.id,
|
|
51
|
+
source: run.sourceRef.kind === 'schedule' ? 'structured schedule' : 'structured session',
|
|
52
|
+
internal: run.systemOwned === true,
|
|
53
|
+
mode: run.templateId,
|
|
54
|
+
status: mapProtocolStatus(run.status),
|
|
55
|
+
messagePreview: buildProtocolMessagePreview(run),
|
|
56
|
+
queuedAt: run.createdAt,
|
|
57
|
+
startedAt: run.startedAt || undefined,
|
|
58
|
+
endedAt: run.endedAt || run.archivedAt || undefined,
|
|
59
|
+
error: run.lastError || undefined,
|
|
60
|
+
resultPreview: buildProtocolResultPreview(run) || undefined,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function protocolEventToRunEventRecord(run: ProtocolRun, event: ProtocolRunEvent): RunEventRecord {
|
|
65
|
+
const status = event.type === 'failed'
|
|
66
|
+
? 'failed'
|
|
67
|
+
: event.type === 'completed'
|
|
68
|
+
? 'completed'
|
|
69
|
+
: event.type === 'cancelled'
|
|
70
|
+
? 'cancelled'
|
|
71
|
+
: event.type === 'created'
|
|
72
|
+
? 'queued'
|
|
73
|
+
: undefined
|
|
74
|
+
return {
|
|
75
|
+
id: event.id,
|
|
76
|
+
runId: run.id,
|
|
77
|
+
sessionId: run.sessionId || run.transcriptChatroomId || `protocol-run:${run.id}`,
|
|
78
|
+
kind: 'protocol_step',
|
|
79
|
+
ownerType: 'protocol_run',
|
|
80
|
+
ownerId: run.id,
|
|
81
|
+
timestamp: event.createdAt,
|
|
82
|
+
phase: status ? 'status' : 'event',
|
|
83
|
+
status,
|
|
84
|
+
summary: event.summary,
|
|
85
|
+
event: {
|
|
86
|
+
t: status === 'failed' ? 'err' : status ? 'status' : 'md',
|
|
87
|
+
text: event.summary,
|
|
88
|
+
},
|
|
89
|
+
citations: event.citations,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -319,7 +319,10 @@ export function normalizeSchedulePayload(payload: SchedulePayload, opts: Normali
|
|
|
319
319
|
const cronTimezone = trimString(normalized.timezone)
|
|
320
320
|
const interval = CronExpressionParser.parse(
|
|
321
321
|
normalized.cron as string,
|
|
322
|
-
|
|
322
|
+
{
|
|
323
|
+
...(cronTimezone ? { tz: cronTimezone } : {}),
|
|
324
|
+
currentDate: new Date(now),
|
|
325
|
+
},
|
|
323
326
|
)
|
|
324
327
|
normalized.nextRunAt = applyStagger(interval.next().getTime(), normalized.staggerSec as number | null)
|
|
325
328
|
} catch {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { prepareScheduleUpdate } from '@/lib/server/schedules/schedule-service'
|
|
5
|
+
import type { Schedule } from '@/types'
|
|
6
|
+
|
|
7
|
+
function readNextRunAt(value: object): number | undefined {
|
|
8
|
+
if (!('nextRunAt' in value)) return undefined
|
|
9
|
+
const nextRunAt = value.nextRunAt
|
|
10
|
+
return typeof nextRunAt === 'number' ? nextRunAt : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeSchedule(overrides: Partial<Schedule> = {}): Schedule {
|
|
14
|
+
return {
|
|
15
|
+
id: 'sched-1',
|
|
16
|
+
name: 'Morning run',
|
|
17
|
+
agentId: 'agent-1',
|
|
18
|
+
taskPrompt: 'Do the thing',
|
|
19
|
+
scheduleType: 'cron',
|
|
20
|
+
cron: '40 10 * * *',
|
|
21
|
+
timezone: 'UTC',
|
|
22
|
+
status: 'active',
|
|
23
|
+
createdAt: 0,
|
|
24
|
+
updatedAt: 0,
|
|
25
|
+
nextRunAt: Date.parse('2026-01-01T10:40:00.000Z'),
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('prepareScheduleUpdate', () => {
|
|
31
|
+
it('recomputes nextRunAt when cron timing changes', () => {
|
|
32
|
+
const current = makeSchedule()
|
|
33
|
+
const now = Date.parse('2026-01-01T10:30:00.000Z')
|
|
34
|
+
const result = prepareScheduleUpdate({
|
|
35
|
+
id: current.id,
|
|
36
|
+
current,
|
|
37
|
+
patch: { cron: '45 10 * * *' },
|
|
38
|
+
schedules: { [current.id]: current },
|
|
39
|
+
now,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
assert.equal(result.ok, true)
|
|
43
|
+
if (result.ok) {
|
|
44
|
+
const nextRunAt = readNextRunAt(result.schedule)
|
|
45
|
+
assert.notEqual(nextRunAt, current.nextRunAt)
|
|
46
|
+
assert.equal(typeof nextRunAt, 'number')
|
|
47
|
+
assert.equal(nextRunAt, Date.parse('2026-01-01T10:45:00.000Z'))
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('recomputes nextRunAt when reactivating a paused interval schedule', () => {
|
|
52
|
+
const current = makeSchedule({
|
|
53
|
+
scheduleType: 'interval',
|
|
54
|
+
cron: undefined,
|
|
55
|
+
intervalMs: 300_000,
|
|
56
|
+
status: 'paused',
|
|
57
|
+
nextRunAt: 123,
|
|
58
|
+
})
|
|
59
|
+
const now = 1_000_000
|
|
60
|
+
const result = prepareScheduleUpdate({
|
|
61
|
+
id: current.id,
|
|
62
|
+
current,
|
|
63
|
+
patch: { status: 'active' },
|
|
64
|
+
schedules: { [current.id]: current },
|
|
65
|
+
now,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
assert.equal(result.ok, true)
|
|
69
|
+
if (result.ok) {
|
|
70
|
+
assert.equal(readNextRunAt(result.schedule), 1_300_000)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -211,10 +211,17 @@ export type PrepareScheduleUpdateResult =
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
export function prepareScheduleUpdate(options: PrepareScheduleUpdateOptions): PrepareScheduleUpdateResult {
|
|
214
|
+
const nextStatus = normalizeScheduleStatus(options.patch.status)
|
|
215
|
+
const shouldRecomputeNextRunAt = !('nextRunAt' in options.patch) && (
|
|
216
|
+
['scheduleType', 'cron', 'intervalMs', 'runAt', 'timezone', 'staggerSec']
|
|
217
|
+
.some((key) => key in options.patch)
|
|
218
|
+
|| (nextStatus === 'active' && options.current.status !== 'active')
|
|
219
|
+
)
|
|
214
220
|
const normalized = normalizeSchedulePayload({
|
|
215
221
|
...options.current,
|
|
216
222
|
...options.patch,
|
|
217
223
|
id: options.id,
|
|
224
|
+
...(shouldRecomputeNextRunAt ? { nextRunAt: undefined } : {}),
|
|
218
225
|
}, {
|
|
219
226
|
cwd: options.cwd,
|
|
220
227
|
now: options.now,
|
|
@@ -237,8 +244,8 @@ export function prepareScheduleUpdate(options: PrepareScheduleUpdateOptions): Pr
|
|
|
237
244
|
})
|
|
238
245
|
|
|
239
246
|
const entries: Array<[string, ScheduleLike]> = [[options.id, nextSchedule]]
|
|
240
|
-
const
|
|
241
|
-
if (options.propagateEquivalentStatuses && (
|
|
247
|
+
const normalizedStatus = normalizeScheduleStatus(nextSchedule.status)
|
|
248
|
+
if (options.propagateEquivalentStatuses && (normalizedStatus === 'paused' || normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'archived')) {
|
|
242
249
|
const relatedIds = findRelatedScheduleIds(
|
|
243
250
|
options.schedules,
|
|
244
251
|
options.propagationSource || options.current,
|
|
@@ -249,7 +256,7 @@ export function prepareScheduleUpdate(options: PrepareScheduleUpdateOptions): Pr
|
|
|
249
256
|
if (!related) continue
|
|
250
257
|
entries.push([relatedId, {
|
|
251
258
|
...related,
|
|
252
|
-
status:
|
|
259
|
+
status: normalizedStatus,
|
|
253
260
|
updatedAt: options.now,
|
|
254
261
|
}])
|
|
255
262
|
}
|
|
@@ -13,6 +13,7 @@ export function runWithTempDataDir<T = unknown>(
|
|
|
13
13
|
dataDir?: string
|
|
14
14
|
workspaceDir?: string
|
|
15
15
|
browserProfilesDir?: string
|
|
16
|
+
timeoutMs?: number
|
|
16
17
|
} = {},
|
|
17
18
|
): T {
|
|
18
19
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), options.prefix || 'swarmclaw-test-'))
|
|
@@ -38,9 +39,10 @@ export function runWithTempDataDir<T = unknown>(
|
|
|
38
39
|
...(browserProfilesDir ? { BROWSER_PROFILES_DIR: browserProfilesDir } : {}),
|
|
39
40
|
},
|
|
40
41
|
encoding: 'utf-8',
|
|
42
|
+
timeout: options.timeoutMs ?? 120_000,
|
|
41
43
|
})
|
|
42
44
|
|
|
43
|
-
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
45
|
+
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout || 'subprocess failed')
|
|
44
46
|
|
|
45
47
|
const lines = (result.stdout || '')
|
|
46
48
|
.trim()
|