create-loopengine 0.2.1 → 0.3.1
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/adapters/http.ts +665 -37
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -122,6 +122,7 @@ import {
|
|
|
122
122
|
TRUSTED_PROXY_IDENTITY_HEADER,
|
|
123
123
|
NotifierFileError,
|
|
124
124
|
verifyDecisionToken,
|
|
125
|
+
decidePath,
|
|
125
126
|
renderDecidePage,
|
|
126
127
|
renderDecideGonePage,
|
|
127
128
|
renderDecideInvalidPage,
|
|
@@ -139,12 +140,22 @@ import {
|
|
|
139
140
|
pauseScheduledTask,
|
|
140
141
|
resumeScheduledTask,
|
|
141
142
|
runScheduledTaskNow,
|
|
143
|
+
setScheduledTaskNotify,
|
|
142
144
|
type ScheduledTask,
|
|
143
145
|
type ScheduledTaskRun,
|
|
144
146
|
listAgentPending,
|
|
145
147
|
listAgentSlackChannels,
|
|
146
148
|
slackCredentialsFor,
|
|
147
|
-
|
|
149
|
+
agentTelegramWebhookInfo,
|
|
150
|
+
clickAllowed,
|
|
151
|
+
connectionPublicUrl,
|
|
152
|
+
conversationsOn,
|
|
153
|
+
jobAllowed,
|
|
154
|
+
jobsOn,
|
|
155
|
+
listAgentTelegramPeople,
|
|
156
|
+
setupAgentTelegramWebhook,
|
|
157
|
+
telegramCredentialsFor,
|
|
158
|
+
type TwoWayApp,
|
|
148
159
|
slackClickAllowed,
|
|
149
160
|
agentApprovers,
|
|
150
161
|
setAgentApprovers,
|
|
@@ -166,6 +177,28 @@ import {
|
|
|
166
177
|
firstSighting,
|
|
167
178
|
handleInboundMessage,
|
|
168
179
|
createSlackConnector,
|
|
180
|
+
outcomeText,
|
|
181
|
+
handleJobMessage,
|
|
182
|
+
isJobMessage,
|
|
183
|
+
settleJobThread,
|
|
184
|
+
type JobDeps,
|
|
185
|
+
type JobTurn,
|
|
186
|
+
findJobThreadBySession,
|
|
187
|
+
findThread,
|
|
188
|
+
type InboundMessage,
|
|
189
|
+
type MessagingConnector,
|
|
190
|
+
formatArgs,
|
|
191
|
+
answerTelegramClick,
|
|
192
|
+
createTelegramConnector,
|
|
193
|
+
editTelegramMessage,
|
|
194
|
+
parseTelegramUpdate,
|
|
195
|
+
readTelegramThread,
|
|
196
|
+
recordTelegramMessage,
|
|
197
|
+
telegramBotUsername,
|
|
198
|
+
telegramEscape,
|
|
199
|
+
telegramRootOf,
|
|
200
|
+
verifyTelegramRequest,
|
|
201
|
+
type TelegramUpdate,
|
|
169
202
|
parseSlackEvent,
|
|
170
203
|
removeAgentNotifier,
|
|
171
204
|
saveAgentNotifier,
|
|
@@ -201,9 +234,11 @@ import {
|
|
|
201
234
|
forgetWorkflow,
|
|
202
235
|
getPlanJob,
|
|
203
236
|
listAgentWorkflows,
|
|
237
|
+
migrateAgentWorkflows,
|
|
204
238
|
planWorkflow,
|
|
205
|
-
|
|
239
|
+
prepareWorkflowRun,
|
|
206
240
|
startPlanJob,
|
|
241
|
+
updateWorkflow,
|
|
207
242
|
type WorkflowAgentInfo,
|
|
208
243
|
type WorkflowMcpDeps,
|
|
209
244
|
workflowPageHtml,
|
|
@@ -273,6 +308,39 @@ import {
|
|
|
273
308
|
createScheduleTools,
|
|
274
309
|
type PendingQuestion,
|
|
275
310
|
WebhookNotifier,
|
|
311
|
+
publicBaseUrl,
|
|
312
|
+
// Workflows (skills with a start message), their runs, run callbacks
|
|
313
|
+
// and webhook triggers.
|
|
314
|
+
WorkflowParamsError,
|
|
315
|
+
readWorkflow,
|
|
316
|
+
readWorkflows,
|
|
317
|
+
validateParams,
|
|
318
|
+
workflowRunMessage,
|
|
319
|
+
WorkflowRunner,
|
|
320
|
+
getRun,
|
|
321
|
+
getRunStore,
|
|
322
|
+
listRuns,
|
|
323
|
+
type WorkflowRun,
|
|
324
|
+
CallbackUrlError,
|
|
325
|
+
callbackSecret,
|
|
326
|
+
checkCallbackUrl,
|
|
327
|
+
deliverCallback,
|
|
328
|
+
runCallbackPayload,
|
|
329
|
+
type RunCallbackEvent,
|
|
330
|
+
TriggerError,
|
|
331
|
+
createTrigger,
|
|
332
|
+
deliveryId,
|
|
333
|
+
findTriggerByToken,
|
|
334
|
+
getTriggerStore,
|
|
335
|
+
listTriggers,
|
|
336
|
+
paramsFromPayload,
|
|
337
|
+
parsePayload,
|
|
338
|
+
passesFilter,
|
|
339
|
+
publicTrigger,
|
|
340
|
+
recordTriggerDelivery as recordDelivery,
|
|
341
|
+
rotateTrigger,
|
|
342
|
+
updateTrigger,
|
|
343
|
+
verifySenderSignature,
|
|
276
344
|
} from 'loopengine'
|
|
277
345
|
import { SkillGarden } from 'loopengine'
|
|
278
346
|
import type { Decision, PendingApproval } from 'actauth'
|
|
@@ -369,6 +437,27 @@ function readRawBody(req: IncomingMessage): Promise<string> {
|
|
|
369
437
|
})
|
|
370
438
|
}
|
|
371
439
|
|
|
440
|
+
/** The raw request body, refused past `maxBytes` (null then). */
|
|
441
|
+
function readLimitedRawBuffer(req: IncomingMessage, maxBytes: number): Promise<Buffer | null> {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
const chunks: Buffer[] = []
|
|
444
|
+
let size = 0
|
|
445
|
+
let over = false
|
|
446
|
+
req.on('data', (chunk: Buffer) => {
|
|
447
|
+
if (over) return
|
|
448
|
+
size += chunk.length
|
|
449
|
+
if (size > maxBytes) {
|
|
450
|
+
over = true
|
|
451
|
+
resolve(null)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
chunks.push(chunk)
|
|
455
|
+
})
|
|
456
|
+
req.on('end', () => { if (!over) resolve(Buffer.concat(chunks)) })
|
|
457
|
+
req.on('error', reject)
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
372
461
|
/** readJsonBody with a size cap, for uploads: a body past `maxBytes` is
|
|
373
462
|
* refused before it's all read. */
|
|
374
463
|
function readLimitedJsonBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
|
|
@@ -563,7 +652,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
563
652
|
// createAskUserTool() with no onPending, purely to describe its
|
|
564
653
|
// schema here — never executed, so the console-prompt fallback its
|
|
565
654
|
// own doc comment mentions never applies to this call.
|
|
566
|
-
systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name))].map(describeTool),
|
|
655
|
+
systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name, { agentDir: agentDirFor(config) }))].map(describeTool),
|
|
567
656
|
localTools: localTools.map((t) => ({ ...describeTool(t), httpToolEditable: editableHttpToolNames.has(t.name) })),
|
|
568
657
|
agentAsTools: agentAsTools.map(describeTool),
|
|
569
658
|
gatewayTools: gatewayTools.map(describeTool),
|
|
@@ -1422,6 +1511,11 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1422
1511
|
return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
|
|
1423
1512
|
},
|
|
1424
1513
|
})
|
|
1514
|
+
// The planned webhook's URL, shown once on the done screen.
|
|
1515
|
+
if (created.webhook) {
|
|
1516
|
+
const { token, ...webhook } = created.webhook
|
|
1517
|
+
return json(200, { ...created, webhook: { ...webhook, url: `${requestBaseUrl(req)}/hooks/workflows/${token}` } })
|
|
1518
|
+
}
|
|
1425
1519
|
json(200, created)
|
|
1426
1520
|
// New tools in an agent that was already running load with a restart,
|
|
1427
1521
|
// as after installing an ability from its tab.
|
|
@@ -1437,7 +1531,28 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1437
1531
|
}
|
|
1438
1532
|
}
|
|
1439
1533
|
|
|
1440
|
-
//
|
|
1534
|
+
// How long POST .../workflows/:id/run with "wait": true waits for the run
|
|
1535
|
+
// before answering 202 with its runId (look it up later with GET
|
|
1536
|
+
// /agents/:name/runs/:runId); under the 90 s an ingress allows.
|
|
1537
|
+
const WORKFLOW_WAIT_MS = Number(process.env.LOOPENGINE_WORKFLOW_WAIT_MS) || 85_000
|
|
1538
|
+
|
|
1539
|
+
// An agent's workflows (its Overview, and apps over the API): list, run
|
|
1540
|
+
// now with parameters, edit how runs start (PUT), forget.
|
|
1541
|
+
//
|
|
1542
|
+
// POST /agents/:name/workflows/:id/run
|
|
1543
|
+
// { "params": {…}, "wait": true, "notify": true, "callback_url": "https://…" }
|
|
1544
|
+
//
|
|
1545
|
+
// "notify" sends the result through the agent's messaging connection when
|
|
1546
|
+
// the run ends; "callback_url" gets the run's record posted to it when it
|
|
1547
|
+
// ends or stops for a decision, signed (core/run-callbacks.ts). Both are
|
|
1548
|
+
// optional, and work with or without "wait".
|
|
1549
|
+
//
|
|
1550
|
+
// starts a one-off run. Without "wait" it answers 202 { runId } straight
|
|
1551
|
+
// away. With it, 200 { runId, text } once the run finishes, 202 { runId,
|
|
1552
|
+
// pending: true } if it stops for an approval or a question, or 202
|
|
1553
|
+
// { runId, running: true } if it's still going after WORKFLOW_WAIT_MS.
|
|
1554
|
+
// A failed run answers 502 { runId, error }. Wrong or missing params are
|
|
1555
|
+
// a 400 before anything runs.
|
|
1441
1556
|
async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
|
|
1442
1557
|
const json = (status: number, body: unknown): void => {
|
|
1443
1558
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
@@ -1446,15 +1561,46 @@ async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, a
|
|
|
1446
1561
|
const dir = agentDir(agentName)
|
|
1447
1562
|
try {
|
|
1448
1563
|
if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
|
|
1449
|
-
if (id && action === '
|
|
1450
|
-
if (id &&
|
|
1451
|
-
|
|
1564
|
+
if (id && action === 'runs' && req.method === 'GET') return json(200, { runs: await listRuns(agentName, { workflowId: id, limit: 50 }) })
|
|
1565
|
+
if (id && action === 'run' && req.method === 'POST') {
|
|
1566
|
+
const body = await readJsonBody(req)
|
|
1567
|
+
if (body.notify !== undefined && typeof body.notify !== 'boolean') return json(400, { error: 'notify must be true or false' })
|
|
1568
|
+
let callbackUrl: string | undefined
|
|
1569
|
+
if (body.callback_url !== undefined && body.callback_url !== null && body.callback_url !== '') {
|
|
1570
|
+
try {
|
|
1571
|
+
callbackUrl = checkCallbackUrl(body.callback_url)
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
return json(400, { error: err instanceof CallbackUrlError ? err.message : String(err) })
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
const prepared = await prepareWorkflowRun(agentName, dir, id, body.params)
|
|
1577
|
+
const run = await workflowRunner.start({
|
|
1578
|
+
agent: agentName, workflow_id: id, params: prepared.params, source: body.source === 'console' ? 'console' : 'api', message: prepared.message,
|
|
1579
|
+
...(body.notify === true ? { notify: true } : {}), ...(callbackUrl ? { callback_url: callbackUrl } : {}),
|
|
1580
|
+
})
|
|
1581
|
+
if (body.wait !== true) return json(202, { runId: run.run_id })
|
|
1582
|
+
const deadline = Date.now() + WORKFLOW_WAIT_MS
|
|
1583
|
+
for (;;) {
|
|
1584
|
+
const current = await getRun(agentName, run.run_id)
|
|
1585
|
+
if (current?.status === 'awaiting_approval') return json(202, { runId: run.run_id, pending: true })
|
|
1586
|
+
if (current?.status === 'done') return json(200, { runId: run.run_id, text: current.result ?? '' })
|
|
1587
|
+
if (current?.status === 'error') return json(502, { runId: run.run_id, error: current.error ?? 'The run failed.' })
|
|
1588
|
+
if (Date.now() >= deadline) return json(202, { runId: run.run_id, running: true })
|
|
1589
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (id && !action && req.method === 'PUT') {
|
|
1593
|
+
updateWorkflow(dir, id, await readJsonBody(req))
|
|
1452
1594
|
return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
|
|
1453
1595
|
}
|
|
1596
|
+
if (id && !action && req.method === 'DELETE') {
|
|
1597
|
+
const { cancelled } = await forgetWorkflow(agentName, dir, id)
|
|
1598
|
+
return json(200, { cancelledSchedules: cancelled, workflows: await listAgentWorkflows(agentName, dir) })
|
|
1599
|
+
}
|
|
1454
1600
|
return json(404, { error: 'not found' })
|
|
1455
1601
|
} catch (err) {
|
|
1456
1602
|
const message = err instanceof Error ? err.message : String(err)
|
|
1457
|
-
return json(err instanceof WorkflowError ? 400 : 500, { error: message })
|
|
1603
|
+
return json(err instanceof WorkflowError || err instanceof WorkflowParamsError ? 400 : 500, { error: message })
|
|
1458
1604
|
}
|
|
1459
1605
|
}
|
|
1460
1606
|
|
|
@@ -1743,34 +1889,71 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
|
|
|
1743
1889
|
// durable approval, and with no notifier at all it's denied rather than
|
|
1744
1890
|
// left waiting on nobody — a scheduled run has no one live in it. ----
|
|
1745
1891
|
|
|
1746
|
-
const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended
|
|
1892
|
+
const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended run. Use your own best judgement and proceed without waiting for a response.'
|
|
1747
1893
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1894
|
+
// One unattended agent turn: a scheduled task firing, or a workflow run
|
|
1895
|
+
// started by hand or by an app (core/workflow-runs.ts).
|
|
1896
|
+
async function executeUnattendedRun(agent: string, message: string, rawSessionId: string): Promise<{ kind: 'done'; text: string } | { kind: 'pending'; pendingIds: string[] }> {
|
|
1897
|
+
const entry = getEntry(agent)
|
|
1898
|
+
if (!entry) throw new Error(`no agent named "${agent}" in this project`)
|
|
1751
1899
|
const tenant = 'default'
|
|
1752
1900
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
1753
|
-
const outcome = await sessions.withSession(`${tenant}:${environment}:${
|
|
1754
|
-
const result = await runAgent(entry.config, entry.createModelCall(),
|
|
1901
|
+
const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
|
|
1902
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
1755
1903
|
tenant,
|
|
1756
1904
|
sessionId: rawSessionId,
|
|
1757
1905
|
channel: 'http',
|
|
1758
1906
|
approver: defaultHttpWebhookNotifier ?? { requestApproval: async () => false },
|
|
1759
1907
|
questionHandler: defaultHttpWebhookNotifier ?? { requestQuestion: async () => NO_HUMAN_ANSWER },
|
|
1760
1908
|
})
|
|
1761
|
-
if (result.pending) await createCheckpointFromPending(
|
|
1909
|
+
if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
|
|
1762
1910
|
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pendingIds: result.pending?.outstanding.map((o) => o.pendingId) } }
|
|
1763
1911
|
})
|
|
1764
1912
|
if (outcome.pendingIds?.length) return { kind: 'pending', pendingIds: outcome.pendingIds }
|
|
1765
|
-
// run-agent's "you denied" wording is for someone live in a chat; in
|
|
1766
|
-
//
|
|
1913
|
+
// run-agent's "you denied" wording is for someone live in a chat; in an
|
|
1914
|
+
// unattended run nobody denied anything, so say what actually happened.
|
|
1767
1915
|
if (outcome.stopReason === 'denied') {
|
|
1768
1916
|
const tools = /denied: (.+?)\. Send/.exec(outcome.text)?.[1] ?? 'a tool call'
|
|
1769
|
-
throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in
|
|
1917
|
+
throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in an unattended run unless the Permissions tab sends approval requests somewhere (or a rule denies it outright).`)
|
|
1770
1918
|
}
|
|
1771
1919
|
return { kind: 'done', text: outcome.text }
|
|
1772
1920
|
}
|
|
1773
1921
|
|
|
1922
|
+
// A run started over the API with "notify" or "callback_url" reports
|
|
1923
|
+
// when it ends (and, to its callback, when it stops for a decision): its
|
|
1924
|
+
// result through the agent's notifier, and its record to the URL.
|
|
1925
|
+
async function reportWorkflowRun(run: WorkflowRun): Promise<void> {
|
|
1926
|
+
const event: RunCallbackEvent | undefined = run.status === 'done' ? 'run.done' : run.status === 'error' ? 'run.failed' : run.status === 'awaiting_approval' ? 'run.awaiting_approval' : undefined
|
|
1927
|
+
if (!event) return
|
|
1928
|
+
const entry = getEntry(run.agent)
|
|
1929
|
+
if (run.notify && entry && event !== 'run.awaiting_approval') {
|
|
1930
|
+
const text = run.status === 'done' ? `Workflow ${run.workflow_id} for ${run.agent} ran:\n${run.result ?? ''}` : `Workflow ${run.workflow_id} for ${run.agent} failed: ${run.error ?? 'unknown error'}`
|
|
1931
|
+
announceToNotifier(entry.config, text, { runId: run.run_id, workflowId: run.workflow_id, status: run.status, result: run.result ?? null, error: run.error ?? null }, defaultWebhook)
|
|
1932
|
+
}
|
|
1933
|
+
if (run.callback_url) {
|
|
1934
|
+
const delivery = await deliverCallback(run.callback_url, event, runCallbackPayload(event, run))
|
|
1935
|
+
await getRunStore().update(run.run_id, (r) => r && { ...r, callbacks: [...(r.callbacks ?? []), delivery].slice(-10) })
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Workflow runs, whatever starts them, each recorded apart from schedules.
|
|
1940
|
+
const workflowRunner = new WorkflowRunner({ execute: executeUnattendedRun, onChange: reportWorkflowRun })
|
|
1941
|
+
|
|
1942
|
+
// A task that runs a workflow is also recorded as a run of that workflow,
|
|
1943
|
+
// so the workflow's Recent runs shows its scheduled runs too.
|
|
1944
|
+
function fireScheduledTask(task: ScheduledTask, rawSessionId: string): ReturnType<typeof executeUnattendedRun> {
|
|
1945
|
+
if (!task.workflow_id) return executeUnattendedRun(task.agent, task.message, rawSessionId)
|
|
1946
|
+
return workflowRunner.runForSchedule({
|
|
1947
|
+
agent: task.agent,
|
|
1948
|
+
workflow_id: task.workflow_id,
|
|
1949
|
+
params: task.params,
|
|
1950
|
+
task_id: task.task_id,
|
|
1951
|
+
task_name: task.task_name,
|
|
1952
|
+
message: task.message,
|
|
1953
|
+
session_id: rawSessionId,
|
|
1954
|
+
})
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1774
1957
|
// A task with `notify` on reports each run through its agent's notifier.
|
|
1775
1958
|
function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void {
|
|
1776
1959
|
if (!task.notify) return
|
|
@@ -1781,24 +1964,71 @@ function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void
|
|
|
1781
1964
|
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)
|
|
1782
1965
|
}
|
|
1783
1966
|
|
|
1784
|
-
|
|
1967
|
+
// A workflow task's message is worked out at run time from the workflow
|
|
1968
|
+
// as it is then: its start message and steps, with the task's params
|
|
1969
|
+
// (which must still fit its parameters) filled in.
|
|
1970
|
+
const scheduler = new Scheduler({
|
|
1971
|
+
fire: fireScheduledTask,
|
|
1972
|
+
resolveWorkflowMessage: (agent, workflowId, params) => {
|
|
1973
|
+
const workflow = getEntry(agent) ? readWorkflow(agentDir(agent), workflowId) : undefined
|
|
1974
|
+
return workflow && workflowRunMessage(workflow, validateParams(workflow, params))
|
|
1975
|
+
},
|
|
1976
|
+
onSettled: announceScheduledRun,
|
|
1977
|
+
})
|
|
1785
1978
|
|
|
1786
1979
|
function scheduleJson(res: ServerResponse, status: number, body: unknown): void {
|
|
1787
1980
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body))
|
|
1788
1981
|
}
|
|
1789
1982
|
|
|
1983
|
+
// A workflow's start message with a task's params in its $name
|
|
1984
|
+
// placeholders (a declared one not given becomes empty), for the
|
|
1985
|
+
// Schedules tab; runs themselves use workflowRunMessage.
|
|
1986
|
+
function fillStartMessage(text: string, params: Record<string, string>, declared: string[]): string {
|
|
1987
|
+
return text.replace(/\$([a-z_][a-z0-9_]*)/g, (match, name: string) =>
|
|
1988
|
+
Object.prototype.hasOwnProperty.call(params, name) ? params[name]! : declared.includes(name) ? '' : match)
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// The agent's tasks, a workflow task with its workflow's name and current
|
|
1992
|
+
// start message (or `workflow_missing` once the workflow is gone), and
|
|
1993
|
+
// the agent's workflows, for the New schedule form's choice.
|
|
1790
1994
|
async function handleSchedulesGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1791
1995
|
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1792
|
-
|
|
1996
|
+
await migrateAgentWorkflows(agentName, agentDir(agentName))
|
|
1997
|
+
const workflows = readWorkflows(agentDir(agentName))
|
|
1998
|
+
const tasks = (await listScheduledTasks({ agent: agentName })).map((t) => {
|
|
1999
|
+
if (!t.workflow_id) return t
|
|
2000
|
+
const workflow = workflows.find((w) => w.id === t.workflow_id)
|
|
2001
|
+
// Its start message as this task's runs see it, parameters filled in.
|
|
2002
|
+
const startMessage = workflow && fillStartMessage(workflow.startMessage, t.params ?? {}, Object.keys(workflow.parameters))
|
|
2003
|
+
return workflow ? { ...t, workflow: { id: workflow.id, title: workflow.title, start_message: startMessage } } : { ...t, workflow_missing: true }
|
|
2004
|
+
})
|
|
2005
|
+
scheduleJson(res, 200, {
|
|
2006
|
+
tasks,
|
|
2007
|
+
workflows: workflows.map((w) => ({ id: w.id, title: w.title, description: w.description, start_message: w.startMessage, parameters: w.parameters })),
|
|
2008
|
+
time_zone: workspaceTimeZone(),
|
|
2009
|
+
})
|
|
1793
2010
|
}
|
|
1794
2011
|
|
|
1795
2012
|
async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1796
2013
|
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1797
2014
|
const body = await readJsonBody(req)
|
|
2015
|
+
const workflowId = typeof body.workflow_id === 'string' && body.workflow_id ? body.workflow_id : undefined
|
|
2016
|
+
let params: Record<string, string> | undefined
|
|
2017
|
+
if (workflowId) {
|
|
2018
|
+
const workflow = readWorkflow(agentDir(agentName), workflowId)
|
|
2019
|
+
if (!workflow) return scheduleJson(res, 400, { error: `${agentName} has no workflow "${workflowId}"` })
|
|
2020
|
+
try {
|
|
2021
|
+
params = validateParams(workflow, body.params)
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
return scheduleJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
1798
2026
|
try {
|
|
1799
2027
|
const task = await createScheduledTask({
|
|
1800
2028
|
agent: agentName,
|
|
1801
|
-
message:
|
|
2029
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
2030
|
+
workflow_id: workflowId,
|
|
2031
|
+
...(params && Object.keys(params).length ? { params } : {}),
|
|
1802
2032
|
schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown; at?: unknown; weekday?: unknown },
|
|
1803
2033
|
task_name: typeof body.task_name === 'string' ? body.task_name : undefined,
|
|
1804
2034
|
notify: body.notify === true,
|
|
@@ -1942,7 +2172,7 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
|
|
|
1942
2172
|
}
|
|
1943
2173
|
const body = await readJsonBody(req)
|
|
1944
2174
|
try {
|
|
1945
|
-
const approvers = setAgentApprovers(entry.config, body.approvers)
|
|
2175
|
+
const approvers = setAgentApprovers(entry.config, body.approvers, body.app === 'telegram' ? 'telegram' : 'slack')
|
|
1946
2176
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
|
|
1947
2177
|
} catch (err) {
|
|
1948
2178
|
const status = err instanceof NotifierFileError ? 400 : 500
|
|
@@ -1950,6 +2180,26 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
|
|
|
1950
2180
|
}
|
|
1951
2181
|
}
|
|
1952
2182
|
|
|
2183
|
+
// Messaging tab → Telegram: where the bot's updates go (GET), and
|
|
2184
|
+
// pointing them at this server (POST), which turns on buttons and
|
|
2185
|
+
// replies. Setting it needs the admin login, like saving a secret.
|
|
2186
|
+
async function handleTelegramWebhookSetup(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
2187
|
+
const json = (status: number, body: unknown): void => {
|
|
2188
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
2189
|
+
}
|
|
2190
|
+
const entry = getEntry(agentName)
|
|
2191
|
+
if (!entry) return json(404, { error: `unknown agent '${agentName}'` })
|
|
2192
|
+
try {
|
|
2193
|
+
if (req.method === 'GET') return json(200, await agentTelegramWebhookInfo(entry.config))
|
|
2194
|
+
if (!secretsProtected) return json(403, { error: 'Setting up the Telegram webhook needs LOOPENGINE_ADMIN_AUTH set on the server.' })
|
|
2195
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
|
|
2196
|
+
const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
|
|
2197
|
+
return json(200, await setupAgentTelegramWebhook(entry.config, base))
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
return json(400, { error: err instanceof Error ? err.message : String(err) })
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
1953
2203
|
async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
|
|
1954
2204
|
const entry = getEntry(agentName)
|
|
1955
2205
|
if (!entry) {
|
|
@@ -3170,6 +3420,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3170
3420
|
}
|
|
3171
3421
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
3172
3422
|
const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
|
|
3423
|
+
const fromJobThread = Boolean(findJobThreadBySession(agentDirFor(entry.config), finalCheckpoint.sessionId))
|
|
3173
3424
|
|
|
3174
3425
|
const responseBody = await sessions.withSession(storageSessionId, async (history) => {
|
|
3175
3426
|
// Same channel/approver/questionHandler fallback the original
|
|
@@ -3197,8 +3448,9 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3197
3448
|
tenant: finalCheckpoint.tenant,
|
|
3198
3449
|
sessionId: finalCheckpoint.sessionId,
|
|
3199
3450
|
channel: 'http',
|
|
3200
|
-
|
|
3201
|
-
|
|
3451
|
+
// A job thread's run keeps asking in its thread (runJobTurn).
|
|
3452
|
+
approver: fromJobThread ? threadApprover : defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
|
|
3453
|
+
questionHandler: fromJobThread ? threadQuestions : defaultHttpWebhookNotifier,
|
|
3202
3454
|
onQuestionPending: () => {},
|
|
3203
3455
|
})
|
|
3204
3456
|
// The resumed turn can itself hit another durably-pending call/
|
|
@@ -3211,10 +3463,12 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3211
3463
|
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
3212
3464
|
})
|
|
3213
3465
|
|
|
3214
|
-
// A scheduled task's paused run (core/scheduler.ts)
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3466
|
+
// A scheduled task's paused run (core/scheduler.ts), or a workflow run's
|
|
3467
|
+
// (core/workflow-runs.ts), settles here.
|
|
3468
|
+
const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
|
|
3469
|
+
void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
|
|
3470
|
+
void workflowRunner.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle workflow run:', err))
|
|
3471
|
+
if (fromJobThread) void settleJobFromResume(finalCheckpoint.agent, finalCheckpoint.sessionId, responseBody.text, responseBody.pending).catch((err) => console.error('[loopengine] could not answer in the job thread:', err))
|
|
3218
3472
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
3219
3473
|
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
3220
3474
|
)
|
|
@@ -3407,7 +3661,7 @@ async function tracked<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
3407
3661
|
async function handleActivityGet(res: ServerResponse): Promise<void> {
|
|
3408
3662
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
3409
3663
|
JSON.stringify({
|
|
3410
|
-
inFlightTurns: sessionTurns.size + scheduler.running + backgroundWork,
|
|
3664
|
+
inFlightTurns: sessionTurns.size + scheduler.running + workflowRunner.running + backgroundWork,
|
|
3411
3665
|
scheduledRuns: scheduler.running,
|
|
3412
3666
|
// When the next scheduled task is due — wake this server by then
|
|
3413
3667
|
// (POST /internal/scheduler/tick) if it's been stopped while idle.
|
|
@@ -3589,24 +3843,217 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
|
|
|
3589
3843
|
}
|
|
3590
3844
|
// Slack wants an answer within three seconds; the reply is posted after.
|
|
3591
3845
|
res.writeHead(200).end()
|
|
3592
|
-
if (event.type !== 'message'
|
|
3593
|
-
await
|
|
3846
|
+
if (event.type !== 'message') return
|
|
3847
|
+
await routeInboundMessage(agentName, entry, event, createSlackConnector(botToken))
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
// Where a person's message goes (core/messaging/): a reply under a
|
|
3851
|
+
// request still waiting goes to the request's thread conversation; a DM,
|
|
3852
|
+
// a mention, or a message in a job's thread runs the agent
|
|
3853
|
+
// (core/messaging/jobs.ts). Anything else is left alone. Checked before
|
|
3854
|
+
// the message counts as seen: Slack sends a mention twice (as a message
|
|
3855
|
+
// and as an app_mention), and only the app_mention starts a job.
|
|
3856
|
+
async function routeInboundMessage(agentName: string, entry: RegistryEntry, message: InboundMessage, connector: MessagingConnector): Promise<void> {
|
|
3857
|
+
const app = connector.app as TwoWayApp
|
|
3858
|
+
const dir = agentDirFor(entry.config)
|
|
3859
|
+
const jobs = jobsOn(entry.config, app) && isJobMessage(dir, message)
|
|
3860
|
+
let target: 'request' | 'job' | undefined = jobs ? 'job' : undefined
|
|
3861
|
+
if (message.threadTs) {
|
|
3862
|
+
const link = findThread(dir, app, message.conversation, message.threadTs)
|
|
3863
|
+
// A request already decided in a job's thread: the job carries on.
|
|
3864
|
+
if (link && (!jobs || (await checkpoints.get(link.pendingId)))) target = conversationsOn(entry.config, app) ? 'request' : undefined
|
|
3865
|
+
}
|
|
3866
|
+
if (!target || !firstSighting(message)) return
|
|
3867
|
+
if (target === 'request') await handleInboundMessage(message, messagingDeps(agentName, entry, connector))
|
|
3868
|
+
else await handleJobMessage(message, jobDeps(agentName, entry, connector))
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
// A Telegram bot's webhook (core/messaging/connectors/telegram/), set up
|
|
3872
|
+
// from the Messaging tab: people's messages (replies to a request, and
|
|
3873
|
+
// DMs and mentions that start jobs) and clicks on the agent's buttons.
|
|
3874
|
+
// Outside the admin login like Slack's endpoints, and checked against the
|
|
3875
|
+
// secret Telegram was given when the webhook was set. Telegram resends an
|
|
3876
|
+
// update that isn't answered 200 promptly, so it's answered first.
|
|
3877
|
+
async function handleTelegramWebhook(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
3878
|
+
const rawBody = await readRawBody(req)
|
|
3879
|
+
const entry = getEntry(agentName)
|
|
3880
|
+
const { botToken, webhookSecret } = entry ? telegramCredentialsFor(entry.config) : {}
|
|
3881
|
+
if (!entry || !botToken || !webhookSecret) {
|
|
3882
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('No Telegram webhook is set up for this agent.')
|
|
3883
|
+
return
|
|
3884
|
+
}
|
|
3885
|
+
const header = req.headers['x-telegram-bot-api-secret-token']
|
|
3886
|
+
if (!verifyTelegramRequest(typeof header === 'string' ? header : undefined, webhookSecret)) {
|
|
3887
|
+
res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Telegram secret token.')
|
|
3888
|
+
return
|
|
3889
|
+
}
|
|
3890
|
+
res.writeHead(200).end()
|
|
3891
|
+
const dir = agentDirFor(entry.config)
|
|
3892
|
+
const update = parseTelegramUpdate(rawBody, { botUsername: await telegramBotUsername(botToken), rootOf: (chat, id) => telegramRootOf(dir, chat, id) })
|
|
3893
|
+
const connector = createTelegramConnector(botToken, dir)
|
|
3894
|
+
if (update.type === 'message') {
|
|
3895
|
+
// Kept for the thread's context and the approver picker: Telegram
|
|
3896
|
+
// can't be asked for either later.
|
|
3897
|
+
recordTelegramMessage(dir, {
|
|
3898
|
+
chat: update.conversation, id: update.ts, root: update.threadTs ?? update.ts, user: update.user, fromBot: false, text: update.text,
|
|
3899
|
+
...(update.userName ? { name: update.userName } : {}), ...(update.username ? { username: update.username } : {}),
|
|
3900
|
+
})
|
|
3901
|
+
await routeInboundMessage(agentName, entry, update, connector)
|
|
3902
|
+
} else if (update.type === 'click') {
|
|
3903
|
+
await handleTelegramClick(agentName, entry, botToken, connector, update)
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
// A click on one of the agent's Telegram buttons: a request's Approve or
|
|
3908
|
+
// Deny, a question's option, or a button under a thread reply (Approve as
|
|
3909
|
+
// changed, Keep original, Confirm, Cancel). The message the button was on
|
|
3910
|
+
// is edited to say what happened, which also takes its buttons away.
|
|
3911
|
+
async function handleTelegramClick(agentName: string, entry: RegistryEntry, botToken: string, connector: MessagingConnector, click: Extract<TelegramUpdate, { type: 'click' }>): Promise<void> {
|
|
3912
|
+
// Once the agent has Telegram approvers, only their clicks count.
|
|
3913
|
+
const gate = clickAllowed(entry.config, 'telegram', click.user)
|
|
3914
|
+
if (!gate.allowed) {
|
|
3915
|
+
await answerTelegramClick(botToken, click.callbackId, gate.message)
|
|
3916
|
+
return
|
|
3917
|
+
}
|
|
3918
|
+
await answerTelegramClick(botToken, click.callbackId)
|
|
3919
|
+
const deps = messagingDeps(agentName, entry, connector)
|
|
3920
|
+
const who = click.username ? `@${click.username}` : click.userName ?? 'someone'
|
|
3921
|
+
if (click.button.type === 'action') {
|
|
3922
|
+
const text = await handleMessagingAction(click.button.action, click.pendingId, click.user, deps)
|
|
3923
|
+
await editTelegramMessage(botToken, click.conversation, click.messageId, text)
|
|
3924
|
+
return
|
|
3925
|
+
}
|
|
3926
|
+
const item = (await checkpoints.get(click.pendingId))?.outstanding[click.pendingId]
|
|
3927
|
+
let decision: MessagingDecision
|
|
3928
|
+
let answer: string | undefined
|
|
3929
|
+
if (click.button.type === 'decide') {
|
|
3930
|
+
decision = { kind: click.button.decision }
|
|
3931
|
+
} else {
|
|
3932
|
+
const options = item?.args.options
|
|
3933
|
+
answer = Array.isArray(options) && typeof options[click.button.index] === 'string' ? (options[click.button.index] as string) : undefined
|
|
3934
|
+
// Gone from the checkpoint: decided already, or an option list that changed.
|
|
3935
|
+
decision = answer === undefined ? { kind: 'deny' } : { kind: 'answer', answer }
|
|
3936
|
+
if (answer === undefined) {
|
|
3937
|
+
await editTelegramMessage(botToken, click.conversation, click.messageId, item ? 'That option is no longer offered.' : 'This was already decided elsewhere.')
|
|
3938
|
+
return
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
const outcome = await decideFromMessaging(click.pendingId, decision)
|
|
3942
|
+
const kind = decision.kind === 'answer' ? 'answer' : decision.kind
|
|
3943
|
+
if (!outcome.error && !outcome.alreadyResolved) {
|
|
3944
|
+
const done = kind === 'approve' ? 'Approved' : kind === 'deny' ? 'Denied' : 'Answered'
|
|
3945
|
+
await settleThreadChoice(deps, click.pendingId, `${done} by ${who} with the request's own buttons.`).catch((err) => console.error('[loopengine] Telegram update failed:', err))
|
|
3946
|
+
}
|
|
3947
|
+
// The request as it was, with what happened under it.
|
|
3948
|
+
const settled = !outcome.error && !outcome.alreadyResolved
|
|
3949
|
+
let result = outcomeText(kind, who, outcome, answer === undefined ? undefined : telegramEscape(answer))
|
|
3950
|
+
if (kind === 'approve' && settled) result = `**Approved by ${who}.** ${result}`
|
|
3951
|
+
const request = readTelegramThread(agentDirFor(entry.config), click.conversation, click.messageId).find((m) => m.ts === click.messageId)?.text
|
|
3952
|
+
if (request) result = `${request}\n\n${result}`
|
|
3953
|
+
else if (item && kind === 'approve' && settled) result += `\nRan \`${item.tool}\` with:\n${formatArgs(item.args, telegramEscape, (await resolveAgentTool(entry.config, item.tool))?.input_schema)}`
|
|
3954
|
+
await editTelegramMessage(botToken, click.conversation, click.messageId, result)
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
// A job someone started by messaging the agent (core/messaging/jobs.ts).
|
|
3958
|
+
function jobDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): JobDeps {
|
|
3959
|
+
const app = connector.app as TwoWayApp
|
|
3960
|
+
return {
|
|
3961
|
+
agentName,
|
|
3962
|
+
agentDir: agentDirFor(entry.config),
|
|
3963
|
+
connector,
|
|
3964
|
+
plan: process.env.LOOPENGINE_PLAN,
|
|
3965
|
+
allowed: (message) => jobAllowed(entry.config, app, message),
|
|
3966
|
+
runTurn: (sessionId, text) => runJobTurn(agentName, sessionId, text),
|
|
3967
|
+
decideLink: (pendingId, kind) => {
|
|
3968
|
+
const base = publicBaseUrl(connectionPublicUrl(entry.config, app))
|
|
3969
|
+
return base ? `${base}${decidePath(pendingId, kind)}` : undefined
|
|
3970
|
+
},
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
// A job thread's approvals and questions wait in the thread itself
|
|
3975
|
+
// (core/messaging/jobs.ts posts them), so the run's own approver only
|
|
3976
|
+
// hands out an id. The agent's notifier, when it has one, still sends its
|
|
3977
|
+
// request too, and wins: run-agent prefers it on the http channel.
|
|
3978
|
+
const threadApprover = {
|
|
3979
|
+
requestApproval: async () => false,
|
|
3980
|
+
requestDurableApproval: () => ({ pendingId: randomUUID() }),
|
|
3981
|
+
}
|
|
3982
|
+
const threadQuestions = {
|
|
3983
|
+
requestQuestion: async () => NO_HUMAN_ANSWER,
|
|
3984
|
+
notifyPendingQuestion: () => ({ pendingId: randomUUID() }),
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
function jobTurnOf(text: string, pending: PendingSummary[] | undefined): JobTurn {
|
|
3988
|
+
if (!pending?.length) return { kind: 'done', text }
|
|
3989
|
+
return {
|
|
3990
|
+
kind: 'pending',
|
|
3991
|
+
items: pending.map((p) => ({
|
|
3992
|
+
pendingId: p.pendingId,
|
|
3993
|
+
kind: p.kind,
|
|
3994
|
+
tool: p.tool,
|
|
3995
|
+
...(p.kind === 'question' ? { question: typeof p.args.question === 'string' ? p.args.question : p.reason } : {}),
|
|
3996
|
+
})),
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
// One turn of a job thread: the same session for the whole thread.
|
|
4001
|
+
async function runJobTurn(agent: string, rawSessionId: string, message: string): Promise<JobTurn> {
|
|
4002
|
+
const entry = getEntry(agent)
|
|
4003
|
+
if (!entry) throw new Error(`no agent named "${agent}" in this project`)
|
|
4004
|
+
const tenant = 'default'
|
|
4005
|
+
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
4006
|
+
const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
|
|
4007
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
4008
|
+
tenant,
|
|
4009
|
+
sessionId: rawSessionId,
|
|
4010
|
+
channel: 'http',
|
|
4011
|
+
approver: threadApprover,
|
|
4012
|
+
questionHandler: threadQuestions,
|
|
4013
|
+
})
|
|
4014
|
+
if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
|
|
4015
|
+
return { newMessages: result.newMessages, result: { text: result.text, pending: pendingSummary(result.pending) } }
|
|
4016
|
+
})
|
|
4017
|
+
return jobTurnOf(outcome.text, outcome.pending)
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
// A resumed run that came from a job thread answers there.
|
|
4021
|
+
async function settleJobFromResume(agent: string, sessionId: string, text: string, pending: PendingSummary[] | undefined): Promise<void> {
|
|
4022
|
+
const entry = getEntry(agent)
|
|
4023
|
+
if (!entry) return
|
|
4024
|
+
const job = findJobThreadBySession(agentDirFor(entry.config), sessionId)
|
|
4025
|
+
if (!job) return
|
|
4026
|
+
let connector: MessagingConnector | undefined
|
|
4027
|
+
if (job.app === 'slack') {
|
|
4028
|
+
const { botToken } = slackCredentialsFor(entry.config)
|
|
4029
|
+
if (botToken) connector = createSlackConnector(botToken)
|
|
4030
|
+
} else if (job.app === 'telegram') {
|
|
4031
|
+
const { botToken } = telegramCredentialsFor(entry.config)
|
|
4032
|
+
if (botToken) connector = createTelegramConnector(botToken, agentDirFor(entry.config))
|
|
4033
|
+
}
|
|
4034
|
+
if (!connector) return
|
|
4035
|
+
await settleJobThread(jobDeps(agent, entry, connector), sessionId, jobTurnOf(text, pending))
|
|
3594
4036
|
}
|
|
3595
4037
|
|
|
3596
4038
|
// What core/messaging/ needs from this server to answer, decide and
|
|
3597
4039
|
// change an agent's Slack requests.
|
|
3598
4040
|
function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
|
|
4041
|
+
return messagingDeps(agentName, entry, createSlackConnector(botToken))
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
// The same, for any app's connector.
|
|
4045
|
+
function messagingDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): MessagingDeps {
|
|
3599
4046
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
3600
4047
|
return {
|
|
3601
4048
|
agentName,
|
|
3602
4049
|
agentDir: agentDirFor(entry.config),
|
|
3603
4050
|
systemPrompt: entry.config.systemPrompt,
|
|
3604
|
-
connector
|
|
4051
|
+
connector,
|
|
3605
4052
|
createModelCall: () => entry.createModelCall(),
|
|
3606
4053
|
getCheckpoint: (pendingId) => checkpoints.get(pendingId),
|
|
3607
4054
|
getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
|
|
3608
4055
|
plan: process.env.LOOPENGINE_PLAN,
|
|
3609
|
-
approvers: () => agentApprovers(entry.config),
|
|
4056
|
+
approvers: () => agentApprovers(entry.config, connector.app as TwoWayApp),
|
|
3610
4057
|
decide: decideFromMessaging,
|
|
3611
4058
|
toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
|
|
3612
4059
|
// The same checks the console's approve-with-edits applies, plus the
|
|
@@ -3630,6 +4077,10 @@ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: s
|
|
|
3630
4077
|
// recorded through the same resolve and answer handlers as the console's
|
|
3631
4078
|
// buttons.
|
|
3632
4079
|
async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
|
|
4080
|
+
// A run from a job thread posts its own result there once it resumes
|
|
4081
|
+
// (settleJobFromResume), so it isn't quoted here as well.
|
|
4082
|
+
const checkpoint = await checkpoints.get(pendingId)
|
|
4083
|
+
const fromJob = checkpoint ? Boolean(getEntry(checkpoint.agent) && findJobThreadBySession(agentDir(checkpoint.agent), checkpoint.sessionId)) : false
|
|
3633
4084
|
const capture = captureResponse()
|
|
3634
4085
|
if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
|
|
3635
4086
|
else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
|
|
@@ -3638,7 +4089,106 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
|
|
|
3638
4089
|
if (body.alreadyResolved) return { alreadyResolved: true }
|
|
3639
4090
|
if (body.resolved) return { stillWaiting: true }
|
|
3640
4091
|
if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
|
|
3641
|
-
return { text: typeof body.text === 'string' ? body.text : undefined }
|
|
4092
|
+
return { text: typeof body.text === 'string' && !fromJob ? body.text : undefined }
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
// ---- Webhook triggers (core/workflow-triggers.ts): an outside service
|
|
4096
|
+
// posts its own events to a workflow's secret URL. Answered 202 as soon as
|
|
4097
|
+
// the run starts (senders retry a slow or failed answer); a retry of a
|
|
4098
|
+
// delivery already handled, or an event the filter skips, is answered 202
|
|
4099
|
+
// too, so it isn't sent again. ----
|
|
4100
|
+
|
|
4101
|
+
const WEBHOOK_MAX_BYTES = 1024 * 1024
|
|
4102
|
+
|
|
4103
|
+
async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, token: string): Promise<void> {
|
|
4104
|
+
const json = (status: number, body: unknown): void => {
|
|
4105
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
4106
|
+
}
|
|
4107
|
+
const raw = await readLimitedRawBuffer(req, WEBHOOK_MAX_BYTES)
|
|
4108
|
+
if (!raw) return json(413, { error: 'The payload is larger than 1 MB.' })
|
|
4109
|
+
// Unknown and turned-off triggers read the same, so a URL can't be probed.
|
|
4110
|
+
const trigger = await findTriggerByToken(token)
|
|
4111
|
+
if (!trigger || !trigger.enabled) return json(404, { error: 'not found' })
|
|
4112
|
+
const at = new Date().toISOString()
|
|
4113
|
+
const record = (outcome: Parameters<typeof recordDelivery>[1]['outcome'], extra: { delivery_id?: string; run_id?: string; detail?: string } = {}, seen?: string) =>
|
|
4114
|
+
recordDelivery(trigger.trigger_id, { at, outcome, ...extra }, seen)
|
|
4115
|
+
|
|
4116
|
+
if (trigger.verify && !verifySenderSignature(trigger.verify, req.headers, raw)) {
|
|
4117
|
+
await record('rejected', { detail: `The ${trigger.verify.sender} signature didn't match.` })
|
|
4118
|
+
return json(401, { error: 'signature check failed' })
|
|
4119
|
+
}
|
|
4120
|
+
const payload = parsePayload(raw, req.headers['content-type'])
|
|
4121
|
+
const id = deliveryId(req.headers, payload)
|
|
4122
|
+
if (id && trigger.seen_ids.includes(id)) {
|
|
4123
|
+
await record('duplicate', { delivery_id: id })
|
|
4124
|
+
return json(202, { ignored: 'duplicate' })
|
|
4125
|
+
}
|
|
4126
|
+
if (!passesFilter(trigger.filter, payload)) {
|
|
4127
|
+
await record('filtered', { ...(id ? { delivery_id: id } : {}) }, id)
|
|
4128
|
+
return json(202, { ignored: 'filtered' })
|
|
4129
|
+
}
|
|
4130
|
+
if (!getEntry(trigger.agent)) {
|
|
4131
|
+
await record('error', { detail: `The agent ${trigger.agent} is gone.` })
|
|
4132
|
+
return json(404, { error: 'not found' })
|
|
4133
|
+
}
|
|
4134
|
+
try {
|
|
4135
|
+
const prepared = await prepareWorkflowRun(trigger.agent, agentDir(trigger.agent), trigger.workflow_id, paramsFromPayload(trigger, payload))
|
|
4136
|
+
const run = await workflowRunner.start({
|
|
4137
|
+
agent: trigger.agent, workflow_id: trigger.workflow_id, params: prepared.params, source: 'webhook', trigger_id: trigger.trigger_id, message: prepared.message,
|
|
4138
|
+
...(trigger.notify ? { notify: true } : {}), ...(trigger.callback_url ? { callback_url: trigger.callback_url } : {}),
|
|
4139
|
+
})
|
|
4140
|
+
await record('started', { run_id: run.run_id, ...(id ? { delivery_id: id } : {}) }, id)
|
|
4141
|
+
return json(202, { runId: run.run_id })
|
|
4142
|
+
} catch (err) {
|
|
4143
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
4144
|
+
if (err instanceof WorkflowError && /already running/.test(message)) {
|
|
4145
|
+
await record('busy', { detail: message, ...(id ? { delivery_id: id } : {}) }, id)
|
|
4146
|
+
return json(202, { ignored: 'already running' })
|
|
4147
|
+
}
|
|
4148
|
+
await record(err instanceof WorkflowError ? 'invalid' : 'error', { detail: message, ...(id ? { delivery_id: id } : {}) })
|
|
4149
|
+
return json(err instanceof WorkflowError ? 422 : 500, { error: message })
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
// This server's address as the request reached it (behind a proxy, as
|
|
4154
|
+
// the proxy says), for URLs shown in the console.
|
|
4155
|
+
function requestBaseUrl(req: IncomingMessage): string {
|
|
4156
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
|
|
4157
|
+
return `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
// A workflow's webhook triggers, for the console: list, make (its URL is
|
|
4161
|
+
// shown once), change, rotate (a new URL), delete.
|
|
4162
|
+
async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
|
|
4163
|
+
const json = (status: number, body: unknown): void => {
|
|
4164
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
4165
|
+
}
|
|
4166
|
+
if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
|
|
4167
|
+
const workflow = readWorkflow(agentDir(agentName), workflowId)
|
|
4168
|
+
if (!workflow) return json(404, { error: `no workflow ${workflowId} in ${agentName}` })
|
|
4169
|
+
const urlFor = (token: string) => `${requestBaseUrl(req)}/hooks/workflows/${token}`
|
|
4170
|
+
try {
|
|
4171
|
+
if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
|
|
4172
|
+
if (!triggerId && req.method === 'POST') {
|
|
4173
|
+
const { trigger, token } = await createTrigger(agentName, workflow, await readJsonBody(req))
|
|
4174
|
+
return json(201, { trigger: publicTrigger(trigger), url: urlFor(token) })
|
|
4175
|
+
}
|
|
4176
|
+
const existing = triggerId ? await getTriggerStore().get(triggerId) : undefined
|
|
4177
|
+
if (!existing || existing.agent !== agentName || existing.workflow_id !== workflowId) return json(404, { error: 'no such trigger' })
|
|
4178
|
+
if (action === 'rotate' && req.method === 'POST') {
|
|
4179
|
+
const { trigger, token } = await rotateTrigger(existing.trigger_id)
|
|
4180
|
+
return json(200, { trigger: publicTrigger(trigger), url: urlFor(token) })
|
|
4181
|
+
}
|
|
4182
|
+
if (!action && req.method === 'PUT') return json(200, { trigger: publicTrigger(await updateTrigger(existing.trigger_id, workflow, await readJsonBody(req))) })
|
|
4183
|
+
if (!action && req.method === 'DELETE') {
|
|
4184
|
+
await getTriggerStore().remove(existing.trigger_id)
|
|
4185
|
+
return json(200, { removed: true })
|
|
4186
|
+
}
|
|
4187
|
+
return json(404, { error: 'not found' })
|
|
4188
|
+
} catch (err) {
|
|
4189
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
4190
|
+
return json(err instanceof TriggerError || err instanceof CallbackUrlError ? 400 : 500, { error: message })
|
|
4191
|
+
}
|
|
3642
4192
|
}
|
|
3643
4193
|
|
|
3644
4194
|
// Set once shutdown() starts, so /healthz turns 503 while the server
|
|
@@ -3679,6 +4229,31 @@ const server = createServer(async (req, res) => {
|
|
|
3679
4229
|
return
|
|
3680
4230
|
}
|
|
3681
4231
|
|
|
4232
|
+
const telegramHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/telegram\/([^/]+)$/)
|
|
4233
|
+
if (telegramHookMatch) {
|
|
4234
|
+
try {
|
|
4235
|
+
lastRequestAt = Date.now()
|
|
4236
|
+
await tracked(() => handleTelegramWebhook(req, res, decodeURIComponent(telegramHookMatch[1])))
|
|
4237
|
+
} catch (err) {
|
|
4238
|
+
console.error('[loopengine] Telegram update failed:', err)
|
|
4239
|
+
if (!res.headersSent) res.writeHead(500).end()
|
|
4240
|
+
}
|
|
4241
|
+
return
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
// A workflow's webhook trigger: its token is the credential (no login).
|
|
4245
|
+
const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
|
|
4246
|
+
if (workflowHookMatch) {
|
|
4247
|
+
try {
|
|
4248
|
+
lastRequestAt = Date.now()
|
|
4249
|
+
await tracked(() => handleWorkflowHook(req, res, workflowHookMatch[1]))
|
|
4250
|
+
} catch (err) {
|
|
4251
|
+
console.error('[loopengine] workflow webhook failed:', err)
|
|
4252
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong.' }))
|
|
4253
|
+
}
|
|
4254
|
+
return
|
|
4255
|
+
}
|
|
4256
|
+
|
|
3682
4257
|
const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
|
|
3683
4258
|
if (decideMatch) {
|
|
3684
4259
|
try {
|
|
@@ -3949,7 +4524,26 @@ const server = createServer(async (req, res) => {
|
|
|
3949
4524
|
await handleAgentApp(req, res, decodeURIComponent(agentAppMatch[1]), decodeURIComponent(agentAppMatch[2]))
|
|
3950
4525
|
return
|
|
3951
4526
|
}
|
|
3952
|
-
|
|
4527
|
+
// The secret run callbacks are signed with, for whoever checks them.
|
|
4528
|
+
if (req.method === 'GET' && pathname === '/runs/callback-secret') {
|
|
4529
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ secret: callbackSecret() }))
|
|
4530
|
+
return
|
|
4531
|
+
}
|
|
4532
|
+
// One workflow run, by the runId its POST .../run returned.
|
|
4533
|
+
const agentRunMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/runs\/([A-Za-z0-9_-]+)$/)
|
|
4534
|
+
if (agentRunMatch) {
|
|
4535
|
+
const agentName = decodeURIComponent(agentRunMatch[1])
|
|
4536
|
+
const run = getEntry(agentName) ? await getRun(agentName, agentRunMatch[2]) : undefined
|
|
4537
|
+
res.writeHead(run ? 200 : 404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
4538
|
+
.end(JSON.stringify(run ?? { error: `no run ${agentRunMatch[2]} for ${agentName}` }))
|
|
4539
|
+
return
|
|
4540
|
+
}
|
|
4541
|
+
const triggersMatch = pathname.match(/^\/agents\/([^/]+)\/workflows\/([a-z0-9-]+)\/triggers(?:\/([A-Za-z0-9_-]+))?(?:\/(rotate))?$/)
|
|
4542
|
+
if (triggersMatch) {
|
|
4543
|
+
await handleWorkflowTriggers(req, res, decodeURIComponent(triggersMatch[1]), triggersMatch[2], triggersMatch[3], triggersMatch[4])
|
|
4544
|
+
return
|
|
4545
|
+
}
|
|
4546
|
+
const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run|runs))?$/)
|
|
3953
4547
|
if (agentWorkflowsMatch) {
|
|
3954
4548
|
await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
|
|
3955
4549
|
return
|
|
@@ -4020,7 +4614,7 @@ const server = createServer(async (req, res) => {
|
|
|
4020
4614
|
return
|
|
4021
4615
|
}
|
|
4022
4616
|
try {
|
|
4023
|
-
const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running > 0 })
|
|
4617
|
+
const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running + workflowRunner.running > 0 })
|
|
4024
4618
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, outcome, restarting: exitOnCodeChange }))
|
|
4025
4619
|
} catch (err) {
|
|
4026
4620
|
const status = err instanceof UpgradeError ? 409 : 500
|
|
@@ -4264,6 +4858,19 @@ const server = createServer(async (req, res) => {
|
|
|
4264
4858
|
await handleSchedulesPost(req, res, decodeURIComponent(schedulesMatch[1]))
|
|
4265
4859
|
return
|
|
4266
4860
|
}
|
|
4861
|
+
// POST /schedules/:id/notify { "notify": true|false }: whether each
|
|
4862
|
+
// run's result goes through the agent's notifier.
|
|
4863
|
+
const scheduleNotifyMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/notify$/)
|
|
4864
|
+
if (scheduleNotifyMatch) {
|
|
4865
|
+
const body = await readJsonBody(req)
|
|
4866
|
+
if (typeof body.notify !== 'boolean') return scheduleJson(res, 400, { error: 'notify must be true or false' })
|
|
4867
|
+
try {
|
|
4868
|
+
scheduleJson(res, 200, await setScheduledTaskNotify(scheduleNotifyMatch[1], body.notify))
|
|
4869
|
+
} catch (err) {
|
|
4870
|
+
scheduleJson(res, err instanceof ScheduleError ? 404 : 500, { error: err instanceof Error ? err.message : String(err) })
|
|
4871
|
+
}
|
|
4872
|
+
return
|
|
4873
|
+
}
|
|
4267
4874
|
const scheduleActionMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/(run|pause|resume|cancel)$/)
|
|
4268
4875
|
if (scheduleActionMatch) {
|
|
4269
4876
|
await handleScheduleAction(res, scheduleActionMatch[1], scheduleActionMatch[2])
|
|
@@ -4281,6 +4888,18 @@ const server = createServer(async (req, res) => {
|
|
|
4281
4888
|
await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
|
|
4282
4889
|
return
|
|
4283
4890
|
}
|
|
4891
|
+
const telegramPeopleMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-people$/)
|
|
4892
|
+
if (telegramPeopleMatch) {
|
|
4893
|
+
const entry = getEntry(decodeURIComponent(telegramPeopleMatch[1]))
|
|
4894
|
+
if (!entry) res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unknown agent' }))
|
|
4895
|
+
else res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ people: listAgentTelegramPeople(entry.config) }))
|
|
4896
|
+
return
|
|
4897
|
+
}
|
|
4898
|
+
const telegramWebhookMatch = (req.method === 'GET' || req.method === 'POST') && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-webhook$/)
|
|
4899
|
+
if (telegramWebhookMatch) {
|
|
4900
|
+
await handleTelegramWebhookSetup(req, res, decodeURIComponent(telegramWebhookMatch[1]))
|
|
4901
|
+
return
|
|
4902
|
+
}
|
|
4284
4903
|
const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
|
|
4285
4904
|
if (slackMembersMatch) {
|
|
4286
4905
|
await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
|
|
@@ -4540,7 +5159,16 @@ server.listen(port, () => {
|
|
|
4540
5159
|
console.log(`agent API listening on :${port}`)
|
|
4541
5160
|
void warmAgentTools()
|
|
4542
5161
|
// LOOPENGINE_SCHEDULER=off leaves scheduling to another process.
|
|
4543
|
-
|
|
5162
|
+
// Workflows from before they were skills move into their skills first,
|
|
5163
|
+
// so their schedules run them.
|
|
5164
|
+
const startScheduling = async () => {
|
|
5165
|
+
for (const name of listAgents()) await migrateAgentWorkflows(name, agentDir(name)).catch((err) => console.error(`[loopengine] moving ${name}'s workflows.yml failed:`, err))
|
|
5166
|
+
if (process.env.LOOPENGINE_SCHEDULER !== 'off') {
|
|
5167
|
+
await workflowRunner.recover()
|
|
5168
|
+
await scheduler.start()
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
void startScheduling().catch((err) => console.error('[loopengine] scheduler failed to start:', err))
|
|
4544
5172
|
})
|
|
4545
5173
|
|
|
4546
5174
|
// An agent that leaves `tools` unset gets its tools/ folder imported
|