@lionhummer/constellation 0.10.0 → 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 +6 -3
- package/dist/index.js +77 -79
- package/dist/types/client-core/src/agent.d.ts +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ const receiver = new EventReceiver(client, hooks, {
|
|
|
152
152
|
// One process: leave this out (an in-memory lock is used).
|
|
153
153
|
// Several processes or pods: share the lock so only one works on an event at a time.
|
|
154
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"),
|
|
155
|
+
onFailure: (eventId, error) => logger.warn({ eventId, error }, error.willRetry ? "retrying event" : "event will be re-sent"),
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
export async function webhook(request: Request): Promise<Response> {
|
|
@@ -177,6 +177,8 @@ Don't run tools inside the webhook call. Constellation waits 5 seconds for the a
|
|
|
177
177
|
|
|
178
178
|
### Recovery
|
|
179
179
|
|
|
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.
|
|
181
|
+
|
|
180
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`:
|
|
181
183
|
|
|
182
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;
|
|
@@ -201,7 +203,8 @@ If you answer an event twice, for example because the reply to your first answer
|
|
|
201
203
|
| `200`, `outcome: "already_accepted"` | This event was already answered by you. `bodyMatched: false` means the earlier answer differed and stands. | `answered` |
|
|
202
204
|
| `200`, `outcome: "event_closed"` | The request was cancelled or expired before your continuation arrived. Your telemetry is still recorded (`observationsRecorded`). | `answered`, `ended: "closed"` |
|
|
203
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"` |
|
|
204
|
-
|
|
|
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. |
|
|
205
208
|
|
|
206
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.
|
|
207
210
|
|
|
@@ -223,7 +226,7 @@ Delivery is at least once. A crash after a tool ran but before the answer was ac
|
|
|
223
226
|
|
|
224
227
|
## Live output
|
|
225
228
|
|
|
226
|
-
`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.
|
|
227
230
|
|
|
228
231
|
## Telemetry
|
|
229
232
|
|
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
|
|
257
|
-
`)
|
|
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),
|
|
@@ -493,19 +464,13 @@ class EventReceiver {
|
|
|
493
464
|
const leaseMs = this.options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
494
465
|
if (!await this.#lock.acquire(event.id, leaseMs))
|
|
495
466
|
return null;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
};
|
|
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 });
|
|
506
470
|
}
|
|
507
471
|
}
|
|
508
472
|
var DEFAULT_LEASE_MS = 60000;
|
|
473
|
+
var DEFAULT_RETRY_DELAYS_MS = [1000, 3000, 9000];
|
|
509
474
|
|
|
510
475
|
class ClientTelemetry {
|
|
511
476
|
parentSpanId;
|
|
@@ -699,10 +664,33 @@ async function prepareResponse(event, hooks, signal) {
|
|
|
699
664
|
async function processEvent(client, lock, event, hooks, options = {}) {
|
|
700
665
|
if (!await lock.acquire(event.id, options.leaseMs ?? DEFAULT_LEASE_MS))
|
|
701
666
|
return { status: "busy" };
|
|
702
|
-
return runClaimed(client, lock, event, hooks, options);
|
|
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;
|
|
703
676
|
}
|
|
704
|
-
|
|
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) {
|
|
705
692
|
const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
693
|
+
const delays = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
706
694
|
const fence = new AbortController;
|
|
707
695
|
const signal = options.signal ? AbortSignal.any([options.signal, fence.signal]) : fence.signal;
|
|
708
696
|
const renewal = setInterval(() => {
|
|
@@ -711,48 +699,58 @@ async function runClaimed(client, lock, event, hooks, options) {
|
|
|
711
699
|
fence.abort(new Error("Event lock lost"));
|
|
712
700
|
}, () => fence.abort(new Error("Event lock unavailable")));
|
|
713
701
|
}, Math.max(1000, Math.floor(leaseMs / 3)));
|
|
714
|
-
let stage = "prepare";
|
|
715
702
|
try {
|
|
716
|
-
|
|
717
|
-
|
|
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)
|
|
727
|
-
throw error;
|
|
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" };
|
|
736
|
-
}
|
|
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 };
|
|
740
|
-
} catch (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" };
|
|
703
|
+
for (let attempt = 1;; attempt++) {
|
|
704
|
+
const progress = { stage: "prepare" };
|
|
743
705
|
try {
|
|
744
|
-
await
|
|
745
|
-
} catch (
|
|
746
|
-
|
|
747
|
-
|
|
706
|
+
return await answer(client, event, await loadHooks(), signal, progress);
|
|
707
|
+
} catch (error) {
|
|
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
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (!willRetry)
|
|
719
|
+
throw error;
|
|
720
|
+
await wait(delays[attempt - 1] ?? 0, signal);
|
|
748
721
|
}
|
|
749
722
|
}
|
|
750
|
-
throw error;
|
|
751
723
|
} finally {
|
|
752
724
|
clearInterval(renewal);
|
|
753
725
|
await lock.release(event.id).catch(() => {});
|
|
754
726
|
}
|
|
755
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
|
+
}
|
|
756
754
|
|
|
757
755
|
// src/index.ts
|
|
758
756
|
function queryString2(values) {
|
|
@@ -46,11 +46,17 @@ export type EventOptions = {
|
|
|
46
46
|
signal?: AbortSignal;
|
|
47
47
|
/** Lock lease, renewed every third of it while processing. Keep it well below the response window. */
|
|
48
48
|
leaseMs?: number;
|
|
49
|
-
/**
|
|
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. */
|
|
50
54
|
onFailure?: (eventId: string, error: {
|
|
51
55
|
status: number;
|
|
52
56
|
code: string;
|
|
53
57
|
message: string;
|
|
58
|
+
attempt: number;
|
|
59
|
+
willRetry: boolean;
|
|
54
60
|
}) => void | Promise<void>;
|
|
55
61
|
};
|
|
56
62
|
/** The error result returned to the model for a call whose arguments were not valid JSON. */
|