loopengine 0.0.21 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +3 -3
- package/README.md +45 -10
- package/dist/core/agent-config.d.ts +291 -8
- 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 +30 -6
- package/dist/core/model-calls/openai-model-call.js +32 -16
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +141 -13
- package/dist/core/run-agent.js +316 -57
- package/dist/core/session-store.d.ts +1 -0
- 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/package.json +6 -0
- package/package.json +14 -4
- package/protocol/loop-event.schema.json +2 -2
- /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
package/PROTOCOL.md
CHANGED
|
@@ -235,9 +235,9 @@ language with a validator, not just JavaScript.
|
|
|
235
235
|
- **`core/client.ts`** — the framework-agnostic TypeScript client this
|
|
236
236
|
spec was extracted from: SSE parsing, the plain-request helpers,
|
|
237
237
|
approve/deny/answer. Re-exported through the package's own entry point.
|
|
238
|
-
- **`
|
|
239
|
-
and a chat component each, built on `core/client.ts`,
|
|
240
|
-
`approval:pending`/`question:pending` as real UI.
|
|
238
|
+
- **`examples/chatbox/react/`**, **`examples/chatbox/vue/`** — a
|
|
239
|
+
hook/composable and a chat component each, built on `core/client.ts`,
|
|
240
|
+
rendering `approval:pending`/`question:pending` as real UI.
|
|
241
241
|
|
|
242
242
|
Implementing this protocol in another language means reproducing what
|
|
243
243
|
`core/client.ts` does: parse whichever transport you chose into
|
package/README.md
CHANGED
|
@@ -102,8 +102,10 @@ hidden control flow: call the model, act on what it asks for, repeat.
|
|
|
102
102
|
1. Call the model with the conversation so far.
|
|
103
103
|
2. If it responds with tool calls, each one is checked against
|
|
104
104
|
`AgentConfig.rules` — allowed calls run, denied calls are refused,
|
|
105
|
-
"ask" calls
|
|
106
|
-
multi-tenancy" below)
|
|
105
|
+
"ask" calls go to whatever approver applies for the call's own channel
|
|
106
|
+
(see "Tool permission and multi-tenancy" below) — a live approver is
|
|
107
|
+
awaited right there; a durable one returns instantly and the call is
|
|
108
|
+
resolved later instead.
|
|
107
109
|
3. Approved calls execute and their results feed back into the
|
|
108
110
|
conversation.
|
|
109
111
|
4. Repeat from step 1 — until the model stops requesting tools
|
|
@@ -293,9 +295,39 @@ adding a source (or use the page's permission dropdown) to seed an
|
|
|
293
295
|
Every tool call is gated by [`actauth`](https://www.npmjs.com/package/actauth):
|
|
294
296
|
each rule in `AgentConfig.rules` maps a `scope` (tenant/environment) + tool
|
|
295
297
|
name to `allow` / `ask` / `deny`. Anything not covered falls through to
|
|
296
|
-
`defaultDecision`. An `ask` decision routes to
|
|
297
|
-
|
|
298
|
-
for
|
|
298
|
+
`defaultDecision`. An `ask` decision routes to whichever approver applies
|
|
299
|
+
for the call's own channel: `cli`/`http_stream` always get the library's
|
|
300
|
+
own live default (`ConsoleApprover`, blocking on stdin, for `cli`; a live
|
|
301
|
+
approval popup for `http_stream`) — nothing to configure there — and
|
|
302
|
+
`http` gets a durable one whenever `AgentConfig.httpNotifier` is
|
|
303
|
+
configured for it (see below), falling back to the same kind of live
|
|
304
|
+
default otherwise.
|
|
305
|
+
|
|
306
|
+
`ask` approvers come in two shapes. A **live** one (`WebchatApprover`,
|
|
307
|
+
`SlackChatApprover`, `ConsoleApprover`) is awaited directly — the turn stays
|
|
308
|
+
suspended until a human decides, which only makes sense when someone's
|
|
309
|
+
actually there right now (a terminal, an open chat connection). A
|
|
310
|
+
**durable** one (`WebhookApprover`) fires a signed webhook and returns
|
|
311
|
+
instantly instead — the turn ends with `stopReason: 'pending_approval'`,
|
|
312
|
+
durably resumable minutes or days later via
|
|
313
|
+
`POST /pending-approvals/:pendingId/resolve`, without holding any
|
|
314
|
+
process open in between. See `HUMAN_IN_THE_LOOP.md` for the full design.
|
|
315
|
+
|
|
316
|
+
The system `ask_user` tool (a model asking a human a genuinely ambiguous
|
|
317
|
+
clarifying question mid-turn) gets the same live/durable split, via
|
|
318
|
+
`RunAgentOptions.questionHandler` (or `AgentConfig.httpNotifier`'s own
|
|
319
|
+
`'question'` event, for the `http` channel specifically): live by default
|
|
320
|
+
(blocks on the channel it's raised on), or durable — a
|
|
321
|
+
`DurableQuestionHandler` (e.g. `core/http-notify-triggers/webhook.ts`'s
|
|
322
|
+
`WebhookNotifier`) instead ends the turn with `stopReason:
|
|
323
|
+
'pending_question'`, resumable via
|
|
324
|
+
`POST /pending-questions/:pendingId/answer`. See `HUMAN_IN_THE_LOOP.md`'s
|
|
325
|
+
own "Durable questions" section.
|
|
326
|
+
|
|
327
|
+
For the practical "what do I actually need to build" version of all of
|
|
328
|
+
this — live chat setup, durable setup, and reference implementations for
|
|
329
|
+
Slack/Lark/email/generic-webhook notification channels — see
|
|
330
|
+
[`HUMAN_IN_THE_LOOP.md`](HUMAN_IN_THE_LOOP.md).
|
|
299
331
|
|
|
300
332
|
A real agent's rules live in `agents/<name>/actauth.yml`:
|
|
301
333
|
|
|
@@ -408,16 +440,18 @@ event protocol, not the only way to consume a turn — see
|
|
|
408
440
|
[`PROTOCOL.md`](PROTOCOL.md) for the full `LoopEvent` catalog, the SSE/
|
|
409
441
|
plain-HTTP/CLI transport bindings, and a JSON Schema
|
|
410
442
|
(`protocol/loop-event.schema.json`) any language can validate against.
|
|
411
|
-
`core/client.ts` and the `
|
|
412
|
-
reference implementations of it.
|
|
443
|
+
`core/client.ts` and the `examples/chatbox/react`/`examples/chatbox/vue`
|
|
444
|
+
hooks are reference implementations of it.
|
|
413
445
|
|
|
414
446
|
**Agent config page:** open `http://localhost:8787/agents/config`
|
|
415
447
|
(optionally `?agent=<name>`) to browse every registered agent's resolved
|
|
416
448
|
config — system prompt, model, tools (with their JSON schemas and
|
|
417
449
|
parallel-safety), the actual ActAuth rules that would be enforced (source,
|
|
418
450
|
default decision, per-rule scope/tool/decision/`when`), and whether
|
|
419
|
-
`sessionIdFor`/`tenantFor`/`isSafeTool
|
|
420
|
-
|
|
451
|
+
`sessionIdFor`/`tenantFor`/`isSafeTool` are custom or defaulted, and
|
|
452
|
+
whether the `http` channel's approver is custom (`httpNotifier` covers
|
|
453
|
+
`'approval'`) or the library's own default. Backed
|
|
454
|
+
by `GET /agents/:name/config`, which reuses the same
|
|
421
455
|
rule/tool resolution `runAgent()` itself uses, so it can't drift out of
|
|
422
456
|
sync with what a real request actually gets. Never returns
|
|
423
457
|
`AgentModelConfig.apiKey`.
|
|
@@ -440,9 +474,10 @@ testing), export your own `createModelCall(): ModelCall` instead.
|
|
|
440
474
|
|
|
441
475
|
| Package | Responsibility |
|
|
442
476
|
|---|---|
|
|
443
|
-
| [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny) with human-approval hooks |
|
|
477
|
+
| [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny/pending) with live and durable human-approval hooks |
|
|
444
478
|
| `core/budget.ts` / `core/compaction.ts` | Context-window budget tracking and tail-preserving compaction (vendored in-repo, not an external dependency) |
|
|
445
479
|
| `core/recovery.ts` | Retries prompt-too-long / truncated-output failures (vendored in-repo, not an external dependency) |
|
|
480
|
+
| `core/durable-approvals.ts` | `TurnCheckpoint`/`CheckpointStore` (file/Redis) backing durable, resumable `ask` decisions — see `HUMAN_IN_THE_LOOP.md` |
|
|
446
481
|
| [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable session log with crash-interruption detection |
|
|
447
482
|
| [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md` discovery and lazy loading |
|
|
448
483
|
| [`toollane`](https://www.npmjs.com/package/toollane) | Parallel/solo tool-call scheduling |
|
|
@@ -1,5 +1,231 @@
|
|
|
1
|
-
import type { Rule, Decision,
|
|
1
|
+
import type { Rule, Decision, Scope } from 'actauth';
|
|
2
|
+
import type { Redis } from 'ioredis';
|
|
2
3
|
import type { SafetyClassifier } from 'toollane';
|
|
4
|
+
/** Which channel a runAgent() call is on — the value RunAgentOptions.channel
|
|
5
|
+
* carries, checked against directly by AgentConfig.httpNotifier's own
|
|
6
|
+
* resolution (see its own doc comment for why it only ever matches
|
|
7
|
+
* `'http'`). `cli`/`http_stream` always get the library's own live
|
|
8
|
+
* defaults (`ConsoleApprover`, `CliQuestionHandler`, or whatever live
|
|
9
|
+
* approver/questionHandler the adapter itself passes as
|
|
10
|
+
* `RunAgentOptions.approver`/`questionHandler`) — there's no
|
|
11
|
+
* AgentConfig-level way to override either for those two channels
|
|
12
|
+
* specifically; only `http` has one, via `httpNotifier`. */
|
|
13
|
+
export type ApproverChannel = 'cli' | 'http' | 'http_stream';
|
|
14
|
+
/** A pending system_ask_user question — defined here, not in
|
|
15
|
+
* core/system-tools/ask_user.ts, purely so LiveQuestionHandler/
|
|
16
|
+
* DurableQuestionHandler below can reference it without a circular import
|
|
17
|
+
* (ask_user.ts already imports from this file for
|
|
18
|
+
* ToolDefinition/DurableQuestionHandler); ask_user.ts re-exports this same
|
|
19
|
+
* type for its own module's callers. */
|
|
20
|
+
export interface PendingQuestion {
|
|
21
|
+
id: string;
|
|
22
|
+
question: string;
|
|
23
|
+
/** Suggested answers, if the model gave any — never the only allowed
|
|
24
|
+
* answer, a human can still respond with free text either way. */
|
|
25
|
+
options?: string[];
|
|
26
|
+
/** Which agent raised this — always known (config.name), so listing can
|
|
27
|
+
* always at least be scoped per-agent even without a session id. */
|
|
28
|
+
agent: string;
|
|
29
|
+
/** Which conversation raised this, if the caller has one (see
|
|
30
|
+
* RunAgentOptions.sessionId's own doc comment for when it doesn't). */
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
requestedAt: string;
|
|
33
|
+
}
|
|
34
|
+
/** The question-side sibling of actauth's own `DurableApprover` — fires a
|
|
35
|
+
* notification and returns a `pendingId` immediately, never awaited for
|
|
36
|
+
* the actual answer (see HUMAN_IN_THE_LOOP.md's "Durable questions"
|
|
37
|
+
* section). Lives here, in loopengine, rather than in actauth: unlike a
|
|
38
|
+
* tool-call approval, `system_ask_user` (core/system-tools/ask_user.ts)
|
|
39
|
+
* is loopengine's own system tool — it never goes through actauth's
|
|
40
|
+
* `Gate` at all (see that file's own doc comment on why it can't be
|
|
41
|
+
* gated), so actauth has no reason to know it exists. Positional args,
|
|
42
|
+
* mirroring `DurableApprover.requestDurableApproval(tool, args, scope,
|
|
43
|
+
* reason)`'s own shape. The built-in implementations are
|
|
44
|
+
* `core/http-notify-triggers/webhook.ts`'s `WebhookNotifier` and its
|
|
45
|
+
* slack.ts/lark.ts/email.ts siblings. */
|
|
46
|
+
export interface DurableQuestionHandler {
|
|
47
|
+
notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
|
|
48
|
+
pendingId: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The question-side sibling of actauth's own `LiveApprover` — awaited
|
|
52
|
+
* directly for the human's actual answer, same call shape
|
|
53
|
+
* `DurableQuestionHandler.notifyPendingQuestion` has except this one
|
|
54
|
+
* blocks and returns the real answer instead of a `pendingId`. `onPending`
|
|
55
|
+
* mirrors `WebchatApprover`'s own notification hook, but stays a per-call
|
|
56
|
+
* argument here rather than a constructor option — see
|
|
57
|
+
* `WebchatQuestionHandler`'s own doc comment (core/system-tools/ask_user.ts)
|
|
58
|
+
* for why a question, unlike an approval, only ever needs one shared
|
|
59
|
+
* registry, not a fresh instance per turn. The two built-in
|
|
60
|
+
* implementations are `WebchatQuestionHandler` and `CliQuestionHandler`
|
|
61
|
+
* (both core/system-tools/ask_user.ts). */
|
|
62
|
+
export interface LiveQuestionHandler {
|
|
63
|
+
requestQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, onPending?: (question: PendingQuestion) => void): Promise<string>;
|
|
64
|
+
}
|
|
65
|
+
/** `RunAgentOptions.questionHandler`'s own type — mirrors actauth's own
|
|
66
|
+
* `Approver = LiveApprover | DurableApprover` union exactly, resolved the
|
|
67
|
+
* same `http`-only-fallback way `httpNotifier` resolves an approver, and
|
|
68
|
+
* duck-typed (`core/system-tools/ask_user.ts`'s `isDurableQuestionHandler`)
|
|
69
|
+
* the identical way `Gate` distinguishes a live from a durable `Approver`. */
|
|
70
|
+
export type QuestionHandler = LiveQuestionHandler | DurableQuestionHandler;
|
|
71
|
+
/** Which of the four durable/lifecycle concerns an HttpNotifierConfig
|
|
72
|
+
* should cover — 'approval'/'question' make that concern durable on the
|
|
73
|
+
* `http` channel (a signed webhook standing in for a live human),
|
|
74
|
+
* 'run_start'/'run_finish' wire `onRunStart`/`onRunFinish` to the same
|
|
75
|
+
* webhook instead of requiring them written out by hand. Listing only
|
|
76
|
+
* some of the four is deliberate, not partial config — an agent that
|
|
77
|
+
* wants durable approvals but a live (blocking) question flow lists just
|
|
78
|
+
* `['approval']`. */
|
|
79
|
+
export type HttpNotifierEvent = 'approval' | 'question' | 'run_start' | 'run_finish';
|
|
80
|
+
/** `channel: 'database'`/`'redis'`'s own narrower `events` — both are
|
|
81
|
+
* approval-only backends (a row/queue entry, not a notification to
|
|
82
|
+
* anyone), with no `DurableQuestionHandler` or lifecycle-sender
|
|
83
|
+
* equivalent at all — see `core/http-notify-triggers/database.ts`'s own
|
|
84
|
+
* doc comment for why. Listing `'question'`/`'run_start'`/`'run_finish'`
|
|
85
|
+
* for either channel wouldn't be a type error under the plain
|
|
86
|
+
* `HttpNotifierEvent[]` these two used before, just silently inert; this
|
|
87
|
+
* narrower type catches that at the config-authoring boundary instead. */
|
|
88
|
+
export type ApprovalOnlyHttpNotifierEvent = 'approval';
|
|
89
|
+
/** A `sendEmail` function, passed in rather than a hardcoded provider —
|
|
90
|
+
* see `core/http-notify-triggers/email.ts`'s own doc comment for why
|
|
91
|
+
* (an SMTP client/provider SDK is a dependency this package deliberately
|
|
92
|
+
* doesn't carry). */
|
|
93
|
+
export type SendEmail = (to: string, subject: string, html: string) => Promise<void>;
|
|
94
|
+
/** One row `channel: 'database'` (`core/http-notify-triggers/database.ts`)
|
|
95
|
+
* inserts per pending approval — implement `insert()` against whatever
|
|
96
|
+
* real database/ORM you already have (a single INSERT, a Prisma/Drizzle
|
|
97
|
+
* create call, anything); `DatabaseApprover` needs nothing else
|
|
98
|
+
* from it. */
|
|
99
|
+
export interface PendingApprovalRow {
|
|
100
|
+
pendingId: string;
|
|
101
|
+
tool: string;
|
|
102
|
+
args: Record<string, unknown>;
|
|
103
|
+
scope: Scope;
|
|
104
|
+
reason: string;
|
|
105
|
+
requestedAt: string;
|
|
106
|
+
}
|
|
107
|
+
export interface PendingApprovalsRepository {
|
|
108
|
+
insert(row: PendingApprovalRow): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
/** The `http` channel's one notification config — replaces what used to
|
|
111
|
+
* be up to three separate fields (a channel-keyed `approvers.http`
|
|
112
|
+
* override, `onRunStart`, `onRunFinish`) with the single shape almost
|
|
113
|
+
* every real deployment actually wants: one webhook, receiving whichever
|
|
114
|
+
* of approvals/questions/lifecycle events it asks for. Resolved by
|
|
115
|
+
* `core/http-notifier.ts`'s `resolveHttpNotifier` — see that module for
|
|
116
|
+
* the actual `WebhookNotifier`/signed-POST construction.
|
|
117
|
+
*
|
|
118
|
+
* Only ever consulted for the `http` channel, and only as a fallback
|
|
119
|
+
* *there*: `RunAgentOptions.approver`/`questionHandler` (an adapter's own
|
|
120
|
+
* per-call default — `adapters/http.ts`'s plain route always passes one)
|
|
121
|
+
* still loses to this when it's set, the same "the agent's own explicit
|
|
122
|
+
* choice for this channel wins outright over whatever the adapter would
|
|
123
|
+
* otherwise default to" precedent a channel-keyed override always had
|
|
124
|
+
* here — but an explicit `AgentConfig.onRunStart`/`onRunFinish`, if also
|
|
125
|
+
* set, still wins outright over this field's own derived hooks (no
|
|
126
|
+
* per-call value competes with those the way `options.approver` does).
|
|
127
|
+
*
|
|
128
|
+
* `cli`/`http_stream` never consult this at all, not even as a fallback
|
|
129
|
+
* — they keep the library's own live defaults (`ConsoleApprover`,
|
|
130
|
+
* `CliQuestionHandler`, or whatever live approver the adapter passes as
|
|
131
|
+
* `RunAgentOptions.approver`) automatically, with nothing to configure.
|
|
132
|
+
* This isn't a limitation to work around: a durable webhook stands in
|
|
133
|
+
* for a human who isn't there to answer live, which is exactly what
|
|
134
|
+
* `http` (no open connection to block on) needs and `cli`/`http_stream`
|
|
135
|
+
* (a real human already attached, live, right now) don't. There's also
|
|
136
|
+
* no more AgentConfig-level way to give `cli`/`http_stream` their own
|
|
137
|
+
* custom *live* approver, the way a former `approvers.cli`/
|
|
138
|
+
* `approvers.http_stream` entry could — see `RunAgentOptions.approver`'s
|
|
139
|
+
* own doc comment if a specific call genuinely needs one; a whole demo
|
|
140
|
+
* agent auto-approving on every channel (this repo used to have two)
|
|
141
|
+
* turned out to be exactly the "config re-deriving the same webhook
|
|
142
|
+
* settings a real approver already has" duplication this field exists
|
|
143
|
+
* to remove, not a use case worth keeping a separate field alive for.
|
|
144
|
+
*
|
|
145
|
+
* Six channels ship today, `resolveHttpNotifier` (`core/http-notifier.ts`)
|
|
146
|
+
* switches on which — each one's own sending-side class lives under
|
|
147
|
+
* `core/http-notify-triggers/`:
|
|
148
|
+
* - `'webhook'` — a signed HMAC-SHA256 POST, via
|
|
149
|
+
* `http-notify-triggers/webhook.ts`'s `WebhookNotifier` (one
|
|
150
|
+
* instance for both concerns, same shape as `'slack'`/`'lark'`/
|
|
151
|
+
* `'email'` below — not actauth's own `WebhookApprover` plus a
|
|
152
|
+
* separate loopengine question class anymore; see that file's own
|
|
153
|
+
* doc comment for why they merged) — plus the lifecycle sender
|
|
154
|
+
* neither approvals nor questions needed on their own.
|
|
155
|
+
* - `'slack'` — bot-token `chat.postMessage`, via
|
|
156
|
+
* `http-notify-triggers/slack.ts`'s `SlackNotifier`.
|
|
157
|
+
* - `'lark'` — Lark/Feishu's own card API, via
|
|
158
|
+
* `http-notify-triggers/lark.ts`'s `LarkNotifier` (lower
|
|
159
|
+
* confidence on exact card-schema field names — see that file's own
|
|
160
|
+
* header comment).
|
|
161
|
+
* - `'email'` — a `sendEmail` callback plus a signed, expiring
|
|
162
|
+
* magic-link token per approval/question, via
|
|
163
|
+
* `http-notify-triggers/email.ts`'s `EmailNotifier`.
|
|
164
|
+
* - `'database'`/`'redis'` — not a notification channel at all (see
|
|
165
|
+
* `ApprovalOnlyHttpNotifierEvent`'s own doc comment): a row insert or
|
|
166
|
+
* a queue push for a separate worker/dashboard to poll, via
|
|
167
|
+
* `http-notify-triggers/database.ts`'s `DatabaseApprover`/
|
|
168
|
+
* `http-notify-triggers/redis.ts`'s `RedisQueueApprover`.
|
|
169
|
+
* `'approval'` is the only event either ever does anything with.
|
|
170
|
+
*
|
|
171
|
+
* `'webhook'`/`'slack'`/`'lark'`/`'email'` post an interactive message
|
|
172
|
+
* (buttons, a card, or a link) for `'approval'`/`'question'`, and a plain
|
|
173
|
+
* announcement (no buttons — nothing to resolve) for
|
|
174
|
+
* `'run_start'`/`'run_finish'`. The discriminated union shape (`channel`
|
|
175
|
+
* plus a matching `config`) is what lets a future kind be added later as
|
|
176
|
+
* another member, not another AgentConfig field. For anything none of
|
|
177
|
+
* these cover (a channel not listed above, a live `QuestionHandler` on
|
|
178
|
+
* `http`, ...) set `onRunStart`/`onRunFinish` directly instead, or pass a
|
|
179
|
+
* custom `RunAgentOptions.approver`/`questionHandler` from your own
|
|
180
|
+
* adapter — this field is sugar over the common cases, never a
|
|
181
|
+
* replacement for everything those can express. */
|
|
182
|
+
export type HttpNotifierConfig = {
|
|
183
|
+
channel: 'webhook';
|
|
184
|
+
config: {
|
|
185
|
+
webhookUrl: string;
|
|
186
|
+
webhookSecret: string;
|
|
187
|
+
};
|
|
188
|
+
events: HttpNotifierEvent[];
|
|
189
|
+
} | {
|
|
190
|
+
channel: 'slack';
|
|
191
|
+
config: {
|
|
192
|
+
botToken: string;
|
|
193
|
+
channelId: string;
|
|
194
|
+
};
|
|
195
|
+
events: HttpNotifierEvent[];
|
|
196
|
+
} | {
|
|
197
|
+
channel: 'lark';
|
|
198
|
+
config: {
|
|
199
|
+
appId: string;
|
|
200
|
+
appSecret: string;
|
|
201
|
+
chatId: string;
|
|
202
|
+
};
|
|
203
|
+
events: HttpNotifierEvent[];
|
|
204
|
+
} | {
|
|
205
|
+
channel: 'email';
|
|
206
|
+
config: {
|
|
207
|
+
to: string;
|
|
208
|
+
sendEmail: SendEmail;
|
|
209
|
+
resolveBaseUrl: string;
|
|
210
|
+
answerBaseUrl: string;
|
|
211
|
+
signingSecret: string;
|
|
212
|
+
linkTtlMs?: number;
|
|
213
|
+
};
|
|
214
|
+
events: HttpNotifierEvent[];
|
|
215
|
+
} | {
|
|
216
|
+
channel: 'database';
|
|
217
|
+
config: {
|
|
218
|
+
repository: PendingApprovalsRepository;
|
|
219
|
+
};
|
|
220
|
+
events: ApprovalOnlyHttpNotifierEvent[];
|
|
221
|
+
} | {
|
|
222
|
+
channel: 'redis';
|
|
223
|
+
config: {
|
|
224
|
+
redis: Redis;
|
|
225
|
+
queueKey?: string;
|
|
226
|
+
};
|
|
227
|
+
events: ApprovalOnlyHttpNotifierEvent[];
|
|
228
|
+
};
|
|
3
229
|
/** Declares which real ModelCall an agent module wants built for it, so
|
|
4
230
|
* the module doesn't have to export its own `createModelCall` —
|
|
5
231
|
* `discoverAgents` synthesizes one instead (see `discover-agents.ts`),
|
|
@@ -69,11 +295,15 @@ export interface AgentConfig {
|
|
|
69
295
|
* Omit entirely and it defaults to importing
|
|
70
296
|
* `agents/<name>/tools/index.{ts,js}` and using its exported `tools` —
|
|
71
297
|
* this repo's own aggregation-file convention (see
|
|
72
|
-
* `agents/customer-service/tools/index.ts`)
|
|
73
|
-
* `run-agent.ts` itself
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
298
|
+
* `agents/customer-service/tools/index.ts`). Tried first next to
|
|
299
|
+
* `run-agent.ts` itself, since that file is real compiled code, not
|
|
300
|
+
* data — right when `agents/` is built alongside `core/` into the same
|
|
301
|
+
* `dist/` tree (this repo's own reference agents); falls back to
|
|
302
|
+
* `process.cwd()`, same as `skillsDirs`/`rules`, when nothing's built
|
|
303
|
+
* there — right for a project that only depends on the published
|
|
304
|
+
* package, whose own `agents/<name>/tools/` was never compiled into
|
|
305
|
+
* `node_modules/loopengine` at all.
|
|
306
|
+
* A missing file either place is just `[]`, not an error — same
|
|
77
307
|
* missing-is-fine treatment `skillsDirs` gets. An agent that needs to
|
|
78
308
|
* merge in tools from somewhere else too (e.g.
|
|
79
309
|
* `agents/file-agent/index.ts`'s Composio-sourced ones) can't rely on
|
|
@@ -111,8 +341,61 @@ export interface AgentConfig {
|
|
|
111
341
|
/** Decision when no rule matches, for the inline-array `rules` form only.
|
|
112
342
|
* Default 'ask' — new tools are opt-in, not silently allowed. */
|
|
113
343
|
defaultDecision?: Decision;
|
|
114
|
-
/**
|
|
115
|
-
|
|
344
|
+
/** The `http` channel's notification config — see
|
|
345
|
+
* `HttpNotifierConfig`'s own doc comment for the full precedence rules
|
|
346
|
+
* and why it only ever touches that one channel. There's no equivalent
|
|
347
|
+
* for `cli`/`http_stream`: both always get the library's own live
|
|
348
|
+
* defaults, with nothing to configure. */
|
|
349
|
+
httpNotifier?: HttpNotifierConfig;
|
|
350
|
+
/** Fires once, fire-and-forget, at the start of every `runAgent()`/
|
|
351
|
+
* `resumeAgent()` call on the `http` channel (or no channel at all —
|
|
352
|
+
* see below) — the "someone triggered this agent" signal, for
|
|
353
|
+
* visibility into unattended/cron-triggered runs specifically. Never
|
|
354
|
+
* fires for `cli`/`http_stream`: both already deliver the "it started"
|
|
355
|
+
* signal to whoever's waiting synchronously, as part of that channel's
|
|
356
|
+
* own normal response (a terminal that just ran the command; an SSE
|
|
357
|
+
* connection carrying its own `session` event on the same stream) — a
|
|
358
|
+
* second, redundant copy of information the caller already has, so
|
|
359
|
+
* loopengine itself skips it there rather than leaving every
|
|
360
|
+
* implementation to notice and filter it out. A caller with no
|
|
361
|
+
* `channel` at all (a bespoke script) still fires, since there's no
|
|
362
|
+
* such guaranteed synchronous delivery to assume for it either way.
|
|
363
|
+
* `trigger` distinguishes a fresh message from a durable resume (a
|
|
364
|
+
* human just answered/approved something) — an implementation that
|
|
365
|
+
* only cares about the former (the common "announce a new
|
|
366
|
+
* cron-triggered run started" case) checks that field itself;
|
|
367
|
+
* loopengine doesn't guess which one you care about. Never awaited: an
|
|
368
|
+
* implementation that returns a Promise has its rejection caught and
|
|
369
|
+
* logged, not surfaced to the loop — a failed announcement shouldn't
|
|
370
|
+
* fail the turn that triggered it. See `HUMAN_IN_THE_LOOP.md` for the
|
|
371
|
+
* problem this and `onRunFinish` below solve. */
|
|
372
|
+
onRunStart?: (context: {
|
|
373
|
+
agent: string;
|
|
374
|
+
tenant: string;
|
|
375
|
+
sessionId?: string;
|
|
376
|
+
channel?: ApproverChannel;
|
|
377
|
+
trigger: 'message' | 'resolution';
|
|
378
|
+
}) => void | Promise<void>;
|
|
379
|
+
/** Fires once, fire-and-forget, whenever a turn reaches a genuine
|
|
380
|
+
* terminal outcome — `loop:done`, `max_turns`, or `denied` — never for
|
|
381
|
+
* `pending_approval`/`pending_question` (those are paused, not
|
|
382
|
+
* finished), and never for `cli`/`http_stream` for the exact same
|
|
383
|
+
* "that channel already delivered this synchronously" reasoning
|
|
384
|
+
* `onRunStart` above has (an SSE connection's own `done` event *is*
|
|
385
|
+
* this signal, already). This is `onRunFinish`, not `onRunStart`'s own
|
|
386
|
+
* mirror by accident: the actual gap it closes is durability-specific —
|
|
387
|
+
* the caller that sent the original message may be long disconnected
|
|
388
|
+
* by the time a durably-paused turn resumes and finishes (see
|
|
389
|
+
* `HUMAN_IN_THE_LOOP.md`'s own section on this). Same never-awaited
|
|
390
|
+
* contract as `onRunStart` above. */
|
|
391
|
+
onRunFinish?: (context: {
|
|
392
|
+
agent: string;
|
|
393
|
+
tenant: string;
|
|
394
|
+
sessionId?: string;
|
|
395
|
+
channel?: ApproverChannel;
|
|
396
|
+
text: string;
|
|
397
|
+
stopReason?: 'max_turns' | 'denied';
|
|
398
|
+
}) => void | Promise<void>;
|
|
116
399
|
/** Resolves the ActAuth tenant for a request, from headers (never the
|
|
117
400
|
* body: tenant feeds permission decisions directly, so it has to come
|
|
118
401
|
* from something verified — an Authorization/API-key header checked
|
|
@@ -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 {};
|