loopengine 0.1.66 → 0.1.67
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 +19 -0
- package/dist/core/durable-approvals.d.ts +9 -0
- package/dist/core/durable-approvals.js +41 -3
- package/dist/core/http-notifier.d.ts +16 -1
- package/dist/core/http-notifier.js +45 -2
- package/dist/core/http-notify-triggers/slack.d.ts +9 -0
- package/dist/core/http-notify-triggers/slack.js +18 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +11 -10
- package/dist/core/http-notify-triggers/webhook.js +69 -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 +31 -0
- package/dist/core/notifier-file.js +96 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/web/agents-config-page.js +199 -2
- package/dist/web/console-shell.js +12 -4
- package/dist/web/notifier-admin.d.ts +61 -0
- package/dist/web/notifier-admin.js +88 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -171,6 +171,25 @@ export const config: AgentConfig = {
|
|
|
171
171
|
}
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
Or skip the code: in the console, **Permissions → Approvals** sets a
|
|
175
|
+
webhook, Slack or Lark target for one agent (saved to
|
|
176
|
+
`agents/<name>/notifier.yml`, with its secret in the agent's own `.env`),
|
|
177
|
+
sends a test, shows recent deliveries, and lists every decision still
|
|
178
|
+
waiting with Approve/Deny buttons.
|
|
179
|
+
|
|
180
|
+
For a third-party app calling an agent over plain HTTP:
|
|
181
|
+
|
|
182
|
+
1. `POST /agents/<name>/messages` → when a call needs approval, the reply
|
|
183
|
+
has `stopReason: "pending_approval"` and a `pending` list, each item
|
|
184
|
+
with its `pendingId` and `resolvePath`.
|
|
185
|
+
2. The webhook carries the same `pendingId`, `resolvePath` (and a full
|
|
186
|
+
`resolveUrl` when `LOOPENGINE_PUBLIC_URL` is set), signed with
|
|
187
|
+
`X-Actauth-Signature`. Failed deliveries are retried.
|
|
188
|
+
3. `POST <resolvePath>` with `{"decision":"approve"}` (or `"deny"`) runs
|
|
189
|
+
the tool, resumes the turn, and returns the agent's reply.
|
|
190
|
+
`GET /agents/<name>/pending` lists what's waiting, for apps that poll
|
|
191
|
+
instead of receiving webhooks.
|
|
192
|
+
|
|
174
193
|
Reference implementations for the receiving side (verifying a Slack
|
|
175
194
|
click, a webhook HMAC signature, a magic-link token) ship under
|
|
176
195
|
`examples/notifier-handler/` for the four channels that need one. See
|
|
@@ -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,10 @@ 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[]>;
|
|
61
68
|
close(): Promise<void>;
|
|
62
69
|
}
|
|
63
70
|
/** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
|
|
@@ -75,6 +82,7 @@ export declare class FileCheckpointStore implements CheckpointStore {
|
|
|
75
82
|
checkpoint: TurnCheckpoint | undefined;
|
|
76
83
|
result: T;
|
|
77
84
|
}>): Promise<T>;
|
|
85
|
+
listOpen(): Promise<TurnCheckpoint[]>;
|
|
78
86
|
close(): Promise<void>;
|
|
79
87
|
}
|
|
80
88
|
/** Redis-backed store for multi-instance deployments. Unlike
|
|
@@ -98,6 +106,7 @@ export declare class RedisCheckpointStore implements CheckpointStore {
|
|
|
98
106
|
checkpoint: TurnCheckpoint | undefined;
|
|
99
107
|
result: T;
|
|
100
108
|
}>): Promise<T>;
|
|
109
|
+
listOpen(): Promise<TurnCheckpoint[]>;
|
|
101
110
|
close(): Promise<void>;
|
|
102
111
|
}
|
|
103
112
|
/** 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,22 @@ export class FileCheckpointStore {
|
|
|
69
72
|
return result;
|
|
70
73
|
});
|
|
71
74
|
}
|
|
75
|
+
async listOpen() {
|
|
76
|
+
const open = [];
|
|
77
|
+
for (const name of readdirSync(this.checkpointsDir)) {
|
|
78
|
+
if (!name.endsWith('.json'))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
const checkpoint = JSON.parse(readFileSync(path.join(this.checkpointsDir, name), 'utf8'));
|
|
82
|
+
if (!checkpoint.closed && Object.keys(checkpoint.outstanding).length)
|
|
83
|
+
open.push(checkpoint);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A checkpoint mid-write or removed between readdir and read.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sortOldestFirst(open);
|
|
90
|
+
}
|
|
72
91
|
async close() { }
|
|
73
92
|
}
|
|
74
93
|
/** Redis-backed store for multi-instance deployments. Unlike
|
|
@@ -117,7 +136,7 @@ export class RedisCheckpointStore {
|
|
|
117
136
|
}
|
|
118
137
|
}
|
|
119
138
|
async create(input) {
|
|
120
|
-
const checkpoint = { id: randomUUID(), closed: false, ...input };
|
|
139
|
+
const checkpoint = { id: randomUUID(), closed: false, createdAt: new Date().toISOString(), ...input };
|
|
121
140
|
await this.redis.set(this.checkpointKey(checkpoint.id), JSON.stringify(checkpoint));
|
|
122
141
|
for (const pendingId of Object.keys(checkpoint.outstanding)) {
|
|
123
142
|
await this.redis.set(this.indexKey(pendingId), checkpoint.id);
|
|
@@ -145,6 +164,25 @@ export class RedisCheckpointStore {
|
|
|
145
164
|
return result;
|
|
146
165
|
});
|
|
147
166
|
}
|
|
167
|
+
async listOpen() {
|
|
168
|
+
const open = [];
|
|
169
|
+
let cursor = '0';
|
|
170
|
+
do {
|
|
171
|
+
// checkpoint:<id> only — checkpoint-lock:/checkpoint-pending-index:
|
|
172
|
+
// keys use a hyphen, so this pattern never matches them.
|
|
173
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', 'checkpoint:*', 'COUNT', 200);
|
|
174
|
+
cursor = next;
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const raw = await this.redis.get(key);
|
|
177
|
+
if (!raw)
|
|
178
|
+
continue;
|
|
179
|
+
const checkpoint = JSON.parse(raw);
|
|
180
|
+
if (!checkpoint.closed && Object.keys(checkpoint.outstanding).length)
|
|
181
|
+
open.push(checkpoint);
|
|
182
|
+
}
|
|
183
|
+
} while (cursor !== '0');
|
|
184
|
+
return sortOldestFirst(open);
|
|
185
|
+
}
|
|
148
186
|
async close() {
|
|
149
187
|
await this.redis.quit();
|
|
150
188
|
}
|
|
@@ -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,13 +31,55 @@ 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) {
|
|
@@ -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
|
}
|
|
@@ -19,3 +19,12 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
|
|
|
19
19
|
* so nothing to attach buttons to. */
|
|
20
20
|
postMessage(text: string, blocks?: unknown[]): void;
|
|
21
21
|
}
|
|
22
|
+
/** One plain chat.postMessage, awaited, reporting whether Slack accepted
|
|
23
|
+
* it — backs the console's "Send test" for a Slack notifier, where
|
|
24
|
+
* SlackNotifier.postMessage's fire-and-forget contract would hide the
|
|
25
|
+
* answer (a bad token, a channel the bot isn't in). */
|
|
26
|
+
export declare function sendTestSlackMessage(botToken: string, channelId: string, text: string): Promise<{
|
|
27
|
+
ok: boolean;
|
|
28
|
+
status?: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
}>;
|
|
@@ -89,3 +89,21 @@ export class SlackNotifier {
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/** One plain chat.postMessage, awaited, reporting whether Slack accepted
|
|
93
|
+
* it — backs the console's "Send test" for a Slack notifier, where
|
|
94
|
+
* SlackNotifier.postMessage's fire-and-forget contract would hide the
|
|
95
|
+
* answer (a bad token, a channel the bot isn't in). */
|
|
96
|
+
export async function sendTestSlackMessage(botToken, channelId, text) {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
|
|
101
|
+
body: JSON.stringify({ channel: channelId, text }),
|
|
102
|
+
});
|
|
103
|
+
const body = (await res.json());
|
|
104
|
+
return body.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: body.error ?? `HTTP ${res.status}` };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,11 +1,18 @@
|
|
|
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
|
+
}
|
|
3
8
|
export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
|
|
4
9
|
private readonly webhookUrl;
|
|
5
10
|
private readonly signingSecret;
|
|
11
|
+
private readonly retryDelaysMs;
|
|
6
12
|
constructor(options: {
|
|
7
13
|
webhookUrl: string;
|
|
8
14
|
signingSecret: string;
|
|
15
|
+
retryDelaysMs?: number[];
|
|
9
16
|
});
|
|
10
17
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
11
18
|
pendingId: string;
|
|
@@ -13,15 +20,9 @@ export declare class WebhookNotifier implements DurableApprover, DurableQuestion
|
|
|
13
20
|
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
|
|
14
21
|
pendingId: string;
|
|
15
22
|
};
|
|
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
23
|
}
|
|
27
24
|
export declare function postLifecycleWebhook(webhookUrl: string, webhookSecret: string, event: 'run_start' | 'run_finish', context: Record<string, unknown>): void;
|
|
25
|
+
/** One signed `{event: "test"}` POST (lifecycle signature header), no
|
|
26
|
+
* retries, awaited — backs the console's "Send test" button so an
|
|
27
|
+
* operator sees right away whether the receiver accepts it. */
|
|
28
|
+
export declare function sendTestWebhook(webhookUrl: string, webhookSecret: string, agent: string): Promise<DeliveryResult>;
|
|
@@ -39,56 +39,94 @@
|
|
|
39
39
|
// pending-approval/pending-question one hitting the same URL (see
|
|
40
40
|
// examples/notifier-handler/webhook.ts's own `verifyWebhookNotifier` for
|
|
41
41
|
// the receiving side of all three).
|
|
42
|
+
// Delivery is retried (RETRY_DELAYS_MS, a non-2xx answer counts as a
|
|
43
|
+
// failure), every attempt is recorded for the console's delivery log
|
|
44
|
+
// (core/notifier-deliveries.ts), and each payload carries where to send
|
|
45
|
+
// the decision back (`resolvePath`/`answerPath`, plus a full
|
|
46
|
+
// `resolveUrl`/`answerUrl` when LOOPENGINE_PUBLIC_URL says where this
|
|
47
|
+
// server is reachable) — all additive fields, so existing receivers keep
|
|
48
|
+
// working unchanged.
|
|
42
49
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
50
|
+
import { recordDelivery } from '../notifier-deliveries.js';
|
|
51
|
+
/** Waits before each retry after the first attempt — four tries in all,
|
|
52
|
+
* spread over about 40 seconds. */
|
|
53
|
+
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
|
|
54
|
+
function sign(body, secret) {
|
|
55
|
+
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
|
|
56
|
+
}
|
|
57
|
+
/** LOOPENGINE_PUBLIC_URL (e.g. https://acme.loopengine.app), read per
|
|
58
|
+
* call so a change takes effect without a restart; undefined when unset,
|
|
59
|
+
* in which case payloads carry only the relative path. */
|
|
60
|
+
function publicUrl(path) {
|
|
61
|
+
const base = process.env.LOOPENGINE_PUBLIC_URL;
|
|
62
|
+
return base ? base.replace(/\/+$/, '') + path : undefined;
|
|
63
|
+
}
|
|
64
|
+
async function attemptOnce(url, body, headers) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
|
|
67
|
+
return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Sends one signed POST, retrying on a network error or non-2xx answer,
|
|
74
|
+
* and records every attempt. Resolves with the last attempt's result —
|
|
75
|
+
* callers that must return right away (a pending decision is already
|
|
76
|
+
* recorded either way) don't await it. */
|
|
77
|
+
async function deliver(url, body, headers, meta, retryDelaysMs) {
|
|
78
|
+
let result = { ok: false };
|
|
79
|
+
for (let attempt = 1; attempt <= retryDelaysMs.length + 1; attempt++) {
|
|
80
|
+
if (attempt > 1)
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt - 2]));
|
|
82
|
+
result = await attemptOnce(url, body, headers);
|
|
83
|
+
recordDelivery({ at: new Date().toISOString(), url, attempt, ...meta, ...result });
|
|
84
|
+
if (result.ok)
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
console.error(`[loopengine] webhook delivery to ${url} failed after ${retryDelaysMs.length + 1} attempts (${meta.kind}${meta.pendingId ? ` ${meta.pendingId}` : ''}): ${result.error}`);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
43
90
|
export class WebhookNotifier {
|
|
44
91
|
webhookUrl;
|
|
45
92
|
signingSecret;
|
|
93
|
+
retryDelaysMs;
|
|
46
94
|
constructor(options) {
|
|
47
95
|
this.webhookUrl = options.webhookUrl;
|
|
48
96
|
this.signingSecret = options.signingSecret;
|
|
97
|
+
this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
49
98
|
}
|
|
50
99
|
requestDurableApproval(tool, args, scope, reason) {
|
|
51
100
|
const pendingId = randomUUID();
|
|
52
101
|
const requestedAt = new Date().toISOString();
|
|
53
|
-
const
|
|
54
|
-
|
|
102
|
+
const resolvePath = `/pending-approvals/${pendingId}/resolve`;
|
|
103
|
+
const body = JSON.stringify({
|
|
104
|
+
pendingId, tool, args, scope, reason, requestedAt,
|
|
105
|
+
kind: 'approval', agent: scope.agent, resolvePath, resolveUrl: publicUrl(resolvePath),
|
|
106
|
+
});
|
|
107
|
+
void deliver(this.webhookUrl, body, { 'X-Actauth-Signature': sign(body, this.signingSecret) }, { agent: scope.agent, kind: 'approval', pendingId }, this.retryDelaysMs);
|
|
55
108
|
return { pendingId };
|
|
56
109
|
}
|
|
57
110
|
notifyPendingQuestion(question, options, agent, sessionId) {
|
|
58
111
|
const pendingId = randomUUID();
|
|
59
112
|
const requestedAt = new Date().toISOString();
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/** Shared by both methods above — the one place either used to
|
|
65
|
-
* independently build the same signed fetch() call. Not awaited by its
|
|
66
|
-
* own callers, same fire-and-forget contract each had on its own: both
|
|
67
|
-
* requestDurableApproval/notifyPendingQuestion return immediately, by
|
|
68
|
-
* contract (see DurableApprover/DurableQuestionHandler's own doc
|
|
69
|
-
* comments), so a delivery failure here has no synchronous way to
|
|
70
|
-
* surface to the caller — logged instead of thrown, since throwing
|
|
71
|
-
* would blow up a decision that has already, correctly, been recorded
|
|
72
|
-
* as pending. */
|
|
73
|
-
post(body, signatureHeader, pendingId) {
|
|
74
|
-
const signature = `sha256=${createHmac('sha256', this.signingSecret).update(body).digest('hex')}`;
|
|
75
|
-
fetch(this.webhookUrl, {
|
|
76
|
-
method: 'POST',
|
|
77
|
-
headers: { 'content-type': 'application/json', [signatureHeader]: signature },
|
|
78
|
-
body,
|
|
79
|
-
}).catch((err) => {
|
|
80
|
-
console.error(`[loopengine] WebhookNotifier: webhook delivery failed for pendingId '${pendingId}':`, err);
|
|
113
|
+
const answerPath = `/pending-questions/${pendingId}/answer`;
|
|
114
|
+
const body = JSON.stringify({
|
|
115
|
+
pendingId, question, options, agent, sessionId, requestedAt,
|
|
116
|
+
kind: 'question', answerPath, answerUrl: publicUrl(answerPath),
|
|
81
117
|
});
|
|
118
|
+
void deliver(this.webhookUrl, body, { 'X-Askuser-Signature': sign(body, this.signingSecret) }, { agent, kind: 'question', pendingId }, this.retryDelaysMs);
|
|
119
|
+
return { pendingId };
|
|
82
120
|
}
|
|
83
121
|
}
|
|
84
122
|
export function postLifecycleWebhook(webhookUrl, webhookSecret, event, context) {
|
|
85
123
|
const payload = JSON.stringify({ event, ...context, occurredAt: new Date().toISOString() });
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
});
|
|
124
|
+
void deliver(webhookUrl, payload, { 'X-Lifecycle-Signature': sign(payload, webhookSecret) }, { agent: String(context.agent ?? ''), kind: event }, RETRY_DELAYS_MS);
|
|
125
|
+
}
|
|
126
|
+
/** One signed `{event: "test"}` POST (lifecycle signature header), no
|
|
127
|
+
* retries, awaited — backs the console's "Send test" button so an
|
|
128
|
+
* operator sees right away whether the receiver accepts it. */
|
|
129
|
+
export function sendTestWebhook(webhookUrl, webhookSecret, agent) {
|
|
130
|
+
const payload = JSON.stringify({ event: 'test', agent, occurredAt: new Date().toISOString() });
|
|
131
|
+
return deliver(webhookUrl, payload, { 'X-Lifecycle-Signature': sign(payload, webhookSecret) }, { agent, kind: 'test' }, []);
|
|
94
132
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type NotifierDeliveryKind = 'approval' | 'question' | 'run_start' | 'run_finish' | 'test';
|
|
2
|
+
export interface NotifierDelivery {
|
|
3
|
+
at: string;
|
|
4
|
+
agent: string;
|
|
5
|
+
kind: NotifierDeliveryKind;
|
|
6
|
+
url: string;
|
|
7
|
+
pendingId?: string;
|
|
8
|
+
/** 1 for the first try, 2+ for retries. */
|
|
9
|
+
attempt: number;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
/** The receiver's HTTP status, when it answered at all. */
|
|
12
|
+
status?: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function recordDelivery(delivery: NotifierDelivery): void;
|
|
16
|
+
/** Newest first. */
|
|
17
|
+
export declare function listDeliveries(agent: string): NotifierDelivery[];
|
|
18
|
+
export declare function clearDeliveries(): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// A short, in-memory record of recent webhook deliveries per agent — what
|
|
2
|
+
// the console's Approvals section shows so an operator can see whether a
|
|
3
|
+
// notification actually reached the app on the other end (and why not).
|
|
4
|
+
// Deliberately not persisted: it's a debugging aid that resets with the
|
|
5
|
+
// process, not an audit log; the pending decision itself is what's
|
|
6
|
+
// durable (core/durable-approvals.ts).
|
|
7
|
+
const MAX_PER_AGENT = 50;
|
|
8
|
+
const byAgent = new Map();
|
|
9
|
+
export function recordDelivery(delivery) {
|
|
10
|
+
const list = byAgent.get(delivery.agent) ?? [];
|
|
11
|
+
list.unshift(delivery);
|
|
12
|
+
if (list.length > MAX_PER_AGENT)
|
|
13
|
+
list.length = MAX_PER_AGENT;
|
|
14
|
+
byAgent.set(delivery.agent, list);
|
|
15
|
+
}
|
|
16
|
+
/** Newest first. */
|
|
17
|
+
export function listDeliveries(agent) {
|
|
18
|
+
return [...(byAgent.get(agent) ?? [])];
|
|
19
|
+
}
|
|
20
|
+
export function clearDeliveries() {
|
|
21
|
+
byAgent.clear();
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AgentEnv, HttpNotifierConfig, HttpNotifierEvent } from '#core/agent-config.js';
|
|
2
|
+
export type NotifierFileChannel = 'webhook' | 'slack' | 'lark';
|
|
3
|
+
export interface NotifierFile {
|
|
4
|
+
channel: NotifierFileChannel;
|
|
5
|
+
events: HttpNotifierEvent[];
|
|
6
|
+
/** webhook: where to POST. */
|
|
7
|
+
url?: string;
|
|
8
|
+
/** slack: channel to post into. */
|
|
9
|
+
channelId?: string;
|
|
10
|
+
/** lark: app id and chat to post into. */
|
|
11
|
+
appId?: string;
|
|
12
|
+
chatId?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The env var each channel's secret is read from, per agent. */
|
|
15
|
+
export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
16
|
+
export declare class NotifierFileError extends Error {
|
|
17
|
+
}
|
|
18
|
+
export declare function notifierFilePath(dir: string): string;
|
|
19
|
+
/** Throws NotifierFileError naming the first problem. */
|
|
20
|
+
export declare function validateNotifierFile(input: unknown): NotifierFile;
|
|
21
|
+
/** undefined when the agent has no notifier.yml. */
|
|
22
|
+
export declare function readNotifierFile(dir: string): NotifierFile | undefined;
|
|
23
|
+
export declare function writeNotifierFile(dir: string, input: unknown): NotifierFile;
|
|
24
|
+
export declare function removeNotifierFile(dir: string): void;
|
|
25
|
+
/** The HttpNotifierConfig a notifier.yml describes, with its secret read
|
|
26
|
+
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
27
|
+
* or unauthenticated. */
|
|
28
|
+
export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv): {
|
|
29
|
+
config?: HttpNotifierConfig;
|
|
30
|
+
missingSecret?: string;
|
|
31
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// agents/<name>/notifier.yml — where an agent's "ask" approvals and
|
|
2
|
+
// questions go when nobody is watching a live chat, set from the console
|
|
3
|
+
// (Permissions → Approvals) instead of in code. Only consulted when the
|
|
4
|
+
// agent's own AgentConfig.httpNotifier is unset; code always wins.
|
|
5
|
+
//
|
|
6
|
+
// The file holds only non-secret settings. The one secret each channel
|
|
7
|
+
// needs (a webhook signing secret, a Slack bot token, a Lark app secret)
|
|
8
|
+
// lives in the agent's own env (agents/<name>/.env by default, through
|
|
9
|
+
// core/secret-store.ts) under a fixed name, so it's never written into a
|
|
10
|
+
// file that might get committed.
|
|
11
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
14
|
+
/** The env var each channel's secret is read from, per agent. */
|
|
15
|
+
export const NOTIFIER_SECRET_VARS = {
|
|
16
|
+
webhook: 'LOOPENGINE_NOTIFIER_WEBHOOK_SECRET',
|
|
17
|
+
slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
|
|
18
|
+
lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
|
|
19
|
+
};
|
|
20
|
+
const CHANNELS = ['webhook', 'slack', 'lark'];
|
|
21
|
+
const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
|
|
22
|
+
export class NotifierFileError extends Error {
|
|
23
|
+
}
|
|
24
|
+
export function notifierFilePath(dir) {
|
|
25
|
+
return join(dir, 'notifier.yml');
|
|
26
|
+
}
|
|
27
|
+
/** Throws NotifierFileError naming the first problem. */
|
|
28
|
+
export function validateNotifierFile(input) {
|
|
29
|
+
if (!input || typeof input !== 'object')
|
|
30
|
+
throw new NotifierFileError('notifier settings must be an object');
|
|
31
|
+
const raw = input;
|
|
32
|
+
const channel = raw.channel;
|
|
33
|
+
if (!CHANNELS.includes(channel))
|
|
34
|
+
throw new NotifierFileError(`channel must be one of: ${CHANNELS.join(', ')}`);
|
|
35
|
+
const events = Array.isArray(raw.events) ? raw.events : [];
|
|
36
|
+
if (!events.length || events.some((e) => !EVENTS.includes(e))) {
|
|
37
|
+
throw new NotifierFileError(`events must be a non-empty list of: ${EVENTS.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
|
|
40
|
+
const file = { channel, events: events };
|
|
41
|
+
if (channel === 'webhook') {
|
|
42
|
+
const url = str('url');
|
|
43
|
+
if (!url || !/^https?:\/\//.test(url))
|
|
44
|
+
throw new NotifierFileError('url must be an http(s) URL');
|
|
45
|
+
file.url = url;
|
|
46
|
+
}
|
|
47
|
+
else if (channel === 'slack') {
|
|
48
|
+
const channelId = str('channelId');
|
|
49
|
+
if (!channelId)
|
|
50
|
+
throw new NotifierFileError('channelId is required for slack');
|
|
51
|
+
file.channelId = channelId;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const appId = str('appId');
|
|
55
|
+
const chatId = str('chatId');
|
|
56
|
+
if (!appId || !chatId)
|
|
57
|
+
throw new NotifierFileError('appId and chatId are required for lark');
|
|
58
|
+
file.appId = appId;
|
|
59
|
+
file.chatId = chatId;
|
|
60
|
+
}
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
/** undefined when the agent has no notifier.yml. */
|
|
64
|
+
export function readNotifierFile(dir) {
|
|
65
|
+
const path = notifierFilePath(dir);
|
|
66
|
+
if (!existsSync(path))
|
|
67
|
+
return undefined;
|
|
68
|
+
return validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
|
|
69
|
+
}
|
|
70
|
+
export function writeNotifierFile(dir, input) {
|
|
71
|
+
const file = validateNotifierFile(input);
|
|
72
|
+
const header = '# Where this agent sends "ask" approvals and questions when nobody is in a live chat.\n' +
|
|
73
|
+
`# Edited from the console (Permissions > Approvals). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
|
|
74
|
+
writeFileSync(notifierFilePath(dir), header + stringifyYaml(file));
|
|
75
|
+
return file;
|
|
76
|
+
}
|
|
77
|
+
export function removeNotifierFile(dir) {
|
|
78
|
+
rmSync(notifierFilePath(dir), { force: true });
|
|
79
|
+
}
|
|
80
|
+
/** The HttpNotifierConfig a notifier.yml describes, with its secret read
|
|
81
|
+
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
82
|
+
* or unauthenticated. */
|
|
83
|
+
export function notifierConfigFromFile(file, env) {
|
|
84
|
+
const secretVar = NOTIFIER_SECRET_VARS[file.channel];
|
|
85
|
+
const secret = env.get(secretVar);
|
|
86
|
+
if (!secret)
|
|
87
|
+
return { missingSecret: secretVar };
|
|
88
|
+
switch (file.channel) {
|
|
89
|
+
case 'webhook':
|
|
90
|
+
return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret }, events: file.events } };
|
|
91
|
+
case 'slack':
|
|
92
|
+
return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId }, events: file.events } };
|
|
93
|
+
case 'lark':
|
|
94
|
+
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export { createAgentEnv, agentDirFor, envScopeOf, type EnvScope } from '#core/ag
|
|
|
7
7
|
export { FileSecretStore, setSecretStore, getSecretStore, type SecretStore } from '#core/secret-store.js';
|
|
8
8
|
export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER, type ProxyIdentity } from '#core/trusted-proxy.js';
|
|
9
9
|
export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#core/usage.js';
|
|
10
|
+
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
|
|
11
|
+
export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
|
|
12
|
+
export { describeNotifier } from '#core/http-notifier.js';
|
|
13
|
+
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
14
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
|
|
10
15
|
export type * from './core/loop-events.js';
|
|
11
16
|
export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
|
|
12
17
|
export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,11 @@ export { createAgentEnv, agentDirFor, envScopeOf } from '#core/agent-env.js';
|
|
|
9
9
|
export { FileSecretStore, setSecretStore, getSecretStore } from '#core/secret-store.js';
|
|
10
10
|
export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER } from '#core/trusted-proxy.js';
|
|
11
11
|
export { setUsageSink, recordUsage } from '#core/usage.js';
|
|
12
|
+
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
|
|
13
|
+
export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
|
|
14
|
+
export { describeNotifier } from '#core/http-notifier.js';
|
|
15
|
+
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
16
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
|
|
12
17
|
// The framework-agnostic browser client for adapters/http.ts's own wire
|
|
13
18
|
// protocol (see client.ts's own header comment) — this, not a deep
|
|
14
19
|
// subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
|
|
@@ -889,7 +889,9 @@ ${consoleShellScript}
|
|
|
889
889
|
// opened. ----
|
|
890
890
|
|
|
891
891
|
function renderActauthTabPlaceholder() {
|
|
892
|
-
return '<
|
|
892
|
+
return '<div id="pendingContent"></div>' +
|
|
893
|
+
'<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading…</p></div></section>' +
|
|
894
|
+
'<div id="approvalsContent"><p class="hint">Loading…</p></div>';
|
|
893
895
|
}
|
|
894
896
|
|
|
895
897
|
function renderActauthRuleRow(r) {
|
|
@@ -953,6 +955,198 @@ ${consoleShellScript}
|
|
|
953
955
|
'</form></section>';
|
|
954
956
|
}
|
|
955
957
|
|
|
958
|
+
// ---- Permissions → Approvals: where "ask" decisions go when nobody is
|
|
959
|
+
// in Chat (web/notifier-admin.ts behind GET/PUT/DELETE .../notifier),
|
|
960
|
+
// the decisions still waiting (GET .../pending), and recent webhook
|
|
961
|
+
// deliveries. Reloaded every time the tab opens, so the waiting list is
|
|
962
|
+
// current. ----
|
|
963
|
+
|
|
964
|
+
var NOTIFIER_EVENTS = [
|
|
965
|
+
['approval', 'Approvals'], ['question', 'Questions'], ['run_finish', 'Run finished'], ['run_start', 'Run started']
|
|
966
|
+
];
|
|
967
|
+
|
|
968
|
+
function approvalsContentEl() {
|
|
969
|
+
return detail.querySelector('#approvalsContent');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function renderPendingTable(pending) {
|
|
973
|
+
if (!pending.length) return '<p class="hint">Nothing is waiting for a decision.</p>';
|
|
974
|
+
return '<div class="le-table-wrap"><table><thead><tr><th>Waiting</th><th>Details</th><th>Session</th><th></th></tr></thead><tbody>' +
|
|
975
|
+
pending.map(function (p) {
|
|
976
|
+
var details = p.kind === 'question'
|
|
977
|
+
? escapeHtml((p.args && p.args.question) || p.reason)
|
|
978
|
+
: '<code>' + escapeHtml(p.tool) + '</code> <span class="hint">' + escapeHtml(JSON.stringify(p.args)) + '</span>';
|
|
979
|
+
var actions = p.kind === 'question'
|
|
980
|
+
? '<form class="pending-answer" data-id="' + escapeHtml(p.pendingId) + '" style="display:flex;gap:6px"><input name="answer" placeholder="Answer" required style="min-width:0"><button type="submit">Answer</button></form>'
|
|
981
|
+
: '<div style="display:flex;gap:6px"><button type="button" class="primary pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="approve">Approve</button>' +
|
|
982
|
+
'<button type="button" class="pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="deny">Deny</button></div>';
|
|
983
|
+
return '<tr><td><span class="le-pill ' + (p.kind === 'question' ? 'accent' : 'ask') + '">' + (p.kind === 'question' ? 'question' : 'approval') + '</span>' +
|
|
984
|
+
(p.createdAt ? '<div class="hint">' + escapeHtml(new Date(p.createdAt).toLocaleString()) + '</div>' : '') + '</td>' +
|
|
985
|
+
'<td>' + details + '</td><td><code>' + escapeHtml(p.sessionId) + '</code></td><td>' + actions + '</td></tr>';
|
|
986
|
+
}).join('') + '</tbody></table></div>';
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function renderNotifierStatus(n) {
|
|
990
|
+
if (n.source === 'code') return 'Set in this agent's <code>index.ts</code> (<code>httpNotifier</code>, ' + escapeHtml(n.channel) + '). Edit it there; code always wins over the form below.';
|
|
991
|
+
if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
|
|
992
|
+
if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
|
|
993
|
+
if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
|
|
994
|
+
return 'Not set up. A call that needs approval only reaches someone in Chat; set a webhook or Slack channel so apps calling this agent over HTTP can get approvals.';
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function renderNotifierForm(n) {
|
|
998
|
+
var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
|
|
999
|
+
var events = f.events || [];
|
|
1000
|
+
function field(channel, html) {
|
|
1001
|
+
return '<div class="notifier-fields" data-channel="' + channel + '"' + (f.channel === channel ? '' : ' hidden') + '>' + html + '</div>';
|
|
1002
|
+
}
|
|
1003
|
+
function secretField(channel, label) {
|
|
1004
|
+
var isSet = n.secretsSet && n.secretsSet[channel];
|
|
1005
|
+
return '<label>' + label + ' <span class="hint">(' + (isSet ? 'set — leave blank to keep it' : 'not set') + '; stored in this agent's env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
|
|
1006
|
+
'<span style="display:flex;gap:6px"><input type="password" name="secret_' + channel + '" autocomplete="off" style="flex:1;min-width:0">' +
|
|
1007
|
+
(channel === 'webhook' ? '<button type="button" class="notifier-generate">Generate</button>' : '') + '</span></label>';
|
|
1008
|
+
}
|
|
1009
|
+
return '<form class="add-source" id="notifierForm" style="max-width:560px">' +
|
|
1010
|
+
'<label>Send to<select name="channel" id="notifierChannel">' +
|
|
1011
|
+
['webhook', 'slack', 'lark'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat' }[c] + '</option>'; }).join('') +
|
|
1012
|
+
'</select></label>' +
|
|
1013
|
+
field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret')) +
|
|
1014
|
+
field('slack', '<label>Channel ID<input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' + secretField('slack', 'Bot token')) +
|
|
1015
|
+
field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
|
|
1016
|
+
'<div class="body-field">Send<div style="display:flex;gap:14px;flex-wrap:wrap">' +
|
|
1017
|
+
NOTIFIER_EVENTS.map(function (e) {
|
|
1018
|
+
return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
|
|
1019
|
+
}).join('') + '</div></div>' +
|
|
1020
|
+
'<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit">Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
|
|
1021
|
+
(n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
|
|
1022
|
+
'<div id="notifierResult" class="hint"></div>' +
|
|
1023
|
+
'<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries <code>pendingId</code> and <code>resolvePath</code>; your app sends <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> there.' +
|
|
1024
|
+
(n.publicUrl ? '' : ' Set <code>LOOPENGINE_PUBLIC_URL</code> to include a full <code>resolveUrl</code> too.') + '</p>' +
|
|
1025
|
+
'</form>';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function renderDeliveries(list) {
|
|
1029
|
+
if (!list.length) return '<p class="hint">No deliveries since the server started.</p>';
|
|
1030
|
+
return '<div class="le-table-wrap"><table><thead><tr><th>When</th><th>What</th><th>Attempt</th><th>Result</th></tr></thead><tbody>' +
|
|
1031
|
+
list.slice(0, 15).map(function (d) {
|
|
1032
|
+
return '<tr><td>' + escapeHtml(new Date(d.at).toLocaleTimeString()) + '</td><td>' + escapeHtml(d.kind) + '</td><td>' + d.attempt + '</td>' +
|
|
1033
|
+
'<td>' + (d.ok ? '<span class="le-pill good">' + (d.status || 'ok') + '</span>' : '<span class="le-pill bad">' + escapeHtml(d.error || 'failed') + '</span>') + '</td></tr>';
|
|
1034
|
+
}).join('') + '</tbody></table></div>';
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Shown at the top of the tab, and only when something is waiting.
|
|
1038
|
+
function renderPendingHtml(pending) {
|
|
1039
|
+
if (!pending.length) return '';
|
|
1040
|
+
return '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function renderApprovalsHtml(n) {
|
|
1044
|
+
return '<section><h3>Approvals when nobody is in Chat</h3><p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
|
|
1045
|
+
(n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
|
|
1046
|
+
(n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
|
|
1047
|
+
'<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function wirePendingHandlers(name) {
|
|
1051
|
+
var content = detail.querySelector('#pendingContent');
|
|
1052
|
+
if (!content) return;
|
|
1053
|
+
var decideBtns = content.querySelectorAll('.pending-decide');
|
|
1054
|
+
for (var i = 0; i < decideBtns.length; i++) {
|
|
1055
|
+
decideBtns[i].addEventListener('click', function (ev) {
|
|
1056
|
+
var btn = ev.currentTarget;
|
|
1057
|
+
btn.disabled = true;
|
|
1058
|
+
fetchJsonRetryOnce('/pending-approvals/' + encodeURIComponent(btn.dataset.id) + '/resolve', {
|
|
1059
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision: btn.dataset.decision }),
|
|
1060
|
+
}).then(function () { loadApprovalsSection(name); });
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
var answerForms = content.querySelectorAll('.pending-answer');
|
|
1064
|
+
for (var j = 0; j < answerForms.length; j++) {
|
|
1065
|
+
answerForms[j].addEventListener('submit', function (ev) {
|
|
1066
|
+
ev.preventDefault();
|
|
1067
|
+
var form = ev.currentTarget;
|
|
1068
|
+
fetchJsonRetryOnce('/pending-questions/' + encodeURIComponent(form.dataset.id) + '/answer', {
|
|
1069
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer: new FormData(form).get('answer') }),
|
|
1070
|
+
}).then(function () { loadApprovalsSection(name); });
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function wireApprovalsHandlers(name) {
|
|
1076
|
+
var content = approvalsContentEl();
|
|
1077
|
+
if (!content) return;
|
|
1078
|
+
var form = content.querySelector('#notifierForm');
|
|
1079
|
+
if (!form) return;
|
|
1080
|
+
var result = content.querySelector('#notifierResult');
|
|
1081
|
+
content.querySelector('#notifierChannel').addEventListener('change', function (ev) {
|
|
1082
|
+
var groups = form.querySelectorAll('.notifier-fields');
|
|
1083
|
+
for (var k = 0; k < groups.length; k++) groups[k].hidden = groups[k].dataset.channel !== ev.currentTarget.value;
|
|
1084
|
+
});
|
|
1085
|
+
var generate = form.querySelector('.notifier-generate');
|
|
1086
|
+
if (generate) {
|
|
1087
|
+
generate.addEventListener('click', function () {
|
|
1088
|
+
var bytes = new Uint8Array(24);
|
|
1089
|
+
crypto.getRandomValues(bytes);
|
|
1090
|
+
var input = form.querySelector('input[name="secret_webhook"]');
|
|
1091
|
+
input.type = 'text';
|
|
1092
|
+
input.value = Array.prototype.map.call(bytes, function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
|
|
1093
|
+
result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
form.addEventListener('submit', function (ev) {
|
|
1097
|
+
ev.preventDefault();
|
|
1098
|
+
var data = new FormData(form);
|
|
1099
|
+
var channel = data.get('channel');
|
|
1100
|
+
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId') };
|
|
1101
|
+
var secret = data.get('secret_' + channel);
|
|
1102
|
+
if (secret) body.secret = secret;
|
|
1103
|
+
result.textContent = 'Saving…';
|
|
1104
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
|
|
1105
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
1106
|
+
}).then(function (r) {
|
|
1107
|
+
if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
|
|
1108
|
+
loadApprovalsSection(name, 'Saved.');
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1111
|
+
content.querySelector('#notifierTestBtn').addEventListener('click', function () {
|
|
1112
|
+
result.textContent = 'Sending…';
|
|
1113
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/test', { method: 'POST' }).then(function (r) {
|
|
1114
|
+
var t = r.body;
|
|
1115
|
+
loadApprovalsSection(name, t.ok ? 'Test delivered (' + (t.status || 'ok') + ').' : 'Test failed: ' + (t.error || 'unknown error'));
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
var removeBtn = content.querySelector('#notifierRemoveBtn');
|
|
1119
|
+
if (removeBtn) {
|
|
1120
|
+
removeBtn.addEventListener('click', function () {
|
|
1121
|
+
if (removeBtn.dataset.confirming !== '1') { removeBtn.dataset.confirming = '1'; removeBtn.textContent = 'Click again to remove'; return; }
|
|
1122
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', { method: 'DELETE' }).then(function () { loadApprovalsSection(name, 'Removed.'); });
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function loadApprovalsSection(name, message) {
|
|
1128
|
+
var content = approvalsContentEl();
|
|
1129
|
+
if (!content) return;
|
|
1130
|
+
Promise.all([
|
|
1131
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier'),
|
|
1132
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/pending'),
|
|
1133
|
+
]).then(function (results) {
|
|
1134
|
+
if (currentName !== name) return;
|
|
1135
|
+
if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
|
|
1136
|
+
content.innerHTML = renderApprovalsHtml(results[0].body);
|
|
1137
|
+
wireApprovalsHandlers(name);
|
|
1138
|
+
var pendingEl = detail.querySelector('#pendingContent');
|
|
1139
|
+
if (pendingEl) {
|
|
1140
|
+
pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || []);
|
|
1141
|
+
wirePendingHandlers(name);
|
|
1142
|
+
}
|
|
1143
|
+
if (message) { var r = content.querySelector('#notifierResult'); if (r) r.textContent = message; }
|
|
1144
|
+
}).catch(function (err) {
|
|
1145
|
+
if (currentName !== name) return;
|
|
1146
|
+
content.innerHTML = '<p class="error">Could not load approvals: ' + escapeHtml(err.message) + '</p>';
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
956
1150
|
function actauthContentEl() {
|
|
957
1151
|
return detail.querySelector('#actauthContent');
|
|
958
1152
|
}
|
|
@@ -2448,6 +2642,7 @@ ${consoleShellScript}
|
|
|
2448
2642
|
if (tab === 'actauth' && actauthLoadedFor !== currentName) {
|
|
2449
2643
|
loadActauthTab(currentName);
|
|
2450
2644
|
}
|
|
2645
|
+
if (tab === 'actauth') loadApprovalsSection(currentName);
|
|
2451
2646
|
if (tab === 'env' && envLoadedFor !== currentName) {
|
|
2452
2647
|
loadEnvTab(currentName);
|
|
2453
2648
|
}
|
|
@@ -2609,7 +2804,9 @@ ${consoleShellScript}
|
|
|
2609
2804
|
currentTab = CONFIG_TABS.indexOf(initialParams.get('tab')) === -1 ? 'overview' : initialParams.get('tab');
|
|
2610
2805
|
leShell.ready.then(function (agents) {
|
|
2611
2806
|
if (!agents.length) {
|
|
2612
|
-
empty.innerHTML =
|
|
2807
|
+
empty.innerHTML = leShell.loadError()
|
|
2808
|
+
? 'Could not load agents: ' + escapeHtml(leShell.loadError()) + '. Reload the page to try again.'
|
|
2809
|
+
: 'No agents yet. <a href="/agents?new=1">Create one</a> or add a folder under agents/.';
|
|
2613
2810
|
return;
|
|
2614
2811
|
}
|
|
2615
2812
|
var requested = initialParams.get('agent');
|
|
@@ -98,15 +98,23 @@ export const consoleShellScript = `<script>
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
var
|
|
102
|
-
|
|
101
|
+
var loadError = null;
|
|
102
|
+
var ready = fetch('/agents', { headers: { accept: 'application/json' } })
|
|
103
|
+
.then(function (r) {
|
|
104
|
+
if (!r.ok) throw new Error(r.status === 401 ? 'sign-in required (HTTP 401)' : 'HTTP ' + r.status);
|
|
105
|
+
return r.json();
|
|
106
|
+
})
|
|
103
107
|
.then(function (data) {
|
|
104
108
|
agents = data.agents || [];
|
|
105
109
|
renderList();
|
|
106
110
|
loadToSet();
|
|
107
111
|
return agents;
|
|
108
112
|
})
|
|
109
|
-
.catch(function () {
|
|
113
|
+
.catch(function (err) {
|
|
114
|
+
loadError = err.message;
|
|
115
|
+
if (list) list.innerHTML = '<li class="le-agent-item"><span></span><span class="le-meta">Could not load agents: ' + esc(err.message) + '</span><span></span></li>';
|
|
116
|
+
return [];
|
|
117
|
+
});
|
|
110
118
|
|
|
111
119
|
// opts: { name, prompt, chips: [{ text, mono }], tab, counts: { tools, skills, abilities, actauth } }
|
|
112
120
|
function renderHead(opts) {
|
|
@@ -160,6 +168,6 @@ export const consoleShellScript = `<script>
|
|
|
160
168
|
if (app.classList.contains('nav-open') && !ev.target.closest('.le-sidebar')) app.classList.remove('nav-open');
|
|
161
169
|
});
|
|
162
170
|
|
|
163
|
-
window.leShell = { ready: ready, renderHead: renderHead, setTab: setTab, setActive: setActive, esc: esc, tabHref: tabHref, envCountHtml: envCountHtml, setCount: setCount };
|
|
171
|
+
window.leShell = { loadError: function () { return loadError; }, ready: ready, renderHead: renderHead, setTab: setTab, setActive: setActive, esc: esc, tabHref: tabHref, envCountHtml: envCountHtml, setCount: setCount };
|
|
164
172
|
})();
|
|
165
173
|
</script>`;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AgentConfig } from '../core/agent-config.js';
|
|
2
|
+
import type { CheckpointStore } from '../core/durable-approvals.js';
|
|
3
|
+
import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
|
|
4
|
+
import { type NotifierFileChannel } from '../core/notifier-file.js';
|
|
5
|
+
/** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
|
|
6
|
+
* _SECRET), which applies to the plain /messages route when an agent has
|
|
7
|
+
* no notifier of its own. */
|
|
8
|
+
export interface DefaultWebhook {
|
|
9
|
+
webhookUrl: string;
|
|
10
|
+
webhookSecret: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function describeAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): {
|
|
13
|
+
source: string;
|
|
14
|
+
defaultWebhookUrl: string | null;
|
|
15
|
+
publicUrl: string | null;
|
|
16
|
+
secretVars: Record<NotifierFileChannel, string>;
|
|
17
|
+
secretsSet: {
|
|
18
|
+
[k: string]: boolean;
|
|
19
|
+
};
|
|
20
|
+
deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
|
|
21
|
+
channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
|
|
22
|
+
events?: string[];
|
|
23
|
+
file?: import("../core/notifier-file.js").NotifierFile;
|
|
24
|
+
missingSecret?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
/** Writes agents/<name>/notifier.yml from the console form and, when the
|
|
28
|
+
* form includes one, the channel's secret into the agent's own env.
|
|
29
|
+
* Throws NotifierFileError for an invalid form. */
|
|
30
|
+
export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
|
|
31
|
+
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
32
|
+
* notifier back on doesn't need it re-entered. */
|
|
33
|
+
export declare function removeAgentNotifier(config: AgentConfig): void;
|
|
34
|
+
/** Sends a test through whichever notifier applies to this agent —
|
|
35
|
+
* webhook or Slack; reports what the receiver answered. */
|
|
36
|
+
export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): Promise<DeliveryResult & {
|
|
37
|
+
channel?: string;
|
|
38
|
+
}>;
|
|
39
|
+
/** Every decision still waiting for this agent, oldest first, each with
|
|
40
|
+
* where to send the answer. */
|
|
41
|
+
export declare function listAgentPending(store: CheckpointStore, agentName: string): Promise<({
|
|
42
|
+
answerPath: string;
|
|
43
|
+
pendingId: string;
|
|
44
|
+
kind: "approval" | "question";
|
|
45
|
+
tool: string;
|
|
46
|
+
args: Record<string, unknown>;
|
|
47
|
+
reason: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
tenant: string;
|
|
50
|
+
createdAt: string | null;
|
|
51
|
+
} | {
|
|
52
|
+
resolvePath: string;
|
|
53
|
+
pendingId: string;
|
|
54
|
+
kind: "approval" | "question";
|
|
55
|
+
tool: string;
|
|
56
|
+
args: Record<string, unknown>;
|
|
57
|
+
reason: string;
|
|
58
|
+
sessionId: string;
|
|
59
|
+
tenant: string;
|
|
60
|
+
createdAt: string | null;
|
|
61
|
+
})[]>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
|
|
2
|
+
import { describeNotifier } from '../core/http-notifier.js';
|
|
3
|
+
import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
|
|
4
|
+
import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
|
|
5
|
+
import { listDeliveries } from '../core/notifier-deliveries.js';
|
|
6
|
+
import { NOTIFIER_SECRET_VARS, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
|
|
7
|
+
import { setAgentEnvVar } from './env-admin.js';
|
|
8
|
+
export function describeAgentNotifier(config, defaultWebhook) {
|
|
9
|
+
const described = describeNotifier(config);
|
|
10
|
+
const env = createAgentEnv(config.name, agentDirFor(config));
|
|
11
|
+
const secretsSet = Object.fromEntries(Object.keys(NOTIFIER_SECRET_VARS).map((channel) => [channel, env.get(NOTIFIER_SECRET_VARS[channel]) !== undefined]));
|
|
12
|
+
const source = described.source === 'none' && defaultWebhook ? 'default' : described.source;
|
|
13
|
+
return {
|
|
14
|
+
...described,
|
|
15
|
+
source,
|
|
16
|
+
defaultWebhookUrl: defaultWebhook?.webhookUrl ?? null,
|
|
17
|
+
publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
|
|
18
|
+
secretVars: NOTIFIER_SECRET_VARS,
|
|
19
|
+
secretsSet,
|
|
20
|
+
deliveries: listDeliveries(config.name),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Writes agents/<name>/notifier.yml from the console form and, when the
|
|
24
|
+
* form includes one, the channel's secret into the agent's own env.
|
|
25
|
+
* Throws NotifierFileError for an invalid form. */
|
|
26
|
+
export async function saveAgentNotifier(config, body) {
|
|
27
|
+
const file = writeNotifierFile(agentDirFor(config), body);
|
|
28
|
+
if (typeof body.secret === 'string' && body.secret.trim()) {
|
|
29
|
+
await setAgentEnvVar(config.name, NOTIFIER_SECRET_VARS[file.channel], body.secret.trim());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
33
|
+
* notifier back on doesn't need it re-entered. */
|
|
34
|
+
export function removeAgentNotifier(config) {
|
|
35
|
+
removeNotifierFile(agentDirFor(config));
|
|
36
|
+
}
|
|
37
|
+
/** Sends a test through whichever notifier applies to this agent —
|
|
38
|
+
* webhook or Slack; reports what the receiver answered. */
|
|
39
|
+
export async function testAgentNotifier(config, defaultWebhook) {
|
|
40
|
+
let target = config.httpNotifier;
|
|
41
|
+
if (!target) {
|
|
42
|
+
const dir = agentDirFor(config);
|
|
43
|
+
const file = readNotifierFile(dir);
|
|
44
|
+
if (file) {
|
|
45
|
+
const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
46
|
+
if (!fromFile)
|
|
47
|
+
return { ok: false, channel: file.channel, error: `${missingSecret} is not set for this agent` };
|
|
48
|
+
target = fromFile;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!target && defaultWebhook) {
|
|
52
|
+
return { channel: 'webhook', ...(await sendTestWebhook(defaultWebhook.webhookUrl, defaultWebhook.webhookSecret, config.name)) };
|
|
53
|
+
}
|
|
54
|
+
if (!target)
|
|
55
|
+
return { ok: false, error: 'This agent has no notifier set up' };
|
|
56
|
+
if (target.channel === 'webhook') {
|
|
57
|
+
return { channel: 'webhook', ...(await sendTestWebhook(target.config.webhookUrl, target.config.webhookSecret, config.name)) };
|
|
58
|
+
}
|
|
59
|
+
if (target.channel === 'slack') {
|
|
60
|
+
return { channel: 'slack', ...(await sendTestSlackMessage(target.config.botToken, target.config.channelId, `Test from LoopEngine: approvals for ${config.name} will be posted here.`)) };
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, channel: target.channel, error: `Send test isn't supported for ${target.channel} yet` };
|
|
63
|
+
}
|
|
64
|
+
/** Every decision still waiting for this agent, oldest first, each with
|
|
65
|
+
* where to send the answer. */
|
|
66
|
+
export async function listAgentPending(store, agentName) {
|
|
67
|
+
const open = await store.listOpen();
|
|
68
|
+
const pending = [];
|
|
69
|
+
for (const checkpoint of open) {
|
|
70
|
+
if (checkpoint.agent !== agentName)
|
|
71
|
+
continue;
|
|
72
|
+
for (const [pendingId, item] of Object.entries(checkpoint.outstanding)) {
|
|
73
|
+
const kind = item.kind ?? 'approval';
|
|
74
|
+
pending.push({
|
|
75
|
+
pendingId,
|
|
76
|
+
kind,
|
|
77
|
+
tool: item.tool,
|
|
78
|
+
args: item.args,
|
|
79
|
+
reason: item.reason,
|
|
80
|
+
sessionId: checkpoint.sessionId,
|
|
81
|
+
tenant: checkpoint.tenant,
|
|
82
|
+
createdAt: checkpoint.createdAt ?? null,
|
|
83
|
+
...(kind === 'question' ? { answerPath: `/pending-questions/${pendingId}/answer` } : { resolvePath: `/pending-approvals/${pendingId}/resolve` }),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return pending;
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
"dist/core/trusted-proxy.d.ts",
|
|
45
45
|
"dist/core/usage.js",
|
|
46
46
|
"dist/core/usage.d.ts",
|
|
47
|
+
"dist/core/notifier-file.js",
|
|
48
|
+
"dist/core/notifier-file.d.ts",
|
|
49
|
+
"dist/core/notifier-deliveries.js",
|
|
50
|
+
"dist/core/notifier-deliveries.d.ts",
|
|
47
51
|
"dist/core/session-store.js",
|
|
48
52
|
"dist/core/session-store.d.ts",
|
|
49
53
|
"dist/core/vector-index.js",
|