@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 +49 -151
- package/dist/index.js +130 -31
- package/dist/types/client-core/src/agent.d.ts +62 -28
- package/dist/types/client-core/src/index.d.ts +27 -2
- package/dist/types/sdk/src/index.d.ts +3 -3
- package/package.json +1 -1
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
|
|
73
|
+
A tool-using agent needs three pieces:
|
|
74
74
|
|
|
75
|
-
1. **A webhook** that receives events
|
|
75
|
+
1. **A webhook** that receives events.
|
|
76
76
|
2. **Hooks** that run your tools and build the next input.
|
|
77
|
-
3. **An `
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
### Recovery
|
|
285
179
|
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
315
|
-
| No handler for a tool the model called, or `prepareRequest` is missing when continuing | The request
|
|
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
|
-
|
|
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: `
|
|
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).
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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,
|
|
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
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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 (
|
|
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" :
|
|
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
|
|
744
|
+
await options.onFailure(event.id, detail);
|
|
652
745
|
} catch (monitorError) {
|
|
653
746
|
if (error && typeof error === "object")
|
|
654
|
-
Object.assign(error, {
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
})
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
79
|
-
*
|
|
80
|
-
|
|
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 {
|
|
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";
|