create-loopengine 0.3.0 → 0.3.2
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 +327 -13
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
type AgentConfig,
|
|
68
68
|
type AgentModelConfig,
|
|
69
69
|
type ToolDefinition,
|
|
70
|
+
type Message,
|
|
70
71
|
type ModelContentBlock,
|
|
71
72
|
type RunAgentResult,
|
|
72
73
|
playgroundHtml,
|
|
@@ -120,8 +121,11 @@ import {
|
|
|
120
121
|
agentDirFor,
|
|
121
122
|
verifyProxyIdentity,
|
|
122
123
|
TRUSTED_PROXY_IDENTITY_HEADER,
|
|
124
|
+
reportRun,
|
|
125
|
+
usedTool,
|
|
123
126
|
NotifierFileError,
|
|
124
127
|
verifyDecisionToken,
|
|
128
|
+
decidePath,
|
|
125
129
|
renderDecidePage,
|
|
126
130
|
renderDecideGonePage,
|
|
127
131
|
renderDecideInvalidPage,
|
|
@@ -145,7 +149,16 @@ import {
|
|
|
145
149
|
listAgentPending,
|
|
146
150
|
listAgentSlackChannels,
|
|
147
151
|
slackCredentialsFor,
|
|
148
|
-
|
|
152
|
+
agentTelegramWebhookInfo,
|
|
153
|
+
clickAllowed,
|
|
154
|
+
connectionPublicUrl,
|
|
155
|
+
conversationsOn,
|
|
156
|
+
jobAllowed,
|
|
157
|
+
jobsOn,
|
|
158
|
+
listAgentTelegramPeople,
|
|
159
|
+
setupAgentTelegramWebhook,
|
|
160
|
+
telegramCredentialsFor,
|
|
161
|
+
type TwoWayApp,
|
|
149
162
|
slackClickAllowed,
|
|
150
163
|
agentApprovers,
|
|
151
164
|
setAgentApprovers,
|
|
@@ -167,6 +180,28 @@ import {
|
|
|
167
180
|
firstSighting,
|
|
168
181
|
handleInboundMessage,
|
|
169
182
|
createSlackConnector,
|
|
183
|
+
outcomeText,
|
|
184
|
+
handleJobMessage,
|
|
185
|
+
isJobMessage,
|
|
186
|
+
settleJobThread,
|
|
187
|
+
type JobDeps,
|
|
188
|
+
type JobTurn,
|
|
189
|
+
findJobThreadBySession,
|
|
190
|
+
findThread,
|
|
191
|
+
type InboundMessage,
|
|
192
|
+
type MessagingConnector,
|
|
193
|
+
formatArgs,
|
|
194
|
+
answerTelegramClick,
|
|
195
|
+
createTelegramConnector,
|
|
196
|
+
editTelegramMessage,
|
|
197
|
+
parseTelegramUpdate,
|
|
198
|
+
readTelegramThread,
|
|
199
|
+
recordTelegramMessage,
|
|
200
|
+
telegramBotUsername,
|
|
201
|
+
telegramEscape,
|
|
202
|
+
telegramRootOf,
|
|
203
|
+
verifyTelegramRequest,
|
|
204
|
+
type TelegramUpdate,
|
|
170
205
|
parseSlackEvent,
|
|
171
206
|
removeAgentNotifier,
|
|
172
207
|
saveAgentNotifier,
|
|
@@ -276,6 +311,7 @@ import {
|
|
|
276
311
|
createScheduleTools,
|
|
277
312
|
type PendingQuestion,
|
|
278
313
|
WebhookNotifier,
|
|
314
|
+
publicBaseUrl,
|
|
279
315
|
// Workflows (skills with a start message), their runs, run callbacks
|
|
280
316
|
// and webhook triggers.
|
|
281
317
|
WorkflowParamsError,
|
|
@@ -1478,6 +1514,11 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1478
1514
|
return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
|
|
1479
1515
|
},
|
|
1480
1516
|
})
|
|
1517
|
+
// The planned webhook's URL, shown once on the done screen.
|
|
1518
|
+
if (created.webhook) {
|
|
1519
|
+
const { token, ...webhook } = created.webhook
|
|
1520
|
+
return json(200, { ...created, webhook: { ...webhook, url: `${requestBaseUrl(req)}/hooks/workflows/${token}` } })
|
|
1521
|
+
}
|
|
1481
1522
|
json(200, created)
|
|
1482
1523
|
// New tools in an agent that was already running load with a restart,
|
|
1483
1524
|
// as after installing an ability from its tab.
|
|
@@ -1887,6 +1928,8 @@ async function executeUnattendedRun(agent: string, message: string, rawSessionId
|
|
|
1887
1928
|
async function reportWorkflowRun(run: WorkflowRun): Promise<void> {
|
|
1888
1929
|
const event: RunCallbackEvent | undefined = run.status === 'done' ? 'run.done' : run.status === 'error' ? 'run.failed' : run.status === 'awaiting_approval' ? 'run.awaiting_approval' : undefined
|
|
1889
1930
|
if (!event) return
|
|
1931
|
+
// For a host's run observer (core/run-events.ts); a failed run isn't reported.
|
|
1932
|
+
if (run.status !== 'error') reportRun({ agent: run.agent, source: 'workflow', id: run.run_id, via: run.source, usedTool: true })
|
|
1890
1933
|
const entry = getEntry(run.agent)
|
|
1891
1934
|
if (run.notify && entry && event !== 'run.awaiting_approval') {
|
|
1892
1935
|
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'}`
|
|
@@ -1904,7 +1947,12 @@ const workflowRunner = new WorkflowRunner({ execute: executeUnattendedRun, onCha
|
|
|
1904
1947
|
// A task that runs a workflow is also recorded as a run of that workflow,
|
|
1905
1948
|
// so the workflow's Recent runs shows its scheduled runs too.
|
|
1906
1949
|
function fireScheduledTask(task: ScheduledTask, rawSessionId: string): ReturnType<typeof executeUnattendedRun> {
|
|
1907
|
-
if (!task.workflow_id)
|
|
1950
|
+
if (!task.workflow_id) {
|
|
1951
|
+
return executeUnattendedRun(task.agent, task.message, rawSessionId).then((outcome) => {
|
|
1952
|
+
reportRun({ agent: task.agent, source: 'schedule', id: randomUUID(), usedTool: true })
|
|
1953
|
+
return outcome
|
|
1954
|
+
})
|
|
1955
|
+
}
|
|
1908
1956
|
return workflowRunner.runForSchedule({
|
|
1909
1957
|
agent: task.agent,
|
|
1910
1958
|
workflow_id: task.workflow_id,
|
|
@@ -2134,7 +2182,7 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
|
|
|
2134
2182
|
}
|
|
2135
2183
|
const body = await readJsonBody(req)
|
|
2136
2184
|
try {
|
|
2137
|
-
const approvers = setAgentApprovers(entry.config, body.approvers)
|
|
2185
|
+
const approvers = setAgentApprovers(entry.config, body.approvers, body.app === 'telegram' ? 'telegram' : 'slack')
|
|
2138
2186
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
|
|
2139
2187
|
} catch (err) {
|
|
2140
2188
|
const status = err instanceof NotifierFileError ? 400 : 500
|
|
@@ -2142,6 +2190,26 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
|
|
|
2142
2190
|
}
|
|
2143
2191
|
}
|
|
2144
2192
|
|
|
2193
|
+
// Messaging tab → Telegram: where the bot's updates go (GET), and
|
|
2194
|
+
// pointing them at this server (POST), which turns on buttons and
|
|
2195
|
+
// replies. Setting it needs the admin login, like saving a secret.
|
|
2196
|
+
async function handleTelegramWebhookSetup(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
2197
|
+
const json = (status: number, body: unknown): void => {
|
|
2198
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
2199
|
+
}
|
|
2200
|
+
const entry = getEntry(agentName)
|
|
2201
|
+
if (!entry) return json(404, { error: `unknown agent '${agentName}'` })
|
|
2202
|
+
try {
|
|
2203
|
+
if (req.method === 'GET') return json(200, await agentTelegramWebhookInfo(entry.config))
|
|
2204
|
+
if (!secretsProtected) return json(403, { error: 'Setting up the Telegram webhook needs LOOPENGINE_ADMIN_AUTH set on the server.' })
|
|
2205
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
|
|
2206
|
+
const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
|
|
2207
|
+
return json(200, await setupAgentTelegramWebhook(entry.config, base))
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
return json(400, { error: err instanceof Error ? err.message : String(err) })
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2145
2213
|
async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
|
|
2146
2214
|
const entry = getEntry(agentName)
|
|
2147
2215
|
if (!entry) {
|
|
@@ -2859,6 +2927,19 @@ async function raceAndRespond(res: ServerResponse, agentName: string, rawSession
|
|
|
2859
2927
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
|
|
2860
2928
|
}
|
|
2861
2929
|
|
|
2930
|
+
// Who sent a message, as a trusted proxy says in its signed identity:
|
|
2931
|
+
// an API key (role "api") or a signed-in person. Without a proxy, unknown.
|
|
2932
|
+
function messageCaller(req: IncomingMessage): 'api' | 'console' | 'unknown' {
|
|
2933
|
+
const identity = trustedProxySecret ? verifyProxyIdentity(req.headers[TRUSTED_PROXY_IDENTITY_HEADER], trustedProxySecret) : undefined
|
|
2934
|
+
if (!identity) return 'unknown'
|
|
2935
|
+
return identity.role === 'api' ? 'api' : 'console'
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// For a host's run observer (core/run-events.ts).
|
|
2939
|
+
function reportMessageRun(req: IncomingMessage, agent: string, newMessages: Message[]): void {
|
|
2940
|
+
reportRun({ agent, source: 'message', id: randomUUID(), caller: messageCaller(req), usedTool: usedTool(newMessages) })
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2862
2943
|
async function handleMessages(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
2863
2944
|
const parsed = await parseRequest(req, agentName)
|
|
2864
2945
|
if (!parsed.ok) {
|
|
@@ -2924,6 +3005,7 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
|
|
|
2924
3005
|
pushSignal(state, { type: 'question', entry: question })
|
|
2925
3006
|
},
|
|
2926
3007
|
})
|
|
3008
|
+
reportMessageRun(req, agentName, result.newMessages)
|
|
2927
3009
|
if (result.pending) {
|
|
2928
3010
|
await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
|
|
2929
3011
|
}
|
|
@@ -3034,6 +3116,7 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
|
|
|
3034
3116
|
pushSignal(state, { type: 'question', entry: question })
|
|
3035
3117
|
},
|
|
3036
3118
|
})
|
|
3119
|
+
reportMessageRun(req, agentName, result.newMessages)
|
|
3037
3120
|
if (result.pending) {
|
|
3038
3121
|
await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
|
|
3039
3122
|
}
|
|
@@ -3362,6 +3445,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3362
3445
|
}
|
|
3363
3446
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
3364
3447
|
const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
|
|
3448
|
+
const fromJobThread = Boolean(findJobThreadBySession(agentDirFor(entry.config), finalCheckpoint.sessionId))
|
|
3365
3449
|
|
|
3366
3450
|
const responseBody = await sessions.withSession(storageSessionId, async (history) => {
|
|
3367
3451
|
// Same channel/approver/questionHandler fallback the original
|
|
@@ -3389,8 +3473,9 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3389
3473
|
tenant: finalCheckpoint.tenant,
|
|
3390
3474
|
sessionId: finalCheckpoint.sessionId,
|
|
3391
3475
|
channel: 'http',
|
|
3392
|
-
|
|
3393
|
-
|
|
3476
|
+
// A job thread's run keeps asking in its thread (runJobTurn).
|
|
3477
|
+
approver: fromJobThread ? threadApprover : defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
|
|
3478
|
+
questionHandler: fromJobThread ? threadQuestions : defaultHttpWebhookNotifier,
|
|
3394
3479
|
onQuestionPending: () => {},
|
|
3395
3480
|
})
|
|
3396
3481
|
// The resumed turn can itself hit another durably-pending call/
|
|
@@ -3408,6 +3493,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3408
3493
|
const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
|
|
3409
3494
|
void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
|
|
3410
3495
|
void workflowRunner.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle workflow run:', err))
|
|
3496
|
+
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))
|
|
3411
3497
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
3412
3498
|
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
3413
3499
|
)
|
|
@@ -3782,24 +3868,219 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
|
|
|
3782
3868
|
}
|
|
3783
3869
|
// Slack wants an answer within three seconds; the reply is posted after.
|
|
3784
3870
|
res.writeHead(200).end()
|
|
3785
|
-
if (event.type !== 'message'
|
|
3786
|
-
await
|
|
3871
|
+
if (event.type !== 'message') return
|
|
3872
|
+
await routeInboundMessage(agentName, entry, event, createSlackConnector(botToken))
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
// Where a person's message goes (core/messaging/): a reply under a
|
|
3876
|
+
// request still waiting goes to the request's thread conversation; a DM,
|
|
3877
|
+
// a mention, or a message in a job's thread runs the agent
|
|
3878
|
+
// (core/messaging/jobs.ts). Anything else is left alone. Checked before
|
|
3879
|
+
// the message counts as seen: Slack sends a mention twice (as a message
|
|
3880
|
+
// and as an app_mention), and only the app_mention starts a job.
|
|
3881
|
+
async function routeInboundMessage(agentName: string, entry: RegistryEntry, message: InboundMessage, connector: MessagingConnector): Promise<void> {
|
|
3882
|
+
const app = connector.app as TwoWayApp
|
|
3883
|
+
const dir = agentDirFor(entry.config)
|
|
3884
|
+
const jobs = jobsOn(entry.config, app) && isJobMessage(dir, message)
|
|
3885
|
+
let target: 'request' | 'job' | undefined = jobs ? 'job' : undefined
|
|
3886
|
+
if (message.threadTs) {
|
|
3887
|
+
const link = findThread(dir, app, message.conversation, message.threadTs)
|
|
3888
|
+
// A request already decided in a job's thread: the job carries on.
|
|
3889
|
+
if (link && (!jobs || (await checkpoints.get(link.pendingId)))) target = conversationsOn(entry.config, app) ? 'request' : undefined
|
|
3890
|
+
}
|
|
3891
|
+
if (!target || !firstSighting(message)) return
|
|
3892
|
+
if (target === 'request') await handleInboundMessage(message, messagingDeps(agentName, entry, connector))
|
|
3893
|
+
else await handleJobMessage(message, jobDeps(agentName, entry, connector))
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
// A Telegram bot's webhook (core/messaging/connectors/telegram/), set up
|
|
3897
|
+
// from the Messaging tab: people's messages (replies to a request, and
|
|
3898
|
+
// DMs and mentions that start jobs) and clicks on the agent's buttons.
|
|
3899
|
+
// Outside the admin login like Slack's endpoints, and checked against the
|
|
3900
|
+
// secret Telegram was given when the webhook was set. Telegram resends an
|
|
3901
|
+
// update that isn't answered 200 promptly, so it's answered first.
|
|
3902
|
+
async function handleTelegramWebhook(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
3903
|
+
const rawBody = await readRawBody(req)
|
|
3904
|
+
const entry = getEntry(agentName)
|
|
3905
|
+
const { botToken, webhookSecret } = entry ? telegramCredentialsFor(entry.config) : {}
|
|
3906
|
+
if (!entry || !botToken || !webhookSecret) {
|
|
3907
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('No Telegram webhook is set up for this agent.')
|
|
3908
|
+
return
|
|
3909
|
+
}
|
|
3910
|
+
const header = req.headers['x-telegram-bot-api-secret-token']
|
|
3911
|
+
if (!verifyTelegramRequest(typeof header === 'string' ? header : undefined, webhookSecret)) {
|
|
3912
|
+
res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Telegram secret token.')
|
|
3913
|
+
return
|
|
3914
|
+
}
|
|
3915
|
+
res.writeHead(200).end()
|
|
3916
|
+
const dir = agentDirFor(entry.config)
|
|
3917
|
+
const update = parseTelegramUpdate(rawBody, { botUsername: await telegramBotUsername(botToken), rootOf: (chat, id) => telegramRootOf(dir, chat, id) })
|
|
3918
|
+
const connector = createTelegramConnector(botToken, dir)
|
|
3919
|
+
if (update.type === 'message') {
|
|
3920
|
+
// Kept for the thread's context and the approver picker: Telegram
|
|
3921
|
+
// can't be asked for either later.
|
|
3922
|
+
recordTelegramMessage(dir, {
|
|
3923
|
+
chat: update.conversation, id: update.ts, root: update.threadTs ?? update.ts, user: update.user, fromBot: false, text: update.text,
|
|
3924
|
+
...(update.userName ? { name: update.userName } : {}), ...(update.username ? { username: update.username } : {}),
|
|
3925
|
+
})
|
|
3926
|
+
await routeInboundMessage(agentName, entry, update, connector)
|
|
3927
|
+
} else if (update.type === 'click') {
|
|
3928
|
+
await handleTelegramClick(agentName, entry, botToken, connector, update)
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
// A click on one of the agent's Telegram buttons: a request's Approve or
|
|
3933
|
+
// Deny, a question's option, or a button under a thread reply (Approve as
|
|
3934
|
+
// changed, Keep original, Confirm, Cancel). The message the button was on
|
|
3935
|
+
// is edited to say what happened, which also takes its buttons away.
|
|
3936
|
+
async function handleTelegramClick(agentName: string, entry: RegistryEntry, botToken: string, connector: MessagingConnector, click: Extract<TelegramUpdate, { type: 'click' }>): Promise<void> {
|
|
3937
|
+
// Once the agent has Telegram approvers, only their clicks count.
|
|
3938
|
+
const gate = clickAllowed(entry.config, 'telegram', click.user)
|
|
3939
|
+
if (!gate.allowed) {
|
|
3940
|
+
await answerTelegramClick(botToken, click.callbackId, gate.message)
|
|
3941
|
+
return
|
|
3942
|
+
}
|
|
3943
|
+
await answerTelegramClick(botToken, click.callbackId)
|
|
3944
|
+
const deps = messagingDeps(agentName, entry, connector)
|
|
3945
|
+
const who = click.username ? `@${click.username}` : click.userName ?? 'someone'
|
|
3946
|
+
if (click.button.type === 'action') {
|
|
3947
|
+
const text = await handleMessagingAction(click.button.action, click.pendingId, click.user, deps)
|
|
3948
|
+
await editTelegramMessage(botToken, click.conversation, click.messageId, text)
|
|
3949
|
+
return
|
|
3950
|
+
}
|
|
3951
|
+
const item = (await checkpoints.get(click.pendingId))?.outstanding[click.pendingId]
|
|
3952
|
+
let decision: MessagingDecision
|
|
3953
|
+
let answer: string | undefined
|
|
3954
|
+
if (click.button.type === 'decide') {
|
|
3955
|
+
decision = { kind: click.button.decision }
|
|
3956
|
+
} else {
|
|
3957
|
+
const options = item?.args.options
|
|
3958
|
+
answer = Array.isArray(options) && typeof options[click.button.index] === 'string' ? (options[click.button.index] as string) : undefined
|
|
3959
|
+
// Gone from the checkpoint: decided already, or an option list that changed.
|
|
3960
|
+
decision = answer === undefined ? { kind: 'deny' } : { kind: 'answer', answer }
|
|
3961
|
+
if (answer === undefined) {
|
|
3962
|
+
await editTelegramMessage(botToken, click.conversation, click.messageId, item ? 'That option is no longer offered.' : 'This was already decided elsewhere.')
|
|
3963
|
+
return
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
const outcome = await decideFromMessaging(click.pendingId, decision)
|
|
3967
|
+
const kind = decision.kind === 'answer' ? 'answer' : decision.kind
|
|
3968
|
+
if (!outcome.error && !outcome.alreadyResolved) {
|
|
3969
|
+
const done = kind === 'approve' ? 'Approved' : kind === 'deny' ? 'Denied' : 'Answered'
|
|
3970
|
+
await settleThreadChoice(deps, click.pendingId, `${done} by ${who} with the request's own buttons.`).catch((err) => console.error('[loopengine] Telegram update failed:', err))
|
|
3971
|
+
}
|
|
3972
|
+
// The request as it was, with what happened under it.
|
|
3973
|
+
const settled = !outcome.error && !outcome.alreadyResolved
|
|
3974
|
+
let result = outcomeText(kind, who, outcome, answer === undefined ? undefined : telegramEscape(answer))
|
|
3975
|
+
if (kind === 'approve' && settled) result = `**Approved by ${who}.** ${result}`
|
|
3976
|
+
const request = readTelegramThread(agentDirFor(entry.config), click.conversation, click.messageId).find((m) => m.ts === click.messageId)?.text
|
|
3977
|
+
if (request) result = `${request}\n\n${result}`
|
|
3978
|
+
else if (item && kind === 'approve' && settled) result += `\nRan \`${item.tool}\` with:\n${formatArgs(item.args, telegramEscape, (await resolveAgentTool(entry.config, item.tool))?.input_schema)}`
|
|
3979
|
+
await editTelegramMessage(botToken, click.conversation, click.messageId, result)
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
// A job someone started by messaging the agent (core/messaging/jobs.ts).
|
|
3983
|
+
function jobDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): JobDeps {
|
|
3984
|
+
const app = connector.app as TwoWayApp
|
|
3985
|
+
return {
|
|
3986
|
+
agentName,
|
|
3987
|
+
agentDir: agentDirFor(entry.config),
|
|
3988
|
+
connector,
|
|
3989
|
+
plan: process.env.LOOPENGINE_PLAN,
|
|
3990
|
+
allowed: (message) => jobAllowed(entry.config, app, message),
|
|
3991
|
+
runTurn: (sessionId, text) => runJobTurn(agentName, sessionId, text, connector.app === 'telegram' ? 'telegram' : 'slack'),
|
|
3992
|
+
decideLink: (pendingId, kind) => {
|
|
3993
|
+
const base = publicBaseUrl(connectionPublicUrl(entry.config, app))
|
|
3994
|
+
return base ? `${base}${decidePath(pendingId, kind)}` : undefined
|
|
3995
|
+
},
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
// A job thread's approvals and questions wait in the thread itself
|
|
4000
|
+
// (core/messaging/jobs.ts posts them), so the run's own approver only
|
|
4001
|
+
// hands out an id. The agent's notifier, when it has one, still sends its
|
|
4002
|
+
// request too, and wins: run-agent prefers it on the http channel.
|
|
4003
|
+
const threadApprover = {
|
|
4004
|
+
requestApproval: async () => false,
|
|
4005
|
+
requestDurableApproval: () => ({ pendingId: randomUUID() }),
|
|
4006
|
+
}
|
|
4007
|
+
const threadQuestions = {
|
|
4008
|
+
requestQuestion: async () => NO_HUMAN_ANSWER,
|
|
4009
|
+
notifyPendingQuestion: () => ({ pendingId: randomUUID() }),
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
function jobTurnOf(text: string, pending: PendingSummary[] | undefined): JobTurn {
|
|
4013
|
+
if (!pending?.length) return { kind: 'done', text }
|
|
4014
|
+
return {
|
|
4015
|
+
kind: 'pending',
|
|
4016
|
+
items: pending.map((p) => ({
|
|
4017
|
+
pendingId: p.pendingId,
|
|
4018
|
+
kind: p.kind,
|
|
4019
|
+
tool: p.tool,
|
|
4020
|
+
...(p.kind === 'question' ? { question: typeof p.args.question === 'string' ? p.args.question : p.reason } : {}),
|
|
4021
|
+
})),
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
// One turn of a job thread: the same session for the whole thread.
|
|
4026
|
+
async function runJobTurn(agent: string, rawSessionId: string, message: string, source: 'slack' | 'telegram'): Promise<JobTurn> {
|
|
4027
|
+
const entry = getEntry(agent)
|
|
4028
|
+
if (!entry) throw new Error(`no agent named "${agent}" in this project`)
|
|
4029
|
+
const tenant = 'default'
|
|
4030
|
+
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
4031
|
+
const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
|
|
4032
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
4033
|
+
tenant,
|
|
4034
|
+
sessionId: rawSessionId,
|
|
4035
|
+
channel: 'http',
|
|
4036
|
+
approver: threadApprover,
|
|
4037
|
+
questionHandler: threadQuestions,
|
|
4038
|
+
})
|
|
4039
|
+
// For a host's run observer (core/run-events.ts).
|
|
4040
|
+
reportRun({ agent, source, id: randomUUID(), usedTool: usedTool(result.newMessages) })
|
|
4041
|
+
if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
|
|
4042
|
+
return { newMessages: result.newMessages, result: { text: result.text, pending: pendingSummary(result.pending) } }
|
|
4043
|
+
})
|
|
4044
|
+
return jobTurnOf(outcome.text, outcome.pending)
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
// A resumed run that came from a job thread answers there.
|
|
4048
|
+
async function settleJobFromResume(agent: string, sessionId: string, text: string, pending: PendingSummary[] | undefined): Promise<void> {
|
|
4049
|
+
const entry = getEntry(agent)
|
|
4050
|
+
if (!entry) return
|
|
4051
|
+
const job = findJobThreadBySession(agentDirFor(entry.config), sessionId)
|
|
4052
|
+
if (!job) return
|
|
4053
|
+
let connector: MessagingConnector | undefined
|
|
4054
|
+
if (job.app === 'slack') {
|
|
4055
|
+
const { botToken } = slackCredentialsFor(entry.config)
|
|
4056
|
+
if (botToken) connector = createSlackConnector(botToken)
|
|
4057
|
+
} else if (job.app === 'telegram') {
|
|
4058
|
+
const { botToken } = telegramCredentialsFor(entry.config)
|
|
4059
|
+
if (botToken) connector = createTelegramConnector(botToken, agentDirFor(entry.config))
|
|
4060
|
+
}
|
|
4061
|
+
if (!connector) return
|
|
4062
|
+
await settleJobThread(jobDeps(agent, entry, connector), sessionId, jobTurnOf(text, pending))
|
|
3787
4063
|
}
|
|
3788
4064
|
|
|
3789
4065
|
// What core/messaging/ needs from this server to answer, decide and
|
|
3790
4066
|
// change an agent's Slack requests.
|
|
3791
4067
|
function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
|
|
4068
|
+
return messagingDeps(agentName, entry, createSlackConnector(botToken))
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
// The same, for any app's connector.
|
|
4072
|
+
function messagingDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): MessagingDeps {
|
|
3792
4073
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
3793
4074
|
return {
|
|
3794
4075
|
agentName,
|
|
3795
4076
|
agentDir: agentDirFor(entry.config),
|
|
3796
4077
|
systemPrompt: entry.config.systemPrompt,
|
|
3797
|
-
connector
|
|
4078
|
+
connector,
|
|
3798
4079
|
createModelCall: () => entry.createModelCall(),
|
|
3799
4080
|
getCheckpoint: (pendingId) => checkpoints.get(pendingId),
|
|
3800
4081
|
getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
|
|
3801
4082
|
plan: process.env.LOOPENGINE_PLAN,
|
|
3802
|
-
approvers: () => agentApprovers(entry.config),
|
|
4083
|
+
approvers: () => agentApprovers(entry.config, connector.app as TwoWayApp),
|
|
3803
4084
|
decide: decideFromMessaging,
|
|
3804
4085
|
toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
|
|
3805
4086
|
// The same checks the console's approve-with-edits applies, plus the
|
|
@@ -3823,6 +4104,10 @@ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: s
|
|
|
3823
4104
|
// recorded through the same resolve and answer handlers as the console's
|
|
3824
4105
|
// buttons.
|
|
3825
4106
|
async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
|
|
4107
|
+
// A run from a job thread posts its own result there once it resumes
|
|
4108
|
+
// (settleJobFromResume), so it isn't quoted here as well.
|
|
4109
|
+
const checkpoint = await checkpoints.get(pendingId)
|
|
4110
|
+
const fromJob = checkpoint ? Boolean(getEntry(checkpoint.agent) && findJobThreadBySession(agentDir(checkpoint.agent), checkpoint.sessionId)) : false
|
|
3826
4111
|
const capture = captureResponse()
|
|
3827
4112
|
if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
|
|
3828
4113
|
else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
|
|
@@ -3831,7 +4116,7 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
|
|
|
3831
4116
|
if (body.alreadyResolved) return { alreadyResolved: true }
|
|
3832
4117
|
if (body.resolved) return { stillWaiting: true }
|
|
3833
4118
|
if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
|
|
3834
|
-
return { text: typeof body.text === 'string' ? body.text : undefined }
|
|
4119
|
+
return { text: typeof body.text === 'string' && !fromJob ? body.text : undefined }
|
|
3835
4120
|
}
|
|
3836
4121
|
|
|
3837
4122
|
// ---- Webhook triggers (core/workflow-triggers.ts): an outside service
|
|
@@ -3892,6 +4177,13 @@ async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, tok
|
|
|
3892
4177
|
}
|
|
3893
4178
|
}
|
|
3894
4179
|
|
|
4180
|
+
// This server's address as the request reached it (behind a proxy, as
|
|
4181
|
+
// the proxy says), for URLs shown in the console.
|
|
4182
|
+
function requestBaseUrl(req: IncomingMessage): string {
|
|
4183
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
|
|
4184
|
+
return `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
|
|
4185
|
+
}
|
|
4186
|
+
|
|
3895
4187
|
// A workflow's webhook triggers, for the console: list, make (its URL is
|
|
3896
4188
|
// shown once), change, rotate (a new URL), delete.
|
|
3897
4189
|
async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
|
|
@@ -3901,9 +4193,7 @@ async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse,
|
|
|
3901
4193
|
if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
|
|
3902
4194
|
const workflow = readWorkflow(agentDir(agentName), workflowId)
|
|
3903
4195
|
if (!workflow) return json(404, { error: `no workflow ${workflowId} in ${agentName}` })
|
|
3904
|
-
const
|
|
3905
|
-
const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
|
|
3906
|
-
const urlFor = (token: string) => `${base}/hooks/workflows/${token}`
|
|
4196
|
+
const urlFor = (token: string) => `${requestBaseUrl(req)}/hooks/workflows/${token}`
|
|
3907
4197
|
try {
|
|
3908
4198
|
if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
|
|
3909
4199
|
if (!triggerId && req.method === 'POST') {
|
|
@@ -3966,6 +4256,18 @@ const server = createServer(async (req, res) => {
|
|
|
3966
4256
|
return
|
|
3967
4257
|
}
|
|
3968
4258
|
|
|
4259
|
+
const telegramHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/telegram\/([^/]+)$/)
|
|
4260
|
+
if (telegramHookMatch) {
|
|
4261
|
+
try {
|
|
4262
|
+
lastRequestAt = Date.now()
|
|
4263
|
+
await tracked(() => handleTelegramWebhook(req, res, decodeURIComponent(telegramHookMatch[1])))
|
|
4264
|
+
} catch (err) {
|
|
4265
|
+
console.error('[loopengine] Telegram update failed:', err)
|
|
4266
|
+
if (!res.headersSent) res.writeHead(500).end()
|
|
4267
|
+
}
|
|
4268
|
+
return
|
|
4269
|
+
}
|
|
4270
|
+
|
|
3969
4271
|
// A workflow's webhook trigger: its token is the credential (no login).
|
|
3970
4272
|
const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
|
|
3971
4273
|
if (workflowHookMatch) {
|
|
@@ -4613,6 +4915,18 @@ const server = createServer(async (req, res) => {
|
|
|
4613
4915
|
await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
|
|
4614
4916
|
return
|
|
4615
4917
|
}
|
|
4918
|
+
const telegramPeopleMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-people$/)
|
|
4919
|
+
if (telegramPeopleMatch) {
|
|
4920
|
+
const entry = getEntry(decodeURIComponent(telegramPeopleMatch[1]))
|
|
4921
|
+
if (!entry) res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unknown agent' }))
|
|
4922
|
+
else res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ people: listAgentTelegramPeople(entry.config) }))
|
|
4923
|
+
return
|
|
4924
|
+
}
|
|
4925
|
+
const telegramWebhookMatch = (req.method === 'GET' || req.method === 'POST') && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-webhook$/)
|
|
4926
|
+
if (telegramWebhookMatch) {
|
|
4927
|
+
await handleTelegramWebhookSetup(req, res, decodeURIComponent(telegramWebhookMatch[1]))
|
|
4928
|
+
return
|
|
4929
|
+
}
|
|
4616
4930
|
const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
|
|
4617
4931
|
if (slackMembersMatch) {
|
|
4618
4932
|
await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
|