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.
Files changed (38) hide show
  1. package/PROTOCOL.md +3 -3
  2. package/README.md +45 -10
  3. package/dist/core/agent-config.d.ts +291 -8
  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 +30 -6
  23. package/dist/core/model-calls/openai-model-call.js +32 -16
  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 +141 -13
  27. package/dist/core/run-agent.js +316 -57
  28. package/dist/core/session-store.d.ts +1 -0
  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/package.json +6 -0
  36. package/package.json +14 -4
  37. package/protocol/loop-event.schema.json +2 -2
  38. /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
- - **`ui-examples/react/`**, **`ui-examples/vue/`** — a hook/composable
239
- and a chat component each, built on `core/client.ts`, rendering
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 wait on `AgentConfig.approver` (see "Tool permission and
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 `AgentConfig.approver`
297
- (defaults to blocking on stdin — swap in a real one, e.g. Slack-backed,
298
- for production).
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 `ui-examples/react`/`ui-examples/vue` hooks are
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`/`approver` are custom or
420
- defaulted. Backed by `GET /agents/:name/config`, which reuses the same
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, Approver } from 'actauth';
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`), resolved next to
73
- * `run-agent.ts` itself (not `process.cwd()`, unlike `skillsDirs`/
74
- * `rules` — that file is real compiled code, not data, so it has to be
75
- * found next to whichever build of `run-agent.ts` is actually running).
76
- * A missing file there is just `[]`, not an error — same
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
- /** Default ConsoleApprover (blocks on stdin) — pass e.g. a Slack-backed Approver for unattended agents. */
115
- approver?: Approver;
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 {};