@lionhummer/constellation 0.9.4 → 0.10.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
- },
144
+ ### 3. Receive webhooks
218
145
 
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.
244
-
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 }, "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,60 @@ 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 });
262
- }
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
- }
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 });
274
171
  }
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
+ 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`:
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
+ - 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;
183
+ - 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.
291
184
 
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**:
185
+ 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.
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
+ ## What happens to each event
305
188
 
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.
189
+ 1. **Take the lock** for the event. If another process holds it: `busy` (answer `202`).
190
+ 2. **Prepare the response:**
191
+ - 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.
192
+ - 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.
193
+ - For `model.failed` and `model.cancelled`, call `onError(event.data)` and produce an `observe` response.
194
+
195
+ The response carries the idempotency key `event.id`, `inReplyTo: { requestId, eventId }` and the telemetry recorded while preparing.
196
+ 3. **Submit it** and release the lock:
197
+
198
+ | Server answer | Meaning | Result |
199
+ | --- | --- | --- |
200
+ | `200`, `outcome: "accepted"` | Your response was accepted. A continuation starts the next step (`scheduled: true`). | `answered` |
201
+ | `200`, `outcome: "already_accepted"` | This event was already answered by you. `bodyMatched: false` means the earlier answer differed and stands. | `answered` |
202
+ | `200`, `outcome: "event_closed"` | The request was cancelled or expired before your continuation arrived. Your telemetry is still recorded (`observationsRecorded`). | `answered`, `ended: "closed"` |
203
+ | 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"` |
204
+ | Anything else: network error, timeout, `5xx`, `429`, `409 conflict`, `401`, `403` | Temporary. | `onFailure` runs and the call throws. The event stays unanswered and is re-sent. |
307
205
 
308
- Fix `401` and `403` by correcting the key: the saved response is still sent unchanged afterwards.
206
+ 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
207
 
310
208
  ## When your code fails
311
209
 
312
210
  | What happened | Without `onError` | With `onError` |
313
211
  | --- | --- | --- |
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. |
212
+ | 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. |
213
+ | 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
214
  | The model step failed or was cancelled (`model.failed`, `model.cancelled`) | An `observe` response records your telemetry. | `onError(event.data)` runs first. |
317
215
 
318
216
  ## Side effects and duplicates
319
217
 
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:
218
+ 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
219
 
322
220
  - pass `effectId` to anything with side effects (payments, emails, writes). It is stable for one tool call of one event;
323
221
  - settle per-step billing or usage once per `event.id`;
@@ -427,7 +325,7 @@ A failed provider call falls back to the next provider only before any output ha
427
325
  | `requests.preview(input)` | Validate a complete input and show routing, without calling a provider. |
428
326
  | `requests.cancel(id)` | Cancel a request. |
429
327
  | `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. |
328
+ | `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
329
  | `requests.getSummary(id)`, `requests.get(id)` | Request detail, without or with spans and logs. |
432
330
  | `requests.list(query)` | Page through requests, e.g. by `externalId` or `status`. |
433
331
  | `requests.listEvents(id, after)`, `requests.streamEvents(id)` | Replay events after a sequence number, or stream them live. |
@@ -440,7 +338,7 @@ A failed provider call falls back to the next provider only before any output ha
440
338
  | `management.registry.*` | Edit the model registry. Needs management permission. |
441
339
  | `discover()` | The OpenAPI document for exactly what this key may call. |
442
340
 
443
- Exports for event processing: `processEvent`, `prepareResponse` (build a response without submitting it), `EventReceiver`, `ClientTelemetry`, `PermanentEventError`, `HookConfigurationError`, `PERMANENT_RESPONSE_CODES`, `ConstellationError` and `uuid7`.
341
+ 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
342
 
445
343
  ## Troubleshooting
446
344
 
@@ -448,7 +346,7 @@ Exports for event processing: `processEvent`, `prepareResponse` (build a respons
448
346
  | --- | --- |
449
347
  | `models.list` is empty | The key's workspace/user scope, an access rule for that workspace, and an enabled model and offering. |
450
348
  | `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. |
349
+ | 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
350
  | `400` on a continuation | Model, workspace, user, `externalId`, plan, events or `timeoutMs` differ from the first request, or the message history is malformed. |
453
351
  | `409 response_conflict` | Your code produced a different response for an event that was already answered. Answers must come from the saved response. |
454
352
  | Tools ran twice | Expected after a crash. Make side effects idempotent with `effectId`. |
package/dist/index.js CHANGED
@@ -424,15 +424,88 @@ class HookConfigurationError extends PermanentEventError {
424
424
  }
425
425
  }
426
426
 
427
- class EventReceiver {
428
- store;
429
- constructor(store) {
430
- this.store = store;
427
+ class MemoryEventLock {
428
+ #held = new Map;
429
+ async acquire(eventId, ttlMs) {
430
+ const until = this.#held.get(eventId);
431
+ if (until !== undefined && until > Date.now())
432
+ return false;
433
+ this.#held.set(eventId, Date.now() + ttlMs);
434
+ return true;
435
+ }
436
+ async renew(eventId, ttlMs) {
437
+ if (!this.#held.has(eventId))
438
+ return false;
439
+ this.#held.set(eventId, Date.now() + ttlMs);
440
+ return true;
441
+ }
442
+ async release(eventId) {
443
+ this.#held.delete(eventId);
431
444
  }
432
- async receive(event) {
433
- await this.store.accept(event);
445
+ }
446
+ var RENEW_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
447
+ var RELEASE_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
448
+ function redisEventLock(command, options = {}) {
449
+ const prefix = options.prefix ?? "constellation:event-lock:";
450
+ const tokens = new Map;
451
+ return {
452
+ async acquire(eventId, ttlMs) {
453
+ const token = uuid7();
454
+ const reply = await command(["SET", prefix + eventId, token, "NX", "PX", String(ttlMs)]);
455
+ if (reply !== "OK")
456
+ return false;
457
+ tokens.set(eventId, token);
458
+ return true;
459
+ },
460
+ async renew(eventId, ttlMs) {
461
+ const token = tokens.get(eventId);
462
+ if (!token)
463
+ return false;
464
+ return Number(await command(["EVAL", RENEW_SCRIPT, "1", prefix + eventId, token, String(ttlMs)])) === 1;
465
+ },
466
+ async release(eventId) {
467
+ const token = tokens.get(eventId);
468
+ tokens.delete(eventId);
469
+ if (token)
470
+ await command(["EVAL", RELEASE_SCRIPT, "1", prefix + eventId, token]);
471
+ }
472
+ };
473
+ }
474
+ function invalidToolInputResult(call) {
475
+ return {
476
+ error: "invalid_tool_input",
477
+ message: `The arguments for ${call.name} were not valid JSON (${call.invalid?.error ?? "unparseable"}). Call ${call.name} again with complete, valid JSON arguments.`
478
+ };
479
+ }
480
+
481
+ class EventReceiver {
482
+ client;
483
+ hooks;
484
+ options;
485
+ #lock;
486
+ constructor(client, hooks, options = {}) {
487
+ this.client = client;
488
+ this.hooks = hooks;
489
+ this.options = options;
490
+ this.#lock = options.lock ?? new MemoryEventLock;
491
+ }
492
+ async claim(event) {
493
+ const leaseMs = this.options.leaseMs ?? DEFAULT_LEASE_MS;
494
+ if (!await this.#lock.acquire(event.id, leaseMs))
495
+ return null;
496
+ return async (signal) => {
497
+ let hooks;
498
+ try {
499
+ hooks = typeof this.hooks === "function" ? await this.hooks(event) : this.hooks;
500
+ } catch (error) {
501
+ await this.#lock.release(event.id).catch(() => {});
502
+ throw error;
503
+ }
504
+ return runClaimed(this.client, this.#lock, event, hooks, { ...this.options, signal: signal ?? this.options.signal });
505
+ };
434
506
  }
435
507
  }
508
+ var DEFAULT_LEASE_MS = 60000;
436
509
 
437
510
  class ClientTelemetry {
438
511
  parentSpanId;
@@ -563,6 +636,10 @@ async function prepareResponseBody(event, hooks, telemetry, signal) {
563
636
  return;
564
637
  signal?.throwIfAborted();
565
638
  const call = calls[index];
639
+ if (call.invalid) {
640
+ results[index] = { id: call.id, result: invalidToolInputResult(call), isError: true };
641
+ continue;
642
+ }
566
643
  const handler = hooks.tools?.[call.name];
567
644
  if (!handler)
568
645
  throw new HookConfigurationError(`No handler for tool ${call.name}`);
@@ -619,42 +696,61 @@ async function prepareResponse(event, hooks, signal) {
619
696
  response.telemetry = telemetry.snapshot();
620
697
  return response;
621
698
  }
622
- async function processEvent(client, store, event, hooks, signal) {
699
+ async function processEvent(client, lock, event, hooks, options = {}) {
700
+ if (!await lock.acquire(event.id, options.leaseMs ?? DEFAULT_LEASE_MS))
701
+ return { status: "busy" };
702
+ return runClaimed(client, lock, event, hooks, options);
703
+ }
704
+ async function runClaimed(client, lock, event, hooks, options) {
705
+ const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
706
+ const fence = new AbortController;
707
+ const signal = options.signal ? AbortSignal.any([options.signal, fence.signal]) : fence.signal;
708
+ const renewal = setInterval(() => {
709
+ lock.renew(event.id, leaseMs).then((held) => {
710
+ if (!held)
711
+ fence.abort(new Error("Event lock lost"));
712
+ }, () => fence.abort(new Error("Event lock unavailable")));
713
+ }, Math.max(1000, Math.floor(leaseMs / 3)));
623
714
  let stage = "prepare";
624
715
  try {
625
- const response = await store.prepare(event, () => prepareResponse(event, hooks, signal));
626
- let receipt = null;
627
- if (response) {
628
- stage = "submit";
629
- try {
630
- receipt = await client.requests.submit(response, signal);
631
- } 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;
636
- }
716
+ const response = await prepareResponse(event, hooks, signal);
717
+ if (!response)
718
+ return { status: "answered", action: "stop", receipt: null, ended: null };
719
+ stage = "submit";
720
+ signal.throwIfAborted();
721
+ const observe = "action" in response && response.action === "observe";
722
+ let receipt;
723
+ try {
724
+ receipt = await client.requests.submit(response, signal);
725
+ } catch (error) {
726
+ if (!(error instanceof ConstellationError) || !PERMANENT_RESPONSE_CODES.has(error.code) || observe)
637
727
  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
- }
728
+ receipt = await client.requests.submit({
729
+ action: "observe",
730
+ idempotencyKey: event.id,
731
+ inReplyTo: { requestId: event.requestId, eventId: event.id },
732
+ telemetry: response.telemetry ?? { spans: [], logs: [] },
733
+ error: { code: error.code, message: error.message.slice(0, 8192) || error.code }
734
+ }, signal);
735
+ return { status: "answered", action: "stop", receipt, ended: receipt.outcome === "event_closed" ? "closed" : "failed" };
644
736
  }
645
- stage = "checkpoint";
646
- await store.complete(event.id, receipt);
737
+ const failed = observe && "error" in response && !!response.error;
738
+ const ended = receipt.outcome === "event_closed" ? "closed" : observe ? failed ? "failed" : "completed" : null;
739
+ return { status: "answered", action: observe ? "stop" : "continue", receipt, ended };
647
740
  } 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" };
741
+ if (options.onFailure) {
742
+ 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_prepare_failed", message: error instanceof Error ? error.message : "Event processing failed" };
650
743
  try {
651
- await store.retry(event.id, detail);
744
+ await options.onFailure(event.id, detail);
652
745
  } catch (monitorError) {
653
746
  if (error && typeof error === "object")
654
- Object.assign(error, { retryHookError: monitorError });
747
+ Object.assign(error, { failureHookError: monitorError });
655
748
  }
656
749
  }
657
750
  throw error;
751
+ } finally {
752
+ clearInterval(renewal);
753
+ await lock.release(event.id).catch(() => {});
658
754
  }
659
755
  }
660
756
 
@@ -807,12 +903,15 @@ class Constellation extends RequestClient {
807
903
  }
808
904
  export {
809
905
  uuid7,
906
+ redisEventLock,
810
907
  processEvent,
811
908
  prepareResponse,
812
909
  parseOpenerSentence,
813
910
  normalizeTimestamp,
911
+ invalidToolInputResult,
814
912
  PermanentEventError,
815
913
  PERMANENT_RESPONSE_CODES,
914
+ MemoryEventLock,
816
915
  HookConfigurationError,
817
916
  EventReceiver,
818
917
  ConstellationError,
@@ -10,34 +10,69 @@ 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
+ /** Reports a failure that leaves the event unanswered; Constellation will re-send it. */
50
+ onFailure?: (eventId: string, error: {
31
51
  status: number;
32
52
  code: string;
33
53
  message: string;
34
- }): Promise<void>;
35
- }
54
+ }) => void | Promise<void>;
55
+ };
56
+ /** The error result returned to the model for a call whose arguments were not valid JSON. */
57
+ export declare function invalidToolInputResult(call: {
58
+ name: string;
59
+ invalid?: {
60
+ error: string;
61
+ };
62
+ }): ToolResult["result"];
63
+ /** Webhook-side processing. After authenticating an event, call `claim`: `null` means another
64
+ * process holds it (answer 202); otherwise answer 204 and run the returned function in the
65
+ * background. Tools never run inside the webhook call. */
36
66
  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>;
67
+ #private;
68
+ private readonly client;
69
+ private readonly hooks;
70
+ private readonly options;
71
+ /** `hooks` may be loaded per event, for example to bind them to the request's own records. */
72
+ constructor(client: RequestClient, hooks: AgentHooks | ((event: WebhookEvent) => Promise<AgentHooks>), options?: EventOptions & {
73
+ lock?: EventLock;
74
+ });
75
+ claim(event: WebhookEvent): Promise<((signal?: AbortSignal) => Promise<EventOutcome>) | null>;
41
76
  }
42
77
  export declare class ClientTelemetry {
43
78
  readonly parentSpanId?: string | undefined;
@@ -75,7 +110,6 @@ export interface AgentHooks {
75
110
  }
76
111
  /** Record the client processing interval and its actual child-span hierarchy. */
77
112
  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>;
113
+ /** Process an authenticated event inline: take its lock, prepare and submit the response, release.
114
+ * Returns `busy` without doing anything while another process holds the event. */
115
+ 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.10.0",
4
4
  "description": "TypeScript client for durable model requests and model events",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",