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,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 '
|
|
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
|
-
/**
|
|
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
|
|
6
|
+
* this never drifts from whatever budget.ts's own CheckResult actually
|
|
8
7
|
* contains. */
|
|
9
|
-
export type
|
|
10
|
-
type: '
|
|
8
|
+
export type BudgetCheckEvent = {
|
|
9
|
+
type: 'budget:check';
|
|
11
10
|
} & CheckResult;
|
|
12
|
-
/**
|
|
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
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
81
|
-
* why Skill invocation isn't
|
|
82
|
-
|
|
83
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
140
|
-
* stopReasons (max_turns/denied)
|
|
141
|
-
* treat as "nothing more is coming
|
|
142
|
-
*
|
|
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
|