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.
Files changed (38) hide show
  1. package/PROTOCOL.md +3 -3
  2. package/README.md +45 -10
  3. package/dist/core/agent-config.d.ts +291 -8
  4. package/dist/core/budget.d.ts +42 -0
  5. package/dist/core/budget.js +39 -0
  6. package/dist/core/compaction.d.ts +43 -0
  7. package/dist/core/compaction.js +70 -0
  8. package/dist/core/http-notifier.d.ts +10 -0
  9. package/dist/core/http-notifier.js +116 -0
  10. package/dist/core/http-notify-triggers/database.d.ts +19 -0
  11. package/dist/core/http-notify-triggers/database.js +53 -0
  12. package/dist/core/http-notify-triggers/email.d.ts +44 -0
  13. package/dist/core/http-notify-triggers/email.js +126 -0
  14. package/dist/core/http-notify-triggers/lark.d.ts +28 -0
  15. package/dist/core/http-notify-triggers/lark.js +168 -0
  16. package/dist/core/http-notify-triggers/redis.d.ts +33 -0
  17. package/dist/core/http-notify-triggers/redis.js +54 -0
  18. package/dist/core/http-notify-triggers/slack.d.ts +21 -0
  19. package/dist/core/http-notify-triggers/slack.js +91 -0
  20. package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
  21. package/dist/core/http-notify-triggers/webhook.js +94 -0
  22. package/dist/core/loop-events.d.ts +30 -6
  23. package/dist/core/model-calls/openai-model-call.js +32 -16
  24. package/dist/core/recovery.d.ts +49 -0
  25. package/dist/core/recovery.js +124 -0
  26. package/dist/core/run-agent.d.ts +141 -13
  27. package/dist/core/run-agent.js +316 -57
  28. package/dist/core/session-store.d.ts +1 -0
  29. package/dist/core/session-store.js +6 -4
  30. package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
  31. package/dist/core/system-tools/ask_user.d.ts +97 -31
  32. package/dist/core/system-tools/ask_user.js +157 -58
  33. package/dist/core/system-tools/index.d.ts +1 -1
  34. package/dist/core/system-tools/index.js +1 -1
  35. package/dist/package.json +6 -0
  36. package/package.json +14 -4
  37. package/protocol/loop-event.schema.json +2 -2
  38. /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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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> &nbsp;|&nbsp; <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(' &nbsp;|&nbsp; ');
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
+ }