create-loopengine 0.1.54 → 0.1.56
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 +168 -5
- package/template/package.json +1 -1
package/package.json
CHANGED
package/template/_gitignore
CHANGED
|
@@ -106,6 +106,21 @@ 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,
|
|
110
125
|
listAgentSlackChannels,
|
|
111
126
|
slackCredentialsFor,
|
|
@@ -157,6 +172,7 @@ import {
|
|
|
157
172
|
resolvePendingQuestion as answerQuestion,
|
|
158
173
|
findQuestion,
|
|
159
174
|
createAskUserTool,
|
|
175
|
+
createScheduleTools,
|
|
160
176
|
type PendingQuestion,
|
|
161
177
|
WebhookNotifier,
|
|
162
178
|
} from 'loopengine'
|
|
@@ -423,7 +439,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
423
439
|
// createAskUserTool() with no onPending, purely to describe its
|
|
424
440
|
// schema here — never executed, so the console-prompt fallback its
|
|
425
441
|
// own doc comment mentions never applies to this call.
|
|
426
|
-
systemTools: [...systemTools, createAskUserTool({ agent: config.name })].map(describeTool),
|
|
442
|
+
systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name))].map(describeTool),
|
|
427
443
|
localTools: localTools.map((t) => ({ ...describeTool(t), httpToolEditable: editableHttpToolNames.has(t.name) })),
|
|
428
444
|
agentAsTools: agentAsTools.map(describeTool),
|
|
429
445
|
gatewayTools: gatewayTools.map(describeTool),
|
|
@@ -1065,6 +1081,92 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
|
|
|
1065
1081
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
|
|
1066
1082
|
}
|
|
1067
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
|
+
|
|
1068
1170
|
// Backs the console's Permissions tab (where approval requests and questions are sent) — see
|
|
1069
1171
|
// web/notifier-admin.ts. Reading is open like the rest of the admin
|
|
1070
1172
|
// surface; saving or removing writes a secret and a file, so it gets the
|
|
@@ -2294,6 +2396,10 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
|
|
|
2294
2396
|
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
2295
2397
|
})
|
|
2296
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))
|
|
2297
2403
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
2298
2404
|
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
2299
2405
|
)
|
|
@@ -2470,10 +2576,14 @@ function exitIfCodeChanged(res: ServerResponse): void {
|
|
|
2470
2576
|
let openStreams = 0
|
|
2471
2577
|
let lastRequestAt = Date.now()
|
|
2472
2578
|
|
|
2473
|
-
function handleActivityGet(res: ServerResponse): void {
|
|
2579
|
+
async function handleActivityGet(res: ServerResponse): Promise<void> {
|
|
2474
2580
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
2475
2581
|
JSON.stringify({
|
|
2476
|
-
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,
|
|
2477
2587
|
openStreams,
|
|
2478
2588
|
lastRequestAt: new Date(lastRequestAt).toISOString(),
|
|
2479
2589
|
idleSeconds: Math.floor((Date.now() - lastRequestAt) / 1000),
|
|
@@ -2635,9 +2745,15 @@ const server = createServer(async (req, res) => {
|
|
|
2635
2745
|
// Only exists in trusted-proxy mode, and polling it doesn't count as
|
|
2636
2746
|
// activity itself.
|
|
2637
2747
|
if (trustedProxySecret && req.method === 'GET' && pathname === '/internal/activity') {
|
|
2638
|
-
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() }))
|
|
2639
2754
|
return
|
|
2640
2755
|
}
|
|
2756
|
+
|
|
2641
2757
|
lastRequestAt = Date.now()
|
|
2642
2758
|
|
|
2643
2759
|
const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
|
|
@@ -3004,6 +3120,28 @@ const server = createServer(async (req, res) => {
|
|
|
3004
3120
|
await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
|
|
3005
3121
|
return
|
|
3006
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
|
+
|
|
3007
3145
|
const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
|
|
3008
3146
|
if (slackChannelsMatch) {
|
|
3009
3147
|
await handleSlackChannels(req, res, decodeURIComponent(slackChannelsMatch[1]))
|
|
@@ -3142,7 +3280,31 @@ const server = createServer(async (req, res) => {
|
|
|
3142
3280
|
})
|
|
3143
3281
|
|
|
3144
3282
|
const port = Number(process.env.PORT ?? 8787)
|
|
3145
|
-
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
|
+
}
|
|
3146
3308
|
|
|
3147
3309
|
// server.close() alone only stops accepting *new* connections — it
|
|
3148
3310
|
// doesn't wait for existing ones, and the old code called process.exit
|
|
@@ -3172,6 +3334,7 @@ server.listen(port, () => console.log(`agent API listening on :${port}`))
|
|
|
3172
3334
|
// process restart, just this one process's own event loop.
|
|
3173
3335
|
async function shutdown() {
|
|
3174
3336
|
console.log('[loopengine] shutting down — draining in-flight requests...')
|
|
3337
|
+
scheduler.stop()
|
|
3175
3338
|
const graceMs = Number(process.env.LOOPENGINE_SHUTDOWN_GRACE_MS ?? 30000)
|
|
3176
3339
|
const closed = new Promise<void>((resolve) => server.close(() => resolve()))
|
|
3177
3340
|
// HTTP keep-alive means a connection whose last request already
|