create-loopengine 0.1.53 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/_gitignore +1 -0
- package/template/adapters/http.ts +281 -5
- package/template/package.json +1 -1
package/package.json
CHANGED
package/template/_gitignore
CHANGED
|
@@ -106,7 +106,29 @@ import {
|
|
|
106
106
|
renderDecideGonePage,
|
|
107
107
|
renderDecideInvalidPage,
|
|
108
108
|
describeAgentNotifier,
|
|
109
|
+
announceToNotifier,
|
|
110
|
+
Scheduler,
|
|
111
|
+
ScheduleError,
|
|
112
|
+
cancelScheduledTask,
|
|
113
|
+
createScheduledTask,
|
|
114
|
+
deleteScheduledTask,
|
|
115
|
+
describeSchedule,
|
|
116
|
+
getScheduledTask,
|
|
117
|
+
listScheduledTasks,
|
|
118
|
+
nextScheduledWakeAt,
|
|
119
|
+
pauseScheduledTask,
|
|
120
|
+
resumeScheduledTask,
|
|
121
|
+
runScheduledTaskNow,
|
|
122
|
+
type ScheduledTask,
|
|
123
|
+
type ScheduledTaskRun,
|
|
109
124
|
listAgentPending,
|
|
125
|
+
listAgentSlackChannels,
|
|
126
|
+
slackCredentialsFor,
|
|
127
|
+
openSlackAnswerModal,
|
|
128
|
+
parseSlackInteraction,
|
|
129
|
+
replyInSlack,
|
|
130
|
+
slackOutcomeText,
|
|
131
|
+
verifySlackRequest,
|
|
110
132
|
removeAgentNotifier,
|
|
111
133
|
saveAgentNotifier,
|
|
112
134
|
testAgentNotifier,
|
|
@@ -150,6 +172,7 @@ import {
|
|
|
150
172
|
resolvePendingQuestion as answerQuestion,
|
|
151
173
|
findQuestion,
|
|
152
174
|
createAskUserTool,
|
|
175
|
+
createScheduleTools,
|
|
153
176
|
type PendingQuestion,
|
|
154
177
|
WebhookNotifier,
|
|
155
178
|
} from 'loopengine'
|
|
@@ -239,6 +262,15 @@ function resolveTenant(
|
|
|
239
262
|
return result === undefined ? { ok: false } : { ok: true, value: result }
|
|
240
263
|
}
|
|
241
264
|
|
|
265
|
+
function readRawBody(req: IncomingMessage): Promise<string> {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
let data = ''
|
|
268
|
+
req.on('data', (chunk) => (data += chunk))
|
|
269
|
+
req.on('end', () => resolve(data))
|
|
270
|
+
req.on('error', reject)
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
242
274
|
function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
243
275
|
return new Promise((resolve, reject) => {
|
|
244
276
|
let data = ''
|
|
@@ -407,7 +439,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
407
439
|
// createAskUserTool() with no onPending, purely to describe its
|
|
408
440
|
// schema here — never executed, so the console-prompt fallback its
|
|
409
441
|
// own doc comment mentions never applies to this call.
|
|
410
|
-
systemTools: [...systemTools, createAskUserTool({ agent: config.name })].map(describeTool),
|
|
442
|
+
systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name))].map(describeTool),
|
|
411
443
|
localTools: localTools.map((t) => ({ ...describeTool(t), httpToolEditable: editableHttpToolNames.has(t.name) })),
|
|
412
444
|
agentAsTools: agentAsTools.map(describeTool),
|
|
413
445
|
gatewayTools: gatewayTools.map(describeTool),
|
|
@@ -1049,6 +1081,92 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
|
|
|
1049
1081
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
|
|
1050
1082
|
}
|
|
1051
1083
|
|
|
1084
|
+
// ---- Scheduled tasks (loopengine's core/scheduler.ts). A task fires through the same
|
|
1085
|
+
// path as a plain POST /agents/:name/messages: the session is kept, an
|
|
1086
|
+
// "ask" goes to the agent's notifier (or the default webhook) as a
|
|
1087
|
+
// durable approval, and with no notifier at all it's denied rather than
|
|
1088
|
+
// left waiting on nobody — a scheduled run has no one live in it. ----
|
|
1089
|
+
|
|
1090
|
+
const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended scheduled run. Use your own best judgement and proceed without waiting for a response.'
|
|
1091
|
+
|
|
1092
|
+
async function fireScheduledTask(task: ScheduledTask, rawSessionId: string): Promise<{ kind: 'done'; text: string } | { kind: 'pending'; pendingIds: string[] }> {
|
|
1093
|
+
const entry = getEntry(task.agent)
|
|
1094
|
+
if (!entry) throw new Error(`no agent named "${task.agent}" in this project`)
|
|
1095
|
+
const tenant = 'default'
|
|
1096
|
+
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
1097
|
+
const outcome = await sessions.withSession(`${tenant}:${environment}:${task.agent}:${rawSessionId}`, async (history) => {
|
|
1098
|
+
const result = await runAgent(entry.config, entry.createModelCall(), task.message, history, {
|
|
1099
|
+
tenant,
|
|
1100
|
+
sessionId: rawSessionId,
|
|
1101
|
+
channel: 'http',
|
|
1102
|
+
approver: defaultHttpWebhookNotifier ?? { requestApproval: async () => false },
|
|
1103
|
+
questionHandler: defaultHttpWebhookNotifier ?? { requestQuestion: async () => NO_HUMAN_ANSWER },
|
|
1104
|
+
})
|
|
1105
|
+
if (result.pending) await createCheckpointFromPending(task.agent, tenant, rawSessionId, result.pending)
|
|
1106
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pendingIds: result.pending?.outstanding.map((o) => o.pendingId) } }
|
|
1107
|
+
})
|
|
1108
|
+
if (outcome.pendingIds?.length) return { kind: 'pending', pendingIds: outcome.pendingIds }
|
|
1109
|
+
// run-agent's "you denied" wording is for someone live in a chat; in a
|
|
1110
|
+
// scheduled run nobody denied anything, so say what actually happened.
|
|
1111
|
+
if (outcome.stopReason === 'denied') {
|
|
1112
|
+
const tools = /denied: (.+?)\. Send/.exec(outcome.text)?.[1] ?? 'a tool call'
|
|
1113
|
+
throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in a scheduled run unless the Permissions tab sends approval requests somewhere (or a rule denies it outright).`)
|
|
1114
|
+
}
|
|
1115
|
+
return { kind: 'done', text: outcome.text }
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// A task with `notify` on reports each run through its agent's notifier.
|
|
1119
|
+
function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void {
|
|
1120
|
+
if (!task.notify) return
|
|
1121
|
+
const entry = getEntry(task.agent)
|
|
1122
|
+
if (!entry) return
|
|
1123
|
+
const label = task.task_name ? `"${task.task_name}"` : `(${describeSchedule(task.schedule)})`
|
|
1124
|
+
const text = run.status === 'ok' ? `Scheduled task ${label} for ${task.agent} ran:\n${run.result ?? ''}` : `Scheduled task ${label} for ${task.agent} failed: ${run.error ?? 'unknown error'}`
|
|
1125
|
+
announceToNotifier(entry.config, text, { scheduledTaskId: task.task_id, taskName: task.task_name ?? null, status: run.status, result: run.result ?? null, error: run.error ?? null }, defaultWebhook)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const scheduler = new Scheduler({ fire: fireScheduledTask, onSettled: announceScheduledRun })
|
|
1129
|
+
|
|
1130
|
+
function scheduleJson(res: ServerResponse, status: number, body: unknown): void {
|
|
1131
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body))
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function handleSchedulesGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1135
|
+
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1136
|
+
scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }) })
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1140
|
+
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1141
|
+
const body = await readJsonBody(req)
|
|
1142
|
+
try {
|
|
1143
|
+
const task = await createScheduledTask({
|
|
1144
|
+
agent: agentName,
|
|
1145
|
+
message: String(body.message ?? ''),
|
|
1146
|
+
schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown },
|
|
1147
|
+
task_name: typeof body.task_name === 'string' ? body.task_name : undefined,
|
|
1148
|
+
notify: body.notify === true,
|
|
1149
|
+
})
|
|
1150
|
+
scheduleJson(res, 201, task)
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
scheduleJson(res, err instanceof ScheduleError ? 400 : 500, { error: err instanceof Error ? err.message : String(err) })
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async function handleScheduleAction(res: ServerResponse, taskId: string, action: string): Promise<void> {
|
|
1157
|
+
try {
|
|
1158
|
+
if (action === 'run') await runScheduledTaskNow(taskId)
|
|
1159
|
+
else if (action === 'pause') await pauseScheduledTask(taskId)
|
|
1160
|
+
else if (action === 'resume') await resumeScheduledTask(taskId)
|
|
1161
|
+
else if (action === 'cancel') await cancelScheduledTask(taskId)
|
|
1162
|
+
else return scheduleJson(res, 404, { error: `unknown action '${action}'` })
|
|
1163
|
+
if (action === 'run') void scheduler.tick()
|
|
1164
|
+
scheduleJson(res, 200, await getScheduledTask(taskId))
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
scheduleJson(res, err instanceof ScheduleError ? 404 : 500, { error: err instanceof Error ? err.message : String(err) })
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1052
1170
|
// Backs the console's Permissions tab (where approval requests and questions are sent) — see
|
|
1053
1171
|
// web/notifier-admin.ts. Reading is open like the rest of the admin
|
|
1054
1172
|
// surface; saving or removing writes a secret and a file, so it gets the
|
|
@@ -1109,6 +1227,23 @@ async function handleNotifierTest(res: ServerResponse, agentName: string): Promi
|
|
|
1109
1227
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
|
|
1110
1228
|
}
|
|
1111
1229
|
|
|
1230
|
+
// Backs the console's Slack channel picker. A bot token typed into the form
|
|
1231
|
+
// (not saved yet) can be sent in the body; otherwise the saved one is used.
|
|
1232
|
+
async function handleSlackChannels(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1233
|
+
const entry = getEntry(agentName)
|
|
1234
|
+
if (!entry) {
|
|
1235
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1236
|
+
return
|
|
1237
|
+
}
|
|
1238
|
+
const body = await readJsonBody(req)
|
|
1239
|
+
try {
|
|
1240
|
+
const channels = await listAgentSlackChannels(entry.config, typeof body.botToken === 'string' ? body.botToken : undefined)
|
|
1241
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ channels }))
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1112
1247
|
// Every approval/question still waiting for this agent — for an app that
|
|
1113
1248
|
// can't receive webhooks (it polls this instead) and for the console.
|
|
1114
1249
|
async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -2261,6 +2396,10 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
|
|
|
2261
2396
|
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
2262
2397
|
})
|
|
2263
2398
|
|
|
2399
|
+
// A scheduled task's paused run (core/scheduler.ts) settles here.
|
|
2400
|
+
void scheduler
|
|
2401
|
+
.settleFromResume(finalCheckpoint.sessionId, { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) })
|
|
2402
|
+
.catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
|
|
2264
2403
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
2265
2404
|
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
2266
2405
|
)
|
|
@@ -2437,10 +2576,14 @@ function exitIfCodeChanged(res: ServerResponse): void {
|
|
|
2437
2576
|
let openStreams = 0
|
|
2438
2577
|
let lastRequestAt = Date.now()
|
|
2439
2578
|
|
|
2440
|
-
function handleActivityGet(res: ServerResponse): void {
|
|
2579
|
+
async function handleActivityGet(res: ServerResponse): Promise<void> {
|
|
2441
2580
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
2442
2581
|
JSON.stringify({
|
|
2443
|
-
inFlightTurns: sessionTurns.size,
|
|
2582
|
+
inFlightTurns: sessionTurns.size + scheduler.running,
|
|
2583
|
+
scheduledRuns: scheduler.running,
|
|
2584
|
+
// When the next scheduled task is due — wake this server by then
|
|
2585
|
+
// (POST /internal/scheduler/tick) if it's been stopped while idle.
|
|
2586
|
+
nextWakeAt: (await nextScheduledWakeAt()) ?? null,
|
|
2444
2587
|
openStreams,
|
|
2445
2588
|
lastRequestAt: new Date(lastRequestAt).toISOString(),
|
|
2446
2589
|
idleSeconds: Math.floor((Date.now() - lastRequestAt) / 1000),
|
|
@@ -2489,7 +2632,82 @@ async function handleDecide(req: IncomingMessage, res: ServerResponse, rawToken:
|
|
|
2489
2632
|
else await resolvePendingApproval(res, token.pendingId, { decision: body.decision, editedArgs: body.editedArgs })
|
|
2490
2633
|
}
|
|
2491
2634
|
|
|
2635
|
+
// Runs one of the resolve/answer handlers above without a real HTTP
|
|
2636
|
+
// response to write to — for the Slack endpoint below, which has already
|
|
2637
|
+
// answered Slack and reports the outcome back into the Slack message.
|
|
2638
|
+
function captureResponse(): { res: ServerResponse; done: Promise<{ status: number; body: Record<string, unknown> }> } {
|
|
2639
|
+
let resolveDone: (value: { status: number; body: Record<string, unknown> }) => void
|
|
2640
|
+
const done = new Promise<{ status: number; body: Record<string, unknown> }>((resolve) => (resolveDone = resolve))
|
|
2641
|
+
let status = 200
|
|
2642
|
+
const res = {
|
|
2643
|
+
headersSent: false,
|
|
2644
|
+
writeHead(code: number) {
|
|
2645
|
+
status = code
|
|
2646
|
+
return res
|
|
2647
|
+
},
|
|
2648
|
+
end(chunk?: string) {
|
|
2649
|
+
let body: Record<string, unknown> = {}
|
|
2650
|
+
try {
|
|
2651
|
+
body = chunk ? (JSON.parse(chunk) as Record<string, unknown>) : {}
|
|
2652
|
+
} catch {}
|
|
2653
|
+
resolveDone({ status, body })
|
|
2654
|
+
return res
|
|
2655
|
+
},
|
|
2656
|
+
}
|
|
2657
|
+
return { res: res as unknown as ServerResponse, done }
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// A Slack app's Interactivity Request URL (core/slack-interactions.ts):
|
|
2661
|
+
// Approve/Deny, an answer option, or the "Other…" modal, from a Slack
|
|
2662
|
+
// notifier's message. Outside the admin login on purpose — Slack can't
|
|
2663
|
+
// send it — and verified with that agent's Slack signing secret instead.
|
|
2664
|
+
// Slack wants an answer within three seconds, so the decision is acked
|
|
2665
|
+
// right away and its outcome posted back into the message afterwards.
|
|
2666
|
+
async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
2667
|
+
const rawBody = await readRawBody(req)
|
|
2668
|
+
const entry = getEntry(agentName)
|
|
2669
|
+
const { botToken, signingSecret } = entry ? slackCredentialsFor(entry.config) : {}
|
|
2670
|
+
if (!entry || !signingSecret) {
|
|
2671
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('No Slack signing secret is set for this agent.')
|
|
2672
|
+
return
|
|
2673
|
+
}
|
|
2674
|
+
const timestamp = req.headers['x-slack-request-timestamp']
|
|
2675
|
+
const signature = req.headers['x-slack-signature']
|
|
2676
|
+
if (!verifySlackRequest(rawBody, typeof timestamp === 'string' ? timestamp : undefined, typeof signature === 'string' ? signature : undefined, signingSecret)) {
|
|
2677
|
+
res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Slack signature.')
|
|
2678
|
+
return
|
|
2679
|
+
}
|
|
2680
|
+
const interaction = parseSlackInteraction(rawBody)
|
|
2681
|
+
if (interaction.type === 'open_answer') {
|
|
2682
|
+
if (botToken) await openSlackAnswerModal(botToken, interaction.triggerId, interaction.pendingId).catch((err) => console.error('[loopengine] Slack modal failed:', err))
|
|
2683
|
+
res.writeHead(200).end()
|
|
2684
|
+
return
|
|
2685
|
+
}
|
|
2686
|
+
res.writeHead(200).end()
|
|
2687
|
+
if (interaction.type === 'ignore') return
|
|
2688
|
+
|
|
2689
|
+
const capture = captureResponse()
|
|
2690
|
+
if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
|
|
2691
|
+
else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
|
|
2692
|
+
const outcome = await capture.done
|
|
2693
|
+
if (interaction.responseUrl) {
|
|
2694
|
+
const text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
|
|
2695
|
+
await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2492
2699
|
const server = createServer(async (req, res) => {
|
|
2700
|
+
const slackHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/slack\/([^/]+)$/)
|
|
2701
|
+
if (slackHookMatch) {
|
|
2702
|
+
try {
|
|
2703
|
+
await handleSlackInteraction(req, res, decodeURIComponent(slackHookMatch[1]))
|
|
2704
|
+
} catch (err) {
|
|
2705
|
+
console.error('[loopengine] Slack interaction failed:', err)
|
|
2706
|
+
if (!res.headersSent) res.writeHead(500).end()
|
|
2707
|
+
}
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2493
2711
|
const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
|
|
2494
2712
|
if (decideMatch) {
|
|
2495
2713
|
try {
|
|
@@ -2527,9 +2745,15 @@ const server = createServer(async (req, res) => {
|
|
|
2527
2745
|
// Only exists in trusted-proxy mode, and polling it doesn't count as
|
|
2528
2746
|
// activity itself.
|
|
2529
2747
|
if (trustedProxySecret && req.method === 'GET' && pathname === '/internal/activity') {
|
|
2530
|
-
handleActivityGet(res)
|
|
2748
|
+
await handleActivityGet(res)
|
|
2749
|
+
return
|
|
2750
|
+
}
|
|
2751
|
+
// A host waking this server for a due task: fire whatever is due now.
|
|
2752
|
+
if (trustedProxySecret && req.method === 'POST' && pathname === '/internal/scheduler/tick') {
|
|
2753
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ fired: await scheduler.tick() }))
|
|
2531
2754
|
return
|
|
2532
2755
|
}
|
|
2756
|
+
|
|
2533
2757
|
lastRequestAt = Date.now()
|
|
2534
2758
|
|
|
2535
2759
|
const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
|
|
@@ -2896,6 +3120,33 @@ const server = createServer(async (req, res) => {
|
|
|
2896
3120
|
await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
|
|
2897
3121
|
return
|
|
2898
3122
|
}
|
|
3123
|
+
// Backs the console's Schedules tab — see handleSchedulesGet above.
|
|
3124
|
+
const schedulesMatch = pathname.match(/^\/agents\/([^/]+)\/schedules$/)
|
|
3125
|
+
if (schedulesMatch && req.method === 'GET') {
|
|
3126
|
+
await handleSchedulesGet(res, decodeURIComponent(schedulesMatch[1]))
|
|
3127
|
+
return
|
|
3128
|
+
}
|
|
3129
|
+
if (schedulesMatch && req.method === 'POST') {
|
|
3130
|
+
await handleSchedulesPost(req, res, decodeURIComponent(schedulesMatch[1]))
|
|
3131
|
+
return
|
|
3132
|
+
}
|
|
3133
|
+
const scheduleActionMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/(run|pause|resume|cancel)$/)
|
|
3134
|
+
if (scheduleActionMatch) {
|
|
3135
|
+
await handleScheduleAction(res, scheduleActionMatch[1], scheduleActionMatch[2])
|
|
3136
|
+
return
|
|
3137
|
+
}
|
|
3138
|
+
const scheduleDeleteMatch = req.method === 'DELETE' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)$/)
|
|
3139
|
+
if (scheduleDeleteMatch) {
|
|
3140
|
+
await deleteScheduledTask(scheduleDeleteMatch[1])
|
|
3141
|
+
scheduleJson(res, 200, { ok: true })
|
|
3142
|
+
return
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
|
|
3146
|
+
if (slackChannelsMatch) {
|
|
3147
|
+
await handleSlackChannels(req, res, decodeURIComponent(slackChannelsMatch[1]))
|
|
3148
|
+
return
|
|
3149
|
+
}
|
|
2899
3150
|
const pendingListMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/pending$/)
|
|
2900
3151
|
if (pendingListMatch) {
|
|
2901
3152
|
await handlePendingGet(res, decodeURIComponent(pendingListMatch[1]))
|
|
@@ -3029,7 +3280,31 @@ const server = createServer(async (req, res) => {
|
|
|
3029
3280
|
})
|
|
3030
3281
|
|
|
3031
3282
|
const port = Number(process.env.PORT ?? 8787)
|
|
3032
|
-
server.listen(port, () =>
|
|
3283
|
+
server.listen(port, () => {
|
|
3284
|
+
console.log(`agent API listening on :${port}`)
|
|
3285
|
+
void warmAgentTools()
|
|
3286
|
+
// LOOPENGINE_SCHEDULER=off leaves scheduling to another process.
|
|
3287
|
+
if (process.env.LOOPENGINE_SCHEDULER !== 'off') void scheduler.start().catch((err) => console.error('[loopengine] scheduler failed to start:', err))
|
|
3288
|
+
})
|
|
3289
|
+
|
|
3290
|
+
// An agent that leaves `tools` unset gets its tools/ folder imported
|
|
3291
|
+
// lazily, on its first turn. Importing every agent's tools (and
|
|
3292
|
+
// subagents) right at startup instead means anything an ability's tool
|
|
3293
|
+
// file starts on load — a background timer, say — runs from boot, not
|
|
3294
|
+
// only after someone first talks to that agent. A broken tool file is
|
|
3295
|
+
// logged and skipped; its agent still fails loudly on its own first turn.
|
|
3296
|
+
async function warmAgentTools(): Promise<void> {
|
|
3297
|
+
for (const name of listAgents()) {
|
|
3298
|
+
const entry = getEntry(name)
|
|
3299
|
+
if (!entry) continue
|
|
3300
|
+
try {
|
|
3301
|
+
if (entry.config.tools === undefined) await loadDefaultTools(entry.config)
|
|
3302
|
+
await loadSubagentAsTools(entry.config)
|
|
3303
|
+
} catch (err) {
|
|
3304
|
+
console.error(`[loopengine] could not load tools for agent '${name}' at startup:`, err instanceof Error ? err.message : err)
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3033
3308
|
|
|
3034
3309
|
// server.close() alone only stops accepting *new* connections — it
|
|
3035
3310
|
// doesn't wait for existing ones, and the old code called process.exit
|
|
@@ -3059,6 +3334,7 @@ server.listen(port, () => console.log(`agent API listening on :${port}`))
|
|
|
3059
3334
|
// process restart, just this one process's own event loop.
|
|
3060
3335
|
async function shutdown() {
|
|
3061
3336
|
console.log('[loopengine] shutting down — draining in-flight requests...')
|
|
3337
|
+
scheduler.stop()
|
|
3062
3338
|
const graceMs = Number(process.env.LOOPENGINE_SHUTDOWN_GRACE_MS ?? 30000)
|
|
3063
3339
|
const closed = new Promise<void>((resolve) => server.close(() => resolve()))
|
|
3064
3340
|
// HTTP keep-alive means a connection whose last request already
|