loopengine 0.1.66 → 0.1.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/core/agent-config.d.ts +3 -0
- package/dist/core/approval-links.d.ts +16 -0
- package/dist/core/approval-links.js +76 -0
- package/dist/core/durable-approvals.d.ts +15 -0
- package/dist/core/durable-approvals.js +61 -3
- package/dist/core/http-notifier.d.ts +16 -1
- package/dist/core/http-notifier.js +49 -6
- package/dist/core/http-notify-triggers/slack.d.ts +21 -0
- package/dist/core/http-notify-triggers/slack.js +49 -10
- package/dist/core/http-notify-triggers/webhook.d.ts +19 -10
- package/dist/core/http-notify-triggers/webhook.js +84 -31
- package/dist/core/notifier-deliveries.d.ts +18 -0
- package/dist/core/notifier-deliveries.js +22 -0
- package/dist/core/notifier-file.d.ts +35 -0
- package/dist/core/notifier-file.js +104 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/web/agents-config-page.js +248 -2
- package/dist/web/console-shell.js +12 -4
- package/dist/web/decide-page.d.ts +16 -0
- package/dist/web/decide-page.js +138 -0
- package/dist/web/notifier-admin.d.ts +63 -0
- package/dist/web/notifier-admin.js +91 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -171,6 +171,34 @@ export const config: AgentConfig = {
|
|
|
171
171
|
}
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
Or skip the code: in the console, the Permissions tab's **Where approval
|
|
175
|
+
requests and questions are sent** section sets a
|
|
176
|
+
webhook, Slack or Lark target for one agent (saved to
|
|
177
|
+
`agents/<name>/notifier.yml`, with its secret in the agent's own `.env`),
|
|
178
|
+
sends a test, shows recent deliveries, and lists every decision still
|
|
179
|
+
waiting with Approve/Deny buttons. The tab warns you when an agent has
|
|
180
|
+
`ask` rules but nobody would be told.
|
|
181
|
+
|
|
182
|
+
Every notification links to a **decision page**: a signed, single-use
|
|
183
|
+
link (`/decide/<token>`, valid 7 days) where whoever got the message can
|
|
184
|
+
approve, deny, edit the arguments or answer a question, with no login and
|
|
185
|
+
no app of your own. So the quickest setup is Slack: paste a bot token,
|
|
186
|
+
pick a channel, save; each approval arrives with an "Open to approve or
|
|
187
|
+
deny" link.
|
|
188
|
+
|
|
189
|
+
For a third-party app calling an agent over plain HTTP:
|
|
190
|
+
|
|
191
|
+
1. `POST /agents/<name>/messages` → when a call needs approval, the reply
|
|
192
|
+
has `stopReason: "pending_approval"` and a `pending` list, each item
|
|
193
|
+
with its `pendingId` and `resolvePath`.
|
|
194
|
+
2. The webhook carries the same `pendingId`, `resolvePath` (and a full
|
|
195
|
+
`resolveUrl` when `LOOPENGINE_PUBLIC_URL` is set), signed with
|
|
196
|
+
`X-Actauth-Signature`. Failed deliveries are retried.
|
|
197
|
+
3. `POST <resolvePath>` with `{"decision":"approve"}` (or `"deny"`) runs
|
|
198
|
+
the tool, resumes the turn, and returns the agent's reply.
|
|
199
|
+
`GET /agents/<name>/pending` lists what's waiting, for apps that poll
|
|
200
|
+
instead of receiving webhooks.
|
|
201
|
+
|
|
174
202
|
Reference implementations for the receiving side (verifying a Slack
|
|
175
203
|
click, a webhook HMAC signature, a magic-link token) ship under
|
|
176
204
|
`examples/notifier-handler/` for the four channels that need one. See
|
|
@@ -185,6 +185,7 @@ export type HttpNotifierConfig = {
|
|
|
185
185
|
config: {
|
|
186
186
|
webhookUrl: string;
|
|
187
187
|
webhookSecret: string;
|
|
188
|
+
publicUrl?: string;
|
|
188
189
|
};
|
|
189
190
|
events: HttpNotifierEvent[];
|
|
190
191
|
} | {
|
|
@@ -192,6 +193,8 @@ export type HttpNotifierConfig = {
|
|
|
192
193
|
config: {
|
|
193
194
|
botToken: string;
|
|
194
195
|
channelId: string;
|
|
196
|
+
publicUrl?: string;
|
|
197
|
+
buttons?: boolean;
|
|
195
198
|
};
|
|
196
199
|
events: HttpNotifierEvent[];
|
|
197
200
|
} | {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type DecisionKind = 'approval' | 'question';
|
|
2
|
+
/** How long a link works: 7 days, or LOOPENGINE_LINK_TTL_HOURS. */
|
|
3
|
+
export declare function decisionLinkTtlMs(): number;
|
|
4
|
+
export declare function createDecisionToken(pendingId: string, kind: DecisionKind, nowMs?: number): string;
|
|
5
|
+
/** The pendingId and kind a token names, or null for a malformed,
|
|
6
|
+
* tampered or expired one. */
|
|
7
|
+
export declare function verifyDecisionToken(token: string, nowMs?: number): {
|
|
8
|
+
pendingId: string;
|
|
9
|
+
kind: DecisionKind;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
} | null;
|
|
12
|
+
export declare function decidePath(pendingId: string, kind: DecisionKind): string;
|
|
13
|
+
/** Builds full decision links against `baseUrl` — undefined when there's
|
|
14
|
+
* no base URL to build them against. */
|
|
15
|
+
export type DecideLinkMaker = (pendingId: string, kind: DecisionKind) => string | undefined;
|
|
16
|
+
export declare function decideLinkMaker(baseUrl: string | undefined): DecideLinkMaker;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Decision links: a signed, expiring URL for one waiting approval or
|
|
2
|
+
// question (/decide/<token>) that opens a small page where whoever has
|
|
3
|
+
// the link can approve, deny or answer — no admin login, no app of your
|
|
4
|
+
// own, no Slack interactivity setup. Every notifier that knows where this
|
|
5
|
+
// server is reachable puts one in its message.
|
|
6
|
+
//
|
|
7
|
+
// The token only names the pendingId, its kind and an expiry, signed
|
|
8
|
+
// with HMAC-SHA256. It's single-use by construction: once the decision
|
|
9
|
+
// is made the pending item no longer exists, so the same link just shows
|
|
10
|
+
// "already decided."
|
|
11
|
+
//
|
|
12
|
+
// The signing secret is LOOPENGINE_LINK_SECRET when set (required for a
|
|
13
|
+
// deployment with more than one server, which must share it), otherwise
|
|
14
|
+
// one generated on first use and kept in .checkpoints/link-secret next to
|
|
15
|
+
// the paused turns it unlocks, so links keep working across restarts.
|
|
16
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
/** How long a link works: 7 days, or LOOPENGINE_LINK_TTL_HOURS. */
|
|
20
|
+
export function decisionLinkTtlMs() {
|
|
21
|
+
const hours = Number(process.env.LOOPENGINE_LINK_TTL_HOURS);
|
|
22
|
+
return (Number.isFinite(hours) && hours > 0 ? hours : 24 * 7) * 3_600_000;
|
|
23
|
+
}
|
|
24
|
+
let generatedSecret;
|
|
25
|
+
function linkSecret() {
|
|
26
|
+
if (process.env.LOOPENGINE_LINK_SECRET)
|
|
27
|
+
return process.env.LOOPENGINE_LINK_SECRET;
|
|
28
|
+
if (generatedSecret)
|
|
29
|
+
return generatedSecret;
|
|
30
|
+
const dir = join(process.cwd(), '.checkpoints');
|
|
31
|
+
const path = join(dir, 'link-secret');
|
|
32
|
+
if (existsSync(path)) {
|
|
33
|
+
generatedSecret = readFileSync(path, 'utf8').trim();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
generatedSecret = randomBytes(32).toString('hex');
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
writeFileSync(path, generatedSecret + '\n', { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
return generatedSecret;
|
|
41
|
+
}
|
|
42
|
+
function sign(body) {
|
|
43
|
+
return createHmac('sha256', linkSecret()).update(body).digest('base64url');
|
|
44
|
+
}
|
|
45
|
+
export function createDecisionToken(pendingId, kind, nowMs = Date.now()) {
|
|
46
|
+
const body = Buffer.from(JSON.stringify({ p: pendingId, k: kind, exp: nowMs + decisionLinkTtlMs() })).toString('base64url');
|
|
47
|
+
return `${body}.${sign(body)}`;
|
|
48
|
+
}
|
|
49
|
+
/** The pendingId and kind a token names, or null for a malformed,
|
|
50
|
+
* tampered or expired one. */
|
|
51
|
+
export function verifyDecisionToken(token, nowMs = Date.now()) {
|
|
52
|
+
const [body, signature] = token.split('.');
|
|
53
|
+
if (!body || !signature)
|
|
54
|
+
return null;
|
|
55
|
+
const expected = Buffer.from(sign(body));
|
|
56
|
+
const actual = Buffer.from(signature);
|
|
57
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
61
|
+
if (typeof payload.p !== 'string' || (payload.k !== 'approval' && payload.k !== 'question') || typeof payload.exp !== 'number')
|
|
62
|
+
return null;
|
|
63
|
+
if (nowMs > payload.exp)
|
|
64
|
+
return null;
|
|
65
|
+
return { pendingId: payload.p, kind: payload.k, expiresAt: payload.exp };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function decidePath(pendingId, kind) {
|
|
72
|
+
return `/decide/${createDecisionToken(pendingId, kind)}`;
|
|
73
|
+
}
|
|
74
|
+
export function decideLinkMaker(baseUrl) {
|
|
75
|
+
return (pendingId, kind) => (baseUrl ? baseUrl.replace(/\/+$/, '') + decidePath(pendingId, kind) : undefined);
|
|
76
|
+
}
|
|
@@ -31,6 +31,9 @@ export interface TurnCheckpoint {
|
|
|
31
31
|
* and is what DurableApprover.requestDurableApproval handed out. */
|
|
32
32
|
outstanding: Record<string, OutstandingItem>;
|
|
33
33
|
closed: boolean;
|
|
34
|
+
/** When the turn paused. Optional only because checkpoints written
|
|
35
|
+
* before this field existed don't have it. */
|
|
36
|
+
createdAt?: string;
|
|
34
37
|
}
|
|
35
38
|
export interface CreateCheckpointInput {
|
|
36
39
|
sessionId: string;
|
|
@@ -58,6 +61,14 @@ export interface CheckpointStore {
|
|
|
58
61
|
checkpoint: TurnCheckpoint | undefined;
|
|
59
62
|
result: T;
|
|
60
63
|
}>): Promise<T>;
|
|
64
|
+
/** Every checkpoint still waiting on at least one decision, oldest
|
|
65
|
+
* first — backs GET /agents/:name/pending. Reads without locking: a
|
|
66
|
+
* snapshot for display, not something to act on without withCheckpoint. */
|
|
67
|
+
listOpen(): Promise<TurnCheckpoint[]>;
|
|
68
|
+
/** The open checkpoint `pendingId` belongs to, read without locking —
|
|
69
|
+
* for showing a decision page, not for acting on it (use
|
|
70
|
+
* withCheckpoint for that). undefined once it's decided or unknown. */
|
|
71
|
+
get(pendingId: string): Promise<TurnCheckpoint | undefined>;
|
|
61
72
|
close(): Promise<void>;
|
|
62
73
|
}
|
|
63
74
|
/** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
|
|
@@ -75,6 +86,8 @@ export declare class FileCheckpointStore implements CheckpointStore {
|
|
|
75
86
|
checkpoint: TurnCheckpoint | undefined;
|
|
76
87
|
result: T;
|
|
77
88
|
}>): Promise<T>;
|
|
89
|
+
get(pendingId: string): Promise<TurnCheckpoint | undefined>;
|
|
90
|
+
listOpen(): Promise<TurnCheckpoint[]>;
|
|
78
91
|
close(): Promise<void>;
|
|
79
92
|
}
|
|
80
93
|
/** Redis-backed store for multi-instance deployments. Unlike
|
|
@@ -98,6 +111,8 @@ export declare class RedisCheckpointStore implements CheckpointStore {
|
|
|
98
111
|
checkpoint: TurnCheckpoint | undefined;
|
|
99
112
|
result: T;
|
|
100
113
|
}>): Promise<T>;
|
|
114
|
+
get(pendingId: string): Promise<TurnCheckpoint | undefined>;
|
|
115
|
+
listOpen(): Promise<TurnCheckpoint[]>;
|
|
101
116
|
close(): Promise<void>;
|
|
102
117
|
}
|
|
103
118
|
/** Same REDIS_URL-gated pattern as session-store.ts's own
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
// does (see HUMAN_IN_THE_LOOP.md's own "request + raw resolve only"
|
|
11
11
|
// division of labor).
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { Redis } from 'ioredis';
|
|
16
16
|
import { KeyedMutex } from './session-store.js';
|
|
17
|
+
function sortOldestFirst(list) {
|
|
18
|
+
return list.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
19
|
+
}
|
|
17
20
|
const fileMutex = new KeyedMutex();
|
|
18
21
|
/** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
|
|
19
22
|
* small pendingId -> checkpointId pointer file per outstanding item under
|
|
@@ -35,7 +38,7 @@ export class FileCheckpointStore {
|
|
|
35
38
|
return path.join(this.indexDir, pendingId);
|
|
36
39
|
}
|
|
37
40
|
async create(input) {
|
|
38
|
-
const checkpoint = { id: randomUUID(), closed: false, ...input };
|
|
41
|
+
const checkpoint = { id: randomUUID(), closed: false, createdAt: new Date().toISOString(), ...input };
|
|
39
42
|
writeFileSync(this.checkpointPath(checkpoint.id), JSON.stringify(checkpoint));
|
|
40
43
|
for (const pendingId of Object.keys(checkpoint.outstanding)) {
|
|
41
44
|
writeFileSync(this.indexPath(pendingId), checkpoint.id);
|
|
@@ -69,6 +72,32 @@ export class FileCheckpointStore {
|
|
|
69
72
|
return result;
|
|
70
73
|
});
|
|
71
74
|
}
|
|
75
|
+
async get(pendingId) {
|
|
76
|
+
const indexPath = this.indexPath(pendingId);
|
|
77
|
+
if (!existsSync(indexPath))
|
|
78
|
+
return undefined;
|
|
79
|
+
const checkpointPath = this.checkpointPath(readFileSync(indexPath, 'utf8'));
|
|
80
|
+
if (!existsSync(checkpointPath))
|
|
81
|
+
return undefined;
|
|
82
|
+
const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8'));
|
|
83
|
+
return !checkpoint.closed && checkpoint.outstanding[pendingId] ? checkpoint : undefined;
|
|
84
|
+
}
|
|
85
|
+
async listOpen() {
|
|
86
|
+
const open = [];
|
|
87
|
+
for (const name of readdirSync(this.checkpointsDir)) {
|
|
88
|
+
if (!name.endsWith('.json'))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const checkpoint = JSON.parse(readFileSync(path.join(this.checkpointsDir, name), 'utf8'));
|
|
92
|
+
if (!checkpoint.closed && Object.keys(checkpoint.outstanding).length)
|
|
93
|
+
open.push(checkpoint);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// A checkpoint mid-write or removed between readdir and read.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return sortOldestFirst(open);
|
|
100
|
+
}
|
|
72
101
|
async close() { }
|
|
73
102
|
}
|
|
74
103
|
/** Redis-backed store for multi-instance deployments. Unlike
|
|
@@ -117,7 +146,7 @@ export class RedisCheckpointStore {
|
|
|
117
146
|
}
|
|
118
147
|
}
|
|
119
148
|
async create(input) {
|
|
120
|
-
const checkpoint = { id: randomUUID(), closed: false, ...input };
|
|
149
|
+
const checkpoint = { id: randomUUID(), closed: false, createdAt: new Date().toISOString(), ...input };
|
|
121
150
|
await this.redis.set(this.checkpointKey(checkpoint.id), JSON.stringify(checkpoint));
|
|
122
151
|
for (const pendingId of Object.keys(checkpoint.outstanding)) {
|
|
123
152
|
await this.redis.set(this.indexKey(pendingId), checkpoint.id);
|
|
@@ -145,6 +174,35 @@ export class RedisCheckpointStore {
|
|
|
145
174
|
return result;
|
|
146
175
|
});
|
|
147
176
|
}
|
|
177
|
+
async get(pendingId) {
|
|
178
|
+
const checkpointId = await this.redis.get(this.indexKey(pendingId));
|
|
179
|
+
if (!checkpointId)
|
|
180
|
+
return undefined;
|
|
181
|
+
const raw = await this.redis.get(this.checkpointKey(checkpointId));
|
|
182
|
+
if (!raw)
|
|
183
|
+
return undefined;
|
|
184
|
+
const checkpoint = JSON.parse(raw);
|
|
185
|
+
return !checkpoint.closed && checkpoint.outstanding[pendingId] ? checkpoint : undefined;
|
|
186
|
+
}
|
|
187
|
+
async listOpen() {
|
|
188
|
+
const open = [];
|
|
189
|
+
let cursor = '0';
|
|
190
|
+
do {
|
|
191
|
+
// checkpoint:<id> only — checkpoint-lock:/checkpoint-pending-index:
|
|
192
|
+
// keys use a hyphen, so this pattern never matches them.
|
|
193
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', 'checkpoint:*', 'COUNT', 200);
|
|
194
|
+
cursor = next;
|
|
195
|
+
for (const key of keys) {
|
|
196
|
+
const raw = await this.redis.get(key);
|
|
197
|
+
if (!raw)
|
|
198
|
+
continue;
|
|
199
|
+
const checkpoint = JSON.parse(raw);
|
|
200
|
+
if (!checkpoint.closed && Object.keys(checkpoint.outstanding).length)
|
|
201
|
+
open.push(checkpoint);
|
|
202
|
+
}
|
|
203
|
+
} while (cursor !== '0');
|
|
204
|
+
return sortOldestFirst(open);
|
|
205
|
+
}
|
|
148
206
|
async close() {
|
|
149
207
|
await this.redis.quit();
|
|
150
208
|
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { type Approver } from 'actauth';
|
|
2
|
-
import type { AgentConfig, QuestionHandler } from './agent-config.js';
|
|
2
|
+
import type { AgentConfig, HttpNotifierConfig, QuestionHandler } from './agent-config.js';
|
|
3
|
+
import { type NotifierFile } from './notifier-file.js';
|
|
3
4
|
interface ResolvedHttpNotifier {
|
|
4
5
|
approver?: Approver;
|
|
5
6
|
questionHandler?: QuestionHandler;
|
|
6
7
|
onRunStart?: NonNullable<AgentConfig['onRunStart']>;
|
|
7
8
|
onRunFinish?: NonNullable<AgentConfig['onRunFinish']>;
|
|
8
9
|
}
|
|
10
|
+
/** Where an agent's durable approvals/questions go, for the console:
|
|
11
|
+
* `code` (AgentConfig.httpNotifier), `file` (agents/<name>/notifier.yml),
|
|
12
|
+
* or `none`. A file whose secret isn't set yet is reported, not used. */
|
|
13
|
+
export declare function describeNotifier(config: AgentConfig): {
|
|
14
|
+
source: 'code' | 'file' | 'none';
|
|
15
|
+
channel?: HttpNotifierConfig['channel'];
|
|
16
|
+
events?: string[];
|
|
17
|
+
file?: NotifierFile;
|
|
18
|
+
missingSecret?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
/** AgentConfig.httpNotifier when the agent sets one in code; otherwise
|
|
22
|
+
* its agents/<name>/notifier.yml (see core/notifier-file.ts), re-read on
|
|
23
|
+
* every call so a console edit applies to the very next turn. */
|
|
9
24
|
export declare function resolveHttpNotifier(config: AgentConfig): ResolvedHttpNotifier;
|
|
10
25
|
export {};
|
|
@@ -4,6 +4,8 @@ import { LarkNotifier } from './http-notify-triggers/lark.js';
|
|
|
4
4
|
import { EmailNotifier } from './http-notify-triggers/email.js';
|
|
5
5
|
import { DatabaseApprover } from './http-notify-triggers/database.js';
|
|
6
6
|
import { RedisQueueApprover } from './http-notify-triggers/redis.js';
|
|
7
|
+
import { agentDirFor, createAgentEnv } from './agent-env.js';
|
|
8
|
+
import { notifierConfigFromFile, readNotifierFile } from './notifier-file.js';
|
|
7
9
|
const EMPTY = {};
|
|
8
10
|
// Keyed by the AgentConfig object itself, not its httpNotifier field
|
|
9
11
|
// alone — resolveHttpNotifier is called on every runAgent()/resumeAgent()
|
|
@@ -29,22 +31,64 @@ function formatChatLifecycleMessage(event, context) {
|
|
|
29
31
|
function formatEmailLifecycleSubject(event, context) {
|
|
30
32
|
return event === 'run_start' ? `${context.agent} started` : `${context.agent} finished`;
|
|
31
33
|
}
|
|
34
|
+
/** Where an agent's durable approvals/questions go, for the console:
|
|
35
|
+
* `code` (AgentConfig.httpNotifier), `file` (agents/<name>/notifier.yml),
|
|
36
|
+
* or `none`. A file whose secret isn't set yet is reported, not used. */
|
|
37
|
+
export function describeNotifier(config) {
|
|
38
|
+
if (config.httpNotifier)
|
|
39
|
+
return { source: 'code', channel: config.httpNotifier.channel, events: [...config.httpNotifier.events] };
|
|
40
|
+
const dir = agentDirFor(config);
|
|
41
|
+
let file;
|
|
42
|
+
try {
|
|
43
|
+
file = readNotifierFile(dir);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return { source: 'none', error: `notifier.yml is invalid: ${err instanceof Error ? err.message : String(err)}` };
|
|
47
|
+
}
|
|
48
|
+
if (!file)
|
|
49
|
+
return { source: 'none' };
|
|
50
|
+
const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
51
|
+
return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
|
|
52
|
+
}
|
|
53
|
+
/** AgentConfig.httpNotifier when the agent sets one in code; otherwise
|
|
54
|
+
* its agents/<name>/notifier.yml (see core/notifier-file.ts), re-read on
|
|
55
|
+
* every call so a console edit applies to the very next turn. */
|
|
32
56
|
export function resolveHttpNotifier(config) {
|
|
33
57
|
const httpNotifier = config.httpNotifier;
|
|
34
58
|
if (!httpNotifier)
|
|
35
|
-
return
|
|
59
|
+
return resolveFromFile(config);
|
|
36
60
|
const cached = cache.get(config);
|
|
37
61
|
if (cached)
|
|
38
62
|
return cached;
|
|
63
|
+
const resolved = buildResolved(httpNotifier);
|
|
64
|
+
cache.set(config, resolved);
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
function resolveFromFile(config) {
|
|
68
|
+
const dir = agentDirFor(config);
|
|
69
|
+
let file;
|
|
70
|
+
try {
|
|
71
|
+
file = readNotifierFile(dir);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.error(`[loopengine] ignoring agents/${config.name}/notifier.yml:`, err instanceof Error ? err.message : err);
|
|
75
|
+
return EMPTY;
|
|
76
|
+
}
|
|
77
|
+
if (!file)
|
|
78
|
+
return EMPTY;
|
|
79
|
+
const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
80
|
+
return httpNotifier ? buildResolved(httpNotifier) : EMPTY;
|
|
81
|
+
}
|
|
82
|
+
function buildResolved(httpNotifier) {
|
|
39
83
|
const events = new Set(httpNotifier.events);
|
|
40
84
|
let resolved;
|
|
41
85
|
switch (httpNotifier.channel) {
|
|
42
86
|
case 'webhook': {
|
|
43
|
-
const { webhookUrl, webhookSecret } = httpNotifier.config;
|
|
87
|
+
const { webhookUrl, webhookSecret, publicUrl } = httpNotifier.config;
|
|
44
88
|
// One shared instance for both concerns, same "it's channel-
|
|
45
89
|
// specific, not concern-specific" reasoning every other channel's
|
|
46
90
|
// own file already has — see WebhookNotifier's own doc comment.
|
|
47
|
-
const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret });
|
|
91
|
+
const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret, publicUrl });
|
|
48
92
|
resolved = {
|
|
49
93
|
approver: events.has('approval') ? webhook : undefined,
|
|
50
94
|
questionHandler: events.has('question') ? webhook : undefined,
|
|
@@ -54,13 +98,13 @@ export function resolveHttpNotifier(config) {
|
|
|
54
98
|
break;
|
|
55
99
|
}
|
|
56
100
|
case 'slack': {
|
|
57
|
-
const { botToken, channelId } = httpNotifier.config;
|
|
101
|
+
const { botToken, channelId, publicUrl, buttons } = httpNotifier.config;
|
|
58
102
|
// One shared instance for every concern this httpNotifier actually
|
|
59
103
|
// covers — same "it's Slack-specific, not approval/question/
|
|
60
104
|
// lifecycle-specific" reasoning SlackNotifier's own doc
|
|
61
105
|
// comment works out; cheap to construct (holds two strings, does no
|
|
62
106
|
// I/O itself) so there's no reason to build one per event kind.
|
|
63
|
-
const slack = new SlackNotifier({ botToken, channelId });
|
|
107
|
+
const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons });
|
|
64
108
|
resolved = {
|
|
65
109
|
approver: events.has('approval') ? slack : undefined,
|
|
66
110
|
questionHandler: events.has('question') ? slack : undefined,
|
|
@@ -111,6 +155,5 @@ export function resolveHttpNotifier(config) {
|
|
|
111
155
|
break;
|
|
112
156
|
}
|
|
113
157
|
}
|
|
114
|
-
cache.set(config, resolved);
|
|
115
158
|
return resolved;
|
|
116
159
|
}
|
|
@@ -3,10 +3,22 @@ import type { DurableQuestionHandler } from '../agent-config.js';
|
|
|
3
3
|
export declare class SlackNotifier implements DurableApprover, DurableQuestionHandler {
|
|
4
4
|
private readonly botToken;
|
|
5
5
|
private readonly channelId;
|
|
6
|
+
private readonly publicUrl?;
|
|
7
|
+
private readonly buttons;
|
|
8
|
+
/** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
|
|
9
|
+
* wins when set) — when known, each message links to a decision page
|
|
10
|
+
* (core/approval-links.ts) anyone in the channel can open. `buttons`:
|
|
11
|
+
* whether to add interactive Approve/Deny buttons, which only work with
|
|
12
|
+
* a Slack interactivity endpoint deployed (examples/notifier-handler/
|
|
13
|
+
* slack.ts); default true, false for a console-configured notifier,
|
|
14
|
+
* which relies on the link instead. */
|
|
6
15
|
constructor(options: {
|
|
7
16
|
botToken: string;
|
|
8
17
|
channelId: string;
|
|
18
|
+
publicUrl?: string;
|
|
19
|
+
buttons?: boolean;
|
|
9
20
|
});
|
|
21
|
+
private decideLine;
|
|
10
22
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
11
23
|
pendingId: string;
|
|
12
24
|
};
|
|
@@ -19,3 +31,12 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
|
|
|
19
31
|
* so nothing to attach buttons to. */
|
|
20
32
|
postMessage(text: string, blocks?: unknown[]): void;
|
|
21
33
|
}
|
|
34
|
+
/** One plain chat.postMessage, awaited, reporting whether Slack accepted
|
|
35
|
+
* it — backs the console's "Send test" for a Slack notifier, where
|
|
36
|
+
* SlackNotifier.postMessage's fire-and-forget contract would hide the
|
|
37
|
+
* answer (a bad token, a channel the bot isn't in). */
|
|
38
|
+
export declare function sendTestSlackMessage(botToken: string, channelId: string, text: string): Promise<{
|
|
39
|
+
ok: boolean;
|
|
40
|
+
status?: number;
|
|
41
|
+
error?: string;
|
|
42
|
+
}>;
|
|
@@ -24,12 +24,29 @@
|
|
|
24
24
|
// it's Slack-specific (the bot token, the channel, the chat.postMessage
|
|
25
25
|
// call shape).
|
|
26
26
|
import { randomUUID } from 'node:crypto';
|
|
27
|
+
import { decidePath } from '../approval-links.js';
|
|
28
|
+
import { publicBaseUrl } from './webhook.js';
|
|
27
29
|
export class SlackNotifier {
|
|
28
30
|
botToken;
|
|
29
31
|
channelId;
|
|
32
|
+
publicUrl;
|
|
33
|
+
buttons;
|
|
34
|
+
/** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
|
|
35
|
+
* wins when set) — when known, each message links to a decision page
|
|
36
|
+
* (core/approval-links.ts) anyone in the channel can open. `buttons`:
|
|
37
|
+
* whether to add interactive Approve/Deny buttons, which only work with
|
|
38
|
+
* a Slack interactivity endpoint deployed (examples/notifier-handler/
|
|
39
|
+
* slack.ts); default true, false for a console-configured notifier,
|
|
40
|
+
* which relies on the link instead. */
|
|
30
41
|
constructor(options) {
|
|
31
42
|
this.botToken = options.botToken;
|
|
32
43
|
this.channelId = options.channelId;
|
|
44
|
+
this.publicUrl = options.publicUrl;
|
|
45
|
+
this.buttons = options.buttons ?? true;
|
|
46
|
+
}
|
|
47
|
+
decideLine(pendingId, kind) {
|
|
48
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
49
|
+
return base ? `\n<${base}${decidePath(pendingId, kind)}|${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}>` : '';
|
|
33
50
|
}
|
|
34
51
|
requestDurableApproval(tool, args, scope, reason) {
|
|
35
52
|
const pendingId = randomUUID();
|
|
@@ -38,16 +55,18 @@ export class SlackNotifier {
|
|
|
38
55
|
type: 'section',
|
|
39
56
|
text: {
|
|
40
57
|
type: 'mrkdwn',
|
|
41
|
-
text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}`,
|
|
58
|
+
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')}`,
|
|
42
59
|
},
|
|
43
60
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
...(this.buttons
|
|
62
|
+
? [{
|
|
63
|
+
type: 'actions',
|
|
64
|
+
elements: [
|
|
65
|
+
{ type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
|
|
66
|
+
{ type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
|
|
67
|
+
],
|
|
68
|
+
}]
|
|
69
|
+
: []),
|
|
51
70
|
]);
|
|
52
71
|
return { pendingId };
|
|
53
72
|
}
|
|
@@ -60,8 +79,10 @@ export class SlackNotifier {
|
|
|
60
79
|
action_id: 'answer_option',
|
|
61
80
|
}));
|
|
62
81
|
this.postMessage(`Question from ${agent}: ${question}`, [
|
|
63
|
-
{ type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}` } },
|
|
64
|
-
|
|
82
|
+
{ type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}${this.decideLine(pendingId, 'question')}` } },
|
|
83
|
+
...(this.buttons
|
|
84
|
+
? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
|
|
85
|
+
: []),
|
|
65
86
|
]);
|
|
66
87
|
return { pendingId };
|
|
67
88
|
}
|
|
@@ -89,3 +110,21 @@ export class SlackNotifier {
|
|
|
89
110
|
});
|
|
90
111
|
}
|
|
91
112
|
}
|
|
113
|
+
/** One plain chat.postMessage, awaited, reporting whether Slack accepted
|
|
114
|
+
* it — backs the console's "Send test" for a Slack notifier, where
|
|
115
|
+
* SlackNotifier.postMessage's fire-and-forget contract would hide the
|
|
116
|
+
* answer (a bad token, a channel the bot isn't in). */
|
|
117
|
+
export async function sendTestSlackMessage(botToken, channelId, text) {
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
|
|
122
|
+
body: JSON.stringify({ channel: channelId, text }),
|
|
123
|
+
});
|
|
124
|
+
const body = (await res.json());
|
|
125
|
+
return body.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: body.error ?? `HTTP ${res.status}` };
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import type { DurableApprover, Scope } from 'actauth';
|
|
2
2
|
import type { DurableQuestionHandler } from '../agent-config.js';
|
|
3
|
+
export interface DeliveryResult {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status?: number;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Where this server is reachable: LOOPENGINE_PUBLIC_URL (e.g.
|
|
9
|
+
* https://acme.loopengine.app) when set, read per call so a change takes
|
|
10
|
+
* effect without a restart, else `fallback` (a notifier.yml's own
|
|
11
|
+
* publicUrl, remembered from the console). undefined when neither is
|
|
12
|
+
* known, in which case payloads carry only relative paths. */
|
|
13
|
+
export declare function publicBaseUrl(fallback?: string): string | undefined;
|
|
3
14
|
export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
|
|
4
15
|
private readonly webhookUrl;
|
|
5
16
|
private readonly signingSecret;
|
|
17
|
+
private readonly retryDelaysMs;
|
|
18
|
+
private readonly publicUrl?;
|
|
6
19
|
constructor(options: {
|
|
7
20
|
webhookUrl: string;
|
|
8
21
|
signingSecret: string;
|
|
22
|
+
retryDelaysMs?: number[];
|
|
23
|
+
publicUrl?: string;
|
|
9
24
|
});
|
|
10
25
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
11
26
|
pendingId: string;
|
|
@@ -13,15 +28,9 @@ export declare class WebhookNotifier implements DurableApprover, DurableQuestion
|
|
|
13
28
|
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
|
|
14
29
|
pendingId: string;
|
|
15
30
|
};
|
|
16
|
-
/** Shared by both methods above — the one place either used to
|
|
17
|
-
* independently build the same signed fetch() call. Not awaited by its
|
|
18
|
-
* own callers, same fire-and-forget contract each had on its own: both
|
|
19
|
-
* requestDurableApproval/notifyPendingQuestion return immediately, by
|
|
20
|
-
* contract (see DurableApprover/DurableQuestionHandler's own doc
|
|
21
|
-
* comments), so a delivery failure here has no synchronous way to
|
|
22
|
-
* surface to the caller — logged instead of thrown, since throwing
|
|
23
|
-
* would blow up a decision that has already, correctly, been recorded
|
|
24
|
-
* as pending. */
|
|
25
|
-
private post;
|
|
26
31
|
}
|
|
27
32
|
export declare function postLifecycleWebhook(webhookUrl: string, webhookSecret: string, event: 'run_start' | 'run_finish', context: Record<string, unknown>): void;
|
|
33
|
+
/** One signed `{event: "test"}` POST (lifecycle signature header), no
|
|
34
|
+
* retries, awaited — backs the console's "Send test" button so an
|
|
35
|
+
* operator sees right away whether the receiver accepts it. */
|
|
36
|
+
export declare function sendTestWebhook(webhookUrl: string, webhookSecret: string, agent: string): Promise<DeliveryResult>;
|