loopengine 0.0.20 → 0.0.21
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 +10 -2
- package/dist/core/loop-events.d.ts +31 -24
- package/dist/core/model-calls/openai-model-call.js +1 -1
- package/dist/core/run-agent.d.ts +2 -2
- package/dist/core/run-agent.js +33 -20
- package/dist/core/session-store.d.ts +1 -1
- package/dist/core/vector-index.js +1 -1
- package/package.json +3 -3
- package/protocol/loop-event.schema.json +257 -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
|
+
- **`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.
|
|
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
|
@@ -403,6 +403,14 @@ into the playground and config page below.
|
|
|
403
403
|
that same loop-step event stream render live instead of reading raw SSE
|
|
404
404
|
frames. Same `/messages/stream` route underneath.
|
|
405
405
|
|
|
406
|
+
**Building your own client:** the playground is one UI on top of a typed
|
|
407
|
+
event protocol, not the only way to consume a turn — see
|
|
408
|
+
[`PROTOCOL.md`](PROTOCOL.md) for the full `LoopEvent` catalog, the SSE/
|
|
409
|
+
plain-HTTP/CLI transport bindings, and a JSON Schema
|
|
410
|
+
(`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.
|
|
413
|
+
|
|
406
414
|
**Agent config page:** open `http://localhost:8787/agents/config`
|
|
407
415
|
(optionally `?agent=<name>`) to browse every registered agent's resolved
|
|
408
416
|
config — system prompt, model, tools (with their JSON schemas and
|
|
@@ -433,8 +441,8 @@ testing), export your own `createModelCall(): ModelCall` instead.
|
|
|
433
441
|
| Package | Responsibility |
|
|
434
442
|
|---|---|
|
|
435
443
|
| [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny) with human-approval hooks |
|
|
436
|
-
|
|
|
437
|
-
|
|
|
444
|
+
| `core/budget.ts` / `core/compaction.ts` | Context-window budget tracking and tail-preserving compaction (vendored in-repo, not an external dependency) |
|
|
445
|
+
| `core/recovery.ts` | Retries prompt-too-long / truncated-output failures (vendored in-repo, not an external dependency) |
|
|
438
446
|
| [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable session log with crash-interruption detection |
|
|
439
447
|
| [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md` discovery and lazy loading |
|
|
440
448
|
| [`toollane`](https://www.npmjs.com/package/toollane) | Parallel/solo tool-call scheduling |
|
|
@@ -1,31 +1,35 @@
|
|
|
1
|
-
import type { CheckResult } from '
|
|
2
|
-
import type { RecoveryAction } from 'reflowkit';
|
|
1
|
+
import type { CheckResult } from './budget.js';
|
|
3
2
|
import type { Decision, PendingApproval } from 'actauth';
|
|
4
3
|
import type { PendingQuestion } from './system-tools/index.js';
|
|
5
|
-
/**
|
|
4
|
+
/** BudgetTracker's own read-only budget check, run once per loop turn
|
|
6
5
|
* before the model call — reported unchanged (spread, not renamed) so
|
|
7
|
-
* this never drifts from whatever
|
|
6
|
+
* this never drifts from whatever budget.ts's own CheckResult actually
|
|
8
7
|
* contains. */
|
|
9
|
-
export type
|
|
10
|
-
type: '
|
|
8
|
+
export type BudgetCheckEvent = {
|
|
9
|
+
type: 'budget:check';
|
|
11
10
|
} & CheckResult;
|
|
12
|
-
/**
|
|
13
|
-
* large — `from`/`to` are message-array lengths (this turn's own
|
|
11
|
+
/** compaction.ts's own recovery kicked in because the prompt was rejected as
|
|
12
|
+
* too large — `from`/`to` are message-array lengths (this turn's own
|
|
14
13
|
* newMessages included), not token counts; see run-agent.ts's own
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
* recovery.call onPromptTooLong hook. Named after *what triggered it and
|
|
15
|
+
* what happened* (the prompt was too long, so it got compacted), not
|
|
16
|
+
* after which module did the work — "compaction:recover" still didn't
|
|
17
|
+
* say this only ever fires for the prompt-too-long case specifically,
|
|
18
|
+
* same problem contextclip:check's own rename from a package name was
|
|
19
|
+
* fixing in the first place.
|
|
20
|
+
*
|
|
21
|
+
* No sibling event for recovery.ts's other two failure modes
|
|
22
|
+
* (media-too-large, truncated output) — neither has an onMediaTooLarge/
|
|
23
|
+
* onTruncated hook actually wired up in run-agent.ts today, so neither
|
|
24
|
+
* can currently fire at all. A prior 'recovery:summary' aggregate event
|
|
25
|
+
* covering all three was removed for being ~100% redundant with this one
|
|
26
|
+
* while that's true; it can come back once a second real recovery type
|
|
27
|
+
* exists to aggregate. */
|
|
28
|
+
export interface PromptCompactionEvent {
|
|
29
|
+
type: 'prompt:compaction';
|
|
18
30
|
from: number;
|
|
19
31
|
to: number;
|
|
20
32
|
}
|
|
21
|
-
/** Ordered log of whatever reflowkit's own Reflow.call had to do to get a
|
|
22
|
-
* usable response this turn (prompt-too-long retries, truncation
|
|
23
|
-
* retries, ...) — empty-array case never fires this event at all (see
|
|
24
|
-
* run-agent.ts: only logged when recoveries.length > 0). */
|
|
25
|
-
export interface ReflowRecoveriesEvent {
|
|
26
|
-
type: 'reflow:recoveries';
|
|
27
|
-
recoveries: RecoveryAction[];
|
|
28
|
-
}
|
|
29
33
|
/** The model's own "I'll do X" preamble alongside a tool_use request —
|
|
30
34
|
* the only *live* signal of assistant text mid-turn (the final answer
|
|
31
35
|
* only ever arrives via 'loop:done'/'done', once, at the very end). */
|
|
@@ -77,10 +81,13 @@ export interface ToolLaneResultEvent {
|
|
|
77
81
|
summary: string;
|
|
78
82
|
}
|
|
79
83
|
/** A `Skill` tool_use block was resolved to a real skill body via
|
|
80
|
-
* SkillGarden — not gated by
|
|
81
|
-
* why Skill invocation isn't
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
* SkillGarden's own lazy "index-now-load-later" design — not gated by
|
|
85
|
+
* ActAuth (see run-agent.ts's own comment on why Skill invocation isn't
|
|
86
|
+
* a real tool call). Named after what happened (the skill's full body
|
|
87
|
+
* got loaded), not after which library method was called — same
|
|
88
|
+
* reasoning budget:check's own rename from contextclip:check used. */
|
|
89
|
+
export interface SkillLoadEvent {
|
|
90
|
+
type: 'skill:loaded';
|
|
84
91
|
skill: string;
|
|
85
92
|
}
|
|
86
93
|
/** An approved call never ran because a sibling call in the same turn's
|
|
@@ -113,7 +120,7 @@ export interface LoopDoneEvent {
|
|
|
113
120
|
/** Every event runAgent() itself can emit via RunAgentOptions.onEvent —
|
|
114
121
|
* not the adapter-synthesized ones below, which no caller of runAgent()
|
|
115
122
|
* itself ever produces. */
|
|
116
|
-
export type RunAgentLoopEvent =
|
|
123
|
+
export type RunAgentLoopEvent = BudgetCheckEvent | PromptCompactionEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillLoadEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | LoopDoneEvent;
|
|
117
124
|
/** adapters/http.ts echoing back which session id this turn used — first
|
|
118
125
|
* event of every streamed turn, so a caller that omitted sessionId can
|
|
119
126
|
* capture what got generated. Not something runAgent() itself knows
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// stop_reason is normalized to the vocabulary anthropic-model-call.ts
|
|
15
15
|
// already established ('end_turn' / 'tool_use' / 'max_tokens') rather than
|
|
16
|
-
// passed through as OpenAI's own finish_reason strings —
|
|
16
|
+
// passed through as OpenAI's own finish_reason strings — recovery.ts's
|
|
17
17
|
// defaultIsTruncated checks a translated ModelResponse for stop_reason ===
|
|
18
18
|
// 'max_tokens', so a provider-agnostic caller wiring up onTruncated later
|
|
19
19
|
// gets the same behavior regardless of which ModelCall is plugged in.
|
package/dist/core/run-agent.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface ModelResponse {
|
|
|
35
35
|
content: ModelContentBlock[];
|
|
36
36
|
}
|
|
37
37
|
/** loopengine's own conversation-message type — a superset of
|
|
38
|
-
*
|
|
38
|
+
* budget.ts's own `{role, content: string}`: `content` can also be a
|
|
39
39
|
* `ModelContentBlock[]`, the same block shape `ModelResponse.content`
|
|
40
40
|
* already used, so a model's tool_use requests and this loop's
|
|
41
41
|
* tool_result replies round-trip with real per-call identity
|
|
@@ -109,7 +109,7 @@ export interface RunAgentResult {
|
|
|
109
109
|
* the caller: store/transmit it, don't inspect or mutate it. */
|
|
110
110
|
history: Message[];
|
|
111
111
|
/** Exactly what this turn added — safe to durably append regardless of
|
|
112
|
-
* whether
|
|
112
|
+
* whether compaction.ts's own recovery reshaped `history` mid-turn. A caller
|
|
113
113
|
* that persists conversations (see session-store.ts) should use this
|
|
114
114
|
* instead of diffing `history` by length against what it loaded: that
|
|
115
115
|
* only works if `history` only ever grows, which stops being true the
|
package/dist/core/run-agent.js
CHANGED
|
@@ -9,9 +9,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
9
9
|
import { parse as parseYaml } from 'yaml';
|
|
10
10
|
import { Gate, RuleSet, ConsoleApprover } from 'actauth';
|
|
11
11
|
import { SkillGarden } from 'skillgarden';
|
|
12
|
-
import {
|
|
12
|
+
import { BudgetTracker } from './budget.js';
|
|
13
|
+
import { Compactor } from './compaction.js';
|
|
13
14
|
import { ToolLane } from 'toollane';
|
|
14
|
-
import {
|
|
15
|
+
import { Recovery } from './recovery.js';
|
|
15
16
|
import { loadAgentModule } from './discover-agents.js';
|
|
16
17
|
import { agentAsTool } from './agent-as-tool.js';
|
|
17
18
|
import { loadGatewayToolsFromDir } from './gateway-tools.js';
|
|
@@ -48,11 +49,11 @@ export const systemSkillsDir = join(dirname(fileURLToPath(import.meta.url)), 'sy
|
|
|
48
49
|
export function dedupeToolsByName(list) {
|
|
49
50
|
return [...new Map(list.map((t) => [t.name, t])).values()];
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
* token usage —
|
|
52
|
+
/** budget.ts/compaction.ts only ever need a flat string per message to
|
|
53
|
+
* estimate token usage — neither needs to understand tool_use/tool_result
|
|
53
54
|
* structure, so this is a one-way, read-only projection, never something
|
|
54
55
|
* that needs to be un-projected back. */
|
|
55
|
-
function
|
|
56
|
+
function flattenForBudget(message) {
|
|
56
57
|
if (typeof message.content === 'string')
|
|
57
58
|
return { role: message.role, content: message.content };
|
|
58
59
|
const text = message.content
|
|
@@ -295,7 +296,14 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
295
296
|
// Also the tail-preservation window recover() below relies on — kept as
|
|
296
297
|
// one named constant so the two can never drift out of sync.
|
|
297
298
|
const tailMessages = 4;
|
|
298
|
-
|
|
299
|
+
// Two separate objects, not one — budget.ts's BudgetTracker (read-only
|
|
300
|
+
// check) and compaction.ts's Compactor (the actual recovery) are
|
|
301
|
+
// deliberately distinct capabilities now, constructed with the same
|
|
302
|
+
// budgetTokens/softThreshold so a nudge firing and compaction's own
|
|
303
|
+
// recovery target agree — see budget.ts's own BudgetTrackerOptions doc
|
|
304
|
+
// comment.
|
|
305
|
+
const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
|
|
306
|
+
const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
|
|
299
307
|
const rules = loadRules(config);
|
|
300
308
|
const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
|
|
301
309
|
// No explicit isSafeTool: fall back to each called tool's own `safe`
|
|
@@ -315,21 +323,21 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
315
323
|
messages.push(message);
|
|
316
324
|
newMessages.push(message);
|
|
317
325
|
}
|
|
318
|
-
const
|
|
326
|
+
const recovery = new Recovery({
|
|
319
327
|
onPromptTooLong: async (currentMessages) => {
|
|
320
328
|
// newMessages (this turn's own content — not durably stored
|
|
321
|
-
// anywhere else yet) must never be handed to
|
|
329
|
+
// anywhere else yet) must never be handed to the compactor at all.
|
|
322
330
|
// An earlier version of this reconciliation tried to recover
|
|
323
|
-
// newMessages *after* compaction by reusing whatever
|
|
324
|
-
// synthetic head contained — that's unsound:
|
|
331
|
+
// newMessages *after* compaction by reusing whatever the
|
|
332
|
+
// compactor's synthetic head contained — that's unsound: its cheap
|
|
325
333
|
// "drain" stage doesn't produce a head at all, it just deletes old
|
|
326
334
|
// messages outright, trusting there's a durable copy elsewhere.
|
|
327
335
|
// That trust is only valid for the *prior* portion; only that
|
|
328
336
|
// portion is safe to compact at all.
|
|
329
337
|
const priorCount = currentMessages.length - newMessages.length;
|
|
330
338
|
const priorPortion = currentMessages.slice(0, priorCount);
|
|
331
|
-
const result = await
|
|
332
|
-
log({ type: '
|
|
339
|
+
const result = await compactor.recover(priorPortion.map(flattenForBudget));
|
|
340
|
+
log({ type: 'prompt:compaction', from: currentMessages.length, to: result.messages.length + newMessages.length });
|
|
333
341
|
if (result.action === 'unchanged')
|
|
334
342
|
return currentMessages;
|
|
335
343
|
// Same tail-preservation reuse as before, just scoped to
|
|
@@ -339,13 +347,13 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
339
347
|
const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
|
|
340
348
|
const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
|
|
341
349
|
const recovered = [...newHead, ...structuredTail, ...newMessages];
|
|
342
|
-
//
|
|
350
|
+
// Recovery.call() only ever returns {value, recoveries, truncated} —
|
|
343
351
|
// it never hands back whatever `currentMessages` became after a
|
|
344
352
|
// retry, so recovery would otherwise only ever affect the one
|
|
345
353
|
// retried call. Reassigning the outer `messages` binding here (this
|
|
346
354
|
// hook is the only place that has both the recovered array and a
|
|
347
355
|
// reason to persist it) is what makes recovery durable: every
|
|
348
|
-
// subsequent
|
|
356
|
+
// subsequent budgetTracker.check() in this loop, and the `history`
|
|
349
357
|
// this function eventually returns, both see the compacted
|
|
350
358
|
// conversation from this point on — not the original, ever-growing
|
|
351
359
|
// one. newMessages itself needs no reconciliation at all — it was
|
|
@@ -413,13 +421,18 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
413
421
|
log({ type: 'loop:max_turns', maxTurns });
|
|
414
422
|
return { text, history: messages, newMessages, stopReason: 'max_turns' };
|
|
415
423
|
}
|
|
416
|
-
const budget =
|
|
417
|
-
log({ type: '
|
|
424
|
+
const budget = budgetTracker.check(messages.map(flattenForBudget));
|
|
425
|
+
log({ type: 'budget:check', ...budget });
|
|
418
426
|
if (budget.action === 'nudge' && budget.nudge)
|
|
419
427
|
pushMessage(budget.nudge);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
428
|
+
// recovery.call()'s own return also includes `recoveries` (which of
|
|
429
|
+
// its three failure modes fired this call) and `truncated` — not
|
|
430
|
+
// used here: prompt:compaction above already reports the one
|
|
431
|
+
// recovery type that's actually wired to a real hook (onPromptTooLong),
|
|
432
|
+
// and there's no second type yet to justify a second, more generic
|
|
433
|
+
// event just to aggregate it (see PromptCompactionEvent's own doc
|
|
434
|
+
// comment for the removed 'recovery:summary').
|
|
435
|
+
const { value: response } = await recovery.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
|
|
423
436
|
// The model's full response — text and tool_use blocks alike, with
|
|
424
437
|
// real ids — becomes this turn's assistant message verbatim. Every
|
|
425
438
|
// tool_use block emitted here gets a matching tool_result pushed
|
|
@@ -464,7 +477,7 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
464
477
|
// so it still needs a tool_result to answer it, same as any other.
|
|
465
478
|
if (block.name === 'Skill' && skillGarden) {
|
|
466
479
|
const body = skillGarden.invoke(block.input.skill, block.input?.args);
|
|
467
|
-
log({ type: '
|
|
480
|
+
log({ type: 'skill:loaded', skill: block.input.skill });
|
|
468
481
|
resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
|
|
469
482
|
continue;
|
|
470
483
|
}
|
|
@@ -6,7 +6,7 @@ export interface SessionResult<T> {
|
|
|
6
6
|
* This has to come from the caller explicitly (runAgent's own
|
|
7
7
|
* RunAgentResult.newMessages) rather than being inferred by diffing a
|
|
8
8
|
* returned `history` against what withSession loaded: that only works
|
|
9
|
-
* if history only ever grows, which isn't true once
|
|
9
|
+
* if history only ever grows, which isn't true once compaction.ts's own
|
|
10
10
|
* recovery can shrink/rewrite it mid-turn — see RunAgentResult's own
|
|
11
11
|
* doc comment for the full reasoning. */
|
|
12
12
|
newMessages: Message[];
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// plus cosine-similarity search. Real technique (the "hashing trick"),
|
|
4
4
|
// just not a learned one — good enough to prove the retrieval mechanics
|
|
5
5
|
// end-to-end without wiring up a live embedding model. Swap embed() for a
|
|
6
|
-
// real one and VectorIndex works unchanged: same seam as
|
|
6
|
+
// real one and VectorIndex works unchanged: same seam as compaction.ts's
|
|
7
7
|
// Summarizer or ActAuth's Approver — a working default the rest of the
|
|
8
8
|
// pipeline can be built and tested against.
|
|
9
9
|
// Higher dimensions mean fewer hash collisions between unrelated tokens,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"dist/core/gateway-tools.d.ts",
|
|
49
49
|
"dist/core/system-tools",
|
|
50
50
|
"core/system-skills",
|
|
51
|
+
"protocol/loop-event.schema.json",
|
|
52
|
+
"PROTOCOL.md",
|
|
51
53
|
"LICENSE"
|
|
52
54
|
],
|
|
53
55
|
"scripts": {
|
|
@@ -59,11 +61,9 @@
|
|
|
59
61
|
"dependencies": {
|
|
60
62
|
"@anthropic-ai/sdk": "^0.116.0",
|
|
61
63
|
"actauth": "^0.0.7",
|
|
62
|
-
"contextclip": "^0.0.1",
|
|
63
64
|
"ioredis": "^6.0.0",
|
|
64
65
|
"mcpplug": "^0.0.1",
|
|
65
66
|
"openai": "^7.4.0",
|
|
66
|
-
"reflowkit": "^0.0.1",
|
|
67
67
|
"sessionknit": "^0.0.2",
|
|
68
68
|
"skillgarden": "^0.0.5",
|
|
69
69
|
"toollane": "^0.0.1",
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://loopengine.co/protocol/loop-event.schema.json",
|
|
4
|
+
"title": "LoopEvent",
|
|
5
|
+
"description": "The full wire vocabulary of a loopengine turn — mirrors core/loop-events.ts exactly. One object per event; `type` is both the JSON discriminant and, on the SSE transport, the literal `event:` field name. See /PROTOCOL.md for transport bindings and stability guarantees.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{ "$ref": "#/$defs/budgetCheck" },
|
|
8
|
+
{ "$ref": "#/$defs/promptCompaction" },
|
|
9
|
+
{ "$ref": "#/$defs/assistantText" },
|
|
10
|
+
{ "$ref": "#/$defs/actauthDecision" },
|
|
11
|
+
{ "$ref": "#/$defs/toolStarted" },
|
|
12
|
+
{ "$ref": "#/$defs/toolResult" },
|
|
13
|
+
{ "$ref": "#/$defs/toollaneResult" },
|
|
14
|
+
{ "$ref": "#/$defs/skillLoad" },
|
|
15
|
+
{ "$ref": "#/$defs/loopSkipped" },
|
|
16
|
+
{ "$ref": "#/$defs/loopMaxTurns" },
|
|
17
|
+
{ "$ref": "#/$defs/loopDenied" },
|
|
18
|
+
{ "$ref": "#/$defs/loopDone" },
|
|
19
|
+
{ "$ref": "#/$defs/session" },
|
|
20
|
+
{ "$ref": "#/$defs/approvalPending" },
|
|
21
|
+
{ "$ref": "#/$defs/questionPending" },
|
|
22
|
+
{ "$ref": "#/$defs/done" },
|
|
23
|
+
{ "$ref": "#/$defs/error" }
|
|
24
|
+
],
|
|
25
|
+
"$defs": {
|
|
26
|
+
"scope": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"description": "ActAuth's own Scope — which rules apply.",
|
|
29
|
+
"required": ["tenant", "environment", "agent"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"tenant": { "type": "string" },
|
|
32
|
+
"environment": { "type": "string" },
|
|
33
|
+
"agent": { "type": "string" }
|
|
34
|
+
},
|
|
35
|
+
"additionalProperties": false
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
"budgetCheck": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"description": "Engine. Read-only context-budget check, run once per model call.",
|
|
41
|
+
"required": ["type", "action", "usedTokens", "budgetTokens", "ratio"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"type": { "const": "budget:check" },
|
|
44
|
+
"action": { "enum": ["ok", "nudge", "over_hard_limit"] },
|
|
45
|
+
"usedTokens": { "type": "number" },
|
|
46
|
+
"budgetTokens": { "type": "number" },
|
|
47
|
+
"ratio": { "type": "number" },
|
|
48
|
+
"nudge": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"description": "Present only when action is 'nudge'.",
|
|
51
|
+
"required": ["role", "content"],
|
|
52
|
+
"properties": { "role": { "type": "string" }, "content": { "type": "string" } }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"additionalProperties": false
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
"promptCompaction": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"description": "Engine. The prompt was rejected as too long, so compaction.ts's own recovery kicked in. from/to are message-array lengths, not token counts. The only recovery type with a dedicated event today — recovery.ts's other two failure modes (media-too-large, truncated output) have no hook wired up in run-agent.ts yet, so neither can currently fire.",
|
|
61
|
+
"required": ["type", "from", "to"],
|
|
62
|
+
"properties": {
|
|
63
|
+
"type": { "const": "prompt:compaction" },
|
|
64
|
+
"from": { "type": "number" },
|
|
65
|
+
"to": { "type": "number" }
|
|
66
|
+
},
|
|
67
|
+
"additionalProperties": false
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
"assistantText": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"description": "Engine. The model's own preamble alongside a tool_use request — the only live glimpse of assistant text before the turn's final answer.",
|
|
73
|
+
"required": ["type", "text"],
|
|
74
|
+
"properties": {
|
|
75
|
+
"type": { "const": "assistant:text" },
|
|
76
|
+
"text": { "type": "string" }
|
|
77
|
+
},
|
|
78
|
+
"additionalProperties": false
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
"actauthDecision": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"description": "Engine. One ActAuth Gate.evaluate() outcome per requested tool call. decision:'allow' can still mean a human was asked and said yes — see approval:pending for the live ask itself.",
|
|
84
|
+
"required": ["type", "tool", "decision", "reason"],
|
|
85
|
+
"properties": {
|
|
86
|
+
"type": { "const": "actauth:decision" },
|
|
87
|
+
"tool": { "type": "string" },
|
|
88
|
+
"decision": { "enum": ["allow", "ask", "deny"] },
|
|
89
|
+
"reason": { "type": "string" }
|
|
90
|
+
},
|
|
91
|
+
"additionalProperties": false
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
"toolStarted": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"description": "Engine. Fired the instant a call is decided, before it runs. Matched to its later tool:result by id.",
|
|
97
|
+
"required": ["type", "id", "tool", "args", "detailText"],
|
|
98
|
+
"properties": {
|
|
99
|
+
"type": { "const": "tool:started" },
|
|
100
|
+
"id": { "type": "string" },
|
|
101
|
+
"tool": { "type": "string" },
|
|
102
|
+
"args": { "type": "object" },
|
|
103
|
+
"detailText": { "type": "string" }
|
|
104
|
+
},
|
|
105
|
+
"additionalProperties": false
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
"toolResult": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"description": "Engine. A tool call's resolution — auto-allowed, interactively approved, denied, or skipped alike. args/detailText are omitted on some denied/skipped paths.",
|
|
111
|
+
"required": ["type", "id", "tool", "statusText"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"type": { "const": "tool:result" },
|
|
114
|
+
"id": { "type": "string" },
|
|
115
|
+
"tool": { "type": "string" },
|
|
116
|
+
"args": { "type": "object" },
|
|
117
|
+
"detailText": { "type": "string" },
|
|
118
|
+
"statusText": { "type": "string" }
|
|
119
|
+
},
|
|
120
|
+
"additionalProperties": false
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
"toollaneResult": {
|
|
124
|
+
"type": "object",
|
|
125
|
+
"description": "Engine. ToolLane's own raw fulfil/reject outcome, logged alongside (not instead of) tool:result.",
|
|
126
|
+
"required": ["type", "name", "summary"],
|
|
127
|
+
"properties": {
|
|
128
|
+
"type": { "const": "toollane:result" },
|
|
129
|
+
"name": { "type": "string" },
|
|
130
|
+
"summary": { "type": "string" }
|
|
131
|
+
},
|
|
132
|
+
"additionalProperties": false
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
"skillLoad": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"description": "Engine. A Skill tool_use block was resolved to a real skill body via SkillGarden's own lazy load. Not gated by ActAuth — Skill invocation isn't a real tool call.",
|
|
138
|
+
"required": ["type", "skill"],
|
|
139
|
+
"properties": {
|
|
140
|
+
"type": { "const": "skill:loaded" },
|
|
141
|
+
"skill": { "type": "string" }
|
|
142
|
+
},
|
|
143
|
+
"additionalProperties": false
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
"loopSkipped": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"description": "Engine. An approved call never ran because a sibling call in the same batch was denied — the whole batch cancels together.",
|
|
149
|
+
"required": ["type", "name", "deniedTools"],
|
|
150
|
+
"properties": {
|
|
151
|
+
"type": { "const": "loop:skipped" },
|
|
152
|
+
"name": { "type": "string" },
|
|
153
|
+
"deniedTools": { "type": "array", "items": { "type": "string" } }
|
|
154
|
+
},
|
|
155
|
+
"additionalProperties": false
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
"loopMaxTurns": {
|
|
159
|
+
"type": "object",
|
|
160
|
+
"description": "Engine. Hit config.maxTurns without the model producing a final answer. Terminal for the turn — see 'done'.",
|
|
161
|
+
"required": ["type", "maxTurns"],
|
|
162
|
+
"properties": {
|
|
163
|
+
"type": { "const": "loop:max_turns" },
|
|
164
|
+
"maxTurns": { "type": "number" }
|
|
165
|
+
},
|
|
166
|
+
"additionalProperties": false
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
"loopDenied": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"description": "Engine. A human denied at least one requested tool call this turn — the whole turn stops right there. Terminal for the turn — see 'done'.",
|
|
172
|
+
"required": ["type", "deniedTools"],
|
|
173
|
+
"properties": {
|
|
174
|
+
"type": { "const": "loop:denied" },
|
|
175
|
+
"deniedTools": { "type": "array", "items": { "type": "string" } }
|
|
176
|
+
},
|
|
177
|
+
"additionalProperties": false
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
"loopDone": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"description": "Engine. A genuine finish — the model produced text with no more tool_use blocks. Terminal for the turn — see 'done'.",
|
|
183
|
+
"required": ["type", "text"],
|
|
184
|
+
"properties": {
|
|
185
|
+
"type": { "const": "loop:done" },
|
|
186
|
+
"text": { "type": "string" }
|
|
187
|
+
},
|
|
188
|
+
"additionalProperties": false
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
"session": {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"description": "Adapter. First event of every streamed turn — echoes back which session id this turn used.",
|
|
194
|
+
"required": ["type", "sessionId"],
|
|
195
|
+
"properties": {
|
|
196
|
+
"type": { "const": "session" },
|
|
197
|
+
"sessionId": { "type": "string" }
|
|
198
|
+
},
|
|
199
|
+
"additionalProperties": false
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
"approvalPending": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"description": "Adapter. A tool call needs a live human 'ask' decision. Resolve via POST /approvals/:id/approve or /deny.",
|
|
205
|
+
"required": ["type", "id", "tool", "args", "scope", "reason", "requestedAt"],
|
|
206
|
+
"properties": {
|
|
207
|
+
"type": { "const": "approval:pending" },
|
|
208
|
+
"id": { "type": "string" },
|
|
209
|
+
"tool": { "type": "string" },
|
|
210
|
+
"args": { "type": "object" },
|
|
211
|
+
"scope": { "$ref": "#/$defs/scope" },
|
|
212
|
+
"reason": { "type": "string" },
|
|
213
|
+
"requestedAt": { "type": "string", "format": "date-time" }
|
|
214
|
+
},
|
|
215
|
+
"additionalProperties": false
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
"questionPending": {
|
|
219
|
+
"type": "object",
|
|
220
|
+
"description": "Adapter. The system ask_user tool raised a genuinely ambiguous question. Resolve via POST /questions/:id/answer.",
|
|
221
|
+
"required": ["type", "id", "question", "agent", "requestedAt"],
|
|
222
|
+
"properties": {
|
|
223
|
+
"type": { "const": "question:pending" },
|
|
224
|
+
"id": { "type": "string" },
|
|
225
|
+
"question": { "type": "string" },
|
|
226
|
+
"options": { "type": "array", "items": { "type": "string" } },
|
|
227
|
+
"agent": { "type": "string" },
|
|
228
|
+
"sessionId": { "type": "string" },
|
|
229
|
+
"requestedAt": { "type": "string", "format": "date-time" }
|
|
230
|
+
},
|
|
231
|
+
"additionalProperties": false
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
"done": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"description": "Adapter. The turn is over — covers loop:done, loop:max_turns, and loop:denied alike. The one event every consumer can treat as 'nothing more is coming'.",
|
|
237
|
+
"required": ["type", "text"],
|
|
238
|
+
"properties": {
|
|
239
|
+
"type": { "const": "done" },
|
|
240
|
+
"text": { "type": "string" },
|
|
241
|
+
"stopReason": { "enum": ["max_turns", "denied"] }
|
|
242
|
+
},
|
|
243
|
+
"additionalProperties": false
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
"error": {
|
|
247
|
+
"type": "object",
|
|
248
|
+
"description": "Adapter. Something failed after the response was already committed — surfaced in-band since it's too late for an HTTP status code.",
|
|
249
|
+
"required": ["type", "error"],
|
|
250
|
+
"properties": {
|
|
251
|
+
"type": { "const": "error" },
|
|
252
|
+
"error": { "type": "string" }
|
|
253
|
+
},
|
|
254
|
+
"additionalProperties": false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|