@lionhummer/constellation 0.9.4 → 0.11.0

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/README.md CHANGED
@@ -70,12 +70,11 @@ console.log(request.status, request.output?.text);
70
70
 
71
71
  ## Build an agent with tools
72
72
 
73
- A tool-using agent needs four pieces:
73
+ A tool-using agent needs three pieces:
74
74
 
75
- 1. **A webhook** that receives events and stores them.
75
+ 1. **A webhook** that receives events.
76
76
  2. **Hooks** that run your tools and build the next input.
77
- 3. **An `EventStore`**: a table in your database that remembers each event and the response you prepared for it.
78
- 4. **`processEvent`**, which ties the other three together.
77
+ 3. **An `EventReceiver`**, which takes each event, runs your hooks in the background and sends the response. You don't store events or responses: Constellation re-sends an event that stays unanswered, and deduplicates your answers.
79
78
 
80
79
  ### 1. Turn on events
81
80
 
@@ -142,112 +141,19 @@ const hooks: AgentHooks = {
142
141
 
143
142
  `conversation.settings` must repeat the first request's `workspaceId`, `userId`, `externalId`, `model`, `planId`, `events` and `timeoutMs`. A continuation that changes any of them is rejected. You may change `messages`, `instructions`, `tools`, `parameters` and `cache`.
144
143
 
145
- ### 3. Implement the `EventStore`
146
-
147
- The store is a small table in your own database. It guarantees two things:
148
-
149
- - an event you acknowledged is never lost;
150
- - a response you prepared is sent unchanged, even after a crash.
151
-
152
- Do not use an in-memory map in production. Here is a complete PostgreSQL version using [`pg`](https://node-postgres.com/):
153
-
154
- ```sql
155
- CREATE TABLE constellation_events (
156
- event_id text PRIMARY KEY,
157
- payload jsonb NOT NULL,
158
- prepared boolean NOT NULL DEFAULT false,
159
- response jsonb, -- the exact body we send (or resend)
160
- state text NOT NULL DEFAULT 'pending', -- pending | done | rejected
161
- rejection jsonb,
162
- failures integer NOT NULL DEFAULT 0,
163
- last_error jsonb,
164
- first_failure_at timestamptz,
165
- last_failure_at timestamptz,
166
- received_at timestamptz NOT NULL DEFAULT now()
167
- );
168
- ```
169
-
170
- ```ts
171
- import pg from "pg";
172
- import { PermanentEventError, type EventStore, type WebhookEvent } from "@lionhummer/constellation";
173
-
174
- const db = new pg.Pool();
175
-
176
- export const store: EventStore & { pending(): Promise<WebhookEvent[]> } = {
177
- // Save the event before acknowledging the webhook. Duplicates must match.
178
- async accept(event) {
179
- await db.query(
180
- `INSERT INTO constellation_events (event_id, payload) VALUES ($1, $2)
181
- ON CONFLICT (event_id) DO NOTHING`, [event.id, event]);
182
- const { rows } = await db.query(
183
- `SELECT payload = $2::jsonb AS same FROM constellation_events WHERE event_id = $1`, [event.id, event]);
184
- if (!rows[0]?.same) throw new Error(`event ${event.id} redelivered with a different payload`);
185
- },
186
-
187
- // Run `prepare` at most once per event at a time, and save its result before returning it.
188
- async prepare(event, prepare) {
189
- const tx = await db.connect();
190
- try {
191
- await tx.query("BEGIN");
192
- const { rows } = await tx.query(
193
- `SELECT prepared, response, state, rejection FROM constellation_events
194
- WHERE event_id = $1 FOR UPDATE`, [event.id]); // serializes concurrent processing
195
- const row = rows[0];
196
- if (row.state === "rejected") {
197
- const r = row.rejection;
198
- throw new PermanentEventError(r.status, r.code, r.message);
199
- }
200
- if (row.prepared) { await tx.query("COMMIT"); return row.response; }
201
- const response = await prepare(); // runs your tools and hooks
202
- await tx.query(
203
- `UPDATE constellation_events SET prepared = true, response = $2 WHERE event_id = $1`,
204
- [event.id, response]);
205
- await tx.query("COMMIT");
206
- return response;
207
- } catch (error) {
208
- await tx.query("ROLLBACK");
209
- throw error;
210
- } finally {
211
- tx.release();
212
- }
213
- },
214
-
215
- async complete(eventId) {
216
- await db.query(`UPDATE constellation_events SET state = 'done' WHERE event_id = $1`, [eventId]);
217
- },
218
-
219
- // A permanent rejection: never resend this response.
220
- async reject(eventId, error) {
221
- await db.query(
222
- `UPDATE constellation_events SET state = 'rejected', rejection = $2 WHERE event_id = $1`,
223
- [eventId, { status: error.status, code: error.code, message: error.message }]);
224
- },
225
-
226
- // Optional: a retryable failure. Alert when failures pile up or get old.
227
- async retry(eventId, error) {
228
- await db.query(
229
- `UPDATE constellation_events SET failures = failures + 1, last_error = $2,
230
- first_failure_at = coalesce(first_failure_at, now()), last_failure_at = now()
231
- WHERE event_id = $1`, [eventId, error]);
232
- },
233
-
234
- // Not part of EventStore: used by the recovery sweep below.
235
- async pending() {
236
- const { rows } = await db.query(
237
- `SELECT payload FROM constellation_events WHERE state = 'pending' ORDER BY received_at LIMIT 100`);
238
- return rows.map((row) => row.payload);
239
- },
240
- };
241
- ```
242
-
243
- `prepare` keeps a row lock while your tools run. That is acceptable for tools that finish in seconds or minutes. For very long work, replace the lock with a lease column.
144
+ ### 3. Receive webhooks
244
145
 
245
- ### 4. Receive webhooks and process events
246
-
247
- Acknowledge quickly, then process in the background. The webhook handler below works with any framework that uses the web `Request`/`Response` types (Hono, Next.js route handlers, Bun, Deno):
146
+ Acknowledge quickly, then process in the background. The handler below works with any framework that uses the web `Request`/`Response` types (Hono, Next.js route handlers, Bun, Deno):
248
147
 
249
148
  ```ts
250
- import { processEvent, PermanentEventError } from "@lionhummer/constellation";
149
+ import { EventReceiver, redisEventLock } from "@lionhummer/constellation";
150
+
151
+ const receiver = new EventReceiver(client, hooks, {
152
+ // One process: leave this out (an in-memory lock is used).
153
+ // Several processes or pods: share the lock so only one works on an event at a time.
154
+ lock: redisEventLock((args) => redis.call(...args)), // ioredis; node-redis: (args) => redis.sendCommand(args)
155
+ onFailure: (eventId, error) => logger.warn({ eventId, error }, error.willRetry ? "retrying event" : "event will be re-sent"),
156
+ });
251
157
 
252
158
  export async function webhook(request: Request): Promise<Response> {
253
159
  let event;
@@ -256,68 +162,63 @@ export async function webhook(request: Request): Promise<Response> {
256
162
  } catch {
257
163
  return new Response(null, { status: 401 });
258
164
  }
259
- await store.accept(event); // if this throws, the 5xx makes Constellation redeliver
260
- void handle(event); // don't make the webhook wait for your tools
261
- return new Response(null, { status: 202 });
165
+ const run = await receiver.claim(event);
166
+ if (!run) return new Response(null, { status: 202 }); // another process is working on it
167
+ void run().then((outcome) => {
168
+ if (outcome.status === "answered" && outcome.ended) markConversationEnded(event.requestId, outcome.ended);
169
+ }, () => {}); // a failure leaves the event unanswered; Constellation re-sends it
170
+ return new Response(null, { status: 204 });
262
171
  }
263
-
264
- async function handle(event: WebhookEvent) {
265
- try {
266
- await processEvent(client, store, event, hooks);
267
- } catch (error) {
268
- if (error instanceof PermanentEventError) {
269
- await markConversationFailed(event.requestId, error); // will not be retried
270
- } else {
271
- logger.warn({ eventId: event.id, error }, "event processing will be retried");
272
- }
273
- }
274
- }
275
-
276
- // Optional: resume unfinished events right after a restart.
277
- for (const event of await store.pending()) void handle(event);
278
172
  ```
279
173
 
280
174
  Don't run tools inside the webhook call. Constellation waits 5 seconds for the acknowledgement by default, and nothing in Constellation waits for your tools.
281
175
 
282
- If your process dies while handling an event, you don't need a poller to recover it. Constellation sends an unanswered `model.completed` event again after the response window (10 minutes by default), up to 3 times, and stops as soon as your response arrives. A re-sent event has the same `id`, so `store.accept` and `store.prepare` resume it from your saved checkpoint. If the event is still being processed when the re-send arrives, acknowledge it anyway. Keep your processing lease shorter than the response window and renew it while work continues, so a re-send after a crash can take the event over.
176
+ `outcome.ended` tells you when the request is over: `completed` or `failed` after your final answer, `closed` when it had already been cancelled or had expired. Requests that fail or are cancelled on the Constellation side arrive as `model.failed` and `model.cancelled` events. You never need to read the request back.
283
177
 
284
- ## What `processEvent` does
178
+ ### Recovery
285
179
 
286
- For each event:
180
+ A failure that may pass, such as a database lock timeout or a network error, is retried in the same process after 1, 3 and 9 seconds while it still holds the event (`retryDelaysMs`; `[]` turns this off). A re-send arriving meanwhile is answered `202`. Your own code can throw `PermanentEventError` to skip these retries.
287
181
 
288
- 1. **Prepare the response, at most once.** `store.prepare` returns the saved response if one exists. Otherwise the SDK builds one:
289
- - For `model.completed`, it runs every tool call in parallel and calls `onStepComplete`. To continue, it calls `prepareRequest`, which produces a continuation. To stop, it produces an `observe` response.
290
- - For `model.failed` and `model.cancelled`, it calls `onError(event.data)` and produces an `observe` response.
182
+ If your process dies while handling an event, you don't need a poller or a store to recover it. Constellation sends an unanswered `model.completed` event again after the response window (10 minutes by default; operators can lower it, for example to 2 minutes), up to 3 times. A re-sent event has the same `id`:
291
183
 
292
- The response carries the idempotency key `event.id`, `inReplyTo: { requestId, eventId }` and the telemetry recorded while preparing. The store saves it before anything is sent.
293
- 2. **Submit it** through `requests.submit`. Retries send the saved body unchanged.
294
- 3. **Record the outcome**:
184
+ - while another process still holds the event's lock, `claim` returns `null` and you answer `202`. Constellation then checks again after another window without counting a re-send;
185
+ - once the holder is gone, its lock expires (60 s by default, renewed while it works) and the re-send is processed from the start.
295
186
 
296
- | Server answer | Meaning | `processEvent` |
297
- | --- | --- | --- |
298
- | `200`, `outcome: "accepted"` | Your response was accepted. A continuation starts the next step (`scheduled: true`). | `store.complete`, resolves |
299
- | `200`, `outcome: "already_accepted"` | A retry of a response that was already accepted, for example after a lost reply. Nothing runs twice. | `store.complete`, resolves |
300
- | `200`, `outcome: "event_closed"` | The request was cancelled or expired before your continuation arrived. Nothing continues; your telemetry is still recorded (`observationsRecorded`). | `store.complete`, resolves |
301
- | `400 bad_request` | The response itself is invalid, for example a changed model or a bad message. | `store.reject`, throws `PermanentEventError` |
302
- | `409 response_conflict` | A *different* response to this event was already accepted. | `store.reject`, throws `PermanentEventError` |
303
- | `410 observations_expired` | The request ended more than 24 hours ago. | `store.reject`, throws `PermanentEventError` |
304
- | Anything else: network error, timeout, `5xx`, `429`, `409 conflict`, `401`, `403` | Temporary. | `store.retry`, throws. The next attempt resends the same body. |
187
+ If you answer an event twice, for example because the reply to your first answer was lost, Constellation keeps the first answer and reports the second as `already_accepted` with `bodyMatched: false`. Nothing continues twice.
188
+
189
+ ## What happens to each event
305
190
 
306
- The permanent codes are exported as `PERMANENT_RESPONSE_CODES`. The receipt passed to `store.complete` includes `outcome`, so your store can record that a request ended without continuing.
191
+ 1. **Take the lock** for the event. If another process holds it: `busy` (answer `202`).
192
+ 2. **Prepare the response:**
193
+ - For `model.completed`, run every tool call in parallel and call `onStepComplete`. To continue, call `prepareRequest`, which produces a continuation. To stop, produce an `observe` response.
194
+ - A tool call marked `invalid` (its arguments were not valid JSON) is never run. Its result is an error from `invalidToolInputResult` with `isError: true`, so the model can correct the call on the next step, as the AI SDK does.
195
+ - For `model.failed` and `model.cancelled`, call `onError(event.data)` and produce an `observe` response.
196
+
197
+ The response carries the idempotency key `event.id`, `inReplyTo: { requestId, eventId }` and the telemetry recorded while preparing.
198
+ 3. **Submit it** and release the lock:
199
+
200
+ | Server answer | Meaning | Result |
201
+ | --- | --- | --- |
202
+ | `200`, `outcome: "accepted"` | Your response was accepted. A continuation starts the next step (`scheduled: true`). | `answered` |
203
+ | `200`, `outcome: "already_accepted"` | This event was already answered by you. `bodyMatched: false` means the earlier answer differed and stands. | `answered` |
204
+ | `200`, `outcome: "event_closed"` | The request was cancelled or expired before your continuation arrived. Your telemetry is still recorded (`observationsRecorded`). | `answered`, `ended: "closed"` |
205
+ | A permanent rejection (`PERMANENT_RESPONSE_CODES`, for example `400 bad_request`) of a continuation | The continuation can never be accepted. | The SDK sends an `observe` response with the error instead, which ends the request as failed: `ended: "failed"` |
206
+ | `401`, `403` | Key or scope problem. | `onFailure` runs and the call throws. The event stays unanswered and is re-sent. |
207
+ | Anything else: network error, timeout, `5xx`, `429`, `409 conflict`, or an error thrown while preparing | Temporary. | `onFailure` runs with `willRetry: true` and the attempt is repeated after the next delay. After the last one, `onFailure` runs with `willRetry: false`, the call throws, and the event is re-sent. |
307
208
 
308
- Fix `401` and `403` by correcting the key: the saved response is still sent unchanged afterwards.
209
+ While processing, the SDK renews the lock every third of its lease. If renewal fails, it aborts its own work through `signal`, so a re-send taken over elsewhere doesn't run alongside it.
309
210
 
310
211
  ## When your code fails
311
212
 
312
213
  | What happened | Without `onError` | With `onError` |
313
214
  | --- | --- | --- |
314
- | A tool or hook throws | `processEvent` throws and the event is retried later. Tools may run again, so use `effectId`. | `onError` runs. If it returns, the request stops and the error is recorded in telemetry. If it throws, the event is retried. |
315
- | No handler for a tool the model called, or `prepareRequest` is missing when continuing | The request is marked **failed** and `HookConfigurationError` (a `PermanentEventError`) is thrown. | `onError` runs, as above. |
215
+ | A tool or hook throws | The event stays unanswered and is re-sent. Tools may run again, so use `effectId`. | `onError` runs. If it returns, the request stops and the error is recorded in telemetry. If it throws, the event is re-sent. |
216
+ | No handler for a tool the model called, or `prepareRequest` is missing when continuing | The request ends as **failed** (`invalid_hook_configuration`). | `onError` runs, as above. |
316
217
  | The model step failed or was cancelled (`model.failed`, `model.cancelled`) | An `observe` response records your telemetry. | `onError(event.data)` runs first. |
317
218
 
318
219
  ## Side effects and duplicates
319
220
 
320
- Hooks can run more than once. A crash after a tool ran, but before the prepared response was saved, means the tool runs again. Constellation cannot make your side effects exactly-once; your code must:
221
+ Delivery is at least once. A crash after a tool ran but before the answer was accepted means the tool runs again when the event is re-sent. Constellation cannot make your side effects exactly-once; your code must:
321
222
 
322
223
  - pass `effectId` to anything with side effects (payments, emails, writes). It is stable for one tool call of one event;
323
224
  - settle per-step billing or usage once per `event.id`;
@@ -325,7 +226,7 @@ Hooks can run more than once. A crash after a tool ran, but before the prepared
325
226
 
326
227
  ## Live output
327
228
 
328
- `requests.streamEvents(id, { after, signal })` streams `model.delta` events (text and reasoning as it is generated), followed by each step's final event. It reconnects automatically. To resume after a restart, pass the last `event.sequence` you processed as `after`. Streaming is for display only: run tools from the webhook, never from the stream. Stopping the stream does not cancel the request; `requests.cancel(id)` does.
229
+ `requests.streamEvents(id, { after, signal })` streams `model.delta` events (text and reasoning as it is generated), followed by each step's final event. It reconnects automatically and ends when the request has ended: the server closes the stream with an end frame after the request's last event, so the loop finishes without any state polling. To resume after a restart, pass the last `event.sequence` you processed as `after`. Streaming is for display only: run tools from the webhook, never from the stream. Stopping the stream does not cancel the request; `requests.cancel(id)` does.
329
230
 
330
231
  ## Telemetry
331
232
 
@@ -427,7 +328,7 @@ A failed provider call falls back to the next provider only before any output ha
427
328
  | `requests.preview(input)` | Validate a complete input and show routing, without calling a provider. |
428
329
  | `requests.cancel(id)` | Cancel a request. |
429
330
  | `openers.create(input)`, `openers.sentence(id)` | Start an opening line for a just-sent message (null when openers are turned off), then wait for its sentence (null when skipped or failed). |
430
- | `requests.getState(id)` | Committed state: status, steps, attempt outcomes, usage and cost. |
331
+ | `requests.getState(id)` | Committed state: status, steps, attempt outcomes, usage and cost. For inspection and support tools; event processing never needs it, because boundary events carry the step number, step input and request identity. |
431
332
  | `requests.getSummary(id)`, `requests.get(id)` | Request detail, without or with spans and logs. |
432
333
  | `requests.list(query)` | Page through requests, e.g. by `externalId` or `status`. |
433
334
  | `requests.listEvents(id, after)`, `requests.streamEvents(id)` | Replay events after a sequence number, or stream them live. |
@@ -440,7 +341,7 @@ A failed provider call falls back to the next provider only before any output ha
440
341
  | `management.registry.*` | Edit the model registry. Needs management permission. |
441
342
  | `discover()` | The OpenAPI document for exactly what this key may call. |
442
343
 
443
- Exports for event processing: `processEvent`, `prepareResponse` (build a response without submitting it), `EventReceiver`, `ClientTelemetry`, `PermanentEventError`, `HookConfigurationError`, `PERMANENT_RESPONSE_CODES`, `ConstellationError` and `uuid7`.
344
+ Exports for event processing: `EventReceiver`, `processEvent` (inline: lock, prepare, submit), `prepareResponse` (build a response without submitting it), `MemoryEventLock`, `redisEventLock`, `invalidToolInputResult`, `ClientTelemetry`, `PermanentEventError`, `HookConfigurationError`, `PERMANENT_RESPONSE_CODES`, `ConstellationError` and `uuid7`.
444
345
 
445
346
  ## Troubleshooting
446
347
 
@@ -448,7 +349,7 @@ Exports for event processing: `processEvent`, `prepareResponse` (build a respons
448
349
  | --- | --- |
449
350
  | `models.list` is empty | The key's workspace/user scope, an access rule for that workspace, and an enabled model and offering. |
450
351
  | `terminal: "notify"` is rejected | The execution key has no webhook URL configured. |
451
- | The same webhook keeps arriving | Your endpoint returned non-2xx or took longer than the delivery timeout (5 s by default). Return 202 after `store.accept` and process in the background. |
352
+ | The same webhook keeps arriving | Your endpoint returned non-2xx or took longer than the delivery timeout (5 s by default). Answer after `receiver.claim` and process in the background. |
452
353
  | `400` on a continuation | Model, workspace, user, `externalId`, plan, events or `timeoutMs` differ from the first request, or the message history is malformed. |
453
354
  | `409 response_conflict` | Your code produced a different response for an event that was already answered. Answers must come from the saved response. |
454
355
  | Tools ran twice | Expected after a crash. Make side effects idempotent with `effectId`. |
package/dist/index.js CHANGED
@@ -253,8 +253,11 @@ class RequestClient {
253
253
  `)) >= 0) {
254
254
  const frame = buffer.slice(0, boundary);
255
255
  buffer = buffer.slice(boundary + 2);
256
- const data = frame.split(`
257
- `).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join(`
256
+ const lines = frame.split(`
257
+ `);
258
+ if (lines.some((line) => line === "event: end" || line === "event:end"))
259
+ return;
260
+ const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join(`
258
261
  `);
259
262
  if (!data)
260
263
  continue;
@@ -270,41 +273,9 @@ class RequestClient {
270
273
  } finally {
271
274
  await reader.cancel().catch(() => {});
272
275
  }
273
- const inspection = await this.#replayRead(`/v1/requests/${encodeURIComponent(id)}/state`, options.signal);
274
- if (["completed", "failed", "cancelled"].includes(inspection.request.status)) {
275
- while (true) {
276
- const before = after;
277
- const tail = await this.#replayRead(`/v1/requests/${encodeURIComponent(id)}/events?after=${after}`, options.signal);
278
- for (const event of tail.items) {
279
- if (event.sequence <= after)
280
- continue;
281
- after = event.sequence;
282
- yield event;
283
- }
284
- if (tail.items.length === 0)
285
- return;
286
- if (after === before)
287
- throw new Error("Replay page did not advance the event cursor");
288
- }
289
- }
290
276
  await pause(500, options.signal);
291
277
  }
292
278
  }
293
- async#replayRead(path, signal) {
294
- for (;; ) {
295
- signal?.throwIfAborted();
296
- try {
297
- return await this.#call(path, "GET", undefined, signal);
298
- } catch (error) {
299
- signal?.throwIfAborted();
300
- if (error instanceof ConstellationError && error.status !== 429 && error.status < 500)
301
- throw error;
302
- if (error instanceof SyntaxError)
303
- throw error;
304
- await pause(500, signal);
305
- }
306
- }
307
- }
308
279
  get models() {
309
280
  return {
310
281
  list: (scope, signal) => this.#call(`/v1/models${queryString(scope)}`, "GET", undefined, signal),
@@ -424,15 +395,82 @@ class HookConfigurationError extends PermanentEventError {
424
395
  }
425
396
  }
426
397
 
427
- class EventReceiver {
428
- store;
429
- constructor(store) {
430
- this.store = store;
398
+ class MemoryEventLock {
399
+ #held = new Map;
400
+ async acquire(eventId, ttlMs) {
401
+ const until = this.#held.get(eventId);
402
+ if (until !== undefined && until > Date.now())
403
+ return false;
404
+ this.#held.set(eventId, Date.now() + ttlMs);
405
+ return true;
406
+ }
407
+ async renew(eventId, ttlMs) {
408
+ if (!this.#held.has(eventId))
409
+ return false;
410
+ this.#held.set(eventId, Date.now() + ttlMs);
411
+ return true;
412
+ }
413
+ async release(eventId) {
414
+ this.#held.delete(eventId);
431
415
  }
432
- async receive(event) {
433
- await this.store.accept(event);
416
+ }
417
+ var RENEW_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
418
+ var RELEASE_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
419
+ function redisEventLock(command, options = {}) {
420
+ const prefix = options.prefix ?? "constellation:event-lock:";
421
+ const tokens = new Map;
422
+ return {
423
+ async acquire(eventId, ttlMs) {
424
+ const token = uuid7();
425
+ const reply = await command(["SET", prefix + eventId, token, "NX", "PX", String(ttlMs)]);
426
+ if (reply !== "OK")
427
+ return false;
428
+ tokens.set(eventId, token);
429
+ return true;
430
+ },
431
+ async renew(eventId, ttlMs) {
432
+ const token = tokens.get(eventId);
433
+ if (!token)
434
+ return false;
435
+ return Number(await command(["EVAL", RENEW_SCRIPT, "1", prefix + eventId, token, String(ttlMs)])) === 1;
436
+ },
437
+ async release(eventId) {
438
+ const token = tokens.get(eventId);
439
+ tokens.delete(eventId);
440
+ if (token)
441
+ await command(["EVAL", RELEASE_SCRIPT, "1", prefix + eventId, token]);
442
+ }
443
+ };
444
+ }
445
+ function invalidToolInputResult(call) {
446
+ return {
447
+ error: "invalid_tool_input",
448
+ message: `The arguments for ${call.name} were not valid JSON (${call.invalid?.error ?? "unparseable"}). Call ${call.name} again with complete, valid JSON arguments.`
449
+ };
450
+ }
451
+
452
+ class EventReceiver {
453
+ client;
454
+ hooks;
455
+ options;
456
+ #lock;
457
+ constructor(client, hooks, options = {}) {
458
+ this.client = client;
459
+ this.hooks = hooks;
460
+ this.options = options;
461
+ this.#lock = options.lock ?? new MemoryEventLock;
462
+ }
463
+ async claim(event) {
464
+ const leaseMs = this.options.leaseMs ?? DEFAULT_LEASE_MS;
465
+ if (!await this.#lock.acquire(event.id, leaseMs))
466
+ return null;
467
+ const hooks = this.hooks;
468
+ const load = typeof hooks === "function" ? () => hooks(event) : async () => hooks;
469
+ return (signal) => runClaimed(this.client, this.#lock, event, load, { ...this.options, signal: signal ?? this.options.signal });
434
470
  }
435
471
  }
472
+ var DEFAULT_LEASE_MS = 60000;
473
+ var DEFAULT_RETRY_DELAYS_MS = [1000, 3000, 9000];
436
474
 
437
475
  class ClientTelemetry {
438
476
  parentSpanId;
@@ -563,6 +601,10 @@ async function prepareResponseBody(event, hooks, telemetry, signal) {
563
601
  return;
564
602
  signal?.throwIfAborted();
565
603
  const call = calls[index];
604
+ if (call.invalid) {
605
+ results[index] = { id: call.id, result: invalidToolInputResult(call), isError: true };
606
+ continue;
607
+ }
566
608
  const handler = hooks.tools?.[call.name];
567
609
  if (!handler)
568
610
  throw new HookConfigurationError(`No handler for tool ${call.name}`);
@@ -619,44 +661,96 @@ async function prepareResponse(event, hooks, signal) {
619
661
  response.telemetry = telemetry.snapshot();
620
662
  return response;
621
663
  }
622
- async function processEvent(client, store, event, hooks, signal) {
623
- let stage = "prepare";
664
+ async function processEvent(client, lock, event, hooks, options = {}) {
665
+ if (!await lock.acquire(event.id, options.leaseMs ?? DEFAULT_LEASE_MS))
666
+ return { status: "busy" };
667
+ return runClaimed(client, lock, event, async () => hooks, options);
668
+ }
669
+ function retryable(error) {
670
+ if (error instanceof PermanentEventError)
671
+ return false;
672
+ if (error instanceof ConstellationError) {
673
+ return !PERMANENT_RESPONSE_CODES.has(error.code) && (error.status === 0 || [408, 409, 425, 429].includes(error.status) || error.status >= 500);
674
+ }
675
+ return true;
676
+ }
677
+ function wait(ms, signal) {
678
+ signal.throwIfAborted();
679
+ return new Promise((resolve, reject) => {
680
+ const abort = () => {
681
+ clearTimeout(timer);
682
+ reject(signal.reason);
683
+ };
684
+ const timer = setTimeout(() => {
685
+ signal.removeEventListener("abort", abort);
686
+ resolve();
687
+ }, ms);
688
+ signal.addEventListener("abort", abort, { once: true });
689
+ });
690
+ }
691
+ async function runClaimed(client, lock, event, loadHooks, options) {
692
+ const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
693
+ const delays = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
694
+ const fence = new AbortController;
695
+ const signal = options.signal ? AbortSignal.any([options.signal, fence.signal]) : fence.signal;
696
+ const renewal = setInterval(() => {
697
+ lock.renew(event.id, leaseMs).then((held) => {
698
+ if (!held)
699
+ fence.abort(new Error("Event lock lost"));
700
+ }, () => fence.abort(new Error("Event lock unavailable")));
701
+ }, Math.max(1000, Math.floor(leaseMs / 3)));
624
702
  try {
625
- const response = await store.prepare(event, () => prepareResponse(event, hooks, signal));
626
- let receipt = null;
627
- if (response) {
628
- stage = "submit";
703
+ for (let attempt = 1;; attempt++) {
704
+ const progress = { stage: "prepare" };
629
705
  try {
630
- receipt = await client.requests.submit(response, signal);
706
+ return await answer(client, event, await loadHooks(), signal, progress);
631
707
  } catch (error) {
632
- if (error instanceof ConstellationError && PERMANENT_RESPONSE_CODES.has(error.code)) {
633
- const rejection = new PermanentEventError(error.status, error.code, error.message);
634
- await store.reject(event.id, rejection);
635
- throw rejection;
708
+ const willRetry = attempt <= delays.length && !signal.aborted && retryable(error);
709
+ if (options.onFailure) {
710
+ const detail = error instanceof ConstellationError ? { status: error.status, code: error.code, message: error.message } : { status: 0, code: error instanceof Error && error.name === "TimeoutError" ? "timeout" : progress.stage === "submit" ? "transport_error" : "event_prepare_failed", message: error instanceof Error ? error.message : "Event processing failed" };
711
+ try {
712
+ await options.onFailure(event.id, { ...detail, attempt, willRetry });
713
+ } catch (monitorError) {
714
+ if (error && typeof error === "object")
715
+ Object.assign(error, { failureHookError: monitorError });
716
+ }
636
717
  }
637
- throw error;
638
- }
639
- if ("action" in response && response.action === "observe" && response.error) {
640
- const failure = new HookConfigurationError(response.error.message);
641
- await store.reject(event.id, failure);
642
- throw failure;
643
- }
644
- }
645
- stage = "checkpoint";
646
- await store.complete(event.id, receipt);
647
- } catch (error) {
648
- if (!(error instanceof PermanentEventError) && store.retry) {
649
- const detail = error instanceof ConstellationError ? { status: error.status, code: error.code, message: error.message } : { status: 0, code: error instanceof Error && error.name === "TimeoutError" ? "timeout" : stage === "submit" ? "transport_error" : `event_${stage}_failed`, message: error instanceof Error ? error.message : "Event processing failed" };
650
- try {
651
- await store.retry(event.id, detail);
652
- } catch (monitorError) {
653
- if (error && typeof error === "object")
654
- Object.assign(error, { retryHookError: monitorError });
718
+ if (!willRetry)
719
+ throw error;
720
+ await wait(delays[attempt - 1] ?? 0, signal);
655
721
  }
656
722
  }
657
- throw error;
723
+ } finally {
724
+ clearInterval(renewal);
725
+ await lock.release(event.id).catch(() => {});
658
726
  }
659
727
  }
728
+ async function answer(client, event, hooks, signal, progress) {
729
+ const response = await prepareResponse(event, hooks, signal);
730
+ if (!response)
731
+ return { status: "answered", action: "stop", receipt: null, ended: null };
732
+ progress.stage = "submit";
733
+ signal.throwIfAborted();
734
+ const observe = "action" in response && response.action === "observe";
735
+ let receipt;
736
+ try {
737
+ receipt = await client.requests.submit(response, signal);
738
+ } catch (error) {
739
+ if (!(error instanceof ConstellationError) || !PERMANENT_RESPONSE_CODES.has(error.code) || observe)
740
+ throw error;
741
+ receipt = await client.requests.submit({
742
+ action: "observe",
743
+ idempotencyKey: event.id,
744
+ inReplyTo: { requestId: event.requestId, eventId: event.id },
745
+ telemetry: response.telemetry ?? { spans: [], logs: [] },
746
+ error: { code: error.code, message: error.message.slice(0, 8192) || error.code }
747
+ }, signal);
748
+ return { status: "answered", action: "stop", receipt, ended: receipt.outcome === "event_closed" ? "closed" : "failed" };
749
+ }
750
+ const failed = observe && "error" in response && !!response.error;
751
+ const ended = receipt.outcome === "event_closed" ? "closed" : observe ? failed ? "failed" : "completed" : null;
752
+ return { status: "answered", action: observe ? "stop" : "continue", receipt, ended };
753
+ }
660
754
 
661
755
  // src/index.ts
662
756
  function queryString2(values) {
@@ -807,12 +901,15 @@ class Constellation extends RequestClient {
807
901
  }
808
902
  export {
809
903
  uuid7,
904
+ redisEventLock,
810
905
  processEvent,
811
906
  prepareResponse,
812
907
  parseOpenerSentence,
813
908
  normalizeTimestamp,
909
+ invalidToolInputResult,
814
910
  PermanentEventError,
815
911
  PERMANENT_RESPONSE_CODES,
912
+ MemoryEventLock,
816
913
  HookConfigurationError,
817
914
  EventReceiver,
818
915
  ConstellationError,
@@ -10,34 +10,75 @@ export declare class PermanentEventError extends Error {
10
10
  export declare class HookConfigurationError extends PermanentEventError {
11
11
  constructor(message: string);
12
12
  }
13
- /** Application-owned durable event checkpoints. All methods must survive process restart. */
14
- export interface EventStore {
15
- /** Atomically retain the event before processing it.
16
- * Duplicate IDs must compare equal payloads. Resolve only after durable receipt. */
17
- accept(event: WebhookEvent): Promise<void>;
18
- /** Serialize processing per event. Return the saved response if present; otherwise
19
- * run prepare and durably store its exact result before returning. A crash can
20
- * rerun prepare: tool/billing effects must use the supplied stable identities. */
21
- prepare(event: WebhookEvent, prepare: () => Promise<Admission | null>): Promise<Admission | null>;
22
- /** Atomically mark the event processed after its saved response settled. `receipt.outcome` is
23
- * `accepted`, `already_accepted` or `event_closed`; for `event_closed`, `receipt.observationsRecorded`
24
- * says whether the continuation's telemetry was kept. `null` when no response was submitted. */
25
- complete(eventId: string, receipt: AdmissionReceipt | null): Promise<void>;
26
- /** Durably stop retries of a rejected saved response. Replays must rethrow this rejection. */
27
- reject(eventId: string, error: PermanentEventError): Promise<void>;
28
- /** Persist retry count, first/last failure timestamps and latest error in the existing inbox. Non-HTTP failures use status 0; alert on age/repeated failures.
29
- * If this hook throws, processEvent still rethrows the original failure, with the hook error as its `retryHookError` property. */
30
- retry?(eventId: string, error: {
13
+ /** Exclusive, expiring ownership of one event across the application's processes.
14
+ * Constellation re-sends an event that stays unanswered; whoever holds the lock
15
+ * answers that re-send with 202 instead of starting a second run. */
16
+ export interface EventLock {
17
+ /** Take the event for `ttlMs`. False while another holder's lease is live. */
18
+ acquire(eventId: string, ttlMs: number): Promise<boolean>;
19
+ /** Extend this holder's lease. False if it expired or another holder took it. */
20
+ renew(eventId: string, ttlMs: number): Promise<boolean>;
21
+ release(eventId: string): Promise<void>;
22
+ }
23
+ /** Correct for a single process. Use `redisEventLock` when several processes receive events. */
24
+ export declare class MemoryEventLock implements EventLock {
25
+ #private;
26
+ acquire(eventId: string, ttlMs: number): Promise<boolean>;
27
+ renew(eventId: string, ttlMs: number): Promise<boolean>;
28
+ release(eventId: string): Promise<void>;
29
+ }
30
+ /** A lock shared through Redis. `command` sends one raw command and resolves its reply,
31
+ * for example `(args) => redis.call(...args)` (ioredis) or `(args) => client.sendCommand(args)` (node-redis). */
32
+ export declare function redisEventLock(command: (args: string[]) => Promise<unknown>, options?: {
33
+ prefix?: string;
34
+ }): EventLock;
35
+ /** What processing an event did. `ended` says the request is over after this response:
36
+ * `completed` or `failed` from this answer, `closed` when the request had already ended. */
37
+ export type EventOutcome = {
38
+ status: "busy";
39
+ } | {
40
+ status: "answered";
41
+ action: "continue" | "stop";
42
+ receipt: AdmissionReceipt | null;
43
+ ended: "completed" | "failed" | "closed" | null;
44
+ };
45
+ export type EventOptions = {
46
+ signal?: AbortSignal;
47
+ /** Lock lease, renewed every third of it while processing. Keep it well below the response window. */
48
+ leaseMs?: number;
49
+ /** Pauses before retrying a transient failure while still holding the event, before leaving
50
+ * it to Constellation's re-send. Default 1 s, 3 s and 9 s; `[]` leaves every failure to the re-send. */
51
+ retryDelaysMs?: readonly number[];
52
+ /** Reports each failed attempt. `willRetry` is false for the last one: the event stays
53
+ * unanswered and Constellation re-sends it after its response window. */
54
+ onFailure?: (eventId: string, error: {
31
55
  status: number;
32
56
  code: string;
33
57
  message: string;
34
- }): Promise<void>;
35
- }
58
+ attempt: number;
59
+ willRetry: boolean;
60
+ }) => void | Promise<void>;
61
+ };
62
+ /** The error result returned to the model for a call whose arguments were not valid JSON. */
63
+ export declare function invalidToolInputResult(call: {
64
+ name: string;
65
+ invalid?: {
66
+ error: string;
67
+ };
68
+ }): ToolResult["result"];
69
+ /** Webhook-side processing. After authenticating an event, call `claim`: `null` means another
70
+ * process holds it (answer 202); otherwise answer 204 and run the returned function in the
71
+ * background. Tools never run inside the webhook call. */
36
72
  export declare class EventReceiver {
37
- private readonly store;
38
- constructor(store: EventStore);
39
- /** Call after webhook authentication. Return a successful HTTP response only after this resolves. */
40
- receive(event: WebhookEvent): Promise<void>;
73
+ #private;
74
+ private readonly client;
75
+ private readonly hooks;
76
+ private readonly options;
77
+ /** `hooks` may be loaded per event, for example to bind them to the request's own records. */
78
+ constructor(client: RequestClient, hooks: AgentHooks | ((event: WebhookEvent) => Promise<AgentHooks>), options?: EventOptions & {
79
+ lock?: EventLock;
80
+ });
81
+ claim(event: WebhookEvent): Promise<((signal?: AbortSignal) => Promise<EventOutcome>) | null>;
41
82
  }
42
83
  export declare class ClientTelemetry {
43
84
  readonly parentSpanId?: string | undefined;
@@ -75,7 +116,6 @@ export interface AgentHooks {
75
116
  }
76
117
  /** Record the client processing interval and its actual child-span hierarchy. */
77
118
  export declare function prepareResponse(event: WebhookEvent, hooks: AgentHooks, signal?: AbortSignal): Promise<Admission | null>;
78
- /** Process an authenticated event using an application-owned durable checkpoint.
79
- * Any receipt (`accepted`, `already_accepted`, `event_closed`) completes the checkpoint; a permanent
80
- * error code rejects it with PermanentEventError; anything else reports through `store.retry` and throws. */
81
- export declare function processEvent(client: RequestClient, store: EventStore, event: WebhookEvent, hooks: AgentHooks, signal?: AbortSignal): Promise<void>;
119
+ /** Process an authenticated event inline: take its lock, prepare and submit the response, release.
120
+ * Returns `busy` without doing anything while another process holds the event. */
121
+ export declare function processEvent(client: RequestClient, lock: EventLock, event: WebhookEvent, hooks: AgentHooks, options?: EventOptions): Promise<EventOutcome>;
@@ -7,10 +7,17 @@ export type Tool = {
7
7
  description?: string;
8
8
  inputSchema: Json;
9
9
  };
10
+ /** The provider's tool arguments were not valid JSON. `raw` is truncated to 16 KiB. */
11
+ export type InvalidToolInput = {
12
+ raw: string;
13
+ error: string;
14
+ };
15
+ /** With `invalid` set, `arguments` is `{}`: answer with an error result instead of running the tool. */
10
16
  export type ToolCall = {
11
17
  id: string;
12
18
  name: string;
13
19
  arguments: Json;
20
+ invalid?: InvalidToolInput;
14
21
  };
15
22
  export type Message = {
16
23
  role: "system" | "user" | "assistant" | "tool";
@@ -122,6 +129,9 @@ export type AdmissionReceipt = {
122
129
  outcome?: ResponseOutcome;
123
130
  /** With `event_closed`: whether the continuation's telemetry was kept as late observations. */
124
131
  observationsRecorded?: boolean;
132
+ /** With `already_accepted`: false when this responder answered the event before with a
133
+ * different body (for example recomputed after a lost acknowledgement). The first answer stands. */
134
+ bodyMatched?: boolean;
125
135
  };
126
136
  export type Status = "queued" | "running" | "client_processing" | "completed" | "failed" | "cancelled";
127
137
  export type RequestRecord = {
@@ -349,6 +359,14 @@ export type ModelResolution = ModelScope & {
349
359
  headers?: Record<string, string>;
350
360
  nativeContentPolicy?: "require" | "omitCompleted" | "omitIncompatible";
351
361
  };
362
+ /** Whose request a boundary event belongs to, so a receiver never reads the request back. */
363
+ export type EventRequest = {
364
+ id: string;
365
+ externalId: string | null;
366
+ workspaceId: string;
367
+ userId: string | null;
368
+ idempotencyKey: string;
369
+ };
352
370
  export type ModelCompletedEvent = {
353
371
  id: string;
354
372
  requestId: string;
@@ -357,10 +375,14 @@ export type ModelCompletedEvent = {
357
375
  type: "model.completed";
358
376
  data: {
359
377
  message: Message;
378
+ /** Step result: `stepId`, `stepInput` (`model`, `parameters`, `maxOutputTokens`, `timeoutMs`, `toolsBytes`),
379
+ * `usage`, costs, `finishReason` and the step's `attempts`. */
360
380
  result: Record<string, Json>;
381
+ /** 1-based number of the step this event completes. */
382
+ stepNumber: number;
383
+ request: EventRequest;
361
384
  events?: Json[];
362
385
  stepId?: string;
363
- stepNumber?: number;
364
386
  attemptId?: string;
365
387
  };
366
388
  };
@@ -370,7 +392,10 @@ export type WebhookEvent = ModelCompletedEvent | {
370
392
  sequence: number;
371
393
  createdAt: string;
372
394
  type: "model.failed" | "model.cancelled";
373
- data: Record<string, Json>;
395
+ data: Record<string, Json> & {
396
+ stepNumber: number;
397
+ request: EventRequest;
398
+ };
374
399
  };
375
400
  export declare class ConstellationError extends Error {
376
401
  status: number;
@@ -1,7 +1,7 @@
1
1
  /** External server SDK. Operator APIs and cookie sessions are internal-only clients. */
2
2
  import { RequestClient, type WebhookEvent, type Json, type Page, type PageQuery, type ModelResolution, type RequestRecord, type RequestEvent, type RecordTypes, type Timeline, type TimelineRow, type RequestListQuery, type RequestRecordsQuery, type RequestTimings, type TokenUsage } from "../../client-core/src/index";
3
3
  export { ConstellationError, uuid7, normalizeTimestamp, parseOpenerSentence } from "../../client-core/src/index";
4
- export type { InferenceParameters, Json, Tool, ToolCall, Message, RequestEventModes, RequestInput, Admission, AdmissionReceipt, ResponseOutcome, ObservationResponse, Observations, EventReference, ModelCompletedEvent, ModelDeltaEvent, ModelStreamFrame, RequestTimings, TimingInterval, TokenUsage, TokenPrice, CustomerPricing, BillingFacts, Status, RequestRecord, Step, Attempt, Span, TimelineRow, Timeline, Log, Inspection, RecordTypes, RequestEvent, Page, PageQuery, ModelScope, ModelResolution, WebhookEvent, RequestRecordsQuery, RequestListQuery, OpenerInput, OpenerTurn } from "../../client-core/src/index";
4
+ export type { InferenceParameters, Json, Tool, ToolCall, InvalidToolInput, EventRequest, Message, RequestEventModes, RequestInput, Admission, AdmissionReceipt, ResponseOutcome, ObservationResponse, Observations, EventReference, ModelCompletedEvent, ModelDeltaEvent, ModelStreamFrame, RequestTimings, TimingInterval, TokenUsage, TokenPrice, CustomerPricing, BillingFacts, Status, RequestRecord, Step, Attempt, Span, TimelineRow, Timeline, Log, Inspection, RecordTypes, RequestEvent, Page, PageQuery, ModelScope, ModelResolution, WebhookEvent, RequestRecordsQuery, RequestListQuery, OpenerInput, OpenerTurn } from "../../client-core/src/index";
5
5
  /** OpenAPI contract filtered to this key's permissions and scope. */
6
6
  export type DiscoveryDocument = {
7
7
  openapi: string;
@@ -263,5 +263,5 @@ export declare class Constellation extends RequestClient {
263
263
  verify: (request: Request, expectedToken: string) => Promise<WebhookEvent>;
264
264
  };
265
265
  }
266
- export { PermanentEventError, HookConfigurationError, EventReceiver, prepareResponse, processEvent, ClientTelemetry, PERMANENT_RESPONSE_CODES } from "../../client-core/src/agent";
267
- export type { EventStore, AgentHooks, HookContext, ToolResult } from "../../client-core/src/agent";
266
+ export { PermanentEventError, HookConfigurationError, EventReceiver, MemoryEventLock, redisEventLock, prepareResponse, processEvent, ClientTelemetry, PERMANENT_RESPONSE_CODES, invalidToolInputResult } from "../../client-core/src/agent";
267
+ export type { EventLock, EventOutcome, EventOptions, AgentHooks, HookContext, ToolResult } from "../../client-core/src/agent";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionhummer/constellation",
3
- "version": "0.9.4",
3
+ "version": "0.11.0",
4
4
  "description": "TypeScript client for durable model requests and model events",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",