create-loopengine 0.1.67 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -132,6 +132,10 @@ import {
132
132
  replyPrivatelyInSlack,
133
133
  type MessagingDecision,
134
134
  type DecisionOutcome,
135
+ type MessagingDeps,
136
+ handleMessagingAction,
137
+ agentConfirmTools,
138
+ setAgentConfirmTools,
135
139
  openSlackAnswerModal,
136
140
  parseSlackInteraction,
137
141
  replyInSlack,
@@ -1287,6 +1291,28 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
1287
1291
  }
1288
1292
  }
1289
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
+
1290
1316
  async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
1291
1317
  const entry = getEntry(agentName)
1292
1318
  if (!entry) {
@@ -2773,23 +2799,39 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
2773
2799
  res.writeHead(200).end()
2774
2800
  if (interaction.type === 'ignore') return
2775
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
+ }
2776
2817
  const capture = captureResponse()
2777
2818
  if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
2778
2819
  else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
2779
2820
  const outcome = await capture.done
2780
2821
  if (interaction.responseUrl) {
2781
- const text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
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
2782
2824
  await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
2783
2825
  }
2784
2826
  }
2785
2827
 
2786
2828
  // A Slack app's Event Subscriptions Request URL (core/messaging/):
2787
2829
  // a reply in the thread under an approval request or a question, which
2788
- // the agent answers about that request. Outside the admin login like the
2789
- // button endpoint above, and verified with the same signing secret.
2790
- // Slack's URL check is answered before that, since the app is created
2791
- // before its signing secret can be saved here; it only echoes Slack's
2792
- // 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.
2793
2835
  async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2794
2836
  const rawBody = await readRawBody(req)
2795
2837
  const entry = getEntry(agentName)
@@ -2816,8 +2858,14 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2816
2858
  // Slack wants an answer within three seconds; the reply is posted after.
2817
2859
  res.writeHead(200).end()
2818
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 {
2819
2867
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
2820
- await handleInboundMessage(event, {
2868
+ return {
2821
2869
  agentName,
2822
2870
  agentDir: agentDirFor(entry.config),
2823
2871
  systemPrompt: entry.config.systemPrompt,
@@ -2827,8 +2875,24 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2827
2875
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
2828
2876
  plan: process.env.LOOPENGINE_PLAN,
2829
2877
  approvers: () => agentApprovers(entry.config),
2878
+ confirmTools: () => agentConfirmTools(entry.config),
2830
2879
  decide: decideFromMessaging,
2831
- })
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
+ }
2832
2896
  }
2833
2897
 
2834
2898
  // A decision made by replying in a request's thread (core/messaging/),
@@ -2837,7 +2901,7 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2837
2901
  async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
2838
2902
  const capture = captureResponse()
2839
2903
  if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
2840
- else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind })
2904
+ else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
2841
2905
  const { status, body } = await capture.done
2842
2906
  if (status >= 400) return { error: typeof body.error === 'string' ? body.error : `HTTP ${status}` }
2843
2907
  if (body.alreadyResolved) return { alreadyResolved: true }
@@ -3405,6 +3469,11 @@ const server = createServer(async (req, res) => {
3405
3469
  await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
3406
3470
  return
3407
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
+ }
3408
3477
  const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
3409
3478
  if (slackMembersMatch) {
3410
3479
  await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.83",
12
+ "loopengine": "^0.1.84",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"