create-loopengine 0.1.54 → 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.54",
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,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, () => 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
+ }
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
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.69",
12
+ "loopengine": "^0.1.70",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {