create-loopengine 0.1.66 → 0.1.68
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 +160 -8
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -125,6 +125,17 @@ import {
|
|
|
125
125
|
listAgentSlackChannels,
|
|
126
126
|
slackCredentialsFor,
|
|
127
127
|
slackConversationsOn,
|
|
128
|
+
slackClickAllowed,
|
|
129
|
+
agentApprovers,
|
|
130
|
+
setAgentApprovers,
|
|
131
|
+
listAgentSlackMembers,
|
|
132
|
+
replyPrivatelyInSlack,
|
|
133
|
+
type MessagingDecision,
|
|
134
|
+
type DecisionOutcome,
|
|
135
|
+
type MessagingDeps,
|
|
136
|
+
handleMessagingAction,
|
|
137
|
+
agentConfirmTools,
|
|
138
|
+
setAgentConfirmTools,
|
|
128
139
|
openSlackAnswerModal,
|
|
129
140
|
parseSlackInteraction,
|
|
130
141
|
replyInSlack,
|
|
@@ -1256,6 +1267,66 @@ async function handleSlackChannels(req: IncomingMessage, res: ServerResponse, ag
|
|
|
1256
1267
|
}
|
|
1257
1268
|
}
|
|
1258
1269
|
|
|
1270
|
+
// Messaging tab → Approvers: who can decide this agent's Slack requests
|
|
1271
|
+
// by replying (core/messaging/), and the people in its channel to pick
|
|
1272
|
+
// from. Changing the list needs the admin login, like saving a secret:
|
|
1273
|
+
// it decides who can approve what the agent does.
|
|
1274
|
+
async function handleApproversPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1275
|
+
const entry = getEntry(agentName)
|
|
1276
|
+
if (!entry) {
|
|
1277
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1278
|
+
return
|
|
1279
|
+
}
|
|
1280
|
+
if (!secretsProtected) {
|
|
1281
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Changing approvers needs LOOPENGINE_ADMIN_AUTH set on the server.' }))
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
const body = await readJsonBody(req)
|
|
1285
|
+
try {
|
|
1286
|
+
const approvers = setAgentApprovers(entry.config, body.approvers)
|
|
1287
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
const status = err instanceof NotifierFileError ? 400 : 500
|
|
1290
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Messaging tab → Confirm-required tools: tools whose approval by reply
|
|
1295
|
+
// needs a Confirm click. Admin login needed, like the approvers.
|
|
1296
|
+
async function handleConfirmToolsPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1297
|
+
const entry = getEntry(agentName)
|
|
1298
|
+
if (!entry) {
|
|
1299
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
if (!secretsProtected) {
|
|
1303
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Changing Confirm-required tools needs LOOPENGINE_ADMIN_AUTH set on the server.' }))
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
const body = await readJsonBody(req)
|
|
1307
|
+
try {
|
|
1308
|
+
const tools = setAgentConfirmTools(entry.config, body.tools)
|
|
1309
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tools }))
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
const status = err instanceof NotifierFileError ? 400 : 500
|
|
1312
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
|
|
1317
|
+
const entry = getEntry(agentName)
|
|
1318
|
+
if (!entry) {
|
|
1319
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1320
|
+
return
|
|
1321
|
+
}
|
|
1322
|
+
try {
|
|
1323
|
+
const members = await listAgentSlackMembers(entry.config)
|
|
1324
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ members }))
|
|
1325
|
+
} catch (err) {
|
|
1326
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1259
1330
|
// Every approval/question still waiting for this agent — for an app that
|
|
1260
1331
|
// can't receive webhooks (it polls this instead) and for the console.
|
|
1261
1332
|
async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -2709,6 +2780,17 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
|
|
|
2709
2780
|
return
|
|
2710
2781
|
}
|
|
2711
2782
|
const interaction = parseSlackInteraction(rawBody)
|
|
2783
|
+
// Once the agent has approvers (Messaging tab), only their clicks count.
|
|
2784
|
+
if (interaction.type !== 'ignore') {
|
|
2785
|
+
const gate = slackClickAllowed(entry.config, interaction.user)
|
|
2786
|
+
if (!gate.allowed) {
|
|
2787
|
+
res.writeHead(200).end()
|
|
2788
|
+
if ('responseUrl' in interaction && interaction.responseUrl) {
|
|
2789
|
+
await replyPrivatelyInSlack(interaction.responseUrl, gate.message ?? 'Only approvers can decide this.').catch((err) => console.error('[loopengine] Slack reply failed:', err))
|
|
2790
|
+
}
|
|
2791
|
+
return
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2712
2794
|
if (interaction.type === 'open_answer') {
|
|
2713
2795
|
if (botToken) await openSlackAnswerModal(botToken, interaction.triggerId, interaction.pendingId).catch((err) => console.error('[loopengine] Slack modal failed:', err))
|
|
2714
2796
|
res.writeHead(200).end()
|
|
@@ -2717,23 +2799,39 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
|
|
|
2717
2799
|
res.writeHead(200).end()
|
|
2718
2800
|
if (interaction.type === 'ignore') return
|
|
2719
2801
|
|
|
2802
|
+
// A button under one of the agent's own thread replies: Approve as
|
|
2803
|
+
// changed, Keep original, Confirm, Cancel (core/messaging/engine.ts).
|
|
2804
|
+
if (interaction.type === 'action') {
|
|
2805
|
+
if (!botToken) return
|
|
2806
|
+
const text = await handleMessagingAction(interaction.action, interaction.pendingId, interaction.user ?? '', slackMessagingDeps(agentName, entry, botToken))
|
|
2807
|
+
if (interaction.responseUrl) await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
|
|
2808
|
+
return
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// Approve reports what ran, so the final arguments are in the channel.
|
|
2812
|
+
let ran = ''
|
|
2813
|
+
if (interaction.type === 'decide' && interaction.decision === 'approve') {
|
|
2814
|
+
const item = (await checkpoints.get(interaction.pendingId))?.outstanding[interaction.pendingId]
|
|
2815
|
+
if (item) ran = `\nRan \`${item.tool}\` with \`${JSON.stringify(item.args)}\`.`
|
|
2816
|
+
}
|
|
2720
2817
|
const capture = captureResponse()
|
|
2721
2818
|
if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
|
|
2722
2819
|
else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
|
|
2723
2820
|
const outcome = await capture.done
|
|
2724
2821
|
if (interaction.responseUrl) {
|
|
2725
|
-
|
|
2822
|
+
let text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
|
|
2823
|
+
if (ran && outcome.status < 400 && !outcome.body.alreadyResolved) text += ran
|
|
2726
2824
|
await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
|
|
2727
2825
|
}
|
|
2728
2826
|
}
|
|
2729
2827
|
|
|
2730
2828
|
// A Slack app's Event Subscriptions Request URL (core/messaging/):
|
|
2731
2829
|
// a reply in the thread under an approval request or a question, which
|
|
2732
|
-
// the agent answers about that request
|
|
2733
|
-
//
|
|
2734
|
-
//
|
|
2735
|
-
// before
|
|
2736
|
-
// own challenge back.
|
|
2830
|
+
// the agent answers about that request, and which an approver can use to
|
|
2831
|
+
// decide or change it. Outside the admin login like the button endpoint
|
|
2832
|
+
// above, and verified with the same signing secret. Slack's URL check is
|
|
2833
|
+
// answered before that, since the app is created before its signing
|
|
2834
|
+
// secret can be saved here; it only echoes Slack's own challenge back.
|
|
2737
2835
|
async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
2738
2836
|
const rawBody = await readRawBody(req)
|
|
2739
2837
|
const entry = getEntry(agentName)
|
|
@@ -2760,8 +2858,14 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
|
|
|
2760
2858
|
// Slack wants an answer within three seconds; the reply is posted after.
|
|
2761
2859
|
res.writeHead(200).end()
|
|
2762
2860
|
if (event.type !== 'message' || !firstSighting(event) || !slackConversationsOn(entry.config)) return
|
|
2861
|
+
await handleInboundMessage(event, slackMessagingDeps(agentName, entry, botToken))
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// What core/messaging/ needs from this server to answer, decide and
|
|
2865
|
+
// change an agent's Slack requests.
|
|
2866
|
+
function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
|
|
2763
2867
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
2764
|
-
|
|
2868
|
+
return {
|
|
2765
2869
|
agentName,
|
|
2766
2870
|
agentDir: agentDirFor(entry.config),
|
|
2767
2871
|
systemPrompt: entry.config.systemPrompt,
|
|
@@ -2770,7 +2874,40 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
|
|
|
2770
2874
|
getCheckpoint: (pendingId) => checkpoints.get(pendingId),
|
|
2771
2875
|
getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
|
|
2772
2876
|
plan: process.env.LOOPENGINE_PLAN,
|
|
2773
|
-
|
|
2877
|
+
approvers: () => agentApprovers(entry.config),
|
|
2878
|
+
confirmTools: () => agentConfirmTools(entry.config),
|
|
2879
|
+
decide: decideFromMessaging,
|
|
2880
|
+
toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
|
|
2881
|
+
// The same checks the console's approve-with-edits applies, plus the
|
|
2882
|
+
// permission rules: changed arguments a rule denies are refused.
|
|
2883
|
+
checkArgs: async (pendingId, args) => {
|
|
2884
|
+
const checkpoint = await checkpoints.get(pendingId)
|
|
2885
|
+
const item = checkpoint?.outstanding[pendingId]
|
|
2886
|
+
if (!checkpoint || !item) return 'the request was already decided'
|
|
2887
|
+
const tool = await resolveAgentTool(entry.config, item.tool)
|
|
2888
|
+
if (!tool) return `the tool '${item.tool}' no longer exists`
|
|
2889
|
+
const invalid = validateEditedArgs(args, tool.input_schema)
|
|
2890
|
+
if (invalid) return `the arguments ${invalid}`
|
|
2891
|
+
const [decision, rule] = loadRules(entry.config).resolve(item.tool, args, { tenant: checkpoint.tenant, environment, agent: checkpoint.agent })
|
|
2892
|
+
if (decision === 'deny') return `a permission rule${rule?.name ? ` (${rule.name})` : ''} doesn't allow it`
|
|
2893
|
+
return undefined
|
|
2894
|
+
},
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// A decision made by replying in a request's thread (core/messaging/),
|
|
2899
|
+
// recorded through the same resolve and answer handlers as the console's
|
|
2900
|
+
// buttons.
|
|
2901
|
+
async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
|
|
2902
|
+
const capture = captureResponse()
|
|
2903
|
+
if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
|
|
2904
|
+
else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
|
|
2905
|
+
const { status, body } = await capture.done
|
|
2906
|
+
if (status >= 400) return { error: typeof body.error === 'string' ? body.error : `HTTP ${status}` }
|
|
2907
|
+
if (body.alreadyResolved) return { alreadyResolved: true }
|
|
2908
|
+
if (body.resolved) return { stillWaiting: true }
|
|
2909
|
+
if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
|
|
2910
|
+
return { text: typeof body.text === 'string' ? body.text : undefined }
|
|
2774
2911
|
}
|
|
2775
2912
|
|
|
2776
2913
|
// Set once shutdown() starts, so /healthz turns 503 while the server
|
|
@@ -3327,6 +3464,21 @@ const server = createServer(async (req, res) => {
|
|
|
3327
3464
|
return
|
|
3328
3465
|
}
|
|
3329
3466
|
|
|
3467
|
+
const approversMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/approvers$/)
|
|
3468
|
+
if (approversMatch) {
|
|
3469
|
+
await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
|
|
3470
|
+
return
|
|
3471
|
+
}
|
|
3472
|
+
const confirmToolsMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/confirm-tools$/)
|
|
3473
|
+
if (confirmToolsMatch) {
|
|
3474
|
+
await handleConfirmToolsPut(req, res, decodeURIComponent(confirmToolsMatch[1]))
|
|
3475
|
+
return
|
|
3476
|
+
}
|
|
3477
|
+
const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
|
|
3478
|
+
if (slackMembersMatch) {
|
|
3479
|
+
await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
|
|
3480
|
+
return
|
|
3481
|
+
}
|
|
3330
3482
|
const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
|
|
3331
3483
|
if (slackChannelsMatch) {
|
|
3332
3484
|
await handleSlackChannels(req, res, decodeURIComponent(slackChannelsMatch[1]))
|