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,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
+ }
@@ -0,0 +1,21 @@
1
+ import type { DurableApprover, Scope } from 'actauth';
2
+ import type { DurableQuestionHandler } from '../agent-config.js';
3
+ export declare class SlackNotifier implements DurableApprover, DurableQuestionHandler {
4
+ private readonly botToken;
5
+ private readonly channelId;
6
+ constructor(options: {
7
+ botToken: string;
8
+ channelId: string;
9
+ });
10
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
11
+ pendingId: string;
12
+ };
13
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
14
+ pendingId: string;
15
+ };
16
+ /** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
17
+ * lifecycle sender for `channel: 'slack'` calls this directly with no
18
+ * `blocks` — a plain announcement, since there's nothing to resolve and
19
+ * so nothing to attach buttons to. */
20
+ postMessage(text: string, blocks?: unknown[]): void;
21
+ }
@@ -0,0 +1,91 @@
1
+ // The *sending* half of a Slack-backed DurableApprover/DurableQuestionHandler,
2
+ // real, maintained core/ code so AgentConfig.httpNotifier
3
+ // (core/http-notifier.ts) can construct one directly for `channel:
4
+ // 'slack'`, the same way it already constructs actauth's own
5
+ // WebhookApprover for `channel: 'webhook'`. Not "DurableSlackNotifier":
6
+ // unlike the Web/Webhook pair, there's no live "SlackNotifier" this name
7
+ // could collide with (Slack's own *live* approver, actauth's
8
+ // `SlackChatApprover`, has an entirely different name already) — durable is
9
+ // just what a Slack-backed notifier defaults to being, so the name
10
+ // doesn't need to say so.
11
+ //
12
+ // The *receiving* side — verifying a Slack Interactivity payload really
13
+ // came from Slack, then dispatching approve/deny/answer back into
14
+ // loopengine's own resolve/answer routes — deliberately stays out of
15
+ // core and out of this class: that's an HTTP endpoint only the host can
16
+ // deploy (it needs a route, a public URL, Slack app configuration), the
17
+ // same "sending built for you, receiving you deploy" split every other
18
+ // durable channel in this repo has. See
19
+ // examples/notifier-handler/slack.ts's own `SlackNotifierHandler` for
20
+ // that half.
21
+ //
22
+ // One class for both concerns, not two: nothing about what a Slack
23
+ // integration needs to send is approval-specific or question-specific,
24
+ // it's Slack-specific (the bot token, the channel, the chat.postMessage
25
+ // call shape).
26
+ import { randomUUID } from 'node:crypto';
27
+ export class SlackNotifier {
28
+ botToken;
29
+ channelId;
30
+ constructor(options) {
31
+ this.botToken = options.botToken;
32
+ this.channelId = options.channelId;
33
+ }
34
+ requestDurableApproval(tool, args, scope, reason) {
35
+ const pendingId = randomUUID();
36
+ this.postMessage(`Approval requested: ${tool} on ${scope.tenant}/${scope.environment}/${scope.agent}`, [
37
+ {
38
+ type: 'section',
39
+ text: {
40
+ type: 'mrkdwn',
41
+ text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}`,
42
+ },
43
+ },
44
+ {
45
+ type: 'actions',
46
+ elements: [
47
+ { type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
48
+ { type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
49
+ ],
50
+ },
51
+ ]);
52
+ return { pendingId };
53
+ }
54
+ notifyPendingQuestion(question, options, agent, sessionId) {
55
+ const pendingId = randomUUID();
56
+ const optionButtons = (options ?? []).map((option) => ({
57
+ type: 'button',
58
+ text: { type: 'plain_text', text: option },
59
+ value: JSON.stringify({ pendingId, answer: option }),
60
+ action_id: 'answer_option',
61
+ }));
62
+ this.postMessage(`Question from ${agent}: ${question}`, [
63
+ { type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}` } },
64
+ { type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] },
65
+ ]);
66
+ return { pendingId };
67
+ }
68
+ /** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
69
+ * lifecycle sender for `channel: 'slack'` calls this directly with no
70
+ * `blocks` — a plain announcement, since there's nothing to resolve and
71
+ * so nothing to attach buttons to. */
72
+ postMessage(text, blocks) {
73
+ // Not awaited — both requestDurableApproval/notifyPendingQuestion
74
+ // above return immediately, by contract (see DurableApprover's own
75
+ // doc comment); a delivery failure here has no synchronous way to
76
+ // surface to either caller, so it's logged instead of thrown.
77
+ fetch('https://slack.com/api/chat.postMessage', {
78
+ method: 'POST',
79
+ headers: { Authorization: `Bearer ${this.botToken}`, 'content-type': 'application/json; charset=utf-8' },
80
+ body: JSON.stringify({ channel: this.channelId, text, ...(blocks ? { blocks } : {}) }),
81
+ })
82
+ .then((res) => res.json())
83
+ .then((body) => {
84
+ if (!body.ok)
85
+ console.error('[loopengine] SlackNotifier: chat.postMessage failed:', body.error);
86
+ })
87
+ .catch((err) => {
88
+ console.error('[loopengine] SlackNotifier: chat.postMessage failed:', err);
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,27 @@
1
+ import type { DurableApprover, Scope } from 'actauth';
2
+ import type { DurableQuestionHandler } from '../agent-config.js';
3
+ export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
4
+ private readonly webhookUrl;
5
+ private readonly signingSecret;
6
+ constructor(options: {
7
+ webhookUrl: string;
8
+ signingSecret: string;
9
+ });
10
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
11
+ pendingId: string;
12
+ };
13
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
14
+ pendingId: string;
15
+ };
16
+ /** Shared by both methods above — the one place either used to
17
+ * independently build the same signed fetch() call. Not awaited by its
18
+ * own callers, same fire-and-forget contract each had on its own: both
19
+ * requestDurableApproval/notifyPendingQuestion return immediately, by
20
+ * contract (see DurableApprover/DurableQuestionHandler's own doc
21
+ * comments), so a delivery failure here has no synchronous way to
22
+ * surface to the caller — logged instead of thrown, since throwing
23
+ * would blow up a decision that has already, correctly, been recorded
24
+ * as pending. */
25
+ private post;
26
+ }
27
+ export declare function postLifecycleWebhook(webhookUrl: string, webhookSecret: string, event: 'run_start' | 'run_finish', context: Record<string, unknown>): void;
@@ -0,0 +1,94 @@
1
+ // The generic-webhook channel's sending side, one class for both
2
+ // concerns — same "it's channel-specific, not concern-specific" shape
3
+ // slack.ts/lark.ts/email.ts already have, not the "two already-shipped
4
+ // classes from two separate packages" split this file used to be (see
5
+ // git history): approval-sending used to be actauth's own
6
+ // `WebhookApprover`, question-sending loopengine's own
7
+ // `WebhookQuestionHandler` (`core/system-tools/ask_user.ts`) — genuinely
8
+ // separate classes, since `actauth`'s `Gate` has no concept of
9
+ // `system_ask_user` questions at all (see HUMAN_IN_THE_LOOP.md's
10
+ // "Durable questions" section). Nothing about *sending* a signed webhook
11
+ // POST is actauth-specific, though, once you stop assuming the approval
12
+ // half has to live in actauth's own package — both halves are the exact
13
+ // same HMAC-SHA256-over-the-raw-body scheme, just under different
14
+ // headers, so merging them removes real duplication (one class, one
15
+ // constructor, one place either concern's own webhook target/secret is
16
+ // held) rather than just looking simpler on paper.
17
+ //
18
+ // `actauth`'s own `WebhookApprover` still exists and is unaffected —
19
+ // this is loopengine's own implementation of the same `DurableApprover`
20
+ // interface, not a fork of actauth's class; a standalone actauth
21
+ // consumer (no loopengine involved) still has and should use actauth's
22
+ // own. loopengine no longer imports it anywhere, since everywhere it
23
+ // used to (this file, and adapters/http.ts's own deployment-wide
24
+ // default) now constructs one `WebhookNotifier` instead.
25
+ //
26
+ // The signed payloads below are wire-compatible with what actauth's
27
+ // `WebhookApprover`/loopengine's former `WebhookQuestionHandler` already
28
+ // sent — same fields, same `X-Actauth-Signature`/`X-Askuser-Signature`
29
+ // headers — so an existing receiving-side deployment (this repo's own
30
+ // `examples/notifier-handler/webhook.ts`, or a host's own) needs no
31
+ // changes; only where the bytes come from moved.
32
+ //
33
+ // `postLifecycleWebhook` below is the third, genuinely new piece:
34
+ // `onRunStart`/`onRunFinish` have no shipped class on either side, for
35
+ // any channel (see this repo's own history — agents/customer-service/index.ts
36
+ // used to hand-write this exact function before `AgentConfig.httpNotifier`
37
+ // existed). Same signing scheme, under its own `X-Lifecycle-Signature`
38
+ // header so a receiver can tell a lifecycle event apart from a
39
+ // pending-approval/pending-question one hitting the same URL (see
40
+ // examples/notifier-handler/webhook.ts's own `verifyWebhookNotifier` for
41
+ // the receiving side of all three).
42
+ import { createHmac, randomUUID } from 'node:crypto';
43
+ export class WebhookNotifier {
44
+ webhookUrl;
45
+ signingSecret;
46
+ constructor(options) {
47
+ this.webhookUrl = options.webhookUrl;
48
+ this.signingSecret = options.signingSecret;
49
+ }
50
+ requestDurableApproval(tool, args, scope, reason) {
51
+ const pendingId = randomUUID();
52
+ const requestedAt = new Date().toISOString();
53
+ const body = JSON.stringify({ pendingId, tool, args, scope, reason, requestedAt });
54
+ this.post(body, 'X-Actauth-Signature', pendingId);
55
+ return { pendingId };
56
+ }
57
+ notifyPendingQuestion(question, options, agent, sessionId) {
58
+ const pendingId = randomUUID();
59
+ const requestedAt = new Date().toISOString();
60
+ const body = JSON.stringify({ pendingId, question, options, agent, sessionId, requestedAt });
61
+ this.post(body, 'X-Askuser-Signature', pendingId);
62
+ return { pendingId };
63
+ }
64
+ /** Shared by both methods above — the one place either used to
65
+ * independently build the same signed fetch() call. Not awaited by its
66
+ * own callers, same fire-and-forget contract each had on its own: both
67
+ * requestDurableApproval/notifyPendingQuestion return immediately, by
68
+ * contract (see DurableApprover/DurableQuestionHandler's own doc
69
+ * comments), so a delivery failure here has no synchronous way to
70
+ * surface to the caller — logged instead of thrown, since throwing
71
+ * would blow up a decision that has already, correctly, been recorded
72
+ * as pending. */
73
+ post(body, signatureHeader, pendingId) {
74
+ const signature = `sha256=${createHmac('sha256', this.signingSecret).update(body).digest('hex')}`;
75
+ fetch(this.webhookUrl, {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json', [signatureHeader]: signature },
78
+ body,
79
+ }).catch((err) => {
80
+ console.error(`[loopengine] WebhookNotifier: webhook delivery failed for pendingId '${pendingId}':`, err);
81
+ });
82
+ }
83
+ }
84
+ export function postLifecycleWebhook(webhookUrl, webhookSecret, event, context) {
85
+ const payload = JSON.stringify({ event, ...context, occurredAt: new Date().toISOString() });
86
+ const signature = `sha256=${createHmac('sha256', webhookSecret).update(payload).digest('hex')}`;
87
+ fetch(webhookUrl, {
88
+ method: 'POST',
89
+ headers: { 'content-type': 'application/json', 'X-Lifecycle-Signature': signature },
90
+ body: payload,
91
+ }).catch((err) => {
92
+ console.error(`[loopengine] httpNotifier ${event} webhook delivery failed:`, err);
93
+ });
94
+ }
@@ -1,31 +1,35 @@
1
- import type { CheckResult } from 'contextclip';
2
- import type { RecoveryAction } from 'reflowkit';
1
+ import type { CheckResult } from './budget.js';
3
2
  import type { Decision, PendingApproval } from 'actauth';
4
3
  import type { PendingQuestion } from './system-tools/index.js';
5
- /** ContextClipper's own read-only budget check, run once per loop turn
4
+ /** BudgetTracker's own read-only budget check, run once per loop turn
6
5
  * before the model call — reported unchanged (spread, not renamed) so
7
- * this never drifts from whatever contextclip's own CheckResult actually
6
+ * this never drifts from whatever budget.ts's own CheckResult actually
8
7
  * contains. */
9
- export type ContextClipCheckEvent = {
10
- type: 'contextclip:check';
8
+ export type BudgetCheckEvent = {
9
+ type: 'budget:check';
11
10
  } & CheckResult;
12
- /** ContextClip recovery kicked in because the prompt was rejected as too
13
- * large — `from`/`to` are message-array lengths (this turn's own
11
+ /** compaction.ts's own recovery kicked in because the prompt was rejected as
12
+ * too large — `from`/`to` are message-array lengths (this turn's own
14
13
  * newMessages included), not token counts; see run-agent.ts's own
15
- * reflow.call onPromptTooLong hook. */
16
- export interface ReflowRecoverEvent {
17
- type: 'reflow:recover';
14
+ * recovery.call onPromptTooLong hook. Named after *what triggered it and
15
+ * what happened* (the prompt was too long, so it got compacted), not
16
+ * after which module did the work — "compaction:recover" still didn't
17
+ * say this only ever fires for the prompt-too-long case specifically,
18
+ * same problem contextclip:check's own rename from a package name was
19
+ * fixing in the first place.
20
+ *
21
+ * No sibling event for recovery.ts's other two failure modes
22
+ * (media-too-large, truncated output) — neither has an onMediaTooLarge/
23
+ * onTruncated hook actually wired up in run-agent.ts today, so neither
24
+ * can currently fire at all. A prior 'recovery:summary' aggregate event
25
+ * covering all three was removed for being ~100% redundant with this one
26
+ * while that's true; it can come back once a second real recovery type
27
+ * exists to aggregate. */
28
+ export interface PromptCompactionEvent {
29
+ type: 'prompt:compaction';
18
30
  from: number;
19
31
  to: number;
20
32
  }
21
- /** Ordered log of whatever reflowkit's own Reflow.call had to do to get a
22
- * usable response this turn (prompt-too-long retries, truncation
23
- * retries, ...) — empty-array case never fires this event at all (see
24
- * run-agent.ts: only logged when recoveries.length > 0). */
25
- export interface ReflowRecoveriesEvent {
26
- type: 'reflow:recoveries';
27
- recoveries: RecoveryAction[];
28
- }
29
33
  /** The model's own "I'll do X" preamble alongside a tool_use request —
30
34
  * the only *live* signal of assistant text mid-turn (the final answer
31
35
  * only ever arrives via 'loop:done'/'done', once, at the very end). */
@@ -77,10 +81,13 @@ export interface ToolLaneResultEvent {
77
81
  summary: string;
78
82
  }
79
83
  /** A `Skill` tool_use block was resolved to a real skill body via
80
- * SkillGarden — not gated by ActAuth (see run-agent.ts's own comment on
81
- * why Skill invocation isn't a real tool call). */
82
- export interface SkillGardenInvokeEvent {
83
- type: 'skillgarden:invoke';
84
+ * SkillGarden's own lazy "index-now-load-later" design — not gated by
85
+ * ActAuth (see run-agent.ts's own comment on why Skill invocation isn't
86
+ * a real tool call). Named after what happened (the skill's full body
87
+ * got loaded), not after which library method was called — same
88
+ * reasoning budget:check's own rename from contextclip:check used. */
89
+ export interface SkillLoadEvent {
90
+ type: 'skill:loaded';
84
91
  skill: string;
85
92
  }
86
93
  /** An approved call never ran because a sibling call in the same turn's
@@ -102,6 +109,26 @@ export interface LoopDeniedEvent {
102
109
  type: 'loop:denied';
103
110
  deniedTools: string[];
104
111
  }
112
+ /** At least one requested tool call is durably pending a DurableApprover
113
+ * decision — the turn stops right there too, but unlike loop:denied this
114
+ * isn't a rejection: any already-approved sibling call in the same batch
115
+ * still ran (see run-agent.ts's own bucket-then-execute), and the turn
116
+ * resumes (via resumeAgent) once every pendingId here is resolved. See
117
+ * HUMAN_IN_THE_LOOP.md for the full design. */
118
+ export interface PendingApprovalEvent {
119
+ type: 'loop:pending_approval';
120
+ pendingIds: string[];
121
+ }
122
+ /** The question-side sibling of PendingApprovalEvent above — at least one
123
+ * `system_ask_user` call is durably pending a DurableQuestionHandler
124
+ * (see HUMAN_IN_THE_LOOP.md's "Durable questions" section), fired only
125
+ * when nothing else in the same batch is a pending *approval* (a mixed
126
+ * batch reports PendingApprovalEvent instead — see
127
+ * RunAgentResult.stopReason's own doc comment for why). */
128
+ export interface PendingQuestionEvent {
129
+ type: 'loop:pending_question';
130
+ pendingIds: string[];
131
+ }
105
132
  /** The loop's own final answer — the model produced text with no more
106
133
  * tool_use blocks. Distinct from the adapter-level 'done' below: this
107
134
  * only ever means a genuine finish, never a synthetic max_turns/denied
@@ -113,7 +140,7 @@ export interface LoopDoneEvent {
113
140
  /** Every event runAgent() itself can emit via RunAgentOptions.onEvent —
114
141
  * not the adapter-synthesized ones below, which no caller of runAgent()
115
142
  * itself ever produces. */
116
- export type RunAgentLoopEvent = ContextClipCheckEvent | ReflowRecoverEvent | ReflowRecoveriesEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillGardenInvokeEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | LoopDoneEvent;
143
+ export type RunAgentLoopEvent = BudgetCheckEvent | PromptCompactionEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillLoadEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | PendingApprovalEvent | PendingQuestionEvent | LoopDoneEvent;
117
144
  /** adapters/http.ts echoing back which session id this turn used — first
118
145
  * event of every streamed turn, so a caller that omitted sessionId can
119
146
  * capture what got generated. Not something runAgent() itself knows
@@ -136,14 +163,18 @@ export type ApprovalPendingEvent = {
136
163
  export type QuestionPendingEvent = {
137
164
  type: 'question:pending';
138
165
  } & PendingQuestion;
139
- /** The turn is over — covers a genuine finish and both synthetic
140
- * stopReasons (max_turns/denied) alike, the one event every caller can
141
- * treat as "nothing more is coming" regardless of which of the three it
142
- * was (see RunAgentResult.stopReason's own doc comment). */
166
+ /** This turn/request is over — covers a genuine finish and all four
167
+ * synthetic stopReasons (max_turns/denied/pending_approval/pending_question)
168
+ * alike, the one event every caller can treat as "nothing more is coming
169
+ * on this response" regardless of which it was (see
170
+ * RunAgentResult.stopReason's own doc comment). pending_approval/
171
+ * pending_question, like denied, don't mean the conversation itself is
172
+ * over — only this turn; it resumes later via a separate resolve call,
173
+ * not by continuing to read this same response. */
143
174
  export interface DoneEvent {
144
175
  type: 'done';
145
176
  text: string;
146
- stopReason?: 'max_turns' | 'denied';
177
+ stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question';
147
178
  }
148
179
  /** Something failed after the response was already committed (SSE
149
180
  * headers sent, or a background turn already running) — surfaced in-band