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.
- package/PROTOCOL.md +245 -0
- package/README.md +53 -10
- package/dist/core/agent-config.d.ts +282 -3
- 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 +60 -29
- package/dist/core/model-calls/openai-model-call.js +33 -17
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +143 -15
- package/dist/core/run-agent.js +321 -66
- package/dist/core/session-store.d.ts +2 -1
- 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/core/vector-index.js +1 -1
- package/dist/package.json +6 -0
- package/package.json +16 -6
- package/protocol/loop-event.schema.json +257 -0
- /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, '&').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
|
+
}
|