loopengine 0.0.20 → 0.1.2

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 (39) hide show
  1. package/PROTOCOL.md +245 -0
  2. package/README.md +53 -10
  3. package/dist/core/agent-config.d.ts +282 -3
  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 +60 -29
  23. package/dist/core/model-calls/openai-model-call.js +33 -17
  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 +143 -15
  27. package/dist/core/run-agent.js +321 -66
  28. package/dist/core/session-store.d.ts +2 -1
  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/core/vector-index.js +1 -1
  36. package/dist/package.json +6 -0
  37. package/package.json +16 -6
  38. package/protocol/loop-event.schema.json +257 -0
  39. /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
@@ -0,0 +1,42 @@
1
+ export interface Message {
2
+ role: string;
3
+ content: string;
4
+ }
5
+ export type CheckAction = 'ok' | 'nudge' | 'over_hard_limit';
6
+ export interface CheckResult {
7
+ action: CheckAction;
8
+ usedTokens: number;
9
+ budgetTokens: number;
10
+ ratio: number;
11
+ /** Present only when action is 'nudge' — the host decides whether and
12
+ * how to inject it. */
13
+ nudge?: Message;
14
+ }
15
+ /** ~4 chars/token — a common rough heuristic, good enough for a budget
16
+ * gate rather than exact accounting. Same heuristic used across the
17
+ * sibling ActAuth/SkillGarden projects for consistency. */
18
+ export declare function estimateTokens(text: string): number;
19
+ export interface BudgetTrackerOptions {
20
+ budgetTokens: number;
21
+ /** Fraction of budget that triggers a nudge. Default 0.7. Also the
22
+ * recovery target compaction.ts's own Compactor uses — the two are
23
+ * constructed with the same threshold so a caller's "start wrapping
24
+ * up" and "how far compaction tries to shrink" agree, not two
25
+ * independently-configured numbers that could drift apart. */
26
+ softThreshold?: number;
27
+ /** Fraction of budget that requires recovery. Default 0.92. */
28
+ hardThreshold?: number;
29
+ }
30
+ /** Tracks budget usage over a generic message array — read-only, never
31
+ * mutates or recovers. See compaction.ts's Compactor for what a caller
32
+ * does once this reports over_hard_limit. */
33
+ export declare class BudgetTracker {
34
+ readonly budgetTokens: number;
35
+ readonly softThreshold: number;
36
+ private readonly hardThreshold;
37
+ constructor(options: BudgetTrackerOptions);
38
+ estimateUsage(messages: Message[]): number;
39
+ /** Returns a nudge message for the host to inject if usage has crossed
40
+ * the soft threshold but not yet the hard one. */
41
+ check(messages: Message[]): CheckResult;
42
+ }
@@ -0,0 +1,39 @@
1
+ /** ~4 chars/token — a common rough heuristic, good enough for a budget
2
+ * gate rather than exact accounting. Same heuristic used across the
3
+ * sibling ActAuth/SkillGarden projects for consistency. */
4
+ export function estimateTokens(text) {
5
+ return Math.ceil(text.length / 4);
6
+ }
7
+ const NUDGE_MESSAGE = {
8
+ role: 'system',
9
+ content: 'The conversation is approaching its context budget. Wrap up the current step concisely.',
10
+ };
11
+ /** Tracks budget usage over a generic message array — read-only, never
12
+ * mutates or recovers. See compaction.ts's Compactor for what a caller
13
+ * does once this reports over_hard_limit. */
14
+ export class BudgetTracker {
15
+ budgetTokens;
16
+ softThreshold;
17
+ hardThreshold;
18
+ constructor(options) {
19
+ this.budgetTokens = options.budgetTokens;
20
+ this.softThreshold = options.softThreshold ?? 0.7;
21
+ this.hardThreshold = options.hardThreshold ?? 0.92;
22
+ }
23
+ estimateUsage(messages) {
24
+ return messages.reduce((sum, m) => sum + estimateTokens(`${m.role}: ${m.content}`), 0);
25
+ }
26
+ /** Returns a nudge message for the host to inject if usage has crossed
27
+ * the soft threshold but not yet the hard one. */
28
+ check(messages) {
29
+ const usedTokens = this.estimateUsage(messages);
30
+ const ratio = usedTokens / this.budgetTokens;
31
+ if (ratio >= this.hardThreshold) {
32
+ return { action: 'over_hard_limit', usedTokens, budgetTokens: this.budgetTokens, ratio };
33
+ }
34
+ if (ratio >= this.softThreshold) {
35
+ return { action: 'nudge', usedTokens, budgetTokens: this.budgetTokens, ratio, nudge: NUDGE_MESSAGE };
36
+ }
37
+ return { action: 'ok', usedTokens, budgetTokens: this.budgetTokens, ratio };
38
+ }
39
+ }
@@ -0,0 +1,43 @@
1
+ import { type Message } from './budget.js';
2
+ export interface Summarizer {
3
+ summarize(messages: Message[]): Promise<Message>;
4
+ }
5
+ /** A real, working default — no model call. Concatenates and truncates
6
+ * rather than genuinely summarizing, so the whole recovery pipeline is
7
+ * testable without wiring up an LLM. Swap in a real Summarizer (an LLM
8
+ * call, host-provided) for actual compression quality — this module
9
+ * doesn't bundle one, same as ActAuth doesn't bundle a Slack client. */
10
+ export declare class TruncatingSummarizer implements Summarizer {
11
+ private readonly maxChars;
12
+ constructor(maxChars?: number);
13
+ summarize(messages: Message[]): Promise<Message>;
14
+ }
15
+ export type RecoverAction = 'unchanged' | 'drained' | 'summarized';
16
+ export interface RecoverResult {
17
+ messages: Message[];
18
+ action: RecoverAction;
19
+ usedTokens: number;
20
+ budgetTokens: number;
21
+ }
22
+ export interface CompactorOptions {
23
+ budgetTokens: number;
24
+ /** Recovery target, as a fraction of budgetTokens — pass the same
25
+ * value as the BudgetTracker's own softThreshold so "how far
26
+ * compaction tries to shrink" agrees with "when a nudge fires"
27
+ * instead of being independently configured. Default 0.7. */
28
+ softThreshold?: number;
29
+ /** Most-recent messages never drained or summarized. Default 4. */
30
+ tailMessages?: number;
31
+ summarizer?: Summarizer;
32
+ }
33
+ /** Recovers in two stages, never touching the most recent tailMessages:
34
+ * a cheap deterministic drain first, a pluggable Summarizer second. */
35
+ export declare class Compactor {
36
+ private readonly budgetTokens;
37
+ private readonly softThreshold;
38
+ private readonly tailMessages;
39
+ private readonly summarizer;
40
+ constructor(options: CompactorOptions);
41
+ private estimateUsage;
42
+ recover(messages: Message[]): Promise<RecoverResult>;
43
+ }
@@ -0,0 +1,70 @@
1
+ // Vendored from the contextclip npm package (MIT license) and split in
2
+ // two — see budget.ts's own header comment for why. This half is the
3
+ // actual tail-preserving compaction: two staged, escalating recovery for
4
+ // when budget.ts's own BudgetTracker.check() reports over_hard_limit.
5
+ import { estimateTokens } from './budget.js';
6
+ /** A real, working default — no model call. Concatenates and truncates
7
+ * rather than genuinely summarizing, so the whole recovery pipeline is
8
+ * testable without wiring up an LLM. Swap in a real Summarizer (an LLM
9
+ * call, host-provided) for actual compression quality — this module
10
+ * doesn't bundle one, same as ActAuth doesn't bundle a Slack client. */
11
+ export class TruncatingSummarizer {
12
+ maxChars;
13
+ constructor(maxChars = 500) {
14
+ this.maxChars = maxChars;
15
+ }
16
+ async summarize(messages) {
17
+ const combined = messages.map((m) => `[${m.role}] ${m.content}`).join('\n');
18
+ const truncated = combined.length > this.maxChars ? `${combined.slice(0, this.maxChars)}…` : combined;
19
+ return { role: 'system', content: `[compacted ${messages.length} earlier message(s)]\n${truncated}` };
20
+ }
21
+ }
22
+ /** Recovers in two stages, never touching the most recent tailMessages:
23
+ * a cheap deterministic drain first, a pluggable Summarizer second. */
24
+ export class Compactor {
25
+ budgetTokens;
26
+ softThreshold;
27
+ tailMessages;
28
+ summarizer;
29
+ constructor(options) {
30
+ this.budgetTokens = options.budgetTokens;
31
+ this.softThreshold = options.softThreshold ?? 0.7;
32
+ this.tailMessages = options.tailMessages ?? 4;
33
+ this.summarizer = options.summarizer ?? new TruncatingSummarizer();
34
+ }
35
+ estimateUsage(messages) {
36
+ return messages.reduce((sum, m) => sum + estimateTokens(`${m.role}: ${m.content}`), 0);
37
+ }
38
+ async recover(messages) {
39
+ const tailStart = Math.max(0, messages.length - this.tailMessages);
40
+ const tail = messages.slice(tailStart);
41
+ const head = messages.slice(0, tailStart);
42
+ const target = this.budgetTokens * this.softThreshold;
43
+ if (this.estimateUsage(messages) <= target) {
44
+ return { messages, action: 'unchanged', usedTokens: this.estimateUsage(messages), budgetTokens: this.budgetTokens };
45
+ }
46
+ // Stage 1: drain — cheap, no model call. Capped at half of `head` so
47
+ // there's always something left for stage 2 to compress instead of
48
+ // deleting everything outright.
49
+ const drainCap = Math.ceil(head.length / 2);
50
+ const remainingHead = head.slice(drainCap);
51
+ const drainedCount = head.length - remainingHead.length;
52
+ const afterDrain = [...remainingHead, ...tail];
53
+ if (this.estimateUsage(afterDrain) <= target) {
54
+ return {
55
+ messages: afterDrain,
56
+ action: drainedCount > 0 ? 'drained' : 'unchanged',
57
+ usedTokens: this.estimateUsage(afterDrain),
58
+ budgetTokens: this.budgetTokens,
59
+ };
60
+ }
61
+ // Stage 2: still over budget — summarize whatever's left of head.
62
+ // The tail is never touched.
63
+ let finalMessages = afterDrain;
64
+ if (remainingHead.length > 0) {
65
+ const summary = await this.summarizer.summarize(remainingHead);
66
+ finalMessages = [summary, ...tail];
67
+ }
68
+ return { messages: finalMessages, action: 'summarized', usedTokens: this.estimateUsage(finalMessages), budgetTokens: this.budgetTokens };
69
+ }
70
+ }
@@ -0,0 +1,10 @@
1
+ import { type Approver } from 'actauth';
2
+ import type { AgentConfig, QuestionHandler } from './agent-config.js';
3
+ interface ResolvedHttpNotifier {
4
+ approver?: Approver;
5
+ questionHandler?: QuestionHandler;
6
+ onRunStart?: NonNullable<AgentConfig['onRunStart']>;
7
+ onRunFinish?: NonNullable<AgentConfig['onRunFinish']>;
8
+ }
9
+ export declare function resolveHttpNotifier(config: AgentConfig): ResolvedHttpNotifier;
10
+ export {};
@@ -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
+ }