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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,7 @@ node_modules
2
2
  dist
3
3
  .sessions
4
4
  .checkpoints
5
+ .scheduler
5
6
  .env
6
7
  *.log
7
8
  .DS_Store
@@ -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, () => console.log(`agent API listening on :${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
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.68",
12
+ "loopengine": "^0.1.70",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {