loopengine 0.0.20 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/PROTOCOL.md +245 -0
  2. package/README.md +53 -10
  3. package/dist/core/agent-config.d.ts +282 -3
  4. package/dist/core/budget.d.ts +42 -0
  5. package/dist/core/budget.js +39 -0
  6. package/dist/core/compaction.d.ts +43 -0
  7. package/dist/core/compaction.js +70 -0
  8. package/dist/core/http-notifier.d.ts +10 -0
  9. package/dist/core/http-notifier.js +116 -0
  10. package/dist/core/http-notify-triggers/database.d.ts +19 -0
  11. package/dist/core/http-notify-triggers/database.js +53 -0
  12. package/dist/core/http-notify-triggers/email.d.ts +44 -0
  13. package/dist/core/http-notify-triggers/email.js +126 -0
  14. package/dist/core/http-notify-triggers/lark.d.ts +28 -0
  15. package/dist/core/http-notify-triggers/lark.js +168 -0
  16. package/dist/core/http-notify-triggers/redis.d.ts +33 -0
  17. package/dist/core/http-notify-triggers/redis.js +54 -0
  18. package/dist/core/http-notify-triggers/slack.d.ts +21 -0
  19. package/dist/core/http-notify-triggers/slack.js +91 -0
  20. package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
  21. package/dist/core/http-notify-triggers/webhook.js +94 -0
  22. package/dist/core/loop-events.d.ts +60 -29
  23. package/dist/core/model-calls/openai-model-call.js +33 -17
  24. package/dist/core/recovery.d.ts +49 -0
  25. package/dist/core/recovery.js +124 -0
  26. package/dist/core/run-agent.d.ts +143 -15
  27. package/dist/core/run-agent.js +321 -66
  28. package/dist/core/session-store.d.ts +2 -1
  29. package/dist/core/session-store.js +6 -4
  30. package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
  31. package/dist/core/system-tools/ask_user.d.ts +97 -31
  32. package/dist/core/system-tools/ask_user.js +157 -58
  33. package/dist/core/system-tools/index.d.ts +1 -1
  34. package/dist/core/system-tools/index.js +1 -1
  35. package/dist/core/vector-index.js +1 -1
  36. package/dist/package.json +6 -0
  37. package/package.json +16 -6
  38. package/protocol/loop-event.schema.json +257 -0
  39. /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
package/PROTOCOL.md ADDED
@@ -0,0 +1,245 @@
1
+ # The loopengine event protocol
2
+
3
+ **Status:** v1 · **Schema:** [`protocol/loop-event.schema.json`](protocol/loop-event.schema.json)
4
+
5
+ One `runAgent()` turn emits an ordered sequence of typed `LoopEvent`
6
+ objects. That sequence — not any particular transport — is the actual
7
+ contract. The SSE stream, the plain HTTP response, and the CLI's `--json`
8
+ mode are three different ways of delivering the exact same objects; a
9
+ client written against one of them can switch to another without
10
+ changing how it interprets a single event.
11
+
12
+ This document specifies that sequence: every event's shape, when it
13
+ fires, what's guaranteed about ordering, and how each transport carries
14
+ it. If you're building a UI or integration against loopengine in any
15
+ language — not just TypeScript — this is what to implement against
16
+ instead of reverse-engineering `core/loop-events.ts`.
17
+
18
+ ## Stability
19
+
20
+ This is v1. Within v1:
21
+
22
+ - **Additive is safe to rely on being additive.** A new event `type` may
23
+ be introduced; a new optional field may be added to an existing event.
24
+ A consumer that ignores unrecognized `type` values and unrecognized
25
+ fields will never break.
26
+ - **These are breaking changes**, and won't happen inside v1: renaming or
27
+ removing an event `type`; renaming, removing, or changing the meaning
28
+ of an existing field; turning an optional field required; changing an
29
+ established transport's framing (SSE's `event: <type>\ndata: <json>`,
30
+ the plain response's `events` array, or `--json`'s one-object-per-line
31
+ NDJSON).
32
+ - A breaking change ships as v2, documented as a new section here, not a
33
+ silent edit to this one.
34
+
35
+ ## Lifecycle
36
+
37
+ Every event has a `type` field, which doubles as the discriminant and,
38
+ on the SSE transport, the literal frame name. Two families:
39
+
40
+ - **Engine events** — emitted by `runAgent()` itself, identically
41
+ regardless of which transport is carrying them.
42
+ - **Adapter events** — synthesized by the HTTP/CLI adapter around a turn
43
+ (`session`, `approval:pending`, `question:pending`, `done`, `error`).
44
+ `runAgent()` itself has no notion of sessions or HTTP-level failures;
45
+ these exist because a real caller needs them.
46
+
47
+ A turn always starts with `session` (streamed) and always ends with
48
+ exactly one `done`. The only real branch is the ActAuth Gate's decision
49
+ on each requested tool call:
50
+
51
+ ```
52
+ session → budget:check → assistant:text? → actauth:decision
53
+ │
54
+ ┌────────────────────────┴───────────────────────┐
55
+ │ allow │ deny
56
+ ▼ ▼
57
+ [approval:pending first, tool:result (statusText: "Denied.")
58
+ if the rule said "ask"] — no execution, no tool:started.
59
+ │ Any sibling call already approved
60
+ ▼ in the same batch gets loop:skipped
61
+ tool:started + its own tool:result ("Skipped.")
62
+ ▼ │
63
+ toollane:result │
64
+ ▼ │
65
+ tool:result (Approved./Error.) │
66
+ │ │
67
+ └─────────────────┬──────────────────────────────┘
68
+ ▼
69
+ loops back to budget:check for the
70
+ next tool call, or the model's next turn,
71
+ until exactly one of:
72
+ loop:done / loop:max_turns / loop:denied
73
+ ▼
74
+ done
75
+ ```
76
+
77
+ `approval:pending`/`question:pending` are the only events without a
78
+ fixed position in that diagram — they can arrive at any point a tool call
79
+ is being evaluated. At most one is ever pending at a time (the Gate
80
+ evaluates one call before starting the next), so a consumer never needs
81
+ to track more than one open decision per turn.
82
+
83
+ ## Event catalog
84
+
85
+ ### Engine — emitted by `runAgent()`
86
+
87
+ | `type` | Fires | Fields |
88
+ |---|---|---|
89
+ | `budget:check` | Once per model call, before it | `action: 'ok'\|'nudge'\|'over_hard_limit'`, `usedTokens`, `budgetTokens`, `ratio`, `nudge?` |
90
+ | `prompt:compaction` | A prompt was rejected as too large | `from`, `to` (message-array lengths, not tokens) |
91
+ | `assistant:text` | The model's "I'll do X" preamble alongside a tool call | `text` |
92
+ | `actauth:decision` | Once per requested tool call | `tool`, `decision: 'allow'\|'ask'\|'deny'`, `reason` |
93
+ | `tool:started` | The instant a call is decided, before it runs | `id`, `tool`, `args`, `detailText` |
94
+ | `tool:result` | A call resolves — allowed, denied, or skipped | `id`, `tool`, `args?`, `detailText?`, `statusText` |
95
+ | `toollane:result` | ToolLane's own raw outcome, alongside `tool:result` | `name`, `summary` |
96
+ | `skill:loaded` | A `Skill` block resolved to a real skill body | `skill` |
97
+ | `loop:skipped` | A sibling call never ran because another was denied | `name`, `deniedTools` |
98
+ | `loop:max_turns` | Hit `config.maxTurns` with no final answer | `maxTurns` |
99
+ | `loop:denied` | A human denied a call — the turn stops | `deniedTools` |
100
+ | `loop:done` | A genuine finish — no more tool calls | `text` |
101
+
102
+ ### Adapter — synthesized around a turn
103
+
104
+ | `type` | Fires | Fields |
105
+ |---|---|---|
106
+ | `session` | First event of every streamed turn | `sessionId` |
107
+ | `approval:pending` | A tool call needs a live human decision | `id`, `tool`, `args`, `scope: {tenant,environment,agent}`, `reason`, `requestedAt` |
108
+ | `question:pending` | The `ask_user` tool raised a real question | `id`, `question`, `options?`, `agent`, `sessionId?`, `requestedAt` |
109
+ | `done` | The turn is over — wraps `loop:done`/`max_turns`/`denied` | `text`, `stopReason?: 'max_turns'\|'denied'` |
110
+ | `error` | Something failed after the response was already committed | `error` |
111
+
112
+ Full field types, `required` vs optional, and `additionalProperties: false`
113
+ enforcement live in [`protocol/loop-event.schema.json`](protocol/loop-event.schema.json)
114
+ — the table above is a summary, the schema is the source of truth.
115
+
116
+ ## Transports
117
+
118
+ ### SSE stream — live, frame by frame
119
+
120
+ ```
121
+ POST /agents/:name/messages/stream
122
+ content-type: application/json
123
+
124
+ {"message": "order A-1001 arrived broken, can you refund it?"}
125
+ ```
126
+
127
+ Each frame is `event: <type>\ndata: <json>\n\n`, `<json>` being the whole
128
+ event object (`type` included, redundantly with the frame name — parse
129
+ whichever is more convenient):
130
+
131
+ ```
132
+ event: tool:started
133
+ data: {"type":"tool:started","id":"call_1","tool":"issue_refund","args":{"orderId":"A-1001"},"detailText":"matched rule 'refunds-need-approval'"}
134
+
135
+ event: approval:pending
136
+ data: {"type":"approval:pending","id":"3f9c...","tool":"issue_refund","args":{"orderId":"A-1001"},"scope":{"tenant":"default","environment":"production","agent":"customer-service"},"reason":"matched rule 'refunds-need-approval'","requestedAt":"2026-08-28T01:34:32.193Z"}
137
+ ```
138
+
139
+ ### Plain HTTP — one request, the full array
140
+
141
+ ```
142
+ POST /agents/:name/messages
143
+ content-type: application/json
144
+
145
+ {"message": "..."}
146
+ ```
147
+
148
+ Two response shapes, both carrying the identical `events` array a
149
+ streamed version of the same turn would have delivered live:
150
+
151
+ **200 — the turn finished:**
152
+
153
+ ```json
154
+ {
155
+ "text": "Refunded $42.00 to your original payment method.",
156
+ "sessionId": "5a83c6b4-...",
157
+ "events": [ /* every LoopEvent this turn produced, in order */ ],
158
+ "stopReason": "max_turns"
159
+ }
160
+ ```
161
+
162
+ `stopReason` is present only for a synthetic finish (`max_turns`/`denied`).
163
+
164
+ **202 — the turn needs a decision right now:**
165
+
166
+ ```json
167
+ {
168
+ "pending": true,
169
+ "type": "approval",
170
+ "id": "3f9c...",
171
+ "sessionId": "5a83c6b4-...",
172
+ "tool": "issue_refund",
173
+ "args": { "orderId": "A-1001" },
174
+ "scope": { "tenant": "default", "environment": "production", "agent": "customer-service" },
175
+ "reason": "matched rule 'refunds-need-approval'",
176
+ "approveUrl": "/approvals/3f9c.../approve",
177
+ "denyUrl": "/approvals/3f9c.../deny",
178
+ "events": [ /* everything so far */ ],
179
+ "statusUrl": "/agents/customer-service/sessions/5a83c6b4-..."
180
+ }
181
+ ```
182
+
183
+ (`type: "question"` carries `question`/`options`/`answerUrl` instead.)
184
+
185
+ ### CLI — one JSON object per line
186
+
187
+ ```
188
+ loopengine run customer-service --json "order A-1001 arrived broken, can you refund it?"
189
+ ```
190
+
191
+ ```
192
+ {"type":"session","sessionId":"5a83c6b4-..."}
193
+ {"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":8000,"ratio":0.00025}
194
+ {"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
195
+ {"type":"done","text":"Refunded $42.00..."}
196
+ ```
197
+
198
+ Every line is a complete `LoopEvent`, pipeable straight into `jq` or
199
+ anything else that reads NDJSON. Without `--json`, stdout stays
200
+ human-readable (just the final answer) and events go to stderr instead.
201
+
202
+ ### Resolving a pending item
203
+
204
+ ```
205
+ POST /approvals/:id/approve POST /approvals/:id/deny
206
+ POST /questions/:id/answer {"answer": "..."}
207
+ ```
208
+
209
+ All three return the exact same two-shape family as plain
210
+ `POST /messages` above (200 done, or 202 the *next* pending item, if the
211
+ turn needed another decision) — a caller can treat send → approve/deny/
212
+ answer → approve/deny/answer → ... as one uniform loop regardless of
213
+ which endpoint produced the response.
214
+
215
+ ## Validating against the schema
216
+
217
+ ```js
218
+ import Ajv2020 from 'ajv/dist/2020.js'
219
+ import addFormats from 'ajv-formats' // schema uses format: 'date-time'
220
+ import schema from './protocol/loop-event.schema.json' with { type: 'json' }
221
+
222
+ const ajv = new Ajv2020({ strict: true })
223
+ addFormats(ajv)
224
+ const validate = ajv.compile(schema)
225
+ validate(event) // true/false; validate.errors on failure
226
+ ```
227
+
228
+ The schema is hand-maintained alongside `core/loop-events.ts`, not
229
+ generated from it — a PR that changes one without the other is
230
+ incomplete. It's plain JSON Schema (draft 2020-12), so it works from any
231
+ language with a validator, not just JavaScript.
232
+
233
+ ## Reference implementations
234
+
235
+ - **`core/client.ts`** — the framework-agnostic TypeScript client this
236
+ spec was extracted from: SSE parsing, the plain-request helpers,
237
+ approve/deny/answer. Re-exported through the package's own entry point.
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
+
242
+ Implementing this protocol in another language means reproducing what
243
+ `core/client.ts` does: parse whichever transport you chose into
244
+ `LoopEvent` objects per this schema, and drive your own UI or logic off
245
+ `type`.
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
 
@@ -403,13 +435,23 @@ into the playground and config page below.
403
435
  that same loop-step event stream render live instead of reading raw SSE
404
436
  frames. Same `/messages/stream` route underneath.
405
437
 
438
+ **Building your own client:** the playground is one UI on top of a typed
439
+ event protocol, not the only way to consume a turn — see
440
+ [`PROTOCOL.md`](PROTOCOL.md) for the full `LoopEvent` catalog, the SSE/
441
+ plain-HTTP/CLI transport bindings, and a JSON Schema
442
+ (`protocol/loop-event.schema.json`) any language can validate against.
443
+ `core/client.ts` and the `examples/chatbox/react`/`examples/chatbox/vue`
444
+ hooks are reference implementations of it.
445
+
406
446
  **Agent config page:** open `http://localhost:8787/agents/config`
407
447
  (optionally `?agent=<name>`) to browse every registered agent's resolved
408
448
  config — system prompt, model, tools (with their JSON schemas and
409
449
  parallel-safety), the actual ActAuth rules that would be enforced (source,
410
450
  default decision, per-rule scope/tool/decision/`when`), and whether
411
- `sessionIdFor`/`tenantFor`/`isSafeTool`/`approver` are custom or
412
- 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
413
455
  rule/tool resolution `runAgent()` itself uses, so it can't drift out of
414
456
  sync with what a real request actually gets. Never returns
415
457
  `AgentModelConfig.apiKey`.
@@ -432,9 +474,10 @@ testing), export your own `createModelCall(): ModelCall` instead.
432
474
 
433
475
  | Package | Responsibility |
434
476
  |---|---|
435
- | [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny) with human-approval hooks |
436
- | [`contextclip`](https://www.npmjs.com/package/contextclip) | Context-window budget tracking |
437
- | [`reflowkit`](https://www.npmjs.com/package/reflowkit) | Retries prompt-too-long / truncated-output failures |
477
+ | [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny/pending) with live and durable human-approval hooks |
478
+ | `core/budget.ts` / `core/compaction.ts` | Context-window budget tracking and tail-preserving compaction (vendored in-repo, not an external dependency) |
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` |
438
481
  | [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable session log with crash-interruption detection |
439
482
  | [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md` discovery and lazy loading |
440
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`),
@@ -111,8 +337,61 @@ export interface AgentConfig {
111
337
  /** Decision when no rule matches, for the inline-array `rules` form only.
112
338
  * Default 'ask' — new tools are opt-in, not silently allowed. */
113
339
  defaultDecision?: Decision;
114
- /** Default ConsoleApprover (blocks on stdin) — pass e.g. a Slack-backed Approver for unattended agents. */
115
- approver?: Approver;
340
+ /** The `http` channel's notification config — see
341
+ * `HttpNotifierConfig`'s own doc comment for the full precedence rules
342
+ * and why it only ever touches that one channel. There's no equivalent
343
+ * for `cli`/`http_stream`: both always get the library's own live
344
+ * defaults, with nothing to configure. */
345
+ httpNotifier?: HttpNotifierConfig;
346
+ /** Fires once, fire-and-forget, at the start of every `runAgent()`/
347
+ * `resumeAgent()` call on the `http` channel (or no channel at all —
348
+ * see below) — the "someone triggered this agent" signal, for
349
+ * visibility into unattended/cron-triggered runs specifically. Never
350
+ * fires for `cli`/`http_stream`: both already deliver the "it started"
351
+ * signal to whoever's waiting synchronously, as part of that channel's
352
+ * own normal response (a terminal that just ran the command; an SSE
353
+ * connection carrying its own `session` event on the same stream) — a
354
+ * second, redundant copy of information the caller already has, so
355
+ * loopengine itself skips it there rather than leaving every
356
+ * implementation to notice and filter it out. A caller with no
357
+ * `channel` at all (a bespoke script) still fires, since there's no
358
+ * such guaranteed synchronous delivery to assume for it either way.
359
+ * `trigger` distinguishes a fresh message from a durable resume (a
360
+ * human just answered/approved something) — an implementation that
361
+ * only cares about the former (the common "announce a new
362
+ * cron-triggered run started" case) checks that field itself;
363
+ * loopengine doesn't guess which one you care about. Never awaited: an
364
+ * implementation that returns a Promise has its rejection caught and
365
+ * logged, not surfaced to the loop — a failed announcement shouldn't
366
+ * fail the turn that triggered it. See `HUMAN_IN_THE_LOOP.md` for the
367
+ * problem this and `onRunFinish` below solve. */
368
+ onRunStart?: (context: {
369
+ agent: string;
370
+ tenant: string;
371
+ sessionId?: string;
372
+ channel?: ApproverChannel;
373
+ trigger: 'message' | 'resolution';
374
+ }) => void | Promise<void>;
375
+ /** Fires once, fire-and-forget, whenever a turn reaches a genuine
376
+ * terminal outcome — `loop:done`, `max_turns`, or `denied` — never for
377
+ * `pending_approval`/`pending_question` (those are paused, not
378
+ * finished), and never for `cli`/`http_stream` for the exact same
379
+ * "that channel already delivered this synchronously" reasoning
380
+ * `onRunStart` above has (an SSE connection's own `done` event *is*
381
+ * this signal, already). This is `onRunFinish`, not `onRunStart`'s own
382
+ * mirror by accident: the actual gap it closes is durability-specific —
383
+ * the caller that sent the original message may be long disconnected
384
+ * by the time a durably-paused turn resumes and finishes (see
385
+ * `HUMAN_IN_THE_LOOP.md`'s own section on this). Same never-awaited
386
+ * contract as `onRunStart` above. */
387
+ onRunFinish?: (context: {
388
+ agent: string;
389
+ tenant: string;
390
+ sessionId?: string;
391
+ channel?: ApproverChannel;
392
+ text: string;
393
+ stopReason?: 'max_turns' | 'denied';
394
+ }) => void | Promise<void>;
116
395
  /** Resolves the ActAuth tenant for a request, from headers (never the
117
396
  * body: tenant feeds permission decisions directly, so it has to come
118
397
  * from something verified — an Authorization/API-key header checked