loopengine 0.0.21 → 0.1.3
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/PROTOCOL.md +3 -3
- package/README.md +45 -10
- package/dist/core/agent-config.d.ts +291 -8
- package/dist/core/budget.d.ts +42 -0
- package/dist/core/budget.js +39 -0
- package/dist/core/compaction.d.ts +43 -0
- package/dist/core/compaction.js +70 -0
- package/dist/core/http-notifier.d.ts +10 -0
- package/dist/core/http-notifier.js +116 -0
- package/dist/core/http-notify-triggers/database.d.ts +19 -0
- package/dist/core/http-notify-triggers/database.js +53 -0
- package/dist/core/http-notify-triggers/email.d.ts +44 -0
- package/dist/core/http-notify-triggers/email.js +126 -0
- package/dist/core/http-notify-triggers/lark.d.ts +28 -0
- package/dist/core/http-notify-triggers/lark.js +168 -0
- package/dist/core/http-notify-triggers/redis.d.ts +33 -0
- package/dist/core/http-notify-triggers/redis.js +54 -0
- package/dist/core/http-notify-triggers/slack.d.ts +21 -0
- package/dist/core/http-notify-triggers/slack.js +91 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
- package/dist/core/http-notify-triggers/webhook.js +94 -0
- package/dist/core/loop-events.d.ts +30 -6
- package/dist/core/model-calls/openai-model-call.js +32 -16
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +141 -13
- package/dist/core/run-agent.js +316 -57
- package/dist/core/session-store.d.ts +1 -0
- package/dist/core/session-store.js +6 -4
- package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/core/system-tools/ask_user.d.ts +97 -31
- package/dist/core/system-tools/ask_user.js +157 -58
- package/dist/core/system-tools/index.d.ts +1 -1
- package/dist/core/system-tools/index.js +1 -1
- package/dist/package.json +6 -0
- package/package.json +14 -4
- package/protocol/loop-event.schema.json +2 -2
- /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { WebhookNotifier, postLifecycleWebhook } from './http-notify-triggers/webhook.js';
|
|
2
|
+
import { SlackNotifier } from './http-notify-triggers/slack.js';
|
|
3
|
+
import { LarkNotifier } from './http-notify-triggers/lark.js';
|
|
4
|
+
import { EmailNotifier } from './http-notify-triggers/email.js';
|
|
5
|
+
import { DatabaseApprover } from './http-notify-triggers/database.js';
|
|
6
|
+
import { RedisQueueApprover } from './http-notify-triggers/redis.js';
|
|
7
|
+
const EMPTY = {};
|
|
8
|
+
// Keyed by the AgentConfig object itself, not its httpNotifier field
|
|
9
|
+
// alone — resolveHttpNotifier is called on every runAgent()/resumeAgent()
|
|
10
|
+
// call (see run-agent.ts's buildTurnContext), so without this a fresh
|
|
11
|
+
// sender would be constructed per turn for no reason; a config object is
|
|
12
|
+
// created once per agent module and reused for its whole process
|
|
13
|
+
// lifetime, the same assumption discoverAgents' own memoized
|
|
14
|
+
// createModelCall already relies on.
|
|
15
|
+
const cache = new WeakMap();
|
|
16
|
+
/** Chat channels (Slack/Lark) have no signed-payload equivalent to react
|
|
17
|
+
* to for a lifecycle event — nothing to resolve, so just a plain,
|
|
18
|
+
* human-readable announcement string. Shared between the two since the
|
|
19
|
+
* message itself doesn't need to be platform-specific, only how it's
|
|
20
|
+
* sent does. */
|
|
21
|
+
function formatChatLifecycleMessage(event, context) {
|
|
22
|
+
if (event === 'run_start') {
|
|
23
|
+
const trigger = context.trigger === 'resolution' ? 'resumed' : 'started';
|
|
24
|
+
return `${context.agent} ${trigger}${context.sessionId ? ` (session ${context.sessionId})` : ''}`;
|
|
25
|
+
}
|
|
26
|
+
const stopReason = typeof context.stopReason === 'string' ? ` (${context.stopReason})` : '';
|
|
27
|
+
return `${context.agent} finished${stopReason}: ${context.text ?? ''}`;
|
|
28
|
+
}
|
|
29
|
+
function formatEmailLifecycleSubject(event, context) {
|
|
30
|
+
return event === 'run_start' ? `${context.agent} started` : `${context.agent} finished`;
|
|
31
|
+
}
|
|
32
|
+
export function resolveHttpNotifier(config) {
|
|
33
|
+
const httpNotifier = config.httpNotifier;
|
|
34
|
+
if (!httpNotifier)
|
|
35
|
+
return EMPTY;
|
|
36
|
+
const cached = cache.get(config);
|
|
37
|
+
if (cached)
|
|
38
|
+
return cached;
|
|
39
|
+
const events = new Set(httpNotifier.events);
|
|
40
|
+
let resolved;
|
|
41
|
+
switch (httpNotifier.channel) {
|
|
42
|
+
case 'webhook': {
|
|
43
|
+
const { webhookUrl, webhookSecret } = httpNotifier.config;
|
|
44
|
+
// One shared instance for both concerns, same "it's channel-
|
|
45
|
+
// specific, not concern-specific" reasoning every other channel's
|
|
46
|
+
// own file already has — see WebhookNotifier's own doc comment.
|
|
47
|
+
const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret });
|
|
48
|
+
resolved = {
|
|
49
|
+
approver: events.has('approval') ? webhook : undefined,
|
|
50
|
+
questionHandler: events.has('question') ? webhook : undefined,
|
|
51
|
+
onRunStart: events.has('run_start') ? (context) => postLifecycleWebhook(webhookUrl, webhookSecret, 'run_start', context) : undefined,
|
|
52
|
+
onRunFinish: events.has('run_finish') ? (context) => postLifecycleWebhook(webhookUrl, webhookSecret, 'run_finish', context) : undefined,
|
|
53
|
+
};
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'slack': {
|
|
57
|
+
const { botToken, channelId } = httpNotifier.config;
|
|
58
|
+
// One shared instance for every concern this httpNotifier actually
|
|
59
|
+
// covers — same "it's Slack-specific, not approval/question/
|
|
60
|
+
// lifecycle-specific" reasoning SlackNotifier's own doc
|
|
61
|
+
// comment works out; cheap to construct (holds two strings, does no
|
|
62
|
+
// I/O itself) so there's no reason to build one per event kind.
|
|
63
|
+
const slack = new SlackNotifier({ botToken, channelId });
|
|
64
|
+
resolved = {
|
|
65
|
+
approver: events.has('approval') ? slack : undefined,
|
|
66
|
+
questionHandler: events.has('question') ? slack : undefined,
|
|
67
|
+
onRunStart: events.has('run_start') ? (context) => slack.postMessage(formatChatLifecycleMessage('run_start', context)) : undefined,
|
|
68
|
+
onRunFinish: events.has('run_finish') ? (context) => slack.postMessage(formatChatLifecycleMessage('run_finish', context)) : undefined,
|
|
69
|
+
};
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'lark': {
|
|
73
|
+
const { appId, appSecret, chatId } = httpNotifier.config;
|
|
74
|
+
const lark = new LarkNotifier({ appId, appSecret, chatId });
|
|
75
|
+
resolved = {
|
|
76
|
+
approver: events.has('approval') ? lark : undefined,
|
|
77
|
+
questionHandler: events.has('question') ? lark : undefined,
|
|
78
|
+
onRunStart: events.has('run_start') ? (context) => lark.postMessage(formatChatLifecycleMessage('run_start', context)) : undefined,
|
|
79
|
+
onRunFinish: events.has('run_finish') ? (context) => lark.postMessage(formatChatLifecycleMessage('run_finish', context)) : undefined,
|
|
80
|
+
};
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case 'email': {
|
|
84
|
+
const { to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs } = httpNotifier.config;
|
|
85
|
+
const email = new EmailNotifier({ to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs });
|
|
86
|
+
resolved = {
|
|
87
|
+
approver: events.has('approval') ? email : undefined,
|
|
88
|
+
questionHandler: events.has('question') ? email : undefined,
|
|
89
|
+
onRunStart: events.has('run_start')
|
|
90
|
+
? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_start', context), formatChatLifecycleMessage('run_start', context))
|
|
91
|
+
: undefined,
|
|
92
|
+
onRunFinish: events.has('run_finish')
|
|
93
|
+
? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_finish', context), formatChatLifecycleMessage('run_finish', context))
|
|
94
|
+
: undefined,
|
|
95
|
+
};
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'database': {
|
|
99
|
+
// Approval-only by construction (AgentConfig.ApprovalOnlyHttpNotifierEvent) —
|
|
100
|
+
// no questionHandler/lifecycle sender exists for this channel at
|
|
101
|
+
// all, see DatabaseApprover's own doc comment for why.
|
|
102
|
+
resolved = { approver: events.has('approval') ? new DatabaseApprover(httpNotifier.config.repository) : undefined };
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'redis': {
|
|
106
|
+
resolved = {
|
|
107
|
+
approver: events.has('approval')
|
|
108
|
+
? new RedisQueueApprover({ redis: httpNotifier.config.redis, queueKey: httpNotifier.config.queueKey })
|
|
109
|
+
: undefined,
|
|
110
|
+
};
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
cache.set(config, resolved);
|
|
115
|
+
return resolved;
|
|
116
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DurableApprover, Scope } from 'actauth';
|
|
2
|
+
import type { PendingApprovalRow, PendingApprovalsRepository } from '../agent-config.js';
|
|
3
|
+
export declare class DatabaseApprover implements DurableApprover {
|
|
4
|
+
private readonly repository;
|
|
5
|
+
constructor(repository: PendingApprovalsRepository);
|
|
6
|
+
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
7
|
+
pendingId: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Illustrative stand-in for a real database — swap for your own
|
|
11
|
+
* Postgres/MySQL/SQLite/whatever client implementing the same insert()
|
|
12
|
+
* signature. Exists so this file (and AgentConfig.httpNotifier's
|
|
13
|
+
* `channel: 'database'`) is genuinely runnable/testable without standing
|
|
14
|
+
* up a real database first, the same reason actauth's own ConsoleApprover
|
|
15
|
+
* exists — not something meant for production use. */
|
|
16
|
+
export declare class InMemoryPendingApprovalsRepository implements PendingApprovalsRepository {
|
|
17
|
+
readonly rows: PendingApprovalRow[];
|
|
18
|
+
insert(row: PendingApprovalRow): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A DurableApprover backed by a database row instead of a notification —
|
|
2
|
+
// the "writing straight to a database" option AgentConfig.httpNotifier's
|
|
3
|
+
// `channel: 'database'` constructs. Deliberately not a notification
|
|
4
|
+
// channel at all: nothing gets told anything — a human finds this row by
|
|
5
|
+
// querying it (a cron job, an admin dashboard) — so there's no
|
|
6
|
+
// DurableQuestionHandler sibling or lifecycle sender to merge in the way
|
|
7
|
+
// webhook/Slack/Lark/email have (see AgentConfig.ApprovalOnlyHttpNotifierEvent's
|
|
8
|
+
// own doc comment).
|
|
9
|
+
//
|
|
10
|
+
// Deliberately driver-agnostic — no Postgres/MySQL/SQLite client is a
|
|
11
|
+
// dependency here, so PendingApprovalsRepository (AgentConfig.ts) is the
|
|
12
|
+
// seam: implement insert() against whatever real database/ORM you
|
|
13
|
+
// already have (a single INSERT, a Prisma/Drizzle create call, anything),
|
|
14
|
+
// and DatabaseApprover needs nothing else from it.
|
|
15
|
+
//
|
|
16
|
+
// Whatever eventually reads this table (a cron job, an admin dashboard
|
|
17
|
+
// query) still has to end up calling POST
|
|
18
|
+
// /pending-approvals/:pendingId/resolve to actually decide it — this
|
|
19
|
+
// class, like WebhookApprover, only owns creating the pending record;
|
|
20
|
+
// "what happens next" is the same checkpoint/resolve machinery
|
|
21
|
+
// regardless of which DurableApprover produced the pendingId.
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
export class DatabaseApprover {
|
|
24
|
+
repository;
|
|
25
|
+
constructor(repository) {
|
|
26
|
+
this.repository = repository;
|
|
27
|
+
}
|
|
28
|
+
requestDurableApproval(tool, args, scope, reason) {
|
|
29
|
+
const pendingId = randomUUID();
|
|
30
|
+
const row = { pendingId, tool, args, scope, reason, requestedAt: new Date().toISOString() };
|
|
31
|
+
// Not awaited — same contract WebhookApprover follows (see its own
|
|
32
|
+
// doc comment): requestDurableApproval returns immediately, by
|
|
33
|
+
// design, so an insert failure here can only be logged, not thrown —
|
|
34
|
+
// the decision has already, correctly, been recorded as pending
|
|
35
|
+
// regardless of whether this write succeeds.
|
|
36
|
+
this.repository.insert(row).catch((err) => {
|
|
37
|
+
console.error(`[loopengine] DatabaseApprover: insert failed for pendingId '${pendingId}':`, err);
|
|
38
|
+
});
|
|
39
|
+
return { pendingId };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Illustrative stand-in for a real database — swap for your own
|
|
43
|
+
* Postgres/MySQL/SQLite/whatever client implementing the same insert()
|
|
44
|
+
* signature. Exists so this file (and AgentConfig.httpNotifier's
|
|
45
|
+
* `channel: 'database'`) is genuinely runnable/testable without standing
|
|
46
|
+
* up a real database first, the same reason actauth's own ConsoleApprover
|
|
47
|
+
* exists — not something meant for production use. */
|
|
48
|
+
export class InMemoryPendingApprovalsRepository {
|
|
49
|
+
rows = [];
|
|
50
|
+
async insert(row) {
|
|
51
|
+
this.rows.push(row);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DurableApprover, Scope } from 'actauth';
|
|
2
|
+
import type { DurableQuestionHandler, SendEmail } from '../agent-config.js';
|
|
3
|
+
export interface EmailLinkPayload {
|
|
4
|
+
pendingId: string;
|
|
5
|
+
/** Present for an approval link (`'approve' | 'deny'`) or a
|
|
6
|
+
* suggested-option question link (the option text itself, resolves on
|
|
7
|
+
* click, no form needed); absent for a question's "type your own
|
|
8
|
+
* answer" link (the receiving side renders a form instead). */
|
|
9
|
+
decision?: 'approve' | 'deny';
|
|
10
|
+
answer?: string;
|
|
11
|
+
exp: number;
|
|
12
|
+
}
|
|
13
|
+
/** Returns the decoded payload, or null for a missing/tampered/expired
|
|
14
|
+
* token — graceful, distinguishable failure, not a thrown exception for
|
|
15
|
+
* the merely-expired case. Exported for the receiving-side example (see
|
|
16
|
+
* this file's own header comment for why it imports this instead of
|
|
17
|
+
* reimplementing it). */
|
|
18
|
+
export declare function verifyMagicLink(token: string, signingSecret: string): EmailLinkPayload | null;
|
|
19
|
+
export declare class EmailNotifier implements DurableApprover, DurableQuestionHandler {
|
|
20
|
+
private readonly to;
|
|
21
|
+
private readonly sendEmail;
|
|
22
|
+
private readonly resolveBaseUrl;
|
|
23
|
+
private readonly answerBaseUrl;
|
|
24
|
+
private readonly signingSecret;
|
|
25
|
+
private readonly linkTtlMs;
|
|
26
|
+
constructor(options: {
|
|
27
|
+
to: string;
|
|
28
|
+
sendEmail: SendEmail;
|
|
29
|
+
resolveBaseUrl: string;
|
|
30
|
+
answerBaseUrl: string;
|
|
31
|
+
signingSecret: string;
|
|
32
|
+
linkTtlMs?: number;
|
|
33
|
+
});
|
|
34
|
+
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
35
|
+
pendingId: string;
|
|
36
|
+
};
|
|
37
|
+
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
|
|
38
|
+
pendingId: string;
|
|
39
|
+
};
|
|
40
|
+
/** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
|
|
41
|
+
* sender for `channel: 'email'` calls this directly — a plain
|
|
42
|
+
* announcement, no magic link, since there's nothing to resolve. */
|
|
43
|
+
sendAnnouncement(subject: string, text: string): void;
|
|
44
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The *sending* half of an email-backed DurableApprover/DurableQuestionHandler,
|
|
2
|
+
// real, maintained core/ code so AgentConfig.httpNotifier
|
|
3
|
+
// (core/http-notifier.ts) can construct one directly for `channel:
|
|
4
|
+
// 'email'`. Takes a `sendEmail` function (AgentConfig.SendEmail) rather
|
|
5
|
+
// than hardcoding one provider — real email needs an SMTP client or
|
|
6
|
+
// provider SDK, a dependency this package deliberately doesn't carry; a
|
|
7
|
+
// minimal Resend-backed one is in
|
|
8
|
+
// examples/notifier-handler/email.ts's own createExampleResendSendEmail,
|
|
9
|
+
// illustrative only.
|
|
10
|
+
//
|
|
11
|
+
// The *receiving* side — turning a clicked link (or submitted form) back
|
|
12
|
+
// into a `POST /pending-approvals/:id/resolve`/`POST /pending-questions/:id/answer`
|
|
13
|
+
// call — deliberately stays out of core: that's an HTTP endpoint only the
|
|
14
|
+
// host can deploy. See examples/notifier-handler/email.ts's own
|
|
15
|
+
// `handleEmailApprovalClick`/`handleEmailQuestionAnswerPage`/
|
|
16
|
+
// `handleEmailQuestionAnswerSubmit` for that half — those import
|
|
17
|
+
// `verifyMagicLink` from *this* file rather than reimplementing it, since
|
|
18
|
+
// unlike Slack/Lark (where the platform itself signs the callback), email
|
|
19
|
+
// has no such mechanism: this file's own `signMagicLink` and the
|
|
20
|
+
// receiving side's verification have to agree on one exact algorithm, not
|
|
21
|
+
// two copies that could drift.
|
|
22
|
+
//
|
|
23
|
+
// The one thing that makes email a genuinely different integration than
|
|
24
|
+
// Slack/Lark, not just "a different send API": a clicked email link is a
|
|
25
|
+
// plain browser GET request — there's no interactivity payload, no
|
|
26
|
+
// signature header a chat platform would attach, nothing stopping a
|
|
27
|
+
// copied or forwarded link from being replayed. `pendingId` alone isn't
|
|
28
|
+
// enough for a link that might sit in an inbox for days — this is exactly
|
|
29
|
+
// the "signed, expiring token per link, with real revocation... still
|
|
30
|
+
// unbuilt" gap HUMAN_IN_THE_LOOP.md's own open questions list — so this
|
|
31
|
+
// file builds one: a small HMAC-signed, expiring token embedded in the
|
|
32
|
+
// link itself, verified before ever touching loopengine's own
|
|
33
|
+
// resolve/answer routes.
|
|
34
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
35
|
+
function signMagicLink(payload, expiresInMs, signingSecret) {
|
|
36
|
+
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + expiresInMs })).toString('base64url');
|
|
37
|
+
const signature = createHmac('sha256', signingSecret).update(body).digest('base64url');
|
|
38
|
+
return `${body}.${signature}`;
|
|
39
|
+
}
|
|
40
|
+
/** Returns the decoded payload, or null for a missing/tampered/expired
|
|
41
|
+
* token — graceful, distinguishable failure, not a thrown exception for
|
|
42
|
+
* the merely-expired case. Exported for the receiving-side example (see
|
|
43
|
+
* this file's own header comment for why it imports this instead of
|
|
44
|
+
* reimplementing it). */
|
|
45
|
+
export function verifyMagicLink(token, signingSecret) {
|
|
46
|
+
const [body, signature] = token.split('.');
|
|
47
|
+
if (!body || !signature)
|
|
48
|
+
return null;
|
|
49
|
+
const expected = createHmac('sha256', signingSecret).update(body).digest('base64url');
|
|
50
|
+
const expectedBuf = Buffer.from(expected);
|
|
51
|
+
const actualBuf = Buffer.from(signature);
|
|
52
|
+
if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf))
|
|
53
|
+
return null;
|
|
54
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
55
|
+
if (Date.now() > payload.exp)
|
|
56
|
+
return null;
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
/** Both the outbound email body here and the receiving side's own
|
|
60
|
+
* confirmation pages interpolate values that ultimately trace back to
|
|
61
|
+
* the model or a human (tool args, a reason, a resumed turn's own reply
|
|
62
|
+
* text) — escaped before ever reaching an HTML template, same as any
|
|
63
|
+
* other untrusted-input-into-HTML boundary. */
|
|
64
|
+
function escapeHtml(s) {
|
|
65
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
66
|
+
}
|
|
67
|
+
export class EmailNotifier {
|
|
68
|
+
to;
|
|
69
|
+
sendEmail;
|
|
70
|
+
resolveBaseUrl; // e.g. https://yourapp.com/email/approvals — YOUR route, not loopengine's own address
|
|
71
|
+
answerBaseUrl; // e.g. https://yourapp.com/email/questions — YOUR route, not loopengine's own address
|
|
72
|
+
signingSecret;
|
|
73
|
+
linkTtlMs;
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.to = options.to;
|
|
76
|
+
this.sendEmail = options.sendEmail;
|
|
77
|
+
this.resolveBaseUrl = options.resolveBaseUrl;
|
|
78
|
+
this.answerBaseUrl = options.answerBaseUrl;
|
|
79
|
+
this.signingSecret = options.signingSecret;
|
|
80
|
+
this.linkTtlMs = options.linkTtlMs ?? 3 * 24 * 60 * 60_000; // 3 days — long enough for someone to actually check email, short enough that a leaked link doesn't stay live forever
|
|
81
|
+
}
|
|
82
|
+
requestDurableApproval(tool, args, scope, reason) {
|
|
83
|
+
const pendingId = randomUUID();
|
|
84
|
+
const approveLink = `${this.resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'approve' }, this.linkTtlMs, this.signingSecret)}`;
|
|
85
|
+
const denyLink = `${this.resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'deny' }, this.linkTtlMs, this.signingSecret)}`;
|
|
86
|
+
// Not awaited — requestDurableApproval returns immediately, by
|
|
87
|
+
// contract (see DurableApprover's own doc comment). Logged instead of
|
|
88
|
+
// thrown on failure.
|
|
89
|
+
this.sendEmail(this.to, `Approval requested: ${tool}`, `<p><b>Tool:</b> ${escapeHtml(tool)}</p>` +
|
|
90
|
+
`<p><b>Args:</b> ${escapeHtml(JSON.stringify(args))}</p>` +
|
|
91
|
+
`<p><b>Scope:</b> ${escapeHtml(`${scope.tenant}/${scope.environment}/${scope.agent}`)}</p>` +
|
|
92
|
+
`<p><b>Reason:</b> ${escapeHtml(reason)}</p>` +
|
|
93
|
+
`<p><a href="${approveLink}">Approve</a> | <a href="${denyLink}">Deny</a></p>` +
|
|
94
|
+
`<p style="color:#888;font-size:12px">This link expires in ${Math.round(this.linkTtlMs / 3_600_000)} hours.</p>`).catch((err) => {
|
|
95
|
+
console.error(`[loopengine] EmailNotifier: sendEmail failed for pendingId '${pendingId}':`, err);
|
|
96
|
+
});
|
|
97
|
+
return { pendingId };
|
|
98
|
+
}
|
|
99
|
+
notifyPendingQuestion(question, options, agent, sessionId) {
|
|
100
|
+
const pendingId = randomUUID();
|
|
101
|
+
const optionLinks = (options ?? [])
|
|
102
|
+
.map((option) => {
|
|
103
|
+
const token = signMagicLink({ pendingId, answer: option }, this.linkTtlMs, this.signingSecret);
|
|
104
|
+
return `<a href="${this.answerBaseUrl}?token=${token}">${escapeHtml(option)}</a>`;
|
|
105
|
+
})
|
|
106
|
+
.join(' | ');
|
|
107
|
+
const freeTextToken = signMagicLink({ pendingId }, this.linkTtlMs, this.signingSecret);
|
|
108
|
+
const freeTextLink = `${this.answerBaseUrl}?token=${freeTextToken}`;
|
|
109
|
+
this.sendEmail(this.to, `Question from ${agent}`, `<p>${escapeHtml(question)}</p>` +
|
|
110
|
+
(sessionId ? `<p style="color:#888;font-size:12px">Session: ${escapeHtml(sessionId)}</p>` : '') +
|
|
111
|
+
(optionLinks ? `<p>${optionLinks}</p>` : '') +
|
|
112
|
+
`<p><a href="${freeTextLink}">${optionLinks ? 'Or answer in your own words' : 'Answer'}</a></p>` +
|
|
113
|
+
`<p style="color:#888;font-size:12px">This link expires in ${Math.round(this.linkTtlMs / 3_600_000)} hours.</p>`).catch((err) => {
|
|
114
|
+
console.error(`[loopengine] EmailNotifier: sendEmail failed for pendingId '${pendingId}':`, err);
|
|
115
|
+
});
|
|
116
|
+
return { pendingId };
|
|
117
|
+
}
|
|
118
|
+
/** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
|
|
119
|
+
* sender for `channel: 'email'` calls this directly — a plain
|
|
120
|
+
* announcement, no magic link, since there's nothing to resolve. */
|
|
121
|
+
sendAnnouncement(subject, text) {
|
|
122
|
+
this.sendEmail(this.to, subject, `<p>${escapeHtml(text)}</p>`).catch((err) => {
|
|
123
|
+
console.error('[loopengine] EmailNotifier: sendEmail failed for lifecycle announcement:', err);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DurableApprover, Scope } from 'actauth';
|
|
2
|
+
import type { DurableQuestionHandler } from '../agent-config.js';
|
|
3
|
+
/** Fixed id for the question form card's own input field — used both
|
|
4
|
+
* when *building* the card (notifyPendingQuestion below) and when
|
|
5
|
+
* *reading* the human's answer back out of the callback payload (the
|
|
6
|
+
* receiving-side example); must match exactly. */
|
|
7
|
+
export declare const LARK_ANSWER_FIELD_TAG = "answer_input";
|
|
8
|
+
export declare class LarkNotifier implements DurableApprover, DurableQuestionHandler {
|
|
9
|
+
private readonly appId;
|
|
10
|
+
private readonly appSecret;
|
|
11
|
+
private readonly chatId;
|
|
12
|
+
constructor(options: {
|
|
13
|
+
appId: string;
|
|
14
|
+
appSecret: string;
|
|
15
|
+
chatId: string;
|
|
16
|
+
});
|
|
17
|
+
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
18
|
+
pendingId: string;
|
|
19
|
+
};
|
|
20
|
+
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
|
|
21
|
+
pendingId: string;
|
|
22
|
+
};
|
|
23
|
+
/** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
|
|
24
|
+
* lifecycle sender for `channel: 'lark'` calls this directly — a plain
|
|
25
|
+
* text announcement, no buttons, since there's nothing to resolve. */
|
|
26
|
+
postMessage(text: string): void;
|
|
27
|
+
private sendCard;
|
|
28
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The *sending* half of a Lark/Feishu-backed DurableApprover/DurableQuestionHandler,
|
|
2
|
+
// real, maintained core/ code so AgentConfig.httpNotifier
|
|
3
|
+
// (core/http-notifier.ts) can construct one directly for `channel:
|
|
4
|
+
// 'lark'`, the same way it already does for `channel: 'slack'`
|
|
5
|
+
// (./slack.ts's own SlackNotifier — read that file first, this one
|
|
6
|
+
// mirrors its reasoning for why approval/question/lifecycle concerns
|
|
7
|
+
// merge into one class rather than staying split).
|
|
8
|
+
//
|
|
9
|
+
// The *receiving* side — verifying a Lark card-callback payload really
|
|
10
|
+
// came from your app, then dispatching approve/deny/answer back into
|
|
11
|
+
// loopengine's own resolve/answer routes — deliberately stays out of
|
|
12
|
+
// core: that's an HTTP endpoint only the host can deploy. See
|
|
13
|
+
// examples/notifier-handler/lark.ts's own `handleLarkInteraction` for
|
|
14
|
+
// that half.
|
|
15
|
+
//
|
|
16
|
+
// Confidence note, stated plainly rather than glossed over: Lark's Open
|
|
17
|
+
// Platform API has had more than one card-callback convention over time
|
|
18
|
+
// (a plain `verification_token` string check is the longest-standing,
|
|
19
|
+
// most widely documented one — see the receiving-side example for where
|
|
20
|
+
// that check actually happens). Verify which your app is configured for
|
|
21
|
+
// at https://open.feishu.cn/document before relying on this in
|
|
22
|
+
// production; the request shapes below (tenant_access_token, the
|
|
23
|
+
// im/v1/messages call, the button value round-trip) are stable regardless
|
|
24
|
+
// of which verification method you end up using on the receiving side.
|
|
25
|
+
//
|
|
26
|
+
// The free-text answer (for a question) is the one genuinely tricky
|
|
27
|
+
// part, worth being honest about: Slack's own answer (a modal, opened via
|
|
28
|
+
// a separate API call keyed off a one-time trigger_id) has no direct Lark
|
|
29
|
+
// equivalent used here. This uses a "form" card (an `input` element
|
|
30
|
+
// inside an `action`/`form` container, submitted as one callback with all
|
|
31
|
+
// field values together) — lower confidence than the rest of this file:
|
|
32
|
+
// Lark's card schema has changed across versions, so verify the exact
|
|
33
|
+
// `tag`/field names below against https://open.feishu.cn/document for
|
|
34
|
+
// your app's configured card version before relying on this. The other
|
|
35
|
+
// real option — replying in-thread, captured via the
|
|
36
|
+
// `im.message.receive_v1` event — needs your own durable
|
|
37
|
+
// message_id -> pendingId mapping (an in-memory Map is NOT enough if
|
|
38
|
+
// sending and receiving ever run in different processes), deliberately
|
|
39
|
+
// not the approach used here.
|
|
40
|
+
import { randomUUID } from 'node:crypto';
|
|
41
|
+
/** Fixed id for the question form card's own input field — used both
|
|
42
|
+
* when *building* the card (notifyPendingQuestion below) and when
|
|
43
|
+
* *reading* the human's answer back out of the callback payload (the
|
|
44
|
+
* receiving-side example); must match exactly. */
|
|
45
|
+
export const LARK_ANSWER_FIELD_TAG = 'answer_input';
|
|
46
|
+
let cachedToken;
|
|
47
|
+
async function getTenantAccessToken(appId, appSecret) {
|
|
48
|
+
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000)
|
|
49
|
+
return cachedToken.token;
|
|
50
|
+
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
54
|
+
});
|
|
55
|
+
const body = (await res.json());
|
|
56
|
+
if (body.code !== 0)
|
|
57
|
+
throw new Error(`getTenantAccessToken failed: ${body.msg}`);
|
|
58
|
+
cachedToken = { token: body.tenant_access_token, expiresAt: Date.now() + body.expire * 1000 };
|
|
59
|
+
return cachedToken.token;
|
|
60
|
+
}
|
|
61
|
+
export class LarkNotifier {
|
|
62
|
+
appId;
|
|
63
|
+
appSecret;
|
|
64
|
+
chatId;
|
|
65
|
+
constructor(options) {
|
|
66
|
+
this.appId = options.appId;
|
|
67
|
+
this.appSecret = options.appSecret;
|
|
68
|
+
this.chatId = options.chatId;
|
|
69
|
+
}
|
|
70
|
+
requestDurableApproval(tool, args, scope, reason) {
|
|
71
|
+
const pendingId = randomUUID();
|
|
72
|
+
void (async () => {
|
|
73
|
+
try {
|
|
74
|
+
const token = await getTenantAccessToken(this.appId, this.appSecret);
|
|
75
|
+
const card = {
|
|
76
|
+
config: { wide_screen_mode: true },
|
|
77
|
+
header: { title: { tag: 'plain_text', content: 'Approval requested' } },
|
|
78
|
+
elements: [
|
|
79
|
+
{
|
|
80
|
+
tag: 'div',
|
|
81
|
+
text: {
|
|
82
|
+
tag: 'lark_md',
|
|
83
|
+
content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}`,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
tag: 'action',
|
|
88
|
+
actions: [
|
|
89
|
+
{ tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
|
|
90
|
+
{ tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
await this.sendCard(token, card, pendingId);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`[loopengine] LarkNotifier: send message failed for pendingId '${pendingId}':`, err);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return { pendingId };
|
|
102
|
+
}
|
|
103
|
+
notifyPendingQuestion(question, options, agent, sessionId) {
|
|
104
|
+
const pendingId = randomUUID();
|
|
105
|
+
const optionButtons = (options ?? []).map((option) => ({
|
|
106
|
+
tag: 'button',
|
|
107
|
+
text: { tag: 'plain_text', content: option },
|
|
108
|
+
value: { pendingId, answer: option },
|
|
109
|
+
}));
|
|
110
|
+
void (async () => {
|
|
111
|
+
try {
|
|
112
|
+
const token = await getTenantAccessToken(this.appId, this.appSecret);
|
|
113
|
+
const card = {
|
|
114
|
+
config: { wide_screen_mode: true },
|
|
115
|
+
header: { title: { tag: 'plain_text', content: `Question from ${agent}` } },
|
|
116
|
+
elements: [
|
|
117
|
+
{ tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') } },
|
|
118
|
+
...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
|
|
119
|
+
{
|
|
120
|
+
tag: 'form',
|
|
121
|
+
name: 'answer_form',
|
|
122
|
+
elements: [
|
|
123
|
+
{ tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
|
|
124
|
+
{
|
|
125
|
+
tag: 'action',
|
|
126
|
+
actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
await this.sendCard(token, card, pendingId);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error(`[loopengine] LarkNotifier: send message failed for pendingId '${pendingId}':`, err);
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
return { pendingId };
|
|
139
|
+
}
|
|
140
|
+
/** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
|
|
141
|
+
* lifecycle sender for `channel: 'lark'` calls this directly — a plain
|
|
142
|
+
* text announcement, no buttons, since there's nothing to resolve. */
|
|
143
|
+
postMessage(text) {
|
|
144
|
+
void (async () => {
|
|
145
|
+
try {
|
|
146
|
+
const token = await getTenantAccessToken(this.appId, this.appSecret);
|
|
147
|
+
const card = {
|
|
148
|
+
config: { wide_screen_mode: true },
|
|
149
|
+
elements: [{ tag: 'div', text: { tag: 'lark_md', content: text } }],
|
|
150
|
+
};
|
|
151
|
+
await this.sendCard(token, card, 'lifecycle');
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
console.error('[loopengine] LarkNotifier: send message failed for lifecycle announcement:', err);
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
}
|
|
158
|
+
async sendCard(token, card, pendingId) {
|
|
159
|
+
const res = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' },
|
|
162
|
+
body: JSON.stringify({ receive_id: this.chatId, msg_type: 'interactive', content: JSON.stringify(card) }),
|
|
163
|
+
});
|
|
164
|
+
const body = (await res.json());
|
|
165
|
+
if (body.code !== 0)
|
|
166
|
+
console.error(`[loopengine] LarkNotifier: send message failed for pendingId '${pendingId}':`, body.msg);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
import type { DurableApprover, Scope } from 'actauth';
|
|
3
|
+
export interface QueuedApproval {
|
|
4
|
+
pendingId: string;
|
|
5
|
+
tool: string;
|
|
6
|
+
args: Record<string, unknown>;
|
|
7
|
+
scope: Scope;
|
|
8
|
+
reason: string;
|
|
9
|
+
requestedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RedisQueueApproverOptions {
|
|
12
|
+
redis: Redis;
|
|
13
|
+
/** Default 'durable-approvals:queue' — override if you're running more
|
|
14
|
+
* than one of these against the same Redis instance and want separate
|
|
15
|
+
* queues (e.g. one per agent, or per severity). */
|
|
16
|
+
queueKey?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class RedisQueueApprover implements DurableApprover {
|
|
19
|
+
private readonly redis;
|
|
20
|
+
private readonly queueKey;
|
|
21
|
+
constructor(options: RedisQueueApproverOptions);
|
|
22
|
+
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
23
|
+
pendingId: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Worker-side companion — drains the queue this approver pushes onto,
|
|
27
|
+
* one entry at a time, blocking (BLPOP) rather than polling. Illustrative
|
|
28
|
+
* only: a real worker would take each QueuedApproval here and do
|
|
29
|
+
* something with it (post to Slack, send an email, write a row somewhere
|
|
30
|
+
* an admin UI reads from) before the human eventually hits
|
|
31
|
+
* POST /pending-approvals/:pendingId/resolve — this function only gets
|
|
32
|
+
* you the queued entries themselves, not a notification channel. */
|
|
33
|
+
export declare function consumePendingApprovals(redis: Redis, queueKey?: string): AsyncGenerator<QueuedApproval>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A DurableApprover backed by a Redis list instead of a notification —
|
|
2
|
+
// the "pushing to a queue" option AgentConfig.httpNotifier's `channel:
|
|
3
|
+
// 'redis'` constructs. Uses `ioredis`, already a real dependency here
|
|
4
|
+
// (core/session-store.ts's own RedisSessionStore), so no new dependency
|
|
5
|
+
// to add. Approval-only, deliberately — see ./database.ts's own header
|
|
6
|
+
// comment for why this isn't a "notification channel" with a
|
|
7
|
+
// question-handler/lifecycle-sender sibling to merge in.
|
|
8
|
+
//
|
|
9
|
+
// Whatever drains the queue (consumePendingApprovals below, or your own
|
|
10
|
+
// worker) still has to end up calling POST
|
|
11
|
+
// /pending-approvals/:pendingId/resolve to actually decide it — this
|
|
12
|
+
// class, like WebhookApprover, only owns creating the pending record
|
|
13
|
+
// and one notification side effect; "what happens next" is the same
|
|
14
|
+
// checkpoint/resolve machinery regardless of which DurableApprover
|
|
15
|
+
// produced the pendingId.
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
export class RedisQueueApprover {
|
|
18
|
+
redis;
|
|
19
|
+
queueKey;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.redis = options.redis;
|
|
22
|
+
this.queueKey = options.queueKey ?? 'durable-approvals:queue';
|
|
23
|
+
}
|
|
24
|
+
requestDurableApproval(tool, args, scope, reason) {
|
|
25
|
+
const pendingId = randomUUID();
|
|
26
|
+
const entry = { pendingId, tool, args, scope, reason, requestedAt: new Date().toISOString() };
|
|
27
|
+
// Not awaited — same contract WebhookApprover follows (see its own
|
|
28
|
+
// doc comment): requestDurableApproval returns immediately, by
|
|
29
|
+
// design, so a delivery failure here can only be logged, not thrown
|
|
30
|
+
// — the decision has already, correctly, been recorded as pending
|
|
31
|
+
// regardless of whether this push succeeds.
|
|
32
|
+
this.redis.rpush(this.queueKey, JSON.stringify(entry)).catch((err) => {
|
|
33
|
+
console.error(`[loopengine] RedisQueueApprover: rpush failed for pendingId '${pendingId}':`, err);
|
|
34
|
+
});
|
|
35
|
+
return { pendingId };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Worker-side companion — drains the queue this approver pushes onto,
|
|
39
|
+
* one entry at a time, blocking (BLPOP) rather than polling. Illustrative
|
|
40
|
+
* only: a real worker would take each QueuedApproval here and do
|
|
41
|
+
* something with it (post to Slack, send an email, write a row somewhere
|
|
42
|
+
* an admin UI reads from) before the human eventually hits
|
|
43
|
+
* POST /pending-approvals/:pendingId/resolve — this function only gets
|
|
44
|
+
* you the queued entries themselves, not a notification channel. */
|
|
45
|
+
export async function* consumePendingApprovals(redis, queueKey = 'durable-approvals:queue') {
|
|
46
|
+
for (;;) {
|
|
47
|
+
// 0 = block forever. BLPOP's own reply is [key, value] | null (null
|
|
48
|
+
// only on a client-side timeout, which never happens here).
|
|
49
|
+
const result = await redis.blpop(queueKey, 0);
|
|
50
|
+
if (!result)
|
|
51
|
+
continue;
|
|
52
|
+
yield JSON.parse(result[1]);
|
|
53
|
+
}
|
|
54
|
+
}
|