@swarmclawai/swarmclaw 0.7.7 → 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 +10 -9
- package/package.json +1 -1
- package/src/app/api/chats/route.ts +1 -0
- package/src/app/api/connectors/[id]/route.ts +20 -2
- package/src/app/api/connectors/route.ts +12 -8
- 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/components/agents/agent-sheet.tsx +184 -14
- 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/layout/app-layout.tsx +40 -23
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- 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/capability-router.test.ts +22 -0
- package/src/lib/server/capability-router.ts +54 -18
- package/src/lib/server/chat-execution.ts +25 -1
- 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/orchestrator-lg.ts +1 -0
- package/src/lib/server/orchestrator.ts +2 -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 +234 -7
- 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/tool-definitions.ts +2 -1
- package/src/types/index.ts +39 -0
package/src/lib/server/queue.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { extractTaskResult, formatResultBody } from './task-result'
|
|
|
13
13
|
import { getCheckpointSaver } from './langgraph-checkpoint'
|
|
14
14
|
import { cascadeUnblock } from './dag-validation'
|
|
15
15
|
import { performGuardianRollback } from './guardian'
|
|
16
|
-
import type { Agent, BoardTask, Connector, Message } from '@/types'
|
|
16
|
+
import type { Agent, BoardTask, Connector, Message, Session } from '@/types'
|
|
17
17
|
|
|
18
18
|
// HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
|
|
19
19
|
const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
|
|
@@ -128,6 +128,212 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
|
|
|
128
128
|
if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
export interface TaskResumeState {
|
|
132
|
+
claudeSessionId: string | null
|
|
133
|
+
codexThreadId: string | null
|
|
134
|
+
opencodeSessionId: string | null
|
|
135
|
+
delegateResumeIds: NonNullable<Session['delegateResumeIds']>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TaskResumeContext {
|
|
139
|
+
source: 'self' | 'delegated_from_task' | 'blocked_by'
|
|
140
|
+
sourceTaskId: string
|
|
141
|
+
sourceTaskTitle: string
|
|
142
|
+
sourceSessionId: string | null
|
|
143
|
+
resume: TaskResumeState
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeResumeHandle(value: unknown): string | null {
|
|
147
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
|
|
151
|
+
return {
|
|
152
|
+
claudeCode: null,
|
|
153
|
+
codex: null,
|
|
154
|
+
opencode: null,
|
|
155
|
+
gemini: null,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeCliProvider(value: unknown): string | null {
|
|
160
|
+
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hasResumeState(state: TaskResumeState | null | undefined): state is TaskResumeState {
|
|
164
|
+
if (!state) return false
|
|
165
|
+
return Boolean(
|
|
166
|
+
state.claudeSessionId
|
|
167
|
+
|| state.codexThreadId
|
|
168
|
+
|| state.opencodeSessionId
|
|
169
|
+
|| state.delegateResumeIds.claudeCode
|
|
170
|
+
|| state.delegateResumeIds.codex
|
|
171
|
+
|| state.delegateResumeIds.opencode
|
|
172
|
+
|| state.delegateResumeIds.gemini
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function extractTaskResumeState(task: Partial<BoardTask> | null | undefined): TaskResumeState | null {
|
|
177
|
+
if (!task) return null
|
|
178
|
+
|
|
179
|
+
const legacyResumeId = normalizeResumeHandle(task.cliResumeId)
|
|
180
|
+
const legacyProvider = normalizeCliProvider(task.cliProvider)
|
|
181
|
+
const claudeSessionId = normalizeResumeHandle(task.claudeResumeId)
|
|
182
|
+
|| (legacyProvider === 'claude-cli' ? legacyResumeId : null)
|
|
183
|
+
const codexThreadId = normalizeResumeHandle(task.codexResumeId)
|
|
184
|
+
|| (legacyProvider === 'codex-cli' ? legacyResumeId : null)
|
|
185
|
+
const opencodeSessionId = normalizeResumeHandle(task.opencodeResumeId)
|
|
186
|
+
|| (legacyProvider === 'opencode-cli' ? legacyResumeId : null)
|
|
187
|
+
const geminiSessionId = normalizeResumeHandle(task.geminiResumeId)
|
|
188
|
+
|| (legacyProvider === 'gemini-cli' ? legacyResumeId : null)
|
|
189
|
+
|
|
190
|
+
const resume = {
|
|
191
|
+
claudeSessionId,
|
|
192
|
+
codexThreadId,
|
|
193
|
+
opencodeSessionId,
|
|
194
|
+
delegateResumeIds: {
|
|
195
|
+
claudeCode: claudeSessionId,
|
|
196
|
+
codex: codexThreadId,
|
|
197
|
+
opencode: opencodeSessionId,
|
|
198
|
+
gemini: geminiSessionId,
|
|
199
|
+
},
|
|
200
|
+
} satisfies TaskResumeState
|
|
201
|
+
|
|
202
|
+
return hasResumeState(resume) ? resume : null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function extractSessionResumeState(session: Partial<Session> | null | undefined): TaskResumeState | null {
|
|
206
|
+
if (!session) return null
|
|
207
|
+
|
|
208
|
+
const claudeSessionId = normalizeResumeHandle(session.claudeSessionId)
|
|
209
|
+
const codexThreadId = normalizeResumeHandle(session.codexThreadId)
|
|
210
|
+
const opencodeSessionId = normalizeResumeHandle(session.opencodeSessionId)
|
|
211
|
+
const delegateResumeIds = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
|
|
212
|
+
? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
|
|
213
|
+
: buildEmptyDelegateResumeIds()
|
|
214
|
+
|
|
215
|
+
const resume = {
|
|
216
|
+
claudeSessionId,
|
|
217
|
+
codexThreadId,
|
|
218
|
+
opencodeSessionId,
|
|
219
|
+
delegateResumeIds: {
|
|
220
|
+
claudeCode: normalizeResumeHandle(delegateResumeIds.claudeCode) || claudeSessionId,
|
|
221
|
+
codex: normalizeResumeHandle(delegateResumeIds.codex) || codexThreadId,
|
|
222
|
+
opencode: normalizeResumeHandle(delegateResumeIds.opencode) || opencodeSessionId,
|
|
223
|
+
gemini: normalizeResumeHandle(delegateResumeIds.gemini),
|
|
224
|
+
},
|
|
225
|
+
} satisfies TaskResumeState
|
|
226
|
+
|
|
227
|
+
return hasResumeState(resume) ? resume : null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function resolveTaskResumeContext(
|
|
231
|
+
task: BoardTask,
|
|
232
|
+
tasksById: Record<string, BoardTask>,
|
|
233
|
+
sessionsById?: Record<string, SessionLike | Session>,
|
|
234
|
+
): TaskResumeContext | null {
|
|
235
|
+
const candidates: Array<{ source: TaskResumeContext['source']; taskId: string | null | undefined }> = [
|
|
236
|
+
{ source: 'self', taskId: task.id },
|
|
237
|
+
{ source: 'delegated_from_task', taskId: task.delegatedFromTaskId },
|
|
238
|
+
...((Array.isArray(task.blockedBy) ? task.blockedBy : []).map((taskId) => ({ source: 'blocked_by' as const, taskId }))),
|
|
239
|
+
]
|
|
240
|
+
const seen = new Set<string>()
|
|
241
|
+
|
|
242
|
+
for (const candidate of candidates) {
|
|
243
|
+
const taskId = typeof candidate.taskId === 'string' ? candidate.taskId.trim() : ''
|
|
244
|
+
if (!taskId || seen.has(taskId)) continue
|
|
245
|
+
seen.add(taskId)
|
|
246
|
+
const sourceTask = taskId === task.id ? task : tasksById[taskId]
|
|
247
|
+
if (!sourceTask) continue
|
|
248
|
+
const sourceSessionId = normalizeResumeHandle(sourceTask.checkpoint?.lastSessionId) || normalizeResumeHandle(sourceTask.sessionId)
|
|
249
|
+
const resume = extractTaskResumeState(sourceTask)
|
|
250
|
+
|| (sourceSessionId && sessionsById?.[sourceSessionId]
|
|
251
|
+
? extractSessionResumeState(sessionsById[sourceSessionId] as Session)
|
|
252
|
+
: null)
|
|
253
|
+
if (!resume) continue
|
|
254
|
+
return {
|
|
255
|
+
source: candidate.source,
|
|
256
|
+
sourceTaskId: sourceTask.id,
|
|
257
|
+
sourceTaskTitle: sourceTask.title,
|
|
258
|
+
sourceSessionId,
|
|
259
|
+
resume,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyTaskResumeStateToSession(session: Session, resume: TaskResumeState | null | undefined): boolean {
|
|
267
|
+
if (!hasResumeState(resume)) return false
|
|
268
|
+
|
|
269
|
+
let changed = false
|
|
270
|
+
const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId', string | null]> = [
|
|
271
|
+
['claudeSessionId', resume.claudeSessionId],
|
|
272
|
+
['codexThreadId', resume.codexThreadId],
|
|
273
|
+
['opencodeSessionId', resume.opencodeSessionId],
|
|
274
|
+
]
|
|
275
|
+
for (const [key, value] of directFields) {
|
|
276
|
+
if (!value || session[key] === value) continue
|
|
277
|
+
session[key] = value
|
|
278
|
+
changed = true
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const currentDelegateResume = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
|
|
282
|
+
? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
|
|
283
|
+
: buildEmptyDelegateResumeIds()
|
|
284
|
+
for (const [key, value] of Object.entries(resume.delegateResumeIds) as Array<[keyof NonNullable<Session['delegateResumeIds']>, string | null]>) {
|
|
285
|
+
if (!value || currentDelegateResume[key] === value) continue
|
|
286
|
+
currentDelegateResume[key] = value
|
|
287
|
+
changed = true
|
|
288
|
+
}
|
|
289
|
+
if (changed) session.delegateResumeIds = currentDelegateResume
|
|
290
|
+
return changed
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function resolveReusableTaskSessionId(
|
|
294
|
+
task: BoardTask,
|
|
295
|
+
tasks: Record<string, BoardTask>,
|
|
296
|
+
sessions: Record<string, SessionLike>,
|
|
297
|
+
): string {
|
|
298
|
+
const candidateTaskIds = [
|
|
299
|
+
task.id,
|
|
300
|
+
typeof task.delegatedFromTaskId === 'string' ? task.delegatedFromTaskId : '',
|
|
301
|
+
...(Array.isArray(task.blockedBy) ? task.blockedBy : []),
|
|
302
|
+
]
|
|
303
|
+
const seen = new Set<string>()
|
|
304
|
+
for (const candidateTaskId of candidateTaskIds) {
|
|
305
|
+
const taskId = typeof candidateTaskId === 'string' ? candidateTaskId.trim() : ''
|
|
306
|
+
if (!taskId || seen.has(taskId)) continue
|
|
307
|
+
seen.add(taskId)
|
|
308
|
+
const sourceTask = taskId === task.id ? task : tasks[taskId]
|
|
309
|
+
if (!sourceTask) continue
|
|
310
|
+
const candidates = [
|
|
311
|
+
normalizeResumeHandle(sourceTask.checkpoint?.lastSessionId),
|
|
312
|
+
normalizeResumeHandle(sourceTask.sessionId),
|
|
313
|
+
]
|
|
314
|
+
for (const candidate of candidates) {
|
|
315
|
+
if (candidate && sessions[candidate]) return candidate
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return ''
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildTaskContinuationNote(
|
|
322
|
+
reusedExistingSession: boolean,
|
|
323
|
+
resumeContext: TaskResumeContext | null,
|
|
324
|
+
): string {
|
|
325
|
+
const notes: string[] = []
|
|
326
|
+
if (reusedExistingSession) {
|
|
327
|
+
notes.push('Reusing the previous execution session for this task.')
|
|
328
|
+
}
|
|
329
|
+
if (resumeContext?.source === 'delegated_from_task' || resumeContext?.source === 'blocked_by') {
|
|
330
|
+
notes.push(`Stored CLI context is available from related task "${resumeContext.sourceTaskTitle}".`)
|
|
331
|
+
} else if (resumeContext?.source === 'self' && !reusedExistingSession) {
|
|
332
|
+
notes.push('Stored CLI resume handles are available for continuation.')
|
|
333
|
+
}
|
|
334
|
+
return notes.length ? `\n\n${notes.join(' ')}` : ''
|
|
335
|
+
}
|
|
336
|
+
|
|
131
337
|
const DEV_TASK_HINT = /\b(dev(?:\s+server)?|start(?:ing)?\s+(?:the\s+)?server|run(?:ning)?\s+(?:the\s+)?(?:app|project|site)|serve|localhost|http\s+server|web\s+server|npm\b|pnpm\b|yarn\b|bun\b|vite|next(?:\.js)?|react|build|compile)\b/i
|
|
132
338
|
const TASK_CWD_NOISE_DIRS = new Set([
|
|
133
339
|
'uploads',
|
|
@@ -1057,7 +1263,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
|
|
|
1057
1263
|
return 'dead_lettered'
|
|
1058
1264
|
}
|
|
1059
1265
|
|
|
1060
|
-
function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
|
|
1266
|
+
export function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
|
|
1061
1267
|
const now = Date.now()
|
|
1062
1268
|
|
|
1063
1269
|
// Remove stale entries first.
|
|
@@ -1071,7 +1277,9 @@ function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTas
|
|
|
1071
1277
|
const task = tasks[id]
|
|
1072
1278
|
if (!task) return false
|
|
1073
1279
|
const retryAt = typeof task.retryScheduledAt === 'number' ? task.retryScheduledAt : null
|
|
1074
|
-
|
|
1280
|
+
if (retryAt && retryAt > now) return false
|
|
1281
|
+
const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
1282
|
+
return blockers.every((blockerId) => tasks[blockerId]?.status === 'completed')
|
|
1075
1283
|
})
|
|
1076
1284
|
if (idx === -1) return null
|
|
1077
1285
|
const [taskId] = queue.splice(idx, 1)
|
|
@@ -1164,6 +1372,8 @@ export async function processNext() {
|
|
|
1164
1372
|
const sourceScheduleId = typeof scheduleTask.sourceScheduleId === 'string'
|
|
1165
1373
|
? scheduleTask.sourceScheduleId
|
|
1166
1374
|
: ''
|
|
1375
|
+
const reusableTaskSessionId = resolveReusableTaskSessionId(task, tasks as Record<string, BoardTask>, sessionsForCwd)
|
|
1376
|
+
const resumeContext = resolveTaskResumeContext(task, tasks as Record<string, BoardTask>, sessionsForCwd as Record<string, SessionLike | Session>)
|
|
1167
1377
|
|
|
1168
1378
|
// Resolve the agent's persistent thread session to use as parentSessionId
|
|
1169
1379
|
const agentThreadSessionId = agent.threadSessionId || null
|
|
@@ -1197,7 +1407,7 @@ export async function processNext() {
|
|
|
1197
1407
|
saveSchedules(schedules)
|
|
1198
1408
|
}
|
|
1199
1409
|
} else {
|
|
1200
|
-
sessionId = createOrchestratorSession(
|
|
1410
|
+
sessionId = reusableTaskSessionId || createOrchestratorSession(
|
|
1201
1411
|
agent,
|
|
1202
1412
|
task.title,
|
|
1203
1413
|
agentThreadSessionId || undefined,
|
|
@@ -1206,6 +1416,13 @@ export async function processNext() {
|
|
|
1206
1416
|
)
|
|
1207
1417
|
}
|
|
1208
1418
|
|
|
1419
|
+
const executionSessions = loadSessions() as Record<string, Session>
|
|
1420
|
+
const executionSession = executionSessions[sessionId]
|
|
1421
|
+
const seededResumeState = executionSession
|
|
1422
|
+
? applyTaskResumeStateToSession(executionSession, resumeContext?.resume)
|
|
1423
|
+
: false
|
|
1424
|
+
if (seededResumeState) saveSessions(executionSessions)
|
|
1425
|
+
|
|
1209
1426
|
// Notify the agent's thread that a task has started
|
|
1210
1427
|
if (agentThreadSessionId) {
|
|
1211
1428
|
try {
|
|
@@ -1231,9 +1448,19 @@ export async function processNext() {
|
|
|
1231
1448
|
}
|
|
1232
1449
|
|
|
1233
1450
|
task.sessionId = sessionId
|
|
1451
|
+
const reusedExistingSession = !isScheduleTask && Boolean(reusableTaskSessionId) && reusableTaskSessionId === sessionId
|
|
1452
|
+
const continuationBits: string[] = []
|
|
1453
|
+
if (reusedExistingSession) {
|
|
1454
|
+
continuationBits.push('reusing prior session')
|
|
1455
|
+
}
|
|
1456
|
+
if (resumeContext?.source === 'delegated_from_task' || resumeContext?.source === 'blocked_by') {
|
|
1457
|
+
continuationBits.push(`seeded from task ${resumeContext.sourceTaskId}`)
|
|
1458
|
+
} else if (seededResumeState) {
|
|
1459
|
+
continuationBits.push('restored CLI resume handles')
|
|
1460
|
+
}
|
|
1234
1461
|
task.checkpoint = {
|
|
1235
1462
|
lastSessionId: sessionId,
|
|
1236
|
-
note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started`,
|
|
1463
|
+
note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started${continuationBits.length ? ` (${continuationBits.join('; ')})` : ''}`,
|
|
1237
1464
|
updatedAt: Date.now(),
|
|
1238
1465
|
}
|
|
1239
1466
|
saveTasks(tasks)
|
|
@@ -1251,9 +1478,9 @@ export async function processNext() {
|
|
|
1251
1478
|
const delegatorId = (task as unknown as Record<string, unknown>).delegatedByAgentId as string | undefined
|
|
1252
1479
|
const delegator = delegatorId ? agents[delegatorId] : null
|
|
1253
1480
|
const prefix = `[delegation-source:${delegatorId || ''}:${delegator?.name || 'Agent'}:${delegator?.avatarSeed || ''}]`
|
|
1254
|
-
initialText = `${prefix}\nDelegated by **${delegator?.name || 'another agent'}** | [${task.title}](#task:${task.id})\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}
|
|
1481
|
+
initialText = `${prefix}\nDelegated by **${delegator?.name || 'another agent'}** | [${task.title}](#task:${task.id})\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`${buildTaskContinuationNote(Boolean(reusedExistingSession), resumeContext)}\n\nI'll begin working on this now.`
|
|
1255
1482
|
} else {
|
|
1256
|
-
initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}
|
|
1483
|
+
initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`${buildTaskContinuationNote(Boolean(reusedExistingSession), resumeContext)}\n\nI'll begin working on this now.`
|
|
1257
1484
|
}
|
|
1258
1485
|
sessions[sessionId].messages.push({
|
|
1259
1486
|
role: 'assistant',
|
|
@@ -7,6 +7,7 @@ import { log } from './logger'
|
|
|
7
7
|
import { isInternalHeartbeatRun } from './heartbeat-source'
|
|
8
8
|
import { cleanupSessionBrowser } from './session-tools/web'
|
|
9
9
|
import { cancelDelegationJobsForParentSession } from './delegation-jobs'
|
|
10
|
+
import { handleMainLoopRunResult } from './main-agent-loop'
|
|
10
11
|
|
|
11
12
|
export type SessionRunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
12
13
|
export type SessionQueueMode = 'followup' | 'steer' | 'collect'
|
|
@@ -276,6 +277,36 @@ async function drainExecution(executionKey: string): Promise<void> {
|
|
|
276
277
|
error: next.run.error || null,
|
|
277
278
|
durationMs: (next.run.endedAt || now()) - (next.run.startedAt || now()),
|
|
278
279
|
})
|
|
280
|
+
const followup = handleMainLoopRunResult({
|
|
281
|
+
sessionId: next.run.sessionId,
|
|
282
|
+
message: next.message,
|
|
283
|
+
internal: next.run.internal,
|
|
284
|
+
source: next.run.source,
|
|
285
|
+
resultText: result.text,
|
|
286
|
+
error: next.run.error,
|
|
287
|
+
toolEvents: result.toolEvents,
|
|
288
|
+
inputTokens: result.inputTokens,
|
|
289
|
+
outputTokens: result.outputTokens,
|
|
290
|
+
estimatedCost: result.estimatedCost,
|
|
291
|
+
})
|
|
292
|
+
if (followup) {
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
try {
|
|
295
|
+
enqueueSessionRun({
|
|
296
|
+
sessionId: next.run.sessionId,
|
|
297
|
+
message: followup.message,
|
|
298
|
+
internal: true,
|
|
299
|
+
source: 'main-loop-followup',
|
|
300
|
+
mode: 'followup',
|
|
301
|
+
dedupeKey: followup.dedupeKey,
|
|
302
|
+
})
|
|
303
|
+
} catch (err: unknown) {
|
|
304
|
+
log.warn('session-run', `Main loop follow-up enqueue failed for ${next.run.sessionId}`, {
|
|
305
|
+
error: err instanceof Error ? err.message : String(err),
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}, Math.max(0, followup.delayMs || 0))
|
|
309
|
+
}
|
|
279
310
|
next.resolve(result)
|
|
280
311
|
} catch (err: any) {
|
|
281
312
|
const aborted = next.signalController.signal.aborted
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { describe, it } from 'node:test'
|
|
5
|
+
import { UPLOAD_DIR } from '../storage'
|
|
6
|
+
import { resolveConnectorMediaInput } from './connector'
|
|
7
|
+
|
|
8
|
+
describe('resolveConnectorMediaInput', () => {
|
|
9
|
+
it('resolves /api/uploads urls passed via mediaPath back to disk', () => {
|
|
10
|
+
const filename = `screenshot-test-${Date.now()}.png`
|
|
11
|
+
const uploadPath = path.join(UPLOAD_DIR, filename)
|
|
12
|
+
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
13
|
+
fs.writeFileSync(uploadPath, 'png')
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const resolved = resolveConnectorMediaInput({
|
|
17
|
+
cwd: process.cwd(),
|
|
18
|
+
mediaPath: `/api/uploads/${filename}`,
|
|
19
|
+
})
|
|
20
|
+
assert.equal(resolved.error, undefined)
|
|
21
|
+
assert.equal(resolved.mediaPath, uploadPath)
|
|
22
|
+
} finally {
|
|
23
|
+
fs.rmSync(uploadPath, { force: true })
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('treats remote urls passed via mediaPath as sendable urls instead of local files', () => {
|
|
28
|
+
const resolved = resolveConnectorMediaInput({
|
|
29
|
+
cwd: process.cwd(),
|
|
30
|
+
mediaPath: 'https://example.com/report.pdf',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
assert.equal(resolved.error, undefined)
|
|
34
|
+
assert.equal(resolved.mediaPath, undefined)
|
|
35
|
+
assert.equal(resolved.fileUrl, 'https://example.com/report.pdf')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -189,7 +189,7 @@ function pickChannelTarget(params: {
|
|
|
189
189
|
return { channelId }
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
function resolveConnectorMediaInput(params: {
|
|
192
|
+
export function resolveConnectorMediaInput(params: {
|
|
193
193
|
cwd: string
|
|
194
194
|
mediaPath?: string
|
|
195
195
|
imageUrl?: string
|
|
@@ -199,6 +199,23 @@ function resolveConnectorMediaInput(params: {
|
|
|
199
199
|
let resolvedImageUrl = params.imageUrl?.trim() || undefined
|
|
200
200
|
let resolvedFileUrl = params.fileUrl?.trim() || undefined
|
|
201
201
|
|
|
202
|
+
// Be forgiving when the model passes a served upload URL or remote URL in mediaPath.
|
|
203
|
+
if (resolvedMediaPath?.startsWith('/api/uploads/')) {
|
|
204
|
+
const fromUpload = resolveUploadUrl(resolvedMediaPath)
|
|
205
|
+
if (fromUpload) {
|
|
206
|
+
resolvedMediaPath = fromUpload.mediaPath
|
|
207
|
+
} else {
|
|
208
|
+
return { error: `Error: File not found: ${resolvedMediaPath}` }
|
|
209
|
+
}
|
|
210
|
+
} else if (resolvedMediaPath && /^https?:\/\//i.test(resolvedMediaPath)) {
|
|
211
|
+
if (/\.(png|jpe?g|webp|gif|svg)(?:[?#].*)?$/i.test(resolvedMediaPath)) {
|
|
212
|
+
resolvedImageUrl = resolvedMediaPath
|
|
213
|
+
} else {
|
|
214
|
+
resolvedFileUrl = resolvedMediaPath
|
|
215
|
+
}
|
|
216
|
+
resolvedMediaPath = undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
202
219
|
if (resolvedMediaPath && !path.isAbsolute(resolvedMediaPath) && !resolvedMediaPath.startsWith('/api/uploads/')) {
|
|
203
220
|
const candidatePaths = [
|
|
204
221
|
path.resolve(params.cwd, resolvedMediaPath),
|
|
@@ -540,6 +557,14 @@ const ConnectorPlugin: Plugin = {
|
|
|
540
557
|
messageId: { type: 'string' },
|
|
541
558
|
targetMessage: { type: 'string', enum: ['last_inbound', 'last_outbound'] },
|
|
542
559
|
emoji: { type: 'string' },
|
|
560
|
+
voiceText: { type: 'string' },
|
|
561
|
+
voiceId: { type: 'string' },
|
|
562
|
+
imageUrl: { type: 'string' },
|
|
563
|
+
fileUrl: { type: 'string' },
|
|
564
|
+
mediaPath: { type: 'string' },
|
|
565
|
+
mimeType: { type: 'string' },
|
|
566
|
+
fileName: { type: 'string' },
|
|
567
|
+
caption: { type: 'string' },
|
|
543
568
|
replyToMessageId: { type: 'string' },
|
|
544
569
|
threadId: { type: 'string' },
|
|
545
570
|
delaySec: { type: 'number' },
|
|
@@ -9,6 +9,11 @@ export interface ToolContext {
|
|
|
9
9
|
platformAssignScope?: 'self' | 'all'
|
|
10
10
|
mcpServerIds?: string[]
|
|
11
11
|
mcpDisabledTools?: string[]
|
|
12
|
+
projectId?: string | null
|
|
13
|
+
projectRoot?: string | null
|
|
14
|
+
projectName?: string | null
|
|
15
|
+
projectDescription?: string | null
|
|
16
|
+
memoryScopeMode?: 'auto' | 'all' | 'global' | 'agent' | 'session' | 'project' | null
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export interface SessionToolsResult {
|