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 +8 -4
- package/dist/core/http-notify-triggers/slack.js +13 -2
- package/dist/core/messaging/connector.d.ts +3 -0
- package/dist/core/messaging/connectors/slack/events.js +2 -1
- package/dist/core/messaging/connectors/slack/format.d.ts +6 -0
- package/dist/core/messaging/connectors/slack/format.js +23 -4
- package/dist/core/messaging/connectors/slack/index.d.ts +1 -1
- package/dist/core/messaging/connectors/slack/index.js +1 -1
- package/dist/core/messaging/engine.d.ts +5 -2
- package/dist/core/messaging/engine.js +68 -12
- package/dist/core/messaging/format-args.d.ts +10 -0
- package/dist/core/messaging/format-args.js +107 -0
- package/dist/core/notifier-file.d.ts +0 -2
- package/dist/core/notifier-file.js +0 -7
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/web/agents-config-page.js +1 -36
- package/dist/web/notifier-admin.d.ts +0 -5
- package/dist/web/notifier-admin.js +2 -24
- package/package.json +1 -1
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.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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})` : ''}
|
|
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
|
-
|
|
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
|
-
.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>') + 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,
|
|
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${
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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 ("${
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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 “yes, go ahead” or “make it $40”. A change is shown to approve
|
|
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 “yes, go ahead” or “make it $40”. A plain “yes” runs it; a reply that's less clear (“sure, send it”, “yes but check the amount”) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers' 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 “yes”, the agent shows exactly what will run with a Confirm button, and only that click runs it. A click on the request'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
|
|
46
|
-
//
|
|
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);
|