create-loopengine 0.1.67 → 0.1.69

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.69",
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) {
@@ -2290,7 +2316,12 @@ async function resolvePendingApproval(res: ServerResponse, pendingId: string, bo
2290
2316
  sessionId: checkpoint.sessionId,
2291
2317
  env: createAgentEnv(entry.config.name, agentDirFor(entry.config)),
2292
2318
  })
2293
- resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: JSON.stringify(output), is_error: false }
2319
+ // The agent's own history still shows the arguments it asked
2320
+ // for, so say when the approver changed them; otherwise its
2321
+ // reply describes what it asked for, not what ran.
2322
+ const changed = editedArgs && JSON.stringify(editedArgs) !== JSON.stringify(item.args)
2323
+ const content = changed ? `The approver changed the arguments before it ran: ${JSON.stringify(editedArgs)}. Result: ${JSON.stringify(output)}` : JSON.stringify(output)
2324
+ resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content, is_error: false }
2294
2325
  } catch (err) {
2295
2326
  resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `ERROR: ${err}`, is_error: true }
2296
2327
  }
@@ -2773,23 +2804,39 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
2773
2804
  res.writeHead(200).end()
2774
2805
  if (interaction.type === 'ignore') return
2775
2806
 
2807
+ // A button under one of the agent's own thread replies: Approve as
2808
+ // changed, Keep original, Confirm, Cancel (core/messaging/engine.ts).
2809
+ if (interaction.type === 'action') {
2810
+ if (!botToken) return
2811
+ const text = await handleMessagingAction(interaction.action, interaction.pendingId, interaction.user ?? '', slackMessagingDeps(agentName, entry, botToken))
2812
+ if (interaction.responseUrl) await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
2813
+ return
2814
+ }
2815
+
2816
+ // Approve reports what ran, so the final arguments are in the channel.
2817
+ let ran = ''
2818
+ if (interaction.type === 'decide' && interaction.decision === 'approve') {
2819
+ const item = (await checkpoints.get(interaction.pendingId))?.outstanding[interaction.pendingId]
2820
+ if (item) ran = `\nRan \`${item.tool}\` with \`${JSON.stringify(item.args)}\`.`
2821
+ }
2776
2822
  const capture = captureResponse()
2777
2823
  if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
2778
2824
  else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
2779
2825
  const outcome = await capture.done
2780
2826
  if (interaction.responseUrl) {
2781
- const text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
2827
+ let text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
2828
+ if (ran && outcome.status < 400 && !outcome.body.alreadyResolved) text += ran
2782
2829
  await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
2783
2830
  }
2784
2831
  }
2785
2832
 
2786
2833
  // A Slack app's Event Subscriptions Request URL (core/messaging/):
2787
2834
  // 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.
2835
+ // the agent answers about that request, and which an approver can use to
2836
+ // decide or change it. Outside the admin login like the button endpoint
2837
+ // above, and verified with the same signing secret. Slack's URL check is
2838
+ // answered before that, since the app is created before its signing
2839
+ // secret can be saved here; it only echoes Slack's own challenge back.
2793
2840
  async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2794
2841
  const rawBody = await readRawBody(req)
2795
2842
  const entry = getEntry(agentName)
@@ -2816,8 +2863,14 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2816
2863
  // Slack wants an answer within three seconds; the reply is posted after.
2817
2864
  res.writeHead(200).end()
2818
2865
  if (event.type !== 'message' || !firstSighting(event) || !slackConversationsOn(entry.config)) return
2866
+ await handleInboundMessage(event, slackMessagingDeps(agentName, entry, botToken))
2867
+ }
2868
+
2869
+ // What core/messaging/ needs from this server to answer, decide and
2870
+ // change an agent's Slack requests.
2871
+ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
2819
2872
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
2820
- await handleInboundMessage(event, {
2873
+ return {
2821
2874
  agentName,
2822
2875
  agentDir: agentDirFor(entry.config),
2823
2876
  systemPrompt: entry.config.systemPrompt,
@@ -2827,8 +2880,24 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2827
2880
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
2828
2881
  plan: process.env.LOOPENGINE_PLAN,
2829
2882
  approvers: () => agentApprovers(entry.config),
2883
+ confirmTools: () => agentConfirmTools(entry.config),
2830
2884
  decide: decideFromMessaging,
2831
- })
2885
+ toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
2886
+ // The same checks the console's approve-with-edits applies, plus the
2887
+ // permission rules: changed arguments a rule denies are refused.
2888
+ checkArgs: async (pendingId, args) => {
2889
+ const checkpoint = await checkpoints.get(pendingId)
2890
+ const item = checkpoint?.outstanding[pendingId]
2891
+ if (!checkpoint || !item) return 'the request was already decided'
2892
+ const tool = await resolveAgentTool(entry.config, item.tool)
2893
+ if (!tool) return `the tool '${item.tool}' no longer exists`
2894
+ const invalid = validateEditedArgs(args, tool.input_schema)
2895
+ if (invalid) return `the arguments ${invalid}`
2896
+ const [decision, rule] = loadRules(entry.config).resolve(item.tool, args, { tenant: checkpoint.tenant, environment, agent: checkpoint.agent })
2897
+ if (decision === 'deny') return `a permission rule${rule?.name ? ` (${rule.name})` : ''} doesn't allow it`
2898
+ return undefined
2899
+ },
2900
+ }
2832
2901
  }
2833
2902
 
2834
2903
  // A decision made by replying in a request's thread (core/messaging/),
@@ -2837,7 +2906,7 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2837
2906
  async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
2838
2907
  const capture = captureResponse()
2839
2908
  if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
2840
- else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind })
2909
+ else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
2841
2910
  const { status, body } = await capture.done
2842
2911
  if (status >= 400) return { error: typeof body.error === 'string' ? body.error : `HTTP ${status}` }
2843
2912
  if (body.alreadyResolved) return { alreadyResolved: true }
@@ -3405,6 +3474,11 @@ const server = createServer(async (req, res) => {
3405
3474
  await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
3406
3475
  return
3407
3476
  }
3477
+ const confirmToolsMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/confirm-tools$/)
3478
+ if (confirmToolsMatch) {
3479
+ await handleConfirmToolsPut(req, res, decodeURIComponent(confirmToolsMatch[1]))
3480
+ return
3481
+ }
3408
3482
  const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
3409
3483
  if (slackMembersMatch) {
3410
3484
  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.85",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"