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 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
- | [`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 |
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 'contextclip';
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
- /** ContextClipper's own read-only budget check, run once per loop turn
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 contextclip's own CheckResult actually
6
+ * this never drifts from whatever budget.ts's own CheckResult actually
8
7
  * contains. */
9
- export type ContextClipCheckEvent = {
10
- type: 'contextclip:check';
8
+ export type BudgetCheckEvent = {
9
+ type: 'budget:check';
11
10
  } & CheckResult;
12
- /** ContextClip recovery kicked in because the prompt was rejected as too
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
- * reflow.call onPromptTooLong hook. */
16
- export interface ReflowRecoverEvent {
17
- type: 'reflow:recover';
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 ActAuth (see run-agent.ts's own comment on
81
- * why Skill invocation isn't a real tool call). */
82
- export interface SkillGardenInvokeEvent {
83
- type: 'skillgarden:invoke';
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 = ContextClipCheckEvent | ReflowRecoverEvent | ReflowRecoveriesEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillGardenInvokeEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | LoopDoneEvent;
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 — reflowkit's
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.
@@ -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
- * ContextClip's `{role, content: string}`: `content` can also be a
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 ContextClip recovery reshaped `history` mid-turn. A caller
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
@@ -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 { ContextClipper } from 'contextclip';
12
+ import { BudgetTracker } from './budget.js';
13
+ import { Compactor } from './compaction.js';
13
14
  import { ToolLane } from 'toollane';
14
- import { Reflow } from 'reflowkit';
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
- /** ContextClip only ever needs a flat string per message to estimate
52
- * token usage — it doesn't need to understand tool_use/tool_result
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 flattenForContextClip(message) {
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
- const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
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 reflow = new Reflow({
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 ContextClip at all.
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 ContextClip's
324
- // synthetic head contained — that's unsound: ContextClip's cheap
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 contextClip.recover(priorPortion.map(flattenForContextClip));
332
- log({ type: 'reflow:recover', from: currentMessages.length, to: result.messages.length + newMessages.length });
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
- // Reflow.call() only ever returns {value, recoveries, truncated} —
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 contextClip.check() in this loop, and the `history`
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 = contextClip.check(messages.map(flattenForContextClip));
417
- log({ type: 'contextclip:check', ...budget });
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
- const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
421
- if (recoveries.length > 0)
422
- log({ type: 'reflow:recoveries', recoveries });
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: 'skillgarden:invoke', skill: block.input.skill });
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 ContextClip
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 ContextClip's
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.20",
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
+ }