loopengine 0.0.20 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +245 -0
- package/README.md +53 -10
- package/dist/core/agent-config.d.ts +282 -3
- package/dist/core/budget.d.ts +42 -0
- package/dist/core/budget.js +39 -0
- package/dist/core/compaction.d.ts +43 -0
- package/dist/core/compaction.js +70 -0
- package/dist/core/http-notifier.d.ts +10 -0
- package/dist/core/http-notifier.js +116 -0
- package/dist/core/http-notify-triggers/database.d.ts +19 -0
- package/dist/core/http-notify-triggers/database.js +53 -0
- package/dist/core/http-notify-triggers/email.d.ts +44 -0
- package/dist/core/http-notify-triggers/email.js +126 -0
- package/dist/core/http-notify-triggers/lark.d.ts +28 -0
- package/dist/core/http-notify-triggers/lark.js +168 -0
- package/dist/core/http-notify-triggers/redis.d.ts +33 -0
- package/dist/core/http-notify-triggers/redis.js +54 -0
- package/dist/core/http-notify-triggers/slack.d.ts +21 -0
- package/dist/core/http-notify-triggers/slack.js +91 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
- package/dist/core/http-notify-triggers/webhook.js +94 -0
- package/dist/core/loop-events.d.ts +60 -29
- package/dist/core/model-calls/openai-model-call.js +33 -17
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +143 -15
- package/dist/core/run-agent.js +321 -66
- package/dist/core/session-store.d.ts +2 -1
- package/dist/core/session-store.js +6 -4
- package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/core/system-tools/ask_user.d.ts +97 -31
- package/dist/core/system-tools/ask_user.js +157 -58
- package/dist/core/system-tools/index.d.ts +1 -1
- package/dist/core/system-tools/index.js +1 -1
- package/dist/core/vector-index.js +1 -1
- package/dist/package.json +6 -0
- package/package.json +16 -6
- package/protocol/loop-event.schema.json +257 -0
- /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
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
|
|
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
|
|
|
@@ -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
|
|
412
|
-
|
|
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
|
-
|
|
|
437
|
-
|
|
|
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,
|
|
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
|
-
/**
|
|
115
|
-
|
|
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
|