create-loopengine 0.3.0 → 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 +299 -12
- 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,
|
|
@@ -145,7 +146,16 @@ import {
|
|
|
145
146
|
listAgentPending,
|
|
146
147
|
listAgentSlackChannels,
|
|
147
148
|
slackCredentialsFor,
|
|
148
|
-
|
|
149
|
+
agentTelegramWebhookInfo,
|
|
150
|
+
clickAllowed,
|
|
151
|
+
connectionPublicUrl,
|
|
152
|
+
conversationsOn,
|
|
153
|
+
jobAllowed,
|
|
154
|
+
jobsOn,
|
|
155
|
+
listAgentTelegramPeople,
|
|
156
|
+
setupAgentTelegramWebhook,
|
|
157
|
+
telegramCredentialsFor,
|
|
158
|
+
type TwoWayApp,
|
|
149
159
|
slackClickAllowed,
|
|
150
160
|
agentApprovers,
|
|
151
161
|
setAgentApprovers,
|
|
@@ -167,6 +177,28 @@ import {
|
|
|
167
177
|
firstSighting,
|
|
168
178
|
handleInboundMessage,
|
|
169
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,
|
|
170
202
|
parseSlackEvent,
|
|
171
203
|
removeAgentNotifier,
|
|
172
204
|
saveAgentNotifier,
|
|
@@ -276,6 +308,7 @@ import {
|
|
|
276
308
|
createScheduleTools,
|
|
277
309
|
type PendingQuestion,
|
|
278
310
|
WebhookNotifier,
|
|
311
|
+
publicBaseUrl,
|
|
279
312
|
// Workflows (skills with a start message), their runs, run callbacks
|
|
280
313
|
// and webhook triggers.
|
|
281
314
|
WorkflowParamsError,
|
|
@@ -1478,6 +1511,11 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1478
1511
|
return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
|
|
1479
1512
|
},
|
|
1480
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
|
+
}
|
|
1481
1519
|
json(200, created)
|
|
1482
1520
|
// New tools in an agent that was already running load with a restart,
|
|
1483
1521
|
// as after installing an ability from its tab.
|
|
@@ -2134,7 +2172,7 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
|
|
|
2134
2172
|
}
|
|
2135
2173
|
const body = await readJsonBody(req)
|
|
2136
2174
|
try {
|
|
2137
|
-
const approvers = setAgentApprovers(entry.config, body.approvers)
|
|
2175
|
+
const approvers = setAgentApprovers(entry.config, body.approvers, body.app === 'telegram' ? 'telegram' : 'slack')
|
|
2138
2176
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
|
|
2139
2177
|
} catch (err) {
|
|
2140
2178
|
const status = err instanceof NotifierFileError ? 400 : 500
|
|
@@ -2142,6 +2180,26 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
|
|
|
2142
2180
|
}
|
|
2143
2181
|
}
|
|
2144
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
|
+
|
|
2145
2203
|
async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
|
|
2146
2204
|
const entry = getEntry(agentName)
|
|
2147
2205
|
if (!entry) {
|
|
@@ -3362,6 +3420,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3362
3420
|
}
|
|
3363
3421
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
3364
3422
|
const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
|
|
3423
|
+
const fromJobThread = Boolean(findJobThreadBySession(agentDirFor(entry.config), finalCheckpoint.sessionId))
|
|
3365
3424
|
|
|
3366
3425
|
const responseBody = await sessions.withSession(storageSessionId, async (history) => {
|
|
3367
3426
|
// Same channel/approver/questionHandler fallback the original
|
|
@@ -3389,8 +3448,9 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3389
3448
|
tenant: finalCheckpoint.tenant,
|
|
3390
3449
|
sessionId: finalCheckpoint.sessionId,
|
|
3391
3450
|
channel: 'http',
|
|
3392
|
-
|
|
3393
|
-
|
|
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,
|
|
3394
3454
|
onQuestionPending: () => {},
|
|
3395
3455
|
})
|
|
3396
3456
|
// The resumed turn can itself hit another durably-pending call/
|
|
@@ -3408,6 +3468,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3408
3468
|
const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
|
|
3409
3469
|
void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
|
|
3410
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))
|
|
3411
3472
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
3412
3473
|
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
3413
3474
|
)
|
|
@@ -3782,24 +3843,217 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
|
|
|
3782
3843
|
}
|
|
3783
3844
|
// Slack wants an answer within three seconds; the reply is posted after.
|
|
3784
3845
|
res.writeHead(200).end()
|
|
3785
|
-
if (event.type !== 'message'
|
|
3786
|
-
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))
|
|
3787
4036
|
}
|
|
3788
4037
|
|
|
3789
4038
|
// What core/messaging/ needs from this server to answer, decide and
|
|
3790
4039
|
// change an agent's Slack requests.
|
|
3791
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 {
|
|
3792
4046
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
3793
4047
|
return {
|
|
3794
4048
|
agentName,
|
|
3795
4049
|
agentDir: agentDirFor(entry.config),
|
|
3796
4050
|
systemPrompt: entry.config.systemPrompt,
|
|
3797
|
-
connector
|
|
4051
|
+
connector,
|
|
3798
4052
|
createModelCall: () => entry.createModelCall(),
|
|
3799
4053
|
getCheckpoint: (pendingId) => checkpoints.get(pendingId),
|
|
3800
4054
|
getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
|
|
3801
4055
|
plan: process.env.LOOPENGINE_PLAN,
|
|
3802
|
-
approvers: () => agentApprovers(entry.config),
|
|
4056
|
+
approvers: () => agentApprovers(entry.config, connector.app as TwoWayApp),
|
|
3803
4057
|
decide: decideFromMessaging,
|
|
3804
4058
|
toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
|
|
3805
4059
|
// The same checks the console's approve-with-edits applies, plus the
|
|
@@ -3823,6 +4077,10 @@ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: s
|
|
|
3823
4077
|
// recorded through the same resolve and answer handlers as the console's
|
|
3824
4078
|
// buttons.
|
|
3825
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
|
|
3826
4084
|
const capture = captureResponse()
|
|
3827
4085
|
if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
|
|
3828
4086
|
else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
|
|
@@ -3831,7 +4089,7 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
|
|
|
3831
4089
|
if (body.alreadyResolved) return { alreadyResolved: true }
|
|
3832
4090
|
if (body.resolved) return { stillWaiting: true }
|
|
3833
4091
|
if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
|
|
3834
|
-
return { text: typeof body.text === 'string' ? body.text : undefined }
|
|
4092
|
+
return { text: typeof body.text === 'string' && !fromJob ? body.text : undefined }
|
|
3835
4093
|
}
|
|
3836
4094
|
|
|
3837
4095
|
// ---- Webhook triggers (core/workflow-triggers.ts): an outside service
|
|
@@ -3892,6 +4150,13 @@ async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, tok
|
|
|
3892
4150
|
}
|
|
3893
4151
|
}
|
|
3894
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
|
+
|
|
3895
4160
|
// A workflow's webhook triggers, for the console: list, make (its URL is
|
|
3896
4161
|
// shown once), change, rotate (a new URL), delete.
|
|
3897
4162
|
async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
|
|
@@ -3901,9 +4166,7 @@ async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse,
|
|
|
3901
4166
|
if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
|
|
3902
4167
|
const workflow = readWorkflow(agentDir(agentName), workflowId)
|
|
3903
4168
|
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}`
|
|
4169
|
+
const urlFor = (token: string) => `${requestBaseUrl(req)}/hooks/workflows/${token}`
|
|
3907
4170
|
try {
|
|
3908
4171
|
if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
|
|
3909
4172
|
if (!triggerId && req.method === 'POST') {
|
|
@@ -3966,6 +4229,18 @@ const server = createServer(async (req, res) => {
|
|
|
3966
4229
|
return
|
|
3967
4230
|
}
|
|
3968
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
|
+
|
|
3969
4244
|
// A workflow's webhook trigger: its token is the credential (no login).
|
|
3970
4245
|
const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
|
|
3971
4246
|
if (workflowHookMatch) {
|
|
@@ -4613,6 +4888,18 @@ const server = createServer(async (req, res) => {
|
|
|
4613
4888
|
await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
|
|
4614
4889
|
return
|
|
4615
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
|
+
}
|
|
4616
4903
|
const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
|
|
4617
4904
|
if (slackMembersMatch) {
|
|
4618
4905
|
await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
|