loopengine 0.1.85 → 0.1.87

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/README.md CHANGED
@@ -185,10 +185,14 @@ only their own message to tell whether it's a decision, and anything
185
185
  unclear is treated as a question. An approver can also change a request
186
186
  first ("make it $40"): the change is checked against the tool's schema
187
187
  and the permission rules and shown with **Approve as changed / Keep
188
- original / Deny**, and nothing runs until they approve it. For
189
- **Confirm-required tools**, a typed yes only brings up a Confirm button
190
- showing exactly what will run. Once approvers are listed, only their
191
- button clicks count too. The same tab lists every decision
188
+ original / Deny**, and nothing runs until they approve it. A plain "yes"
189
+ ("approve", "go ahead", "好的", "はい", "sí", 👍…: the whole message,
190
+ matched against a fixed list of phrases in common languages, not by a
191
+ model) runs it; any other reply read as approval ("sure, send
192
+ it", "yes but check the amount") shows exactly what will run with a
193
+ **Confirm** button, and only that click runs it. Arguments are shown
194
+ as labelled fields, with long text such as an email body quoted, not as
195
+ JSON. Once approvers are listed, only their button clicks count too. The same tab lists every decision
192
196
  still waiting with Approve/Deny buttons (its count shows on the tab), and
193
197
  warns you when an agent has `ask` rules but nobody would be told.
194
198
 
@@ -25,6 +25,7 @@
25
25
  // call shape).
26
26
  import { randomUUID } from 'node:crypto';
27
27
  import { decidePath } from '../approval-links.js';
28
+ import { slackArgs, slackEscape, toSlackMrkdwn } from '../messaging/connectors/slack/format.js';
28
29
  import { publicBaseUrl } from './webhook.js';
29
30
  export class SlackNotifier {
30
31
  botToken;
@@ -72,9 +73,12 @@ export class SlackNotifier {
72
73
  type: 'section',
73
74
  text: {
74
75
  type: 'mrkdwn',
75
- text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}${this.decideLine(pendingId, 'approval')}`,
76
+ // Arguments as labelled lines, long text quoted (not JSON);
77
+ // a Slack section holds 3,000 characters, so they're kept under.
78
+ text: fitSection(`*Approval requested:* \`${tool}\``, slackArgs(args), `*Why:* ${toSlackMrkdwn(slackEscape(reason))}${this.decideLine(pendingId, 'approval')}`),
76
79
  },
77
80
  },
81
+ { type: 'context', elements: [{ type: 'mrkdwn', text: `${scope.agent} · ${scope.tenant}/${scope.environment}` }] },
78
82
  ...(this.buttons
79
83
  ? [{
80
84
  type: 'actions',
@@ -99,7 +103,7 @@ export class SlackNotifier {
99
103
  action_id: `answer_option_${i}`,
100
104
  }));
101
105
  this.postMessage(`Question from ${agent}: ${question}`, [
102
- { type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}${this.decideLine(pendingId, 'question')}` } },
106
+ { type: 'section', text: { type: 'mrkdwn', text: fitSection(`*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*`, toSlackMrkdwn(slackEscape(question)), this.decideLine(pendingId, 'question').trim()) } },
103
107
  ...(this.buttons
104
108
  ? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
105
109
  : []),
@@ -134,6 +138,13 @@ export class SlackNotifier {
134
138
  });
135
139
  }
136
140
  }
141
+ const SECTION_MAX = 3000;
142
+ /** A head line, a body cut to fit, and a tail line, in one Slack section. */
143
+ function fitSection(head, body, tail) {
144
+ const room = SECTION_MAX - head.length - tail.length - 60;
145
+ const fitted = body.length > room ? body.slice(0, Math.max(0, room)).trimEnd() + '\n… (cut short here; the decision link has it all)' : body;
146
+ return [head, fitted, tail].filter(Boolean).join('\n');
147
+ }
137
148
  /** One plain chat.postMessage, awaited, reporting whether Slack accepted
138
149
  * it — backs the console's "Send test" for a Slack notifier, where
139
150
  * SlackNotifier.postMessage's fire-and-forget contract would hide the
@@ -42,6 +42,9 @@ export interface MessagingConnector {
42
42
  name: string;
43
43
  /** How to refer to a person in a message (Slack: <@U123>). */
44
44
  mention(user: string): string;
45
+ /** Makes text from anyone else (a tool's arguments, an agent's reason)
46
+ * safe to put in a message, so it can't become a mention or a link. */
47
+ escape(text: string): string;
45
48
  /** The thread's messages, oldest first, its first message included. */
46
49
  fetchThread(conversation: string, threadTs: string): Promise<ThreadMessage[]>;
47
50
  postInThread(conversation: string, threadTs: string, text: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { toSlackMrkdwn } from './format.js';
1
+ import { slackEscape, toSlackMrkdwn } from './format.js';
2
2
  /** Reads an Events API body. Only people's new messages come through;
3
3
  * edits, deletions, joins and every bot's messages (this agent's own
4
4
  * included) are `ignore`. */
@@ -40,6 +40,7 @@ export function createSlackConnector(botToken) {
40
40
  app: 'slack',
41
41
  name: 'Slack',
42
42
  mention: (user) => `<@${user}>`,
43
+ escape: slackEscape,
43
44
  // Needs channels:history or groups:history, depending on the channel.
44
45
  async fetchThread(conversation, threadTs) {
45
46
  const params = new URLSearchParams({ channel: conversation, ts: threadTs, limit: '100' });
@@ -1 +1,7 @@
1
1
  export declare function toSlackMrkdwn(markdown: string): string;
2
+ /** Slack's own escaping, for text that isn't ours (an argument, a
3
+ * reason): with &, < and > escaped, a value can't become a mention, a
4
+ * channel-wide ping (<!channel>) or a disguised link. */
5
+ export declare function slackEscape(text: string): string;
6
+ /** A call's arguments as Slack mrkdwn (core/messaging/format-args.ts). */
7
+ export declare function slackArgs(args: Record<string, unknown>, schema?: Record<string, unknown>): string;
@@ -1,13 +1,22 @@
1
1
  // Models write Markdown even when asked for Slack's own format, and Slack
2
2
  // shows Markdown's **bold**, [links](url) and # headings as literal
3
3
  // characters. This turns the common cases into Slack mrkdwn before a
4
- // reply is posted; code (```blocks``` and `spans`) is left as written.
5
- const CODE = /(```[\s\S]*?```|`[^`\n]*`)/;
4
+ // reply is posted; code (```blocks``` and `spans`) is left as written,
5
+ // and so is text slackEscape marked as someone else's.
6
+ import { formatArgs } from '../../format-args.js';
7
+ // slackEscape wraps text that isn't ours in these, so the conversion
8
+ // below never turns it into formatting or a link; they're removed after.
9
+ const OPEN = '\u0001';
10
+ const CLOSE = '\u0002';
11
+ const KEEP = /(```[\s\S]*?```|`[^`\n]*`|\u0001[\s\S]*?\u0002)/;
6
12
  export function toSlackMrkdwn(markdown) {
7
13
  return markdown
8
- .split(CODE)
14
+ // ~~old value~~ around protected text: the markers sit outside it.
15
+ .replace(/~~(\u0001[^\u0002]*\u0002)~~/g, '~$1~')
16
+ .split(KEEP)
9
17
  .map((part, i) => (i % 2 === 1 ? part : convertProse(part)))
10
- .join('');
18
+ .join('')
19
+ .replace(/[\u0001\u0002]/g, '');
11
20
  }
12
21
  function convertProse(text) {
13
22
  const bold = [];
@@ -32,3 +41,13 @@ function convertProse(text) {
32
41
  out = out.replace(/\u0000(\d+)\u0000/g, (_m, n) => `*${bold[Number(n)]}*`);
33
42
  return out;
34
43
  }
44
+ /** Slack's own escaping, for text that isn't ours (an argument, a
45
+ * reason): with &, < and > escaped, a value can't become a mention, a
46
+ * channel-wide ping (<!channel>) or a disguised link. */
47
+ export function slackEscape(text) {
48
+ return OPEN + text.replace(/[\u0001\u0002]/g, '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + CLOSE;
49
+ }
50
+ /** A call's arguments as Slack mrkdwn (core/messaging/format-args.ts). */
51
+ export function slackArgs(args, schema) {
52
+ return toSlackMrkdwn(formatArgs(args, slackEscape, schema));
53
+ }
@@ -1,4 +1,4 @@
1
1
  export { createSlackConnector, parseSlackEvent, type SlackEvent } from './events.js';
2
- export { toSlackMrkdwn } from './format.js';
2
+ export { slackArgs, slackEscape, toSlackMrkdwn } from './format.js';
3
3
  export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, verifySlackRequest, type SlackInteraction, } from './interactions.js';
4
4
  export { listSlackChannelMembers, listSlackChannels, slackAppManifest, type SlackChannel, type SlackMember } from './setup.js';
@@ -4,6 +4,6 @@
4
4
  // Posting requests is still SlackNotifier's job
5
5
  // (core/http-notify-triggers/slack.ts).
6
6
  export { createSlackConnector, parseSlackEvent } from './events.js';
7
- export { toSlackMrkdwn } from './format.js';
7
+ export { slackArgs, slackEscape, toSlackMrkdwn } from './format.js';
8
8
  export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, verifySlackRequest, } from './interactions.js';
9
9
  export { listSlackChannelMembers, listSlackChannels, slackAppManifest } from './setup.js';
@@ -25,8 +25,6 @@ export interface MessagingDeps {
25
25
  /** Records a decision on a pending request, as the console's own
26
26
  * Approve/Deny/Answer would. */
27
27
  decide(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome>;
28
- /** Tools whose approval needs a Confirm click (Messaging tab). */
29
- confirmTools(): string[];
30
28
  /** The tool's argument schema, for proposing a change. */
31
29
  toolSchema(tool: string): Promise<Record<string, unknown> | undefined>;
32
30
  /** Why changed arguments can't be used (they don't fit the tool's
@@ -57,6 +55,11 @@ export interface DecisionOutcome {
57
55
  /** Handles one person's message: a reply in a request's thread gets an
58
56
  * answer; anything else is left alone. */
59
57
  export declare function handleInboundMessage(message: InboundMessage, deps: MessagingDeps): Promise<void>;
58
+ /** Whether a reply is a plain approval: lower-cased, a leading @mention,
59
+ * emoji skin tones and "please" dropped, punctuation and spaces
60
+ * (full-width ones too) ignored, then compared with the fixed list. A
61
+ * trailing question mark keeps it from matching ("yes?"). */
62
+ export declare function isPlainApproval(text: string): boolean;
60
63
  /** A click on one of the engine's own buttons (Approve as changed, Keep
61
64
  * original, Confirm, Cancel) — the caller has already checked the
62
65
  * person may decide. Returns what the clicked message should now say. */
@@ -1,5 +1,6 @@
1
1
  import { KeyedMutex } from '../session-store.js';
2
- import { decideReply, describeChange, proposeEdit } from './decide.js';
2
+ import { decideReply, proposeEdit } from './decide.js';
3
+ import { formatArgs, formatChange } from './format-args.js';
3
4
  import { findThread, findThreadByPending, recordThread } from './threads.js';
4
5
  // An app may redeliver a message it thinks went unanswered (and Slack
5
6
  // sends a reply that @mentions the bot as both `message` and
@@ -84,7 +85,7 @@ export async function handleInboundMessage(message, deps) {
84
85
  return;
85
86
  }
86
87
  recordThread(deps.agentDir, { ...link, proposedArgs: proposed });
87
- await connector.postChoice(message.conversation, threadTs, `Here's the change ${who} asked for:\n${describeChange(item.args, proposed).map((l) => `• ${l}`).join('\n')}\nNothing else changes.`, [
88
+ await connector.postChoice(message.conversation, threadTs, `Here's the change ${who} asked for:\n${formatChange(item.args, proposed, connector.escape, await deps.toolSchema(item.tool))}\nNothing else changes.`, [
88
89
  { action: 'approve_edited', label: 'Approve as changed', style: 'primary', pendingId: link.pendingId },
89
90
  { action: 'keep_original', label: 'Keep original', pendingId: link.pendingId },
90
91
  { action: 'deny', label: 'Deny', style: 'danger', pendingId: link.pendingId },
@@ -93,21 +94,24 @@ export async function handleInboundMessage(message, deps) {
93
94
  }
94
95
  if (verdict.decision === 'approve') {
95
96
  const args = link.proposedArgs ?? item.args;
96
- if (deps.confirmTools().includes(item.tool)) {
97
- await connector.postChoice(message.conversation, threadTs, `\`${item.tool}\` needs a Confirm click. It will run with \`${JSON.stringify(args)}\`.`, [
98
- { action: 'confirm', label: 'Confirm', style: 'primary', pendingId: link.pendingId },
99
- { action: 'cancel_confirm', label: 'Cancel', pendingId: link.pendingId },
100
- ]);
97
+ // A plain "yes" runs it; anything less clear that still reads as
98
+ // approval asks for a Confirm click first.
99
+ if (isPlainApproval(message.text)) {
100
+ await runApproved(deps, link, item.tool, args, who);
101
101
  return;
102
102
  }
103
- await runApproved(deps, link, item.tool, args, who);
103
+ await connector.postChoice(message.conversation, threadTs, `Confirm to run \`${item.tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(item.tool))}`, [
104
+ { action: 'confirm', label: 'Confirm', style: 'primary', pendingId: link.pendingId },
105
+ { action: 'cancel_confirm', label: 'Cancel', pendingId: link.pendingId },
106
+ ]);
104
107
  return;
105
108
  }
106
109
  const outcome = await deps.decide(link.pendingId, verdict.decision === 'answer' ? { kind: 'answer', answer: verdict.answer } : { kind: 'deny' });
107
- await say(outcomeText(verdict.decision, who, outcome, verdict.decision === 'answer' ? verdict.answer : undefined));
110
+ const answer = verdict.decision === 'answer' ? connector.escape(verdict.answer) : undefined;
111
+ await say(outcomeText(verdict.decision, who, outcome, answer));
108
112
  if (!outcome.error && !outcome.alreadyResolved) {
109
113
  const what = link.kind === 'approval' ? `\`${item.tool}\`` : 'The question';
110
- await replaceRequest(connector, link, `${what} was ${verdict.decision === 'deny' ? 'denied' : `answered ("${verdict.answer}")`} by ${who} in the thread.`);
114
+ await replaceRequest(connector, link, `${what} was ${verdict.decision === 'deny' ? 'denied' : `answered ("${answer}")`} by ${who} in the thread.`);
111
115
  }
112
116
  return;
113
117
  }
@@ -135,13 +139,65 @@ export async function handleInboundMessage(message, deps) {
135
139
  await say(reply);
136
140
  });
137
141
  }
142
+ // Whole messages that approve and nothing else, in the languages people
143
+ // most often reply in. Matched exactly after the clean-up in
144
+ // isPlainApproval, so a condition or a second thought ("yes but…",
145
+ // "好的,但是…") never counts as plain; a phrase that isn't here just gets
146
+ // a Confirm click. Words that can also mean "hmm", "understood" or "no
147
+ // thanks" (嗯, 了解, いいです) are left out on purpose.
148
+ const PLAIN_APPROVAL_PHRASES = [
149
+ // English
150
+ 'yes', 'y', 'yep', 'yeah', 'yup', 'sure', 'yes go ahead', 'go ahead', 'approve', 'approved', 'i approve', 'do it', 'yes do it',
151
+ 'ok', 'okay', 'ok go ahead', 'lgtm', 'confirm', 'confirmed', 'send it', 'run it',
152
+ // Chinese (simplified and traditional)
153
+ '是', '是的', '好', '好的', '可以', '行', '同意', '批准', '确认', '確認', '没问题', '沒問題', '发吧', '發吧', '执行', '執行', '执行吧', '執行吧',
154
+ '请执行', '請執行', '好的发吧', '好的發吧', '可以执行', '可以執行', '同意执行', '同意執行',
155
+ // Japanese
156
+ 'はい', '承認', '承認します', 'お願いします', 'はいお願いします', 'okです', '進めて', '進めてください', '実行してください',
157
+ // Korean
158
+ '네', '예', '승인', '승인합니다', '좋아요', '진행해주세요', '네진행해주세요',
159
+ // Spanish, Portuguese, Italian
160
+ 'sí', 'si', 'aprobado', 'apruebo', 'adelante', 'sí adelante', 'dale', 'de acuerdo',
161
+ 'sim', 'aprovado', 'aprovo', 'pode', 'pode mandar', 'manda', 'pode seguir',
162
+ 'sì', 'approvato', 'approvo', 'vai', 'procedi', 'va bene',
163
+ // French, German
164
+ 'oui', 'approuvé', "j'approuve", 'vas-y', 'allez-y', "d'accord", 'ok vas-y',
165
+ 'ja', 'genehmigt', 'freigegeben', 'mach es', 'mach das', 'einverstanden', 'passt',
166
+ // Russian
167
+ 'да', 'одобряю', 'одобрено', 'подтверждаю', 'давай',
168
+ // Emoji, typed or as Slack shortcodes
169
+ '👍', '✅', '👌', ':+1:', ':thumbsup:', ':white_check_mark:', ':heavy_check_mark:', ':ok_hand:',
170
+ ];
171
+ // "Please" at either end of a reply doesn't change it.
172
+ const PLEASE = /^(please|pls|por favor|bitte|svp|s'il vous plaît|per favore)\s+|\s+(please|pls|por favor|bitte|svp|s'il vous plaît|per favore)$/g;
173
+ function approvalKey(text) {
174
+ return text
175
+ .toLowerCase()
176
+ .replace(/^\s*<@[a-z0-9]+>\s*/, '')
177
+ .replace(/:skin-tone-\d:/g, '')
178
+ .replace(/[\u{1F3FB}-\u{1F3FF}\u{FE0F}]/gu, '')
179
+ .replace(/[\u2018\u2019]/g, "'")
180
+ .replace(/[,,、;;]+/g, ' ')
181
+ .replace(/[.!?。!~~]+$/, (m) => (m.includes('?') ? '?' : ''))
182
+ .trim()
183
+ .replace(PLEASE, '')
184
+ .replace(/\s+/g, '');
185
+ }
186
+ const PLAIN_APPROVALS = new Set(PLAIN_APPROVAL_PHRASES.map(approvalKey));
187
+ /** Whether a reply is a plain approval: lower-cased, a leading @mention,
188
+ * emoji skin tones and "please" dropped, punctuation and spaces
189
+ * (full-width ones too) ignored, then compared with the fixed list. A
190
+ * trailing question mark keeps it from matching ("yes?"). */
191
+ export function isPlainApproval(text) {
192
+ return PLAIN_APPROVALS.has(approvalKey(text));
193
+ }
138
194
  /** Says what will run, runs it, reports the outcome and updates the
139
195
  * request message. `args` differing from the request's own means it was
140
196
  * approved as changed. */
141
197
  async function runApproved(deps, link, tool, args, who) {
142
198
  const { connector } = deps;
143
199
  const edited = Boolean(link.proposedArgs);
144
- await connector.postInThread(link.conversation, link.ts, `Approved${edited ? ' as changed' : ''} by ${who}. Running \`${tool}\` with \`${JSON.stringify(args)}\`.`);
200
+ await connector.postInThread(link.conversation, link.ts, `Approved${edited ? ' as changed' : ''} by ${who}. Running \`${tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(tool))}`);
145
201
  const outcome = await deps.decide(link.pendingId, { kind: 'approve', ...(edited ? { editedArgs: args } : {}) });
146
202
  await connector.postInThread(link.conversation, link.ts, outcomeText('approve', who, outcome));
147
203
  if (!outcome.error && !outcome.alreadyResolved)
@@ -191,7 +247,7 @@ export function outcomeText(decision, who, outcome, answer) {
191
247
  return `${what}. The agent is still waiting on another decision from the same step.`;
192
248
  if (outcome.morePending)
193
249
  return `${what}. The agent needs another decision; a new request is on its way.`;
194
- return `${what}.${outcome.text ? `\n>${outcome.text.replace(/\n/g, '\n>')}` : ''}`;
250
+ return `${what}.${outcome.text ? `\n> ${outcome.text.replace(/\n/g, '\n> ')}` : ''}`;
195
251
  }
196
252
  /** The thread as chat messages: the agent's own replies as `assistant`,
197
253
  * people's as `user` (with who said it), the request itself left out
@@ -0,0 +1,10 @@
1
+ type Escape = (text: string) => string;
2
+ /** "customer_id" / "customerId" -> "Customer id". */
3
+ export declare function labelFor(key: string, schema?: Record<string, unknown>): string;
4
+ /** All of a call's arguments, one per line (long text quoted below its
5
+ * label). */
6
+ export declare function formatArgs(args: Record<string, unknown>, escape: Escape, schema?: Record<string, unknown>): string;
7
+ /** Only the arguments that differ, old struck through and new beside it;
8
+ * changed long text is shown in full as it will run. */
9
+ export declare function formatChange(before: Record<string, unknown>, after: Record<string, unknown>, escape: Escape, schema?: Record<string, unknown>): string;
10
+ export {};
@@ -0,0 +1,107 @@
1
+ // A tool call's arguments as something a person can read in a chat app,
2
+ // instead of JSON: one labelled line per argument, long or multi-line
3
+ // text (an email body) as a quoted block with its line breaks, lists as
4
+ // bullets. Labels come from the tool's schema (a property's `title`),
5
+ // else from the argument's name ("customer_id" -> "Customer id").
6
+ //
7
+ // The result is Markdown, which each connector turns into its own format
8
+ // (Slack: ./connectors/slack/format.ts). Every value goes through the
9
+ // connector's `escape`, so text the agent wrote can't become a mention,
10
+ // a link or a ping in the app.
11
+ const INLINE_MAX = 80;
12
+ const LONG_MAX = 1500;
13
+ function propertiesOf(schema) {
14
+ const props = schema?.properties;
15
+ return props && typeof props === 'object' ? props : {};
16
+ }
17
+ /** A label comes from the tool's own schema, not from the agent, but is
18
+ * still kept plain: no markup or link characters. */
19
+ function plain(label) {
20
+ return label.replace(/[*_~`<>&|[\]]/g, '').trim() || 'Value';
21
+ }
22
+ /** "customer_id" / "customerId" -> "Customer id". */
23
+ export function labelFor(key, schema) {
24
+ const title = propertiesOf(schema)[key]?.title;
25
+ if (typeof title === 'string' && title.trim())
26
+ return title.trim();
27
+ const words = key
28
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
29
+ .replace(/[_-]+/g, ' ')
30
+ .trim()
31
+ .toLowerCase();
32
+ return words ? words[0].toUpperCase() + words.slice(1) : key;
33
+ }
34
+ /** Keys in the schema's order, then any others. */
35
+ function orderedKeys(args, schema) {
36
+ const fromSchema = Object.keys(propertiesOf(schema)).filter((k) => k in args);
37
+ return [...fromSchema, ...Object.keys(args).filter((k) => !fromSchema.includes(k))];
38
+ }
39
+ function isLongText(value) {
40
+ return typeof value === 'string' && (value.length > INLINE_MAX || value.includes('\n'));
41
+ }
42
+ function quote(text, escape) {
43
+ const cut = text.length > LONG_MAX ? text.slice(0, LONG_MAX).trimEnd() + '… (cut short here; the decision link has it all)' : text;
44
+ return cut
45
+ .split('\n')
46
+ .map((line) => `> ${escape(line)}`)
47
+ .join('\n');
48
+ }
49
+ function inline(value, escape) {
50
+ if (typeof value === 'string')
51
+ return escape(value);
52
+ if (value === null || typeof value !== 'object')
53
+ return String(value);
54
+ return '`' + escape(JSON.stringify(value)) + '`';
55
+ }
56
+ /** One argument: `**Label:** value`, or the label over a quoted block,
57
+ * bullets or nested fields. */
58
+ function field(label, value, escape, indent = '') {
59
+ const head = `${indent}**${plain(label)}:**`;
60
+ if (isLongText(value))
61
+ return `${head}\n${quote(value, escape)}`;
62
+ if (Array.isArray(value)) {
63
+ if (!value.length)
64
+ return `${head} (none)`;
65
+ const simple = value.every((v) => v === null || typeof v !== 'object');
66
+ const joined = value.map((v) => inline(v, escape)).join(', ');
67
+ if (simple && joined.length <= INLINE_MAX)
68
+ return `${head} ${joined}`;
69
+ return `${head}\n${value.map((v) => `${indent}- ${isLongText(v) ? escape(v.slice(0, 300)) : inline(v, escape)}`).join('\n')}`;
70
+ }
71
+ if (value && typeof value === 'object') {
72
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined);
73
+ if (!entries.length)
74
+ return `${head} (empty)`;
75
+ if (indent)
76
+ return `${head} ${inline(value, escape)}`;
77
+ return `${head}\n${entries.map(([k, v]) => field(labelFor(k), v, escape, ' ')).join('\n')}`;
78
+ }
79
+ return `${head} ${inline(value, escape)}`;
80
+ }
81
+ /** All of a call's arguments, one per line (long text quoted below its
82
+ * label). */
83
+ export function formatArgs(args, escape, schema) {
84
+ const keys = orderedKeys(args, schema).filter((k) => args[k] !== undefined && args[k] !== null);
85
+ if (!keys.length)
86
+ return '(no arguments)';
87
+ return keys.map((k) => field(labelFor(k, schema), args[k], escape)).join('\n');
88
+ }
89
+ /** Only the arguments that differ, old struck through and new beside it;
90
+ * changed long text is shown in full as it will run. */
91
+ export function formatChange(before, after, escape, schema) {
92
+ const keys = [...new Set([...orderedKeys(after, schema), ...Object.keys(before)])].filter((k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
93
+ return keys
94
+ .map((k) => {
95
+ const label = `**${plain(labelFor(k, schema))}:**`;
96
+ const was = before[k];
97
+ const now = after[k];
98
+ if (now === undefined || now === null)
99
+ return `${label} ~~${inline(was, escape)}~~ (removed)`;
100
+ if (isLongText(now) || isLongText(was))
101
+ return `${label} (changed, now:)\n${quote(typeof now === 'string' ? now : JSON.stringify(now), escape)}`;
102
+ if (was === undefined || was === null)
103
+ return `${label} ${inline(now, escape)} (added)`;
104
+ return `${label} ~~${inline(was, escape)}~~ → ${inline(now, escape)}`;
105
+ })
106
+ .join('\n');
107
+ }
@@ -20,8 +20,6 @@ export interface NotifierFile {
20
20
  /** slack: the people who can decide a request by replying in its
21
21
  * thread, and whose button clicks count once any are listed. */
22
22
  approvers?: MessagingApprover[];
23
- /** slack: tools whose approval by reply needs a Confirm click. */
24
- confirmTools?: string[];
25
23
  }
26
24
  export interface MessagingApprover {
27
25
  /** The app's id for the person (Slack: U0123ABC). */
@@ -81,13 +81,6 @@ export function validateNotifierFile(input) {
81
81
  if (approvers.length)
82
82
  file.approvers = approvers;
83
83
  }
84
- if (raw.confirmTools !== undefined && channel === 'slack') {
85
- if (!Array.isArray(raw.confirmTools) || raw.confirmTools.some((t) => typeof t !== 'string' || !t.trim()))
86
- throw new NotifierFileError('confirmTools must be a list of tool names');
87
- const tools = [...new Set(raw.confirmTools.map((t) => t.trim()))];
88
- if (tools.length)
89
- file.confirmTools = tools;
90
- }
91
84
  const publicUrl = str('publicUrl');
92
85
  if (publicUrl) {
93
86
  if (!/^https?:\/\/[^/]+/.test(publicUrl))
package/dist/index.d.ts CHANGED
@@ -12,15 +12,16 @@ export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/not
12
12
  export { describeNotifier } from '#core/http-notifier.js';
13
13
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
14
14
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
- export { firstSighting, handleInboundMessage, handleMessagingAction, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
15
+ export { firstSighting, handleInboundMessage, handleMessagingAction, isPlainApproval, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
16
+ export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
16
17
  export { decideReply, proposeEdit, describeChange, type ReplyDecision, type DecisionRequest } from '#core/messaging/decide.js';
17
18
  export { recordThread, findThread, findThreadByPending, type ThreadLink } from '#core/messaging/threads.js';
18
19
  export type { InboundMessage, MessagingConnector, ThreadMessage, MessagingChoice, MessagingAction } from '#core/messaging/connector.js';
19
- export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn, type SlackEvent, type SlackInteraction, type SlackChannel } from '#core/messaging/connectors/slack/index.js';
20
+ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn, slackArgs, slackEscape, type SlackEvent, type SlackInteraction, type SlackChannel } from '#core/messaging/connectors/slack/index.js';
20
21
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
21
22
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
22
23
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
23
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, agentConfirmTools, setAgentConfirmTools, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
24
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
24
25
  export type * from './core/loop-events.js';
25
26
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
26
27
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -14,14 +14,15 @@ export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
14
  export { describeNotifier } from '#core/http-notifier.js';
15
15
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
16
16
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
- export { firstSighting, handleInboundMessage, handleMessagingAction } from '#core/messaging/engine.js';
17
+ export { firstSighting, handleInboundMessage, handleMessagingAction, isPlainApproval } from '#core/messaging/engine.js';
18
+ export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
18
19
  export { decideReply, proposeEdit, describeChange } from '#core/messaging/decide.js';
19
20
  export { recordThread, findThread, findThreadByPending } from '#core/messaging/threads.js';
20
- export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn } from '#core/messaging/connectors/slack/index.js';
21
+ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn, slackArgs, slackEscape } from '#core/messaging/connectors/slack/index.js';
21
22
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
22
23
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
23
24
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
24
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, agentConfirmTools, setAgentConfirmTools, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
25
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
25
26
  // The framework-agnostic browser client for adapters/http.ts's own wire
26
27
  // protocol (see client.ts's own header comment) — this, not a deep
27
28
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -1194,7 +1194,7 @@ ${consoleShellScript}
1194
1194
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
1195
1195
  }).join('');
1196
1196
  return '<section id="approversSection"><h3>Approvers</h3>' +
1197
- '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A change is shown to approve before anything runs. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1197
+ '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1198
1198
  (approvers.length
1199
1199
  ? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>Slack ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
1200
1200
  : '<p class="hint">No approvers yet. Until you add someone, anyone in the channel can use the buttons, and deciding by reply is off.</p>') +
@@ -1205,39 +1205,6 @@ ${consoleShellScript}
1205
1205
  '<div id="approversResult" class="hint"></div></section>';
1206
1206
  }
1207
1207
 
1208
- // Slack only: tools an approver can't approve with a typed yes; the
1209
- // agent asks for a Confirm click showing exactly what will run.
1210
- function renderConfirmToolsHtml(n) {
1211
- if (n.source === 'code' || !n.file || n.file.channel !== 'slack') return '';
1212
- var chosen = n.file.confirmTools || [];
1213
- var tools = ((currentCfg && currentCfg.tools) || []).map(function (t) { return t.name; });
1214
- chosen.forEach(function (t) { if (tools.indexOf(t) === -1) tools.push(t); });
1215
- var boxes = tools.map(function (t) {
1216
- return '<label class="tool-picker-item" style="justify-content:flex-start"><input type="checkbox" value="' + escapeHtml(t) + '"' + (chosen.indexOf(t) !== -1 ? ' checked' : '') + (n.canSave === false ? ' disabled' : '') + '> <code>' + escapeHtml(t) + '</code></label>';
1217
- }).join('');
1218
- return '<section id="confirmToolsSection"><h3>Confirm-required tools</h3>' +
1219
- '<p class="muted" style="margin:0 0 10px">For tools that move money or delete data: when an approver replies &ldquo;yes&rdquo;, the agent shows exactly what will run with a Confirm button, and only that click runs it. A click on the request&#39;s own Approve button still runs it straight away.</p>' +
1220
- (tools.length ? '<div class="tool-picker" style="max-width:560px">' + boxes + '</div>' : '<p class="hint">This agent has no tools.</p>') +
1221
- (n.canSave === false || !tools.length ? '' : '<p style="margin:8px 0 0"><button type="button" id="confirmToolsSave">Save</button></p>') +
1222
- '<div id="confirmToolsResult" class="hint"></div></section>';
1223
- }
1224
-
1225
- function wireConfirmToolsHandlers(name) {
1226
- var section = detail.querySelector('#confirmToolsSection');
1227
- var btn = section && section.querySelector('#confirmToolsSave');
1228
- if (!btn) return;
1229
- var result = section.querySelector('#confirmToolsResult');
1230
- btn.addEventListener('click', function () {
1231
- var tools = Array.prototype.map.call(section.querySelectorAll('input:checked'), function (el) { return el.value; });
1232
- result.textContent = 'Saving' + String.fromCharCode(8230);
1233
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/confirm-tools', {
1234
- method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tools: tools }),
1235
- }).then(function (r) {
1236
- result.textContent = r.ok ? 'Saved.' : (r.body.error || 'Could not save');
1237
- });
1238
- });
1239
- }
1240
-
1241
1208
  function wireApproversHandlers(name, n) {
1242
1209
  var section = detail.querySelector('#approversSection');
1243
1210
  if (!section) return;
@@ -1294,7 +1261,6 @@ ${consoleShellScript}
1294
1261
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1295
1262
  (n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
1296
1263
  renderApproversHtml(n) +
1297
- renderConfirmToolsHtml(n) +
1298
1264
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
1299
1265
  }
1300
1266
 
@@ -1456,7 +1422,6 @@ ${consoleShellScript}
1456
1422
  content.innerHTML = renderApprovalsHtml(results[0].body);
1457
1423
  wireApprovalsHandlers(name);
1458
1424
  wireApproversHandlers(name, results[0].body);
1459
- wireConfirmToolsHandlers(name);
1460
1425
  var pendingEl = detail.querySelector('#pendingContent');
1461
1426
  var pending = (results[1].ok && results[1].body.pending) || [];
1462
1427
  if (pendingEl) {
@@ -63,11 +63,6 @@ export declare function slackClickAllowed(config: AgentConfig, user: string | un
63
63
  /** Replaces the approver list on the agent's saved Slack connection.
64
64
  * Throws NotifierFileError when there's no Slack connection to add them to. */
65
65
  export declare function setAgentApprovers(config: AgentConfig, approvers: unknown): MessagingApprover[];
66
- /** Tools whose approval by reply needs a Confirm click. */
67
- export declare function agentConfirmTools(config: AgentConfig): string[];
68
- /** Replaces the Confirm-required tools on the agent's saved Slack
69
- * connection. Throws NotifierFileError without one. */
70
- export declare function setAgentConfirmTools(config: AgentConfig, tools: unknown): string[];
71
66
  /** The people in the agent's Slack channel, for picking approvers. */
72
67
  export declare function listAgentSlackMembers(config: AgentConfig): Promise<SlackMember[]>;
73
68
  /** Channels for the console's picker, with the token being typed in or,
@@ -42,8 +42,8 @@ export function describeAgentNotifier(config, defaultWebhook) {
42
42
  * form includes one, the channel's secret into the agent's own env.
43
43
  * Throws NotifierFileError for an invalid form. */
44
44
  export async function saveAgentNotifier(config, body) {
45
- // The form doesn't carry the approvers or the Confirm-required tools
46
- // (they have their own sections), so saving it keeps them.
45
+ // The form doesn't carry the approvers (they have their own section),
46
+ // so saving it keeps them.
47
47
  let existing;
48
48
  try {
49
49
  existing = readNotifierFile(agentDirFor(config));
@@ -52,8 +52,6 @@ export async function saveAgentNotifier(config, body) {
52
52
  if (existing && body.channel === existing.channel) {
53
53
  if (body.approvers === undefined && existing.approvers)
54
54
  body = { ...body, approvers: existing.approvers };
55
- if (body.confirmTools === undefined && existing.confirmTools)
56
- body = { ...body, confirmTools: existing.confirmTools };
57
55
  }
58
56
  const file = writeNotifierFile(agentDirFor(config), body);
59
57
  if (typeof body.secret === 'string' && body.secret.trim()) {
@@ -115,26 +113,6 @@ export function setAgentApprovers(config, approvers) {
115
113
  throw new NotifierFileError('Save a Slack connection first');
116
114
  return writeNotifierFile(dir, { ...file, approvers: Array.isArray(approvers) ? approvers : [] }).approvers ?? [];
117
115
  }
118
- /** Tools whose approval by reply needs a Confirm click. */
119
- export function agentConfirmTools(config) {
120
- if (config.httpNotifier)
121
- return [];
122
- try {
123
- return readNotifierFile(agentDirFor(config))?.confirmTools ?? [];
124
- }
125
- catch {
126
- return [];
127
- }
128
- }
129
- /** Replaces the Confirm-required tools on the agent's saved Slack
130
- * connection. Throws NotifierFileError without one. */
131
- export function setAgentConfirmTools(config, tools) {
132
- const dir = agentDirFor(config);
133
- const file = readNotifierFile(dir);
134
- if (!file || file.channel !== 'slack')
135
- throw new NotifierFileError('Save a Slack connection first');
136
- return writeNotifierFile(dir, { ...file, confirmTools: Array.isArray(tools) ? tools : [] }).confirmTools ?? [];
137
- }
138
116
  /** The people in the agent's Slack channel, for picking approvers. */
139
117
  export async function listAgentSlackMembers(config) {
140
118
  const { botToken } = slackCredentialsFor(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",