loopengine 0.1.94 → 0.1.97
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 +21 -2
- package/dist/bin/ability-manager.d.ts +4 -0
- package/dist/bin/ability-manager.js +34 -2
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +3 -0
- package/dist/core/ability-details.d.ts +31 -0
- package/dist/core/ability-details.js +140 -0
- package/dist/core/ability-sources.d.ts +59 -0
- package/dist/core/ability-sources.js +312 -0
- package/dist/core/agent-config.d.ts +21 -1
- package/dist/core/discover-agents.js +11 -1
- package/dist/core/http-notifier.d.ts +7 -1
- package/dist/core/http-notifier.js +68 -9
- package/dist/core/http-notify-triggers/email.d.ts +2 -2
- package/dist/core/http-notify-triggers/email.js +2 -4
- package/dist/core/http-notify-triggers/lark.d.ts +2 -2
- package/dist/core/http-notify-triggers/lark.js +2 -4
- package/dist/core/http-notify-triggers/slack.d.ts +2 -2
- package/dist/core/http-notify-triggers/slack.js +2 -4
- package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
- package/dist/core/http-notify-triggers/telegram.js +71 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
- package/dist/core/http-notify-triggers/webhook.js +2 -4
- package/dist/core/model-calls/meta-model-call.d.ts +15 -0
- package/dist/core/model-calls/meta-model-call.js +48 -0
- package/dist/core/model-calls/openai-model-call.d.ts +5 -0
- package/dist/core/model-calls/openai-model-call.js +47 -1
- package/dist/core/model-calls/xai-model-call.d.ts +15 -0
- package/dist/core/model-calls/xai-model-call.js +45 -0
- package/dist/core/notifier-file.d.ts +40 -6
- package/dist/core/notifier-file.js +119 -25
- package/dist/core/system-agents/workflow-planner.d.ts +100 -0
- package/dist/core/system-agents/workflow-planner.js +278 -0
- package/dist/core/workflows.d.ts +26 -0
- package/dist/core/workflows.js +39 -0
- package/dist/core/workspace-abilities.d.ts +31 -0
- package/dist/core/workspace-abilities.js +110 -0
- package/dist/index.d.ts +18 -4
- package/dist/index.js +17 -3
- package/dist/web/ability-details-admin.d.ts +17 -0
- package/dist/web/ability-details-admin.js +45 -0
- package/dist/web/ability-sources-admin.d.ts +33 -0
- package/dist/web/ability-sources-admin.js +65 -0
- package/dist/web/agent-file-admin.d.ts +1 -1
- package/dist/web/agent-file-admin.js +3 -0
- package/dist/web/agents-config-page.js +608 -224
- package/dist/web/agents-list-page.js +49 -20
- package/dist/web/console-shell.js +248 -7
- package/dist/web/dev-ui-styles.js +13 -1
- package/dist/web/global-config-page.js +403 -81
- package/dist/web/global-config.d.ts +44 -1
- package/dist/web/global-config.js +162 -2
- package/dist/web/interview-admin.js +2 -2
- package/dist/web/messaging-icons.d.ts +2 -0
- package/dist/web/messaging-icons.js +7 -0
- package/dist/web/notifier-admin.d.ts +33 -15
- package/dist/web/notifier-admin.js +109 -65
- package/dist/web/provider-icons.d.ts +2 -0
- package/dist/web/provider-icons.js +10 -0
- package/dist/web/templates-admin.d.ts +3 -0
- package/dist/web/templates-admin.js +5 -1
- package/dist/web/workflow-admin.d.ts +124 -0
- package/dist/web/workflow-admin.js +308 -0
- package/dist/web/workflow-page.d.ts +1 -0
- package/dist/web/workflow-page.js +226 -0
- package/dist/web/workspace-abilities-admin.d.ts +24 -0
- package/dist/web/workspace-abilities-admin.js +118 -0
- package/package.json +13 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The sending half of a Telegram-backed DurableApprover/DurableQuestionHandler,
|
|
2
|
+
// so AgentConfig.httpNotifier (core/http-notifier.ts) can construct one
|
|
3
|
+
// for `channel: 'telegram'`, the way it does for Slack and Lark (read
|
|
4
|
+
// ./lark.ts first; this mirrors it).
|
|
5
|
+
//
|
|
6
|
+
// Posts through the Bot API's sendMessage (core.telegram.org/bots/api)
|
|
7
|
+
// to one chat: a group, a channel the bot can post in, or a person who
|
|
8
|
+
// has started the bot. Like a console-configured Lark notifier, each
|
|
9
|
+
// request carries a link to the decision page rather than buttons:
|
|
10
|
+
// inline-keyboard callbacks would need a webhook this server receives
|
|
11
|
+
// and verifies, which nothing here sets up yet. Plain text, no
|
|
12
|
+
// parse_mode, so tool names and arguments never need escaping.
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { decidePath } from '../approval-links.js';
|
|
15
|
+
import { publicBaseUrl } from './webhook.js';
|
|
16
|
+
// Telegram's limit for one message's text.
|
|
17
|
+
const MAX_TEXT = 4096;
|
|
18
|
+
async function sendTelegramMessage(botToken, chatId, text) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ chat_id: chatId, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text, disable_web_page_preview: true }),
|
|
24
|
+
});
|
|
25
|
+
const body = (await res.json().catch(() => ({})));
|
|
26
|
+
return body.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: body.description || `HTTP ${res.status}` };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The Messaging tab's Send test. */
|
|
33
|
+
export function sendTestTelegramMessage(botToken, chatId, text) {
|
|
34
|
+
return sendTelegramMessage(botToken, chatId, text);
|
|
35
|
+
}
|
|
36
|
+
export class TelegramNotifier {
|
|
37
|
+
botToken;
|
|
38
|
+
chatId;
|
|
39
|
+
publicUrl;
|
|
40
|
+
/** `publicUrl`: same meaning as SlackNotifier's — each message links to
|
|
41
|
+
* the decision page when the server's address is known. */
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.botToken = options.botToken;
|
|
44
|
+
this.chatId = options.chatId;
|
|
45
|
+
this.publicUrl = options.publicUrl;
|
|
46
|
+
}
|
|
47
|
+
decideLine(pendingId, kind) {
|
|
48
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
49
|
+
return base ? `\n\n${kind === 'approval' ? 'Approve or deny' : 'Answer'}: ${base}${decidePath(pendingId, kind)}` : '';
|
|
50
|
+
}
|
|
51
|
+
send(text, label) {
|
|
52
|
+
void sendTelegramMessage(this.botToken, this.chatId, text).then((result) => {
|
|
53
|
+
if (!result.ok)
|
|
54
|
+
console.error(`[loopengine] TelegramNotifier: send message failed for ${label}:`, result.error);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
|
|
58
|
+
this.send(`Approval requested\n\ntool: ${tool}\nargs: ${JSON.stringify(args)}\nscope: ${scope.tenant}/${scope.environment}/${scope.agent}\nreason: ${reason}${this.decideLine(pendingId, 'approval')}`, `pendingId '${pendingId}'`);
|
|
59
|
+
return { pendingId };
|
|
60
|
+
}
|
|
61
|
+
notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
|
|
62
|
+
const choices = options?.length ? `\n\nOptions: ${options.join(' / ')}` : '';
|
|
63
|
+
this.send(`Question from ${agent}\n\n${question}${choices}${sessionId ? `\n(session ${sessionId})` : ''}${this.decideLine(pendingId, 'question')}`, `pendingId '${pendingId}'`);
|
|
64
|
+
return { pendingId };
|
|
65
|
+
}
|
|
66
|
+
/** A plain announcement (run started/finished, a scheduled task's
|
|
67
|
+
* result), with nothing to decide. */
|
|
68
|
+
postMessage(text) {
|
|
69
|
+
this.send(text, 'an announcement');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -22,10 +22,10 @@ export declare class WebhookNotifier implements DurableApprover, DurableQuestion
|
|
|
22
22
|
retryDelaysMs?: number[];
|
|
23
23
|
publicUrl?: string;
|
|
24
24
|
});
|
|
25
|
-
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
25
|
+
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string, pendingId?: string): {
|
|
26
26
|
pendingId: string;
|
|
27
27
|
};
|
|
28
|
-
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
|
|
28
|
+
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, pendingId?: string): {
|
|
29
29
|
pendingId: string;
|
|
30
30
|
};
|
|
31
31
|
}
|
|
@@ -104,8 +104,7 @@ export class WebhookNotifier {
|
|
|
104
104
|
this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
105
105
|
this.publicUrl = options.publicUrl;
|
|
106
106
|
}
|
|
107
|
-
requestDurableApproval(tool, args, scope, reason) {
|
|
108
|
-
const pendingId = randomUUID();
|
|
107
|
+
requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
|
|
109
108
|
const requestedAt = new Date().toISOString();
|
|
110
109
|
const resolvePath = `/pending-approvals/${pendingId}/resolve`;
|
|
111
110
|
const decide = decidePath(pendingId, 'approval');
|
|
@@ -119,8 +118,7 @@ export class WebhookNotifier {
|
|
|
119
118
|
void deliver(this.webhookUrl, body, { 'X-Actauth-Signature': sign(body, this.signingSecret) }, { agent: scope.agent, kind: 'approval', pendingId }, this.retryDelaysMs);
|
|
120
119
|
return { pendingId };
|
|
121
120
|
}
|
|
122
|
-
notifyPendingQuestion(question, options, agent, sessionId) {
|
|
123
|
-
const pendingId = randomUUID();
|
|
121
|
+
notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
|
|
124
122
|
const requestedAt = new Date().toISOString();
|
|
125
123
|
const answerPath = `/pending-questions/${pendingId}/answer`;
|
|
126
124
|
const decide = decidePath(pendingId, 'question');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import type { ModelCall } from '#core/run-agent.js';
|
|
3
|
+
export interface MetaModelCallOptions {
|
|
4
|
+
/** Defaults to the META_API_KEY env var. */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
/** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
|
|
7
|
+
* pass the one you actually want. */
|
|
8
|
+
model: string;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
/** Defaults to Meta's own Model API — override to point at a proxy/mirror. */
|
|
11
|
+
baseURL?: string;
|
|
12
|
+
/** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
|
|
13
|
+
client?: OpenAI;
|
|
14
|
+
}
|
|
15
|
+
export declare function createMetaModelCall(options: MetaModelCallOptions): ModelCall;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// An eighth real ModelCall implementation, for Meta's Muse models
|
|
2
|
+
// (muse-spark-1.3 and later) through the Meta Model API. Its
|
|
3
|
+
// /chat/completions endpoint is OpenAI-compatible, function calling
|
|
4
|
+
// included, so this reuses openai-model-call.ts's translation verbatim via
|
|
5
|
+
// the real `openai` SDK client pointed at https://api.meta.ai/v1, same
|
|
6
|
+
// pattern deepseek/kimi/glm/gemini-model-call.ts established.
|
|
7
|
+
//
|
|
8
|
+
// META_API_KEY is the name LiteLLM uses; Meta's own examples read a
|
|
9
|
+
// generic MODEL_API_KEY, too vague next to the other providers' keys.
|
|
10
|
+
// Uses `max_completion_tokens`: Meta accepts `max_tokens` only as a
|
|
11
|
+
// deprecated alias. The Model API was a US-only public preview when this
|
|
12
|
+
// was written.
|
|
13
|
+
import OpenAI from 'openai';
|
|
14
|
+
import { toMessageParams, toModelResponse } from './openai-model-call.js';
|
|
15
|
+
export function createMetaModelCall(options) {
|
|
16
|
+
if (!options.client && !options.apiKey && !process.env.META_API_KEY) {
|
|
17
|
+
// Fails here, with a Meta-specific message, rather than letting the
|
|
18
|
+
// OpenAI SDK's own constructor validation reject it — that error says
|
|
19
|
+
// "set OPENAI_API_KEY", which would send someone who never touched
|
|
20
|
+
// that variable chasing the wrong fix.
|
|
21
|
+
throw new Error('createMetaModelCall: no apiKey given and META_API_KEY is not set.');
|
|
22
|
+
}
|
|
23
|
+
const client = options.client ??
|
|
24
|
+
new OpenAI({
|
|
25
|
+
// Explicitly null, not undefined, if neither is set — see
|
|
26
|
+
// kimi-model-call.ts's comment for why this matters even
|
|
27
|
+
// though the guard above already rules out reaching here with no
|
|
28
|
+
// key at all.
|
|
29
|
+
apiKey: options.apiKey ?? process.env.META_API_KEY ?? null,
|
|
30
|
+
baseURL: options.baseURL ?? 'https://api.meta.ai/v1',
|
|
31
|
+
});
|
|
32
|
+
const maxTokens = options.maxTokens ?? 4096;
|
|
33
|
+
return async (messages, system, tools) => {
|
|
34
|
+
const response = await client.chat.completions.create({
|
|
35
|
+
model: options.model,
|
|
36
|
+
max_completion_tokens: maxTokens,
|
|
37
|
+
messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
|
|
38
|
+
tools: tools.length
|
|
39
|
+
? tools.map((t) => ({
|
|
40
|
+
type: 'function',
|
|
41
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
42
|
+
}))
|
|
43
|
+
: undefined,
|
|
44
|
+
});
|
|
45
|
+
const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
|
|
46
|
+
return { ...toModelResponse(response.choices[0]), usage };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -22,6 +22,11 @@ export interface OpenAIModelCallOptions {
|
|
|
22
22
|
/** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
|
|
23
23
|
client?: OpenAI;
|
|
24
24
|
}
|
|
25
|
+
/** A tool call's arguments. Some OpenAI-compatible models now and then
|
|
26
|
+
* add something after the JSON object (a second copy, a stray character):
|
|
27
|
+
* the first complete object is used then. Arguments cut off part-way still
|
|
28
|
+
* throw, so the caller can ask again. */
|
|
29
|
+
export declare function parseToolArguments(raw: string): Record<string, unknown>;
|
|
25
30
|
export declare function toMessageParams(message: Message): ChatMessage[];
|
|
26
31
|
export declare function toStopReason(finishReason: string): string;
|
|
27
32
|
export declare function toModelResponse(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): ModelResponse;
|
|
@@ -25,6 +25,52 @@
|
|
|
25
25
|
// limit field name, which is only decided at the request-building call
|
|
26
26
|
// site below, not in these shared translation functions.
|
|
27
27
|
import OpenAI from 'openai';
|
|
28
|
+
/** A tool call's arguments. Some OpenAI-compatible models now and then
|
|
29
|
+
* add something after the JSON object (a second copy, a stray character):
|
|
30
|
+
* the first complete object is used then. Arguments cut off part-way still
|
|
31
|
+
* throw, so the caller can ask again. */
|
|
32
|
+
export function parseToolArguments(raw) {
|
|
33
|
+
const text = (raw ?? '').trim();
|
|
34
|
+
if (!text)
|
|
35
|
+
return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const end = firstObjectEnd(text);
|
|
41
|
+
if (end > 0 && end < text.length)
|
|
42
|
+
return JSON.parse(text.slice(0, end));
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Where the first top-level JSON object in `text` ends (after its closing
|
|
47
|
+
* brace), or -1 when it doesn't close. */
|
|
48
|
+
function firstObjectEnd(text) {
|
|
49
|
+
if (text[0] !== '{')
|
|
50
|
+
return -1;
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let inString = false;
|
|
53
|
+
let escaped = false;
|
|
54
|
+
for (let i = 0; i < text.length; i++) {
|
|
55
|
+
const c = text[i];
|
|
56
|
+
if (inString) {
|
|
57
|
+
if (escaped)
|
|
58
|
+
escaped = false;
|
|
59
|
+
else if (c === '\\')
|
|
60
|
+
escaped = true;
|
|
61
|
+
else if (c === '"')
|
|
62
|
+
inString = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (c === '"')
|
|
66
|
+
inString = true;
|
|
67
|
+
else if (c === '{')
|
|
68
|
+
depth++;
|
|
69
|
+
else if (c === '}' && --depth === 0)
|
|
70
|
+
return i + 1;
|
|
71
|
+
}
|
|
72
|
+
return -1;
|
|
73
|
+
}
|
|
28
74
|
export function toMessageParams(message) {
|
|
29
75
|
if (typeof message.content === 'string') {
|
|
30
76
|
return [{ role: message.role === 'assistant' ? 'assistant' : 'user', content: message.content }];
|
|
@@ -101,7 +147,7 @@ export function toModelResponse(choice) {
|
|
|
101
147
|
type: 'tool_use',
|
|
102
148
|
id: call.id,
|
|
103
149
|
name: call.function.name,
|
|
104
|
-
input:
|
|
150
|
+
input: parseToolArguments(call.function.arguments),
|
|
105
151
|
});
|
|
106
152
|
}
|
|
107
153
|
return { stop_reason: toStopReason(choice.finish_reason), content };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import type { ModelCall } from '#core/run-agent.js';
|
|
3
|
+
export interface XaiModelCallOptions {
|
|
4
|
+
/** Defaults to the XAI_API_KEY env var. */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
/** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
|
|
7
|
+
* pass the one you actually want. */
|
|
8
|
+
model: string;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
/** Defaults to xAI's own API — override to point at a proxy/mirror. */
|
|
11
|
+
baseURL?: string;
|
|
12
|
+
/** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
|
|
13
|
+
client?: OpenAI;
|
|
14
|
+
}
|
|
15
|
+
export declare function createXaiModelCall(options: XaiModelCallOptions): ModelCall;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A seventh real ModelCall implementation. xAI's API (Grok models) is
|
|
2
|
+
// OpenAI-compatible: its own docs (docs.x.ai) point the OpenAI SDK at
|
|
3
|
+
// https://api.x.ai/v1 with an xAI key, same messages/tools/tool_choice
|
|
4
|
+
// request shape and tool_calls response shape. So this reuses
|
|
5
|
+
// openai-model-call.ts's translation verbatim via the real `openai` SDK
|
|
6
|
+
// client, same pattern deepseek/kimi/glm/gemini-model-call.ts established.
|
|
7
|
+
//
|
|
8
|
+
// Uses `max_completion_tokens`: xAI documents `max_tokens` as deprecated,
|
|
9
|
+
// as OpenAI does.
|
|
10
|
+
import OpenAI from 'openai';
|
|
11
|
+
import { toMessageParams, toModelResponse } from './openai-model-call.js';
|
|
12
|
+
export function createXaiModelCall(options) {
|
|
13
|
+
if (!options.client && !options.apiKey && !process.env.XAI_API_KEY) {
|
|
14
|
+
// Fails here, with a xAI-specific message, rather than letting the
|
|
15
|
+
// OpenAI SDK's own constructor validation reject it — that error says
|
|
16
|
+
// "set OPENAI_API_KEY", which would send someone who never touched
|
|
17
|
+
// that variable chasing the wrong fix.
|
|
18
|
+
throw new Error('createXaiModelCall: no apiKey given and XAI_API_KEY is not set.');
|
|
19
|
+
}
|
|
20
|
+
const client = options.client ??
|
|
21
|
+
new OpenAI({
|
|
22
|
+
// Explicitly null, not undefined, if neither is set — see
|
|
23
|
+
// kimi-model-call.ts's comment for why this matters even
|
|
24
|
+
// though the guard above already rules out reaching here with no
|
|
25
|
+
// key at all.
|
|
26
|
+
apiKey: options.apiKey ?? process.env.XAI_API_KEY ?? null,
|
|
27
|
+
baseURL: options.baseURL ?? 'https://api.x.ai/v1',
|
|
28
|
+
});
|
|
29
|
+
const maxTokens = options.maxTokens ?? 4096;
|
|
30
|
+
return async (messages, system, tools) => {
|
|
31
|
+
const response = await client.chat.completions.create({
|
|
32
|
+
model: options.model,
|
|
33
|
+
max_completion_tokens: maxTokens,
|
|
34
|
+
messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
|
|
35
|
+
tools: tools.length
|
|
36
|
+
? tools.map((t) => ({
|
|
37
|
+
type: 'function',
|
|
38
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
39
|
+
}))
|
|
40
|
+
: undefined,
|
|
41
|
+
});
|
|
42
|
+
const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
|
|
43
|
+
return { ...toModelResponse(response.choices[0]), usage };
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentEnv, HttpNotifierConfig, HttpNotifierEvent } from '#core/agent-config.js';
|
|
2
2
|
import { type EmailProvider } from '#core/email-senders.js';
|
|
3
|
-
export type NotifierFileChannel = 'webhook' | 'slack' | 'lark' | 'email';
|
|
3
|
+
export type NotifierFileChannel = 'webhook' | 'telegram' | 'slack' | 'lark' | 'email';
|
|
4
4
|
export interface NotifierFile {
|
|
5
5
|
channel: NotifierFileChannel;
|
|
6
6
|
events: HttpNotifierEvent[];
|
|
@@ -8,7 +8,8 @@ export interface NotifierFile {
|
|
|
8
8
|
url?: string;
|
|
9
9
|
/** slack: channel to post into. */
|
|
10
10
|
channelId?: string;
|
|
11
|
-
/** lark: app id and chat to post into.
|
|
11
|
+
/** lark: app id and chat to post into. telegram: the chat to post
|
|
12
|
+
* into (a numeric id like -1001234567890, or @channelname). */
|
|
12
13
|
appId?: string;
|
|
13
14
|
chatId?: string;
|
|
14
15
|
/** email: who receives requests (comma-separated), who they come from
|
|
@@ -40,6 +41,12 @@ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
|
40
41
|
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
41
42
|
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
42
43
|
export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
|
|
44
|
+
/** In the order the Messaging tab lists them: the messaging apps, then
|
|
45
|
+
* the webhook. */
|
|
46
|
+
export declare const NOTIFIER_CHANNELS: NotifierFileChannel[];
|
|
47
|
+
/** The apps that take turns as primary. The webhook isn't one: it always
|
|
48
|
+
* sends once set up. */
|
|
49
|
+
export declare const PRIMARY_CHANNELS: NotifierFileChannel[];
|
|
43
50
|
export declare class NotifierFileError extends Error {
|
|
44
51
|
}
|
|
45
52
|
/** The older single-connection file, still read when messaging.yml is absent. */
|
|
@@ -47,11 +54,38 @@ export declare function notifierFilePath(dir: string): string;
|
|
|
47
54
|
export declare function messagingFilePath(dir: string): string;
|
|
48
55
|
/** Throws NotifierFileError naming the first problem. */
|
|
49
56
|
export declare function validateNotifierFile(input: unknown): NotifierFile;
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
export interface MessagingConnections {
|
|
58
|
+
/** At most one per app, in the order saved. */
|
|
59
|
+
connections: NotifierFile[];
|
|
60
|
+
/** The messaging app in use, if any (never the webhook). */
|
|
61
|
+
primary?: NotifierFileChannel;
|
|
62
|
+
}
|
|
63
|
+
/** Every saved connection and which messaging app is primary:
|
|
64
|
+
* messaging.yml's, else the older notifier.yml's one connection. */
|
|
65
|
+
export declare function readMessagingConnections(dir: string): MessagingConnections;
|
|
66
|
+
/** The connections that send: the webhook when one is set up, then the
|
|
67
|
+
* primary messaging app. */
|
|
68
|
+
export declare function readActiveConnections(dir: string): NotifierFile[];
|
|
69
|
+
/** The agent's main connection: the primary messaging app, else the
|
|
70
|
+
* webhook; undefined when neither is set up. It's the one the console
|
|
71
|
+
* reports, and the one Slack's replies and approvers go by. Use
|
|
72
|
+
* readActiveConnections for everything that sends. */
|
|
52
73
|
export declare function readNotifierFile(dir: string): NotifierFile | undefined;
|
|
53
|
-
/**
|
|
54
|
-
|
|
74
|
+
/** Saves one app's connection, replacing that app's earlier one and
|
|
75
|
+
* keeping the others. The first messaging app saved becomes primary;
|
|
76
|
+
* `makePrimary` makes this one primary too (a webhook never is: it
|
|
77
|
+
* always sends). Writes messaging.yml (and drops an older notifier.yml
|
|
78
|
+
* it replaces). */
|
|
79
|
+
export declare function writeNotifierFile(dir: string, input: unknown, options?: {
|
|
80
|
+
makePrimary?: boolean;
|
|
81
|
+
}): NotifierFile;
|
|
82
|
+
/** Makes a saved connection the one in use. */
|
|
83
|
+
export declare function setPrimaryConnection(dir: string, channel: string): void;
|
|
84
|
+
/** Removes one app's connection. Removing the primary leaves no
|
|
85
|
+
* messaging app primary, rather than quietly moving requests to another
|
|
86
|
+
* one. */
|
|
87
|
+
export declare function removeMessagingConnection(dir: string, channel: string): void;
|
|
88
|
+
/** Removes every connection. */
|
|
55
89
|
export declare function removeNotifierFile(dir: string): void;
|
|
56
90
|
/** The HttpNotifierConfig a connection describes, with its secret read
|
|
57
91
|
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
@@ -4,21 +4,31 @@
|
|
|
4
4
|
// in code. Only consulted when the agent's own AgentConfig.httpNotifier
|
|
5
5
|
// is unset; code always wins.
|
|
6
6
|
//
|
|
7
|
-
// The file holds
|
|
8
|
-
// for
|
|
7
|
+
// The file holds at most one connection per app, set up independently
|
|
8
|
+
// from the tab's own section for each. A webhook always sends once it's
|
|
9
|
+
// set up. Of the messaging apps (Telegram, Slack, Lark, email), only the
|
|
10
|
+
// primary one does: requests, announcements and Slack's replies go
|
|
11
|
+
// through it, and the others stay saved but idle until made primary. So
|
|
12
|
+
// a request can go to the webhook and the primary app both, under one
|
|
13
|
+
// request id.
|
|
9
14
|
//
|
|
15
|
+
// primary: slack
|
|
10
16
|
// connections:
|
|
11
17
|
// - app: slack
|
|
12
18
|
// channelId: C0123456
|
|
13
19
|
// events: [approval, question]
|
|
14
20
|
// conversations: true
|
|
21
|
+
// - app: telegram
|
|
22
|
+
// chatId: "-1001234567890"
|
|
23
|
+
// events: [approval, question]
|
|
15
24
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
25
|
+
// A file without `primary:` (written before there was one) uses its
|
|
26
|
+
// first messaging app; `primary:` left empty means none is. An older
|
|
27
|
+
// agents/<name>/notifier.yml (one connection, `channel:` for `app:`) is
|
|
28
|
+
// still read; saving from the console replaces it with messaging.yml.
|
|
19
29
|
//
|
|
20
30
|
// The file holds only non-secret settings. The one secret each channel
|
|
21
|
-
// needs (a webhook signing secret, a Slack bot token, a Lark app secret, an email provider's API key)
|
|
31
|
+
// needs (a webhook signing secret, a Slack or Telegram bot token, a Lark app secret, an email provider's API key)
|
|
22
32
|
// lives in the agent's own env (agents/<name>/.env by default, through
|
|
23
33
|
// core/secret-store.ts) under a fixed name, so it's never written into a
|
|
24
34
|
// file that might get committed.
|
|
@@ -29,6 +39,7 @@ import { EMAIL_PROVIDERS, createProviderSendEmail, parseAddress, parseRecipients
|
|
|
29
39
|
/** The env var each channel's secret is read from, per agent. */
|
|
30
40
|
export const NOTIFIER_SECRET_VARS = {
|
|
31
41
|
webhook: 'LOOPENGINE_NOTIFIER_WEBHOOK_SECRET',
|
|
42
|
+
telegram: 'LOOPENGINE_NOTIFIER_TELEGRAM_BOT_TOKEN',
|
|
32
43
|
slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
|
|
33
44
|
lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
|
|
34
45
|
email: 'LOOPENGINE_NOTIFIER_EMAIL_API_KEY',
|
|
@@ -37,7 +48,14 @@ export const NOTIFIER_SECRET_VARS = {
|
|
|
37
48
|
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
38
49
|
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
39
50
|
export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
|
|
40
|
-
|
|
51
|
+
/** In the order the Messaging tab lists them: the messaging apps, then
|
|
52
|
+
* the webhook. */
|
|
53
|
+
export const NOTIFIER_CHANNELS = ['slack', 'telegram', 'lark', 'email', 'webhook'];
|
|
54
|
+
/** The apps that take turns as primary. The webhook isn't one: it always
|
|
55
|
+
* sends once set up. */
|
|
56
|
+
export const PRIMARY_CHANNELS = ['telegram', 'slack', 'lark', 'email'];
|
|
57
|
+
const canBePrimary = (channel) => PRIMARY_CHANNELS.includes(channel);
|
|
58
|
+
const CHANNELS = NOTIFIER_CHANNELS;
|
|
41
59
|
const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
|
|
42
60
|
export class NotifierFileError extends Error {
|
|
43
61
|
}
|
|
@@ -101,6 +119,14 @@ export function validateNotifierFile(input) {
|
|
|
101
119
|
throw new NotifierFileError('channelId is required for slack');
|
|
102
120
|
file.channelId = channelId;
|
|
103
121
|
}
|
|
122
|
+
else if (channel === 'telegram') {
|
|
123
|
+
// Strings even when numeric: YAML would read -1001234567890 as a
|
|
124
|
+
// number, so it's quoted when written and accepted either way.
|
|
125
|
+
const chatId = typeof raw.chatId === 'number' ? String(raw.chatId) : str('chatId');
|
|
126
|
+
if (!chatId || !/^(-?[0-9]{1,20}|@[A-Za-z0-9_]{4,64})$/.test(chatId))
|
|
127
|
+
throw new NotifierFileError('chatId must be a Telegram chat id, like -1001234567890, or @channelname');
|
|
128
|
+
file.chatId = chatId;
|
|
129
|
+
}
|
|
104
130
|
else if (channel === 'lark') {
|
|
105
131
|
const appId = str('appId');
|
|
106
132
|
const chatId = str('chatId');
|
|
@@ -126,38 +152,103 @@ export function validateNotifierFile(input) {
|
|
|
126
152
|
}
|
|
127
153
|
return file;
|
|
128
154
|
}
|
|
129
|
-
/**
|
|
130
|
-
*
|
|
131
|
-
export function
|
|
155
|
+
/** Every saved connection and which messaging app is primary:
|
|
156
|
+
* messaging.yml's, else the older notifier.yml's one connection. */
|
|
157
|
+
export function readMessagingConnections(dir) {
|
|
132
158
|
const messagingPath = messagingFilePath(dir);
|
|
133
159
|
if (existsSync(messagingPath)) {
|
|
134
160
|
const parsed = parseYaml(readFileSync(messagingPath, 'utf8'));
|
|
135
161
|
if (parsed?.connections !== undefined && !Array.isArray(parsed.connections))
|
|
136
162
|
throw new NotifierFileError('connections must be a list');
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
163
|
+
const connections = [];
|
|
164
|
+
for (const entry of parsed?.connections ?? []) {
|
|
165
|
+
if (!entry || typeof entry !== 'object')
|
|
166
|
+
throw new NotifierFileError('each connection must be an object');
|
|
167
|
+
const { app, ...rest } = entry;
|
|
168
|
+
const file = validateNotifierFile({ channel: app, ...rest });
|
|
169
|
+
if (!connections.some((c) => c.channel === file.channel))
|
|
170
|
+
connections.push(file);
|
|
171
|
+
}
|
|
172
|
+
// No `primary:` at all is a file from before there was one: its first
|
|
173
|
+
// messaging app was the one used, so it still is.
|
|
174
|
+
const hasPrimaryKey = parsed !== null && typeof parsed === 'object' && 'primary' in parsed;
|
|
175
|
+
const named = hasPrimaryKey ? parsed.primary : connections.find((c) => canBePrimary(c.channel))?.channel;
|
|
176
|
+
const primary = canBePrimary(named) ? connections.find((c) => c.channel === named)?.channel : undefined;
|
|
177
|
+
return primary ? { connections, primary } : { connections };
|
|
144
178
|
}
|
|
145
179
|
const path = notifierFilePath(dir);
|
|
146
180
|
if (!existsSync(path))
|
|
147
|
-
return
|
|
148
|
-
|
|
181
|
+
return { connections: [] };
|
|
182
|
+
const file = validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
|
|
183
|
+
return canBePrimary(file.channel) ? { connections: [file], primary: file.channel } : { connections: [file] };
|
|
149
184
|
}
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const {
|
|
185
|
+
/** The connections that send: the webhook when one is set up, then the
|
|
186
|
+
* primary messaging app. */
|
|
187
|
+
export function readActiveConnections(dir) {
|
|
188
|
+
const { connections, primary } = readMessagingConnections(dir);
|
|
189
|
+
const webhook = connections.find((c) => c.channel === 'webhook');
|
|
190
|
+
const app = connections.find((c) => c.channel === primary);
|
|
191
|
+
return [...(webhook ? [webhook] : []), ...(app ? [app] : [])];
|
|
192
|
+
}
|
|
193
|
+
/** The agent's main connection: the primary messaging app, else the
|
|
194
|
+
* webhook; undefined when neither is set up. It's the one the console
|
|
195
|
+
* reports, and the one Slack's replies and approvers go by. Use
|
|
196
|
+
* readActiveConnections for everything that sends. */
|
|
197
|
+
export function readNotifierFile(dir) {
|
|
198
|
+
const { connections, primary } = readMessagingConnections(dir);
|
|
199
|
+
return connections.find((c) => c.channel === primary) ?? connections.find((c) => c.channel === 'webhook');
|
|
200
|
+
}
|
|
201
|
+
function writeMessagingConnections(dir, { connections, primary }) {
|
|
202
|
+
if (!connections.length) {
|
|
203
|
+
removeNotifierFile(dir);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
154
206
|
const header = '# Where this agent sends approval requests and questions when it is called over HTTP,\n' +
|
|
155
207
|
'# and whether it answers replies there. Edited from the console (Messaging tab).\n' +
|
|
156
|
-
|
|
157
|
-
|
|
208
|
+
'# A webhook always sends; of the messaging apps, only the primary one does.\n' +
|
|
209
|
+
'# Each secret lives in this agent\'s env: ' + connections.map((c) => NOTIFIER_SECRET_VARS[c.channel]).join(', ') + '.\n';
|
|
210
|
+
const body = { primary: primary ?? null, connections: connections.map(({ channel, ...rest }) => ({ app: channel, ...rest })) };
|
|
211
|
+
writeFileSync(messagingFilePath(dir), header + stringifyYaml(body));
|
|
158
212
|
rmSync(notifierFilePath(dir), { force: true });
|
|
213
|
+
}
|
|
214
|
+
/** Saves one app's connection, replacing that app's earlier one and
|
|
215
|
+
* keeping the others. The first messaging app saved becomes primary;
|
|
216
|
+
* `makePrimary` makes this one primary too (a webhook never is: it
|
|
217
|
+
* always sends). Writes messaging.yml (and drops an older notifier.yml
|
|
218
|
+
* it replaces). */
|
|
219
|
+
export function writeNotifierFile(dir, input, options = {}) {
|
|
220
|
+
const file = validateNotifierFile(input);
|
|
221
|
+
const current = readMessagingConnections(dir);
|
|
222
|
+
const index = current.connections.findIndex((c) => c.channel === file.channel);
|
|
223
|
+
const connections = [...current.connections];
|
|
224
|
+
if (index === -1)
|
|
225
|
+
connections.push(file);
|
|
226
|
+
else
|
|
227
|
+
connections[index] = file;
|
|
228
|
+
const primary = canBePrimary(file.channel) && (options.makePrimary || !current.primary) ? file.channel : current.primary;
|
|
229
|
+
writeMessagingConnections(dir, { connections, primary });
|
|
159
230
|
return file;
|
|
160
231
|
}
|
|
232
|
+
/** Makes a saved connection the one in use. */
|
|
233
|
+
export function setPrimaryConnection(dir, channel) {
|
|
234
|
+
if (!canBePrimary(channel))
|
|
235
|
+
throw new NotifierFileError(`Only a messaging app (${PRIMARY_CHANNELS.join(', ')}) can be primary; a webhook always sends once set up`);
|
|
236
|
+
const current = readMessagingConnections(dir);
|
|
237
|
+
if (!current.connections.some((c) => c.channel === channel))
|
|
238
|
+
throw new NotifierFileError(`Set up ${channel} before making it primary`);
|
|
239
|
+
writeMessagingConnections(dir, { ...current, primary: channel });
|
|
240
|
+
}
|
|
241
|
+
/** Removes one app's connection. Removing the primary leaves no
|
|
242
|
+
* messaging app primary, rather than quietly moving requests to another
|
|
243
|
+
* one. */
|
|
244
|
+
export function removeMessagingConnection(dir, channel) {
|
|
245
|
+
const current = readMessagingConnections(dir);
|
|
246
|
+
writeMessagingConnections(dir, {
|
|
247
|
+
connections: current.connections.filter((c) => c.channel !== channel),
|
|
248
|
+
primary: current.primary === channel ? undefined : current.primary,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Removes every connection. */
|
|
161
252
|
export function removeNotifierFile(dir) {
|
|
162
253
|
rmSync(messagingFilePath(dir), { force: true });
|
|
163
254
|
rmSync(notifierFilePath(dir), { force: true });
|
|
@@ -196,6 +287,9 @@ export function notifierConfigFromFile(file, env, agentName = '') {
|
|
|
196
287
|
// No card buttons: nothing built in handles Lark's card callbacks yet,
|
|
197
288
|
// so a console-configured Lark notifier relies on the decision link.
|
|
198
289
|
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
|
|
290
|
+
case 'telegram':
|
|
291
|
+
// Links to the decision page, like Lark without buttons.
|
|
292
|
+
return { config: { channel: 'telegram', config: { botToken: secret, chatId: file.chatId, publicUrl: file.publicUrl }, events: file.events } };
|
|
199
293
|
case 'email':
|
|
200
294
|
// Links to the decision page, like Lark without buttons; the secret
|
|
201
295
|
// is the provider's API key.
|